-
Notifications
You must be signed in to change notification settings - Fork 0
feat(ai): add agentic tool-calling loop with search_knowledge, create_note, add_graph_node tools #293
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat(ai): add agentic tool-calling loop with search_knowledge, create_note, add_graph_node tools #293
Changes from all commits
57174fa
1d4e130
26e5403
9f03107
2785c68
ed5a7df
2f30303
30a34f0
601cf84
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| import React, { useRef, useEffect, useCallback } from 'react'; | ||
| import { Bot, User, Loader2, Globe, ExternalLink, X, Send } from 'lucide-react'; | ||
| import { Message, TokenUsage } from './useChat'; | ||
| import React, { useRef, useEffect, useCallback, useState } from 'react'; | ||
| import { Bot, User, Loader2, Globe, ExternalLink, X, Send, ChevronDown, ChevronRight, Wrench } from 'lucide-react'; | ||
| import { Message, TokenUsage, ToolCallRecord } from './useChat'; | ||
| import { ResolvedContent } from '../../lib/resolver'; | ||
| import MarkdownRenderer from '../../lib/llm/markdown'; | ||
|
|
||
|
|
@@ -23,6 +23,59 @@ const safeHostname = (url: string): string => { | |
| try { return new URL(url).hostname; } catch { return url; } | ||
| }; | ||
|
|
||
| const nameStyle: React.CSSProperties = { fontWeight: 600 }; | ||
| const labelStyle: React.CSSProperties = { fontWeight: 600, color: 'var(--text-muted)' }; | ||
| const codeStyle: React.CSSProperties = { whiteSpace: 'pre-wrap', wordBreak: 'break-all' }; | ||
| const bodyDivStyle: React.CSSProperties = { padding: '6px 8px', background: 'var(--surface-primary)', display: 'flex', flexDirection: 'column', gap: '4px' }; | ||
| const blockStyle: React.CSSProperties = { margin: '4px 0', border: '1px solid var(--border-default)', borderRadius: '6px', fontSize: '12px', overflow: 'hidden' }; | ||
| const btnStyle: React.CSSProperties = { width: '100%', display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 8px', background: 'var(--surface-secondary)', border: 'none', cursor: 'pointer', textAlign: 'left' }; | ||
| const errorStyle: React.CSSProperties = { color: '#dc2626', marginLeft: 'auto' }; | ||
| const iconBaseStyle: React.CSSProperties = { marginLeft: 'auto' }; | ||
| const errorIconStyle: React.CSSProperties = { marginLeft: '0' }; | ||
|
|
||
| function ToolCallHeader({ toolCall, expanded, onToggle }: { toolCall: ToolCallRecord; expanded: boolean; onToggle: () => void }): React.ReactElement { | ||
| return ( | ||
| <button type="button" onClick={onToggle} style={btnStyle} aria-expanded={expanded}> | ||
| <Wrench size={12} /> | ||
| <span style={nameStyle}>{toolCall.name}</span> | ||
| {toolCall.isError && <span style={errorStyle}>error</span>} | ||
| <span style={toolCall.isError ? errorIconStyle : iconBaseStyle}> | ||
| {expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />} | ||
| </span> | ||
| </button> | ||
| ); | ||
| } | ||
|
|
||
| const resultCodeBaseStyle: React.CSSProperties = { whiteSpace: 'pre-wrap', wordBreak: 'break-all' }; | ||
|
|
||
| function ToolCallBody({ toolCall }: { toolCall: ToolCallRecord }): React.ReactElement { | ||
| return ( | ||
| <div style={bodyDivStyle}> | ||
| <div> | ||
| <span style={labelStyle}>Input: </span> | ||
| <code style={codeStyle}>{JSON.stringify(toolCall.arguments, null, 2)}</code> | ||
| </div> | ||
| {toolCall.result !== undefined && ( | ||
| <div> | ||
| <span style={labelStyle}>Result: </span> | ||
| <code style={toolCall.isError ? { ...resultCodeBaseStyle, color: '#dc2626' } : resultCodeBaseStyle}>{toolCall.result}</code> | ||
| </div> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
|
Comment on lines
+51
to
+66
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| function ToolCallBlock({ toolCall }: { toolCall: ToolCallRecord }): React.ReactElement { | ||
| const [expanded, setExpanded] = useState(false); | ||
| const toggle = useCallback(() => { setExpanded(v => !v); }, []); | ||
| return ( | ||
| <div style={blockStyle}> | ||
| <ToolCallHeader toolCall={toolCall} expanded={expanded} onToggle={toggle} /> | ||
| {expanded && <ToolCallBody toolCall={toolCall} />} | ||
| </div> | ||
| ); | ||
| } | ||
|
Comment on lines
+68
to
+77
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| export const ChatView: React.FC<ChatViewProps> = ({ | ||
| messages, | ||
| isLoading, | ||
|
|
@@ -92,7 +145,14 @@ export const ChatView: React.FC<ChatViewProps> = ({ | |
| {m.role === 'assistant' ? 'Assistant' : 'You'} | ||
| </div> | ||
| {m.role === 'assistant' ? ( | ||
| <MarkdownRenderer content={m.content} /> | ||
| <> | ||
| {m.toolCalls && m.toolCalls.length > 0 && ( | ||
| <div style={{ marginBottom: '6px' }}> | ||
| {m.toolCalls.map(tc => <ToolCallBlock key={tc.id} toolCall={tc} />)} | ||
| </div> | ||
| )} | ||
| <MarkdownRenderer content={m.content} /> | ||
| </> | ||
| ) : ( | ||
| m.content | ||
| )} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,24 +1,40 @@ | ||
| import { useState, useCallback, useRef, useEffect } from 'react'; | ||
| import { loadConfig, createProvider } from '../../lib/llm/config'; | ||
| import { LLMMessage } from '../../lib/llm/types'; | ||
| import type { LLMMessage } from '../../lib/llm/types'; | ||
| import { BUILT_IN_TOOLS } from '../../lib/llm/tool-registry'; | ||
| import { executeTool } from '../../lib/llm/tool-executor'; | ||
| import { searchKnowledge } from '../../lib/search'; | ||
| import { resolveUrl, ResolvedContent } from '../../lib/resolver'; | ||
| import { logger } from '../../lib/logger'; | ||
|
|
||
| const URL_REGEX = /https?:\/\/[^\s<>"'{}|\\^[\]]+/gi; | ||
| const MAX_TOOL_ROUNDS = 5; | ||
|
|
||
| export interface ToolCallRecord { | ||
| id: string; | ||
| name: string; | ||
| arguments: Record<string, unknown>; | ||
| result?: string; | ||
| isError?: boolean; | ||
| } | ||
|
|
||
| export interface Message { | ||
| id: string; | ||
| role: 'assistant' | 'user' | 'system'; | ||
| content: string; | ||
| tokenUsage?: { input: number; output: number }; | ||
| toolCalls?: ToolCallRecord[]; | ||
| } | ||
|
|
||
| export interface TokenUsage { | ||
| input: number; | ||
| output: number; | ||
| } | ||
|
|
||
| function buildToolCallRecord(tc: ToolCallRecord, result: string, isError?: boolean): ToolCallRecord { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| return { id: tc.id, name: tc.name, arguments: tc.arguments, result, isError }; | ||
| } | ||
|
Comment on lines
+34
to
+36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| export function useChat() { | ||
| const [messages, setMessages] = useState<Message[]>([ | ||
| { id: 'initial', role: 'assistant', content: 'AI agent ready to assist with TRIZ analysis and knowledge synthesis. Ask me anything about your local knowledge base, or paste URLs to have me fetch and analyze external content.' } | ||
|
|
@@ -86,59 +102,107 @@ | |
| const currentConfig = await loadConfig(); | ||
| const provider = createProvider(currentConfig); | ||
|
|
||
| const promptMessages = [ | ||
| { role: 'system', content: 'You are a helpful knowledge assistant. Ground your answers in the provided context whenever possible. When external URLs are provided, analyze their content thoroughly and cite specific details. Mark sources clearly in your response.' }, | ||
| ...messagesRef.current.map(m => ({ role: m.role, content: m.content })), | ||
| { role: 'user', content: userMessage + contextString + externalContent } | ||
| ]; | ||
|
|
||
| const providerConfig = currentConfig.providers[currentConfig.activeProvider]; | ||
| const model = activeModel || providerConfig.defaultModel || 'google/gemini-2.0-flash-lite-preview-02-05:free'; | ||
|
|
||
| let streamedContent = ''; | ||
| let streamUsage: { input: number; output: number } | undefined; | ||
| const systemMessage: LLMMessage = { | ||
| role: 'system', | ||
| content: 'You are a helpful knowledge assistant. Ground your answers in the provided context whenever possible. When external URLs are provided, analyze their content thoroughly and cite specific details. Mark sources clearly in your response.', | ||
| }; | ||
|
|
||
| let promptMessages: LLMMessage[] = [ | ||
| systemMessage, | ||
| ...messagesRef.current.map(m => ({ role: m.role, content: m.content })), | ||
| { role: 'user', content: userMessage + contextString + externalContent }, | ||
| ]; | ||
|
|
||
| // --- Agentic tool-call loop --- | ||
| const assistantId = crypto.randomUUID(); | ||
| setMessages(prev => [...prev, { id: assistantId, role: 'assistant', content: '', tokenUsage: undefined }]); | ||
| const accumulatedToolCalls: ToolCallRecord[] = []; | ||
|
|
||
| for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { | ||
| const response = await provider.chat({ | ||
| model, | ||
| messages: promptMessages, | ||
| temperature: 0.7, | ||
| maxTokens: 1000, | ||
| tools: BUILT_IN_TOOLS, | ||
| }); | ||
|
|
||
| const stream = provider.chatStream({ | ||
| model, | ||
| messages: promptMessages as LLMMessage[], | ||
| temperature: 0.7, | ||
| maxTokens: 1000 | ||
| }); | ||
| if (!response.toolCalls?.length) { | ||
| // No more tool calls — stream the final answer | ||
| let streamedContent = ''; | ||
| let streamUsage: { input: number; output: number } | undefined; | ||
| setMessages(prev => [ | ||
| ...prev, | ||
| { | ||
| id: assistantId, | ||
| role: 'assistant', | ||
| content: '', | ||
| toolCalls: accumulatedToolCalls.length > 0 ? accumulatedToolCalls : undefined, | ||
| }, | ||
| ]); | ||
|
|
||
| const stream = provider.chatStream({ model, messages: promptMessages, temperature: 0.7, maxTokens: 1000 }); | ||
| for await (const chunk of stream) { | ||
| if (chunk.done) { | ||
| if (chunk.usage) { | ||
| streamUsage = { input: chunk.usage.inputTokens, output: chunk.usage.outputTokens }; | ||
| setSessionTokens(prev => ({ | ||
| input: prev.input + chunk.usage.inputTokens, | ||
| output: prev.output + chunk.usage.outputTokens, | ||
| })); | ||
| } | ||
| break; | ||
| } | ||
| const content: string = chunk.content; | ||
| streamedContent += content; | ||
| setMessages(prev => { | ||
| const updated = [...prev]; | ||
| const last = updated[updated.length - 1]; | ||
| if (last && last.id === assistantId) { | ||
| updated[updated.length - 1] = { ...last, content: streamedContent }; | ||
| } | ||
| return updated; | ||
| }); | ||
| } | ||
|
|
||
| for await (const chunk of stream) { | ||
| if (chunk.done) { | ||
| if (chunk.usage) { | ||
| streamUsage = { input: chunk.usage.inputTokens, output: chunk.usage.outputTokens }; | ||
| setSessionTokens(prev => ({ | ||
| input: prev.input + chunk.usage.inputTokens, | ||
| output: prev.output + chunk.usage.outputTokens, | ||
| })); | ||
| if (streamUsage) { | ||
| setMessages(prev => { | ||
| const updated = [...prev]; | ||
| const last = updated[updated.length - 1]; | ||
| if (last && last.id === assistantId) { | ||
| updated[updated.length - 1] = { ...last, content: streamedContent, tokenUsage: streamUsage }; | ||
| } | ||
| return updated; | ||
| }); | ||
| } | ||
| break; | ||
| } | ||
| const content: string = chunk.content; | ||
| streamedContent += content; | ||
| setMessages(prev => { | ||
| const updated = [...prev]; | ||
| const last = updated[updated.length - 1]; | ||
| if (last && last.id === assistantId) { | ||
| updated[updated.length - 1] = { ...last, content: streamedContent }; | ||
| } | ||
| return updated; | ||
| }); | ||
| } | ||
|
|
||
| if (streamUsage) { | ||
| setMessages(prev => { | ||
| const updated = [...prev]; | ||
| const last = updated[updated.length - 1]; | ||
| if (last && last.id === assistantId) { | ||
| updated[updated.length - 1] = { ...last, content: streamedContent, tokenUsage: streamUsage }; | ||
| } | ||
| return updated; | ||
| // Execute tool calls and collect results | ||
| const toolResults = await Promise.all( | ||
| response.toolCalls.map(tc => | ||
| executeTool(tc, { search: searchKnowledge }) | ||
| ) | ||
| ); | ||
|
|
||
| response.toolCalls.forEach((tc, i) => { | ||
| const tr = toolResults[i]; | ||
| const rec: ToolCallRecord = { id: tc.id, name: tc.name, arguments: tc.arguments, result: tr.content, isError: tr.isError }; | ||
| accumulatedToolCalls.push(rec); | ||
| }); | ||
|
|
||
| // Append assistant tool-call message + tool result messages for next round | ||
| promptMessages = [ | ||
| ...promptMessages, | ||
| { role: 'assistant', content: response.content, tool_calls: response.toolCalls }, | ||
| ...toolResults.map(tr => ({ | ||
| role: 'tool' as const, | ||
| content: tr.content, | ||
| tool_call_id: tr.toolCallId, | ||
| })), | ||
| ]; | ||
| } | ||
| } catch (err) { | ||
| logger.error('AI chat failed', err); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is considered a best practice to avoid 'polluting' the global scope with variables that are intended to be local to the script. Global variables created from a script can produce name collisions with global variables created from another script, which will usually lead to runtime errors or unexpected behavior. It is mostly useful for browser scripts.