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:
parent
4cbd84edb8
commit
552f319583
28 changed files with 5082 additions and 2 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
140
apps/web/src/app/api/agents/[id]/collections/route.ts
Normal file
140
apps/web/src/app/api/agents/[id]/collections/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
243
apps/web/src/app/api/agents/[id]/route.ts
Normal file
243
apps/web/src/app/api/agents/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
52
apps/web/src/app/api/agents/[id]/tools/[toolId]/route.ts
Normal file
52
apps/web/src/app/api/agents/[id]/tools/[toolId]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
125
apps/web/src/app/api/agents/[id]/tools/route.ts
Normal file
125
apps/web/src/app/api/agents/[id]/tools/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
79
apps/web/src/app/api/agents/[uid]/conversations/route.ts
Normal file
79
apps/web/src/app/api/agents/[uid]/conversations/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
226
apps/web/src/app/api/agents/route.ts
Normal file
226
apps/web/src/app/api/agents/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
53
apps/web/src/app/api/user/api-keys/[provider]/route.ts
Normal file
53
apps/web/src/app/api/user/api-keys/[provider]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
103
apps/web/src/app/api/user/api-keys/route.ts
Normal file
103
apps/web/src/app/api/user/api-keys/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
456
apps/web/src/app/dashboard/agents/[id]/chat/page.tsx
Normal file
456
apps/web/src/app/dashboard/agents/[id]/chat/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
535
apps/web/src/app/dashboard/agents/[id]/page.tsx
Normal file
535
apps/web/src/app/dashboard/agents/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
378
apps/web/src/app/dashboard/agents/new/page.tsx
Normal file
378
apps/web/src/app/dashboard/agents/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
231
apps/web/src/app/dashboard/agents/page.tsx
Normal file
231
apps/web/src/app/dashboard/agents/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
364
apps/web/src/app/dashboard/settings/api-keys/page.tsx
Normal file
364
apps/web/src/app/dashboard/settings/api-keys/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 "Add Key" 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'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's detail page, use the "Add Tool" 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'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'll need to add these keys in your API Keys
|
||||
settings. The required environment variables are shown on each tool'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 "Chat with Agent" from your agent'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'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'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 needed—tools 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 "Create Collection". Give it a
|
||||
descriptive name like "Web Scraping Tools" or "Content
|
||||
Creation".
|
||||
</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'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'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'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'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'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'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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
171
apps/web/src/lib/agents/build-tools.ts
Normal file
171
apps/web/src/lib/agents/build-tools.ts
Normal 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;
|
||||
}
|
||||
70
apps/web/src/lib/crypto/api-keys.ts
Normal file
70
apps/web/src/lib/crypto/api-keys.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
235
packages/types/src/agent.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
55
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue