Skip to content
Open
1 change: 1 addition & 0 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
2 changes: 0 additions & 2 deletions src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,6 @@ const CommandPalette: React.FC<CommandPaletteProps> = ({ isOpen, onClose, onView

useEffect(() => {
if (isOpen) {
setQuery('');
setSelectedIndex(0);
setTimeout(() => inputRef.current?.focus(), 10);
}
}, [isOpen]);
Expand Down
10 changes: 2 additions & 8 deletions src/components/DatabaseSettings.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -8,13 +8,7 @@ interface DatabaseSettingsProps {
}

const DatabaseSettings: React.FC<DatabaseSettingsProps> = ({ onHandlesSelected, currentHandle }) => {
const [isSupported, setIsSupported] = useState(true);

useEffect(() => {
if (!('showOpenFilePicker' in window)) {
setIsSupported(false);
}
}, []);
const [isSupported] = useState(() => 'showOpenFilePicker' in window);

const handleConnect = async () => {
try {
Expand Down
2 changes: 2 additions & 0 deletions src/features/ai/AIHarness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand All @@ -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);
}
}
Expand Down
68 changes: 64 additions & 4 deletions src/features/ai/ChatView.tsx
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';

Expand All @@ -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>
);
}
Comment on lines +36 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unexpected function declaration in the global scope, wrap in an IIFE for a local variable, assign as global property for a global variable


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.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unexpected function declaration in the global scope, wrap in an IIFE for a local variable, assign as global property for a global variable


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.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unexpected function declaration in the global scope, wrap in an IIFE for a local variable, assign as global property for a global variable


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.


export const ChatView: React.FC<ChatViewProps> = ({
messages,
isLoading,
Expand Down Expand Up @@ -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
)}
Expand Down
150 changes: 107 additions & 43 deletions src/features/ai/useChat.ts
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 {

Check warning on line 34 in src/features/ai/useChat.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/ai/useChat.ts#L34

This function buildToolCallRecord is unused.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'buildToolCallRecord' is defined but never used


Unused variables are generally considered a code smell and should be avoided.

return { id: tc.id, name: tc.name, arguments: tc.arguments, result, isError };
}
Comment on lines +34 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unexpected function declaration in the global scope, wrap in an IIFE for a local variable, assign as global property for a global variable


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.


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.' }
Expand Down Expand Up @@ -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];

Check warning on line 191 in src/features/ai/useChat.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/ai/useChat.ts#L191

Variable Assigned to Object Injection Sink
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);
Expand Down
8 changes: 2 additions & 6 deletions src/features/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,18 +67,16 @@ const Editor: React.FC<EditorProps> = ({ 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]);

Expand Down Expand Up @@ -247,7 +245,6 @@ const Editor: React.FC<EditorProps> = ({ editingEntityId, onEditComplete }) => {
</div>
<div className="toolbar">
<button
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return
onClick={() => editor?.chain().focus().toggleBold().run()}
className={editor?.isActive('bold') ? 'active' : ''}
aria-label="Toggle Bold"
Expand All @@ -256,7 +253,6 @@ const Editor: React.FC<EditorProps> = ({ editingEntityId, onEditComplete }) => {
B
</button>
<button
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return
onClick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor?.isActive('heading', { level: 1 }) ? 'active' : ''}
aria-label="Toggle Heading 1"
Expand Down
Loading
Loading