Skip to content

Commit 95ff657

Browse files
Add files via upload
1 parent 8c826ac commit 95ff657

15 files changed

Lines changed: 1034 additions & 152 deletions

File tree

14.8 KB
Binary file not shown.

agent/agent.py

Lines changed: 333 additions & 60 deletions
Large diffs are not rendered by default.

agent/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
flask>=3.0.0
22
flask-cors>=4.0.0
33
requests>=2.31.0
4+
llama-cpp-python>=0.2.90

dist/flint-logo.png

3.33 MB
Loading

dist/index.html

Lines changed: 254 additions & 0 deletions
Large diffs are not rendered by default.

src/components/AIChat.tsx

Lines changed: 114 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useStore } from '../store';
33
import { askFlintAI, fetchOllamaModels, checkOllamaStatus, checkAgentStatus } from '../services/ollama';
44
import { FlintLogo } from './FlintLogo';
55
import { 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

78
export 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={{

src/components/Editor.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,42 @@ export function Editor({ noteId }: { noteId: string }) {
5454
}
5555
};
5656

57+
const insertAtCursor = useCallback((text: string) => {
58+
const ta = taRef.current;
59+
if (!ta) return;
60+
const start = ta.selectionStart;
61+
const end = ta.selectionEnd;
62+
const val = ta.value;
63+
const next = val.substring(0, start) + text + val.substring(end);
64+
ta.value = next;
65+
const caret = start + text.length;
66+
ta.selectionStart = caret;
67+
ta.selectionEnd = caret;
68+
ta.focus();
69+
handleChange(next);
70+
}, [handleChange]);
71+
72+
const handleDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
73+
if (e.dataTransfer.types.includes('text/flint-note-title') || e.dataTransfer.types.includes('text/plain')) {
74+
e.preventDefault();
75+
e.dataTransfer.dropEffect = 'copy';
76+
}
77+
};
78+
79+
const handleDrop = (e: React.DragEvent<HTMLTextAreaElement>) => {
80+
e.preventDefault();
81+
const title = e.dataTransfer.getData('text/flint-note-title');
82+
if (title) {
83+
insertAtCursor(`[[${title}]]`);
84+
return;
85+
}
86+
const text = e.dataTransfer.getData('text/plain');
87+
if (!text) return;
88+
if (/^\[\[[^\]]+\]\]$/.test(text.trim())) {
89+
insertAtCursor(text.trim());
90+
}
91+
};
92+
5793
// Listen for formatting events from toolbar
5894
useEffect(() => {
5995
const handler = (e: Event) => {
@@ -167,6 +203,8 @@ export function Editor({ noteId }: { noteId: string }) {
167203
defaultValue={note.content}
168204
onChange={e => handleChange(e.target.value)}
169205
onKeyDown={handleKey}
206+
onDragOver={handleDragOver}
207+
onDrop={handleDrop}
170208
placeholder="Start writing..."
171209
spellCheck={false}
172210
/>

0 commit comments

Comments
 (0)