diff --git a/apps/web/package.json b/apps/web/package.json index 3e48181..36ee060 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/app/api/agents/[id]/collections/[collectionId]/route.ts b/apps/web/src/app/api/agents/[id]/collections/[collectionId]/route.ts new file mode 100644 index 0000000..c35bc2d --- /dev/null +++ b/apps/web/src/app/api/agents/[id]/collections/[collectionId]/route.ts @@ -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 { + 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 } + ); + } +} diff --git a/apps/web/src/app/api/agents/[id]/collections/route.ts b/apps/web/src/app/api/agents/[id]/collections/route.ts new file mode 100644 index 0000000..9052729 --- /dev/null +++ b/apps/web/src/app/api/agents/[id]/collections/route.ts @@ -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 { + 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 } + ); + } +} diff --git a/apps/web/src/app/api/agents/[id]/route.ts b/apps/web/src/app/api/agents/[id]/route.ts new file mode 100644 index 0000000..4d60506 --- /dev/null +++ b/apps/web/src/app/api/agents/[id]/route.ts @@ -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 { + 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 { + 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 { + 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 }); + } +} diff --git a/apps/web/src/app/api/agents/[id]/tools/[toolId]/route.ts b/apps/web/src/app/api/agents/[id]/tools/[toolId]/route.ts new file mode 100644 index 0000000..e3e04fa --- /dev/null +++ b/apps/web/src/app/api/agents/[id]/tools/[toolId]/route.ts @@ -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 { + 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 }); + } +} diff --git a/apps/web/src/app/api/agents/[id]/tools/route.ts b/apps/web/src/app/api/agents/[id]/tools/route.ts new file mode 100644 index 0000000..d99d366 --- /dev/null +++ b/apps/web/src/app/api/agents/[id]/tools/route.ts @@ -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 { + 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 }); + } +} diff --git a/apps/web/src/app/api/agents/[uid]/conversation/[conversationId]/route.ts b/apps/web/src/app/api/agents/[uid]/conversation/[conversationId]/route.ts new file mode 100644 index 0000000..5066f8d --- /dev/null +++ b/apps/web/src/app/api/agents/[uid]/conversation/[conversationId]/route.ts @@ -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 { + 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 { + 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 { + 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 { + 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 } + ); + } +} diff --git a/apps/web/src/app/api/agents/[uid]/conversations/route.ts b/apps/web/src/app/api/agents/[uid]/conversations/route.ts new file mode 100644 index 0000000..df19fad --- /dev/null +++ b/apps/web/src/app/api/agents/[uid]/conversations/route.ts @@ -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 { + 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 } + ); + } +} diff --git a/apps/web/src/app/api/agents/route.ts b/apps/web/src/app/api/agents/route.ts new file mode 100644 index 0000000..88be484 --- /dev/null +++ b/apps/web/src/app/api/agents/route.ts @@ -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 { + 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 { + 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 }); + } +} diff --git a/apps/web/src/app/api/user/api-keys/[provider]/route.ts b/apps/web/src/app/api/user/api-keys/[provider]/route.ts new file mode 100644 index 0000000..8647ac5 --- /dev/null +++ b/apps/web/src/app/api/user/api-keys/[provider]/route.ts @@ -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 { + 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 } + ); + } +} diff --git a/apps/web/src/app/api/user/api-keys/route.ts b/apps/web/src/app/api/user/api-keys/route.ts new file mode 100644 index 0000000..e81ae7b --- /dev/null +++ b/apps/web/src/app/api/user/api-keys/route.ts @@ -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 { + 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 { + 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 }); + } +} diff --git a/apps/web/src/app/dashboard/agents/[id]/chat/page.tsx b/apps/web/src/app/dashboard/agents/[id]/chat/page.tsx new file mode 100644 index 0000000..22853d7 --- /dev/null +++ b/apps/web/src/app/dashboard/agents/[id]/chat/page.tsx @@ -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 = { + 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(null); + const [conversations, setConversations] = useState([]); + const [activeConversationId, setActiveConversationId] = useState(null); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isSending, setIsSending] = useState(false); + const [streamingContent, setStreamingContent] = useState(''); + const [error, setError] = useState(null); + + const messagesEndRef = useRef(null); + const inputRef = useRef(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 ( +
+ +
+
+
+
+
+
+
+ ); + } + + if (error && !agent) { + return ( +
+ +
+
+ +

Error

+

{error}

+ + + +
+
+
+ ); + } + + if (!agent) { + return ( +
+ +
+ +
+
+ ); + } + + return ( +
+ + +
+ {/* Sidebar */} +
+ {/* Agent Info */} +
+ +
+ +
+
+

{agent.name}

+

+ {PROVIDER_DISPLAY_NAMES[agent.provider]} +

+
+ +
+ + {/* New Conversation Button */} +
+ +
+ + {/* Conversations List */} +
+ {conversations.map((conv) => ( + + ))} +
+
+ + {/* Chat Area */} +
+ {/* Messages */} +
+ {messages.length === 0 && !streamingContent && ( +
+
+
+ +
+

Start a conversation

+

+ Send a message to start chatting with {agent.name}. +

+
+
+ )} + + {messages.map((message) => ( +
+
+ {message.role === 'TOOL' && ( +
+ + {message.toolName} +
+ )} +

{message.content}

+
+
+ ))} + + {streamingContent && ( +
+
+

{streamingContent}

+ +
+
+ )} + + {isSending && !streamingContent && ( +
+
+
+ + Thinking... +
+
+
+ )} + +
+
+ + {/* Error Message */} + {error && ( +
+

{error}

+
+ )} + + {/* Input Area */} +
+
+