feat: show tool usage in the ai chat
This commit is contained in:
@@ -300,9 +300,10 @@ export class OpenCodeManager {
|
|||||||
systemPrompt,
|
systemPrompt,
|
||||||
dbMessages,
|
dbMessages,
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
{ onDelta }
|
{ onDelta, onToolCall, onToolResult }
|
||||||
);
|
);
|
||||||
fullResponse = result.content;
|
fullResponse = result.content;
|
||||||
|
toolCallsCollected.push(...result.toolCalls);
|
||||||
}
|
}
|
||||||
console.log('[OpenCodeManager] fullResponse length:', fullResponse.length);
|
console.log('[OpenCodeManager] fullResponse length:', fullResponse.length);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -517,8 +518,12 @@ export class OpenCodeManager {
|
|||||||
systemPrompt: string,
|
systemPrompt: string,
|
||||||
dbMessages: Array<{ role: string; content?: string }>,
|
dbMessages: Array<{ role: string; content?: string }>,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
callbacks: { onDelta?: (delta: string) => void }
|
callbacks: {
|
||||||
): Promise<{ content: string }> {
|
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
|
// Build OpenAI-format messages
|
||||||
const messages: Array<Record<string, unknown>> = [
|
const messages: Array<Record<string, unknown>> = [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
@@ -542,6 +547,7 @@ export class OpenCodeManager {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let accumulatedText = '';
|
let accumulatedText = '';
|
||||||
|
const allToolCalls: Array<{ name: string; args: unknown }> = [];
|
||||||
const MAX_TOOL_ROUNDS = 10;
|
const MAX_TOOL_ROUNDS = 10;
|
||||||
let round = 0;
|
let round = 0;
|
||||||
|
|
||||||
@@ -614,7 +620,7 @@ export class OpenCodeManager {
|
|||||||
// If no tool calls, we're done
|
// If no tool calls, we're done
|
||||||
if (!choice.message.tool_calls || choice.message.tool_calls.length === 0) {
|
if (!choice.message.tool_calls || choice.message.tool_calls.length === 0) {
|
||||||
console.log('[OpenCodeManager:OpenAI] Done. Accumulated text length:', accumulatedText.length);
|
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
|
// Add assistant message (with tool_calls) to conversation
|
||||||
@@ -624,8 +630,18 @@ export class OpenCodeManager {
|
|||||||
for (const toolCall of choice.message.tool_calls) {
|
for (const toolCall of choice.message.tool_calls) {
|
||||||
const toolName = toolCall.function.name;
|
const toolName = toolCall.function.name;
|
||||||
const toolArgs = JSON.parse(toolCall.function.arguments || '{}');
|
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);
|
const result = await this.executeTool(toolName, toolArgs);
|
||||||
|
|
||||||
|
if (callbacks.onToolResult) {
|
||||||
|
callbacks.onToolResult({ name: toolName, result });
|
||||||
|
}
|
||||||
|
|
||||||
messages.push({
|
messages.push({
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
content: JSON.stringify(result),
|
content: JSON.stringify(result),
|
||||||
@@ -636,7 +652,7 @@ export class OpenCodeManager {
|
|||||||
|
|
||||||
// Hit max rounds
|
// Hit max rounds
|
||||||
const fallbackText = accumulatedText || 'I reached the maximum number of tool calls. Please try again.';
|
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 {
|
.chat-message-text em {
|
||||||
font-style: italic;
|
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 [inputValue, setInputValue] = useState('');
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
const [streamingContent, setStreamingContent] = useState('');
|
const [streamingContent, setStreamingContent] = useState('');
|
||||||
|
const [toolEvents, setToolEvents] = useState<Array<{ type: 'call' | 'result'; name: string; args?: unknown; timestamp: number }>>([]);
|
||||||
const [availableModels, setAvailableModels] = useState<ChatModel[]>([]);
|
const [availableModels, setAvailableModels] = useState<ChatModel[]>([]);
|
||||||
const [showModelSelector, setShowModelSelector] = useState(false);
|
const [showModelSelector, setShowModelSelector] = useState(false);
|
||||||
const [needsApiKey, setNeedsApiKey] = useState(false);
|
const [needsApiKey, setNeedsApiKey] = useState(false);
|
||||||
@@ -22,6 +23,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const streamingRef = useRef('');
|
const streamingRef = useRef('');
|
||||||
|
const toolEventsRef = useRef<Array<{ name: string; args?: unknown }>>([]);
|
||||||
|
|
||||||
// Scroll to bottom when messages change
|
// Scroll to bottom when messages change
|
||||||
const scrollToBottom = useCallback(() => {
|
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) => {
|
const unsubTitle = window.electronAPI?.chat.onTitleUpdated((data) => {
|
||||||
if (data.conversationId === conversationId) {
|
if (data.conversationId === conversationId) {
|
||||||
setConversation(prev => prev ? { ...prev, title: data.title } : null);
|
setConversation(prev => prev ? { ...prev, title: data.title } : null);
|
||||||
@@ -80,6 +101,8 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubDelta?.();
|
unsubDelta?.();
|
||||||
|
unsubToolCall?.();
|
||||||
|
unsubToolResult?.();
|
||||||
unsubTitle?.();
|
unsubTitle?.();
|
||||||
};
|
};
|
||||||
}, [conversationId, loadData, scrollToBottom, checkReady]);
|
}, [conversationId, loadData, scrollToBottom, checkReady]);
|
||||||
@@ -120,6 +143,8 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
streamingRef.current = '';
|
streamingRef.current = '';
|
||||||
setStreamingContent('');
|
setStreamingContent('');
|
||||||
|
setToolEvents([]);
|
||||||
|
toolEventsRef.current = [];
|
||||||
|
|
||||||
// Add user message optimistically
|
// Add user message optimistically
|
||||||
const userMessage: ChatMessage = {
|
const userMessage: ChatMessage = {
|
||||||
@@ -145,6 +170,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
conversationId,
|
conversationId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: assistantContent,
|
content: assistantContent,
|
||||||
|
toolCalls: toolEventsRef.current.length > 0 ? JSON.stringify(toolEventsRef.current) : undefined,
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
setMessages(prev => [...prev, assistantMessage]);
|
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) => {
|
const renderMessage = (msg: ChatMessage) => {
|
||||||
if (msg.role === 'system' || msg.role === 'tool') return null;
|
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 (
|
return (
|
||||||
<div key={msg.id} className={`chat-message ${msg.role}`}>
|
<div key={msg.id} className={`chat-message ${msg.role}`}>
|
||||||
<div className="chat-message-avatar">
|
<div className="chat-message-avatar">
|
||||||
@@ -243,6 +322,24 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
{msg.role === 'user' ? 'You' : 'Assistant'}
|
{msg.role === 'user' ? 'You' : 'Assistant'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="chat-message-text">
|
||||||
{msg.role === 'assistant' ? (
|
{msg.role === 'assistant' ? (
|
||||||
<Markdown gfm>{msg.content}</Markdown>
|
<Markdown gfm>{msg.content}</Markdown>
|
||||||
@@ -340,7 +437,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
|
|
||||||
{messages.map(renderMessage)}
|
{messages.map(renderMessage)}
|
||||||
|
|
||||||
{isStreaming && streamingContent && (
|
{isStreaming && (streamingContent || toolEvents.length > 0) && (
|
||||||
<div className="chat-message assistant streaming">
|
<div className="chat-message assistant streaming">
|
||||||
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
||||||
<div className="chat-message-content">
|
<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="chat-message-role">Assistant</span>
|
||||||
<span className="streaming-indicator">{'\u25CF'}</span>
|
<span className="streaming-indicator">{'\u25CF'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-message-text">
|
{renderToolMarkers(toolEvents)}
|
||||||
<Markdown gfm>{streamingContent}</Markdown>
|
{streamingContent && (
|
||||||
</div>
|
<div className="chat-message-text">
|
||||||
|
<Markdown gfm>{streamingContent}</Markdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isStreaming && !streamingContent && (
|
{isStreaming && !streamingContent && toolEvents.length === 0 && (
|
||||||
<div className="chat-message assistant thinking">
|
<div className="chat-message assistant thinking">
|
||||||
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
||||||
<div className="chat-message-content">
|
<div className="chat-message-content">
|
||||||
|
|||||||
Reference in New Issue
Block a user