Hallucinate tags and automatically generating tags via ollama
Holy shit what a fat change !!!!
This commit is contained in:
@@ -31,6 +31,7 @@ interface Note {
|
||||
topLetter: string;
|
||||
topLetterFrequency: number;
|
||||
letterCount: number;
|
||||
tags?: string[];
|
||||
snippet?: string;
|
||||
isProblematic?: boolean;
|
||||
problemReason?: string;
|
||||
@@ -47,6 +48,10 @@ const MEILISEARCH_API_KEY = '31qXgGbQ3lT4DYHQ0TOKpMzh7wcigs7agHyxP5Fz6T6D61xsvNr
|
||||
const NOTE_INDEX = 'notes';
|
||||
const SCRATCH_INDEX = 'scratch';
|
||||
const SETTINGS_INDEX = 'settings';
|
||||
|
||||
// Ollama configuration
|
||||
const OLLAMA_ENDPOINT = 'http://localhost:11434';
|
||||
const OLLAMA_MODEL = 'gemma3:4b-it-qat';
|
||||
const meiliHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${MEILISEARCH_API_KEY}`,
|
||||
@@ -60,6 +65,7 @@ const mapHitToNote = (hit: any): Note => ({
|
||||
topLetter: hit.topLetter,
|
||||
topLetterFrequency: hit.topLetterFrequency,
|
||||
letterCount: hit.letterCount || 0,
|
||||
tags: hit.tags || [],
|
||||
});
|
||||
|
||||
const Index = () => {
|
||||
@@ -88,6 +94,8 @@ const Index = () => {
|
||||
const [cacheMode, setCacheMode] = useState<'global' | 'scoped'>('global');
|
||||
const [debugInfo, setDebugInfo] = useState<string[]>([]);
|
||||
const [showDebugPanel, setShowDebugPanel] = useState(false);
|
||||
const [autoGenerateTags, setAutoGenerateTags] = useState(true);
|
||||
const [ollamaStatus, setOllamaStatus] = useState<'unknown' | 'online' | 'offline'>('unknown');
|
||||
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
|
||||
@@ -207,6 +215,113 @@ const Index = () => {
|
||||
return { mostFrequentLetter, mostFrequentLetterFrequency, letterCount };
|
||||
};
|
||||
|
||||
// Check Ollama status
|
||||
const checkOllamaStatus = async () => {
|
||||
try {
|
||||
const response = await fetch(`${OLLAMA_ENDPOINT}/api/tags`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setOllamaStatus('online');
|
||||
return true;
|
||||
} else {
|
||||
setOllamaStatus('offline');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setOllamaStatus('offline');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Generate tags using Ollama
|
||||
const generateTags = async (content: string): Promise<string[]> => {
|
||||
try {
|
||||
const systemPrompt = `You are a helpful assistant that generates searchable tags for journal entries.
|
||||
|
||||
Your task is to analyze the content and generate 3-8 relevant tags that would help the author find this note later.
|
||||
Focus on:
|
||||
- Programming languages, frameworks, libraries mentioned
|
||||
- Technical concepts and methodologies
|
||||
- Tools and utilities discussed
|
||||
- Problem domains or contexts
|
||||
- Key technical terms that someone might search for
|
||||
|
||||
Return ONLY a JSON array of strings, no other text. Example: ["golang", "testing", "array-comparison", "cmp-library"]
|
||||
|
||||
Keep tags concise, use lowercase, and separate words with hyphens if needed.`;
|
||||
|
||||
const userPrompt = `Generate tags for this journal entry:
|
||||
|
||||
${content}`;
|
||||
|
||||
const response = await fetchWithTiming(`${OLLAMA_ENDPOINT}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: OLLAMA_MODEL,
|
||||
system: systemPrompt,
|
||||
prompt: userPrompt,
|
||||
stream: false,
|
||||
}),
|
||||
}, 'Generate Tags');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate tags');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const responseText = data.response?.trim();
|
||||
|
||||
if (!responseText) {
|
||||
throw new Error('Empty response from Ollama');
|
||||
}
|
||||
|
||||
// Try to parse JSON from the response
|
||||
try {
|
||||
const tags = JSON.parse(responseText);
|
||||
if (Array.isArray(tags) && tags.every(tag => typeof tag === 'string')) {
|
||||
addDebugInfo(`Generated ${tags.length} tags: ${tags.join(', ')}`);
|
||||
return tags;
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('Failed to parse tags as JSON, trying to extract from text:', responseText);
|
||||
}
|
||||
|
||||
// Fallback: try to extract tags from text response
|
||||
const tagMatch = responseText.match(/\[(.*?)\]/);
|
||||
if (tagMatch) {
|
||||
const tags = tagMatch[1]
|
||||
.split(',')
|
||||
.map((tag: string) => tag.trim().replace(/"/g, ''))
|
||||
.filter((tag: string) => tag.length > 0);
|
||||
addDebugInfo(`Extracted ${tags.length} tags from text: ${tags.join(', ')}`);
|
||||
return tags;
|
||||
}
|
||||
|
||||
// Last resort: return empty array
|
||||
addDebugInfo('Could not extract tags from Ollama response');
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error generating tags:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
addDebugInfo(`Tag generation failed: ${errorMessage}`);
|
||||
|
||||
// Check if it's a connection error
|
||||
if (errorMessage.includes('fetch') || errorMessage.includes('network') || errorMessage.includes('ECONNREFUSED')) {
|
||||
addDebugInfo('Ollama appears to be offline. Please ensure Ollama is running on localhost:11434');
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Clean content function
|
||||
const cleanContent = (content: string) => {
|
||||
return content.trim();
|
||||
@@ -319,6 +434,13 @@ const Index = () => {
|
||||
const { mostFrequentLetter, mostFrequentLetterFrequency, letterCount } = calculateLetterFrequency(trimmedContent);
|
||||
const now = new Date();
|
||||
|
||||
// Generate tags using Ollama if enabled
|
||||
let tags: string[] = [];
|
||||
if (autoGenerateTags) {
|
||||
addDebugInfo('Generating tags for new note...');
|
||||
tags = await generateTags(trimmedContent);
|
||||
}
|
||||
|
||||
const document = {
|
||||
id: generateRandomString(32),
|
||||
date: now.getTime(),
|
||||
@@ -327,6 +449,7 @@ const Index = () => {
|
||||
topLetter: mostFrequentLetter,
|
||||
topLetterFrequency: mostFrequentLetterFrequency,
|
||||
letterCount: letterCount,
|
||||
tags: tags,
|
||||
};
|
||||
|
||||
const response = await fetchWithTiming(`${MEILISEARCH_ENDPOINT}/indexes/${NOTE_INDEX}/documents`, {
|
||||
@@ -350,6 +473,7 @@ const Index = () => {
|
||||
topLetter: document.topLetter,
|
||||
topLetterFrequency: document.topLetterFrequency,
|
||||
letterCount: document.letterCount,
|
||||
tags: document.tags,
|
||||
};
|
||||
|
||||
// Update cache and set as previous note
|
||||
@@ -359,7 +483,7 @@ const Index = () => {
|
||||
|
||||
toast({
|
||||
title: "Note saved",
|
||||
description: "Your note has been saved successfully.",
|
||||
description: `Your note has been saved successfully${tags.length > 0 ? ` with ${tags.length} tags` : ''}.`,
|
||||
});
|
||||
|
||||
return newNote;
|
||||
@@ -380,6 +504,13 @@ const Index = () => {
|
||||
const trimmedContent = cleanContent(note.content);
|
||||
const { mostFrequentLetter, mostFrequentLetterFrequency, letterCount } = calculateLetterFrequency(trimmedContent);
|
||||
|
||||
// Regenerate tags if content has changed significantly and auto-generation is enabled
|
||||
let tags = note.tags || [];
|
||||
if (autoGenerateTags && trimmedContent !== note.content) {
|
||||
addDebugInfo('Content changed, regenerating tags...');
|
||||
tags = await generateTags(trimmedContent);
|
||||
}
|
||||
|
||||
const document = {
|
||||
id: note.id,
|
||||
content: trimmedContent,
|
||||
@@ -388,6 +519,7 @@ const Index = () => {
|
||||
topLetter: mostFrequentLetter,
|
||||
topLetterFrequency: mostFrequentLetterFrequency,
|
||||
letterCount: letterCount,
|
||||
tags: tags,
|
||||
};
|
||||
|
||||
const response = await fetchWithTiming(`${MEILISEARCH_ENDPOINT}/indexes/${NOTE_INDEX}/documents`, {
|
||||
@@ -404,11 +536,11 @@ const Index = () => {
|
||||
}
|
||||
|
||||
// Update cache
|
||||
setNoteCache(prev => prev.map(n => n.id === note.id ? { ...note, content: trimmedContent, letterCount } : n));
|
||||
setNoteCache(prev => prev.map(n => n.id === note.id ? { ...note, content: trimmedContent, letterCount, tags } : n));
|
||||
|
||||
toast({
|
||||
title: "Note updated",
|
||||
description: "Your changes have been saved.",
|
||||
description: `Your changes have been saved${tags.length > 0 ? ` with ${tags.length} tags` : ''}.`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating note:', error);
|
||||
@@ -637,7 +769,7 @@ const Index = () => {
|
||||
q: query,
|
||||
matchingStrategy: 'all',
|
||||
limit: 50,
|
||||
attributesToHighlight: ['content'],
|
||||
attributesToHighlight: ['content', 'tags'],
|
||||
showRankingScore: true,
|
||||
highlightPreTag: '<mark class="bg-yellow-200">',
|
||||
highlightPostTag: '</mark>',
|
||||
@@ -863,6 +995,65 @@ const Index = () => {
|
||||
setIsCleanupOpen(true);
|
||||
};
|
||||
|
||||
// Regenerate tags for all notes
|
||||
const regenerateAllTags = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
addDebugInfo('Regenerating tags for all notes...');
|
||||
|
||||
// Load all notes
|
||||
const response = await fetchWithTiming(`${MEILISEARCH_ENDPOINT}/indexes/${NOTE_INDEX}/search`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${MEILISEARCH_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
q: '',
|
||||
limit: 1000,
|
||||
sort: ['date:desc'],
|
||||
}),
|
||||
}, 'Load All Notes for Tag Regeneration');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load notes for tag regeneration');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const notes: Note[] = data.hits.map((hit: any) => mapHitToNote(hit));
|
||||
|
||||
let updatedCount = 0;
|
||||
for (const note of notes) {
|
||||
try {
|
||||
const newTags = await generateTags(note.content);
|
||||
if (JSON.stringify(newTags) !== JSON.stringify(note.tags || [])) {
|
||||
const updatedNote = { ...note, tags: newTags };
|
||||
await updateNote(updatedNote);
|
||||
updatedCount++;
|
||||
addDebugInfo(`Updated tags for note ${note.id}: ${newTags.join(', ')}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error updating tags for note ${note.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
addDebugInfo(`Tag regeneration complete: ${updatedCount} notes updated`);
|
||||
toast({
|
||||
title: "Tag regeneration complete",
|
||||
description: `Updated tags for ${updatedCount} notes.`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error regenerating tags:', error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to regenerate tags. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update the keyboard shortcuts effect
|
||||
useEffect(() => {
|
||||
const handleKeyDown = async (e: KeyboardEvent) => {
|
||||
@@ -1154,7 +1345,7 @@ const Index = () => {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${MEILISEARCH_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify(['date', 'topLetter', 'letterCount', 'topLetterFrequency']),
|
||||
body: JSON.stringify(['date', 'topLetter', 'letterCount', 'topLetterFrequency', 'tags']),
|
||||
}, 'Configure Notes Filterable Attributes'),
|
||||
|
||||
// Scratch index configurations
|
||||
@@ -1247,6 +1438,8 @@ const Index = () => {
|
||||
await loadNotes();
|
||||
await loadLatestScratch();
|
||||
await loadFontSizeSetting();
|
||||
await loadAutoGenerateTagsSetting();
|
||||
await checkOllamaStatus();
|
||||
|
||||
const totalTime = Date.now() - dataLoadStartTime;
|
||||
debugTiming('Total Data Loading', dataLoadStartTime);
|
||||
@@ -1302,6 +1495,33 @@ const Index = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Load auto-generate tags setting
|
||||
const loadAutoGenerateTagsSetting = async () => {
|
||||
try {
|
||||
const response = await fetchWithTiming(`${MEILISEARCH_ENDPOINT}/indexes/${SETTINGS_INDEX}/search`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${MEILISEARCH_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
q: 'autoGenerateTags',
|
||||
filter: 'key = "autoGenerateTags"',
|
||||
limit: 1,
|
||||
}),
|
||||
}, 'Load Auto Generate Tags Setting');
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.hits.length > 0) {
|
||||
setAutoGenerateTags(data.hits[0].value === 'true');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading auto-generate tags setting:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Save font size setting
|
||||
const saveFontSizeSetting = async (newFontSize: string) => {
|
||||
try {
|
||||
@@ -1339,6 +1559,43 @@ const Index = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Save auto-generate tags setting
|
||||
const saveAutoGenerateTagsSetting = async (enabled: boolean) => {
|
||||
try {
|
||||
const document = {
|
||||
key: 'autoGenerateTags',
|
||||
value: enabled.toString(),
|
||||
updatedAt: new Date().getTime(),
|
||||
};
|
||||
|
||||
const response = await fetchWithTiming(`${MEILISEARCH_ENDPOINT}/indexes/${SETTINGS_INDEX}/documents`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${MEILISEARCH_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify(document),
|
||||
}, 'Save Auto Generate Tags Setting');
|
||||
|
||||
if (response.status !== 202) {
|
||||
throw new Error('Failed to save auto-generate tags setting');
|
||||
}
|
||||
|
||||
setAutoGenerateTags(enabled);
|
||||
toast({
|
||||
title: "Auto-tagging updated",
|
||||
description: `Automatic tag generation ${enabled ? 'enabled' : 'disabled'}.`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving auto-generate tags setting:', error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to save auto-generate tags setting.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen bg-background text-foreground flex flex-col ${getTextClass('base')}`}>
|
||||
{/* Header */}
|
||||
@@ -1366,6 +1623,14 @@ const Index = () => {
|
||||
<SelectItem value="xl">Extra Large</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`${getTextClass('base')} text-muted-foreground`}>Auto-tags</span>
|
||||
<Switch
|
||||
checked={autoGenerateTags}
|
||||
onCheckedChange={saveAutoGenerateTagsSetting}
|
||||
aria-label="Toggle automatic tag generation"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleCleanup} size="sm" variant="outline" className={getTextClass('base')}>
|
||||
<Trash2 className="h-8 w-8" />
|
||||
Cleanup
|
||||
@@ -1386,6 +1651,26 @@ const Index = () => {
|
||||
{showDebugPanel && (
|
||||
<div className="bg-muted border-b border-border p-4 max-h-48 overflow-y-auto">
|
||||
<div className={`${getTextClass('xl')} font-semibold mb-2`}>Debug Information</div>
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<div className={`${getTextClass('base')} flex items-center gap-2`}>
|
||||
<span>Ollama Status:</span>
|
||||
<Badge
|
||||
variant={ollamaStatus === 'online' ? 'default' : ollamaStatus === 'offline' ? 'destructive' : 'secondary'}
|
||||
className={`${getTextClass('base')} px-2 py-1`}
|
||||
>
|
||||
{ollamaStatus === 'online' ? 'Online' : ollamaStatus === 'offline' ? 'Offline' : 'Unknown'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className={`${getTextClass('base')} flex items-center gap-2`}>
|
||||
<span>Auto-tags:</span>
|
||||
<Badge
|
||||
variant={autoGenerateTags ? 'default' : 'secondary'}
|
||||
className={`${getTextClass('base')} px-2 py-1`}
|
||||
>
|
||||
{autoGenerateTags ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{debugInfo.length > 0 ? (
|
||||
debugInfo.map((info, index) => (
|
||||
@@ -1411,15 +1696,44 @@ const Index = () => {
|
||||
ref={previousNoteRef}
|
||||
className="flex-1 bg-card rounded-lg border border-border shadow-sm p-6 overflow-auto cursor-pointer select-none"
|
||||
>
|
||||
<div className={`${getTextClass('2xl')} text-muted-foreground mb-3`}>
|
||||
Previous Entry {currentNoteIndex > 0 && `(${currentNoteIndex + 1} of ${noteCache.length})`}
|
||||
<span className={`ml-2 ${getTextClass('xl')} text-muted-foreground`}>Scroll to navigate</span>
|
||||
<div className={`${getTextClass('2xl')} text-muted-foreground mb-3 flex justify-between items-center`}>
|
||||
<div>
|
||||
Previous Entry {currentNoteIndex > 0 && `(${currentNoteIndex + 1} of ${noteCache.length})`}
|
||||
<span className={`ml-2 ${getTextClass('xl')} text-muted-foreground`}>Scroll to navigate</span>
|
||||
</div>
|
||||
{previousNote && !autoGenerateTags && (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (previousNote) {
|
||||
addDebugInfo('Manually generating tags...');
|
||||
const tags = await generateTags(previousNote.content);
|
||||
const updatedNote = { ...previousNote, tags };
|
||||
setPreviousNote(updatedNote);
|
||||
setIsPreviousNoteModified(true);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={`${getTextClass('base')} px-2 py-1`}
|
||||
>
|
||||
Generate Tags
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{previousNote ? (
|
||||
<div className="space-y-4 h-[calc(100%-2rem)]">
|
||||
<div className={`${getTextClass('xl')} text-muted-foreground`}>
|
||||
{formatDate(previousNote.epochTime)}
|
||||
</div>
|
||||
{previousNote.tags && previousNote.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{previousNote.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="secondary" className={`${getTextClass('base')} px-2 py-1`}>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Textarea
|
||||
value={previousNote.content}
|
||||
onChange={(e) => {
|
||||
@@ -1431,7 +1745,7 @@ const Index = () => {
|
||||
}
|
||||
}}
|
||||
onBlur={handlePreviousNoteBlur}
|
||||
className={`h-[calc(100%-3rem)] border-0 resize-none focus:ring-0 bg-transparent ${getTextClass('2xl')}`}
|
||||
className={`h-[calc(100%-${previousNote.tags && previousNote.tags.length > 0 ? '6rem' : '3rem'})] border-0 resize-none focus:ring-0 bg-transparent ${getTextClass('2xl')}`}
|
||||
placeholder="Previous entry content..."
|
||||
/>
|
||||
</div>
|
||||
@@ -1497,6 +1811,15 @@ const Index = () => {
|
||||
<div className={`${getTextClass('xl')} text-muted-foreground mb-2`}>
|
||||
{formatDate(note.epochTime)}
|
||||
</div>
|
||||
{note.tags && note.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{note.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="secondary" className={`${getTextClass('base')} px-2 py-1`}>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`${getTextClass('2xl')} whitespace-pre-wrap`}
|
||||
dangerouslySetInnerHTML={{ __html: note.snippet || note.content }}
|
||||
@@ -1598,9 +1921,19 @@ const Index = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={getProblematicNotes} className={`w-full ${getTextClass('2xl')} py-6`}>
|
||||
Analyze Notes
|
||||
</Button>
|
||||
<div className="space-y-4">
|
||||
<Button onClick={getProblematicNotes} className={`w-full ${getTextClass('2xl')} py-6`}>
|
||||
Analyze Notes
|
||||
</Button>
|
||||
<Button
|
||||
onClick={regenerateAllTags}
|
||||
variant="outline"
|
||||
className={`w-full ${getTextClass('2xl')} py-6`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Regenerating Tags...' : 'Regenerate All Tags'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto max-h-[50vh] space-y-6">
|
||||
|
Reference in New Issue
Block a user