ALwrity AI Blog Writer - Added Google Grounding UI Implementation

This commit is contained in:
ajaysi
2025-09-18 18:45:53 +05:30
parent 9f13daf443
commit 4d153b292d
72 changed files with 11944 additions and 1526 deletions

View File

@@ -0,0 +1,198 @@
import { useState, useEffect, useCallback } from 'react';
import { BlogOutlineSection, BlogResearchResponse, BlogSEOMetadataResponse, BlogSEOAnalyzeResponse, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage } from '../services/blogWriterApi';
import { researchCache } from '../services/researchCache';
export const useBlogWriterState = () => {
// Core state
const [research, setResearch] = useState<BlogResearchResponse | null>(null);
const [outline, setOutline] = useState<BlogOutlineSection[]>([]);
const [titleOptions, setTitleOptions] = useState<string[]>([]);
const [selectedTitle, setSelectedTitle] = useState<string>('');
const [sections, setSections] = useState<Record<string, string>>({});
const [seoAnalysis, setSeoAnalysis] = useState<BlogSEOAnalyzeResponse | null>(null);
const [genMode, setGenMode] = useState<'draft' | 'polished'>('polished');
const [seoMetadata, setSeoMetadata] = useState<BlogSEOMetadataResponse | null>(null);
const [continuityRefresh, setContinuityRefresh] = useState<number>(0);
const [outlineTaskId, setOutlineTaskId] = useState<string | null>(null);
// Enhanced metadata state
const [sourceMappingStats, setSourceMappingStats] = useState<SourceMappingStats | null>(null);
const [groundingInsights, setGroundingInsights] = useState<GroundingInsights | null>(null);
const [optimizationResults, setOptimizationResults] = useState<OptimizationResults | null>(null);
const [researchCoverage, setResearchCoverage] = useState<ResearchCoverage | null>(null);
// Separate research titles from AI-generated titles
const [researchTitles, setResearchTitles] = useState<string[]>([]);
const [aiGeneratedTitles, setAiGeneratedTitles] = useState<string[]>([]);
// Cache recovery - restore most recent research on page load
useEffect(() => {
const cachedEntries = researchCache.getAllCachedEntries();
if (cachedEntries.length > 0) {
// Get the most recent cached research
const mostRecent = cachedEntries[0];
console.log('Restoring cached research from page load:', mostRecent.keywords);
setResearch(mostRecent.result);
// Also try to restore outline if it exists in localStorage
try {
const savedOutline = localStorage.getItem('blog_outline');
const savedTitleOptions = localStorage.getItem('blog_title_options');
const savedSelectedTitle = localStorage.getItem('blog_selected_title');
if (savedOutline) {
setOutline(JSON.parse(savedOutline));
}
if (savedTitleOptions) {
setTitleOptions(JSON.parse(savedTitleOptions));
}
if (savedSelectedTitle) {
setSelectedTitle(savedSelectedTitle);
}
console.log('Restored outline and title data from localStorage');
} catch (error) {
console.error('Error restoring outline data:', error);
}
}
}, []);
// Handle research completion
const handleResearchComplete = useCallback((researchData: BlogResearchResponse) => {
setResearch(researchData);
}, []);
// Handle outline completion with enhanced metadata
const handleOutlineComplete = useCallback((result: any) => {
if (result?.outline) {
setOutline(result.outline);
setTitleOptions(result.title_options || []);
// Store enhanced metadata
if (result.source_mapping_stats) {
setSourceMappingStats(result.source_mapping_stats);
}
if (result.grounding_insights) {
setGroundingInsights(result.grounding_insights);
}
if (result.optimization_results) {
setOptimizationResults(result.optimization_results);
}
if (result.research_coverage) {
setResearchCoverage(result.research_coverage);
}
// Separate research titles from AI-generated titles
if (result.title_options && research) {
const researchAngles = research.suggested_angles || [];
const researchTitlesList = result.title_options.filter((title: string) =>
researchAngles.some((angle: string) => title.toLowerCase().includes(angle.toLowerCase().substring(0, 20)))
);
const aiTitlesList = result.title_options.filter((title: string) =>
!researchTitlesList.includes(title)
);
setResearchTitles(researchTitlesList);
setAiGeneratedTitles(aiTitlesList);
// Auto-select first AI-generated title if available, otherwise first research title
if (aiTitlesList.length > 0) {
setSelectedTitle(aiTitlesList[0]);
} else if (researchTitlesList.length > 0) {
setSelectedTitle(researchTitlesList[0]);
} else if (result.title_options.length > 0) {
setSelectedTitle(result.title_options[0]);
}
}
// Save to localStorage for persistence
try {
localStorage.setItem('blog_outline', JSON.stringify(result.outline));
localStorage.setItem('blog_title_options', JSON.stringify(result.title_options || []));
localStorage.setItem('blog_selected_title', result.title_options?.[0] || '');
console.log('Saved outline data to localStorage');
} catch (error) {
console.error('Error saving outline data:', error);
}
}
setOutlineTaskId(null);
}, [research]);
// Handle outline error
const handleOutlineError = useCallback((error: any) => {
console.error('Outline generation error:', error);
setOutlineTaskId(null);
}, []);
// Handle section generation
const handleSectionGenerated = useCallback((sectionId: string, markdown: string) => {
setSections(prev => ({ ...prev, [sectionId]: markdown }));
}, []);
// Handle continuity refresh
const handleContinuityRefresh = useCallback(() => {
setContinuityRefresh(Date.now());
}, []);
// Handle title selection
const handleTitleSelect = useCallback((title: string) => {
setSelectedTitle(title);
localStorage.setItem('blog_selected_title', title);
}, []);
// Handle custom title
const handleCustomTitle = useCallback((title: string) => {
const newTitleOptions = [...titleOptions, title];
setTitleOptions(newTitleOptions);
setSelectedTitle(title);
localStorage.setItem('blog_title_options', JSON.stringify(newTitleOptions));
localStorage.setItem('blog_selected_title', title);
}, [titleOptions]);
return {
// State
research,
outline,
titleOptions,
selectedTitle,
sections,
seoAnalysis,
genMode,
seoMetadata,
continuityRefresh,
outlineTaskId,
sourceMappingStats,
groundingInsights,
optimizationResults,
researchCoverage,
researchTitles,
aiGeneratedTitles,
// Setters
setResearch,
setOutline,
setTitleOptions,
setSelectedTitle,
setSections,
setSeoAnalysis,
setGenMode,
setSeoMetadata,
setContinuityRefresh,
setOutlineTaskId,
setSourceMappingStats,
setGroundingInsights,
setOptimizationResults,
setResearchCoverage,
setResearchTitles,
setAiGeneratedTitles,
// Handlers
handleResearchComplete,
handleOutlineComplete,
handleOutlineError,
handleSectionGenerated,
handleContinuityRefresh,
handleTitleSelect,
handleCustomTitle
};
};

View File

@@ -0,0 +1,94 @@
import { useCallback } from 'react';
import { BlogOutlineSection } from '../services/blogWriterApi';
export const useClaimFixer = (
outline: BlogOutlineSection[],
sections: Record<string, string>,
onSectionsUpdate: (sections: Record<string, string>) => void
) => {
// Sentence-level claim mapping and patching helpers
const normalized = useCallback((s: string) => s.toLowerCase().replace(/\s+/g, ' ').trim(), []);
const fuzzyScore = useCallback((a: string, b: string) => {
// Dice's coefficient over word bigrams for robustness (no deps)
const bigrams = (s: string) => {
const t = s.split(/\W+/).filter(Boolean);
const grams: string[] = [];
for (let i = 0; i < t.length - 1; i++) grams.push(`${t[i]} ${t[i+1]}`);
return grams;
};
const A = new Set(bigrams(a));
const B = new Set(bigrams(b));
if (!A.size || !B.size) return 0;
let overlap = 0;
A.forEach(g => { if (B.has(g)) overlap++; });
return (2 * overlap) / (A.size + B.size);
}, []);
const findSentenceForClaim = useCallback((md: string, claimText: string) => {
const text = md || '';
// Split by sentence enders; keep delimiters
const sentences = text.split(/(?<=[.!?])\s+/);
const normalizedClaim = claimText.trim().toLowerCase();
// Direct includes first
let bestIndex = sentences.findIndex(s => s.toLowerCase().includes(normalizedClaim));
if (bestIndex >= 0) return { sentence: sentences[bestIndex], index: bestIndex, sentences };
// Fallback: overlap ratio by words
const claimWords = normalizedClaim.split(/\W+/).filter(Boolean);
let bestScore = 0; bestIndex = -1;
sentences.forEach((s, i) => {
const sw = s.toLowerCase().split(/\W+/).filter(Boolean);
const overlap = claimWords.filter(w => sw.includes(w)).length;
const score = overlap / Math.max(claimWords.length, 1);
if (score > bestScore) { bestScore = score; bestIndex = i; }
});
// Second fallback: Dice coefficient on normalized strings
if (bestIndex < 0) {
let diceBest = 0; let diceIdx = -1;
sentences.forEach((s, i) => {
const sc = fuzzyScore(normalized(s), normalized(claimText));
if (sc > diceBest) { diceBest = sc; diceIdx = i; }
});
if (diceIdx >= 0) return { sentence: sentences[diceIdx], index: diceIdx, sentences };
}
if (bestIndex >= 0) return { sentence: sentences[bestIndex], index: bestIndex, sentences };
return { sentence: '', index: -1, sentences };
}, [normalized, fuzzyScore]);
const buildFullMarkdown = useCallback(() => {
if (!outline.length) return '';
return outline.map(s => `## ${s.heading}\n\n${sections[s.id] || ''}`).join('\n\n');
}, [outline, sections]);
const buildUpdatedMarkdownForClaim = useCallback((claimText: string, supportingUrl?: string) => {
const md = buildFullMarkdown();
const { sentence, index, sentences } = findSentenceForClaim(md, claimText);
if (!sentence || index < 0) return { original: '', updated: '', updatedMarkdown: md };
const alreadyHasLink = /\[[^\]]+\]\(([^)]+)\)/.test(sentence);
const fix = supportingUrl && !alreadyHasLink ? `${sentence} [source](${supportingUrl})` : sentence;
const updatedSentences = [...sentences];
updatedSentences[index] = fix;
const updatedMarkdown = updatedSentences.join(' ');
return { original: sentence, updated: fix, updatedMarkdown };
}, [buildFullMarkdown, findSentenceForClaim]);
const applyClaimFix = useCallback((claimText: string, supportingUrl?: string) => {
// Naive fix: append citation footnote to the first occurrence of claim text
const { updatedMarkdown } = buildUpdatedMarkdownForClaim(claimText, supportingUrl);
const updated = updatedMarkdown;
// Re-split content back to per-section, by headings
const parts = updated.split(/^## /gm).filter(Boolean);
const newSections: Record<string, string> = {};
outline.forEach((s, idx) => {
const body = parts[idx] ? parts[idx].replace(new RegExp(`^${s.heading}\n\n?`), '') : (sections[s.id] || '');
newSections[s.id] = body;
});
onSectionsUpdate(newSections);
}, [buildUpdatedMarkdownForClaim, outline, sections, onSectionsUpdate]);
return {
buildFullMarkdown,
buildUpdatedMarkdownForClaim,
applyClaimFix
};
};

View File

@@ -0,0 +1,30 @@
import { useCallback } from 'react';
/**
* Hook to trigger copilot actions from UI components
*/
export const useCopilotTrigger = () => {
const triggerResearch = useCallback((topic?: string) => {
// This function can be used to programmatically trigger copilot actions
// For now, it's a placeholder that can be extended to interact with the copilot
console.log('Triggering research for topic:', topic);
// In a real implementation, this could:
// 1. Send a message to the copilot
// 2. Trigger a specific copilot action
// 3. Open the copilot sidebar with a pre-filled message
// For now, we'll just log the action
// The user can still interact with the copilot manually
}, []);
const triggerOutlineGeneration = useCallback(() => {
console.log('Triggering outline generation');
// Similar to triggerResearch, this could interact with the copilot
}, []);
return {
triggerResearch,
triggerOutlineGeneration
};
};

View File

@@ -0,0 +1,55 @@
import { useCallback } from 'react';
import { BlogOutlineSection } from '../services/blogWriterApi';
export const useMarkdownProcessor = (
outline: BlogOutlineSection[],
sections: Record<string, string>
) => {
const buildFullMarkdown = useCallback(() => {
if (!outline.length) return '';
return outline.map(s => `## ${s.heading}\n\n${sections[s.id] || ''}`).join('\n\n');
}, [outline, sections]);
const convertMarkdownToHTML = useCallback((md: string) => {
return md
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
.replace(/\*(.*)\*/gim, '<em>$1</em>')
.replace(/\n\n/g, '<br/><br/>');
}, []);
const getTotalWords = useCallback(() => {
const fullMarkdown = buildFullMarkdown();
return fullMarkdown.split(/\s+/).filter(word => word.length > 0).length;
}, [buildFullMarkdown]);
const getSectionWordCount = useCallback((sectionId: string) => {
const content = sections[sectionId] || '';
return content.split(/\s+/).filter(word => word.length > 0).length;
}, [sections]);
const getOutlineStats = useCallback(() => {
const totalWords = getTotalWords();
const totalSections = outline.length;
const totalSubheadings = outline.reduce((sum, section) => sum + section.subheadings.length, 0);
const totalKeyPoints = outline.reduce((sum, section) => sum + section.key_points.length, 0);
return {
totalWords,
totalSections,
totalSubheadings,
totalKeyPoints,
averageWordsPerSection: totalSections > 0 ? Math.round(totalWords / totalSections) : 0
};
}, [outline, getTotalWords]);
return {
buildFullMarkdown,
convertMarkdownToHTML,
getTotalWords,
getSectionWordCount,
getOutlineStats
};
};

View File

@@ -0,0 +1,148 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { blogWriterApi, TaskStatusResponse } from '../services/blogWriterApi';
export interface UsePollingOptions {
interval?: number; // Polling interval in milliseconds
maxAttempts?: number; // Maximum number of polling attempts
onProgress?: (message: string) => void; // Callback for progress updates
onComplete?: (result: any) => void; // Callback when task completes
onError?: (error: string) => void; // Callback when task fails
}
export interface UsePollingReturn {
isPolling: boolean;
currentStatus: string;
progressMessages: Array<{ timestamp: string; message: string }>;
result: any;
error: string | null;
startPolling: (taskId: string) => void;
stopPolling: () => void;
}
export function usePolling(
pollFunction: (taskId: string) => Promise<TaskStatusResponse>,
options: UsePollingOptions = {}
): UsePollingReturn {
const {
interval = 2000, // 2 seconds default
maxAttempts = 0, // No timeout - poll until backend says done
onProgress,
onComplete,
onError
} = options;
const [isPolling, setIsPolling] = useState(false);
const [currentStatus, setCurrentStatus] = useState<string>('idle');
const [progressMessages, setProgressMessages] = useState<Array<{ timestamp: string; message: string }>>([]);
const [result, setResult] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const attemptsRef = useRef(0);
const currentTaskIdRef = useRef<string | null>(null);
const stopPolling = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setIsPolling(false);
attemptsRef.current = 0;
currentTaskIdRef.current = null;
}, []);
const startPolling = useCallback((taskId: string) => {
if (isPolling) {
stopPolling();
}
currentTaskIdRef.current = taskId;
setIsPolling(true);
setCurrentStatus('pending');
setProgressMessages([]);
setResult(null);
setError(null);
attemptsRef.current = 0;
const poll = async () => {
if (!currentTaskIdRef.current) {
stopPolling();
return;
}
try {
const status = await pollFunction(currentTaskIdRef.current);
console.log('Polling status update:', status);
setCurrentStatus(status.status);
// Update progress messages
if (status.progress_messages && status.progress_messages.length > 0) {
console.log('Progress messages received:', status.progress_messages);
console.log('Previous progress messages count:', progressMessages.length);
setProgressMessages(status.progress_messages);
console.log('Progress messages state updated to:', status.progress_messages.length, 'messages');
// Call onProgress with the latest message for backward compatibility
const latestMessage = status.progress_messages[status.progress_messages.length - 1];
console.log('Latest progress message:', latestMessage.message);
onProgress?.(latestMessage.message);
}
if (status.status === 'completed') {
setResult(status.result);
onComplete?.(status.result);
stopPolling();
} else if (status.status === 'failed') {
setError(status.error || 'Task failed');
onError?.(status.error || 'Task failed');
stopPolling();
}
attemptsRef.current++;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Polling error:', errorMessage);
// Only stop polling for actual task failures (404, task not found)
// For network errors, timeouts, etc., continue polling
if (errorMessage.includes('404') || errorMessage.includes('Task not found')) {
setError('Task not found - it may have expired or been cleaned up');
onError?.('Task not found - it may have expired or been cleaned up');
stopPolling();
}
// For other errors (timeouts, network issues), continue polling
// The backend will eventually complete or fail, and we'll catch it
}
};
// Start polling immediately, then at intervals
poll();
intervalRef.current = setInterval(poll, interval);
}, [isPolling, interval, maxAttempts, onProgress, onComplete, onError, pollFunction, stopPolling, progressMessages.length]);
// Cleanup on unmount
useEffect(() => {
return () => {
stopPolling();
};
}, [stopPolling]);
return {
isPolling,
currentStatus,
progressMessages,
result,
error,
startPolling,
stopPolling
};
}
// Specialized hooks for specific operations
export function useResearchPolling(options: UsePollingOptions = {}) {
return usePolling(blogWriterApi.pollResearchStatus, options);
}
export function useOutlinePolling(options: UsePollingOptions = {}) {
return usePolling(blogWriterApi.pollOutlineStatus, options);
}