328 lines
12 KiB
TypeScript
328 lines
12 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
|
import { debug } from '../../../utils/debug';
|
|
import { assistiveWritingApi } from '../../../services/blogWriterApi';
|
|
|
|
interface SmartTypingAssistProps {
|
|
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
|
|
onTextReplace?: (originalText: string, newText: string, editType: string) => void;
|
|
}
|
|
|
|
interface Suggestion {
|
|
text: string;
|
|
confidence?: number;
|
|
sources?: Array<{
|
|
title: string;
|
|
url: string;
|
|
text?: string;
|
|
author?: string;
|
|
published_date?: string;
|
|
score: number;
|
|
}>;
|
|
}
|
|
|
|
const useSmartTypingAssist = (
|
|
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>,
|
|
onTextReplace?: (originalText: string, newText: string, editType: string) => void
|
|
) => {
|
|
// Smart typing assist states
|
|
const [smartSuggestion, setSmartSuggestion] = useState<{
|
|
text: string;
|
|
position: { x: number; y: number };
|
|
confidence?: number;
|
|
sources?: Array<{
|
|
title: string;
|
|
url: string;
|
|
text?: string;
|
|
author?: string;
|
|
published_date?: string;
|
|
score: number;
|
|
}>;
|
|
} | null>(null);
|
|
const [suggestionIndex, setSuggestionIndex] = useState(0);
|
|
const [allSuggestions, setAllSuggestions] = useState<Suggestion[]>([]);
|
|
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false);
|
|
const [hasShownFirstSuggestion, setHasShownFirstSuggestion] = useState(false);
|
|
const [showContinueWritingPrompt, setShowContinueWritingPrompt] = useState(false);
|
|
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const lastGeneratedAtRef = useRef<number>(0);
|
|
const hasShownFirstRef = useRef(false);
|
|
const isGeneratingRef = useRef(false);
|
|
const smartSuggestionRef = useRef<typeof smartSuggestion>(null);
|
|
const initialContentLengthRef = useRef<number | null>(null);
|
|
const mountedRef = useRef(true);
|
|
|
|
// Quality improvement tracking
|
|
const [suggestionStats, setSuggestionStats] = useState({
|
|
totalShown: 0,
|
|
totalAccepted: 0,
|
|
totalRejected: 0,
|
|
totalCycled: 0
|
|
});
|
|
|
|
// Smart typing assist functionality
|
|
const generateSmartSuggestion = async (currentText: string, cursorPosition?: number) => {
|
|
debug.log('[SmartTypingAssist] generateSmartSuggestion called', { textLength: currentText.length, cursorPosition });
|
|
|
|
if (currentText.length < 20) {
|
|
debug.log('[SmartTypingAssist] Text too short for suggestion');
|
|
return; // Only suggest after some meaningful content
|
|
}
|
|
|
|
debug.log('[SmartTypingAssist] Starting suggestion generation...');
|
|
setIsGeneratingSuggestion(true);
|
|
isGeneratingRef.current = true;
|
|
|
|
try {
|
|
if (!mountedRef.current) return;
|
|
|
|
debug.log('[SmartTypingAssist] Calling assistive writing API...');
|
|
const response = await assistiveWritingApi.getSuggestion(currentText, cursorPosition);
|
|
|
|
if (!mountedRef.current) return;
|
|
|
|
if (!response.success || !response.suggestions.length) {
|
|
debug.log('[SmartTypingAssist] No suggestions from API', { message: response.message });
|
|
return;
|
|
}
|
|
|
|
debug.log('[SmartTypingAssist] Received suggestions from API', { count: response.suggestions.length });
|
|
|
|
// Store all suggestions
|
|
setAllSuggestions(response.suggestions);
|
|
setSuggestionIndex(0);
|
|
|
|
// Show first suggestion
|
|
const firstSuggestion = response.suggestions[0];
|
|
debug.log('[SmartTypingAssist] Showing first suggestion', { preview: firstSuggestion.text.substring(0, 50) + '...' });
|
|
|
|
// Track suggestion shown
|
|
setSuggestionStats(prev => ({
|
|
...prev,
|
|
totalShown: prev.totalShown + 1
|
|
}));
|
|
|
|
// Get viewport-safe position for suggestion placement
|
|
if (contentRef.current) {
|
|
const element = contentRef.current;
|
|
const rect = element.getBoundingClientRect();
|
|
const maxWidth = 420;
|
|
const maxHeight = 350;
|
|
|
|
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
|
|
let y = rect.bottom + 10;
|
|
|
|
if (y + maxHeight > window.innerHeight - 20) {
|
|
y = rect.top - maxHeight - 10;
|
|
if (y < 20) {
|
|
y = Math.max(20, (window.innerHeight - maxHeight) / 2);
|
|
x = Math.max(20, (window.innerWidth - maxWidth) / 2);
|
|
}
|
|
}
|
|
|
|
y = Math.max(20, Math.min(y, window.innerHeight - maxHeight - 20));
|
|
x = Math.max(20, Math.min(x, window.innerWidth - maxWidth - 20));
|
|
|
|
setSmartSuggestion({
|
|
text: firstSuggestion.text,
|
|
position: { x, y },
|
|
confidence: firstSuggestion.confidence,
|
|
sources: firstSuggestion.sources
|
|
});
|
|
}
|
|
} catch (error) {
|
|
debug.error('[SmartTypingAssist] Failed to generate smart suggestion', error);
|
|
} finally {
|
|
setIsGeneratingSuggestion(false);
|
|
isGeneratingRef.current = false;
|
|
}
|
|
};
|
|
|
|
const handleTypingChange = (newText: string, cursorPosition?: number) => {
|
|
if (typingTimeoutRef.current) {
|
|
clearTimeout(typingTimeoutRef.current);
|
|
}
|
|
|
|
// Track initial content baseline on first user keystroke
|
|
// This prevents triggering suggestions on pre-filled content
|
|
if (initialContentLengthRef.current === null) {
|
|
initialContentLengthRef.current = newText.length;
|
|
debug.log('[SmartTypingAssist] Set initial content baseline', { length: newText.length });
|
|
}
|
|
|
|
// Store cursor position for use after debounce
|
|
const cursorPos = cursorPosition;
|
|
|
|
typingTimeoutRef.current = setTimeout(() => {
|
|
const cooldownMs = 15000;
|
|
const now = Date.now();
|
|
const sinceLast = now - lastGeneratedAtRef.current;
|
|
const baseline = initialContentLengthRef.current ?? 0;
|
|
const userAddedChars = newText.length - baseline;
|
|
|
|
if (!hasShownFirstRef.current && newText.length >= 50 && userAddedChars >= 30 && !isGeneratingRef.current) {
|
|
debug.log('[SmartTypingAssist] Generating first suggestion');
|
|
generateSmartSuggestion(newText, cursorPos);
|
|
setHasShownFirstSuggestion(true);
|
|
lastGeneratedAtRef.current = now;
|
|
} else if (hasShownFirstRef.current && newText.length > 100 && userAddedChars >= 30 && sinceLast >= cooldownMs && !isGeneratingRef.current && !smartSuggestionRef.current) {
|
|
debug.log('[SmartTypingAssist] Showing "Continue writing" prompt');
|
|
setShowContinueWritingPrompt(true);
|
|
}
|
|
}, 3000);
|
|
};
|
|
|
|
const handleAcceptSuggestion = () => {
|
|
if (smartSuggestion && onTextReplace && contentRef.current) {
|
|
const element = contentRef.current as HTMLTextAreaElement;
|
|
const currentContent = element.value || '';
|
|
|
|
// Get cursor position
|
|
const cursorPosition = element.selectionStart || currentContent.length;
|
|
debug.log('[SmartTypingAssist] Cursor position', { cursorPosition, contentLength: currentContent.length });
|
|
|
|
// Insert suggestion at cursor position
|
|
const beforeCursor = currentContent.substring(0, cursorPosition);
|
|
const afterCursor = currentContent.substring(cursorPosition);
|
|
const suggestionWithSpace = ' ' + smartSuggestion.text + ' ';
|
|
const newContent = beforeCursor + suggestionWithSpace + afterCursor;
|
|
|
|
// Calculate where cursor should be after insertion (right after the suggestion)
|
|
const newCursorPosition = cursorPosition + suggestionWithSpace.length;
|
|
|
|
// Track suggestion accepted
|
|
setSuggestionStats(prev => ({
|
|
...prev,
|
|
totalAccepted: prev.totalAccepted + 1
|
|
}));
|
|
|
|
debug.log('[SmartTypingAssist] Suggestion accepted', { cursorPosition, newContentLength: newContent.length, newCursorPosition });
|
|
|
|
// Use the text replacement callback
|
|
onTextReplace(currentContent, newContent, 'smart-suggestion');
|
|
|
|
// Set cursor position after the inserted text
|
|
setTimeout(() => {
|
|
if (contentRef.current) {
|
|
const el = contentRef.current as HTMLTextAreaElement;
|
|
el.focus();
|
|
el.setSelectionRange(newCursorPosition, newCursorPosition);
|
|
debug.log('[SmartTypingAssist] Cursor positioned', { position: newCursorPosition });
|
|
}
|
|
}, 50);
|
|
|
|
setSmartSuggestion(null);
|
|
}
|
|
};
|
|
|
|
const handleRejectSuggestion = () => {
|
|
// Track suggestion rejected
|
|
setSuggestionStats(prev => ({
|
|
...prev,
|
|
totalRejected: prev.totalRejected + 1
|
|
}));
|
|
|
|
debug.log('[SmartTypingAssist] Suggestion rejected', { stats: { ...suggestionStats, totalRejected: suggestionStats.totalRejected + 1 } });
|
|
|
|
setSmartSuggestion(null);
|
|
setAllSuggestions([]);
|
|
setSuggestionIndex(0);
|
|
};
|
|
|
|
const handleNextSuggestion = () => {
|
|
if (allSuggestions.length > 0 && suggestionIndex < allSuggestions.length - 1) {
|
|
const nextIndex = suggestionIndex + 1;
|
|
const nextSuggestion = allSuggestions[nextIndex];
|
|
|
|
// Track suggestion cycled
|
|
setSuggestionStats(prev => ({
|
|
...prev,
|
|
totalCycled: prev.totalCycled + 1
|
|
}));
|
|
|
|
debug.log('[SmartTypingAssist] Showing next suggestion', { index: nextIndex + 1, total: allSuggestions.length });
|
|
debug.log('[SmartTypingAssist] Suggestion cycled', { stats: { ...suggestionStats, totalCycled: suggestionStats.totalCycled + 1 } });
|
|
|
|
setSuggestionIndex(nextIndex);
|
|
setSmartSuggestion(prev => prev ? {
|
|
...prev,
|
|
text: nextSuggestion.text,
|
|
confidence: nextSuggestion.confidence,
|
|
sources: nextSuggestion.sources
|
|
} : null);
|
|
}
|
|
};
|
|
|
|
// Handle "Continue writing" button click
|
|
const handleRequestSuggestion = async () => {
|
|
if (!contentRef.current) return;
|
|
|
|
const element = contentRef.current as HTMLTextAreaElement;
|
|
const currentContent = element.value || '';
|
|
const cursorPos = element.selectionStart;
|
|
|
|
setShowContinueWritingPrompt(false);
|
|
|
|
const baseline = initialContentLengthRef.current ?? 0;
|
|
const userAddedChars = currentContent.length - baseline;
|
|
if (currentContent.length > 20 && userAddedChars >= 10) {
|
|
await generateSmartSuggestion(currentContent, cursorPos);
|
|
}
|
|
};
|
|
|
|
// Handle dismissing the "Continue writing" prompt
|
|
const handleDismissPrompt = () => {
|
|
setShowContinueWritingPrompt(false);
|
|
};
|
|
|
|
// Get suggestion statistics for quality improvement
|
|
const getSuggestionStats = () => {
|
|
const acceptanceRate = suggestionStats.totalShown > 0
|
|
? Math.round((suggestionStats.totalAccepted / suggestionStats.totalShown) * 100)
|
|
: 0;
|
|
|
|
return {
|
|
...suggestionStats,
|
|
acceptanceRate,
|
|
engagementRate: suggestionStats.totalShown > 0
|
|
? Math.round(((suggestionStats.totalAccepted + suggestionStats.totalCycled) / suggestionStats.totalShown) * 100)
|
|
: 0
|
|
};
|
|
};
|
|
|
|
// Sync refs with state so timeout callbacks always read latest values
|
|
useEffect(() => { hasShownFirstRef.current = hasShownFirstSuggestion; }, [hasShownFirstSuggestion]);
|
|
useEffect(() => { isGeneratingRef.current = isGeneratingSuggestion; }, [isGeneratingSuggestion]);
|
|
useEffect(() => { smartSuggestionRef.current = smartSuggestion; }, [smartSuggestion]);
|
|
|
|
// Mount guard and cleanup
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
return () => {
|
|
mountedRef.current = false;
|
|
if (typingTimeoutRef.current) {
|
|
clearTimeout(typingTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
return {
|
|
smartSuggestion,
|
|
isGeneratingSuggestion,
|
|
allSuggestions,
|
|
suggestionIndex,
|
|
suggestionStats: getSuggestionStats(),
|
|
showContinueWritingPrompt,
|
|
handleTypingChange,
|
|
handleAcceptSuggestion,
|
|
handleRejectSuggestion,
|
|
handleNextSuggestion,
|
|
handleRequestSuggestion,
|
|
handleDismissPrompt,
|
|
getSuggestionStats,
|
|
generateSmartSuggestion
|
|
};
|
|
};
|
|
|
|
export default useSmartTypingAssist;
|
|
export type { SmartTypingAssistProps, Suggestion };
|