feat(web): redesign agent chat interface with style guide
- Add three-panel layout: conversations, chat, tools - Create ChatToolsPanel for viewing available tools - Create ToolDetailsModal for tool information - Create ChatSettingsDrawer for editing agent settings - Update styling to match style guide (fieldsets, dashed borders) - Add tools toggle and settings buttons to header
This commit is contained in:
parent
014da0171e
commit
5014822b67
4 changed files with 678 additions and 85 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { AIProvider } from '@tpmjs/types/agent';
|
import type { AIProvider } from '@tpmjs/types/agent';
|
||||||
|
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||||
import { Button } from '@tpmjs/ui/Button/Button';
|
import { Button } from '@tpmjs/ui/Button/Button';
|
||||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||||
import { Textarea } from '@tpmjs/ui/Textarea/Textarea';
|
import { Textarea } from '@tpmjs/ui/Textarea/Textarea';
|
||||||
|
|
@ -8,6 +9,11 @@ import Link from 'next/link';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Streamdown } from 'streamdown';
|
import { Streamdown } from 'streamdown';
|
||||||
|
import type { AgentSettings } from '~/components/agents/ChatSettingsDrawer';
|
||||||
|
import { ChatSettingsDrawer } from '~/components/agents/ChatSettingsDrawer';
|
||||||
|
import type { CollectionInfo, ToolInfo } from '~/components/agents/ChatToolsPanel';
|
||||||
|
import { ChatToolsPanel } from '~/components/agents/ChatToolsPanel';
|
||||||
|
import { ToolDetailsModal } from '~/components/agents/ToolDetailsModal';
|
||||||
import { DashboardLayout } from '~/components/dashboard/DashboardLayout';
|
import { DashboardLayout } from '~/components/dashboard/DashboardLayout';
|
||||||
|
|
||||||
interface Agent {
|
interface Agent {
|
||||||
|
|
@ -17,6 +23,12 @@ interface Agent {
|
||||||
description: string | null;
|
description: string | null;
|
||||||
provider: AIProvider;
|
provider: AIProvider;
|
||||||
modelId: string;
|
modelId: string;
|
||||||
|
systemPrompt: string | null;
|
||||||
|
temperature: number;
|
||||||
|
maxToolCallsPerTurn: number;
|
||||||
|
maxMessagesInContext: number;
|
||||||
|
tools: ToolInfo[];
|
||||||
|
collections: CollectionInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessageToolCall {
|
interface MessageToolCall {
|
||||||
|
|
@ -71,7 +83,7 @@ function getErrorMessage(output: unknown): string | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sexy tool call debug card component
|
* Tool call card with style guide aesthetics
|
||||||
*/
|
*/
|
||||||
function ToolCallCard({
|
function ToolCallCard({
|
||||||
toolCall,
|
toolCall,
|
||||||
|
|
@ -82,7 +94,6 @@ function ToolCallCard({
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
}) {
|
}) {
|
||||||
// Detect if the output contains an error
|
|
||||||
const hasError = Boolean(toolCall.output && isToolError(toolCall.output));
|
const hasError = Boolean(toolCall.output && isToolError(toolCall.output));
|
||||||
const errorMessage = hasError ? getErrorMessage(toolCall.output) : null;
|
const errorMessage = hasError ? getErrorMessage(toolCall.output) : null;
|
||||||
const effectiveStatus = hasError ? 'error' : toolCall.status;
|
const effectiveStatus = hasError ? 'error' : toolCall.status;
|
||||||
|
|
@ -110,10 +121,12 @@ function ToolCallCard({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<fieldset
|
||||||
className={`rounded-lg border overflow-hidden font-mono text-xs ${hasError ? 'border-error/50 bg-error/5' : 'border-border bg-surface-secondary/50'}`}
|
className={`border border-dashed overflow-hidden font-mono text-xs ${
|
||||||
|
hasError ? 'border-error/50 bg-error/5' : 'border-border bg-surface-secondary/30'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* header */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
|
|
@ -126,41 +139,38 @@ function ToolCallCard({
|
||||||
className={effectiveStatus === 'running' ? 'animate-spin' : ''}
|
className={effectiveStatus === 'running' ? 'animate-spin' : ''}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 text-left">
|
<div className="flex-1 text-left min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-foreground font-semibold">{toolCall.toolName}</span>
|
<span className="text-foreground font-semibold truncate">{toolCall.toolName}</span>
|
||||||
<span className="text-foreground-tertiary text-[10px]">
|
<span className="text-foreground-tertiary text-[10px]">
|
||||||
{toolCall.toolCallId.slice(0, 8)}...
|
{toolCall.toolCallId.slice(0, 8)}...
|
||||||
</span>
|
</span>
|
||||||
{hasError && (
|
{hasError && (
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-error/10 text-error">
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-error/10 text-error uppercase">
|
||||||
ERROR
|
error
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Show error message preview in header */}
|
|
||||||
{hasError && errorMessage && !isExpanded && (
|
{hasError && errorMessage && !isExpanded && (
|
||||||
<div className="text-error text-[10px] mt-1 truncate max-w-[300px]">
|
<div className="text-error text-[10px] mt-1 truncate max-w-[300px]">{errorMessage}</div>
|
||||||
{errorMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Icon
|
<Icon
|
||||||
icon="chevronRight"
|
icon="chevronRight"
|
||||||
size="xs"
|
size="xs"
|
||||||
className={`text-foreground-tertiary transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
className={`text-foreground-tertiary transition-transform flex-shrink-0 ${isExpanded ? 'rotate-90' : ''}`}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Expanded Content */}
|
{/* expanded content */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="border-t border-border">
|
<div className="border-t border-dashed border-border">
|
||||||
{/* Input Section */}
|
{/* input */}
|
||||||
{toolCall.input !== undefined && toolCall.input !== null ? (
|
{toolCall.input !== undefined && toolCall.input !== null ? (
|
||||||
<div className="p-3 border-b border-border/50">
|
<div className="p-3 border-b border-dashed border-border/50">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span className="text-[10px] uppercase tracking-wider text-foreground-tertiary">
|
<span className="text-[10px] uppercase tracking-wider text-foreground-tertiary">
|
||||||
Input
|
input
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 h-px bg-border/50" />
|
<div className="flex-1 h-px bg-border/50" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -170,25 +180,25 @@ function ToolCallCard({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Error Message Section */}
|
{/* error message */}
|
||||||
{hasError && errorMessage && (
|
{hasError && errorMessage && (
|
||||||
<div className="p-3 bg-error/10 border-b border-error/20">
|
<div className="p-3 bg-error/10 border-b border-dashed border-error/20">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Icon icon="alertCircle" size="xs" className="text-error" />
|
<Icon icon="alertCircle" size="xs" className="text-error" />
|
||||||
<span className="text-[10px] uppercase tracking-wider text-error font-semibold">
|
<span className="text-[10px] uppercase tracking-wider text-error font-semibold">
|
||||||
Error
|
error
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-error">{errorMessage}</p>
|
<p className="text-[11px] text-error">{errorMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Output Section */}
|
{/* output */}
|
||||||
{toolCall.output !== undefined && toolCall.output !== null ? (
|
{toolCall.output !== undefined && toolCall.output !== null ? (
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span className="text-[10px] uppercase tracking-wider text-foreground-tertiary">
|
<span className="text-[10px] uppercase tracking-wider text-foreground-tertiary">
|
||||||
{hasError ? 'Full Response' : 'Output'}
|
{hasError ? 'full response' : 'output'}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 h-px bg-border/50" />
|
<div className="flex-1 h-px bg-border/50" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -200,16 +210,16 @@ function ToolCallCard({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Status indicator for running */}
|
{/* running indicator */}
|
||||||
{toolCall.status === 'running' && !toolCall.output && (
|
{toolCall.status === 'running' && !toolCall.output && (
|
||||||
<div className="p-3 flex items-center gap-2 text-foreground-tertiary">
|
<div className="p-3 flex items-center gap-2 text-foreground-tertiary">
|
||||||
<Icon icon="loader" size="xs" className="animate-spin" />
|
<Icon icon="loader" size="xs" className="animate-spin" />
|
||||||
<span>Executing...</span>
|
<span>executing...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</fieldset>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,7 +251,6 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
|
|
||||||
const [agent, setAgent] = useState<Agent | null>(null);
|
const [agent, setAgent] = useState<Agent | null>(null);
|
||||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||||
// Use chatId from URL as the active conversation
|
|
||||||
const activeConversationId = chatId;
|
const activeConversationId = chatId;
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
|
|
@ -252,6 +261,11 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
const [toolCalls, setToolCalls] = useState<ToolCall[]>([]);
|
const [toolCalls, setToolCalls] = useState<ToolCall[]>([]);
|
||||||
const [expandedToolCalls, setExpandedToolCalls] = useState<Set<string>>(new Set());
|
const [expandedToolCalls, setExpandedToolCalls] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const [showToolsPanel, setShowToolsPanel] = useState(true);
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [selectedTool, setSelectedTool] = useState<ToolInfo | null>(null);
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
|
@ -314,11 +328,6 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
const msgs = data.data.messages || [];
|
const msgs = data.data.messages || [];
|
||||||
console.log(
|
|
||||||
'[fetchMessages] Received messages:',
|
|
||||||
msgs.length,
|
|
||||||
msgs.map((m: Message) => ({ role: m.role, toolName: m.toolName }))
|
|
||||||
);
|
|
||||||
setMessages(msgs);
|
setMessages(msgs);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -352,7 +361,6 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Navigate to conversation when selected from sidebar
|
|
||||||
const handleSelectConversation = (convSlug: string) => {
|
const handleSelectConversation = (convSlug: string) => {
|
||||||
router.push(`/dashboard/agents/${agentId}/chat/${convSlug}`);
|
router.push(`/dashboard/agents/${agentId}/chat/${convSlug}`);
|
||||||
};
|
};
|
||||||
|
|
@ -367,7 +375,6 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
setError(null);
|
setError(null);
|
||||||
setToolCalls([]);
|
setToolCalls([]);
|
||||||
|
|
||||||
// Use the chatId from URL
|
|
||||||
const conversationId = activeConversationId || chatId;
|
const conversationId = activeConversationId || chatId;
|
||||||
|
|
||||||
// Optimistically add user message
|
// Optimistically add user message
|
||||||
|
|
@ -406,7 +413,7 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
|
|
||||||
// Parse SSE events
|
// Parse SSE events
|
||||||
const lines = buffer.split('\n');
|
const lines = buffer.split('\n');
|
||||||
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
let eventType = '';
|
let eventType = '';
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
|
@ -420,7 +427,6 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
setStreamingContent((prev) => prev + data.text);
|
setStreamingContent((prev) => prev + data.text);
|
||||||
break;
|
break;
|
||||||
case 'tool_call':
|
case 'tool_call':
|
||||||
// Add tool call to tracking
|
|
||||||
setToolCalls((prev) => [
|
setToolCalls((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
|
|
@ -430,11 +436,9 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
status: 'running',
|
status: 'running',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
// Auto-expand new tool calls
|
|
||||||
setExpandedToolCalls((prev) => new Set([...prev, data.toolCallId]));
|
setExpandedToolCalls((prev) => new Set([...prev, data.toolCallId]));
|
||||||
break;
|
break;
|
||||||
case 'tool_result':
|
case 'tool_result':
|
||||||
// Update tool call with result
|
|
||||||
setToolCalls((prev) =>
|
setToolCalls((prev) =>
|
||||||
prev.map((tc) =>
|
prev.map((tc) =>
|
||||||
tc.toolCallId === data.toolCallId
|
tc.toolCallId === data.toolCallId
|
||||||
|
|
@ -444,13 +448,10 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'complete':
|
case 'complete':
|
||||||
// Refresh messages
|
|
||||||
console.log('[SSE] Complete event received, fetching messages...');
|
|
||||||
await fetchMessages();
|
await fetchMessages();
|
||||||
await fetchConversations();
|
await fetchConversations();
|
||||||
setStreamingContent('');
|
setStreamingContent('');
|
||||||
setToolCalls([]);
|
setToolCalls([]);
|
||||||
console.log('[SSE] Messages and conversations refreshed');
|
|
||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
throw new Error(data.message);
|
throw new Error(data.message);
|
||||||
|
|
@ -461,7 +462,6 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to send message:', err);
|
console.error('Failed to send message:', err);
|
||||||
setError(err instanceof Error ? err.message : 'Failed to send message');
|
setError(err instanceof Error ? err.message : 'Failed to send message');
|
||||||
// Remove optimistic message on error
|
|
||||||
setMessages((prev) => prev.filter((m) => m.id !== userMessage.id));
|
setMessages((prev) => prev.filter((m) => m.id !== userMessage.id));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSending(false);
|
setIsSending(false);
|
||||||
|
|
@ -482,6 +482,15 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
router.push(`/dashboard/agents/${agentId}/chat/${newId}`);
|
router.push(`/dashboard/agents/${agentId}/chat/${newId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSettingsChange = (newSettings: AgentSettings) => {
|
||||||
|
if (agent) {
|
||||||
|
setAgent({
|
||||||
|
...agent,
|
||||||
|
...newSettings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout
|
<DashboardLayout
|
||||||
|
|
@ -531,13 +540,37 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout
|
<DashboardLayout
|
||||||
title={`Chat with ${agent.name}`}
|
title={agent.name}
|
||||||
subtitle={PROVIDER_DISPLAY_NAMES[agent.provider]}
|
subtitle={`${PROVIDER_DISPLAY_NAMES[agent.provider]} / ${agent.modelId}`}
|
||||||
showBackButton
|
showBackButton
|
||||||
backUrl={`/dashboard/agents/${agent.id}`}
|
backUrl={`/dashboard/agents/${agent.id}`}
|
||||||
fullHeight
|
fullHeight
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowToolsPanel(!showToolsPanel)}
|
||||||
|
title={showToolsPanel ? 'Hide tools panel' : 'Show tools panel'}
|
||||||
|
className={showToolsPanel ? 'bg-primary/10 text-primary' : ''}
|
||||||
|
>
|
||||||
|
<Icon icon="puzzle" size="xs" className="mr-1.5" />
|
||||||
|
<span className="hidden sm:inline">Tools</span>
|
||||||
|
{agent.tools.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-1.5 text-[10px]">
|
||||||
|
{agent.tools.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowSettings(true)}
|
||||||
|
title="Agent settings"
|
||||||
|
>
|
||||||
|
<Icon icon="edit" size="xs" className="mr-1.5" />
|
||||||
|
<span className="hidden sm:inline">Settings</span>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -549,11 +582,10 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
}}
|
}}
|
||||||
title="View conversation as JSON"
|
title="View conversation as JSON"
|
||||||
>
|
>
|
||||||
<Icon icon="terminal" size="xs" className="mr-2" />
|
<Icon icon="terminal" size="xs" />
|
||||||
JSON
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={startNewConversation}>
|
<Button variant="outline" size="sm" onClick={startNewConversation}>
|
||||||
<Icon icon="plus" size="xs" className="mr-2" />
|
<Icon icon="plus" size="xs" className="mr-1.5" />
|
||||||
New Chat
|
New Chat
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -561,50 +593,57 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
>
|
>
|
||||||
<div className="flex h-full overflow-hidden">
|
<div className="flex h-full overflow-hidden">
|
||||||
{/* Conversations Sidebar */}
|
{/* Conversations Sidebar */}
|
||||||
<div className="w-64 border-r border-border flex flex-col bg-surface-secondary/30 hidden md:flex">
|
<div className="w-56 border-r border-dashed border-border flex flex-col bg-surface hidden md:flex">
|
||||||
{/* Conversations List */}
|
<div className="p-3 border-b border-dashed border-border">
|
||||||
|
<span className="font-mono text-xs text-foreground-tertiary lowercase">
|
||||||
|
conversations
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
{conversations.length === 0 ? (
|
{conversations.length === 0 ? (
|
||||||
<p className="text-sm text-foreground-tertiary text-center py-4">
|
<p className="text-xs text-foreground-tertiary text-center py-4 font-mono">
|
||||||
No conversations yet
|
no conversations yet
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
conversations.map((conv) => (
|
conversations.map((conv) => (
|
||||||
<Button
|
<button
|
||||||
|
type="button"
|
||||||
key={conv.id}
|
key={conv.id}
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleSelectConversation(conv.slug)}
|
onClick={() => handleSelectConversation(conv.slug)}
|
||||||
className={`w-full text-left h-auto py-2 px-3 justify-start mb-1 ${
|
className={`w-full text-left p-2 mb-1 rounded border border-transparent transition-colors ${
|
||||||
chatId === conv.slug
|
chatId === conv.slug
|
||||||
? 'bg-primary/10 text-primary'
|
? 'bg-primary/10 border-primary/20 text-primary'
|
||||||
: 'text-foreground-secondary hover:bg-surface-secondary'
|
: 'text-foreground-secondary hover:bg-surface-secondary hover:border-border'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-start">
|
<p className="text-sm font-medium truncate">{conv.title || 'Untitled Chat'}</p>
|
||||||
<p className="text-sm font-medium truncate">{conv.title || 'Untitled Chat'}</p>
|
<p className="text-[10px] text-foreground-tertiary font-mono mt-0.5">
|
||||||
<p className="text-xs text-foreground-tertiary">{conv.messageCount} messages</p>
|
{conv.messageCount} messages
|
||||||
</div>
|
</p>
|
||||||
</Button>
|
</button>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chat Area */}
|
{/* Chat Area */}
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
{messages.length === 0 && !streamingContent && (
|
{messages.length === 0 && !streamingContent && (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-center">
|
<fieldset className="border border-dashed border-border p-8 max-w-md text-center">
|
||||||
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
|
<legend className="px-2 font-mono text-xs text-foreground-tertiary lowercase">
|
||||||
|
new conversation
|
||||||
|
</legend>
|
||||||
|
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
|
||||||
<Icon icon="message" size="lg" className="text-primary" />
|
<Icon icon="message" size="lg" className="text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-medium text-foreground mb-2">Start a conversation</h3>
|
<h3 className="font-mono text-foreground mb-2 lowercase">start chatting</h3>
|
||||||
<p className="text-foreground-secondary max-w-sm">
|
<p className="text-sm text-foreground-secondary">
|
||||||
Send a message to start chatting with {agent.name}.
|
Send a message to start a conversation with {agent.name}.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -628,7 +667,6 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages.map((message) => {
|
return messages.map((message) => {
|
||||||
// Check if this ASSISTANT message has embedded tool calls
|
|
||||||
const hasEmbeddedToolCalls =
|
const hasEmbeddedToolCalls =
|
||||||
message.role === 'ASSISTANT' &&
|
message.role === 'ASSISTANT' &&
|
||||||
message.toolCalls &&
|
message.toolCalls &&
|
||||||
|
|
@ -641,13 +679,12 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
{hasEmbeddedToolCalls && (
|
{hasEmbeddedToolCalls && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{message.toolCalls?.map((tc) => {
|
{message.toolCalls?.map((tc) => {
|
||||||
// Look up the output for this tool call
|
|
||||||
const toolOutput = toolResultsMap.get(tc.toolCallId);
|
const toolOutput = toolResultsMap.get(tc.toolCallId);
|
||||||
const hasOutput = toolOutput !== undefined;
|
const hasOutput = toolOutput !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={tc.toolCallId} className="flex justify-start">
|
<div key={tc.toolCallId} className="flex justify-start">
|
||||||
<div className="max-w-[80%]">
|
<div className="max-w-[85%]">
|
||||||
<ToolCallCard
|
<ToolCallCard
|
||||||
toolCall={{
|
toolCall={{
|
||||||
toolCallId: tc.toolCallId,
|
toolCallId: tc.toolCallId,
|
||||||
|
|
@ -666,16 +703,16 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Render the message content - skip TOOL messages as they're shown with their calls */}
|
{/* Render the message content - skip TOOL messages */}
|
||||||
{message.role !== 'TOOL' && (
|
{message.role !== 'TOOL' && (
|
||||||
<div
|
<div
|
||||||
className={`flex ${message.role === 'USER' ? 'justify-end' : 'justify-start'}`}
|
className={`flex ${message.role === 'USER' ? 'justify-end' : 'justify-start'}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`max-w-[80%] rounded-lg p-4 ${
|
className={`max-w-[85%] rounded-lg p-4 ${
|
||||||
message.role === 'USER'
|
message.role === 'USER'
|
||||||
? 'bg-primary text-primary-foreground'
|
? 'bg-primary text-primary-foreground'
|
||||||
: 'bg-surface-secondary'
|
: 'bg-surface border border-dashed border-border'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{message.role === 'USER' ? (
|
{message.role === 'USER' ? (
|
||||||
|
|
@ -698,7 +735,7 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{toolCalls.map((tc) => (
|
{toolCalls.map((tc) => (
|
||||||
<div key={tc.toolCallId} className="flex justify-start">
|
<div key={tc.toolCallId} className="flex justify-start">
|
||||||
<div className="max-w-[80%]">
|
<div className="max-w-[85%]">
|
||||||
<ToolCallCard
|
<ToolCallCard
|
||||||
toolCall={tc}
|
toolCall={tc}
|
||||||
isExpanded={expandedToolCalls.has(tc.toolCallId)}
|
isExpanded={expandedToolCalls.has(tc.toolCallId)}
|
||||||
|
|
@ -712,7 +749,7 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
|
|
||||||
{streamingContent && (
|
{streamingContent && (
|
||||||
<div className="flex justify-start">
|
<div className="flex justify-start">
|
||||||
<div className="max-w-[80%] rounded-lg p-4 bg-surface-secondary">
|
<div className="max-w-[85%] rounded-lg p-4 bg-surface border border-dashed border-border">
|
||||||
<div className="text-sm prose prose-sm dark:prose-invert max-w-none">
|
<div className="text-sm prose prose-sm dark:prose-invert max-w-none">
|
||||||
<Streamdown>{streamingContent}</Streamdown>
|
<Streamdown>{streamingContent}</Streamdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -723,10 +760,10 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
|
|
||||||
{isSending && !streamingContent && toolCalls.length === 0 && (
|
{isSending && !streamingContent && toolCalls.length === 0 && (
|
||||||
<div className="flex justify-start">
|
<div className="flex justify-start">
|
||||||
<div className="rounded-lg p-4 bg-surface-secondary">
|
<div className="rounded-lg p-4 bg-surface border border-dashed border-border">
|
||||||
<div className="flex items-center gap-2 text-foreground-secondary">
|
<div className="flex items-center gap-2 text-foreground-secondary">
|
||||||
<Icon icon="loader" size="sm" className="animate-spin" />
|
<Icon icon="loader" size="sm" className="animate-spin" />
|
||||||
<span className="text-sm">Thinking...</span>
|
<span className="text-sm font-mono lowercase">thinking...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -737,13 +774,13 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="px-4 py-2 bg-error/10 border-t border-error/20">
|
<div className="px-4 py-2 bg-error/10 border-t border-dashed border-error/20">
|
||||||
<p className="text-sm text-error">{error}</p>
|
<p className="text-sm text-error font-mono">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Input Area */}
|
{/* Input Area */}
|
||||||
<div className="border-t border-border p-4">
|
<div className="border-t border-dashed border-border p-4 bg-surface">
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<Textarea
|
<Textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|
@ -753,7 +790,7 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
placeholder="Type a message..."
|
placeholder="Type a message..."
|
||||||
rows={1}
|
rows={1}
|
||||||
resize="none"
|
resize="none"
|
||||||
className="flex-1 min-h-[48px] max-h-[200px]"
|
className="flex-1 min-h-[48px] max-h-[200px] font-mono"
|
||||||
style={{
|
style={{
|
||||||
height: 'auto',
|
height: 'auto',
|
||||||
minHeight: '48px',
|
minHeight: '48px',
|
||||||
|
|
@ -768,12 +805,45 @@ export default function AgentChatPage(): React.ReactElement {
|
||||||
<Icon icon="send" size="sm" />
|
<Icon icon="send" size="sm" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-foreground-tertiary mt-2">
|
<p className="text-[10px] text-foreground-tertiary mt-2 font-mono">
|
||||||
Press Enter to send, Shift+Enter for new line
|
enter to send, shift+enter for new line
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tools Panel */}
|
||||||
|
<ChatToolsPanel
|
||||||
|
tools={agent.tools}
|
||||||
|
collections={agent.collections}
|
||||||
|
isOpen={showToolsPanel}
|
||||||
|
onClose={() => setShowToolsPanel(false)}
|
||||||
|
onToolClick={setSelectedTool}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tool Details Modal */}
|
||||||
|
<ToolDetailsModal
|
||||||
|
tool={selectedTool}
|
||||||
|
open={!!selectedTool}
|
||||||
|
onClose={() => setSelectedTool(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Settings Drawer */}
|
||||||
|
<ChatSettingsDrawer
|
||||||
|
open={showSettings}
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
agentId={agent.id}
|
||||||
|
settings={{
|
||||||
|
name: agent.name,
|
||||||
|
provider: agent.provider,
|
||||||
|
modelId: agent.modelId,
|
||||||
|
systemPrompt: agent.systemPrompt,
|
||||||
|
temperature: agent.temperature,
|
||||||
|
maxToolCallsPerTurn: agent.maxToolCallsPerTurn,
|
||||||
|
maxMessagesInContext: agent.maxMessagesInContext,
|
||||||
|
}}
|
||||||
|
onSettingsChange={handleSettingsChange}
|
||||||
|
/>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
274
apps/web/src/components/agents/ChatSettingsDrawer.tsx
Normal file
274
apps/web/src/components/agents/ChatSettingsDrawer.tsx
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { AIProvider } from '@tpmjs/types/agent';
|
||||||
|
import { PROVIDER_MODELS, SUPPORTED_PROVIDERS } from '@tpmjs/types/agent';
|
||||||
|
import { Button } from '@tpmjs/ui/Button/Button';
|
||||||
|
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||||
|
import { Input } from '@tpmjs/ui/Input/Input';
|
||||||
|
import { Label } from '@tpmjs/ui/Label/Label';
|
||||||
|
import { Modal } from '@tpmjs/ui/Modal/Modal';
|
||||||
|
import { Select } from '@tpmjs/ui/Select/Select';
|
||||||
|
import { Textarea } from '@tpmjs/ui/Textarea/Textarea';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
const PROVIDER_DISPLAY_NAMES: Record<AIProvider, string> = {
|
||||||
|
OPENAI: 'OpenAI',
|
||||||
|
ANTHROPIC: 'Anthropic',
|
||||||
|
GOOGLE: 'Google',
|
||||||
|
GROQ: 'Groq',
|
||||||
|
MISTRAL: 'Mistral',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AgentSettings {
|
||||||
|
name: string;
|
||||||
|
provider: AIProvider;
|
||||||
|
modelId: string;
|
||||||
|
systemPrompt: string | null;
|
||||||
|
temperature: number;
|
||||||
|
maxToolCallsPerTurn: number;
|
||||||
|
maxMessagesInContext: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatSettingsDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
agentId: string;
|
||||||
|
settings: AgentSettings;
|
||||||
|
onSettingsChange: (settings: AgentSettings) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatSettingsDrawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
agentId,
|
||||||
|
settings,
|
||||||
|
onSettingsChange,
|
||||||
|
}: ChatSettingsDrawerProps) {
|
||||||
|
const [formData, setFormData] = useState<AgentSettings>(settings);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Sync formData when settings prop changes
|
||||||
|
const syncFormData = useCallback(() => {
|
||||||
|
setFormData(settings);
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: sync form when modal opens
|
||||||
|
useState(() => {
|
||||||
|
if (open) syncFormData();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||||
|
) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData((prev) => {
|
||||||
|
const newData = { ...prev, [name]: value };
|
||||||
|
|
||||||
|
// Reset model when provider changes
|
||||||
|
if (name === 'provider') {
|
||||||
|
const provider = value as AIProvider;
|
||||||
|
const models = PROVIDER_MODELS[provider];
|
||||||
|
newData.modelId = models?.[0]?.id || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/agents/${agentId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider: formData.provider,
|
||||||
|
modelId: formData.modelId,
|
||||||
|
systemPrompt: formData.systemPrompt || null,
|
||||||
|
temperature: Number.parseFloat(formData.temperature.toString()),
|
||||||
|
maxToolCallsPerTurn: Number.parseInt(formData.maxToolCallsPerTurn.toString(), 10),
|
||||||
|
maxMessagesInContext: Number.parseInt(formData.maxMessagesInContext.toString(), 10),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
onSettingsChange(formData);
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'Failed to save settings');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save settings:', err);
|
||||||
|
setError('Failed to save settings');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const models = PROVIDER_MODELS[formData.provider] || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} size="lg">
|
||||||
|
{/* header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-dashed border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-surface-secondary flex items-center justify-center">
|
||||||
|
<Icon icon="edit" size="sm" className="text-foreground-secondary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-mono text-lg text-foreground lowercase">agent settings</h2>
|
||||||
|
<p className="text-sm text-foreground-tertiary">{settings.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded hover:bg-surface-secondary text-foreground-tertiary hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Icon icon="x" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* content */}
|
||||||
|
<div className="p-6 space-y-6 max-h-[60vh] overflow-y-auto">
|
||||||
|
{/* model configuration */}
|
||||||
|
<fieldset className="border border-dashed border-border p-4">
|
||||||
|
<legend className="px-2 font-mono text-xs text-foreground-tertiary lowercase">
|
||||||
|
model configuration
|
||||||
|
</legend>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="provider" className="font-mono text-xs lowercase">
|
||||||
|
provider
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
id="provider"
|
||||||
|
name="provider"
|
||||||
|
value={formData.provider}
|
||||||
|
onChange={handleChange}
|
||||||
|
options={SUPPORTED_PROVIDERS.map((p) => ({
|
||||||
|
value: p,
|
||||||
|
label: PROVIDER_DISPLAY_NAMES[p],
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="modelId" className="font-mono text-xs lowercase">
|
||||||
|
model
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
id="modelId"
|
||||||
|
name="modelId"
|
||||||
|
value={formData.modelId}
|
||||||
|
onChange={handleChange}
|
||||||
|
options={models.map((m) => ({ value: m.id, label: m.name }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{/* behavior */}
|
||||||
|
<fieldset className="border border-dashed border-border p-4">
|
||||||
|
<legend className="px-2 font-mono text-xs text-foreground-tertiary lowercase">
|
||||||
|
behavior
|
||||||
|
</legend>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="temperature" className="font-mono text-xs lowercase">
|
||||||
|
temperature
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
id="temperature"
|
||||||
|
name="temperature"
|
||||||
|
value={formData.temperature}
|
||||||
|
onChange={handleChange}
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="maxToolCallsPerTurn" className="font-mono text-xs lowercase">
|
||||||
|
max tool calls
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
id="maxToolCallsPerTurn"
|
||||||
|
name="maxToolCallsPerTurn"
|
||||||
|
value={formData.maxToolCallsPerTurn}
|
||||||
|
onChange={handleChange}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="maxMessagesInContext" className="font-mono text-xs lowercase">
|
||||||
|
context messages
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
id="maxMessagesInContext"
|
||||||
|
name="maxMessagesInContext"
|
||||||
|
value={formData.maxMessagesInContext}
|
||||||
|
onChange={handleChange}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{/* system prompt */}
|
||||||
|
<fieldset className="border border-dashed border-border p-4">
|
||||||
|
<legend className="px-2 font-mono text-xs text-foreground-tertiary lowercase">
|
||||||
|
system prompt
|
||||||
|
</legend>
|
||||||
|
<Textarea
|
||||||
|
id="systemPrompt"
|
||||||
|
name="systemPrompt"
|
||||||
|
value={formData.systemPrompt || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
rows={8}
|
||||||
|
resize="none"
|
||||||
|
placeholder="Instructions for the agent..."
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{/* error message */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-error/10 border border-error/20 rounded-lg">
|
||||||
|
<p className="text-sm text-error">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* footer */}
|
||||||
|
<div className="flex items-center justify-end gap-2 p-4 border-t border-dashed border-border bg-surface-secondary/30">
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={isSaving}>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Icon icon="loader" size="xs" className="mr-1.5 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Save Changes'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
apps/web/src/components/agents/ChatToolsPanel.tsx
Normal file
153
apps/web/src/components/agents/ChatToolsPanel.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@tpmjs/ui/Button/Button';
|
||||||
|
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||||
|
|
||||||
|
export interface ToolInfo {
|
||||||
|
id: string;
|
||||||
|
toolId: string;
|
||||||
|
tool: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
package: {
|
||||||
|
npmPackageName: string;
|
||||||
|
category: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionInfo {
|
||||||
|
id: string;
|
||||||
|
collectionId: string;
|
||||||
|
collection: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
toolCount: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatToolsPanelProps {
|
||||||
|
tools: ToolInfo[];
|
||||||
|
collections: CollectionInfo[];
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onToolClick: (tool: ToolInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatToolsPanel({
|
||||||
|
tools,
|
||||||
|
collections,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onToolClick,
|
||||||
|
}: ChatToolsPanelProps) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const totalToolsFromCollections = collections.reduce(
|
||||||
|
(acc, c) => acc + c.collection.toolCount,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-72 border-l border-dashed border-border flex flex-col bg-surface">
|
||||||
|
{/* header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-dashed border-border">
|
||||||
|
<span className="font-mono text-sm text-foreground-secondary lowercase">
|
||||||
|
available tools
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose} className="h-7 w-7 p-0">
|
||||||
|
<Icon icon="x" size="xs" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* tools list */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{/* direct tools */}
|
||||||
|
{tools.length > 0 && (
|
||||||
|
<fieldset className="m-3 border border-dashed border-border">
|
||||||
|
<legend className="px-2 font-mono text-xs text-foreground-tertiary lowercase">
|
||||||
|
direct tools ({tools.length})
|
||||||
|
</legend>
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
{tools.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToolClick(t)}
|
||||||
|
className="w-full text-left p-2 rounded hover:bg-primary/5 hover:border-primary/20 border border-transparent transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-primary mt-1.5 flex-shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-mono text-sm text-foreground truncate group-hover:text-primary transition-colors">
|
||||||
|
{t.tool.name}
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-xs text-foreground-tertiary truncate">
|
||||||
|
{t.tool.package.npmPackageName}
|
||||||
|
</p>
|
||||||
|
{t.tool.description && (
|
||||||
|
<p className="text-xs text-foreground-secondary mt-0.5 line-clamp-2">
|
||||||
|
{t.tool.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* collections */}
|
||||||
|
{collections.length > 0 && (
|
||||||
|
<fieldset className="m-3 border border-dashed border-border">
|
||||||
|
<legend className="px-2 font-mono text-xs text-foreground-tertiary lowercase">
|
||||||
|
from collections ({totalToolsFromCollections})
|
||||||
|
</legend>
|
||||||
|
<div className="p-2 space-y-2">
|
||||||
|
{collections.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className="p-2 border border-dashed border-border/50 rounded bg-surface-secondary/30"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon icon="folder" size="xs" className="text-foreground-tertiary" />
|
||||||
|
<span className="font-mono text-xs text-foreground truncate">
|
||||||
|
{c.collection.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-foreground-tertiary mt-1 pl-5">
|
||||||
|
{c.collection.toolCount} tools
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* empty state */}
|
||||||
|
{tools.length === 0 && collections.length === 0 && (
|
||||||
|
<div className="p-6 text-center">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-surface-secondary flex items-center justify-center mx-auto mb-3">
|
||||||
|
<Icon icon="puzzle" size="sm" className="text-foreground-tertiary" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-foreground-secondary">No tools attached</p>
|
||||||
|
<p className="text-xs text-foreground-tertiary mt-1">
|
||||||
|
Add tools from the agent settings
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* footer stats */}
|
||||||
|
<div className="p-3 border-t border-dashed border-border bg-surface-secondary/30">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-foreground-tertiary font-mono">
|
||||||
|
{tools.length} direct + {totalToolsFromCollections} from collections
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
apps/web/src/components/agents/ToolDetailsModal.tsx
Normal file
96
apps/web/src/components/agents/ToolDetailsModal.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||||
|
import { Button } from '@tpmjs/ui/Button/Button';
|
||||||
|
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||||
|
import { Modal } from '@tpmjs/ui/Modal/Modal';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import type { ToolInfo } from './ChatToolsPanel';
|
||||||
|
|
||||||
|
interface ToolDetailsModalProps {
|
||||||
|
tool: ToolInfo | null;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolDetailsModal({ tool, open, onClose }: ToolDetailsModalProps) {
|
||||||
|
if (!tool) return null;
|
||||||
|
|
||||||
|
const toolPageUrl = `/tool/${tool.tool.package.npmPackageName}/${tool.tool.name}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} size="lg">
|
||||||
|
{/* custom header */}
|
||||||
|
<div className="flex items-start justify-between p-6 border-b border-dashed border-border">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Icon icon="puzzle" size="sm" className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-mono text-lg text-foreground">{tool.tool.name}</h2>
|
||||||
|
<p className="font-mono text-sm text-foreground-tertiary mt-0.5">
|
||||||
|
{tool.tool.package.npmPackageName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded hover:bg-surface-secondary text-foreground-tertiary hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Icon icon="x" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* content */}
|
||||||
|
<div className="p-6 space-y-6 max-h-[60vh] overflow-y-auto">
|
||||||
|
{/* overview fieldset */}
|
||||||
|
<fieldset className="border border-dashed border-border p-4">
|
||||||
|
<legend className="px-2 font-mono text-xs text-foreground-tertiary lowercase">
|
||||||
|
overview
|
||||||
|
</legend>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{tool.tool.description && (
|
||||||
|
<p className="text-sm text-foreground-secondary leading-relaxed">
|
||||||
|
{tool.tool.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary">{tool.tool.package.category}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{/* note about parameters */}
|
||||||
|
<fieldset className="border border-dashed border-border p-4">
|
||||||
|
<legend className="px-2 font-mono text-xs text-foreground-tertiary lowercase">
|
||||||
|
parameters
|
||||||
|
</legend>
|
||||||
|
<p className="text-sm text-foreground-secondary">
|
||||||
|
This tool accepts parameters defined in its input schema. View the full tool page for
|
||||||
|
detailed parameter documentation and usage examples.
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* footer */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-t border-dashed border-border bg-surface-secondary/30">
|
||||||
|
<p className="text-xs text-foreground-tertiary font-mono">
|
||||||
|
id: {tool.tool.id.slice(0, 8)}...
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Link href={toolPageUrl}>
|
||||||
|
<Button size="sm">
|
||||||
|
<Icon icon="externalLink" size="xs" className="mr-1.5" />
|
||||||
|
View Full Details
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue