ALwrity AI Blog Writer - Added Google Grounding UI Implementation
This commit is contained in:
198
frontend/src/hooks/useBlogWriterState.ts
Normal file
198
frontend/src/hooks/useBlogWriterState.ts
Normal 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
|
||||
};
|
||||
};
|
||||
94
frontend/src/hooks/useClaimFixer.ts
Normal file
94
frontend/src/hooks/useClaimFixer.ts
Normal 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
|
||||
};
|
||||
};
|
||||
30
frontend/src/hooks/useCopilotTrigger.ts
Normal file
30
frontend/src/hooks/useCopilotTrigger.ts
Normal 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
|
||||
};
|
||||
};
|
||||
55
frontend/src/hooks/useMarkdownProcessor.ts
Normal file
55
frontend/src/hooks/useMarkdownProcessor.ts
Normal 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
|
||||
};
|
||||
};
|
||||
148
frontend/src/hooks/usePolling.ts
Normal file
148
frontend/src/hooks/usePolling.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user