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:
Ajax Davis 2026-01-11 02:01:34 +10:00
parent 6661957111
commit d95a7119c7
2 changed files with 161 additions and 15 deletions

View file

@ -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 };

View file

@ -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">