feat: add tool error rendering and JSON logs button in agent chat
- Add error detection for tool call outputs (success=false, error field) - Display ERROR badge and red styling for failed tool calls - Show error message preview in tool call header - Add "JSON" button to view full conversation history as raw JSON - Support format=json parameter in conversation API endpoint 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
6661957111
commit
d95a7119c7
2 changed files with 161 additions and 15 deletions
|
|
@ -8,13 +8,13 @@
|
|||
* This endpoint uses agent id directly for dashboard usage
|
||||
*/
|
||||
|
||||
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, ModelMessage } from 'ai';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { type RateLimitConfig, checkRateLimit } from '~/lib/rate-limit';
|
||||
import { decryptApiKey } from '@/lib/crypto/api-keys';
|
||||
import { checkRateLimit, type RateLimitConfig } from '~/lib/rate-limit';
|
||||
|
||||
/**
|
||||
* Rate limit for chat messages: 30 requests per minute
|
||||
|
|
@ -449,6 +449,7 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
|
|||
* - limit: Max messages to return (default: 50, max: 100)
|
||||
* - before: Fetch messages created before this ISO timestamp (for loading older messages)
|
||||
* - after: Fetch messages created after this ISO timestamp (for loading newer messages)
|
||||
* - format: 'json' for full debug view (no pagination, includes agent info)
|
||||
*
|
||||
* Default behavior (no before/after): Returns the most recent messages
|
||||
* With before: Returns messages older than the timestamp (for scrolling up)
|
||||
|
|
@ -458,15 +459,24 @@ export async function GET(request: NextRequest, context: RouteContext): Promise<
|
|||
const { id: agentId, 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 ID
|
||||
// Fetch agent by ID with more details for JSON format
|
||||
const agent = await prisma.agent.findUnique({
|
||||
where: { id: agentId },
|
||||
select: { id: true },
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
name: true,
|
||||
provider: true,
|
||||
modelId: true,
|
||||
executorType: true,
|
||||
executorConfig: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
|
|
@ -490,7 +500,68 @@ export async function GET(request: NextRequest, context: RouteContext): Promise<
|
|||
);
|
||||
}
|
||||
|
||||
// Build the where clause based on cursor
|
||||
// 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 };
|
||||
|
|
|
|||
|
|
@ -35,6 +35,32 @@ interface ToolCall {
|
|||
status: 'pending' | 'running' | 'success' | 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool output contains an error
|
||||
*/
|
||||
function isToolError(output: unknown): boolean {
|
||||
if (!output || typeof output !== 'object') return false;
|
||||
const obj = output as Record<string, unknown>;
|
||||
return (
|
||||
obj.success === false ||
|
||||
obj.error !== undefined ||
|
||||
obj.__tpmjs_error__ !== undefined ||
|
||||
(typeof obj.message === 'string' && obj.message.toLowerCase().includes('error'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from tool output
|
||||
*/
|
||||
function getErrorMessage(output: unknown): string | null {
|
||||
if (!output || typeof output !== 'object') return null;
|
||||
const obj = output as Record<string, unknown>;
|
||||
if (typeof obj.error === 'string') return obj.error;
|
||||
if (typeof obj.__tpmjs_error__ === 'string') return obj.__tpmjs_error__;
|
||||
if (obj.success === false && typeof obj.message === 'string') return obj.message;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sexy tool call debug card component
|
||||
*/
|
||||
|
|
@ -47,6 +73,11 @@ function ToolCallCard({
|
|||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
// Detect if the output contains an error
|
||||
const hasError = Boolean(toolCall.output && isToolError(toolCall.output));
|
||||
const errorMessage = hasError ? getErrorMessage(toolCall.output) : null;
|
||||
const effectiveStatus = hasError ? 'error' : toolCall.status;
|
||||
|
||||
const statusColors = {
|
||||
pending: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||
running: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
|
|
@ -70,18 +101,20 @@ function ToolCallCard({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-secondary/50 overflow-hidden font-mono text-xs">
|
||||
<div
|
||||
className={`rounded-lg border overflow-hidden font-mono text-xs ${hasError ? 'border-red-500/50 bg-red-500/5' : 'border-border bg-surface-secondary/50'}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-3 p-3 hover:bg-surface-secondary/80 transition-colors"
|
||||
>
|
||||
<div className={`p-1.5 rounded ${statusColors[toolCall.status]}`}>
|
||||
<div className={`p-1.5 rounded ${statusColors[effectiveStatus]}`}>
|
||||
<Icon
|
||||
icon={statusIcons[toolCall.status]}
|
||||
icon={statusIcons[effectiveStatus]}
|
||||
size="xs"
|
||||
className={toolCall.status === 'running' ? 'animate-spin' : ''}
|
||||
className={effectiveStatus === 'running' ? 'animate-spin' : ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
|
|
@ -90,7 +123,18 @@ function ToolCallCard({
|
|||
<span className="text-foreground-tertiary text-[10px]">
|
||||
{toolCall.toolCallId.slice(0, 8)}...
|
||||
</span>
|
||||
{hasError && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/20 text-red-400">
|
||||
ERROR
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Show error message preview in header */}
|
||||
{hasError && errorMessage && !isExpanded && (
|
||||
<div className="text-red-400 text-[10px] mt-1 truncate max-w-[300px]">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Icon
|
||||
icon="chevronRight"
|
||||
|
|
@ -117,16 +161,31 @@ function ToolCallCard({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Error Message Section */}
|
||||
{hasError && errorMessage && (
|
||||
<div className="p-3 bg-red-500/10 border-b border-red-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon icon="alertCircle" size="xs" className="text-red-400" />
|
||||
<span className="text-[10px] uppercase tracking-wider text-red-400 font-semibold">
|
||||
Error
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-red-400">{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output Section */}
|
||||
{toolCall.output !== undefined && toolCall.output !== null ? (
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-[10px] uppercase tracking-wider text-foreground-tertiary">
|
||||
Output
|
||||
{hasError ? 'Full Response' : 'Output'}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-border/50" />
|
||||
</div>
|
||||
<pre className="text-[11px] text-green-400 overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto">
|
||||
<pre
|
||||
className={`text-[11px] overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto ${hasError ? 'text-red-300' : 'text-green-400'}`}
|
||||
>
|
||||
{formatJson(toolCall.output)}
|
||||
</pre>
|
||||
</div>
|
||||
|
|
@ -469,10 +528,26 @@ export default function AgentChatPage(): React.ReactElement {
|
|||
backUrl={`/dashboard/agents/${agent.id}`}
|
||||
fullHeight
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={startNewConversation}>
|
||||
<Icon icon="plus" size="xs" className="mr-2" />
|
||||
New Chat
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.open(
|
||||
`/api/agents/${agentId}/conversation/${activeConversationId}?format=json`,
|
||||
'_blank'
|
||||
);
|
||||
}}
|
||||
title="View conversation as JSON"
|
||||
>
|
||||
<Icon icon="terminal" size="xs" className="mr-2" />
|
||||
JSON
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={startNewConversation}>
|
||||
<Icon icon="plus" size="xs" className="mr-2" />
|
||||
New Chat
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex h-full overflow-hidden">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue