feat: add fork-based ownership and fix MCP non-scoped package names
Fork-based ownership: - Add forkedFromId, forkCount fields to Collection and Agent models - Add fork-status API endpoints for collections and agents - Add ForkButton and ForkedFromBadge UI components - Update clone endpoints to set forkedFromId and increment forkCount - Add COLLECTION_FORKED and AGENT_FORKED activity types - Enforce owner-only access for MCP tool execution and agent chat MCP fix: - Fix parseToolName to handle non-scoped packages like firecrawl-aisdk - Try both scoped (@scope/name) and literal (name-with-hyphens) interpretations - Pass collection envVars to tool executor for API key support
This commit is contained in:
parent
e2bb06df60
commit
95cb0a96f0
22 changed files with 925 additions and 79 deletions
|
|
@ -7,8 +7,10 @@ import Link from 'next/link';
|
|||
import { notFound, useParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
import { CloneButton } from '~/components/CloneButton';
|
||||
import { ForkButton } from '~/components/ForkButton';
|
||||
import { ForkedFromBadge } from '~/components/ForkedFromBadge';
|
||||
import { LikeButton } from '~/components/LikeButton';
|
||||
import { useSession } from '~/lib/auth-client';
|
||||
|
||||
interface AgentTool {
|
||||
id: string;
|
||||
|
|
@ -46,6 +48,7 @@ interface PublicAgent {
|
|||
systemPrompt: string | null;
|
||||
temperature: number;
|
||||
likeCount: number;
|
||||
forkCount: number;
|
||||
toolCount: number;
|
||||
collectionCount: number;
|
||||
createdAt: string;
|
||||
|
|
@ -57,6 +60,15 @@ interface PublicAgent {
|
|||
};
|
||||
tools: AgentTool[];
|
||||
collections: AgentCollection[];
|
||||
forkedFromId: string | null;
|
||||
forkedFrom: {
|
||||
id: string;
|
||||
name: string;
|
||||
uid: string;
|
||||
user: {
|
||||
username: string;
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function PrettyAgentDetailPage(): React.ReactElement {
|
||||
|
|
@ -64,11 +76,15 @@ export default function PrettyAgentDetailPage(): React.ReactElement {
|
|||
const rawUsername = params.username as string;
|
||||
const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername;
|
||||
const uid = params.uid as string;
|
||||
const { data: session } = useSession();
|
||||
|
||||
const [agent, setAgent] = useState<PublicAgent | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Check if current user is the owner
|
||||
const isOwner = session?.user?.id && agent?.createdBy?.id === session.user.id;
|
||||
|
||||
const fetchAgent = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/public/users/${username}/agents/${uid}`);
|
||||
|
|
@ -123,22 +139,29 @@ export default function PrettyAgentDetailPage(): React.ReactElement {
|
|||
{agent.description && (
|
||||
<p className="text-foreground-secondary">{agent.description}</p>
|
||||
)}
|
||||
<Link
|
||||
href={`/${username}`}
|
||||
className="text-sm text-foreground-tertiary hover:text-foreground-secondary mt-2 inline-flex items-center gap-1"
|
||||
>
|
||||
by @{agent.createdBy.username}
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<Link
|
||||
href={`/${username}`}
|
||||
className="text-sm text-foreground-tertiary hover:text-foreground-secondary inline-flex items-center gap-1"
|
||||
>
|
||||
by @{agent.createdBy.username}
|
||||
</Link>
|
||||
{agent.forkedFrom && (
|
||||
<ForkedFromBadge type="agent" forkedFrom={agent.forkedFrom} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LikeButton entityType="agent" entityId={agent.id} initialCount={agent.likeCount} />
|
||||
<CloneButton type="agent" sourceId={agent.id} sourceName={agent.name} />
|
||||
<Link href={`/${username}/agents/${uid}/chat`}>
|
||||
<Button>
|
||||
<Icon icon="message" className="w-4 h-4 mr-2" />
|
||||
Chat
|
||||
</Button>
|
||||
</Link>
|
||||
<ForkButton type="agent" sourceId={agent.id} sourceName={agent.name} />
|
||||
{isOwner && (
|
||||
<Link href={`/${username}/agents/${uid}/chat`}>
|
||||
<Button>
|
||||
<Icon icon="message" className="w-4 h-4 mr-2" />
|
||||
Chat
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -152,10 +175,21 @@ export default function PrettyAgentDetailPage(): React.ReactElement {
|
|||
<Icon icon="folder" className="w-4 h-4" />
|
||||
{agent.collectionCount} collections
|
||||
</span>
|
||||
{agent.forkCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon icon="gitFork" className="w-4 h-4" />
|
||||
{agent.forkCount} forks
|
||||
</span>
|
||||
)}
|
||||
<span>Model: {agent.modelId}</span>
|
||||
<span>Temperature: {agent.temperature}</span>
|
||||
</div>
|
||||
|
||||
{/* Fork CTA for non-owners */}
|
||||
{!isOwner && (
|
||||
<ForkButton type="agent" sourceId={agent.id} sourceName={agent.name} variant="full" />
|
||||
)}
|
||||
|
||||
{/* System Prompt */}
|
||||
{agent.systemPrompt && (
|
||||
<section>
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@ import Link from 'next/link';
|
|||
import { notFound, useParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
import { CloneButton } from '~/components/CloneButton';
|
||||
import { ForkButton } from '~/components/ForkButton';
|
||||
import { ForkedFromBadge } from '~/components/ForkedFromBadge';
|
||||
import { LikeButton } from '~/components/LikeButton';
|
||||
import { useSession } from '~/lib/auth-client';
|
||||
|
||||
interface CollectionTool {
|
||||
id: string;
|
||||
|
|
@ -35,6 +37,7 @@ interface PublicCollection {
|
|||
description: string | null;
|
||||
likeCount: number;
|
||||
toolCount: number;
|
||||
forkCount: number;
|
||||
createdAt: string;
|
||||
createdBy: {
|
||||
id: string;
|
||||
|
|
@ -43,6 +46,15 @@ interface PublicCollection {
|
|||
image: string | null;
|
||||
};
|
||||
tools: CollectionTool[];
|
||||
forkedFromId: string | null;
|
||||
forkedFrom: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
user: {
|
||||
username: string;
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
|
||||
function McpUrlSection({ username, slug }: { username: string; slug: string }) {
|
||||
|
|
@ -163,11 +175,15 @@ export default function PrettyCollectionDetailPage(): React.ReactElement {
|
|||
const rawUsername = params.username as string;
|
||||
const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername;
|
||||
const slug = params.slug as string;
|
||||
const { data: session } = useSession();
|
||||
|
||||
const [collection, setCollection] = useState<PublicCollection | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Check if current user is the owner
|
||||
const isOwner = session?.user?.id && collection?.createdBy?.id === session.user.id;
|
||||
|
||||
const fetchCollection = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/public/users/${username}/collections/${slug}`);
|
||||
|
|
@ -219,12 +235,17 @@ export default function PrettyCollectionDetailPage(): React.ReactElement {
|
|||
{collection.description && (
|
||||
<p className="text-foreground-secondary mt-2">{collection.description}</p>
|
||||
)}
|
||||
<Link
|
||||
href={`/${username}`}
|
||||
className="text-sm text-foreground-tertiary hover:text-foreground-secondary mt-2 inline-flex items-center gap-1"
|
||||
>
|
||||
by @{collection.createdBy.username}
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<Link
|
||||
href={`/${username}`}
|
||||
className="text-sm text-foreground-tertiary hover:text-foreground-secondary inline-flex items-center gap-1"
|
||||
>
|
||||
by @{collection.createdBy.username}
|
||||
</Link>
|
||||
{collection.forkedFrom && (
|
||||
<ForkedFromBadge type="collection" forkedFrom={collection.forkedFrom} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LikeButton
|
||||
|
|
@ -232,7 +253,7 @@ export default function PrettyCollectionDetailPage(): React.ReactElement {
|
|||
entityId={collection.id}
|
||||
initialCount={collection.likeCount}
|
||||
/>
|
||||
<CloneButton
|
||||
<ForkButton
|
||||
type="collection"
|
||||
sourceId={collection.id}
|
||||
sourceName={collection.name}
|
||||
|
|
@ -250,10 +271,25 @@ export default function PrettyCollectionDetailPage(): React.ReactElement {
|
|||
<Icon icon="heart" className="w-4 h-4" />
|
||||
{collection.likeCount} likes
|
||||
</span>
|
||||
{collection.forkCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon icon="gitFork" className="w-4 h-4" />
|
||||
{collection.forkCount} forks
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* MCP Server URLs */}
|
||||
<McpUrlSection username={username} slug={collection.slug} />
|
||||
{/* MCP Server URLs - Only shown for owner */}
|
||||
{isOwner ? (
|
||||
<McpUrlSection username={username} slug={collection.slug} />
|
||||
) : (
|
||||
<ForkButton
|
||||
type="collection"
|
||||
sourceId={collection.id}
|
||||
sourceName={collection.name}
|
||||
variant="full"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tools */}
|
||||
{collection.tools.length > 0 ? (
|
||||
|
|
|
|||
|
|
@ -151,9 +151,10 @@ export async function POST(request: NextRequest, context: RouteContext) {
|
|||
}
|
||||
}
|
||||
|
||||
// Create the cloned agent with all its relationships
|
||||
const clonedAgent = await prisma.$transaction(async (tx) => {
|
||||
// Create the agent
|
||||
// Create the forked agent with all its relationships
|
||||
// NOTE: envVars and executorConfig are NOT copied - user must add their own
|
||||
const forkedAgent = await prisma.$transaction(async (tx) => {
|
||||
// Create the agent with fork reference
|
||||
const newAgent = await tx.agent.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
|
|
@ -166,11 +167,20 @@ export async function POST(request: NextRequest, context: RouteContext) {
|
|||
temperature: sourceAgent.temperature,
|
||||
maxToolCallsPerTurn: sourceAgent.maxToolCallsPerTurn,
|
||||
maxMessagesInContext: sourceAgent.maxMessagesInContext,
|
||||
isPublic: false, // Cloned agents start as private
|
||||
isPublic: false, // Forked agents start as private
|
||||
likeCount: 1, // Start with 1 like (from owner)
|
||||
forkedFromId: sourceAgent.id, // Track fork origin
|
||||
// NOTE: envVars is intentionally NOT copied - user adds their own API keys
|
||||
// NOTE: executorConfig is intentionally NOT copied - user configures their own
|
||||
},
|
||||
});
|
||||
|
||||
// Increment fork count on source agent
|
||||
await tx.agent.update({
|
||||
where: { id: sourceAgent.id },
|
||||
data: { forkCount: { increment: 1 } },
|
||||
});
|
||||
|
||||
// Auto-like the agent
|
||||
await tx.agentLike.create({
|
||||
data: {
|
||||
|
|
@ -215,24 +225,28 @@ export async function POST(request: NextRequest, context: RouteContext) {
|
|||
return newAgent;
|
||||
});
|
||||
|
||||
// Log activity
|
||||
// Log activity as FORK
|
||||
logActivity({
|
||||
userId: session.user.id,
|
||||
type: 'AGENT_CLONED',
|
||||
targetName: clonedAgent.name,
|
||||
type: 'AGENT_FORKED',
|
||||
targetName: forkedAgent.name,
|
||||
targetType: 'agent',
|
||||
agentId: clonedAgent.id,
|
||||
metadata: { sourceAgentId: sourceAgent.id },
|
||||
agentId: forkedAgent.id,
|
||||
metadata: {
|
||||
sourceAgentId: sourceAgent.id,
|
||||
sourceAgentName: sourceAgent.name,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
id: clonedAgent.id,
|
||||
uid: clonedAgent.uid,
|
||||
name: clonedAgent.name,
|
||||
description: clonedAgent.description,
|
||||
isPublic: clonedAgent.isPublic,
|
||||
createdAt: clonedAgent.createdAt,
|
||||
id: forkedAgent.id,
|
||||
uid: forkedAgent.uid,
|
||||
name: forkedAgent.name,
|
||||
description: forkedAgent.description,
|
||||
isPublic: forkedAgent.isPublic,
|
||||
forkedFromId: forkedAgent.forkedFromId,
|
||||
createdAt: forkedAgent.createdAt,
|
||||
},
|
||||
{ requestId, status: 201 }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -122,6 +122,19 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
|
|||
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Owner-only enforcement: Only the agent owner can chat with the agent
|
||||
if (authResult.userId !== agent.userId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
'Fork this agent to use it. Only the agent owner can chat with agents. ' +
|
||||
'Visit the agent page to fork it to your account.',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Map provider to expected key name format
|
||||
const providerKeyNames: Record<string, string> = {
|
||||
OPENAI: 'OPENAI_API_KEY',
|
||||
|
|
|
|||
88
apps/web/src/app/api/agents/[id]/fork-status/route.ts
Normal file
88
apps/web/src/app/api/agents/[id]/fork-status/route.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { AGENT_LIMITS } from '@tpmjs/types/agent';
|
||||
import { headers } from 'next/headers';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { apiNotFound, apiSuccess, apiUnauthorized } from '~/lib/api-response';
|
||||
import { auth } from '~/lib/auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/agents/[id]/fork-status
|
||||
* Check if the current user has forked this agent
|
||||
*/
|
||||
export async function GET(_request: NextRequest, context: RouteContext) {
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return apiUnauthorized('Authentication required', requestId);
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
|
||||
// Get the source agent
|
||||
const agent = await prisma.agent.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, isPublic: true },
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
return apiNotFound('Agent', requestId);
|
||||
}
|
||||
|
||||
const isOwner = agent.userId === session.user.id;
|
||||
|
||||
// If owner, they can't fork their own agent
|
||||
if (isOwner) {
|
||||
return apiSuccess(
|
||||
{
|
||||
hasFork: false,
|
||||
fork: null,
|
||||
isOwner: true,
|
||||
canFork: false,
|
||||
},
|
||||
{ requestId }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user already has a fork of this agent
|
||||
const existingFork = await prisma.agent.findFirst({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
forkedFromId: id,
|
||||
},
|
||||
select: { id: true, uid: true, name: true },
|
||||
});
|
||||
|
||||
// Check if user can fork (within limits)
|
||||
let canFork = false;
|
||||
if (!existingFork && agent.isPublic) {
|
||||
const existingCount = await prisma.agent.count({
|
||||
where: { userId: session.user.id },
|
||||
});
|
||||
canFork = existingCount < AGENT_LIMITS.MAX_AGENTS_PER_USER;
|
||||
}
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
hasFork: !!existingFork,
|
||||
fork: existingFork
|
||||
? {
|
||||
id: existingFork.id,
|
||||
uid: existingFork.uid,
|
||||
name: existingFork.name,
|
||||
}
|
||||
: null,
|
||||
isOwner: false,
|
||||
canFork,
|
||||
},
|
||||
{ requestId }
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { COLLECTION_LIMITS, CloneCollectionSchema } from '@tpmjs/types/collection';
|
||||
import { CloneCollectionSchema, COLLECTION_LIMITS } from '@tpmjs/types/collection';
|
||||
import { headers } from 'next/headers';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
|
|
@ -135,20 +135,30 @@ export async function POST(request: NextRequest, context: RouteContext) {
|
|||
const name = customName || `${sourceCollection.name} (copy)`;
|
||||
const slug = await generateUniqueSlug(session.user.id, name);
|
||||
|
||||
// Create the cloned collection with all its tools
|
||||
const clonedCollection = await prisma.$transaction(async (tx) => {
|
||||
// Create the collection
|
||||
// Create the forked collection with all its tools
|
||||
// NOTE: envVars and executorConfig are NOT copied - user must add their own
|
||||
const forkedCollection = await prisma.$transaction(async (tx) => {
|
||||
// Create the collection with fork reference
|
||||
const newCollection = await tx.collection.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
name,
|
||||
slug,
|
||||
description: sourceCollection.description,
|
||||
isPublic: false, // Cloned collections start as private
|
||||
isPublic: false, // Forked collections start as private
|
||||
likeCount: 1, // Start with 1 like (from owner)
|
||||
forkedFromId: sourceCollection.id, // Track fork origin
|
||||
// NOTE: envVars is intentionally NOT copied - user adds their own API keys
|
||||
// NOTE: executorConfig is intentionally NOT copied - user configures their own
|
||||
},
|
||||
});
|
||||
|
||||
// Increment fork count on source collection
|
||||
await tx.collection.update({
|
||||
where: { id: sourceCollection.id },
|
||||
data: { forkCount: { increment: 1 } },
|
||||
});
|
||||
|
||||
// Auto-like the collection
|
||||
await tx.collectionLike.create({
|
||||
data: {
|
||||
|
|
@ -172,25 +182,29 @@ export async function POST(request: NextRequest, context: RouteContext) {
|
|||
return newCollection;
|
||||
});
|
||||
|
||||
// Log activity
|
||||
// Log activity as FORK
|
||||
logActivity({
|
||||
userId: session.user.id,
|
||||
type: 'COLLECTION_CLONED',
|
||||
targetName: clonedCollection.name,
|
||||
type: 'COLLECTION_FORKED',
|
||||
targetName: forkedCollection.name,
|
||||
targetType: 'collection',
|
||||
collectionId: clonedCollection.id,
|
||||
metadata: { sourceCollectionId: sourceCollection.id },
|
||||
collectionId: forkedCollection.id,
|
||||
metadata: {
|
||||
sourceCollectionId: sourceCollection.id,
|
||||
sourceCollectionName: sourceCollection.name,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
id: clonedCollection.id,
|
||||
name: clonedCollection.name,
|
||||
slug: clonedCollection.slug,
|
||||
description: clonedCollection.description,
|
||||
isPublic: clonedCollection.isPublic,
|
||||
id: forkedCollection.id,
|
||||
name: forkedCollection.name,
|
||||
slug: forkedCollection.slug,
|
||||
description: forkedCollection.description,
|
||||
isPublic: forkedCollection.isPublic,
|
||||
forkedFromId: forkedCollection.forkedFromId,
|
||||
toolCount: sourceCollection.tools.length,
|
||||
createdAt: clonedCollection.createdAt,
|
||||
createdAt: forkedCollection.createdAt,
|
||||
},
|
||||
{ requestId, status: 201 }
|
||||
);
|
||||
|
|
|
|||
88
apps/web/src/app/api/collections/[id]/fork-status/route.ts
Normal file
88
apps/web/src/app/api/collections/[id]/fork-status/route.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { COLLECTION_LIMITS } from '@tpmjs/types/collection';
|
||||
import { headers } from 'next/headers';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { apiNotFound, apiSuccess, apiUnauthorized } from '~/lib/api-response';
|
||||
import { auth } from '~/lib/auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/collections/[id]/fork-status
|
||||
* Check if the current user has forked this collection
|
||||
*/
|
||||
export async function GET(_request: NextRequest, context: RouteContext) {
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return apiUnauthorized('Authentication required', requestId);
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
|
||||
// Get the source collection
|
||||
const collection = await prisma.collection.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, isPublic: true },
|
||||
});
|
||||
|
||||
if (!collection) {
|
||||
return apiNotFound('Collection', requestId);
|
||||
}
|
||||
|
||||
const isOwner = collection.userId === session.user.id;
|
||||
|
||||
// If owner, they can't fork their own collection
|
||||
if (isOwner) {
|
||||
return apiSuccess(
|
||||
{
|
||||
hasFork: false,
|
||||
fork: null,
|
||||
isOwner: true,
|
||||
canFork: false,
|
||||
},
|
||||
{ requestId }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user already has a fork of this collection
|
||||
const existingFork = await prisma.collection.findFirst({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
forkedFromId: id,
|
||||
},
|
||||
select: { id: true, slug: true, name: true },
|
||||
});
|
||||
|
||||
// Check if user can fork (within limits)
|
||||
let canFork = false;
|
||||
if (!existingFork && collection.isPublic) {
|
||||
const existingCount = await prisma.collection.count({
|
||||
where: { userId: session.user.id },
|
||||
});
|
||||
canFork = existingCount < COLLECTION_LIMITS.MAX_COLLECTIONS_PER_USER;
|
||||
}
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
hasFork: !!existingFork,
|
||||
fork: existingFork
|
||||
? {
|
||||
id: existingFork.id,
|
||||
slug: existingFork.slug,
|
||||
name: existingFork.name,
|
||||
}
|
||||
: null,
|
||||
isOwner: false,
|
||||
canFork,
|
||||
},
|
||||
{ requestId }
|
||||
);
|
||||
}
|
||||
|
|
@ -52,7 +52,7 @@ async function getPublicCollectionByUsernameAndSlug(username: string, slug: stri
|
|||
isPublic: true,
|
||||
user: { username },
|
||||
},
|
||||
select: { id: true, name: true, description: true },
|
||||
select: { id: true, name: true, description: true, userId: true },
|
||||
}),
|
||||
DB_TIMEOUT_MS,
|
||||
`Database query timed out after ${DB_TIMEOUT_MS}ms`
|
||||
|
|
@ -289,6 +289,23 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
|
|||
);
|
||||
}
|
||||
|
||||
// Owner-only enforcement: Only the collection owner can execute tools via MCP
|
||||
if (authResult.userId !== collection.userId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32403,
|
||||
message:
|
||||
'Fork this collection to use it. Only the collection owner can execute tools via MCP. ' +
|
||||
'Visit the collection page to fork it to your account.',
|
||||
},
|
||||
id: null,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
if (transport === 'sse') {
|
||||
response = await handleSseTransport(request, collection.id, collection.name);
|
||||
|
|
|
|||
|
|
@ -71,6 +71,16 @@ export async function GET(_request: NextRequest, context: RouteContext) {
|
|||
orderBy: { position: 'asc' },
|
||||
take: 20,
|
||||
},
|
||||
forkedFrom: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
uid: true,
|
||||
user: {
|
||||
select: { username: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { tools: true, collections: true },
|
||||
},
|
||||
|
|
@ -97,6 +107,7 @@ export async function GET(_request: NextRequest, context: RouteContext) {
|
|||
systemPrompt: agent.systemPrompt,
|
||||
temperature: agent.temperature,
|
||||
likeCount: agent.likeCount,
|
||||
forkCount: agent.forkCount,
|
||||
toolCount: agent._count.tools,
|
||||
collectionCount: agent._count.collections,
|
||||
createdAt: agent.createdAt.toISOString(),
|
||||
|
|
@ -122,6 +133,8 @@ export async function GET(_request: NextRequest, context: RouteContext) {
|
|||
toolCount: ac.collection._count.tools,
|
||||
},
|
||||
})),
|
||||
forkedFromId: agent.forkedFromId,
|
||||
forkedFrom: agent.forkedFrom,
|
||||
},
|
||||
{ requestId }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -58,6 +58,16 @@ export async function GET(_request: NextRequest, context: RouteContext) {
|
|||
orderBy: { position: 'asc' },
|
||||
take: 100,
|
||||
},
|
||||
forkedFrom: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
user: {
|
||||
select: { username: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { tools: true },
|
||||
},
|
||||
|
|
@ -80,6 +90,7 @@ export async function GET(_request: NextRequest, context: RouteContext) {
|
|||
name: collection.name,
|
||||
description: collection.description,
|
||||
likeCount: collection.likeCount,
|
||||
forkCount: collection.forkCount,
|
||||
toolCount: collection._count.tools,
|
||||
createdAt: collection.createdAt.toISOString(),
|
||||
createdBy: {
|
||||
|
|
@ -95,6 +106,8 @@ export async function GET(_request: NextRequest, context: RouteContext) {
|
|||
note: ct.note,
|
||||
tool: ct.tool,
|
||||
})),
|
||||
forkedFromId: collection.forkedFromId,
|
||||
forkedFrom: collection.forkedFrom,
|
||||
},
|
||||
{ requestId }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -179,7 +179,9 @@ export default async function HomePage(): Promise<React.ReactElement> {
|
|||
{/* Generator Highlight Box */}
|
||||
<div className="mb-12 p-6 border-2 border-primary/50 rounded-lg bg-primary/5 text-left">
|
||||
<div className="flex flex-col sm:flex-row items-start gap-4">
|
||||
<div className="text-3xl sm:text-4xl">✨</div>
|
||||
<div className="text-3xl sm:text-4xl" aria-hidden="true">
|
||||
✨
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-bold mb-2 text-foreground">
|
||||
Start with Our Package Generator
|
||||
|
|
@ -208,21 +210,27 @@ export default async function HomePage(): Promise<React.ReactElement> {
|
|||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6 mb-8">
|
||||
<div className="p-4">
|
||||
<div className="text-3xl mb-2">🚀</div>
|
||||
<div className="text-3xl mb-2" aria-hidden="true">
|
||||
🚀
|
||||
</div>
|
||||
<h3 className="font-semibold mb-1 text-foreground">Quick Setup</h3>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Add one keyword to package.json and publish to NPM
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="text-3xl mb-2">⚡</div>
|
||||
<div className="text-3xl mb-2" aria-hidden="true">
|
||||
⚡
|
||||
</div>
|
||||
<h3 className="font-semibold mb-1 text-foreground">Auto Discovery</h3>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Your tool appears on tpmjs.com within 15 minutes
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="text-3xl mb-2">📊</div>
|
||||
<div className="text-3xl mb-2" aria-hidden="true">
|
||||
📊
|
||||
</div>
|
||||
<h3 className="font-semibold mb-1 text-foreground">Quality Metrics</h3>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Automatic scoring based on docs, downloads, and stars
|
||||
|
|
|
|||
|
|
@ -23,27 +23,80 @@ interface NavDropdownProps {
|
|||
|
||||
function NavDropdown({ label, items }: NavDropdownProps): React.ReactElement {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setFocusedIndex(-1);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Focus menu item when focusedIndex changes
|
||||
useEffect(() => {
|
||||
if (isOpen && focusedIndex >= 0 && menuRef.current) {
|
||||
const menuItems = menuRef.current.querySelectorAll<HTMLElement>('[role="menuitem"]');
|
||||
menuItems[focusedIndex]?.focus();
|
||||
}
|
||||
}, [focusedIndex, isOpen]);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
setFocusedIndex(-1);
|
||||
buttonRef.current?.focus();
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
setFocusedIndex(0);
|
||||
} else {
|
||||
setFocusedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0));
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
if (isOpen) {
|
||||
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1));
|
||||
}
|
||||
break;
|
||||
case 'Tab':
|
||||
if (isOpen) {
|
||||
setIsOpen(false);
|
||||
setFocusedIndex(-1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemClick = () => {
|
||||
setIsOpen(false);
|
||||
setFocusedIndex(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<div className="relative" ref={dropdownRef} onKeyDown={handleKeyDown} role="menu">
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-foreground hover:text-foreground flex items-center gap-1"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onClick={() => {
|
||||
setIsOpen(!isOpen);
|
||||
if (!isOpen) setFocusedIndex(-1);
|
||||
}}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="true"
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
{label}
|
||||
<Icon
|
||||
|
|
@ -53,16 +106,23 @@ function NavDropdown({ label, items }: NavDropdownProps): React.ReactElement {
|
|||
/>
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 w-56 bg-background border border-border rounded-lg shadow-lg py-1 z-50">
|
||||
{items.map((item) =>
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
aria-label={label}
|
||||
className="absolute top-full left-0 mt-1 w-56 bg-background border border-border rounded-lg shadow-lg py-1 z-50"
|
||||
>
|
||||
{items.map((item, index) =>
|
||||
item.external ? (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between px-4 py-2 text-sm text-foreground hover:bg-surface transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
role="menuitem"
|
||||
tabIndex={focusedIndex === index ? 0 : -1}
|
||||
className="flex items-center justify-between px-4 py-2 text-sm text-foreground hover:bg-surface focus:bg-surface focus:outline-none transition-colors"
|
||||
onClick={handleItemClick}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{item.label}</div>
|
||||
|
|
@ -76,8 +136,10 @@ function NavDropdown({ label, items }: NavDropdownProps): React.ReactElement {
|
|||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="block px-4 py-2 text-sm text-foreground hover:bg-surface transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
role="menuitem"
|
||||
tabIndex={focusedIndex === index ? 0 : -1}
|
||||
className="block px-4 py-2 text-sm text-foreground hover:bg-surface focus:bg-surface focus:outline-none transition-colors"
|
||||
onClick={handleItemClick}
|
||||
>
|
||||
<div className="font-medium">{item.label}</div>
|
||||
{item.description && (
|
||||
|
|
|
|||
312
apps/web/src/components/ForkButton.tsx
Normal file
312
apps/web/src/components/ForkButton.tsx
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
'use client';
|
||||
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSession } from '~/lib/auth-client';
|
||||
|
||||
interface ForkStatus {
|
||||
hasFork: boolean;
|
||||
fork: { id: string; slug?: string; uid?: string; name: string } | null;
|
||||
isOwner: boolean;
|
||||
canFork: boolean;
|
||||
}
|
||||
|
||||
interface ForkButtonProps {
|
||||
type: 'agent' | 'collection';
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
className?: string;
|
||||
/** Show full-width variant with description text */
|
||||
variant?: 'compact' | 'full';
|
||||
}
|
||||
|
||||
export function ForkButton({
|
||||
type,
|
||||
sourceId,
|
||||
sourceName,
|
||||
className,
|
||||
variant = 'compact',
|
||||
}: ForkButtonProps): React.ReactElement {
|
||||
void sourceName;
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const [isForking, setIsForking] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [forkStatus, setForkStatus] = useState<ForkStatus | null>(null);
|
||||
|
||||
// Fetch fork status on mount
|
||||
useEffect(() => {
|
||||
if (!session?.user) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
async function checkForkStatus() {
|
||||
try {
|
||||
const endpoint =
|
||||
type === 'agent'
|
||||
? `/api/agents/${sourceId}/fork-status`
|
||||
: `/api/collections/${sourceId}/fork-status`;
|
||||
|
||||
const response = await fetch(endpoint);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setForkStatus(data.data);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - will show fork button as fallback
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
checkForkStatus();
|
||||
}, [session?.user, sourceId, type]);
|
||||
|
||||
async function handleFork() {
|
||||
if (!session?.user) {
|
||||
router.push(`/sign-in?redirect=${encodeURIComponent(window.location.pathname)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsForking(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const endpoint =
|
||||
type === 'agent' ? `/api/agents/${sourceId}/clone` : `/api/collections/${sourceId}/clone`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Redirect to the forked item in dashboard
|
||||
if (type === 'agent') {
|
||||
router.push(`/dashboard/agents/${data.data.id}`);
|
||||
} else {
|
||||
router.push(`/dashboard/collections/${data.data.id}`);
|
||||
}
|
||||
} else {
|
||||
setError(data.error?.message || 'Failed to fork');
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to fork');
|
||||
} finally {
|
||||
setIsForking(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Not logged in - show sign in prompt
|
||||
if (!session?.user) {
|
||||
if (variant === 'full') {
|
||||
return (
|
||||
<div className={`p-4 bg-surface border border-border rounded-lg ${className || ''}`}>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Icon icon="gitFork" className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground">Fork to Use</h3>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Sign in to fork this {type} to your account
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/sign-in?redirect=${encodeURIComponent(typeof window !== 'undefined' ? window.location.pathname : '')}`}
|
||||
>
|
||||
<Button className="w-full mt-2">
|
||||
<Icon icon="user" className="w-4 h-4 mr-2" />
|
||||
Sign In to Fork
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={className}>
|
||||
<Link href="/sign-in">
|
||||
<Button variant="outline" title={`Sign in to fork this ${type}`}>
|
||||
<Icon icon="gitFork" className="w-4 h-4 mr-2" />
|
||||
Fork
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Button variant="outline" disabled>
|
||||
<Icon icon="loader" className="w-4 h-4 animate-spin mr-2" />
|
||||
{variant === 'full' ? 'Loading...' : ''}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Owner - show "Your" badge
|
||||
if (forkStatus?.isOwner) {
|
||||
if (variant === 'full') {
|
||||
return (
|
||||
<div
|
||||
className={`p-4 bg-green-500/10 border border-green-500/20 rounded-lg ${className || ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-500/20 rounded-lg">
|
||||
<Icon icon="check" className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground">
|
||||
Your {type === 'agent' ? 'Agent' : 'Collection'}
|
||||
</h3>
|
||||
<p className="text-sm text-foreground-secondary">You own this {type}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={className}>
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-green-500/10 text-green-700 rounded-lg text-sm font-medium">
|
||||
<Icon icon="check" className="w-4 h-4" />
|
||||
Your {type === 'agent' ? 'Agent' : 'Collection'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Already forked - show link to fork
|
||||
if (forkStatus?.hasFork && forkStatus.fork) {
|
||||
const forkUrl =
|
||||
type === 'agent'
|
||||
? `/dashboard/agents/${forkStatus.fork.id}`
|
||||
: `/dashboard/collections/${forkStatus.fork.id}`;
|
||||
|
||||
if (variant === 'full') {
|
||||
return (
|
||||
<div
|
||||
className={`p-4 bg-blue-500/10 border border-blue-500/20 rounded-lg ${className || ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-blue-500/20 rounded-lg">
|
||||
<Icon icon="gitFork" className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground">Already Forked</h3>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
You have a fork: “{forkStatus.fork.name}”
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href={forkUrl}>
|
||||
<Button variant="secondary" className="w-full mt-2">
|
||||
<Icon icon="externalLink" className="w-4 h-4 mr-2" />
|
||||
View Your Fork
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={className}>
|
||||
<Link href={forkUrl}>
|
||||
<Button variant="outline" title="View your forked version">
|
||||
<Icon icon="gitFork" className="w-4 h-4 mr-2" />
|
||||
View Fork
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Can't fork (over limit)
|
||||
if (!forkStatus?.canFork) {
|
||||
if (variant === 'full') {
|
||||
return (
|
||||
<div
|
||||
className={`p-4 bg-amber-500/10 border border-amber-500/20 rounded-lg ${className || ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-amber-500/20 rounded-lg">
|
||||
<Icon icon="alertTriangle" className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground">Limit Reached</h3>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
You've reached the maximum number of{' '}
|
||||
{type === 'agent' ? 'agents' : 'collections'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={className}>
|
||||
<Button variant="outline" disabled title={`${type} limit reached`}>
|
||||
<Icon icon="gitFork" className="w-4 h-4 mr-2" />
|
||||
Limit Reached
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Can fork - show fork button
|
||||
if (variant === 'full') {
|
||||
return (
|
||||
<div className={`p-4 bg-surface border border-border rounded-lg ${className || ''}`}>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Icon icon="gitFork" className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground">Fork to Use</h3>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Fork this {type} to your account to use it with your own API keys
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleFork} disabled={isForking} className="w-full mt-2">
|
||||
{isForking ? (
|
||||
<Icon icon="loader" className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Icon icon="gitFork" className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Fork {type === 'agent' ? 'Agent' : 'Collection'}
|
||||
</Button>
|
||||
{error && <p className="text-xs text-red-500 mt-2">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleFork}
|
||||
disabled={isForking}
|
||||
title={`Fork this ${type} to your account`}
|
||||
>
|
||||
{isForking ? (
|
||||
<Icon icon="loader" className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Icon icon="gitFork" className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Fork
|
||||
</Button>
|
||||
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
apps/web/src/components/ForkedFromBadge.tsx
Normal file
61
apps/web/src/components/ForkedFromBadge.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
'use client';
|
||||
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface ForkedFromInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
uid?: string;
|
||||
user?: {
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ForkedFromBadgeProps {
|
||||
type: 'agent' | 'collection';
|
||||
forkedFrom: ForkedFromInfo | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ForkedFromBadge({
|
||||
type,
|
||||
forkedFrom,
|
||||
className,
|
||||
}: ForkedFromBadgeProps): React.ReactElement | null {
|
||||
if (!forkedFrom) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build the link to the original
|
||||
let href: string;
|
||||
if (forkedFrom.user?.username) {
|
||||
if (type === 'agent' && forkedFrom.uid) {
|
||||
href = `/${forkedFrom.user.username}/agents/${forkedFrom.uid}`;
|
||||
} else if (type === 'collection' && forkedFrom.slug) {
|
||||
href = `/${forkedFrom.user.username}/collections/${forkedFrom.slug}`;
|
||||
} else {
|
||||
// Fallback to ID-based URL
|
||||
href = type === 'agent' ? `/agents/${forkedFrom.id}` : `/collections/${forkedFrom.id}`;
|
||||
}
|
||||
} else {
|
||||
// No username available, use ID-based URL
|
||||
href = type === 'agent' ? `/agents/${forkedFrom.id}` : `/collections/${forkedFrom.id}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`inline-flex items-center gap-1.5 text-sm text-foreground-secondary hover:text-foreground transition-colors ${className || ''}`}
|
||||
>
|
||||
<Icon icon="gitFork" className="w-3.5 h-3.5" />
|
||||
<span>
|
||||
Forked from{' '}
|
||||
<span className="font-medium text-foreground-secondary hover:text-foreground">
|
||||
{forkedFrom.name}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -90,7 +90,10 @@ export function HeroSection({ stats }: HeroSectionProps): React.ReactElement {
|
|||
<div className="max-w-3xl">
|
||||
<div className="relative">
|
||||
{/* Command Line Prompt */}
|
||||
<div className="absolute left-0 top-0 bottom-0 flex items-center pl-6 font-mono text-brutalist-accent text-lg font-bold pointer-events-none">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute left-0 top-0 bottom-0 flex items-center pl-6 font-mono text-brutalist-accent text-lg font-bold pointer-events-none"
|
||||
>
|
||||
$
|
||||
</div>
|
||||
|
||||
|
|
@ -100,6 +103,7 @@ export function HeroSection({ stats }: HeroSectionProps): React.ReactElement {
|
|||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="search tools..."
|
||||
aria-label="Search tools"
|
||||
className="brutalist-border h-16 md:h-20 pl-14 pr-36 md:pr-40 text-lg md:text-xl font-mono placeholder:text-foreground-tertiary placeholder:uppercase focus:ring-4 focus:ring-brutalist-accent focus:ring-offset-0 bg-background"
|
||||
style={{ borderRadius: 0 }}
|
||||
/>
|
||||
|
|
@ -126,7 +130,10 @@ export function HeroSection({ stats }: HeroSectionProps): React.ReactElement {
|
|||
</div>
|
||||
|
||||
{/* Scroll Indicator */}
|
||||
<div className="absolute bottom-12 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 animate-pulse">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute bottom-12 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 animate-pulse motion-reduce:animate-none"
|
||||
>
|
||||
<span className="font-mono text-xs uppercase tracking-widest text-foreground-tertiary">
|
||||
Scroll
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ export const ACTIVITY_MESSAGES: Record<
|
|||
AGENT_UPDATED: (name) => `Updated agent "${name}"`,
|
||||
AGENT_DELETED: (name) => `Deleted agent "${name}"`,
|
||||
AGENT_CLONED: (name) => `Cloned agent "${name}"`,
|
||||
AGENT_FORKED: (name, meta) =>
|
||||
meta?.sourceAgentName
|
||||
? `Forked agent "${meta.sourceAgentName}" as "${name}"`
|
||||
: `Forked agent as "${name}"`,
|
||||
AGENT_TOOL_ADDED: (name, meta) =>
|
||||
meta?.toolName
|
||||
? `Added tool "${meta.toolName}" to agent "${name}"`
|
||||
|
|
@ -67,6 +71,10 @@ export const ACTIVITY_MESSAGES: Record<
|
|||
COLLECTION_UPDATED: (name) => `Updated collection "${name}"`,
|
||||
COLLECTION_DELETED: (name) => `Deleted collection "${name}"`,
|
||||
COLLECTION_CLONED: (name) => `Cloned collection "${name}"`,
|
||||
COLLECTION_FORKED: (name, meta) =>
|
||||
meta?.sourceCollectionName
|
||||
? `Forked collection "${meta.sourceCollectionName}" as "${name}"`
|
||||
: `Forked collection as "${name}"`,
|
||||
COLLECTION_TOOL_ADDED: (name, meta) =>
|
||||
meta?.toolName
|
||||
? `Added tool "${meta.toolName}" to collection "${name}"`
|
||||
|
|
@ -91,6 +99,7 @@ export const ACTIVITY_ICONS: Record<ActivityType, string> = {
|
|||
AGENT_UPDATED: 'pencil',
|
||||
AGENT_DELETED: 'trash',
|
||||
AGENT_CLONED: 'copy',
|
||||
AGENT_FORKED: 'gitFork',
|
||||
AGENT_TOOL_ADDED: 'link',
|
||||
AGENT_TOOL_REMOVED: 'unlink',
|
||||
AGENT_COLLECTION_ADDED: 'folderPlus',
|
||||
|
|
@ -99,6 +108,7 @@ export const ACTIVITY_ICONS: Record<ActivityType, string> = {
|
|||
COLLECTION_UPDATED: 'pencil',
|
||||
COLLECTION_DELETED: 'trash',
|
||||
COLLECTION_CLONED: 'copy',
|
||||
COLLECTION_FORKED: 'gitFork',
|
||||
COLLECTION_TOOL_ADDED: 'link',
|
||||
COLLECTION_TOOL_REMOVED: 'unlink',
|
||||
TOOL_LIKED: 'heart',
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ export async function handleToolsCall(
|
|||
select: {
|
||||
executorType: true,
|
||||
executorConfig: true,
|
||||
envVars: true,
|
||||
tools: {
|
||||
include: { tool: { include: { package: true } } },
|
||||
},
|
||||
|
|
@ -168,11 +169,24 @@ export async function handleToolsCall(
|
|||
'Database query timed out'
|
||||
);
|
||||
|
||||
const collectionTool = collection?.tools.find(
|
||||
// Try both scoped (@scope/name) and literal (name-with-hyphens) package name interpretations
|
||||
// This handles cases like:
|
||||
// - @tpmjs/hello (scoped) → sanitized as tpmjs-hello → parsed back as @tpmjs/hello
|
||||
// - firecrawl-aisdk (not scoped) → sanitized as firecrawl-aisdk → should match as-is
|
||||
let collectionTool = collection?.tools.find(
|
||||
(ct) =>
|
||||
ct.tool.package.npmPackageName === parsed.packageName && ct.tool.name === parsed.toolName
|
||||
);
|
||||
|
||||
// If not found with scoped name, try the literal package name
|
||||
if (!collectionTool && parsed.literalPackageName !== parsed.packageName) {
|
||||
collectionTool = collection?.tools.find(
|
||||
(ct) =>
|
||||
ct.tool.package.npmPackageName === parsed.literalPackageName &&
|
||||
ct.tool.name === parsed.toolName
|
||||
);
|
||||
}
|
||||
|
||||
if (!collectionTool) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
|
|
@ -181,17 +195,21 @@ export async function handleToolsCall(
|
|||
};
|
||||
}
|
||||
|
||||
// Get the actual package name that matched
|
||||
const actualPackageName = collectionTool.tool.package.npmPackageName;
|
||||
|
||||
// Resolve executor configuration (collection config only for MCP - no agent context)
|
||||
const executorConfig = parseExecutorConfig(
|
||||
collection?.executorType,
|
||||
collection?.executorConfig
|
||||
);
|
||||
|
||||
// Execute via resolved executor
|
||||
// Execute via resolved executor with collection's environment variables
|
||||
const result = await executeWithExecutor(executorConfig, {
|
||||
packageName: parsed.packageName,
|
||||
packageName: actualPackageName,
|
||||
name: parsed.toolName,
|
||||
params: params.arguments ?? {},
|
||||
env: (collection?.envVars as Record<string, string>) ?? undefined,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export function convertToMcpTool(tool: Tool & { package: Package }): McpToolDefi
|
|||
* Parsed tool name result - either a registry tool or a bridge tool
|
||||
*/
|
||||
export type ParsedToolName =
|
||||
| { type: 'registry'; packageName: string; toolName: string }
|
||||
| { type: 'registry'; packageName: string; literalPackageName: string; toolName: string }
|
||||
| { type: 'bridge'; serverId: string; toolName: string };
|
||||
|
||||
/**
|
||||
|
|
@ -70,7 +70,7 @@ export type ParsedToolName =
|
|||
export function parseToolName(mcpName: string): ParsedToolName | null {
|
||||
// Check if it's a bridge tool
|
||||
const bridgeMatch = mcpName.match(/^bridge--([^-]+(?:-[^-]+)*)--(.+)$/);
|
||||
if (bridgeMatch && bridgeMatch[1] && bridgeMatch[2]) {
|
||||
if (bridgeMatch?.[1] && bridgeMatch[2]) {
|
||||
return {
|
||||
type: 'bridge',
|
||||
serverId: bridgeMatch[1],
|
||||
|
|
@ -85,11 +85,19 @@ export function parseToolName(mcpName: string): ParsedToolName | null {
|
|||
const pkg = match[1];
|
||||
const toolName = match[2];
|
||||
|
||||
// Reconstruct @scope/name format if it looks scoped
|
||||
// Try to reconstruct @scope/name format if it looks scoped
|
||||
// tpmjs-hello → @tpmjs/hello (first dash becomes @scope/)
|
||||
const packageName = pkg.includes('-') ? `@${pkg.replace('-', '/')}` : pkg;
|
||||
// But also keep the original for non-scoped packages like firecrawl-aisdk
|
||||
const scopedPackageName = pkg.includes('-') ? `@${pkg.replace('-', '/')}` : pkg;
|
||||
const literalPackageName = pkg;
|
||||
|
||||
return { type: 'registry', packageName, toolName };
|
||||
// Return both possible interpretations - the handler will try both
|
||||
return {
|
||||
type: 'registry',
|
||||
packageName: scopedPackageName,
|
||||
literalPackageName,
|
||||
toolName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -436,6 +436,12 @@ model Collection {
|
|||
// These are passed to tools when executed
|
||||
envVars Json? @map("env_vars") @db.JsonB
|
||||
|
||||
// Fork tracking - for "fork to use" model
|
||||
forkedFromId String? @map("forked_from_id")
|
||||
forkedFrom Collection? @relation("CollectionForks", fields: [forkedFromId], references: [id], onDelete: SetNull)
|
||||
forks Collection[] @relation("CollectionForks")
|
||||
forkCount Int @default(0) @map("fork_count")
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
|
@ -453,6 +459,8 @@ model Collection {
|
|||
@@index([isPublic])
|
||||
@@index([likeCount])
|
||||
@@index([createdAt])
|
||||
@@index([forkedFromId])
|
||||
@@index([forkCount])
|
||||
@@map("collections")
|
||||
}
|
||||
|
||||
|
|
@ -537,6 +545,12 @@ model Agent {
|
|||
// Agent env vars override collection env vars, otherwise merged
|
||||
envVars Json? @map("env_vars") @db.JsonB
|
||||
|
||||
// Fork tracking - for "fork to use" model
|
||||
forkedFromId String? @map("forked_from_id")
|
||||
forkedFrom Agent? @relation("AgentForks", fields: [forkedFromId], references: [id], onDelete: SetNull)
|
||||
forks Agent[] @relation("AgentForks")
|
||||
forkCount Int @default(0) @map("fork_count")
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
|
@ -553,6 +567,8 @@ model Agent {
|
|||
@@index([isPublic])
|
||||
@@index([likeCount])
|
||||
@@index([createdAt])
|
||||
@@index([forkedFromId])
|
||||
@@index([forkCount])
|
||||
@@map("agents")
|
||||
}
|
||||
|
||||
|
|
@ -755,6 +771,7 @@ enum ActivityType {
|
|||
AGENT_UPDATED
|
||||
AGENT_DELETED
|
||||
AGENT_CLONED
|
||||
AGENT_FORKED
|
||||
AGENT_TOOL_ADDED
|
||||
AGENT_TOOL_REMOVED
|
||||
AGENT_COLLECTION_ADDED
|
||||
|
|
@ -763,6 +780,7 @@ enum ActivityType {
|
|||
COLLECTION_UPDATED
|
||||
COLLECTION_DELETED
|
||||
COLLECTION_CLONED
|
||||
COLLECTION_FORKED
|
||||
COLLECTION_TOOL_ADDED
|
||||
COLLECTION_TOOL_REMOVED
|
||||
TOOL_LIKED
|
||||
|
|
|
|||
|
|
@ -169,6 +169,8 @@ export const AgentSchema = z.object({
|
|||
isPublic: z.boolean(),
|
||||
toolCount: z.number(),
|
||||
collectionCount: z.number(),
|
||||
forkCount: z.number().default(0),
|
||||
forkedFromId: z.string().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@ export const CollectionSchema = z.object({
|
|||
description: z.string().nullable(),
|
||||
isPublic: z.boolean(),
|
||||
toolCount: z.number(),
|
||||
forkCount: z.number().default(0),
|
||||
forkedFromId: z.string().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -140,6 +140,14 @@ export const icons = {
|
|||
viewBox: '0 0 24 24',
|
||||
path: 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z',
|
||||
},
|
||||
gitFork: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M6 3a3 3 0 1 0 0 6 3 3 0 0 0 0-6zm0 2a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm12-2a3 3 0 1 0 0 6 3 3 0 0 0 0-6zm0 2a1 1 0 1 1 0 2 1 1 0 0 1 0-2zM6 15a3 3 0 1 0 0 6 3 3 0 0 0 0-6zm0 2a1 1 0 1 1 0 2 1 1 0 0 1 0-2zM7 8v5a3 3 0 0 0 3 3h4a1 1 0 0 1 1 1v1h-2v-1H9a5 5 0 0 1-5-5V8h3zm10 0v4a1 1 0 0 1-1 1h-4v-2h3V8h2z',
|
||||
},
|
||||
alertTriangle: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof icons;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue