feat: add username/slug based agent API endpoints
- Add /api/[username]/agents/[agentSlug]/conversation/[conversationId] - Add /api/[username]/agents/[agentSlug]/conversations - Update agent dashboard API docs to use new URL format - Include user.username in agent API response
This commit is contained in:
parent
74a9de290e
commit
cd5eaeaf59
5 changed files with 884 additions and 19 deletions
|
|
@ -0,0 +1,778 @@
|
||||||
|
/**
|
||||||
|
* Agent Conversation Endpoint (Username/Slug-based version)
|
||||||
|
*
|
||||||
|
* POST: Send a message and stream the AI response
|
||||||
|
* GET: Retrieve conversation history
|
||||||
|
* DELETE: Delete a conversation
|
||||||
|
*
|
||||||
|
* This endpoint uses username and agent slug for public-friendly URLs.
|
||||||
|
* Example: /api/ajax/agents/tpmjs-discord/conversation/my-conv-1
|
||||||
|
*
|
||||||
|
* Authentication: Supports both session auth and TPMJS API key auth.
|
||||||
|
* Requires 'agent:chat' scope for API key access.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Prisma, prisma } from '@tpmjs/db';
|
||||||
|
import type { AIProvider } from '@tpmjs/types/agent';
|
||||||
|
import { SendMessageSchema } from '@tpmjs/types/agent';
|
||||||
|
import type { LanguageModel, ModelMessage } from 'ai';
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { decryptApiKey } from '@/lib/crypto/api-keys';
|
||||||
|
import { authenticateRequest, hasScope } from '~/lib/api-keys/middleware';
|
||||||
|
import { trackUsage } from '~/lib/api-keys/usage';
|
||||||
|
import { checkRateLimit, type RateLimitConfig } from '~/lib/rate-limit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limit for chat messages: 30 requests per minute
|
||||||
|
* This is stricter than default because chat involves expensive LLM calls
|
||||||
|
*/
|
||||||
|
const CHAT_RATE_LIMIT: RateLimitConfig = {
|
||||||
|
limit: 30,
|
||||||
|
windowSeconds: 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 300; // 5 minutes for long agentic runs
|
||||||
|
|
||||||
|
type RouteContext = {
|
||||||
|
params: Promise<{ username: string; agentSlug: 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/[username]/agents/[agentSlug]/conversation/[conversationId]
|
||||||
|
* Send a message and stream the AI response via SSE
|
||||||
|
*/
|
||||||
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex streaming logic required
|
||||||
|
export async function POST(request: NextRequest, context: RouteContext): Promise<Response> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Authenticate request (supports both session and API key)
|
||||||
|
const authResult = await authenticateRequest();
|
||||||
|
|
||||||
|
if (!authResult.authenticated) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check scope for API key auth
|
||||||
|
if (authResult.authenticated && !authResult.isSessionAuth) {
|
||||||
|
if (!hasScope(authResult, 'agent:chat')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'API key does not have agent:chat scope' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rate limit (after auth so we can track by user if needed)
|
||||||
|
const rateLimitResponse = checkRateLimit(request, CHAT_RATE_LIMIT);
|
||||||
|
if (rateLimitResponse) {
|
||||||
|
return rateLimitResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, agentSlug, 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 by username and slug with all tool relations
|
||||||
|
const { fetchAgentByUsernameAndUidWithTools, buildAgentTools } = await import(
|
||||||
|
'@/lib/agents/build-tools'
|
||||||
|
);
|
||||||
|
const agent = await fetchAgentByUsernameAndUidWithTools(username, agentSlug);
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner-only enforcement: Only the agent owner can chat with the agent
|
||||||
|
if (authResult.userId !== agent.userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
'Fork this agent to use it. Only the agent owner can chat with agents. ' +
|
||||||
|
'Visit the agent page to fork it to your account.',
|
||||||
|
},
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map provider to expected key name format
|
||||||
|
const providerKeyNames: Record<string, string> = {
|
||||||
|
OPENAI: 'OPENAI_API_KEY',
|
||||||
|
ANTHROPIC: 'ANTHROPIC_API_KEY',
|
||||||
|
GOOGLE: 'GOOGLE_API_KEY',
|
||||||
|
GROQ: 'GROQ_API_KEY',
|
||||||
|
MISTRAL: 'MISTRAL_API_KEY',
|
||||||
|
};
|
||||||
|
const keyName = providerKeyNames[agent.provider];
|
||||||
|
|
||||||
|
if (!keyName) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: `Unsupported provider: ${agent.provider}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's API key for this provider
|
||||||
|
const userApiKey = await prisma.userApiKey.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_keyName: {
|
||||||
|
userId: agent.userId,
|
||||||
|
keyName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
const messages: ModelMessage[] = [];
|
||||||
|
|
||||||
|
// Add system prompt if defined
|
||||||
|
if (agent.systemPrompt) {
|
||||||
|
messages.push({
|
||||||
|
role: 'system',
|
||||||
|
content: agent.systemPrompt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add conversation history - properly format for AI SDK
|
||||||
|
for (const msg of recentMessages) {
|
||||||
|
if (msg.role === 'USER') {
|
||||||
|
messages.push({ role: 'user', content: msg.content });
|
||||||
|
} else if (msg.role === 'ASSISTANT') {
|
||||||
|
// For assistant messages with tool calls, include ToolCallParts in content
|
||||||
|
if (msg.toolCalls && Array.isArray(msg.toolCalls) && msg.toolCalls.length > 0) {
|
||||||
|
const toolCallParts = (
|
||||||
|
msg.toolCalls as Array<{ toolCallId: string; toolName: string; args: unknown }>
|
||||||
|
).map((tc) => ({
|
||||||
|
type: 'tool-call' as const,
|
||||||
|
toolCallId: tc.toolCallId,
|
||||||
|
toolName: tc.toolName,
|
||||||
|
input: tc.args,
|
||||||
|
}));
|
||||||
|
// Content includes text (if any) plus tool call parts
|
||||||
|
const content: Array<
|
||||||
|
| { type: 'text'; text: string }
|
||||||
|
| { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
|
||||||
|
> = [];
|
||||||
|
if (msg.content) {
|
||||||
|
content.push({ type: 'text', text: msg.content });
|
||||||
|
}
|
||||||
|
content.push(...toolCallParts);
|
||||||
|
messages.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
messages.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: msg.content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (msg.role === 'TOOL') {
|
||||||
|
// Tool results use the 'tool' role with content array
|
||||||
|
messages.push({
|
||||||
|
role: 'tool',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool-result' as const,
|
||||||
|
toolCallId: msg.toolCallId || '',
|
||||||
|
toolName: msg.toolName || '',
|
||||||
|
output: {
|
||||||
|
type: 'json' as const,
|
||||||
|
value: 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({
|
||||||
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex streaming logic
|
||||||
|
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 = '';
|
||||||
|
// Accumulate tool calls with their input args
|
||||||
|
const toolCallsMap: Map<string, { toolCallId: string; toolName: string; args: unknown }> =
|
||||||
|
new Map();
|
||||||
|
// Collect tool results to save AFTER assistant message (for correct chronological order)
|
||||||
|
const pendingToolResults: Array<{
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: string;
|
||||||
|
output: unknown;
|
||||||
|
}> = [];
|
||||||
|
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 and capture their inputs
|
||||||
|
if (chunk.type === 'tool-call') {
|
||||||
|
const input = 'args' in chunk ? chunk.args : chunk.input;
|
||||||
|
|
||||||
|
// Log tool call for debugging
|
||||||
|
console.log('[Agent] Tool call initiated:', {
|
||||||
|
toolName: chunk.toolName,
|
||||||
|
toolCallId: chunk.toolCallId,
|
||||||
|
input: JSON.stringify(input).slice(0, 500),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store tool call with input for later persistence
|
||||||
|
toolCallsMap.set(chunk.toolCallId, {
|
||||||
|
toolCallId: chunk.toolCallId,
|
||||||
|
toolName: chunk.toolName,
|
||||||
|
args: input,
|
||||||
|
});
|
||||||
|
sendEvent('tool_call', {
|
||||||
|
toolCallId: chunk.toolCallId,
|
||||||
|
toolName: chunk.toolName,
|
||||||
|
input,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex callback
|
||||||
|
onStepFinish: async ({ toolCalls, toolResults, usage }) => {
|
||||||
|
// Capture tool calls from step finish (backup in case onChunk missed any)
|
||||||
|
if (toolCalls && Array.isArray(toolCalls)) {
|
||||||
|
for (const tc of toolCalls) {
|
||||||
|
if (!toolCallsMap.has(tc.toolCallId)) {
|
||||||
|
// Use 'input' from DynamicToolCall or fall back to type assertion for typed calls
|
||||||
|
const args =
|
||||||
|
'input' in tc ? tc.input : 'args' in tc ? (tc as { args: unknown }).args : {};
|
||||||
|
toolCallsMap.set(tc.toolCallId, {
|
||||||
|
toolCallId: tc.toolCallId,
|
||||||
|
toolName: tc.toolName,
|
||||||
|
args,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send tool results via SSE but don't save yet (save after assistant message for correct order)
|
||||||
|
if (toolResults && toolResults.length > 0) {
|
||||||
|
for (const tr of toolResults) {
|
||||||
|
// Check if this is an error result (AI SDK wraps errors in the output)
|
||||||
|
const isError =
|
||||||
|
tr.output && typeof tr.output === 'object' && 'error' in tr.output;
|
||||||
|
|
||||||
|
// Log tool results for debugging
|
||||||
|
if (isError) {
|
||||||
|
console.error('[Agent] Tool execution failed:', {
|
||||||
|
toolName: tr.toolName,
|
||||||
|
toolCallId: tr.toolCallId,
|
||||||
|
error: tr.output,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('[Agent] Tool execution success:', {
|
||||||
|
toolName: tr.toolName,
|
||||||
|
toolCallId: tr.toolCallId,
|
||||||
|
outputPreview: JSON.stringify(tr.output).slice(0, 200),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendEvent('tool_result', {
|
||||||
|
toolCallId: tr.toolCallId,
|
||||||
|
output: tr.output,
|
||||||
|
isError,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect tool results to save after assistant message
|
||||||
|
pendingToolResults.push({
|
||||||
|
toolCallId: tr.toolCallId,
|
||||||
|
toolName: tr.toolName,
|
||||||
|
output: tr.output,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 finalUsage = await result.usage;
|
||||||
|
|
||||||
|
// Convert tool calls map to array for storage
|
||||||
|
const allToolCalls = Array.from(toolCallsMap.values());
|
||||||
|
|
||||||
|
// Update token counts from final usage
|
||||||
|
if (finalUsage) {
|
||||||
|
inputTokens = finalUsage.inputTokens ?? inputTokens;
|
||||||
|
outputTokens = finalUsage.outputTokens ?? outputTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save assistant message FIRST (so it has earlier createdAt than tool results)
|
||||||
|
const assistantMessage = await prisma.message.create({
|
||||||
|
data: {
|
||||||
|
conversationId: conversation.id,
|
||||||
|
role: 'ASSISTANT',
|
||||||
|
content: fullContent,
|
||||||
|
// Cast to Prisma-compatible JSON type
|
||||||
|
toolCalls:
|
||||||
|
allToolCalls.length > 0
|
||||||
|
? (allToolCalls as unknown as Prisma.InputJsonValue)
|
||||||
|
: Prisma.JsonNull,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now save TOOL messages (after assistant, for correct chronological order)
|
||||||
|
for (const tr of pendingToolResults) {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track usage
|
||||||
|
if (authResult.userId) {
|
||||||
|
trackUsage({
|
||||||
|
apiKeyId: authResult.apiKeyId ?? undefined,
|
||||||
|
userId: authResult.userId,
|
||||||
|
endpoint: `/api/${username}/agents/${agentSlug}/conversation/${conversationId}`,
|
||||||
|
method: 'POST',
|
||||||
|
statusCode: 200,
|
||||||
|
latencyMs: executionTimeMs,
|
||||||
|
resourceType: 'agent',
|
||||||
|
resourceId: agent.id,
|
||||||
|
tokensIn: inputTokens,
|
||||||
|
tokensOut: outputTokens,
|
||||||
|
model: agent.modelId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Log detailed error for debugging
|
||||||
|
console.error('[Agent] Conversation stream error:', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
agentId: agent.id,
|
||||||
|
conversationId: conversation.id,
|
||||||
|
});
|
||||||
|
sendEvent('error', {
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track error
|
||||||
|
if (authResult.userId) {
|
||||||
|
trackUsage({
|
||||||
|
apiKeyId: authResult.apiKeyId ?? undefined,
|
||||||
|
userId: authResult.userId,
|
||||||
|
endpoint: `/api/${username}/agents/${agentSlug}/conversation/${conversationId}`,
|
||||||
|
method: 'POST',
|
||||||
|
statusCode: 500,
|
||||||
|
latencyMs: Date.now() - startTime,
|
||||||
|
resourceType: 'agent',
|
||||||
|
resourceId: agent.id,
|
||||||
|
errorCode: 'STREAM_ERROR',
|
||||||
|
errorMessage: 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/[username]/agents/[agentSlug]/conversation/[conversationId]
|
||||||
|
* Retrieve conversation history with pagination
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest, context: RouteContext): Promise<NextResponse> {
|
||||||
|
const { username, agentSlug, conversationId } = await context.params;
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const format = searchParams.get('format');
|
||||||
|
const limit = Math.min(Number.parseInt(searchParams.get('limit') || '50', 10), 100);
|
||||||
|
const before = searchParams.get('before');
|
||||||
|
const after = searchParams.get('after');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch agent by username and slug
|
||||||
|
const agent = await prisma.agent.findFirst({
|
||||||
|
where: {
|
||||||
|
uid: agentSlug,
|
||||||
|
user: { username },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
uid: true,
|
||||||
|
name: true,
|
||||||
|
provider: true,
|
||||||
|
modelId: true,
|
||||||
|
executorType: true,
|
||||||
|
executorConfig: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch conversation
|
||||||
|
const conversation = await prisma.conversation.findUnique({
|
||||||
|
where: {
|
||||||
|
agentId_slug: {
|
||||||
|
agentId: agent.id,
|
||||||
|
slug: conversationId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!conversation) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Conversation not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For JSON format, return all messages with full details
|
||||||
|
if (format === 'json') {
|
||||||
|
const allMessages = await prisma.message.findMany({
|
||||||
|
where: { conversationId: conversation.id },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
_meta: {
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
format: 'tpmjs-conversation-log',
|
||||||
|
version: '1.0',
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
id: agent.id,
|
||||||
|
uid: agent.uid,
|
||||||
|
name: agent.name,
|
||||||
|
provider: agent.provider,
|
||||||
|
modelId: agent.modelId,
|
||||||
|
executorType: agent.executorType,
|
||||||
|
executorConfig: agent.executorConfig,
|
||||||
|
},
|
||||||
|
conversation: {
|
||||||
|
id: conversation.id,
|
||||||
|
slug: conversation.slug,
|
||||||
|
title: conversation.title,
|
||||||
|
createdAt: conversation.createdAt,
|
||||||
|
updatedAt: conversation.updatedAt,
|
||||||
|
messageCount: allMessages.length,
|
||||||
|
},
|
||||||
|
messages: allMessages.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,
|
||||||
|
})),
|
||||||
|
stats: {
|
||||||
|
totalMessages: allMessages.length,
|
||||||
|
userMessages: allMessages.filter((m) => m.role === 'USER').length,
|
||||||
|
assistantMessages: allMessages.filter((m) => m.role === 'ASSISTANT').length,
|
||||||
|
toolResults: allMessages.filter((m) => m.role === 'TOOL').length,
|
||||||
|
totalInputTokens: allMessages.reduce((sum, m) => sum + (m.inputTokens || 0), 0),
|
||||||
|
totalOutputTokens: allMessages.reduce((sum, m) => sum + (m.outputTokens || 0), 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return pretty-printed JSON with proper content type
|
||||||
|
return new NextResponse(JSON.stringify(response, null, 2), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Disposition': `inline; filename="${conversation.slug}-logs.json"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard paginated response
|
||||||
|
const whereClause: {
|
||||||
|
conversationId: string;
|
||||||
|
createdAt?: { lt?: Date; gt?: Date };
|
||||||
|
} = { conversationId: conversation.id };
|
||||||
|
|
||||||
|
if (before) {
|
||||||
|
whereClause.createdAt = { lt: new Date(before) };
|
||||||
|
} else if (after) {
|
||||||
|
whereClause.createdAt = { gt: new Date(after) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine fetch order:
|
||||||
|
// - Default (no cursor) or "before": Fetch desc (newest first), then reverse for chronological order
|
||||||
|
// - "after": Fetch asc (oldest first) to get messages after the cursor
|
||||||
|
const shouldFetchDesc = !after;
|
||||||
|
|
||||||
|
// Fetch messages
|
||||||
|
const messages = await prisma.message.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
orderBy: { createdAt: shouldFetchDesc ? 'desc' : 'asc' },
|
||||||
|
take: limit + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasMore = messages.length > limit;
|
||||||
|
let paginatedMessages = hasMore ? messages.slice(0, limit) : messages;
|
||||||
|
|
||||||
|
// Reverse if we fetched in desc order to maintain chronological order
|
||||||
|
if (shouldFetchDesc) {
|
||||||
|
paginatedMessages = paginatedMessages.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedMessages = paginatedMessages.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,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: conversation.id,
|
||||||
|
slug: conversation.slug,
|
||||||
|
title: conversation.title,
|
||||||
|
createdAt: conversation.createdAt,
|
||||||
|
updatedAt: conversation.updatedAt,
|
||||||
|
messages: mappedMessages,
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
limit,
|
||||||
|
hasMore,
|
||||||
|
...(before && { before }),
|
||||||
|
...(after && { after }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch conversation:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Failed to fetch conversation' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/[username]/agents/[agentSlug]/conversation/[conversationId]
|
||||||
|
* Delete a conversation
|
||||||
|
*/
|
||||||
|
export async function DELETE(_request: NextRequest, context: RouteContext): Promise<NextResponse> {
|
||||||
|
const { username, agentSlug, conversationId } = await context.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch agent by username and slug
|
||||||
|
const agent = await prisma.agent.findFirst({
|
||||||
|
where: {
|
||||||
|
uid: agentSlug,
|
||||||
|
user: { username },
|
||||||
|
},
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
/**
|
||||||
|
* Agent Conversations List Endpoint (Username/Slug-based version)
|
||||||
|
*
|
||||||
|
* GET: List all conversations for an agent
|
||||||
|
*
|
||||||
|
* Example: /api/ajax/agents/tpmjs-discord/conversations
|
||||||
|
*/
|
||||||
|
|
||||||
|
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<{ username: string; agentSlug: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/[username]/agents/[agentSlug]/conversations
|
||||||
|
* List all conversations for an agent
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest, context: RouteContext): Promise<NextResponse> {
|
||||||
|
const { username, agentSlug } = 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 by username and slug
|
||||||
|
const agent = await prisma.agent.findFirst({
|
||||||
|
where: {
|
||||||
|
uid: agentSlug,
|
||||||
|
user: { username },
|
||||||
|
},
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -114,11 +114,9 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch agent with all tool relations using agent ID or UID
|
// Fetch agent with all tool relations using agent ID
|
||||||
const { fetchAgentByIdOrUidWithTools, buildAgentTools } = await import(
|
const { fetchAgentWithTools, buildAgentTools } = await import('@/lib/agents/build-tools');
|
||||||
'@/lib/agents/build-tools'
|
const agent = await fetchAgentWithTools(agentId);
|
||||||
);
|
|
||||||
const agent = await fetchAgentByIdOrUidWithTools(agentId);
|
|
||||||
|
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
|
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
|
||||||
|
|
@ -571,11 +569,9 @@ export async function GET(request: NextRequest, context: RouteContext): Promise<
|
||||||
const after = searchParams.get('after');
|
const after = searchParams.get('after');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch agent by ID or UID with more details for JSON format
|
// Fetch agent by ID with more details for JSON format
|
||||||
const agent = await prisma.agent.findFirst({
|
const agent = await prisma.agent.findUnique({
|
||||||
where: {
|
where: { id: agentId },
|
||||||
OR: [{ id: agentId }, { uid: agentId }],
|
|
||||||
},
|
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
uid: true,
|
uid: true,
|
||||||
|
|
@ -748,11 +744,9 @@ export async function DELETE(_request: NextRequest, context: RouteContext): Prom
|
||||||
const { id: agentId, conversationId } = await context.params;
|
const { id: agentId, conversationId } = await context.params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch agent by ID or UID
|
// Fetch agent by ID
|
||||||
const agent = await prisma.agent.findFirst({
|
const agent = await prisma.agent.findUnique({
|
||||||
where: {
|
where: { id: agentId },
|
||||||
OR: [{ id: agentId }, { uid: agentId }],
|
|
||||||
},
|
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,11 @@ export async function GET(_request: NextRequest, context: RouteContext) {
|
||||||
const agent = await prisma.agent.findUnique({
|
const agent = await prisma.agent.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
username: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
collections: {
|
collections: {
|
||||||
include: {
|
include: {
|
||||||
collection: {
|
collection: {
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ interface Agent {
|
||||||
collectionCount: number;
|
collectionCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
user: {
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AgentTool {
|
interface AgentTool {
|
||||||
|
|
@ -111,8 +114,9 @@ function ApiDocsSection({ agent, agentTools }: { agent: Agent; agentTools: Agent
|
||||||
const [activeSection, setActiveSection] = useState('send');
|
const [activeSection, setActiveSection] = useState('send');
|
||||||
const [activeLang, setActiveLang] = useState('curl');
|
const [activeLang, setActiveLang] = useState('curl');
|
||||||
const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com';
|
const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com';
|
||||||
const endpoint = `${baseUrl}/api/agents/${agent.uid}/conversation/my-conv-1`;
|
const username = agent.user.username;
|
||||||
const listEndpoint = `${baseUrl}/api/agents/${agent.uid}/conversations`;
|
const endpoint = `${baseUrl}/api/${username}/agents/${agent.uid}/conversation/my-conv-1`;
|
||||||
|
const listEndpoint = `${baseUrl}/api/${username}/agents/${agent.uid}/conversations`;
|
||||||
const toolPackages = agentTools.map((t) => t.tool.npmPackageName).join(' ') || '@tpmjs/hello';
|
const toolPackages = agentTools.map((t) => t.tool.npmPackageName).join(' ') || '@tpmjs/hello';
|
||||||
|
|
||||||
const sendExamples: Record<string, { language: string; code: string }> = {
|
const sendExamples: Record<string, { language: string; code: string }> = {
|
||||||
|
|
@ -237,8 +241,8 @@ conv = resp.json() # conv['data']['messages']`,
|
||||||
<h2 className="text-lg font-medium text-foreground">API Reference</h2>
|
<h2 className="text-lg font-medium text-foreground">API Reference</h2>
|
||||||
<code className="text-xs text-foreground-secondary font-mono bg-surface px-2 py-1 rounded">
|
<code className="text-xs text-foreground-secondary font-mono bg-surface px-2 py-1 rounded">
|
||||||
{isSend
|
{isSend
|
||||||
? `POST /api/agents/${agent.uid}/conversation/:id`
|
? `POST /api/${username}/agents/${agent.uid}/conversation/:id`
|
||||||
: `GET /api/agents/${agent.uid}/conversations`}
|
: `GET /api/${username}/agents/${agent.uid}/conversations`}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue