tpmjs/apps/web/src/app/(profile)/[username]/agents/[uid]/page.tsx
Ajax Davis 4fa8344b34 feat: replace hardcoded stats with real DB data and add view tracking
- 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
2026-02-10 02:27:14 +10:00

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&apos;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>
);
}