feat: show tool usage in the ai chat
This commit is contained in:
@@ -300,9 +300,10 @@ export class OpenCodeManager {
|
||||
systemPrompt,
|
||||
dbMessages,
|
||||
abortController.signal,
|
||||
{ onDelta }
|
||||
{ onDelta, onToolCall, onToolResult }
|
||||
);
|
||||
fullResponse = result.content;
|
||||
toolCallsCollected.push(...result.toolCalls);
|
||||
}
|
||||
console.log('[OpenCodeManager] fullResponse length:', fullResponse.length);
|
||||
} catch (error) {
|
||||
@@ -517,8 +518,12 @@ export class OpenCodeManager {
|
||||
systemPrompt: string,
|
||||
dbMessages: Array<{ role: string; content?: string }>,
|
||||
signal: AbortSignal,
|
||||
callbacks: { onDelta?: (delta: string) => void }
|
||||
): Promise<{ content: string }> {
|
||||
callbacks: {
|
||||
onDelta?: (delta: string) => void;
|
||||
onToolCall?: (toolCall: { name: string; args: unknown }) => void;
|
||||
onToolResult?: (result: { name: string; result: unknown }) => void;
|
||||
}
|
||||
): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> {
|
||||
// Build OpenAI-format messages
|
||||
const messages: Array<Record<string, unknown>> = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
@@ -542,6 +547,7 @@ export class OpenCodeManager {
|
||||
}));
|
||||
|
||||
let accumulatedText = '';
|
||||
const allToolCalls: Array<{ name: string; args: unknown }> = [];
|
||||
const MAX_TOOL_ROUNDS = 10;
|
||||
let round = 0;
|
||||
|
||||
@@ -614,7 +620,7 @@ export class OpenCodeManager {
|
||||
// If no tool calls, we're done
|
||||
if (!choice.message.tool_calls || choice.message.tool_calls.length === 0) {
|
||||
console.log('[OpenCodeManager:OpenAI] Done. Accumulated text length:', accumulatedText.length);
|
||||
return { content: accumulatedText };
|
||||
return { content: accumulatedText, toolCalls: allToolCalls };
|
||||
}
|
||||
|
||||
// Add assistant message (with tool_calls) to conversation
|
||||
@@ -624,8 +630,18 @@ export class OpenCodeManager {
|
||||
for (const toolCall of choice.message.tool_calls) {
|
||||
const toolName = toolCall.function.name;
|
||||
const toolArgs = JSON.parse(toolCall.function.arguments || '{}');
|
||||
|
||||
allToolCalls.push({ name: toolName, args: toolArgs });
|
||||
if (callbacks.onToolCall) {
|
||||
callbacks.onToolCall({ name: toolName, args: toolArgs });
|
||||
}
|
||||
|
||||
const result = await this.executeTool(toolName, toolArgs);
|
||||
|
||||
if (callbacks.onToolResult) {
|
||||
callbacks.onToolResult({ name: toolName, result });
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: JSON.stringify(result),
|
||||
@@ -636,7 +652,7 @@ export class OpenCodeManager {
|
||||
|
||||
// Hit max rounds
|
||||
const fallbackText = accumulatedText || 'I reached the maximum number of tool calls. Please try again.';
|
||||
return { content: fallbackText };
|
||||
return { content: fallbackText, toolCalls: allToolCalls };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -523,3 +523,54 @@
|
||||
.chat-message-text em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Tool usage markers */
|
||||
.tool-markers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 10px;
|
||||
background-color: var(--vscode-textBlockQuote-background, rgba(127, 127, 127, 0.1));
|
||||
border-left: 3px solid var(--vscode-textLink-foreground, #3794ff);
|
||||
border-radius: 0 4px 4px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tool-marker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tool-marker.pending .tool-marker-icon {
|
||||
color: var(--vscode-charts-yellow, #cca700);
|
||||
animation: tool-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tool-marker.completed .tool-marker-icon {
|
||||
color: var(--vscode-charts-green, #89d185);
|
||||
}
|
||||
|
||||
.tool-marker-name {
|
||||
font-family: var(--vscode-editor-font-family, monospace);
|
||||
font-weight: 600;
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.tool-marker-args {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-family: var(--vscode-editor-font-family, monospace);
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
@keyframes tool-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
const [toolEvents, setToolEvents] = useState<Array<{ type: 'call' | 'result'; name: string; args?: unknown; timestamp: number }>>([]);
|
||||
const [availableModels, setAvailableModels] = useState<ChatModel[]>([]);
|
||||
const [showModelSelector, setShowModelSelector] = useState(false);
|
||||
const [needsApiKey, setNeedsApiKey] = useState(false);
|
||||
@@ -22,6 +23,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const streamingRef = useRef('');
|
||||
const toolEventsRef = useRef<Array<{ name: string; args?: unknown }>>([]);
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
const scrollToBottom = useCallback(() => {
|
||||
@@ -72,6 +74,25 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
}
|
||||
});
|
||||
|
||||
const unsubToolCall = window.electronAPI?.chat.onToolCall((data) => {
|
||||
console.log('[ChatPanel] Tool call received:', data);
|
||||
if (data.conversationId === conversationId) {
|
||||
const toolCall = data.toolCall as { name: string; args: unknown };
|
||||
toolEventsRef.current.push({ name: toolCall.name, args: toolCall.args });
|
||||
setToolEvents(prev => [...prev, { type: 'call', name: toolCall.name, args: toolCall.args, timestamp: Date.now() }]);
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
const unsubToolResult = window.electronAPI?.chat.onToolResult((data) => {
|
||||
console.log('[ChatPanel] Tool result received:', data);
|
||||
if (data.conversationId === conversationId) {
|
||||
const result = data.result as { name: string; result: unknown };
|
||||
setToolEvents(prev => [...prev, { type: 'result', name: result.name, timestamp: Date.now() }]);
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
const unsubTitle = window.electronAPI?.chat.onTitleUpdated((data) => {
|
||||
if (data.conversationId === conversationId) {
|
||||
setConversation(prev => prev ? { ...prev, title: data.title } : null);
|
||||
@@ -80,6 +101,8 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
|
||||
return () => {
|
||||
unsubDelta?.();
|
||||
unsubToolCall?.();
|
||||
unsubToolResult?.();
|
||||
unsubTitle?.();
|
||||
};
|
||||
}, [conversationId, loadData, scrollToBottom, checkReady]);
|
||||
@@ -120,6 +143,8 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
setIsStreaming(true);
|
||||
streamingRef.current = '';
|
||||
setStreamingContent('');
|
||||
setToolEvents([]);
|
||||
toolEventsRef.current = [];
|
||||
|
||||
// Add user message optimistically
|
||||
const userMessage: ChatMessage = {
|
||||
@@ -145,6 +170,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: assistantContent,
|
||||
toolCalls: toolEventsRef.current.length > 0 ? JSON.stringify(toolEventsRef.current) : undefined,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
@@ -229,9 +255,62 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderToolMarkers = (events: Array<{ type: 'call' | 'result'; name: string; args?: unknown; timestamp: number }>) => {
|
||||
if (events.length === 0) return null;
|
||||
|
||||
// Group into pairs: call + result for each tool invocation
|
||||
const markers: Array<{ name: string; args?: unknown; completed: boolean }> = [];
|
||||
const pendingCalls = new Map<string, number>();
|
||||
|
||||
for (const event of events) {
|
||||
if (event.type === 'call') {
|
||||
markers.push({ name: event.name, args: event.args, completed: false });
|
||||
const count = pendingCalls.get(event.name) || 0;
|
||||
pendingCalls.set(event.name, count + 1);
|
||||
} else if (event.type === 'result') {
|
||||
// Find the last uncompleted marker for this tool
|
||||
for (let i = markers.length - 1; i >= 0; i--) {
|
||||
if (markers[i].name === event.name && !markers[i].completed) {
|
||||
markers[i].completed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tool-markers">
|
||||
{markers.map((marker, i) => {
|
||||
const argsPreview = marker.args
|
||||
? Object.entries(marker.args as Record<string, unknown>)
|
||||
.map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v.length > 30 ? v.slice(0, 30) + '...' : v}"` : JSON.stringify(v)}`)
|
||||
.join(', ')
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div key={i} className={`tool-marker ${marker.completed ? 'completed' : 'pending'}`}>
|
||||
<span className="tool-marker-icon">{marker.completed ? '\u2713' : '\u25CF'}</span>
|
||||
<span className="tool-marker-name">{marker.name}</span>
|
||||
{argsPreview && <span className="tool-marker-args">({argsPreview})</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMessage = (msg: ChatMessage) => {
|
||||
if (msg.role === 'system' || msg.role === 'tool') return null;
|
||||
|
||||
// Parse tool calls from stored message data
|
||||
const storedToolCalls: Array<{ name: string; args?: unknown; completed: boolean }> = [];
|
||||
if (msg.role === 'assistant' && msg.toolCalls) {
|
||||
try {
|
||||
const calls = JSON.parse(msg.toolCalls) as Array<{ name: string; args?: unknown }>;
|
||||
calls.forEach(c => storedToolCalls.push({ name: c.name, args: c.args, completed: true }));
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={`chat-message ${msg.role}`}>
|
||||
<div className="chat-message-avatar">
|
||||
@@ -243,6 +322,24 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
{msg.role === 'user' ? 'You' : 'Assistant'}
|
||||
</span>
|
||||
</div>
|
||||
{storedToolCalls.length > 0 && (
|
||||
<div className="tool-markers">
|
||||
{storedToolCalls.map((marker, i) => {
|
||||
const argsPreview = marker.args
|
||||
? Object.entries(marker.args as Record<string, unknown>)
|
||||
.map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v.length > 30 ? v.slice(0, 30) + '...' : v}"` : JSON.stringify(v)}`)
|
||||
.join(', ')
|
||||
: '';
|
||||
return (
|
||||
<div key={i} className="tool-marker completed">
|
||||
<span className="tool-marker-icon">{'\u2713'}</span>
|
||||
<span className="tool-marker-name">{marker.name}</span>
|
||||
{argsPreview && <span className="tool-marker-args">({argsPreview})</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-message-text">
|
||||
{msg.role === 'assistant' ? (
|
||||
<Markdown gfm>{msg.content}</Markdown>
|
||||
@@ -340,7 +437,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
|
||||
{messages.map(renderMessage)}
|
||||
|
||||
{isStreaming && streamingContent && (
|
||||
{isStreaming && (streamingContent || toolEvents.length > 0) && (
|
||||
<div className="chat-message assistant streaming">
|
||||
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
||||
<div className="chat-message-content">
|
||||
@@ -348,14 +445,17 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
<span className="chat-message-role">Assistant</span>
|
||||
<span className="streaming-indicator">{'\u25CF'}</span>
|
||||
</div>
|
||||
<div className="chat-message-text">
|
||||
<Markdown gfm>{streamingContent}</Markdown>
|
||||
</div>
|
||||
{renderToolMarkers(toolEvents)}
|
||||
{streamingContent && (
|
||||
<div className="chat-message-text">
|
||||
<Markdown gfm>{streamingContent}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isStreaming && !streamingContent && (
|
||||
{isStreaming && !streamingContent && toolEvents.length === 0 && (
|
||||
<div className="chat-message assistant thinking">
|
||||
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
||||
<div className="chat-message-content">
|
||||
|
||||
Reference in New Issue
Block a user