feat: add AI Agents feature with multi-provider support and documentation

- Add Agent, AgentCollection, AgentTool, UserApiKey, Conversation, Message models to Prisma schema
- Create agent types and Zod schemas in @tpmjs/types
- Implement AES-256 API key encryption utilities
- Add CRUD API endpoints for agents, tools, collections, and user API keys
- Create conversation streaming endpoint with SSE events
- Build agent tool builder to merge collections and individual tools
- Add dashboard pages: agents list, new agent form, agent detail/edit, chat interface
- Add API keys settings page for managing provider keys
- Add comprehensive Agents documentation section to /docs
- Update navigation to include Agents link in header and mobile menu
- Add new icons: terminal, puzzle, message, key, info, send

Supported providers: OpenAI, Anthropic, Google, Groq, Mistral
This commit is contained in:
Ajax Davis 2026-01-02 20:08:52 +10:00
parent 4cbd84edb8
commit 552f319583
28 changed files with 5082 additions and 2 deletions

View file

@ -15,6 +15,10 @@
"generate-og": "tsx scripts/generate-og-images.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.2",
"@ai-sdk/google": "^3.0.2",
"@ai-sdk/groq": "^3.0.2",
"@ai-sdk/mistral": "^3.0.2",
"@ai-sdk/openai": "3.0.1",
"@modelcontextprotocol/sdk": "^1.25.1",
"@prisma/client": "^6.19.0",

View file

@ -0,0 +1,55 @@
import { prisma } from '@tpmjs/db';
import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server';
import { auth } from '~/lib/auth';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
type RouteContext = {
params: Promise<{ id: string; collectionId: string }>;
};
/**
* DELETE /api/agents/[id]/collections/[collectionId]
* Remove a collection from an agent
*/
export async function DELETE(_request: NextRequest, context: RouteContext): Promise<NextResponse> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
}
const { id, collectionId } = await context.params;
// Check agent ownership
const agent = await prisma.agent.findUnique({
where: { id },
select: { userId: true },
});
if (!agent) {
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
}
if (agent.userId !== session.user.id) {
return NextResponse.json({ success: false, error: 'Access denied' }, { status: 403 });
}
// Delete the agent-collection link
await prisma.agentCollection.deleteMany({
where: { agentId: id, collectionId },
});
return NextResponse.json({
success: true,
data: { removed: true },
});
} catch (error) {
console.error('Failed to remove collection from agent:', error);
return NextResponse.json(
{ success: false, error: 'Failed to remove collection' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,140 @@
import { prisma } from '@tpmjs/db';
import { AGENT_LIMITS, AddCollectionToAgentSchema } from '@tpmjs/types/agent';
import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server';
import { auth } from '~/lib/auth';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
type RouteContext = {
params: Promise<{ id: string }>;
};
/**
* POST /api/agents/[id]/collections
* Add a collection to an agent
*/
export async function POST(request: NextRequest, context: RouteContext): Promise<NextResponse> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
}
const { id } = await context.params;
const body = await request.json();
const parsed = AddCollectionToAgentSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: 'Invalid request', details: parsed.error.flatten() },
{ status: 400 }
);
}
// Check agent ownership
const agent = await prisma.agent.findUnique({
where: { id },
select: { userId: true, _count: { select: { collections: true } } },
});
if (!agent) {
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
}
if (agent.userId !== session.user.id) {
return NextResponse.json({ success: false, error: 'Access denied' }, { status: 403 });
}
// Check collection limit
if (agent._count.collections >= AGENT_LIMITS.MAX_COLLECTIONS_PER_AGENT) {
return NextResponse.json(
{
success: false,
error: `Maximum ${AGENT_LIMITS.MAX_COLLECTIONS_PER_AGENT} collections per agent`,
},
{ status: 400 }
);
}
// Check collection exists and user has access
const collection = await prisma.collection.findUnique({
where: { id: parsed.data.collectionId },
select: {
id: true,
name: true,
userId: true,
isPublic: true,
_count: { select: { tools: true } },
},
});
if (!collection) {
return NextResponse.json({ success: false, error: 'Collection not found' }, { status: 404 });
}
if (collection.userId !== session.user.id && !collection.isPublic) {
return NextResponse.json(
{ success: false, error: 'Collection access denied' },
{ status: 403 }
);
}
// Check if already added
const existing = await prisma.agentCollection.findUnique({
where: {
agentId_collectionId: { agentId: id, collectionId: parsed.data.collectionId },
},
});
if (existing) {
return NextResponse.json(
{ success: false, error: 'Collection already added to agent' },
{ status: 409 }
);
}
// Get next position
const maxPosition = await prisma.agentCollection.aggregate({
where: { agentId: id },
_max: { position: true },
});
const position = parsed.data.position ?? (maxPosition._max.position ?? -1) + 1;
const agentCollection = await prisma.agentCollection.create({
data: {
agentId: id,
collectionId: parsed.data.collectionId,
position,
},
include: {
collection: {
select: {
id: true,
name: true,
_count: { select: { tools: true } },
},
},
},
});
return NextResponse.json(
{
success: true,
data: {
id: agentCollection.id,
collectionId: agentCollection.collectionId,
position: agentCollection.position,
addedAt: agentCollection.addedAt,
collection: {
...agentCollection.collection,
toolCount: agentCollection.collection._count.tools,
},
},
},
{ status: 201 }
);
} catch (error) {
console.error('Failed to add collection to agent:', error);
return NextResponse.json(
{ success: false, error: 'Failed to add collection' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,243 @@
import { prisma } from '@tpmjs/db';
import { UpdateAgentSchema } from '@tpmjs/types/agent';
import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server';
import { auth } from '~/lib/auth';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
type RouteContext = {
params: Promise<{ id: string }>;
};
/**
* GET /api/agents/[id]
* Get a single agent's details
*/
export async function GET(_request: NextRequest, context: RouteContext): Promise<NextResponse> {
try {
const session = await auth.api.getSession({ headers: await headers() });
const { id } = await context.params;
const agent = await prisma.agent.findUnique({
where: { id },
include: {
collections: {
include: {
collection: {
select: {
id: true,
name: true,
_count: { select: { tools: true } },
},
},
},
orderBy: { position: 'asc' },
},
tools: {
include: {
tool: {
select: {
id: true,
name: true,
description: true,
package: {
select: {
npmPackageName: true,
category: true,
},
},
},
},
},
orderBy: { position: 'asc' },
},
_count: {
select: {
tools: true,
collections: true,
conversations: true,
},
},
},
});
if (!agent) {
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
}
// Check access - owner or public
const isOwner = session?.user?.id === agent.userId;
if (!isOwner && !agent.isPublic) {
return NextResponse.json({ success: false, error: 'Access denied' }, { status: 403 });
}
return NextResponse.json({
success: true,
data: {
...agent,
isOwner,
toolCount: agent._count.tools,
collectionCount: agent._count.collections,
conversationCount: agent._count.conversations,
collections: agent.collections.map((ac) => ({
id: ac.id,
collectionId: ac.collectionId,
position: ac.position,
addedAt: ac.addedAt,
collection: {
...ac.collection,
toolCount: ac.collection._count.tools,
},
})),
tools: agent.tools.map((at) => ({
id: at.id,
toolId: at.toolId,
position: at.position,
addedAt: at.addedAt,
tool: at.tool,
})),
_count: undefined,
},
});
} catch (error) {
console.error('Failed to get agent:', error);
return NextResponse.json({ success: false, error: 'Failed to get agent' }, { status: 500 });
}
}
/**
* PATCH /api/agents/[id]
* Update an agent's configuration
*/
export async function PATCH(request: NextRequest, context: RouteContext): Promise<NextResponse> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
}
const { id } = await context.params;
const body = await request.json();
const parsed = UpdateAgentSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: 'Invalid request', details: parsed.error.flatten() },
{ status: 400 }
);
}
// Check ownership
const existing = await prisma.agent.findUnique({
where: { id },
select: { userId: true },
});
if (!existing) {
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
}
if (existing.userId !== session.user.id) {
return NextResponse.json({ success: false, error: 'Access denied' }, { status: 403 });
}
// Check UID uniqueness if being changed
if (parsed.data.uid) {
const existingByUid = await prisma.agent.findFirst({
where: { uid: parsed.data.uid, id: { not: id } },
});
if (existingByUid) {
return NextResponse.json({ success: false, error: 'UID already in use' }, { status: 409 });
}
}
// Check name uniqueness if being changed
if (parsed.data.name) {
const existingByName = await prisma.agent.findFirst({
where: { userId: session.user.id, name: parsed.data.name, id: { not: id } },
});
if (existingByName) {
return NextResponse.json(
{ success: false, error: 'An agent with this name already exists' },
{ status: 409 }
);
}
}
const agent = await prisma.agent.update({
where: { id },
data: parsed.data,
select: {
id: true,
uid: true,
name: true,
description: true,
provider: true,
modelId: true,
systemPrompt: true,
temperature: true,
maxToolCallsPerTurn: true,
maxMessagesInContext: true,
isPublic: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
tools: true,
collections: true,
},
},
},
});
return NextResponse.json({
success: true,
data: {
...agent,
toolCount: agent._count.tools,
collectionCount: agent._count.collections,
_count: undefined,
},
});
} catch (error) {
console.error('Failed to update agent:', error);
return NextResponse.json({ success: false, error: 'Failed to update agent' }, { status: 500 });
}
}
/**
* DELETE /api/agents/[id]
* Delete an agent and all its conversations
*/
export async function DELETE(_request: NextRequest, context: RouteContext): Promise<NextResponse> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
}
const { id } = await context.params;
// Check ownership
const existing = await prisma.agent.findUnique({
where: { id },
select: { userId: true },
});
if (!existing) {
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
}
if (existing.userId !== session.user.id) {
return NextResponse.json({ success: false, error: 'Access denied' }, { status: 403 });
}
await prisma.agent.delete({ where: { id } });
return NextResponse.json({
success: true,
data: { deleted: true },
});
} catch (error) {
console.error('Failed to delete agent:', error);
return NextResponse.json({ success: false, error: 'Failed to delete agent' }, { status: 500 });
}
}

View file

@ -0,0 +1,52 @@
import { prisma } from '@tpmjs/db';
import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server';
import { auth } from '~/lib/auth';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
type RouteContext = {
params: Promise<{ id: string; toolId: string }>;
};
/**
* DELETE /api/agents/[id]/tools/[toolId]
* Remove an individual tool from an agent
*/
export async function DELETE(_request: NextRequest, context: RouteContext): Promise<NextResponse> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
}
const { id, toolId } = await context.params;
// Check agent ownership
const agent = await prisma.agent.findUnique({
where: { id },
select: { userId: true },
});
if (!agent) {
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
}
if (agent.userId !== session.user.id) {
return NextResponse.json({ success: false, error: 'Access denied' }, { status: 403 });
}
// Delete the agent-tool link
await prisma.agentTool.deleteMany({
where: { agentId: id, toolId },
});
return NextResponse.json({
success: true,
data: { removed: true },
});
} catch (error) {
console.error('Failed to remove tool from agent:', error);
return NextResponse.json({ success: false, error: 'Failed to remove tool' }, { status: 500 });
}
}

View file

@ -0,0 +1,125 @@
import { prisma } from '@tpmjs/db';
import { AGENT_LIMITS, AddToolToAgentSchema } from '@tpmjs/types/agent';
import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server';
import { auth } from '~/lib/auth';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
type RouteContext = {
params: Promise<{ id: string }>;
};
/**
* POST /api/agents/[id]/tools
* Add an individual tool to an agent
*/
export async function POST(request: NextRequest, context: RouteContext): Promise<NextResponse> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
}
const { id } = await context.params;
const body = await request.json();
const parsed = AddToolToAgentSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: 'Invalid request', details: parsed.error.flatten() },
{ status: 400 }
);
}
// Check agent ownership
const agent = await prisma.agent.findUnique({
where: { id },
select: { userId: true, _count: { select: { tools: true } } },
});
if (!agent) {
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
}
if (agent.userId !== session.user.id) {
return NextResponse.json({ success: false, error: 'Access denied' }, { status: 403 });
}
// Check tool limit
if (agent._count.tools >= AGENT_LIMITS.MAX_TOOLS_PER_AGENT) {
return NextResponse.json(
{ success: false, error: `Maximum ${AGENT_LIMITS.MAX_TOOLS_PER_AGENT} tools per agent` },
{ status: 400 }
);
}
// Check tool exists
const tool = await prisma.tool.findUnique({
where: { id: parsed.data.toolId },
select: {
id: true,
name: true,
description: true,
package: { select: { npmPackageName: true, category: true } },
},
});
if (!tool) {
return NextResponse.json({ success: false, error: 'Tool not found' }, { status: 404 });
}
// Check if already added
const existing = await prisma.agentTool.findUnique({
where: {
agentId_toolId: { agentId: id, toolId: parsed.data.toolId },
},
});
if (existing) {
return NextResponse.json(
{ success: false, error: 'Tool already added to agent' },
{ status: 409 }
);
}
// Get next position
const maxPosition = await prisma.agentTool.aggregate({
where: { agentId: id },
_max: { position: true },
});
const position = parsed.data.position ?? (maxPosition._max.position ?? -1) + 1;
const agentTool = await prisma.agentTool.create({
data: {
agentId: id,
toolId: parsed.data.toolId,
position,
},
include: {
tool: {
select: {
id: true,
name: true,
description: true,
package: { select: { npmPackageName: true, category: true } },
},
},
},
});
return NextResponse.json(
{
success: true,
data: {
id: agentTool.id,
toolId: agentTool.toolId,
position: agentTool.position,
addedAt: agentTool.addedAt,
tool: agentTool.tool,
},
},
{ status: 201 }
);
} catch (error) {
console.error('Failed to add tool to agent:', error);
return NextResponse.json({ success: false, error: 'Failed to add tool' }, { status: 500 });
}
}

View file

@ -0,0 +1,452 @@
/**
* Agent Conversation Endpoint
*
* POST: Send a message and stream the AI response
* GET: Retrieve conversation history
* DELETE: Delete a conversation
*/
import { decryptApiKey } from '@/lib/crypto/api-keys';
import { Prisma, prisma } from '@tpmjs/db';
import type { AIProvider } from '@tpmjs/types/agent';
import { SendMessageSchema } from '@tpmjs/types/agent';
import type { LanguageModel } from 'ai';
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 300; // 5 minutes for long agentic runs
type RouteContext = {
params: Promise<{ uid: string; conversationId: string }>;
};
/**
* Get AI provider SDK based on provider type
*/
async function getProviderModel(
provider: AIProvider,
modelId: string,
apiKey: string
): Promise<LanguageModel> {
switch (provider) {
case 'OPENAI': {
const { createOpenAI } = await import('@ai-sdk/openai');
return createOpenAI({ apiKey })(modelId);
}
case 'ANTHROPIC': {
const { createAnthropic } = await import('@ai-sdk/anthropic');
return createAnthropic({ apiKey })(modelId);
}
case 'GOOGLE': {
const { createGoogleGenerativeAI } = await import('@ai-sdk/google');
return createGoogleGenerativeAI({ apiKey })(modelId);
}
case 'GROQ': {
const { createGroq } = await import('@ai-sdk/groq');
return createGroq({ apiKey })(modelId);
}
case 'MISTRAL': {
const { createMistral } = await import('@ai-sdk/mistral');
return createMistral({ apiKey })(modelId);
}
default:
throw new Error(`Unsupported provider: ${provider}`);
}
}
/**
* POST /api/agents/[uid]/conversation/[conversationId]
* Send a message and stream the AI response via SSE
*/
export async function POST(request: NextRequest, context: RouteContext): Promise<Response> {
const { uid, conversationId } = await context.params;
try {
const body = await request.json();
const parsed = SendMessageSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: 'Invalid request', details: parsed.error.flatten() },
{ status: 400 }
);
}
// Fetch agent with all tool relations
const { fetchAgentByUidWithTools, buildAgentTools } = await import('@/lib/agents/build-tools');
const agent = await fetchAgentByUidWithTools(uid);
if (!agent) {
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
}
// Get user's API key for this provider
const userApiKey = await prisma.userApiKey.findUnique({
where: {
userId_provider: {
userId: agent.userId,
provider: agent.provider,
},
},
});
if (!userApiKey) {
return NextResponse.json(
{
success: false,
error: `No API key configured for ${agent.provider}. Please add your API key in settings.`,
},
{ status: 400 }
);
}
// Decrypt the API key
const apiKey = decryptApiKey(userApiKey.encryptedKey, userApiKey.keyIv);
// Get or create conversation
let conversation = await prisma.conversation.findUnique({
where: {
agentId_slug: {
agentId: agent.id,
slug: conversationId,
},
},
});
if (!conversation) {
conversation = await prisma.conversation.create({
data: {
agentId: agent.id,
slug: conversationId,
title: parsed.data.message.slice(0, 100),
},
});
}
// Fetch recent messages for context
const recentMessages = await prisma.message.findMany({
where: { conversationId: conversation.id },
orderBy: { createdAt: 'desc' },
take: agent.maxMessagesInContext,
});
// Reverse to get chronological order
recentMessages.reverse();
// Save user message
await prisma.message.create({
data: {
conversationId: conversation.id,
role: 'USER',
content: parsed.data.message,
},
});
// Build AI SDK messages from conversation history
const { streamText, stepCountIs } = await import('ai');
// biome-ignore lint/suspicious/noExplicitAny: AI SDK message types
const messages: any[] = [];
// Add system prompt if defined
if (agent.systemPrompt) {
messages.push({
role: 'system',
content: agent.systemPrompt,
});
}
// Add conversation history
for (const msg of recentMessages) {
if (msg.role === 'USER') {
messages.push({ role: 'user', content: msg.content });
} else if (msg.role === 'ASSISTANT') {
const assistantMsg: { role: string; content: string; toolCalls?: unknown[] } = {
role: 'assistant',
content: msg.content,
};
if (msg.toolCalls) {
assistantMsg.toolCalls = msg.toolCalls as unknown[];
}
messages.push(assistantMsg);
} else if (msg.role === 'TOOL') {
messages.push({
role: 'tool',
toolCallId: msg.toolCallId,
toolName: msg.toolName,
result: msg.toolResult,
});
}
}
// Add new user message
messages.push({ role: 'user', content: parsed.data.message });
// Build tools from agent configuration
const tools = buildAgentTools(agent);
// Get the provider model
const model = await getProviderModel(agent.provider, agent.modelId, apiKey);
// Create SSE stream
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const sendEvent = (event: string, data: unknown) => {
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
controller.enqueue(encoder.encode(message));
};
try {
const startTime = Date.now();
let fullContent = '';
// biome-ignore lint/suspicious/noExplicitAny: Dynamic tool call structure
let allToolCalls: any[] = [];
let inputTokens = 0;
let outputTokens = 0;
// Stream the response with agentic loop control
const result = await streamText({
model,
messages,
tools,
stopWhen: stepCountIs(agent.maxToolCallsPerTurn),
onChunk: async ({ chunk }) => {
// Stream tool calls as they come in
if (chunk.type === 'tool-call') {
sendEvent('tool_call', {
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
input: 'args' in chunk ? chunk.args : chunk.input,
});
}
},
onStepFinish: async ({ toolResults, usage }) => {
// Send tool results
if (toolResults && toolResults.length > 0) {
for (const tr of toolResults) {
sendEvent('tool_result', {
toolCallId: tr.toolCallId,
output: tr.output,
});
// Save tool message to database
await prisma.message.create({
data: {
conversationId: conversation.id,
role: 'TOOL',
content: JSON.stringify(tr.output),
toolCallId: tr.toolCallId,
toolName: tr.toolName,
toolResult: tr.output as object,
},
});
}
}
// Track token usage
if (usage) {
inputTokens += usage.inputTokens ?? 0;
outputTokens += usage.outputTokens ?? 0;
}
},
});
// Stream text chunks
for await (const chunk of result.textStream) {
fullContent += chunk;
sendEvent('chunk', { type: 'text', text: chunk });
}
// Get final response data
const finalResponse = await result.response;
const finalUsage = await result.usage;
// Extract tool calls from final response
if (finalResponse.messages) {
for (const msg of finalResponse.messages) {
if ('toolCalls' in msg && msg.toolCalls && Array.isArray(msg.toolCalls)) {
allToolCalls = [...allToolCalls, ...(msg.toolCalls as unknown[])];
}
}
}
// Update token counts from final usage
if (finalUsage) {
inputTokens = finalUsage.inputTokens ?? inputTokens;
outputTokens = finalUsage.outputTokens ?? outputTokens;
}
// Save assistant message
const assistantMessage = await prisma.message.create({
data: {
conversationId: conversation.id,
role: 'ASSISTANT',
content: fullContent,
toolCalls: allToolCalls.length > 0 ? allToolCalls : Prisma.JsonNull,
inputTokens,
outputTokens,
},
});
// Update conversation timestamp
await prisma.conversation.update({
where: { id: conversation.id },
data: { updatedAt: new Date() },
});
const executionTimeMs = Date.now() - startTime;
// Send token usage
sendEvent('tokens', {
inputTokens,
outputTokens,
totalTokens: inputTokens + outputTokens,
});
// Send completion event
sendEvent('complete', {
messageId: assistantMessage.id,
conversationId: conversation.id,
executionTimeMs,
});
} catch (error) {
console.error('Agent conversation error:', error);
sendEvent('error', {
message: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
controller.close();
}
},
});
return new NextResponse(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
} catch (error) {
console.error('Failed to process message:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to process message',
},
{ status: 500 }
);
}
}
/**
* GET /api/agents/[uid]/conversation/[conversationId]
* Retrieve conversation history
*/
export async function GET(_request: NextRequest, context: RouteContext): Promise<NextResponse> {
const { uid, conversationId } = await context.params;
try {
// Fetch agent
const agent = await prisma.agent.findUnique({
where: { uid },
select: { id: true },
});
if (!agent) {
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
}
// Fetch conversation with messages
const conversation = await prisma.conversation.findUnique({
where: {
agentId_slug: {
agentId: agent.id,
slug: conversationId,
},
},
include: {
messages: {
orderBy: { createdAt: 'asc' },
},
},
});
if (!conversation) {
return NextResponse.json(
{ success: false, error: 'Conversation not found' },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
data: {
id: conversation.id,
slug: conversation.slug,
title: conversation.title,
createdAt: conversation.createdAt,
updatedAt: conversation.updatedAt,
messages: conversation.messages.map((m) => ({
id: m.id,
role: m.role,
content: m.content,
toolCalls: m.toolCalls,
toolCallId: m.toolCallId,
toolName: m.toolName,
toolResult: m.toolResult,
inputTokens: m.inputTokens,
outputTokens: m.outputTokens,
createdAt: m.createdAt,
})),
},
});
} catch (error) {
console.error('Failed to fetch conversation:', error);
return NextResponse.json(
{ success: false, error: 'Failed to fetch conversation' },
{ status: 500 }
);
}
}
/**
* DELETE /api/agents/[uid]/conversation/[conversationId]
* Delete a conversation
*/
export async function DELETE(_request: NextRequest, context: RouteContext): Promise<NextResponse> {
const { uid, conversationId } = await context.params;
try {
// Fetch agent
const agent = await prisma.agent.findUnique({
where: { uid },
select: { id: true },
});
if (!agent) {
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
}
// Delete conversation (messages cascade)
await prisma.conversation.deleteMany({
where: {
agentId: agent.id,
slug: conversationId,
},
});
return NextResponse.json({
success: true,
data: { deleted: true },
});
} catch (error) {
console.error('Failed to delete conversation:', error);
return NextResponse.json(
{ success: false, error: 'Failed to delete conversation' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,79 @@
/**
* Agent Conversations List Endpoint
*
* GET: List all conversations for an agent
*/
import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 30;
type RouteContext = {
params: Promise<{ uid: string }>;
};
/**
* GET /api/agents/[uid]/conversations
* List all conversations for an agent
*/
export async function GET(request: NextRequest, context: RouteContext): Promise<NextResponse> {
const { uid } = await context.params;
const { searchParams } = new URL(request.url);
const limit = Math.min(Number.parseInt(searchParams.get('limit') || '20', 10), 100);
const offset = Number.parseInt(searchParams.get('offset') || '0', 10);
try {
// Fetch agent
const agent = await prisma.agent.findUnique({
where: { uid },
select: { id: true },
});
if (!agent) {
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
}
// Fetch conversations with message count
const conversations = await prisma.conversation.findMany({
where: { agentId: agent.id },
orderBy: { updatedAt: 'desc' },
take: limit + 1,
skip: offset,
include: {
_count: {
select: { messages: true },
},
},
});
const hasMore = conversations.length > limit;
const data = hasMore ? conversations.slice(0, limit) : conversations;
return NextResponse.json({
success: true,
data: data.map((c) => ({
id: c.id,
slug: c.slug,
title: c.title,
messageCount: c._count.messages,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
})),
pagination: {
limit,
offset,
hasMore,
},
});
} catch (error) {
console.error('Failed to fetch conversations:', error);
return NextResponse.json(
{ success: false, error: 'Failed to fetch conversations' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,226 @@
import { prisma } from '@tpmjs/db';
import { AGENT_LIMITS, CreateAgentSchema } from '@tpmjs/types/agent';
import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server';
import { auth } from '~/lib/auth';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* Generate a URL-friendly UID from a name
*/
function generateUid(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 50);
}
/**
* GET /api/agents
* List all agents owned by the authenticated user
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const limit = Math.min(Number.parseInt(searchParams.get('limit') || '20'), 50);
const offset = Number.parseInt(searchParams.get('offset') || '0');
const agents = await prisma.agent.findMany({
where: { userId: session.user.id },
select: {
id: true,
uid: true,
name: true,
description: true,
provider: true,
modelId: true,
temperature: true,
maxToolCallsPerTurn: true,
maxMessagesInContext: true,
isPublic: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
tools: true,
collections: true,
},
},
},
orderBy: { updatedAt: 'desc' },
take: limit + 1,
skip: offset,
});
const hasMore = agents.length > limit;
const data = hasMore ? agents.slice(0, limit) : agents;
return NextResponse.json({
success: true,
data: data.map((a) => ({
...a,
toolCount: a._count.tools,
collectionCount: a._count.collections,
_count: undefined,
})),
pagination: {
limit,
offset,
count: data.length,
hasMore,
},
});
} catch (error) {
console.error('Failed to list agents:', error);
return NextResponse.json({ success: false, error: 'Failed to list agents' }, { status: 500 });
}
}
/**
* POST /api/agents
* Create a new agent
*/
export async function POST(request: NextRequest): Promise<NextResponse> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const parsed = CreateAgentSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: 'Invalid request', details: parsed.error.flatten() },
{ status: 400 }
);
}
// Check agent limit
const agentCount = await prisma.agent.count({
where: { userId: session.user.id },
});
if (agentCount >= AGENT_LIMITS.MAX_AGENTS_PER_USER) {
return NextResponse.json(
{ success: false, error: `Maximum ${AGENT_LIMITS.MAX_AGENTS_PER_USER} agents allowed` },
{ status: 400 }
);
}
const {
name,
uid,
description,
provider,
modelId,
systemPrompt,
temperature,
maxToolCallsPerTurn,
maxMessagesInContext,
isPublic,
collectionIds,
toolIds,
} = parsed.data;
// Generate UID if not provided
let finalUid = uid || generateUid(name);
// Check for UID uniqueness
const existingByUid = await prisma.agent.findUnique({ where: { uid: finalUid } });
if (existingByUid) {
// Append random suffix if UID exists
finalUid = `${finalUid}-${Math.random().toString(36).slice(2, 6)}`;
}
// Check for name uniqueness within user's agents
const existingByName = await prisma.agent.findFirst({
where: { userId: session.user.id, name },
});
if (existingByName) {
return NextResponse.json(
{ success: false, error: 'An agent with this name already exists' },
{ status: 409 }
);
}
const agent = await prisma.agent.create({
data: {
userId: session.user.id,
uid: finalUid,
name,
description,
provider,
modelId,
systemPrompt,
temperature,
maxToolCallsPerTurn,
maxMessagesInContext,
isPublic,
collections: collectionIds?.length
? {
create: collectionIds.map((collectionId, index) => ({
collectionId,
position: index,
})),
}
: undefined,
tools: toolIds?.length
? {
create: toolIds.map((toolId, index) => ({
toolId,
position: index,
})),
}
: undefined,
},
select: {
id: true,
uid: true,
name: true,
description: true,
provider: true,
modelId: true,
systemPrompt: true,
temperature: true,
maxToolCallsPerTurn: true,
maxMessagesInContext: true,
isPublic: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
tools: true,
collections: true,
},
},
},
});
return NextResponse.json(
{
success: true,
data: {
...agent,
toolCount: agent._count.tools,
collectionCount: agent._count.collections,
_count: undefined,
},
},
{ status: 201 }
);
} catch (error) {
console.error('Failed to create agent:', error);
return NextResponse.json({ success: false, error: 'Failed to create agent' }, { status: 500 });
}
}

View file

@ -0,0 +1,53 @@
import type { AIProvider } from '@prisma/client';
import { prisma } from '@tpmjs/db';
import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server';
import { auth } from '~/lib/auth';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
type RouteContext = {
params: Promise<{ provider: string }>;
};
/**
* DELETE /api/user/api-keys/[provider]
* Remove an API key for a provider
*/
export async function DELETE(_request: NextRequest, context: RouteContext): Promise<NextResponse> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
}
const { provider } = await context.params;
// Validate provider is a valid enum value
const validProviders = ['OPENAI', 'ANTHROPIC', 'GOOGLE', 'GROQ', 'MISTRAL'];
if (!validProviders.includes(provider.toUpperCase())) {
return NextResponse.json({ success: false, error: 'Invalid provider' }, { status: 400 });
}
await prisma.userApiKey.deleteMany({
where: {
userId: session.user.id,
provider: provider.toUpperCase() as AIProvider,
},
});
return NextResponse.json({
success: true,
data: { deleted: true },
});
} catch (error) {
console.error('Failed to delete API key:', error);
return NextResponse.json(
{ success: false, error: 'Failed to delete API key' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,103 @@
import { prisma } from '@tpmjs/db';
import { AddApiKeySchema } from '@tpmjs/types/agent';
import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server';
import { auth } from '~/lib/auth';
import { encryptApiKey, getKeyHint } from '~/lib/crypto/api-keys';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* GET /api/user/api-keys
* List user's stored API keys (masked)
*/
export async function GET(): Promise<NextResponse> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
}
const apiKeys = await prisma.userApiKey.findMany({
where: { userId: session.user.id },
select: {
provider: true,
keyHint: true,
createdAt: true,
updatedAt: true,
},
orderBy: { createdAt: 'asc' },
});
return NextResponse.json({
success: true,
data: apiKeys,
});
} catch (error) {
console.error('Failed to list API keys:', error);
return NextResponse.json({ success: false, error: 'Failed to list API keys' }, { status: 500 });
}
}
/**
* POST /api/user/api-keys
* Add or update an API key for a provider
*/
export async function POST(request: NextRequest): Promise<NextResponse> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const parsed = AddApiKeySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: 'Invalid request', details: parsed.error.flatten() },
{ status: 400 }
);
}
const { provider, apiKey } = parsed.data;
const { encrypted, iv } = encryptApiKey(apiKey);
const keyHint = getKeyHint(apiKey);
const result = await prisma.userApiKey.upsert({
where: {
userId_provider: {
userId: session.user.id,
provider,
},
},
create: {
userId: session.user.id,
provider,
encryptedKey: encrypted,
keyIv: iv,
keyHint,
},
update: {
encryptedKey: encrypted,
keyIv: iv,
keyHint,
},
select: {
provider: true,
keyHint: true,
createdAt: true,
updatedAt: true,
},
});
return NextResponse.json({
success: true,
data: result,
});
} catch (error) {
console.error('Failed to save API key:', error);
return NextResponse.json({ success: false, error: 'Failed to save API key' }, { status: 500 });
}
}

View file

@ -0,0 +1,456 @@
'use client';
import type { AIProvider } from '@tpmjs/types/agent';
import { Button } from '@tpmjs/ui/Button/Button';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useRef, useState } from 'react';
import { AppHeader } from '~/components/AppHeader';
interface Agent {
id: string;
uid: string;
name: string;
description: string | null;
provider: AIProvider;
modelId: string;
}
interface Message {
id: string;
role: 'USER' | 'ASSISTANT' | 'TOOL';
content: string;
toolName?: string;
toolResult?: unknown;
createdAt: string;
}
interface Conversation {
id: string;
slug: string;
title: string | null;
messageCount: number;
updatedAt: string;
}
const PROVIDER_DISPLAY_NAMES: Record<AIProvider, string> = {
OPENAI: 'OpenAI',
ANTHROPIC: 'Anthropic',
GOOGLE: 'Google',
GROQ: 'Groq',
MISTRAL: 'Mistral',
};
export default function AgentChatPage(): React.ReactElement {
const params = useParams();
const router = useRouter();
const agentId = params.id as string;
const [agent, setAgent] = useState<Agent | null>(null);
const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isSending, setIsSending] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const [error, setError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// Fetch agent data
const fetchAgent = useCallback(async () => {
try {
const response = await fetch(`/api/agents/${agentId}`);
const data = await response.json();
if (data.success) {
setAgent(data.data);
} else {
if (response.status === 401) {
router.push('/sign-in');
return;
}
setError(data.error || 'Failed to fetch agent');
}
} catch (err) {
console.error('Failed to fetch agent:', err);
setError('Failed to fetch agent');
}
}, [agentId, router]);
// Fetch conversations
const fetchConversations = useCallback(async () => {
if (!agent) return;
try {
const response = await fetch(`/api/agents/${agent.uid}/conversations`);
const data = await response.json();
if (data.success) {
setConversations(data.data);
}
} catch (err) {
console.error('Failed to fetch conversations:', err);
}
}, [agent]);
// Fetch messages for active conversation
const fetchMessages = useCallback(async () => {
if (!agent || !activeConversationId) return;
try {
const response = await fetch(`/api/agents/${agent.uid}/conversation/${activeConversationId}`);
const data = await response.json();
if (data.success) {
setMessages(data.data.messages || []);
}
} catch (err) {
console.error('Failed to fetch messages:', err);
}
}, [agent, activeConversationId]);
useEffect(() => {
const init = async () => {
await fetchAgent();
setIsLoading(false);
};
init();
}, [fetchAgent]);
useEffect(() => {
if (agent) {
fetchConversations();
}
}, [agent, fetchConversations]);
useEffect(() => {
if (activeConversationId) {
fetchMessages();
} else {
setMessages([]);
}
}, [activeConversationId, fetchMessages]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
});
const generateConversationId = () => {
return `conv-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
};
const handleSend = async () => {
if (!input.trim() || !agent || isSending) return;
const messageContent = input.trim();
setInput('');
setIsSending(true);
setStreamingContent('');
setError(null);
// Create new conversation if needed
const conversationId = activeConversationId || generateConversationId();
if (!activeConversationId) {
setActiveConversationId(conversationId);
}
// Optimistically add user message
const userMessage: Message = {
id: `temp-${Date.now()}`,
role: 'USER',
content: messageContent,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMessage]);
try {
const response = await fetch(`/api/agents/${agent.uid}/conversation/${conversationId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: messageContent }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to send message');
}
// Handle SSE stream
const reader = response.body?.getReader();
if (!reader) throw new Error('No response body');
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Parse SSE events
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
let eventType = '';
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7);
} else if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));
switch (eventType) {
case 'chunk':
setStreamingContent((prev) => prev + data.text);
break;
case 'tool_call':
// Add tool call indicator
setStreamingContent((prev) => `${prev}\n[Calling tool: ${data.toolName}...]\n`);
break;
case 'tool_result':
// Tool result received
break;
case 'complete':
// Refresh messages
await fetchMessages();
await fetchConversations();
setStreamingContent('');
break;
case 'error':
throw new Error(data.message);
}
}
}
}
} catch (err) {
console.error('Failed to send message:', err);
setError(err instanceof Error ? err.message : 'Failed to send message');
// Remove optimistic message on error
setMessages((prev) => prev.filter((m) => m.id !== userMessage.id));
} finally {
setIsSending(false);
setStreamingContent('');
inputRef.current?.focus();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const startNewConversation = () => {
setActiveConversationId(null);
setMessages([]);
inputRef.current?.focus();
};
if (isLoading) {
return (
<div className="min-h-screen bg-background">
<AppHeader />
<div className="max-w-6xl mx-auto py-12 px-4">
<div className="animate-pulse">
<div className="h-8 bg-surface-secondary rounded w-48 mb-8" />
<div className="h-96 bg-surface-secondary rounded-lg" />
</div>
</div>
</div>
);
}
if (error && !agent) {
return (
<div className="min-h-screen bg-background">
<AppHeader />
<div className="max-w-6xl mx-auto py-12 px-4">
<div className="text-center py-16">
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
<h2 className="text-lg font-medium text-foreground mb-2">Error</h2>
<p className="text-foreground-secondary mb-4">{error}</p>
<Link href="/dashboard/agents">
<Button>Back to Agents</Button>
</Link>
</div>
</div>
</div>
);
}
if (!agent) {
return (
<div className="min-h-screen bg-background">
<AppHeader />
<div className="flex items-center justify-center h-[calc(100vh-4rem)]">
<Icon icon="loader" size="lg" className="animate-spin text-foreground-secondary" />
</div>
</div>
);
}
return (
<div className="h-screen flex flex-col bg-background">
<AppHeader />
<div className="flex-1 flex overflow-hidden">
{/* Sidebar */}
<div className="w-64 border-r border-border flex flex-col bg-surface-secondary/50">
{/* Agent Info */}
<div className="p-4 border-b border-border">
<Link
href={`/dashboard/agents/${agent.id}`}
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
>
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
<Icon icon="terminal" size="sm" className="text-primary" />
</div>
<div className="flex-1 min-w-0">
<h2 className="font-medium text-foreground truncate">{agent.name}</h2>
<p className="text-xs text-foreground-tertiary">
{PROVIDER_DISPLAY_NAMES[agent.provider]}
</p>
</div>
</Link>
</div>
{/* New Conversation Button */}
<div className="p-4">
<Button className="w-full" onClick={startNewConversation}>
<Icon icon="plus" size="xs" className="mr-2" />
New Chat
</Button>
</div>
{/* Conversations List */}
<div className="flex-1 overflow-y-auto p-2">
{conversations.map((conv) => (
<button
key={conv.id}
type="button"
onClick={() => setActiveConversationId(conv.slug)}
className={`w-full text-left px-3 py-2 rounded-lg mb-1 transition-colors ${
activeConversationId === conv.slug
? 'bg-primary/10 text-primary'
: 'text-foreground-secondary hover:bg-surface-secondary'
}`}
>
<p className="text-sm font-medium truncate">{conv.title || 'Untitled Chat'}</p>
<p className="text-xs text-foreground-tertiary">{conv.messageCount} messages</p>
</button>
))}
</div>
</div>
{/* Chat Area */}
<div className="flex-1 flex flex-col">
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 && !streamingContent && (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
<Icon icon="message" size="lg" className="text-primary" />
</div>
<h3 className="text-lg font-medium text-foreground mb-2">Start a conversation</h3>
<p className="text-foreground-secondary max-w-sm">
Send a message to start chatting with {agent.name}.
</p>
</div>
</div>
)}
{messages.map((message) => (
<div
key={message.id}
className={`flex ${message.role === 'USER' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-lg p-4 ${
message.role === 'USER'
? 'bg-primary text-primary-foreground'
: message.role === 'TOOL'
? 'bg-surface-secondary border border-border'
: 'bg-surface-secondary'
}`}
>
{message.role === 'TOOL' && (
<div className="flex items-center gap-2 text-xs text-foreground-tertiary mb-2">
<Icon icon="puzzle" size="xs" />
<span>{message.toolName}</span>
</div>
)}
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
</div>
</div>
))}
{streamingContent && (
<div className="flex justify-start">
<div className="max-w-[80%] rounded-lg p-4 bg-surface-secondary">
<p className="whitespace-pre-wrap text-sm">{streamingContent}</p>
<span className="inline-block w-2 h-4 bg-primary animate-pulse ml-1" />
</div>
</div>
)}
{isSending && !streamingContent && (
<div className="flex justify-start">
<div className="rounded-lg p-4 bg-surface-secondary">
<div className="flex items-center gap-2 text-foreground-secondary">
<Icon icon="loader" size="sm" className="animate-spin" />
<span className="text-sm">Thinking...</span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Error Message */}
{error && (
<div className="px-4 py-2 bg-red-50 dark:bg-red-900/20 border-t border-red-200 dark:border-red-800">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
{/* Input Area */}
<div className="border-t border-border p-4">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
rows={1}
className="flex-1 px-4 py-3 bg-background border border-border rounded-lg text-foreground placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary resize-none min-h-[48px] max-h-[200px]"
style={{
height: 'auto',
minHeight: '48px',
}}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = 'auto';
target.style.height = `${Math.min(target.scrollHeight, 200)}px`;
}}
/>
<Button onClick={handleSend} disabled={isSending || !input.trim()}>
<Icon icon="send" size="sm" />
</Button>
</div>
<p className="text-xs text-foreground-tertiary mt-2">
Press Enter to send, Shift+Enter for new line
</p>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,535 @@
'use client';
import type { AIProvider } from '@tpmjs/types/agent';
import { PROVIDER_MODELS, SUPPORTED_PROVIDERS } from '@tpmjs/types/agent';
import { Button } from '@tpmjs/ui/Button/Button';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { AppHeader } from '~/components/AppHeader';
interface Agent {
id: string;
uid: string;
name: string;
description: string | null;
provider: AIProvider;
modelId: string;
systemPrompt: string | null;
temperature: number;
maxToolCallsPerTurn: number;
maxMessagesInContext: number;
isPublic: boolean;
toolCount: number;
collectionCount: number;
createdAt: string;
updatedAt: string;
}
const PROVIDER_DISPLAY_NAMES: Record<AIProvider, string> = {
OPENAI: 'OpenAI',
ANTHROPIC: 'Anthropic',
GOOGLE: 'Google',
GROQ: 'Groq',
MISTRAL: 'Mistral',
};
export default function AgentDetailPage(): React.ReactElement {
const params = useParams();
const router = useRouter();
const agentId = params.id as string;
const [agent, setAgent] = useState<Agent | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({
name: '',
uid: '',
description: '',
provider: 'OPENAI' as AIProvider,
modelId: '',
systemPrompt: '',
temperature: 0.7,
maxToolCallsPerTurn: 20,
maxMessagesInContext: 10,
});
const fetchAgent = useCallback(async () => {
try {
const response = await fetch(`/api/agents/${agentId}`);
const data = await response.json();
if (data.success) {
setAgent(data.data);
setFormData({
name: data.data.name,
uid: data.data.uid,
description: data.data.description || '',
provider: data.data.provider,
modelId: data.data.modelId,
systemPrompt: data.data.systemPrompt || '',
temperature: data.data.temperature,
maxToolCallsPerTurn: data.data.maxToolCallsPerTurn,
maxMessagesInContext: data.data.maxMessagesInContext,
});
} else {
if (response.status === 401) {
router.push('/sign-in');
return;
}
setError(data.error || 'Failed to fetch agent');
}
} catch (err) {
console.error('Failed to fetch agent:', err);
setError('Failed to fetch agent');
} finally {
setIsLoading(false);
}
}, [agentId, router]);
useEffect(() => {
fetchAgent();
}, [fetchAgent]);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
const { name, value } = e.target;
setFormData((prev) => {
const newData = { ...prev, [name]: value };
// Reset model when provider changes
if (name === 'provider') {
const provider = value as AIProvider;
const models = PROVIDER_MODELS[provider];
newData.modelId = models?.[0]?.id || '';
}
return newData;
});
};
const handleSave = async () => {
setIsSaving(true);
try {
const response = await fetch(`/api/agents/${agentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
temperature: Number.parseFloat(formData.temperature.toString()),
maxToolCallsPerTurn: Number.parseInt(formData.maxToolCallsPerTurn.toString(), 10),
maxMessagesInContext: Number.parseInt(formData.maxMessagesInContext.toString(), 10),
description: formData.description || null,
systemPrompt: formData.systemPrompt || null,
}),
});
const result = await response.json();
if (result.success) {
setAgent(result.data);
setIsEditing(false);
} else {
throw new Error(result.error || 'Failed to update agent');
}
} catch (err) {
console.error('Failed to update agent:', err);
alert(err instanceof Error ? err.message : 'Failed to update agent');
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this agent? This action cannot be undone.')) {
return;
}
try {
const response = await fetch(`/api/agents/${agentId}`, {
method: 'DELETE',
});
const result = await response.json();
if (result.success) {
router.push('/dashboard/agents');
} else {
throw new Error(result.error || 'Failed to delete agent');
}
} catch (err) {
console.error('Failed to delete agent:', err);
alert(err instanceof Error ? err.message : 'Failed to delete agent');
}
};
const models = PROVIDER_MODELS[formData.provider] || [];
if (isLoading) {
return (
<div className="min-h-screen bg-background">
<AppHeader />
<div className="max-w-4xl mx-auto py-12 px-4">
<div className="animate-pulse">
<div className="h-8 bg-surface-secondary rounded w-48 mb-8" />
<div className="h-64 bg-surface-secondary rounded-lg" />
</div>
</div>
</div>
);
}
if (error || !agent) {
return (
<div className="min-h-screen bg-background">
<AppHeader />
<div className="max-w-4xl mx-auto py-12 px-4">
<div className="text-center py-16">
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
<h2 className="text-lg font-medium text-foreground mb-2">Error</h2>
<p className="text-foreground-secondary mb-4">{error || 'Agent not found'}</p>
<Link href="/dashboard/agents">
<Button>Back to Agents</Button>
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<AppHeader />
<div className="max-w-4xl mx-auto py-12 px-4">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-4">
<Link
href="/dashboard/agents"
className="text-foreground-secondary hover:text-foreground transition-colors"
>
<Icon icon="arrowLeft" size="sm" />
</Link>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
<Icon icon="terminal" size="sm" className="text-primary" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">{agent.name}</h1>
<p className="text-sm text-foreground-tertiary">
{PROVIDER_DISPLAY_NAMES[agent.provider]} / {agent.modelId}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Link href={`/dashboard/agents/${agent.id}/chat`}>
<Button>
<Icon icon="message" size="sm" className="mr-2" />
Chat
</Button>
</Link>
</div>
</div>
{/* Quick Stats */}
<div className="grid gap-4 sm:grid-cols-3 mb-8">
<div className="bg-background border border-border rounded-lg p-4">
<div className="flex items-center gap-2 text-foreground-secondary mb-1">
<Icon icon="puzzle" size="xs" />
<span className="text-sm">Tools</span>
</div>
<p className="text-2xl font-bold text-foreground">{agent.toolCount}</p>
</div>
<div className="bg-background border border-border rounded-lg p-4">
<div className="flex items-center gap-2 text-foreground-secondary mb-1">
<Icon icon="folder" size="xs" />
<span className="text-sm">Collections</span>
</div>
<p className="text-2xl font-bold text-foreground">{agent.collectionCount}</p>
</div>
<div className="bg-background border border-border rounded-lg p-4">
<div className="flex items-center gap-2 text-foreground-secondary mb-1">
<Icon icon="terminal" size="xs" />
<span className="text-sm">Max Tool Calls</span>
</div>
<p className="text-2xl font-bold text-foreground">{agent.maxToolCallsPerTurn}</p>
</div>
</div>
{/* API Endpoint */}
<div className="bg-background border border-border rounded-lg p-4 mb-8">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">API Endpoint</p>
<code className="text-sm text-foreground-secondary font-mono">
POST /api/agents/{agent.uid}/conversation/{'<conversation-id>'}
</code>
</div>
<Button
size="sm"
variant="secondary"
onClick={() => {
navigator.clipboard.writeText(
`${window.location.origin}/api/agents/${agent.uid}/conversation/<conversation-id>`
);
}}
>
<Icon icon="copy" size="xs" />
</Button>
</div>
</div>
{/* Configuration */}
<div className="bg-background border border-border rounded-lg p-6 mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium text-foreground">Configuration</h2>
{!isEditing && (
<Button size="sm" variant="secondary" onClick={() => setIsEditing(true)}>
<Icon icon="edit" size="xs" className="mr-1" />
Edit
</Button>
)}
</div>
{isEditing ? (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="name" className="block text-sm font-medium text-foreground mb-1">
Name
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
/>
</div>
<div>
<label htmlFor="uid" className="block text-sm font-medium text-foreground mb-1">
UID
</label>
<input
type="text"
id="uid"
name="uid"
value={formData.uid}
onChange={handleChange}
className="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
/>
</div>
</div>
<div>
<label
htmlFor="description"
className="block text-sm font-medium text-foreground mb-1"
>
Description
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
rows={2}
className="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary resize-none"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label
htmlFor="provider"
className="block text-sm font-medium text-foreground mb-1"
>
Provider
</label>
<select
id="provider"
name="provider"
value={formData.provider}
onChange={handleChange}
className="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
>
{SUPPORTED_PROVIDERS.map((p) => (
<option key={p} value={p}>
{PROVIDER_DISPLAY_NAMES[p]}
</option>
))}
</select>
</div>
<div>
<label
htmlFor="modelId"
className="block text-sm font-medium text-foreground mb-1"
>
Model
</label>
<select
id="modelId"
name="modelId"
value={formData.modelId}
onChange={handleChange}
className="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</div>
</div>
<div>
<label
htmlFor="systemPrompt"
className="block text-sm font-medium text-foreground mb-1"
>
System Prompt
</label>
<textarea
id="systemPrompt"
name="systemPrompt"
value={formData.systemPrompt}
onChange={handleChange}
rows={6}
className="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary resize-none"
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div>
<label
htmlFor="temperature"
className="block text-sm font-medium text-foreground mb-1"
>
Temperature
</label>
<input
type="number"
id="temperature"
name="temperature"
value={formData.temperature}
onChange={handleChange}
min={0}
max={2}
step={0.1}
className="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
/>
</div>
<div>
<label
htmlFor="maxToolCallsPerTurn"
className="block text-sm font-medium text-foreground mb-1"
>
Max Tool Calls
</label>
<input
type="number"
id="maxToolCallsPerTurn"
name="maxToolCallsPerTurn"
value={formData.maxToolCallsPerTurn}
onChange={handleChange}
min={1}
max={100}
className="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
/>
</div>
<div>
<label
htmlFor="maxMessagesInContext"
className="block text-sm font-medium text-foreground mb-1"
>
Context Messages
</label>
<input
type="number"
id="maxMessagesInContext"
name="maxMessagesInContext"
value={formData.maxMessagesInContext}
onChange={handleChange}
min={1}
max={100}
className="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-4">
<Button
variant="outline"
onClick={() => {
setIsEditing(false);
setFormData({
name: agent.name,
uid: agent.uid,
description: agent.description || '',
provider: agent.provider,
modelId: agent.modelId,
systemPrompt: agent.systemPrompt || '',
temperature: agent.temperature,
maxToolCallsPerTurn: agent.maxToolCallsPerTurn,
maxMessagesInContext: agent.maxMessagesInContext,
});
}}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
) : (
<dl className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm text-foreground-secondary">Description</dt>
<dd className="text-foreground">{agent.description || '-'}</dd>
</div>
<div>
<dt className="text-sm text-foreground-secondary">UID</dt>
<dd className="text-foreground font-mono text-sm">{agent.uid}</dd>
</div>
<div>
<dt className="text-sm text-foreground-secondary">Temperature</dt>
<dd className="text-foreground">{agent.temperature}</dd>
</div>
<div>
<dt className="text-sm text-foreground-secondary">Context Messages</dt>
<dd className="text-foreground">{agent.maxMessagesInContext}</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-sm text-foreground-secondary">System Prompt</dt>
<dd className="text-foreground font-mono text-sm whitespace-pre-wrap bg-surface-secondary rounded-lg p-3 mt-1">
{agent.systemPrompt || '(No system prompt)'}
</dd>
</div>
</dl>
)}
</div>
{/* Danger Zone */}
<div className="bg-background border border-red-200 dark:border-red-800 rounded-lg p-6">
<h2 className="text-lg font-medium text-red-600 dark:text-red-400 mb-4">Danger Zone</h2>
<p className="text-sm text-foreground-secondary mb-4">
Once you delete an agent, there is no going back. Please be certain.
</p>
<Button variant="outline" onClick={handleDelete}>
<Icon icon="trash" size="xs" className="mr-1" />
Delete Agent
</Button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,378 @@
'use client';
import type { AIProvider } from '@tpmjs/types/agent';
import { PROVIDER_MODELS, SUPPORTED_PROVIDERS } from '@tpmjs/types/agent';
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 { useState } from 'react';
import { AppHeader } from '~/components/AppHeader';
const PROVIDER_DISPLAY_NAMES: Record<AIProvider, string> = {
OPENAI: 'OpenAI',
ANTHROPIC: 'Anthropic',
GOOGLE: 'Google',
GROQ: 'Groq',
MISTRAL: 'Mistral',
};
interface FormData {
name: string;
uid: string;
description: string;
provider: AIProvider;
modelId: string;
systemPrompt: string;
temperature: number;
maxToolCallsPerTurn: number;
maxMessagesInContext: number;
}
export default function NewAgentPage(): React.ReactElement {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState<FormData>({
name: '',
uid: '',
description: '',
provider: 'OPENAI',
modelId: 'gpt-4o',
systemPrompt: '',
temperature: 0.7,
maxToolCallsPerTurn: 20,
maxMessagesInContext: 10,
});
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
const { name, value } = e.target;
setFormData((prev) => {
const newData = { ...prev, [name]: value };
// Auto-generate uid from name if uid is empty or was auto-generated
if (name === 'name' && (!prev.uid || prev.uid === generateUid(prev.name))) {
newData.uid = generateUid(value);
}
// Reset model when provider changes
if (name === 'provider') {
const provider = value as AIProvider;
const models = PROVIDER_MODELS[provider];
newData.modelId = models?.[0]?.id || '';
}
return newData;
});
};
const generateUid = (name: string): string => {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 50);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsSubmitting(true);
try {
const response = await fetch('/api/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
temperature: Number.parseFloat(formData.temperature.toString()),
maxToolCallsPerTurn: Number.parseInt(formData.maxToolCallsPerTurn.toString(), 10),
maxMessagesInContext: Number.parseInt(formData.maxMessagesInContext.toString(), 10),
}),
});
const result = await response.json();
if (result.success) {
router.push(`/dashboard/agents/${result.data.id}`);
} else {
throw new Error(result.error || 'Failed to create agent');
}
} catch (err) {
console.error('Failed to create agent:', err);
setError(err instanceof Error ? err.message : 'Failed to create agent');
} finally {
setIsSubmitting(false);
}
};
const models = PROVIDER_MODELS[formData.provider] || [];
return (
<div className="min-h-screen bg-background">
<AppHeader />
<div className="max-w-3xl mx-auto py-12 px-4">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<Link
href="/dashboard/agents"
className="text-foreground-secondary hover:text-foreground transition-colors"
>
<Icon icon="arrowLeft" size="sm" />
</Link>
<h1 className="text-2xl font-bold text-foreground">Create New Agent</h1>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-8">
{/* Basic Info */}
<div className="bg-background border border-border rounded-lg p-6">
<h2 className="text-lg font-medium text-foreground mb-4">Basic Information</h2>
<div className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-foreground mb-1">
Name *
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
maxLength={100}
placeholder="My AI Agent"
className="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
/>
</div>
<div>
<label htmlFor="uid" className="block text-sm font-medium text-foreground mb-1">
UID (URL-friendly identifier)
</label>
<input
type="text"
id="uid"
name="uid"
value={formData.uid}
onChange={handleChange}
maxLength={50}
pattern="[a-z0-9-]+"
placeholder="my-ai-agent"
className="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary font-mono text-sm"
/>
<p className="text-xs text-foreground-tertiary mt-1">
Used in API URLs. Lowercase letters, numbers, and hyphens only.
</p>
</div>
<div>
<label
htmlFor="description"
className="block text-sm font-medium text-foreground mb-1"
>
Description
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
maxLength={500}
rows={2}
placeholder="What does this agent do?"
className="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary resize-none"
/>
</div>
</div>
</div>
{/* Model Configuration */}
<div className="bg-background border border-border rounded-lg p-6">
<h2 className="text-lg font-medium text-foreground mb-4">Model Configuration</h2>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label
htmlFor="provider"
className="block text-sm font-medium text-foreground mb-1"
>
Provider *
</label>
<select
id="provider"
name="provider"
value={formData.provider}
onChange={handleChange}
required
className="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
>
{SUPPORTED_PROVIDERS.map((provider) => (
<option key={provider} value={provider}>
{PROVIDER_DISPLAY_NAMES[provider]}
</option>
))}
</select>
</div>
<div>
<label
htmlFor="modelId"
className="block text-sm font-medium text-foreground mb-1"
>
Model *
</label>
<select
id="modelId"
name="modelId"
value={formData.modelId}
onChange={handleChange}
required
className="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
>
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.name}
</option>
))}
</select>
</div>
</div>
<div>
<label
htmlFor="systemPrompt"
className="block text-sm font-medium text-foreground mb-1"
>
System Prompt
</label>
<textarea
id="systemPrompt"
name="systemPrompt"
value={formData.systemPrompt}
onChange={handleChange}
maxLength={10000}
rows={6}
placeholder="You are a helpful assistant that..."
className="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary resize-none font-mono text-sm"
/>
<p className="text-xs text-foreground-tertiary mt-1">
Instructions that define how the agent behaves.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div>
<label
htmlFor="temperature"
className="block text-sm font-medium text-foreground mb-1"
>
Temperature
</label>
<input
type="number"
id="temperature"
name="temperature"
value={formData.temperature}
onChange={handleChange}
min={0}
max={2}
step={0.1}
className="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
/>
<p className="text-xs text-foreground-tertiary mt-1">
0 = deterministic, 2 = creative
</p>
</div>
<div>
<label
htmlFor="maxToolCallsPerTurn"
className="block text-sm font-medium text-foreground mb-1"
>
Max Tool Calls
</label>
<input
type="number"
id="maxToolCallsPerTurn"
name="maxToolCallsPerTurn"
value={formData.maxToolCallsPerTurn}
onChange={handleChange}
min={1}
max={100}
className="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
/>
<p className="text-xs text-foreground-tertiary mt-1">Per response turn</p>
</div>
<div>
<label
htmlFor="maxMessagesInContext"
className="block text-sm font-medium text-foreground mb-1"
>
Context Messages
</label>
<input
type="number"
id="maxMessagesInContext"
name="maxMessagesInContext"
value={formData.maxMessagesInContext}
onChange={handleChange}
min={1}
max={100}
className="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
/>
<p className="text-xs text-foreground-tertiary mt-1">
Recent messages to include
</p>
</div>
</div>
</div>
</div>
{/* Note about tools */}
<div className="bg-primary/5 border border-primary/20 rounded-lg p-4">
<div className="flex items-start gap-3">
<Icon icon="info" size="sm" className="text-primary mt-0.5" />
<div className="text-sm">
<p className="text-foreground">
You can add tools and collections to your agent after creating it.
</p>
</div>
</div>
</div>
{/* Error */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<Icon
icon="alertCircle"
size="sm"
className="text-red-600 dark:text-red-400 mt-0.5"
/>
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-4">
<Link href="/dashboard/agents">
<Button type="button" variant="outline">
Cancel
</Button>
</Link>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Agent'}
</Button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,231 @@
'use client';
import type { AIProvider } from '@tpmjs/types/agent';
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 { useCallback, useEffect, useState } from 'react';
import { AppHeader } from '~/components/AppHeader';
interface Agent {
id: string;
uid: string;
name: string;
description: string | null;
provider: AIProvider;
modelId: string;
toolCount: number;
collectionCount: number;
createdAt: string;
updatedAt: string;
}
const PROVIDER_DISPLAY_NAMES: Record<AIProvider, string> = {
OPENAI: 'OpenAI',
ANTHROPIC: 'Anthropic',
GOOGLE: 'Google',
GROQ: 'Groq',
MISTRAL: 'Mistral',
};
export default function AgentsPage(): React.ReactElement {
const router = useRouter();
const [agents, setAgents] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const fetchAgents = useCallback(async () => {
try {
const response = await fetch('/api/agents');
const data = await response.json();
if (data.success) {
setAgents(data.data);
} else {
if (response.status === 401) {
router.push('/sign-in');
return;
}
setError(data.error || 'Failed to fetch agents');
}
} catch (err) {
console.error('Failed to fetch agents:', err);
setError('Failed to fetch agents');
} finally {
setIsLoading(false);
}
}, [router]);
useEffect(() => {
fetchAgents();
}, [fetchAgents]);
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this agent? This action cannot be undone.')) {
return;
}
setDeletingId(id);
try {
const response = await fetch(`/api/agents/${id}`, {
method: 'DELETE',
});
const result = await response.json();
if (result.success) {
setAgents((prev) => prev.filter((a) => a.id !== id));
} else {
throw new Error(result.error || 'Failed to delete agent');
}
} catch (err) {
console.error('Failed to delete agent:', err);
alert(err instanceof Error ? err.message : 'Failed to delete agent');
} finally {
setDeletingId(null);
}
};
if (isLoading) {
return (
<div className="min-h-screen bg-background">
<AppHeader />
<div className="max-w-6xl mx-auto py-12 px-4">
<div className="animate-pulse">
<div className="h-8 bg-surface-secondary rounded w-48 mb-8" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-48 bg-surface-secondary rounded-lg" />
))}
</div>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-background">
<AppHeader />
<div className="max-w-6xl mx-auto py-12 px-4">
<div className="text-center py-16">
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
<h2 className="text-lg font-medium text-foreground mb-2">Error</h2>
<p className="text-foreground-secondary mb-4">{error}</p>
<Button onClick={fetchAgents}>Try Again</Button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<AppHeader />
<div className="max-w-6xl mx-auto py-12 px-4">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-4">
<Link
href="/dashboard"
className="text-foreground-secondary hover:text-foreground transition-colors"
>
<Icon icon="arrowLeft" size="sm" />
</Link>
<h1 className="text-2xl font-bold text-foreground">My Agents</h1>
</div>
<Link href="/dashboard/agents/new">
<Button>
<Icon icon="plus" size="sm" className="mr-2" />
New Agent
</Button>
</Link>
</div>
{/* Empty State */}
{agents.length === 0 && (
<div className="text-center py-16 bg-background border border-border rounded-lg">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
<Icon icon="terminal" size="lg" className="text-primary" />
</div>
<h2 className="text-lg font-medium text-foreground mb-2">No agents yet</h2>
<p className="text-foreground-secondary mb-6 max-w-md mx-auto">
Create your first AI agent to start chatting with tools. Agents can use any tools from
your collections or individual tools.
</p>
<Link href="/dashboard/agents/new">
<Button>
<Icon icon="plus" size="sm" className="mr-2" />
Create Your First Agent
</Button>
</Link>
</div>
)}
{/* Agents Grid */}
{agents.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{agents.map((agent) => (
<div
key={agent.id}
className="bg-background border border-border rounded-lg p-6 hover:border-foreground/20 transition-colors group"
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
<Icon icon="terminal" size="sm" className="text-primary" />
</div>
<div>
<h3 className="font-medium text-foreground">{agent.name}</h3>
<p className="text-xs text-foreground-tertiary">
{PROVIDER_DISPLAY_NAMES[agent.provider]} / {agent.modelId}
</p>
</div>
</div>
</div>
{agent.description && (
<p className="text-sm text-foreground-secondary mb-4 line-clamp-2">
{agent.description}
</p>
)}
<div className="flex items-center gap-4 text-sm text-foreground-tertiary mb-4">
<span className="flex items-center gap-1">
<Icon icon="puzzle" size="xs" />
{agent.toolCount + agent.collectionCount * 5} tools
</span>
</div>
<div className="flex items-center gap-2 pt-4 border-t border-border">
<Link href={`/dashboard/agents/${agent.id}/chat`} className="flex-1">
<Button size="sm" className="w-full">
<Icon icon="message" size="xs" className="mr-1" />
Chat
</Button>
</Link>
<Link href={`/dashboard/agents/${agent.id}`}>
<Button size="sm" variant="secondary">
<Icon icon="edit" size="xs" />
</Button>
</Link>
<Button
size="sm"
variant="outline"
onClick={() => handleDelete(agent.id)}
disabled={deletingId === agent.id}
>
<Icon icon="trash" size="xs" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View file

@ -25,7 +25,23 @@ export default async function DashboardPage() {
</div>
{/* Quick Actions */}
<div className="grid gap-4 sm:grid-cols-2 mb-8">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 mb-8">
<Link href="/dashboard/agents" className="block">
<div className="bg-background border border-border rounded-lg p-6 hover:border-foreground/20 transition-colors group">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
<Icon icon="terminal" size="md" className="text-primary" />
</div>
<div>
<h2 className="text-lg font-medium text-foreground">My Agents</h2>
<p className="text-sm text-foreground-secondary">
Create and manage AI agents with tools
</p>
</div>
</div>
</div>
</Link>
<Link href="/dashboard/collections" className="block">
<div className="bg-background border border-border rounded-lg p-6 hover:border-foreground/20 transition-colors group">
<div className="flex items-center gap-4">
@ -41,6 +57,22 @@ export default async function DashboardPage() {
</div>
</div>
</Link>
<Link href="/dashboard/settings/api-keys" className="block">
<div className="bg-background border border-border rounded-lg p-6 hover:border-foreground/20 transition-colors group">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
<Icon icon="key" size="md" className="text-primary" />
</div>
<div>
<h2 className="text-lg font-medium text-foreground">API Keys</h2>
<p className="text-sm text-foreground-secondary">
Manage AI provider credentials
</p>
</div>
</div>
</div>
</Link>
</div>
{/* Profile Section */}

View file

@ -0,0 +1,364 @@
'use client';
import type { AIProvider } from '@tpmjs/types/agent';
import { SUPPORTED_PROVIDERS } from '@tpmjs/types/agent';
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 { useCallback, useEffect, useState } from 'react';
import { AppHeader } from '~/components/AppHeader';
interface ApiKeyInfo {
provider: AIProvider;
keyHint: string | null;
createdAt: string;
updatedAt: string;
}
const PROVIDER_DISPLAY_NAMES: Record<AIProvider, string> = {
OPENAI: 'OpenAI',
ANTHROPIC: 'Anthropic',
GOOGLE: 'Google',
GROQ: 'Groq',
MISTRAL: 'Mistral',
};
const PROVIDER_DESCRIPTIONS: Record<AIProvider, string> = {
OPENAI: 'GPT-4o, GPT-4 Turbo, GPT-3.5 Turbo',
ANTHROPIC: 'Claude 3.5 Sonnet, Claude 3 Opus, Claude 3.5 Haiku',
GOOGLE: 'Gemini 2.0 Flash, Gemini 1.5 Pro, Gemini 1.5 Flash',
GROQ: 'Llama 3.3 70B, Llama 3.1 8B, Mixtral 8x7B',
MISTRAL: 'Mistral Large, Mistral Small',
};
export default function ApiKeysPage(): React.ReactElement {
const router = useRouter();
const [apiKeys, setApiKeys] = useState<ApiKeyInfo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [addingProvider, setAddingProvider] = useState<AIProvider | null>(null);
const [newApiKey, setNewApiKey] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [deletingProvider, setDeletingProvider] = useState<AIProvider | null>(null);
const fetchApiKeys = useCallback(async () => {
try {
const response = await fetch('/api/user/api-keys');
const data = await response.json();
if (data.success) {
setApiKeys(data.data);
} else {
if (response.status === 401) {
router.push('/sign-in');
return;
}
setError(data.error || 'Failed to fetch API keys');
}
} catch (err) {
console.error('Failed to fetch API keys:', err);
setError('Failed to fetch API keys');
} finally {
setIsLoading(false);
}
}, [router]);
useEffect(() => {
fetchApiKeys();
}, [fetchApiKeys]);
const handleAddKey = async (provider: AIProvider) => {
if (!newApiKey.trim()) return;
setIsSaving(true);
try {
const response = await fetch('/api/user/api-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider, apiKey: newApiKey }),
});
const result = await response.json();
if (result.success) {
// Update or add the key in state
setApiKeys((prev) => {
const existing = prev.findIndex((k) => k.provider === provider);
if (existing >= 0) {
const updated = [...prev];
updated[existing] = result.data;
return updated;
}
return [...prev, result.data];
});
setAddingProvider(null);
setNewApiKey('');
} else {
throw new Error(result.error || 'Failed to save API key');
}
} catch (err) {
console.error('Failed to save API key:', err);
alert(err instanceof Error ? err.message : 'Failed to save API key');
} finally {
setIsSaving(false);
}
};
const handleDeleteKey = async (provider: AIProvider) => {
if (
!confirm(`Are you sure you want to delete your ${PROVIDER_DISPLAY_NAMES[provider]} API key?`)
) {
return;
}
setDeletingProvider(provider);
try {
const response = await fetch(`/api/user/api-keys/${provider}`, {
method: 'DELETE',
});
const result = await response.json();
if (result.success) {
setApiKeys((prev) => prev.filter((k) => k.provider !== provider));
} else {
throw new Error(result.error || 'Failed to delete API key');
}
} catch (err) {
console.error('Failed to delete API key:', err);
alert(err instanceof Error ? err.message : 'Failed to delete API key');
} finally {
setDeletingProvider(null);
}
};
const hasKey = (provider: AIProvider) => apiKeys.some((k) => k.provider === provider);
const getKeyInfo = (provider: AIProvider) => apiKeys.find((k) => k.provider === provider);
if (isLoading) {
return (
<div className="min-h-screen bg-background">
<AppHeader />
<div className="max-w-4xl mx-auto py-12 px-4">
<div className="animate-pulse">
<div className="h-8 bg-surface-secondary rounded w-48 mb-8" />
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-24 bg-surface-secondary rounded-lg" />
))}
</div>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-background">
<AppHeader />
<div className="max-w-4xl mx-auto py-12 px-4">
<div className="text-center py-16">
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
<h2 className="text-lg font-medium text-foreground mb-2">Error</h2>
<p className="text-foreground-secondary mb-4">{error}</p>
<Button onClick={fetchApiKeys}>Try Again</Button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<AppHeader />
<div className="max-w-4xl mx-auto py-12 px-4">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<Link
href="/dashboard"
className="text-foreground-secondary hover:text-foreground transition-colors"
>
<Icon icon="arrowLeft" size="sm" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">API Keys</h1>
<p className="text-foreground-secondary mt-1">
Manage your AI provider API keys for agents
</p>
</div>
</div>
{/* Info Box */}
<div className="bg-primary/5 border border-primary/20 rounded-lg p-4 mb-8">
<div className="flex items-start gap-3">
<Icon icon="info" size="sm" className="text-primary mt-0.5" />
<div className="text-sm">
<p className="text-foreground">
Your API keys are encrypted and stored securely. They are used to make calls to AI
providers when running your agents.
</p>
</div>
</div>
</div>
{/* Provider Cards */}
<div className="space-y-4">
{SUPPORTED_PROVIDERS.map((provider) => {
const keyInfo = getKeyInfo(provider);
const isAdding = addingProvider === provider;
const isDeleting = deletingProvider === provider;
return (
<div key={provider} className="bg-background border border-border rounded-lg p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium text-foreground">
{PROVIDER_DISPLAY_NAMES[provider]}
</h3>
{hasKey(provider) && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
Configured
</span>
)}
</div>
<p className="text-sm text-foreground-secondary mt-1">
{PROVIDER_DESCRIPTIONS[provider]}
</p>
{keyInfo && (
<p className="text-xs text-foreground-tertiary mt-2">
Key ending in <span className="font-mono">{keyInfo.keyHint}</span>
</p>
)}
</div>
{!isAdding && (
<div className="flex items-center gap-2">
{hasKey(provider) ? (
<>
<Button
size="sm"
variant="secondary"
onClick={() => setAddingProvider(provider)}
>
Update
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleDeleteKey(provider)}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
</>
) : (
<Button size="sm" onClick={() => setAddingProvider(provider)}>
Add Key
</Button>
)}
</div>
)}
</div>
{/* Add/Update Form */}
{isAdding && (
<div className="mt-4 pt-4 border-t border-border">
<label
htmlFor={`api-key-${provider}`}
className="block text-sm font-medium text-foreground mb-2"
>
{hasKey(provider) ? 'New' : ''} API Key
</label>
<div className="flex gap-2">
<input
id={`api-key-${provider}`}
type="password"
value={newApiKey}
onChange={(e) => setNewApiKey(e.target.value)}
placeholder={`Enter your ${PROVIDER_DISPLAY_NAMES[provider]} API key`}
className="flex-1 px-3 py-2 bg-background border border-border rounded-lg text-foreground placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
autoFocus
/>
<Button
onClick={() => handleAddKey(provider)}
disabled={isSaving || !newApiKey.trim()}
>
{isSaving ? 'Saving...' : 'Save'}
</Button>
<Button
variant="outline"
onClick={() => {
setAddingProvider(null);
setNewApiKey('');
}}
disabled={isSaving}
>
Cancel
</Button>
</div>
<p className="text-xs text-foreground-tertiary mt-2">
Get your API key from{' '}
{provider === 'OPENAI' && (
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
platform.openai.com
</a>
)}
{provider === 'ANTHROPIC' && (
<a
href="https://console.anthropic.com/settings/keys"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
console.anthropic.com
</a>
)}
{provider === 'GOOGLE' && (
<a
href="https://aistudio.google.com/apikey"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
aistudio.google.com
</a>
)}
{provider === 'GROQ' && (
<a
href="https://console.groq.com/keys"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
console.groq.com
</a>
)}
{provider === 'MISTRAL' && (
<a
href="https://console.mistral.ai/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
console.mistral.ai
</a>
)}
</p>
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
}

View file

@ -26,6 +26,26 @@ const NAV_SECTIONS = [
{ id: 'passing-api-keys', label: 'Passing API Keys' },
],
},
{
title: 'AI Agents',
items: [
{ id: 'agents-overview', label: 'Overview' },
{ id: 'agents-api-keys', label: 'API Keys Setup' },
{ id: 'agents-creating', label: 'Creating Agents' },
{ id: 'agents-tools', label: 'Attaching Tools' },
{ id: 'agents-chat', label: 'Chat Interface' },
{ id: 'agents-api', label: 'Conversation API' },
],
},
{
title: 'MCP Collections',
items: [
{ id: 'mcp-overview', label: 'Overview' },
{ id: 'mcp-creating-collections', label: 'Creating Collections' },
{ id: 'mcp-connecting-clients', label: 'Connecting Clients' },
{ id: 'mcp-protocol', label: 'MCP Protocol' },
],
},
{
title: 'API Reference',
items: [
@ -596,6 +616,500 @@ const result = streamText({
</DocSubSection>
</DocSection>
{/* ==================== AI AGENTS ==================== */}
<DocSection id="agents-overview" title="AI Agents Overview">
<p className="text-foreground-secondary mb-6">
TPMJS Agents let you create custom AI assistants powered by any LLM provider. Build
agents with custom system prompts, attach tools from the registry, and have
persistent conversations through a streaming API.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<InfoCard icon="🤖" title="Multi-Provider">
Support for OpenAI, Anthropic, Google, Groq, and Mistral - bring your own API keys
</InfoCard>
<InfoCard icon="🔧" title="Tool Integration">
Attach individual tools or entire collections to give your agent capabilities
</InfoCard>
<InfoCard icon="💬" title="Persistent Conversations">
Full conversation history with streaming responses and tool call visualization
</InfoCard>
</div>
<p className="text-foreground-secondary">
Agents are user-owned and require authentication. Each agent gets a unique UID that
can be used in API calls.
</p>
<div className="mt-6">
<Link href="/dashboard/agents">
<Button variant="default">Go to Agents Dashboard</Button>
</Link>
</div>
</DocSection>
<DocSection id="agents-api-keys" title="API Keys Setup">
<p className="text-foreground-secondary mb-6">
Before creating agents, you need to add your AI provider API keys. Keys are
encrypted using AES-256 and stored securely.
</p>
<DocSubSection title="1. Navigate to API Keys Settings">
<p className="text-foreground-secondary mb-4">
Go to{' '}
<Link
href="/dashboard/settings/api-keys"
className="text-primary hover:underline"
>
Dashboard Settings API Keys
</Link>{' '}
to manage your provider keys.
</p>
</DocSubSection>
<DocSubSection title="2. Add Your Provider Keys">
<p className="text-foreground-secondary mb-4">
Click &quot;Add Key&quot; for each provider you want to use:
</p>
<div className="space-y-3">
<div className="p-3 border border-border rounded-lg bg-surface">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-foreground">OpenAI</span>
</div>
<p className="text-sm text-foreground-secondary">
GPT-4o, GPT-4 Turbo, GPT-3.5 Turbo -{' '}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Get key
</a>
</p>
</div>
<div className="p-3 border border-border rounded-lg bg-surface">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-foreground">Anthropic</span>
</div>
<p className="text-sm text-foreground-secondary">
Claude 3.5 Sonnet, Claude 3 Opus, Claude 3.5 Haiku -{' '}
<a
href="https://console.anthropic.com/settings/keys"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Get key
</a>
</p>
</div>
<div className="p-3 border border-border rounded-lg bg-surface">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-foreground">Google</span>
</div>
<p className="text-sm text-foreground-secondary">
Gemini 2.0 Flash, Gemini 1.5 Pro -{' '}
<a
href="https://aistudio.google.com/apikey"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Get key
</a>
</p>
</div>
<div className="p-3 border border-border rounded-lg bg-surface">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-foreground">Groq</span>
</div>
<p className="text-sm text-foreground-secondary">
Llama 3.3 70B, Llama 3.1 8B, Mixtral 8x7B -{' '}
<a
href="https://console.groq.com/keys"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Get key
</a>
</p>
</div>
<div className="p-3 border border-border rounded-lg bg-surface">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-foreground">Mistral</span>
</div>
<p className="text-sm text-foreground-secondary">
Mistral Large, Mistral Small -{' '}
<a
href="https://console.mistral.ai/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Get key
</a>
</p>
</div>
</div>
</DocSubSection>
<DocSubSection title="Security">
<div className="p-4 border border-primary/30 rounded-lg bg-primary/5">
<p className="text-sm text-foreground-secondary">
<strong className="text-foreground">🔐 Encryption:</strong> Your API keys are
encrypted using AES-256-GCM before being stored. Only you can use your keys, and
they&apos;re never exposed in API responses - only a hint of the last 4
characters is shown.
</p>
</div>
</DocSubSection>
</DocSection>
<DocSection id="agents-creating" title="Creating Agents">
<p className="text-foreground-secondary mb-6">
Create an agent to customize its behavior with a system prompt, choose the AI model,
and configure execution parameters.
</p>
<DocSubSection title="Basic Information">
<ParamTable
params={[
{
name: 'Name',
type: 'string',
required: true,
description: 'Display name for your agent (max 100 chars)',
},
{
name: 'UID',
type: 'string',
required: false,
description:
'URL-friendly identifier (auto-generated from name). Used in API calls.',
},
{
name: 'Description',
type: 'string',
required: false,
description: 'Brief description of what the agent does (max 500 chars)',
},
]}
/>
</DocSubSection>
<DocSubSection title="Model Configuration">
<ParamTable
params={[
{
name: 'Provider',
type: 'enum',
required: true,
description: 'AI provider (OpenAI, Anthropic, Google, Groq, Mistral)',
},
{
name: 'Model',
type: 'string',
required: true,
description: 'Specific model ID (e.g., gpt-4o, claude-sonnet-4-20250514)',
},
{
name: 'System Prompt',
type: 'string',
required: false,
description:
'Instructions that define how the agent behaves (max 10,000 chars)',
},
{
name: 'Temperature',
type: 'number',
required: false,
description:
'Response randomness (0 = deterministic, 2 = creative). Default: 0.7',
},
]}
/>
</DocSubSection>
<DocSubSection title="Execution Limits">
<ParamTable
params={[
{
name: 'Max Tool Calls',
type: 'number',
required: false,
description:
'Maximum tool calls per response turn. Prevents runaway loops. Default: 20',
},
{
name: 'Context Messages',
type: 'number',
required: false,
description:
'Number of recent messages included in context window. Default: 10',
},
]}
/>
</DocSubSection>
<DocSubSection title="Example System Prompt">
<CodeBlock
language="text"
code={`You are a helpful research assistant specializing in web scraping and data analysis.
When asked to research a topic:
1. Use available web scraping tools to gather information
2. Analyze and synthesize the data
3. Present findings in a clear, structured format
Always cite your sources and be transparent about limitations.`}
/>
</DocSubSection>
</DocSection>
<DocSection id="agents-tools" title="Attaching Tools">
<p className="text-foreground-secondary mb-6">
Give your agent capabilities by attaching tools from the TPMJS registry. You can
attach individual tools or entire collections.
</p>
<DocSubSection title="Adding Individual Tools">
<p className="text-foreground-secondary mb-4">
From your agent&apos;s detail page, use the &quot;Add Tool&quot; button to search
and attach specific tools from the registry. Each tool appears with its name,
description, and any required environment variables.
</p>
<CodeBlock
language="text"
code={`Example tools you might attach:
- @firecrawl/ai-sdk::scrapeTool - Web scraping
- @exalabs/ai-sdk::webSearch - Web search
- @tpmjs/hello::helloWorldTool - Simple test tool`}
/>
</DocSubSection>
<DocSubSection title="Adding Collections">
<p className="text-foreground-secondary mb-4">
Collections let you attach multiple related tools at once. If you&apos;ve created
MCP collections, you can attach the entire collection to your agent.
</p>
</DocSubSection>
<DocSubSection title="Tool Order">
<p className="text-foreground-secondary">
Tools are presented to the AI model in the order they appear. You can drag to
reorder tools to prioritize certain capabilities.
</p>
</DocSubSection>
<DocSubSection title="Required API Keys">
<div className="p-4 border border-border rounded-lg bg-surface">
<p className="text-sm text-foreground-secondary">
<strong className="text-foreground">Note:</strong> Some tools require API keys
(e.g., Firecrawl, Exa). You&apos;ll need to add these keys in your API Keys
settings. The required environment variables are shown on each tool&apos;s card.
</p>
</div>
</DocSubSection>
</DocSection>
<DocSection id="agents-chat" title="Chat Interface">
<p className="text-foreground-secondary mb-6">
Interact with your agents through the built-in chat interface with streaming
responses and tool call visualization.
</p>
<DocSubSection title="Starting a Conversation">
<p className="text-foreground-secondary mb-4">
Click &quot;Chat with Agent&quot; from your agent&apos;s detail page or navigate
directly to{' '}
<code className="text-primary bg-surface px-1.5 py-0.5 rounded">
/dashboard/agents/[id]/chat
</code>
.
</p>
</DocSubSection>
<DocSubSection title="Features">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InfoCard icon="💬" title="Conversation History">
Previous conversations appear in the sidebar. Click to resume any conversation.
</InfoCard>
<InfoCard icon="⚡" title="Streaming Responses">
Responses stream in real-time as the AI generates them.
</InfoCard>
<InfoCard icon="🔧" title="Tool Calls">
When the agent uses a tool, you&apos;ll see the tool name and can expand to view
parameters.
</InfoCard>
<InfoCard icon="📊" title="Token Usage">
Token counts are tracked and displayed for monitoring usage.
</InfoCard>
</div>
</DocSubSection>
<DocSubSection title="Keyboard Shortcuts">
<div className="space-y-2 text-foreground-secondary text-sm">
<p>
<code className="text-primary bg-surface px-1.5 py-0.5 rounded">Enter</code> -
Send message
</p>
<p>
<code className="text-primary bg-surface px-1.5 py-0.5 rounded">
Shift + Enter
</code>{' '}
- New line
</p>
</div>
</DocSubSection>
</DocSection>
<DocSection id="agents-api" title="Conversation API">
<p className="text-foreground-secondary mb-6">
Integrate agent conversations into your own applications using the streaming API.
</p>
<DocSubSection title="Endpoint">
<CodeBlock
language="text"
code="POST /api/agents/[uid]/conversation/[conversationId]"
/>
<div className="mt-4 space-y-2 text-foreground-secondary text-sm">
<p>
<strong className="text-foreground">uid:</strong> Your agent&apos;s unique
identifier
</p>
<p>
<strong className="text-foreground">conversationId:</strong> Unique ID for the
conversation (create your own or use a new ID to start a new conversation)
</p>
</div>
</DocSubSection>
<DocSubSection title="Request Body">
<ParamTable
params={[
{
name: 'message',
type: 'string',
required: true,
description: 'The user message to send to the agent',
},
{
name: 'env',
type: 'object',
required: false,
description: 'Additional environment variables for tool execution',
},
]}
/>
</DocSubSection>
<DocSubSection title="SSE Events">
<div className="space-y-4">
<div className="p-3 border border-border rounded-lg bg-surface">
<code className="text-primary font-mono">chunk</code>
<p className="text-sm text-foreground-secondary mt-1">
Streaming text content:{' '}
<code className="text-primary">{`{ "text": "..." }`}</code>
</p>
</div>
<div className="p-3 border border-border rounded-lg bg-surface">
<code className="text-primary font-mono">tool_call</code>
<p className="text-sm text-foreground-secondary mt-1">
Tool invocation:{' '}
<code className="text-primary">{`{ "toolCallId": "...", "toolName": "...", "input": {...} }`}</code>
</p>
</div>
<div className="p-3 border border-border rounded-lg bg-surface">
<code className="text-primary font-mono">tool_result</code>
<p className="text-sm text-foreground-secondary mt-1">
Tool result:{' '}
<code className="text-primary">{`{ "toolCallId": "...", "result": {...} }`}</code>
</p>
</div>
<div className="p-3 border border-border rounded-lg bg-surface">
<code className="text-primary font-mono">complete</code>
<p className="text-sm text-foreground-secondary mt-1">
Response complete:{' '}
<code className="text-primary">{`{ "conversationId": "...", "inputTokens": 150, "outputTokens": 300 }`}</code>
</p>
</div>
<div className="p-3 border border-border rounded-lg bg-surface">
<code className="text-primary font-mono">error</code>
<p className="text-sm text-foreground-secondary mt-1">
Error occurred: <code className="text-primary">{`{ "message": "..." }`}</code>
</p>
</div>
</div>
</DocSubSection>
<DocSubSection title="Example: JavaScript Client">
<CodeBlock
language="typescript"
code={`const conversationId = 'conv-' + Date.now();
const response = await fetch(
\`https://tpmjs.com/api/agents/\${agentUid}/conversation/\${conversationId}\`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'Search for AI tools' }),
}
);
const reader = response.body?.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\\n');
for (const line of lines) {
if (line.startsWith('event: ')) {
const event = line.slice(7);
console.log('Event:', event);
}
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));
if (event === 'chunk') {
process.stdout.write(data.text);
}
}
}
}`}
/>
</DocSubSection>
<DocSubSection title="Get Conversation History">
<CodeBlock
language="text"
code="GET /api/agents/[uid]/conversation/[conversationId]"
/>
<p className="text-foreground-secondary mt-4">
Returns the full conversation with all messages:
</p>
<CodeBlock
language="json"
code={`{
"success": true,
"data": {
"id": "...",
"slug": "conv-1234567890",
"title": "AI Tools Search",
"messages": [
{ "role": "USER", "content": "Search for AI tools" },
{ "role": "ASSISTANT", "content": "I found several..." }
]
}
}`}
/>
</DocSubSection>
<DocSubSection title="List All Conversations">
<CodeBlock language="text" code="GET /api/agents/[uid]/conversations" />
<p className="text-foreground-secondary mt-4">
Returns a list of all conversations for the agent:
</p>
<CodeBlock
language="json"
code={`{
"success": true,
"data": [
{
"id": "...",
"slug": "conv-1234567890",
"title": "AI Tools Search",
"messageCount": 5,
"updatedAt": "2024-12-15T10:30:00Z"
}
]
}`}
/>
</DocSubSection>
</DocSection>
{/* ==================== API REFERENCE ==================== */}
<DocSection id="api-overview" title="API Overview">
<p className="text-foreground-secondary mb-6">
@ -1110,6 +1624,285 @@ export TPMJS_EXECUTOR_URL=https://executor.mycompany.com`}
</div>
</DocSection>
{/* ==================== MCP COLLECTIONS ==================== */}
<DocSection id="mcp-overview" title="MCP Overview">
<p className="text-foreground-secondary mb-6">
The Model Context Protocol (MCP) allows AI assistants like Claude Desktop, Cursor,
and other clients to connect directly to your TPMJS tool collections. Instead of
using the SDK, MCP clients can call your tools natively through the protocol.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<InfoCard icon="🔗" title="Direct Connection">
Connect Claude Desktop or Cursor directly to your tools via MCP
</InfoCard>
<InfoCard icon="📦" title="Curated Collections">
Group related tools into collections for focused use cases
</InfoCard>
<InfoCard icon="⚡" title="Native Integration">
No SDK neededtools appear as native capabilities in your AI client
</InfoCard>
</div>
<p className="text-foreground-secondary">
Each collection gets a unique MCP endpoint URL that any MCP-compatible client can
connect to. Tools in your collection are automatically exposed with proper schemas
and descriptions.
</p>
</DocSection>
<DocSection id="mcp-creating-collections" title="Creating Collections">
<p className="text-foreground-secondary mb-6">
Collections let you group related tools together. Create a collection in the
dashboard, then add tools from the registry.
</p>
<DocSubSection title="1. Sign In">
<p className="text-foreground-secondary mb-4">
Sign in to{' '}
<a href="https://tpmjs.com" className="text-primary hover:underline">
tpmjs.com
</a>{' '}
using GitHub authentication. This allows you to create and manage collections.
</p>
</DocSubSection>
<DocSubSection title="2. Create a Collection">
<p className="text-foreground-secondary mb-4">
Navigate to your dashboard and click &quot;Create Collection&quot;. Give it a
descriptive name like &quot;Web Scraping Tools&quot; or &quot;Content
Creation&quot;.
</p>
</DocSubSection>
<DocSubSection title="3. Add Tools">
<p className="text-foreground-secondary mb-4">
Browse the tool registry and add tools to your collection. You can add any
published TPMJS tool. Each collection can contain multiple tools from different
packages.
</p>
</DocSubSection>
<DocSubSection title="4. Get Your MCP URL">
<p className="text-foreground-secondary mb-4">
Once your collection has tools, you&apos;ll see an MCP endpoint URL in the format:
</p>
<CodeBlock
language="text"
code="https://tpmjs.com/api/collections/<collection-id>/mcp/http"
/>
<p className="text-foreground-secondary mt-4">
This URL is what you&apos;ll use to connect MCP clients to your collection.
</p>
</DocSubSection>
</DocSection>
<DocSection id="mcp-connecting-clients" title="Connecting Clients">
<p className="text-foreground-secondary mb-6">
Connect your TPMJS collection to any MCP-compatible client. Below are examples for
popular clients.
</p>
<DocSubSection title="Claude Desktop">
<p className="text-foreground-secondary mb-4">
Add your collection to Claude Desktop&apos;s configuration file:
</p>
<CodeBlock
language="json"
code={`{
"mcpServers": {
"tpmjs-my-collection": {
"command": "npx",
"args": [
"mcp-remote",
"https://tpmjs.com/api/collections/<collection-id>/mcp/http"
]
}
}
}`}
/>
<p className="text-foreground-secondary mt-4">
<strong className="text-foreground">Config file location:</strong>
</p>
<ul className="list-disc list-inside space-y-1 text-foreground-secondary text-sm mt-2">
<li>
<strong>macOS:</strong>{' '}
<code className="text-primary">
~/Library/Application Support/Claude/claude_desktop_config.json
</code>
</li>
<li>
<strong>Windows:</strong>{' '}
<code className="text-primary">
%APPDATA%\Claude\claude_desktop_config.json
</code>
</li>
</ul>
</DocSubSection>
<DocSubSection title="Claude Code CLI">
<p className="text-foreground-secondary mb-4">
Add an MCP server directly from the command line:
</p>
<CodeBlock
language="bash"
code={`claude mcp add tpmjs-my-collection \\
-- npx mcp-remote https://tpmjs.com/api/collections/<collection-id>/mcp/http`}
/>
<p className="text-foreground-secondary mt-4">
This automatically adds the server to your Claude Code configuration.
</p>
</DocSubSection>
<DocSubSection title="Cursor">
<p className="text-foreground-secondary mb-4">
Cursor supports MCP servers through its settings. Add your collection URL in the
MCP configuration section of Cursor&apos;s preferences.
</p>
</DocSubSection>
<DocSubSection title="Other Clients">
<p className="text-foreground-secondary">
Any client that supports the Model Context Protocol can connect using your
collection&apos;s HTTP endpoint. Use the{' '}
<code className="text-primary">mcp-remote</code> package to bridge HTTP MCP
servers to clients that expect stdio-based servers.
</p>
</DocSubSection>
</DocSection>
<DocSection id="mcp-protocol" title="MCP Protocol">
<p className="text-foreground-secondary mb-6">
TPMJS implements the Model Context Protocol (MCP) using JSON-RPC 2.0 over HTTP. This
section covers the technical details for advanced users and client developers.
</p>
<DocSubSection title="Endpoint Format">
<CodeBlock
language="text"
code="https://tpmjs.com/api/collections/<collection-id>/mcp/<transport>"
/>
<div className="mt-4 space-y-2 text-foreground-secondary text-sm">
<p>
<strong className="text-foreground">Transport options:</strong>
</p>
<ul className="list-disc list-inside ml-4">
<li>
<code className="text-primary">http</code> - Streamable HTTP transport
(recommended)
</li>
<li>
<code className="text-primary">sse</code> - Server-Sent Events transport
</li>
</ul>
</div>
</DocSubSection>
<DocSubSection title="Supported Methods">
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg bg-surface">
<code className="text-primary font-mono">initialize</code>
<p className="text-sm text-foreground-secondary mt-2">
Initialize the MCP session. Returns server info and capabilities.
</p>
<CodeBlock
language="json"
code={`{
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"clientInfo": { "name": "my-client", "version": "1.0.0" }
},
"id": 1
}`}
/>
</div>
<div className="p-4 border border-border rounded-lg bg-surface">
<code className="text-primary font-mono">tools/list</code>
<p className="text-sm text-foreground-secondary mt-2">
List all tools in the collection with their schemas.
</p>
<CodeBlock
language="json"
code={`{
"jsonrpc": "2.0",
"method": "tools/list",
"params": {},
"id": 2
}`}
/>
</div>
<div className="p-4 border border-border rounded-lg bg-surface">
<code className="text-primary font-mono">tools/call</code>
<p className="text-sm text-foreground-secondary mt-2">
Execute a tool with the given arguments.
</p>
<CodeBlock
language="json"
code={`{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "tpmjs-hello--helloWorldTool",
"arguments": { "name": "World" }
},
"id": 3
}`}
/>
</div>
</div>
</DocSubSection>
<DocSubSection title="Tool Name Format">
<p className="text-foreground-secondary mb-4">
Tool names in MCP follow a sanitized format to comply with MCP naming
requirements:
</p>
<CodeBlock
language="text"
code={`Package: @tpmjs/hello
Tool: helloWorldTool
MCP Name: tpmjs-hello--helloWorldTool
Format: <sanitized-package>--<tool-name>
- @ prefix is removed
- / becomes -
- -- separates package from tool name`}
/>
</DocSubSection>
<DocSubSection title="Example Response">
<p className="text-foreground-secondary mb-4">
Here&apos;s an example response from{' '}
<code className="text-primary">tools/call</code>:
</p>
<CodeBlock
language="json"
code={`{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{
"type": "text",
"text": "Hello, World!"
}
]
}
}`}
/>
</DocSubSection>
<DocSubSection title="Testing with curl">
<p className="text-foreground-secondary mb-4">
You can test your MCP endpoint directly with curl:
</p>
<CodeBlock
language="bash"
code={`# Initialize the session
curl -X POST https://tpmjs.com/api/collections/<id>/mcp/http \\
-H "Content-Type: application/json" \\
-d '{"jsonrpc":"2.0","method":"initialize","params":{},"id":1}'
# List available tools
curl -X POST https://tpmjs.com/api/collections/<id>/mcp/http \\
-H "Content-Type: application/json" \\
-d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":2}'
# Call a tool
curl -X POST https://tpmjs.com/api/collections/<id>/mcp/http \\
-H "Content-Type: application/json" \\
-d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"tpmjs-hello--helloWorldTool","arguments":{}},"id":3}'`}
/>
</DocSubSection>
</DocSection>
{/* ==================== RESOURCES ==================== */}
<DocSection id="faq" title="FAQ">
<div className="space-y-6">

View file

@ -37,6 +37,11 @@ export function AppHeader(): React.ReactElement {
Tools
</Button>
</Link>
<Link href="/dashboard/agents">
<Button variant="ghost" size="sm" className="text-foreground hover:text-foreground">
Agents
</Button>
</Link>
<Link href="/docs">
<Button variant="ghost" size="sm" className="text-foreground hover:text-foreground">
Docs

View file

@ -13,6 +13,7 @@ interface MobileMenuProps {
const navLinks = [
{ href: '/tool/tool-search', label: 'Tools' },
{ href: '/dashboard/agents', label: 'Agents' },
{ href: '/docs', label: 'Docs' },
{ href: '/how-it-works', label: 'How It Works' },
{ href: '/integrations', label: 'Integrations' },

View file

@ -0,0 +1,171 @@
/**
* Build AI SDK tools from an agent's collections and individual tools
*/
import type { Agent, AgentCollection, AgentTool, Collection, Package, Tool } from '@tpmjs/db';
import { prisma } from '@tpmjs/db';
import { createToolDefinition } from '../ai-agent/tool-executor-agent';
type AgentWithRelations = Agent & {
collections: (AgentCollection & {
collection: Collection & {
tools: Array<{
tool: Tool & { package: Package };
}>;
};
})[];
tools: (AgentTool & {
tool: Tool & { package: Package };
})[];
};
/**
* Fetch a full agent with all tool relations
*/
export async function fetchAgentWithTools(agentId: string): Promise<AgentWithRelations | null> {
return prisma.agent.findUnique({
where: { id: agentId },
include: {
collections: {
include: {
collection: {
include: {
tools: {
include: {
tool: {
include: { package: true },
},
},
orderBy: { position: 'asc' },
},
},
},
},
orderBy: { position: 'asc' },
},
tools: {
include: {
tool: {
include: { package: true },
},
},
orderBy: { position: 'asc' },
},
},
});
}
/**
* Fetch an agent by UID with all tool relations
*/
export async function fetchAgentByUidWithTools(uid: string): Promise<AgentWithRelations | null> {
return prisma.agent.findUnique({
where: { uid },
include: {
collections: {
include: {
collection: {
include: {
tools: {
include: {
tool: {
include: { package: true },
},
},
orderBy: { position: 'asc' },
},
},
},
},
orderBy: { position: 'asc' },
},
tools: {
include: {
tool: {
include: { package: true },
},
},
orderBy: { position: 'asc' },
},
},
});
}
/**
* Sanitize npm package name to valid tool name
*/
function sanitizeToolName(name: string): string {
return name.replace(/[@/]/g, '-').replace(/^-+/, '');
}
/**
* Build all tools from an agent's collections and individual tools
* Returns a map of tool name -> AI SDK tool definition
*/
export function buildAgentTools(
agent: AgentWithRelations
): Record<string, ReturnType<typeof createToolDefinition>> {
const tools: Record<string, ReturnType<typeof createToolDefinition>> = {};
const seenTools = new Set<string>();
// Add tools from collections first
for (const agentCollection of agent.collections) {
for (const collectionTool of agentCollection.collection.tools) {
const tool = collectionTool.tool;
const toolKey = `${tool.package.npmPackageName}::${tool.name}`;
// Avoid duplicates
if (seenTools.has(toolKey)) continue;
seenTools.add(toolKey);
const toolName = sanitizeToolName(`${tool.package.npmPackageName}-${tool.name}`);
tools[toolName] = createToolDefinition(tool);
}
}
// Add individual tools (may override collection tools)
for (const agentTool of agent.tools) {
const tool = agentTool.tool;
const toolKey = `${tool.package.npmPackageName}::${tool.name}`;
// Skip if already added from collections
if (seenTools.has(toolKey)) continue;
seenTools.add(toolKey);
const toolName = sanitizeToolName(`${tool.package.npmPackageName}-${tool.name}`);
tools[toolName] = createToolDefinition(tool);
}
return tools;
}
/**
* Get list of tool names for an agent (for display)
*/
export function getAgentToolNames(agent: AgentWithRelations): string[] {
const names: string[] = [];
const seenTools = new Set<string>();
for (const agentCollection of agent.collections) {
for (const collectionTool of agentCollection.collection.tools) {
const tool = collectionTool.tool;
const toolKey = `${tool.package.npmPackageName}::${tool.name}`;
if (!seenTools.has(toolKey)) {
seenTools.add(toolKey);
names.push(sanitizeToolName(`${tool.package.npmPackageName}-${tool.name}`));
}
}
}
for (const agentTool of agent.tools) {
const tool = agentTool.tool;
const toolKey = `${tool.package.npmPackageName}::${tool.name}`;
if (!seenTools.has(toolKey)) {
seenTools.add(toolKey);
names.push(sanitizeToolName(`${tool.package.npmPackageName}-${tool.name}`));
}
}
return names;
}

View file

@ -0,0 +1,70 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
const ALGORITHM = 'aes-256-gcm';
function getEncryptionKey(): Buffer {
const secret = process.env.API_KEY_ENCRYPTION_SECRET;
if (!secret) {
throw new Error('API_KEY_ENCRYPTION_SECRET environment variable is not set');
}
return createHash('sha256').update(secret).digest();
}
/**
* Encrypts an API key using AES-256-GCM
*/
export function encryptApiKey(apiKey: string): { encrypted: string; iv: string } {
const key = getEncryptionKey();
const iv = randomBytes(16);
const cipher = createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(apiKey, 'utf8', 'hex');
encrypted += cipher.final('hex');
// Append the auth tag to the encrypted data
const authTag = cipher.getAuthTag().toString('hex');
encrypted += authTag;
return {
encrypted,
iv: iv.toString('hex'),
};
}
/**
* Decrypts an API key using AES-256-GCM
*/
export function decryptApiKey(encrypted: string, iv: string): string {
const key = getEncryptionKey();
// Extract the auth tag (last 32 hex chars = 16 bytes)
const authTag = Buffer.from(encrypted.slice(-32), 'hex');
const encryptedData = encrypted.slice(0, -32);
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'hex'));
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
/**
* Creates a masked version of an API key for display (e.g., "sk-...XXXX")
*/
export function maskApiKey(apiKey: string): string {
if (apiKey.length <= 8) return '****';
const prefix = apiKey.slice(0, 4);
const suffix = apiKey.slice(-4);
return `${prefix}...${suffix}`;
}
/**
* Gets the last 4 characters of an API key for identification
*/
export function getKeyHint(apiKey: string): string {
if (apiKey.length < 4) return apiKey;
return apiKey.slice(-4);
}

View file

@ -96,6 +96,7 @@ model Tool {
simulations Simulation[]
healthChecks HealthCheck[]
collections CollectionTool[]
agents AgentTool[]
@@unique([packageId, name])
@@index([qualityScore])
@ -333,6 +334,8 @@ model User {
sessions Session[]
accounts Account[]
collections Collection[]
agents Agent[]
apiKeys UserApiKey[]
@@map("users")
}
@ -412,6 +415,7 @@ model Collection {
// Relations
tools CollectionTool[]
agents AgentCollection[]
// Unique constraint: user can't have duplicate collection names
@@unique([userId, name])
@ -446,3 +450,193 @@ model CollectionTool {
@@index([toolId])
@@map("collection_tools")
}
// ============================================================================
// Agent Models
// ============================================================================
/// AI Provider enum - supported LLM providers
enum AIProvider {
OPENAI
ANTHROPIC
GOOGLE
GROQ
MISTRAL
}
/// Message role enum - conversation message types
enum MessageRole {
USER
ASSISTANT
TOOL
SYSTEM
}
/// Agent - user-owned AI agent configurations
model Agent {
id String @id @default(cuid())
// Owner relationship
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Agent identity
uid String @unique @db.VarChar(50) // URL-friendly identifier
name String @db.VarChar(100)
description String? @db.VarChar(500)
// Model configuration
provider AIProvider
modelId String @map("model_id") @db.VarChar(100)
systemPrompt String? @map("system_prompt") @db.Text
temperature Float @default(0.7)
maxToolCallsPerTurn Int @default(20) @map("max_tool_calls_per_turn")
maxMessagesInContext Int @default(10) @map("max_messages_in_context")
// Visibility
isPublic Boolean @default(false) @map("is_public")
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
collections AgentCollection[]
tools AgentTool[]
conversations Conversation[]
@@unique([userId, name])
@@index([userId])
@@index([uid])
@@index([isPublic])
@@index([createdAt])
@@map("agents")
}
/// AgentCollection - junction table for Agent -> Collection (many-to-many)
model AgentCollection {
id String @id @default(cuid())
// Relationships
agentId String @map("agent_id")
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
collectionId String @map("collection_id")
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
// Ordering
position Int @default(0)
// Timestamps
addedAt DateTime @default(now()) @map("added_at")
@@unique([agentId, collectionId])
@@index([agentId])
@@index([collectionId])
@@map("agent_collections")
}
/// AgentTool - junction table for Agent -> Tool (many-to-many)
model AgentTool {
id String @id @default(cuid())
// Relationships
agentId String @map("agent_id")
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
toolId String @map("tool_id")
tool Tool @relation(fields: [toolId], references: [id], onDelete: Cascade)
// Ordering
position Int @default(0)
// Timestamps
addedAt DateTime @default(now()) @map("added_at")
@@unique([agentId, toolId])
@@index([agentId])
@@index([toolId])
@@map("agent_tools")
}
/// UserApiKey - encrypted API keys per user per provider
model UserApiKey {
id String @id @default(cuid())
// Owner relationship
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Provider identification
provider AIProvider
// Encrypted key storage
encryptedKey String @map("encrypted_key") @db.Text
keyIv String @map("key_iv") @db.VarChar(32)
keyHint String? @map("key_hint") @db.VarChar(10) // Last 4 chars
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([userId, provider])
@@index([userId])
@@map("user_api_keys")
}
/// Conversation - chat session with an agent
model Conversation {
id String @id @default(cuid())
// Agent relationship
agentId String @map("agent_id")
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
// Conversation identity
slug String @db.VarChar(100) // User-chosen unique ID
// Metadata
title String? @db.VarChar(200)
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
messages Message[]
@@unique([agentId, slug])
@@index([agentId])
@@index([createdAt])
@@map("conversations")
}
/// Message - individual message in a conversation
model Message {
id String @id @default(cuid())
// Conversation relationship
conversationId String @map("conversation_id")
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
// Message content
role MessageRole
content String @db.Text
// Tool call metadata (for role=ASSISTANT with tool calls)
toolCalls Json? @map("tool_calls") @db.JsonB
// Tool result metadata (for role=TOOL)
toolCallId String? @map("tool_call_id") @db.VarChar(100)
toolName String? @map("tool_name") @db.VarChar(200)
toolResult Json? @map("tool_result") @db.JsonB
// Token tracking
inputTokens Int? @map("input_tokens")
outputTokens Int? @map("output_tokens")
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
@@index([conversationId])
@@index([createdAt])
@@map("messages")
}

View file

@ -28,6 +28,10 @@
"./collection": {
"types": "./dist/collection.d.ts",
"default": "./dist/collection.js"
},
"./agent": {
"types": "./dist/agent.d.ts",
"default": "./dist/agent.js"
}
},
"files": ["dist"],

235
packages/types/src/agent.ts Normal file
View file

@ -0,0 +1,235 @@
import { z } from 'zod';
// Regex for valid agent UID: lowercase alphanumeric and hyphens
const UID_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
// ============================================================================
// Enums
// ============================================================================
export const AIProviderSchema = z.enum(['OPENAI', 'ANTHROPIC', 'GOOGLE', 'GROQ', 'MISTRAL']);
export const MessageRoleSchema = z.enum(['USER', 'ASSISTANT', 'TOOL', 'SYSTEM']);
export type AIProvider = z.infer<typeof AIProviderSchema>;
export type MessageRole = z.infer<typeof MessageRoleSchema>;
// ============================================================================
// Agent Schemas
// ============================================================================
export const CreateAgentSchema = z.object({
name: z.string().min(1, 'Name is required').max(100, 'Name must be 100 characters or less'),
uid: z
.string()
.min(1, 'UID is required')
.max(50, 'UID must be 50 characters or less')
.regex(UID_REGEX, 'UID must be lowercase alphanumeric with hyphens')
.optional(),
description: z.string().max(500, 'Description must be 500 characters or less').optional(),
provider: AIProviderSchema,
modelId: z.string().min(1, 'Model ID is required').max(100),
systemPrompt: z.string().max(10000, 'System prompt must be 10,000 characters or less').optional(),
temperature: z.number().min(0).max(2).default(0.7),
maxToolCallsPerTurn: z.number().int().min(1).max(100).default(20),
maxMessagesInContext: z.number().int().min(1).max(100).default(10),
isPublic: z.boolean().default(false),
collectionIds: z.array(z.string()).optional(),
toolIds: z.array(z.string()).optional(),
});
export const UpdateAgentSchema = z.object({
name: z.string().min(1).max(100).optional(),
uid: z.string().min(1).max(50).regex(UID_REGEX).optional(),
description: z.string().max(500).nullable().optional(),
provider: AIProviderSchema.optional(),
modelId: z.string().min(1).max(100).optional(),
systemPrompt: z.string().max(10000).nullable().optional(),
temperature: z.number().min(0).max(2).optional(),
maxToolCallsPerTurn: z.number().int().min(1).max(100).optional(),
maxMessagesInContext: z.number().int().min(1).max(100).optional(),
isPublic: z.boolean().optional(),
});
export const AddCollectionToAgentSchema = z.object({
collectionId: z.string().min(1, 'Collection ID is required'),
position: z.number().int().min(0).optional(),
});
export const AddToolToAgentSchema = z.object({
toolId: z.string().min(1, 'Tool ID is required'),
position: z.number().int().min(0).optional(),
});
// ============================================================================
// User API Key Schemas
// ============================================================================
export const SUPPORTED_PROVIDERS = ['OPENAI', 'ANTHROPIC', 'GOOGLE', 'GROQ', 'MISTRAL'] as const;
export const AddApiKeySchema = z.object({
provider: AIProviderSchema,
apiKey: z.string().min(10, 'API key is required'),
});
export const ApiKeyInfoSchema = z.object({
provider: AIProviderSchema,
keyHint: z.string().nullable(),
createdAt: z.date(),
updatedAt: z.date(),
});
// ============================================================================
// Conversation Schemas
// ============================================================================
export const CreateConversationSchema = z.object({
slug: z
.string()
.min(1)
.max(100)
.regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens')
.optional(),
title: z.string().max(200).optional(),
});
export const SendMessageSchema = z.object({
message: z.string().min(1, 'Message is required').max(50000, 'Message too long'),
env: z.record(z.string(), z.string()).optional(),
});
// ============================================================================
// Message Schemas
// ============================================================================
export const ToolCallSchema = z.object({
id: z.string(),
name: z.string(),
arguments: z.string(),
});
export const MessageSchema = z.object({
id: z.string(),
role: MessageRoleSchema,
content: z.string(),
toolCalls: z.array(ToolCallSchema).nullable(),
toolCallId: z.string().nullable(),
toolName: z.string().nullable(),
toolResult: z.any().nullable(),
inputTokens: z.number().nullable(),
outputTokens: z.number().nullable(),
createdAt: z.date(),
});
// ============================================================================
// Response Types
// ============================================================================
export const AgentSchema = z.object({
id: z.string(),
uid: z.string(),
name: z.string(),
description: z.string().nullable(),
provider: AIProviderSchema,
modelId: z.string(),
systemPrompt: z.string().nullable(),
temperature: z.number(),
maxToolCallsPerTurn: z.number(),
maxMessagesInContext: z.number(),
isPublic: z.boolean(),
toolCount: z.number(),
collectionCount: z.number(),
createdAt: z.date(),
updatedAt: z.date(),
});
export const ConversationSchema = z.object({
id: z.string(),
agentId: z.string(),
slug: z.string(),
title: z.string().nullable(),
createdAt: z.date(),
updatedAt: z.date(),
messageCount: z.number().optional(),
});
export const ConversationWithMessagesSchema = ConversationSchema.extend({
messages: z.array(MessageSchema),
});
// ============================================================================
// Type Exports
// ============================================================================
export type CreateAgentInput = z.infer<typeof CreateAgentSchema>;
export type UpdateAgentInput = z.infer<typeof UpdateAgentSchema>;
export type AddCollectionToAgentInput = z.infer<typeof AddCollectionToAgentSchema>;
export type AddToolToAgentInput = z.infer<typeof AddToolToAgentSchema>;
export type AddApiKeyInput = z.infer<typeof AddApiKeySchema>;
export type ApiKeyInfo = z.infer<typeof ApiKeyInfoSchema>;
export type CreateConversationInput = z.infer<typeof CreateConversationSchema>;
export type SendMessageInput = z.infer<typeof SendMessageSchema>;
export type ToolCall = z.infer<typeof ToolCallSchema>;
export type Message = z.infer<typeof MessageSchema>;
export type Agent = z.infer<typeof AgentSchema>;
export type Conversation = z.infer<typeof ConversationSchema>;
export type ConversationWithMessages = z.infer<typeof ConversationWithMessagesSchema>;
// ============================================================================
// Constants
// ============================================================================
export const AGENT_LIMITS = {
MAX_AGENTS_PER_USER: 20,
MAX_COLLECTIONS_PER_AGENT: 10,
MAX_TOOLS_PER_AGENT: 50,
MAX_SYSTEM_PROMPT_LENGTH: 10000,
MAX_NAME_LENGTH: 100,
MAX_DESCRIPTION_LENGTH: 500,
MAX_UID_LENGTH: 50,
} as const;
export const CONVERSATION_LIMITS = {
MAX_CONVERSATIONS_PER_AGENT: 100,
MAX_MESSAGE_LENGTH: 50000,
MAX_TITLE_LENGTH: 200,
MAX_SLUG_LENGTH: 100,
} as const;
// ============================================================================
// Provider Model Mappings
// ============================================================================
export const PROVIDER_MODELS = {
OPENAI: [
{ id: 'gpt-4o', name: 'GPT-4o', contextWindow: 128000 },
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini', contextWindow: 128000 },
{ id: 'gpt-4-turbo', name: 'GPT-4 Turbo', contextWindow: 128000 },
{ id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo', contextWindow: 16385 },
],
ANTHROPIC: [
{ id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet', contextWindow: 200000 },
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku', contextWindow: 200000 },
{ id: 'claude-3-opus-20240229', name: 'Claude 3 Opus', contextWindow: 200000 },
],
GOOGLE: [
{ id: 'gemini-2.0-flash-exp', name: 'Gemini 2.0 Flash', contextWindow: 1000000 },
{ id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro', contextWindow: 1000000 },
{ id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash', contextWindow: 1000000 },
],
GROQ: [
{ id: 'llama-3.3-70b-versatile', name: 'Llama 3.3 70B', contextWindow: 131072 },
{ id: 'llama-3.1-8b-instant', name: 'Llama 3.1 8B', contextWindow: 131072 },
{ id: 'mixtral-8x7b-32768', name: 'Mixtral 8x7B', contextWindow: 32768 },
],
MISTRAL: [
{ id: 'mistral-large-latest', name: 'Mistral Large', contextWindow: 128000 },
{ id: 'mistral-small-latest', name: 'Mistral Small', contextWindow: 128000 },
],
} as const;
export type ProviderModel = {
id: string;
name: string;
contextWindow: number;
};

View file

@ -1,7 +1,7 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/tool.ts', 'src/registry.ts', 'src/tpmjs.ts', 'src/collection.ts'],
entry: ['src/tool.ts', 'src/registry.ts', 'src/tpmjs.ts', 'src/collection.ts', 'src/agent.ts'],
format: ['esm'],
dts: true,
clean: true,

View file

@ -84,6 +84,30 @@ export const icons = {
viewBox: '0 0 24 24',
path: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z',
},
terminal: {
viewBox: '0 0 24 24',
path: 'M20 19V7c0-1.1-.9-2-2-2H6c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2zM7.5 12.79l1.41-1.41L11.08 13.55l-2.17 2.17-1.41-1.41.76-.76-.76-.76zm9 4.71h-6v-1.5h6v1.5z',
},
puzzle: {
viewBox: '0 0 24 24',
path: 'M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z',
},
message: {
viewBox: '0 0 24 24',
path: 'M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z',
},
key: {
viewBox: '0 0 24 24',
path: 'M12.65 10C11.83 7.67 9.61 6 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6c2.61 0 4.83-1.67 5.65-4H17v4h4v-4h2v-4H12.65zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z',
},
info: {
viewBox: '0 0 24 24',
path: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z',
},
send: {
viewBox: '0 0 24 24',
path: 'M2.01 21L23 12 2.01 3 2 10l15 2-15 2z',
},
} as const;
export type IconName = keyof typeof icons;

55
pnpm-lock.yaml generated
View file

@ -223,6 +223,18 @@ importers:
apps/web:
dependencies:
'@ai-sdk/anthropic':
specifier: ^3.0.2
version: 3.0.2(zod@4.1.13)
'@ai-sdk/google':
specifier: ^3.0.2
version: 3.0.2(zod@4.1.13)
'@ai-sdk/groq':
specifier: ^3.0.2
version: 3.0.2(zod@4.1.13)
'@ai-sdk/mistral':
specifier: ^3.0.2
version: 3.0.2(zod@4.1.13)
'@ai-sdk/openai':
specifier: 3.0.1
version: 3.0.1(zod@4.1.13)
@ -3613,6 +3625,18 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/groq@3.0.2':
resolution: {integrity: sha512-Gs7Ir9cUSYlbDIArNMt3+0Ql+OrEKELQhYfji5CCxQ8MdcJGbhbyPf9AQralu9PMxq/QEy2JSOgYW5zOnHDd2g==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/mistral@3.0.2':
resolution: {integrity: sha512-I2wPbhh3uzSqPA/OdU+PNOT5RZMhnJz86wHhMq8KEDSQEo6kDt82X5OlpL3LFdG0wlPYfZZnWhKpDFn7lcy/2Q==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/openai@3.0.1':
resolution: {integrity: sha512-P+qxz2diOrh8OrpqLRg+E+XIFVIKM3z2kFjABcCJGHjGbXBK88AJqmuKAi87qLTvTe/xn1fhZBjklZg9bTyigw==}
engines: {node: '>=18'}
@ -11036,6 +11060,12 @@ snapshots:
'@ai-sdk/provider-utils': 4.0.2(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/anthropic@3.0.2(zod@4.1.13)':
dependencies:
'@ai-sdk/provider': 3.0.1
'@ai-sdk/provider-utils': 4.0.2(zod@4.1.13)
zod: 4.1.13
'@ai-sdk/gateway@2.0.0-beta.68(effect@3.18.4)(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.0-beta.22
@ -11096,6 +11126,24 @@ snapshots:
'@ai-sdk/provider-utils': 4.0.2(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/google@3.0.2(zod@4.1.13)':
dependencies:
'@ai-sdk/provider': 3.0.1
'@ai-sdk/provider-utils': 4.0.2(zod@4.1.13)
zod: 4.1.13
'@ai-sdk/groq@3.0.2(zod@4.1.13)':
dependencies:
'@ai-sdk/provider': 3.0.1
'@ai-sdk/provider-utils': 4.0.2(zod@4.1.13)
zod: 4.1.13
'@ai-sdk/mistral@3.0.2(zod@4.1.13)':
dependencies:
'@ai-sdk/provider': 3.0.1
'@ai-sdk/provider-utils': 4.0.2(zod@4.1.13)
zod: 4.1.13
'@ai-sdk/openai@3.0.1(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.0
@ -11170,6 +11218,13 @@ snapshots:
eventsource-parser: 3.0.6
zod: 3.25.76
'@ai-sdk/provider-utils@4.0.2(zod@4.1.13)':
dependencies:
'@ai-sdk/provider': 3.0.1
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.0.6
zod: 4.1.13
'@ai-sdk/provider@1.1.3':
dependencies:
json-schema: 0.4.0