@@ -3,6 +3,7 @@ import { useStore } from '../store';
33import { askFlintAI , fetchOllamaModels , checkOllamaStatus , checkAgentStatus } from '../services/ollama' ;
44import { FlintLogo } from './FlintLogo' ;
55import { X , Send , Trash2 , User , Loader2 , Settings , Wifi , Globe , Brain , BookOpen , Network , Sparkles , Zap , Cpu , Server , AlertTriangle } from 'lucide-react' ;
6+ import type { AIAction } from '../types' ;
67
78export function AIChat ( ) {
89 const { state, dispatch } = useStore ( ) ;
@@ -18,6 +19,8 @@ export function AIChat() {
1819 const messagesEndRef = useRef < HTMLDivElement > ( null ) ;
1920 const inputRef = useRef < HTMLTextAreaElement > ( null ) ;
2021 const abortRef = useRef ( false ) ;
22+ const messagesContainerRef = useRef < HTMLDivElement > ( null ) ;
23+ const shouldAutoScrollRef = useRef ( true ) ;
2124
2225 const { aiMessages, aiSettings, notes, activeNoteId } = state ;
2326
@@ -72,12 +75,72 @@ export function AIChat() {
7275
7376 // Scroll to bottom
7477 useEffect ( ( ) => {
75- messagesEndRef . current ?. scrollIntoView ( { behavior : 'smooth' } ) ;
78+ if ( shouldAutoScrollRef . current ) {
79+ messagesEndRef . current ?. scrollIntoView ( { behavior : 'smooth' } ) ;
80+ }
7681 } , [ aiMessages , streamContent ] ) ;
7782
83+ const handleMessagesScroll = ( ) => {
84+ const el = messagesContainerRef . current ;
85+ if ( ! el ) return ;
86+ const distanceFromBottom = el . scrollHeight - el . scrollTop - el . clientHeight ;
87+ shouldAutoScrollRef . current = distanceFromBottom < 120 ;
88+ } ;
89+
90+ const resolveTargetNoteId = ( action : AIAction ) : string | null => {
91+ if ( action . target === 'active' ) return activeNoteId ;
92+ if ( action . target === 'id' && action . noteId ) return action . noteId ;
93+ if ( action . target === 'title' && action . matchTitle ) {
94+ const match = notes . find ( n => n . title . toLowerCase ( ) === action . matchTitle ! . toLowerCase ( ) ) ;
95+ return match ?. id || null ;
96+ }
97+ return activeNoteId ;
98+ } ;
99+
100+ const applyAIActions = ( actions : AIAction [ ] ) => {
101+ const summaries : string [ ] = [ ] ;
102+ actions . forEach ( action => {
103+ if ( action . type === 'rename_note' ) {
104+ const noteId = resolveTargetNoteId ( action ) ;
105+ if ( ! noteId || ! action . title ) return ;
106+ dispatch ( { type : 'RENAME_NOTE' , payload : { id : noteId , title : action . title } } ) ;
107+ summaries . push ( `Renamed note to "${ action . title } "` ) ;
108+ }
109+ if ( action . type === 'update_note' ) {
110+ const noteId = resolveTargetNoteId ( action ) ;
111+ if ( ! noteId || ! action . content ) return ;
112+ dispatch ( { type : 'UPDATE_NOTE' , payload : { id : noteId , content : action . content } } ) ;
113+ summaries . push ( 'Updated note content' ) ;
114+ }
115+ if ( action . type === 'create_note' && action . title ) {
116+ const newNote = {
117+ id : Math . random ( ) . toString ( 36 ) . slice ( 2 ) + Date . now ( ) . toString ( 36 ) ,
118+ title : action . title ,
119+ content : action . content || '# ' + action . title + '\n\n' ,
120+ folderId : null ,
121+ pinned : false ,
122+ createdAt : Date . now ( ) ,
123+ updatedAt : Date . now ( ) ,
124+ } ;
125+ dispatch ( { type : 'ADD_NOTE' , payload : newNote } ) ;
126+ summaries . push ( `Created note "${ action . title } "` ) ;
127+ }
128+ if ( action . type === 'delete_note' ) {
129+ const noteId = resolveTargetNoteId ( action ) ;
130+ if ( ! noteId ) return ;
131+ dispatch ( { type : 'DELETE_NOTE' , payload : noteId } ) ;
132+ summaries . push ( 'Deleted a note' ) ;
133+ }
134+ } ) ;
135+ return summaries ;
136+ } ;
137+
78138 const activeNote = notes . find ( n => n . id === activeNoteId ) ;
79- const isApiProvider = aiSettings . provider !== 'ollama' ;
139+ const isCredentialProvider = aiSettings . provider === 'openai' || aiSettings . provider === 'gemini' || aiSettings . provider === 'openai-compatible' ;
140+ const isLocalProvider = aiSettings . provider === 'local-gguf' ;
141+ const isApiProvider = isCredentialProvider ;
80142 const hasApiConfig = ! ! aiSettings . apiKey && ! ! aiSettings . model ;
143+ const hasLocalConfig = ! ! aiSettings . localModelPath ;
81144
82145 const sendMessage = useCallback ( async ( ) => {
83146 const trimmed = input . trim ( ) ;
@@ -109,12 +172,14 @@ export function AIChat() {
109172 if ( abortRef . current ) return ;
110173 setStreamContent ( prev => prev + chunk ) ;
111174 } ,
112- ( fullContent , webResults , usedOllama ) => {
175+ ( fullContent , webResults , usedOllama , actions ) => {
113176 if ( abortRef . current ) return ;
177+ const actionSummaries = actions ?. length ? applyAIActions ( actions ) : [ ] ;
178+ const actionSuffix = actionSummaries . length ? `\n\nChanges: ${ actionSummaries . join ( '; ' ) } .` : '' ;
114179 const assistantMsg = {
115180 id : Math . random ( ) . toString ( 36 ) . slice ( 2 ) + Date . now ( ) . toString ( 36 ) ,
116181 role : 'assistant' as const ,
117- content : fullContent ,
182+ content : fullContent + actionSuffix ,
118183 timestamp : Date . now ( ) ,
119184 webResults : usedOllama ? webResults : undefined ,
120185 } ;
@@ -162,7 +227,8 @@ export function AIChat() {
162227 const isAgentMode = agentStatus === 'agent-up' ;
163228 const isOllamaMode = isAgentMode && aiSettings . provider === 'ollama' && ollamaStatus === 'connected' && ! ! aiSettings . model ;
164229 const isCloudMode = isAgentMode && isApiProvider && hasApiConfig ;
165- const providerName = aiSettings . provider === 'openai' ? 'OpenAI' : aiSettings . provider === 'gemini' ? 'Gemini' : aiSettings . provider === 'openai-compatible' ? 'Custom API' : 'Ollama' ;
230+ const isLocalMode = isAgentMode && isLocalProvider && hasLocalConfig ;
231+ const providerName = aiSettings . provider === 'openai' ? 'OpenAI' : aiSettings . provider === 'gemini' ? 'Gemini' : aiSettings . provider === 'openai-compatible' ? 'Custom API' : aiSettings . provider === 'local-gguf' ? 'Local GGUF' : 'Ollama' ;
166232
167233 return (
168234 < div style = { {
@@ -184,10 +250,12 @@ export function AIChat() {
184250 </ div >
185251 < div style = { { flex : 1 } } >
186252 < div style = { { fontSize : 12 , fontWeight : 600 , color : '#aaa' } } > Flint AI</ div >
187- < div className = "flex items-center gap-1" style = { { fontSize : 10 , color : isOllamaMode || isCloudMode ? '#5a5' : isAgentMode ? '#855' : '#655' } } >
188- { ( isOllamaMode || isCloudMode ) ? < Wifi size = { 8 } /> : isAgentMode ? < Server size = { 8 } /> : < Cpu size = { 8 } /> }
253+ < div className = "flex items-center gap-1" style = { { fontSize : 10 , color : isOllamaMode || isCloudMode || isLocalMode ? '#5a5' : isAgentMode ? '#855' : '#655' } } >
254+ { ( isOllamaMode || isCloudMode || isLocalMode ) ? < Wifi size = { 8 } /> : isAgentMode ? < Server size = { 8 } /> : < Cpu size = { 8 } /> }
189255 { isOllamaMode
190256 ? `Ollama · ${ aiSettings . model } `
257+ : isLocalMode
258+ ? `Local GGUF · ${ aiSettings . localModelPath . split ( '/' ) . pop ( ) || 'model.gguf' } `
191259 : isCloudMode
192260 ? `${ providerName } · ${ aiSettings . model } `
193261 : isAgentMode
@@ -246,19 +314,24 @@ export function AIChat() {
246314 < div style = { { fontSize : 10 , color : '#555' , marginBottom : 8 , padding : '4px 8px' , background : '#0a0a0a' , borderRadius : 4 , border : '1px solid #1a1a1a' } } >
247315 { isOllamaMode
248316 ? `Agent + Ollama (${ aiSettings . model } ) — full AI`
317+ : isLocalMode
318+ ? `Agent + Local GGUF (${ aiSettings . localModelPath . split ( '/' ) . pop ( ) || 'model.gguf' } ) — full AI`
249319 : isCloudMode
250320 ? `Agent + ${ providerName } (${ aiSettings . model } ) — full AI`
251321 : isAgentMode
252- ? isApiProvider
322+ ? isLocalProvider
323+ ? 'Agent running — set GGUF model path to enable local model'
324+ : isApiProvider
253325 ? `Agent running — add API key + model for ${ providerName } `
254326 : `Agent running — no Ollama. Install: ollama pull llama3.2`
255327 : `Agent not running. Run: python3 ~/.flint/agent/agent.py` }
256328 </ div >
257329 < ConfigField label = "Provider" >
258330 < select value = { aiSettings . provider }
259- onChange = { e => dispatch ( { type : 'UPDATE_AI_SETTINGS' , payload : { provider : e . target . value as 'ollama' | 'openai' | 'gemini' | 'openai-compatible' } } ) }
331+ onChange = { e => dispatch ( { type : 'UPDATE_AI_SETTINGS' , payload : { provider : e . target . value as 'ollama' | 'openai' | 'gemini' | 'openai-compatible' | 'local-gguf' } } ) }
260332 style = { { ...inputStyle , fontSize : 11 } } >
261333 < option value = "ollama" > Ollama (local)</ option >
334+ < option value = "local-gguf" > Local GGUF file</ option >
262335 < option value = "openai" > OpenAI</ option >
263336 < option value = "gemini" > Gemini</ option >
264337 < option value = "openai-compatible" > OpenAI-compatible</ option >
@@ -277,6 +350,30 @@ export function AIChat() {
277350 style = { { ...inputStyle , fontSize : 11 } } />
278351 </ ConfigField >
279352 ) }
353+ { isLocalProvider && (
354+ < ConfigField label = "GGUF path" >
355+ < input type = "text" value = { aiSettings . localModelPath }
356+ onChange = { e => dispatch ( { type : 'UPDATE_AI_SETTINGS' , payload : { localModelPath : e . target . value } } ) }
357+ placeholder = "/path/to/model.gguf"
358+ style = { { ...inputStyle , fontSize : 11 } } />
359+ </ ConfigField >
360+ ) }
361+ { isLocalProvider && (
362+ < ConfigField label = "Local ctx" >
363+ < input type = "range" min = { 512 } max = { 8192 } step = { 256 } value = { aiSettings . localModelContext }
364+ onChange = { e => dispatch ( { type : 'UPDATE_AI_SETTINGS' , payload : { localModelContext : parseInt ( e . target . value ) } } ) }
365+ style = { { flex : 1 , accentColor : '#666' } } />
366+ < span style = { { fontSize : 10 , color : '#555' , width : 44 , textAlign : 'right' } } > { aiSettings . localModelContext } </ span >
367+ </ ConfigField >
368+ ) }
369+ { isLocalProvider && (
370+ < ConfigField label = "Threads" >
371+ < input type = "range" min = { 1 } max = { 16 } step = { 1 } value = { aiSettings . localModelThreads }
372+ onChange = { e => dispatch ( { type : 'UPDATE_AI_SETTINGS' , payload : { localModelThreads : parseInt ( e . target . value ) } } ) }
373+ style = { { flex : 1 , accentColor : '#666' } } />
374+ < span style = { { fontSize : 10 , color : '#555' , width : 24 , textAlign : 'right' } } > { aiSettings . localModelThreads } </ span >
375+ </ ConfigField >
376+ ) }
280377 { aiSettings . provider === 'openai-compatible' && (
281378 < ConfigField label = "API base" >
282379 < input type = "text" value = { aiSettings . apiBaseUrl }
@@ -296,7 +393,7 @@ export function AIChat() {
296393 ) : (
297394 < input type = "text" value = { aiSettings . model }
298395 onChange = { e => dispatch ( { type : 'UPDATE_AI_SETTINGS' , payload : { model : e . target . value } } ) }
299- placeholder = { aiSettings . provider === 'openai' ? 'e.g. gpt-4o-mini' : aiSettings . provider === 'gemini' ? 'e.g. gemini-1.5-flash' : aiSettings . provider === 'openai-compatible' ? 'Provider model id' : 'e.g. llama3.2, mistral, codellama' }
396+ placeholder = { aiSettings . provider === 'openai' ? 'e.g. gpt-4o-mini' : aiSettings . provider === 'gemini' ? 'e.g. gemini-1.5-flash' : aiSettings . provider === 'openai-compatible' ? 'Provider model id' : aiSettings . provider === 'local-gguf' ? 'Optional alias' : 'e.g. llama3.2, mistral, codellama' }
300397 style = { { ...inputStyle , fontSize : 11 } } />
301398 ) }
302399 </ ConfigField >
@@ -312,6 +409,12 @@ export function AIChat() {
312409 style = { { flex : 1 , accentColor : '#666' } } />
313410 < span style = { { fontSize : 10 , color : '#555' , width : 30 , textAlign : 'right' } } > { aiSettings . temperature . toFixed ( 2 ) } </ span >
314411 </ ConfigField >
412+ < ConfigField label = "Max output" >
413+ < input type = "range" min = { 64 } max = { 1024 } step = { 32 } value = { aiSettings . maxOutputTokens }
414+ onChange = { e => dispatch ( { type : 'UPDATE_AI_SETTINGS' , payload : { maxOutputTokens : parseInt ( e . target . value ) } } ) }
415+ style = { { flex : 1 , accentColor : '#666' } } />
416+ < span style = { { fontSize : 10 , color : '#555' , width : 36 , textAlign : 'right' } } > { aiSettings . maxOutputTokens } </ span >
417+ </ ConfigField >
315418 < ConfigField label = "Internet" >
316419 < div onClick = { ( ) => dispatch ( { type : 'UPDATE_AI_SETTINGS' , payload : { internetAccess : ! aiSettings . internetAccess } } ) }
317420 style = { {
@@ -351,7 +454,7 @@ export function AIChat() {
351454 ) }
352455
353456 { /* Messages */ }
354- < div style = { { flex : 1 , overflowY : 'auto' , padding : '10px 14px' } } className = "flint-scrollbar" >
457+ < div ref = { messagesContainerRef } onScroll = { handleMessagesScroll } style = { { flex : 1 , overflowY : 'auto' , padding : '10px 14px' , overscrollBehavior : 'contain ' } } className = "flint-scrollbar" >
355458 { aiMessages . length === 0 && ! isStreaming && (
356459 < div style = { { textAlign : 'center' , padding : '24px 10px' } } >
357460 < div style = { {
0 commit comments