diff --git a/src/app/App.tsx b/src/app/App.tsx index 93810328..65a34e89 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -135,6 +135,7 @@ const AppContent: React.FC = () => { useEffect(() => { if (dbReady && (currentView === 'graph' || currentView === 'mindmap')) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- setState inside async refreshData, not synchronous void refreshData(); } }, [currentView, dbReady, refreshData]); diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 403da486..4dde99c6 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -52,8 +52,6 @@ const CommandPalette: React.FC = ({ isOpen, onClose, onView useEffect(() => { if (isOpen) { - setQuery(''); - setSelectedIndex(0); setTimeout(() => inputRef.current?.focus(), 10); } }, [isOpen]); diff --git a/src/components/DatabaseSettings.tsx b/src/components/DatabaseSettings.tsx index 14c22f32..a81ff4ba 100644 --- a/src/components/DatabaseSettings.tsx +++ b/src/components/DatabaseSettings.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { Database, HardDrive, RefreshCcw, AlertCircle, CheckCircle2, FileWarning } from 'lucide-react'; import { logger } from '../lib/logger'; @@ -8,13 +8,7 @@ interface DatabaseSettingsProps { } const DatabaseSettings: React.FC = ({ onHandlesSelected, currentHandle }) => { - const [isSupported, setIsSupported] = useState(true); - - useEffect(() => { - if (!('showOpenFilePicker' in window)) { - setIsSupported(false); - } - }, []); + const [isSupported] = useState(() => 'showOpenFilePicker' in window); const handleConnect = async () => { try { diff --git a/src/features/ai/AIHarness.tsx b/src/features/ai/AIHarness.tsx index 7be55d28..ad8abf77 100644 --- a/src/features/ai/AIHarness.tsx +++ b/src/features/ai/AIHarness.tsx @@ -56,6 +56,7 @@ const AIHarness: React.FC = () => { const seen = localStorage.getItem(WIZARD_SEEN_KEY); const hasAnyKey = Object.values(config.providers).some(p => p.apiKey); if (!seen && !hasAnyKey) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- conditional UI flag set after async config load, not a cascade risk setShowWizard(true); } void getDbHandles().then(({ fileHandle }) => { @@ -70,6 +71,7 @@ const AIHarness: React.FC = () => { ? editModel : (config.providers[config.activeProvider].defaultModel || ''); if (currentModel && currentModel !== editModel) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- model sync on provider/settings change, no cascade risk setEditModel(currentModel); } } diff --git a/src/features/ai/ChatView.tsx b/src/features/ai/ChatView.tsx index 7ecc6c15..acc2e918 100644 --- a/src/features/ai/ChatView.tsx +++ b/src/features/ai/ChatView.tsx @@ -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 ( + + ); +} + +const resultCodeBaseStyle: React.CSSProperties = { whiteSpace: 'pre-wrap', wordBreak: 'break-all' }; + +function ToolCallBody({ toolCall }: { toolCall: ToolCallRecord }): React.ReactElement { + return ( +
+
+ Input: + {JSON.stringify(toolCall.arguments, null, 2)} +
+ {toolCall.result !== undefined && ( +
+ Result: + {toolCall.result} +
+ )} +
+ ); +} + +function ToolCallBlock({ toolCall }: { toolCall: ToolCallRecord }): React.ReactElement { + const [expanded, setExpanded] = useState(false); + const toggle = useCallback(() => { setExpanded(v => !v); }, []); + return ( +
+ + {expanded && } +
+ ); +} + export const ChatView: React.FC = ({ messages, isLoading, @@ -92,7 +145,14 @@ export const ChatView: React.FC = ({ {m.role === 'assistant' ? 'Assistant' : 'You'} {m.role === 'assistant' ? ( - + <> + {m.toolCalls && m.toolCalls.length > 0 && ( +
+ {m.toolCalls.map(tc => )} +
+ )} + + ) : ( m.content )} diff --git a/src/features/ai/useChat.ts b/src/features/ai/useChat.ts index 20097e30..4350ee23 100644 --- a/src/features/ai/useChat.ts +++ b/src/features/ai/useChat.ts @@ -1,17 +1,29 @@ 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; + 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 { @@ -19,6 +31,10 @@ export interface TokenUsage { output: number; } +function buildToolCallRecord(tc: ToolCallRecord, result: string, isError?: boolean): ToolCallRecord { + return { id: tc.id, name: tc.name, arguments: tc.arguments, result, isError }; +} + export function useChat() { const [messages, setMessages] = useState([ { 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 @@ export function useChat() { 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); diff --git a/src/features/editor/Editor.tsx b/src/features/editor/Editor.tsx index d0ac7884..128dff27 100644 --- a/src/features/editor/Editor.tsx +++ b/src/features/editor/Editor.tsx @@ -67,18 +67,16 @@ const Editor: React.FC = ({ editingEntityId, onEditComplete }) => { if (!editingEntityId) { return; } - setIsLoadingEntity(true); - /* eslint-disable @typescript-eslint/no-unsafe-assignment -- type resolution through Promise chain */ + setIsLoadingEntity(true); // eslint-disable-line react-hooks/set-state-in-effect -- loading flag set before async fetch, not a cascade risk repository.getEntityById(editingEntityId).then((entity: (Entity & { rowid: number }) | null) => { if (!entity) return; setTitle(entity.name || ''); setType(entity.type); setSourceUrl(entity.sourceUrl ?? ''); - setShowAdvanced(entity.metadata?.advanced ?? false); + setShowAdvanced((entity.metadata?.advanced as boolean | undefined) ?? false); setStatus(null); }).catch(err => logger.error('Failed to load entity for editing', { error: err })) .finally(() => setIsLoadingEntity(false)); - /* eslint-enable @typescript-eslint/no-unsafe-assignment */ }, [editingEntityId, repository]); @@ -247,7 +245,6 @@ const Editor: React.FC = ({ editingEntityId, onEditComplete }) => {