- Add PageView model for daily-bucketed view tracking - Add viewCount fields to Tool, Collection, Agent models - Add social proof fields to StatsSnapshot - Add POST /api/track/view endpoint with IP-based dedup - Add GET /api/activity/public endpoint for real activity stream - Add /api/sync/view-rollup daily cron for aggregating views - Expand stats-snapshot cron with new social proof queries - Replace hardcoded homepage stats with real DB-driven props - Add downloads to hero metrics strip - Add PublicActivityStream component fetching real UserActivity - Add useTrackView hook for tool, collection, agent detail pages - Add forkCount column to collections and agents listings - Add views/reviews to tool detail statistics sidebar - Remove deprecated hardcoded statistics and categories from homePageData
381 lines
13 KiB
TypeScript
381 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
|
import { Button } from '@tpmjs/ui/Button/Button';
|
|
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
|
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
|
import Link from 'next/link';
|
|
import { notFound, useParams } from 'next/navigation';
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
import { AppHeader } from '~/components/AppHeader';
|
|
import { ForkButton } from '~/components/ForkButton';
|
|
import { ForkedFromBadge } from '~/components/ForkedFromBadge';
|
|
import { LikeButton } from '~/components/LikeButton';
|
|
import { useTrackView } from '~/hooks/useTrackView';
|
|
import { useSession } from '~/lib/auth-client';
|
|
|
|
interface AgentTool {
|
|
id: string;
|
|
toolId: string;
|
|
position: number;
|
|
tool: {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
package: {
|
|
npmPackageName: string;
|
|
category: string;
|
|
};
|
|
};
|
|
}
|
|
|
|
interface AgentCollection {
|
|
id: string;
|
|
collectionId: string;
|
|
collection: {
|
|
id: string;
|
|
slug: string;
|
|
name: string;
|
|
description: string | null;
|
|
toolCount: number;
|
|
user: {
|
|
username: string;
|
|
};
|
|
};
|
|
}
|
|
|
|
interface PublicAgent {
|
|
id: string;
|
|
uid: string;
|
|
name: string;
|
|
description: string | null;
|
|
provider: string;
|
|
modelId: string;
|
|
systemPrompt: string | null;
|
|
temperature: number;
|
|
likeCount: number;
|
|
forkCount: number;
|
|
toolCount: number;
|
|
collectionCount: number;
|
|
createdAt: string;
|
|
createdBy: {
|
|
id: string;
|
|
username: string;
|
|
name: string;
|
|
image: string | null;
|
|
};
|
|
tools: AgentTool[];
|
|
collections: AgentCollection[];
|
|
forkedFromId: string | null;
|
|
forkedFrom: {
|
|
id: string;
|
|
name: string;
|
|
uid: string;
|
|
user: {
|
|
username: string;
|
|
};
|
|
} | null;
|
|
}
|
|
|
|
function AgentApiSection({
|
|
agentId,
|
|
provider,
|
|
isOwner,
|
|
}: {
|
|
agentId: string;
|
|
provider: string;
|
|
isOwner: boolean;
|
|
}) {
|
|
const [showExample, setShowExample] = useState(false);
|
|
|
|
const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com';
|
|
const apiUrl = `${baseUrl}/api/agents/${agentId}/conversation`;
|
|
|
|
const apiExampleSnippet = `// Create a conversation and send a message
|
|
const createConvo = await fetch("${apiUrl}", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Authorization": "Bearer YOUR_TPMJS_API_KEY"
|
|
},
|
|
body: JSON.stringify({
|
|
name: "My Conversation"
|
|
})
|
|
});
|
|
const { data: { conversation } } = await createConvo.json();
|
|
|
|
// Send a message (include providerApiKey for non-owners)
|
|
const response = await fetch(\`${apiUrl}/\${conversation.id}\`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Authorization": "Bearer YOUR_TPMJS_API_KEY"
|
|
},
|
|
body: JSON.stringify({
|
|
message: "Hello!",
|
|
providerApiKey: "YOUR_${provider.toUpperCase()}_API_KEY", // Required for non-owners
|
|
env: {
|
|
// Your env vars for any tools the agent uses
|
|
"SOME_API_KEY": "your-key-here"
|
|
}
|
|
})
|
|
});`;
|
|
|
|
return (
|
|
<section className="p-4 bg-gradient-to-br from-primary/5 via-transparent to-primary/5 border border-primary/20 rounded-xl">
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<div className="p-1.5 bg-primary/10 rounded-lg">
|
|
<Icon icon="link" className="w-4 h-4 text-primary" />
|
|
</div>
|
|
<h3 className="font-semibold text-foreground">API Access</h3>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<div className="group">
|
|
<div className="flex items-center gap-2 mb-1.5">
|
|
<span className="text-xs font-medium text-foreground-secondary uppercase tracking-wide">
|
|
Conversation API
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto">
|
|
{apiUrl}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Note for non-owners */}
|
|
{!isOwner && (
|
|
<div className="mt-4 p-3 bg-warning/10 border border-warning/20 rounded-lg">
|
|
<p className="text-sm text-warning-foreground">
|
|
<Icon icon="info" className="w-4 h-4 inline mr-1" />
|
|
You'll need to provide your own <strong>{provider}</strong> API key via the{' '}
|
|
<code className="font-mono text-xs bg-surface px-1 rounded">providerApiKey</code> field,
|
|
plus any tool credentials via the{' '}
|
|
<code className="font-mono text-xs bg-surface px-1 rounded">env</code> parameter.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-4 pt-4 border-t border-border/50">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowExample(!showExample)}
|
|
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
|
|
>
|
|
<Icon icon={showExample ? 'chevronDown' : 'chevronRight'} className="w-4 h-4" />
|
|
<span>Show API usage example</span>
|
|
</button>
|
|
|
|
{showExample && (
|
|
<div className="mt-3">
|
|
<CodeBlock language="typescript" code={apiExampleSnippet} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<p className="mt-3 text-xs text-foreground-tertiary">
|
|
<Link href="/docs/tutorials/agents" className="text-primary hover:underline">
|
|
Learn more about the Agent Conversation API
|
|
</Link>
|
|
</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: large detail page with many conditional sections
|
|
export default function PrettyAgentDetailPage(): React.ReactElement {
|
|
const params = useParams();
|
|
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);
|
|
|
|
// Track page view
|
|
useTrackView('agent', agent?.id ?? '');
|
|
|
|
// 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}`);
|
|
if (response.status === 404) {
|
|
setError('not_found');
|
|
return;
|
|
}
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
setAgent(data.data);
|
|
} else {
|
|
setError(data.error?.message || 'Failed to load agent');
|
|
}
|
|
} catch {
|
|
setError('Failed to load agent');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [username, uid]);
|
|
|
|
useEffect(() => {
|
|
fetchAgent();
|
|
}, [fetchAgent]);
|
|
|
|
if (error === 'not_found') {
|
|
notFound();
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
<AppHeader />
|
|
|
|
<main className="max-w-5xl mx-auto px-4 py-8">
|
|
{isLoading ? (
|
|
<div className="flex justify-center py-12">
|
|
<Icon icon="loader" className="w-8 h-8 animate-spin text-foreground-secondary" />
|
|
</div>
|
|
) : error ? (
|
|
<div className="text-center py-12">
|
|
<p className="text-error">{error}</p>
|
|
</div>
|
|
) : agent ? (
|
|
<div className="space-y-8">
|
|
{/* Agent Header */}
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<h1 className="text-2xl font-bold text-foreground">{agent.name}</h1>
|
|
<Badge variant="outline">{agent.provider}</Badge>
|
|
</div>
|
|
{agent.description && (
|
|
<p className="text-foreground-secondary">{agent.description}</p>
|
|
)}
|
|
<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} />
|
|
<ForkButton 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>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="flex items-center gap-6 text-sm text-foreground-secondary">
|
|
<span className="flex items-center gap-1">
|
|
<Icon icon="puzzle" className="w-4 h-4" />
|
|
{agent.toolCount} tools
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<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>
|
|
|
|
{/* API Access - Available to everyone (non-owners must provide their own credentials) */}
|
|
<AgentApiSection agentId={agent.id} provider={agent.provider} isOwner={!!isOwner} />
|
|
|
|
{/* System Prompt */}
|
|
{agent.systemPrompt && (
|
|
<section>
|
|
<h2 className="text-lg font-semibold text-foreground mb-2">System Prompt</h2>
|
|
<div className="bg-surface border border-border rounded-lg p-4">
|
|
<pre className="text-sm text-foreground-secondary whitespace-pre-wrap font-mono">
|
|
{agent.systemPrompt}
|
|
</pre>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* Tools */}
|
|
{agent.tools.length > 0 && (
|
|
<section>
|
|
<h2 className="text-lg font-semibold text-foreground mb-4">Tools</h2>
|
|
<div className="grid gap-3">
|
|
{agent.tools.map((at) => (
|
|
<Link
|
|
key={at.id}
|
|
href={`/tool/${at.tool.package.npmPackageName}/${at.tool.name}`}
|
|
className="block p-4 bg-surface border border-border rounded-lg hover:border-foreground-secondary transition-colors"
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h3 className="font-medium text-foreground">{at.tool.name}</h3>
|
|
<p className="text-sm text-foreground-secondary mt-1 line-clamp-2">
|
|
{at.tool.description}
|
|
</p>
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<Badge variant="secondary" className="text-xs">
|
|
{at.tool.package.category}
|
|
</Badge>
|
|
<span className="text-xs text-foreground-tertiary">
|
|
{at.tool.package.npmPackageName}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* Collections */}
|
|
{agent.collections.length > 0 && (
|
|
<section>
|
|
<h2 className="text-lg font-semibold text-foreground mb-4">Collections</h2>
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
{agent.collections.map((ac) => (
|
|
<Link
|
|
key={ac.id}
|
|
href={`/${ac.collection.user.username}/collections/${ac.collection.slug}`}
|
|
className="block p-4 bg-surface border border-border rounded-lg hover:border-foreground-secondary transition-colors"
|
|
>
|
|
<h3 className="font-medium text-foreground">{ac.collection.name}</h3>
|
|
{ac.collection.description && (
|
|
<p className="text-sm text-foreground-secondary mt-1 line-clamp-2">
|
|
{ac.collection.description}
|
|
</p>
|
|
)}
|
|
<span className="text-xs text-foreground-tertiary mt-2 inline-block">
|
|
{ac.collection.toolCount} tools
|
|
</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|