ALwrity AI Blog Writer - Added Google Grounding UI Implementation
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { CopilotSidebar } from '@copilotkit/react-ui';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import '@copilotkit/react-ui/styles.css';
|
||||
import { blogWriterApi, BlogOutlineSection, BlogResearchResponse, BlogSEOMetadataResponse, BlogSEOAnalyzeResponse } from '../../services/blogWriterApi';
|
||||
import { blogWriterApi } from '../../services/blogWriterApi';
|
||||
import { useOutlinePolling } from '../../hooks/usePolling';
|
||||
import { useClaimFixer } from '../../hooks/useClaimFixer';
|
||||
import { useMarkdownProcessor } from '../../hooks/useMarkdownProcessor';
|
||||
import { useBlogWriterState } from '../../hooks/useBlogWriterState';
|
||||
import { useSuggestions } from './SuggestionsGenerator';
|
||||
import EnhancedOutlineEditor from './EnhancedOutlineEditor';
|
||||
import ContinuityBadge from './ContinuityBadge';
|
||||
import TitleSelector from './TitleSelector';
|
||||
import DiffPreview from './DiffPreview';
|
||||
import EnhancedTitleSelector from './EnhancedTitleSelector';
|
||||
import SEOMiniPanel from './SEOMiniPanel';
|
||||
import ResearchResults from './ResearchResults';
|
||||
import KeywordInputForm from './KeywordInputForm';
|
||||
@@ -14,490 +17,85 @@ import ResearchAction from './ResearchAction';
|
||||
import { CustomOutlineForm } from './CustomOutlineForm';
|
||||
import { ResearchDataActions } from './ResearchDataActions';
|
||||
import { EnhancedOutlineActions } from './EnhancedOutlineActions';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
import HallucinationChecker from './HallucinationChecker';
|
||||
import Publisher from './Publisher';
|
||||
import OutlineGenerator from './OutlineGenerator';
|
||||
import SectionGenerator from './SectionGenerator';
|
||||
import OutlineRefiner from './OutlineRefiner';
|
||||
import SEOProcessor from './SEOProcessor';
|
||||
import BlogWriterLanding from './BlogWriterLanding';
|
||||
import ResearchProgressModal from './ResearchProgressModal';
|
||||
|
||||
export const BlogWriter: React.FC = () => {
|
||||
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 [hallucinationResult, setHallucinationResult] = useState<any>(null);
|
||||
const [continuityRefresh, setContinuityRefresh] = useState<number>(0);
|
||||
// Use custom hook for all state management
|
||||
const {
|
||||
research,
|
||||
outline,
|
||||
titleOptions,
|
||||
selectedTitle,
|
||||
sections,
|
||||
seoAnalysis,
|
||||
genMode,
|
||||
seoMetadata,
|
||||
continuityRefresh,
|
||||
outlineTaskId,
|
||||
sourceMappingStats,
|
||||
groundingInsights,
|
||||
optimizationResults,
|
||||
researchCoverage,
|
||||
researchTitles,
|
||||
aiGeneratedTitles,
|
||||
setOutline,
|
||||
setTitleOptions,
|
||||
setSections,
|
||||
setSeoAnalysis,
|
||||
setGenMode,
|
||||
setSeoMetadata,
|
||||
setOutlineTaskId,
|
||||
handleResearchComplete,
|
||||
handleOutlineComplete,
|
||||
handleOutlineError,
|
||||
handleSectionGenerated,
|
||||
handleContinuityRefresh,
|
||||
handleTitleSelect,
|
||||
handleCustomTitle
|
||||
} = useBlogWriterState();
|
||||
|
||||
const buildFullMarkdown = () => {
|
||||
if (!outline.length) return '';
|
||||
return outline.map(s => `## ${s.heading}\n\n${sections[s.id] || ''}`).join('\n\n');
|
||||
};
|
||||
// Custom hooks for complex functionality
|
||||
const { buildFullMarkdown, buildUpdatedMarkdownForClaim, applyClaimFix } = useClaimFixer(
|
||||
outline,
|
||||
sections,
|
||||
setSections
|
||||
);
|
||||
|
||||
const { convertMarkdownToHTML, getTotalWords, getOutlineStats } = useMarkdownProcessor(
|
||||
outline,
|
||||
sections
|
||||
);
|
||||
|
||||
// Sentence-level claim mapping and patching helpers
|
||||
const normalized = (s: string) => s.toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
// Get suggestions
|
||||
const suggestions = useSuggestions(research, outline);
|
||||
|
||||
const fuzzyScore = (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 = (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 };
|
||||
};
|
||||
|
||||
const buildUpdatedMarkdownForClaim = (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 };
|
||||
};
|
||||
|
||||
const applyClaimFix = (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;
|
||||
});
|
||||
setSections(newSections);
|
||||
};
|
||||
|
||||
// Handle research completion
|
||||
const handleResearchComplete = (researchData: BlogResearchResponse) => {
|
||||
setResearch(researchData);
|
||||
};
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'generateOutline',
|
||||
description: 'Generate outline from research results using AI analysis',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
if (!research) return { success: false, message: 'No research yet. Please research a topic first.' };
|
||||
|
||||
try {
|
||||
const res = await blogWriterApi.generateOutline({ research });
|
||||
if (res?.outline) {
|
||||
setOutline(res.outline);
|
||||
setTitleOptions(res.title_options || []);
|
||||
if (res.title_options && res.title_options.length > 0) {
|
||||
setSelectedTitle(res.title_options[0]); // Auto-select first title
|
||||
}
|
||||
|
||||
const outlineCount = res.outline.length;
|
||||
const primaryKeywords = research.keyword_analysis?.primary || [];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `🧩 Outline generated successfully! Created ${outlineCount} sections based on your research. The outline incorporates your primary keywords (${primaryKeywords.join(', ')}) and follows the content angles we discovered. You can now review the outline structure, choose a title, and generate content for individual sections.`,
|
||||
outline_summary: {
|
||||
sections: outlineCount,
|
||||
primary_keywords: primaryKeywords,
|
||||
research_sources: research.sources?.length || 0,
|
||||
title_options: res.title_options?.length || 0
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Outline generation failed:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
// Provide more specific error messages based on the error type
|
||||
let userMessage = '❌ Outline generation failed. ';
|
||||
if (errorMessage.includes('503') || errorMessage.includes('overloaded')) {
|
||||
userMessage += 'The AI service is temporarily overloaded. Please try again in a few minutes.';
|
||||
} else if (errorMessage.includes('timeout')) {
|
||||
userMessage += 'The request timed out. Please try again.';
|
||||
} else if (errorMessage.includes('Invalid outline structure')) {
|
||||
userMessage += 'The AI generated an invalid response. Please try again with different research data.';
|
||||
} else {
|
||||
userMessage += `${errorMessage}. Please try again or contact support if the problem persists.`;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: userMessage
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to generate outline. The AI outline generation system encountered an issue. Please try again or contact support if the problem persists.'
|
||||
};
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
console.log('generateOutline render called with status:', status);
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #388e3c',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#388e3c' }}>🧩 Generating Outline</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Analyzing research results and content angles...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Structuring content based on keyword analysis...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Creating logical flow and section hierarchy...</p>
|
||||
<p style={{ margin: '0' }}>• Optimizing for SEO and reader engagement...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'generateSection',
|
||||
description: 'Generate content for a specific section using research and outline',
|
||||
parameters: [ { name: 'sectionId', type: 'string', description: 'Section ID', required: true } ],
|
||||
handler: async ({ sectionId }: { sectionId: string }) => {
|
||||
const section = outline.find(s => s.id === sectionId);
|
||||
if (!section) return { success: false, message: 'Section not found. Please generate an outline first.' };
|
||||
|
||||
try {
|
||||
const res = await blogWriterApi.generateSection({ section, mode: genMode });
|
||||
if (res?.markdown) {
|
||||
setSections(prev => ({ ...prev, [sectionId]: res.markdown }));
|
||||
setContinuityRefresh(Date.now());
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `✍️ Content generated for "${section.heading}"! The section incorporates your research findings and primary keywords. You can now review the content, run SEO analysis, or generate more sections.`,
|
||||
section_summary: {
|
||||
heading: section.heading,
|
||||
content_length: res.markdown.length,
|
||||
primary_keywords: research?.keyword_analysis?.primary || []
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Section generation failed:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Content generation failed for "${section.heading}": ${errorMessage}. Please try again or contact support if the problem persists.`
|
||||
};
|
||||
}
|
||||
return { success: false, message: 'Failed to generate section content. Please try again.' };
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #f57c00',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#f57c00' }}>✍️ Generating Section Content</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Analyzing section requirements and research data...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Incorporating primary keywords and SEO best practices...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Writing engaging content with proper structure...</p>
|
||||
<p style={{ margin: '0' }}>• Ensuring factual accuracy and readability...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'generateAllSections',
|
||||
description: 'Generate content for every section in the outline',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
for (const s of outline) {
|
||||
const res = await blogWriterApi.generateSection({ section: s, mode: genMode });
|
||||
setSections(prev => ({ ...prev, [s.id]: res.markdown }));
|
||||
setContinuityRefresh(Date.now());
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
render: ({ status }: any) => (status === 'inProgress' || status === 'executing') ? <div>Generating all sections…</div> : null
|
||||
});
|
||||
|
||||
// Outline refinement (basic op pass-through)
|
||||
useCopilotActionTyped({
|
||||
name: 'refineOutline',
|
||||
description: 'Refine the outline (add/remove/move/merge)',
|
||||
parameters: [
|
||||
{ name: 'operation', type: 'string', description: 'add|remove|move|merge|rename', required: true },
|
||||
{ name: 'sectionId', type: 'string', description: 'Target section ID', required: false },
|
||||
{ name: 'payload', type: 'string', description: 'JSON payload for operation', required: false },
|
||||
],
|
||||
handler: async ({ operation, sectionId, payload }: { operation: string; sectionId?: string; payload?: string }) => {
|
||||
const payloadObj = payload ? (() => { try { return JSON.parse(payload); } catch { return {}; } })() : undefined;
|
||||
const res = await blogWriterApi.refineOutline({ outline, operation, section_id: sectionId, payload: payloadObj });
|
||||
if (res?.outline) setOutline(res.outline);
|
||||
return { success: true };
|
||||
}
|
||||
});
|
||||
|
||||
// Optimize section with HITL diff preview
|
||||
useCopilotActionTyped({
|
||||
name: 'optimizeSection',
|
||||
description: 'Optimize a section for readability/EEAT/examples/data with HITL diff',
|
||||
parameters: [
|
||||
{ name: 'sectionId', type: 'string', description: 'Section ID', required: true },
|
||||
{ name: 'goals', type: 'string', description: 'Comma-separated goals', required: false },
|
||||
],
|
||||
handler: async ({ sectionId, goals }: { sectionId: string; goals?: string }) => {
|
||||
const current = sections[sectionId] || '';
|
||||
if (!current) return { success: false, message: 'No content yet for this section' };
|
||||
const res = await blogWriterApi.seoAnalyze({ content: current, keywords: [] });
|
||||
setSeoAnalysis(res);
|
||||
return { success: true, message: 'Analysis ready' };
|
||||
},
|
||||
renderAndWaitForResponse: ({ respond, args, status }: any) => {
|
||||
if (status === 'complete') return <div>Optimization applied.</div>;
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Optimization preview</div>
|
||||
<div style={{ marginBottom: 8 }}>Goals: {args.goals || 'readability, EEAT'}</div>
|
||||
<button onClick={() => respond?.('apply')}>Apply Changes</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// SEO analyze full draft
|
||||
useCopilotActionTyped({
|
||||
name: 'runSEOAnalyze',
|
||||
description: 'Analyze SEO for the full draft',
|
||||
parameters: [ { name: 'keywords', type: 'string', description: 'Comma-separated keywords', required: false } ],
|
||||
handler: async ({ keywords }: { keywords?: string }) => {
|
||||
const content = buildFullMarkdown();
|
||||
const res = await blogWriterApi.seoAnalyze({ content, keywords: keywords ? keywords.split(',').map(k => k.trim()) : [] });
|
||||
setSeoAnalysis(res);
|
||||
return { success: true, seo_score: res.seo_score };
|
||||
},
|
||||
render: ({ status, result }: any) => status === 'complete' ? (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div>SEO Score: {result?.seo_score ?? '—'}</div>
|
||||
</div>
|
||||
) : null
|
||||
});
|
||||
|
||||
// SEO metadata generate + HITL accept
|
||||
useCopilotActionTyped({
|
||||
name: 'generateSEOMetadata',
|
||||
description: 'Generate SEO metadata for the full draft',
|
||||
parameters: [ { name: 'title', type: 'string', description: 'Preferred title', required: false } ],
|
||||
handler: async ({ title }: { title?: string }) => {
|
||||
const content = buildFullMarkdown();
|
||||
const res = await blogWriterApi.seoMetadata({ content, title, keywords: [] });
|
||||
setSeoMetadata(res);
|
||||
return { success: true };
|
||||
},
|
||||
renderAndWaitForResponse: ({ respond }: any) => (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>SEO Metadata Ready</div>
|
||||
<div style={{ marginBottom: 8 }}>Review the generated title, meta description, and OG/Twitter tags in the editor.</div>
|
||||
<button onClick={() => respond?.('accept')}>Accept Metadata</button>
|
||||
</div>
|
||||
)
|
||||
});
|
||||
|
||||
// Hallucination check with HITL apply-fix
|
||||
useCopilotActionTyped({
|
||||
name: 'runHallucinationCheck',
|
||||
description: 'Run hallucination detector on full draft and view claims',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
const content = buildFullMarkdown();
|
||||
const res = await fetch('/api/blog/quality/hallucination-check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: content })
|
||||
});
|
||||
const data = await res.json();
|
||||
setHallucinationResult(data);
|
||||
return { success: true, total_claims: data?.total_claims };
|
||||
},
|
||||
renderAndWaitForResponse: ({ respond, result }: any) => {
|
||||
if (!result) return null;
|
||||
const claims = hallucinationResult?.claims || [];
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Hallucination Check</div>
|
||||
<div>Total claims: {hallucinationResult?.total_claims ?? 0}</div>
|
||||
<ul>
|
||||
{claims.slice(0, 5).map((c: any, i: number) => {
|
||||
const supporting = (c.supporting_sources && c.supporting_sources[0]?.url) || undefined;
|
||||
const { original, updated } = buildUpdatedMarkdownForClaim(c.text, supporting);
|
||||
return (
|
||||
<li key={i} style={{ marginBottom: 10 }}>
|
||||
<div style={{ marginBottom: 4 }}>[{c.assessment}] {c.text} (conf: {Math.round((c.confidence || 0)*100)/100})</div>
|
||||
{original && updated ? (
|
||||
<DiffPreview
|
||||
original={original}
|
||||
updated={updated}
|
||||
onApply={() => { applyClaimFix(c.text, supporting); respond?.('applied'); }}
|
||||
onDiscard={() => { respond?.('discarded'); }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ fontStyle: 'italic', color: '#666' }}>No matching sentence found for preview.</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<button onClick={() => respond?.('ack')}>Close</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Outline polling hook
|
||||
const outlinePolling = useOutlinePolling({
|
||||
onComplete: handleOutlineComplete,
|
||||
onError: handleOutlineError
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
// Publish (convert markdown -> HTML rudimentary; TODO: replace with proper converter like marked)
|
||||
useCopilotActionTyped({
|
||||
name: 'publishToPlatform',
|
||||
description: 'Publish the blog to Wix or WordPress',
|
||||
parameters: [
|
||||
{ name: 'platform', type: 'string', description: 'wix|wordpress', required: true },
|
||||
{ name: 'schedule_time', type: 'string', description: 'Optional ISO datetime', required: false }
|
||||
],
|
||||
handler: async ({ platform, schedule_time }: { platform: 'wix' | 'wordpress'; schedule_time?: string }) => {
|
||||
const md = buildFullMarkdown();
|
||||
const html = 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/>');
|
||||
if (!seoMetadata) return { success: false, message: 'Generate SEO metadata first' };
|
||||
const res = await blogWriterApi.publish({ platform, html, metadata: seoMetadata, schedule_time });
|
||||
return { success: true, url: res.url };
|
||||
},
|
||||
render: ({ status, result }: any) => status === 'complete' ? (
|
||||
<div style={{ padding: 12 }}>Published: {result?.url || 'Success'}</div>
|
||||
) : null
|
||||
});
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
const items = [] as { title: string; message: string }[];
|
||||
|
||||
if (!research) {
|
||||
items.push({ title: '🔎 Start research', message: "I want to research a topic for my blog" });
|
||||
} else if (research && outline.length === 0) {
|
||||
// Research completed, guide user to outline creation
|
||||
items.push({
|
||||
title: '🧩 Create Outline',
|
||||
message: 'Let\'s proceed to create an outline based on the research results'
|
||||
});
|
||||
items.push({
|
||||
title: '💬 Chat with Research Data',
|
||||
message: 'I want to explore the research data and ask questions about the findings'
|
||||
});
|
||||
items.push({
|
||||
title: '🎨 Create Custom Outline',
|
||||
message: 'I want to create an outline with my own specific instructions and requirements'
|
||||
});
|
||||
} else if (outline.length > 0) {
|
||||
// Outline created, focus on content generation
|
||||
items.push({ title: '📝 Generate all sections', message: 'Generate all sections of my blog post' });
|
||||
outline.forEach(s => items.push({ title: `✍️ Generate ${s.heading}`, message: `Generate the section: ${s.heading}` }));
|
||||
items.push({ title: '🔧 Refine outline', message: 'Help me refine the outline structure' });
|
||||
items.push({ title: '✨ Enhance outline', message: 'Optimize the entire outline for better flow and engagement' });
|
||||
items.push({ title: '⚖️ Rebalance word counts', message: 'Rebalance word count distribution across sections' });
|
||||
items.push({ title: '📈 Run SEO analysis', message: 'Analyze SEO for my blog post' });
|
||||
items.push({ title: '🧾 Generate SEO metadata', message: 'Generate SEO metadata and title' });
|
||||
items.push({ title: '🧪 Hallucination check', message: 'Check for any false claims in my content' });
|
||||
items.push({ title: '🚀 Publish to WordPress', message: 'Publish my blog to WordPress' });
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [research, outline]);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Outline Progress Modal */}
|
||||
<ResearchProgressModal
|
||||
open={Boolean(outlineTaskId && (outlinePolling.isPolling || outlinePolling.currentStatus === 'pending' || outlinePolling.currentStatus === 'running'))}
|
||||
title="Outline generation in progress"
|
||||
status={outlinePolling.currentStatus}
|
||||
messages={outlinePolling.progressMessages}
|
||||
error={outlinePolling.error}
|
||||
onClose={() => { /* informational while processing */ }}
|
||||
/>
|
||||
{/* Extracted Components */}
|
||||
<KeywordInputForm onResearchComplete={handleResearchComplete} />
|
||||
<CustomOutlineForm onOutlineCreated={setOutline} />
|
||||
@@ -512,44 +110,78 @@ export const BlogWriter: React.FC = () => {
|
||||
onOutlineUpdated={setOutline}
|
||||
/>
|
||||
|
||||
{/* New extracted functionality components */}
|
||||
<OutlineGenerator
|
||||
research={research}
|
||||
onTaskStart={(taskId) => setOutlineTaskId(taskId)}
|
||||
onPollingStart={(taskId) => outlinePolling.startPolling(taskId)}
|
||||
/>
|
||||
<SectionGenerator
|
||||
outline={outline}
|
||||
research={research}
|
||||
genMode={genMode}
|
||||
onSectionGenerated={handleSectionGenerated}
|
||||
onContinuityRefresh={handleContinuityRefresh}
|
||||
/>
|
||||
<OutlineRefiner
|
||||
outline={outline}
|
||||
onOutlineUpdated={setOutline}
|
||||
/>
|
||||
<SEOProcessor
|
||||
buildFullMarkdown={buildFullMarkdown}
|
||||
seoMetadata={seoMetadata}
|
||||
onSEOAnalysis={setSeoAnalysis}
|
||||
onSEOMetadata={setSeoMetadata}
|
||||
/>
|
||||
<HallucinationChecker
|
||||
buildFullMarkdown={buildFullMarkdown}
|
||||
buildUpdatedMarkdownForClaim={buildUpdatedMarkdownForClaim}
|
||||
applyClaimFix={applyClaimFix}
|
||||
/>
|
||||
<Publisher
|
||||
buildFullMarkdown={buildFullMarkdown}
|
||||
convertMarkdownToHTML={convertMarkdownToHTML}
|
||||
seoMetadata={seoMetadata}
|
||||
/>
|
||||
|
||||
{!research ? (
|
||||
<BlogWriterLanding
|
||||
onStartWriting={() => {
|
||||
// This will trigger the copilot to start the research process
|
||||
// The user can then interact with the copilot to begin research
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ padding: 16, borderBottom: '1px solid #eee' }}>
|
||||
<h2 style={{ margin: 0 }}>AI Blog Writer</h2>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
|
||||
{!research && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px 20px',
|
||||
color: '#666',
|
||||
fontSize: '16px'
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🔍</div>
|
||||
<h3 style={{ margin: '0 0 8px 0', color: '#333' }}>Ready to Research Your Blog Topic</h3>
|
||||
<p style={{ margin: 0 }}>Start by asking the copilot to research your topic.</p>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{research && outline.length === 0 && <ResearchResults research={research} />}
|
||||
{outline.length > 0 && (
|
||||
<div>
|
||||
{/* Title Selection */}
|
||||
{titleOptions.length > 0 && (
|
||||
<TitleSelector
|
||||
{/* Enhanced Title Selection */}
|
||||
<EnhancedTitleSelector
|
||||
titleOptions={titleOptions}
|
||||
selectedTitle={selectedTitle}
|
||||
onTitleSelect={setSelectedTitle}
|
||||
onCustomTitle={(title) => {
|
||||
setTitleOptions(prev => [...prev, title]);
|
||||
setSelectedTitle(title);
|
||||
}}
|
||||
sections={outline}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
onTitleSelect={handleTitleSelect}
|
||||
onCustomTitle={handleCustomTitle}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{/* Enhanced Outline Editor */}
|
||||
<EnhancedOutlineEditor
|
||||
outline={outline}
|
||||
research={research}
|
||||
onRefine={(op, id, payload) => blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then(res => setOutline(res.outline))}
|
||||
sourceMappingStats={sourceMappingStats}
|
||||
groundingInsights={groundingInsights}
|
||||
optimizationResults={optimizationResults}
|
||||
researchCoverage={researchCoverage}
|
||||
onRefine={(op, id, payload) => blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then((res: any) => setOutline(res.outline))}
|
||||
/>
|
||||
|
||||
{/* Draft/Polished Mode Toggle */}
|
||||
@@ -584,9 +216,16 @@ export const BlogWriter: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<CopilotSidebar
|
||||
labels={{ title: 'ALwrity Co-Pilot', initial: 'Hi! I can help you research, outline, and draft your blog. Just tell me what topic you want to write about and I\'ll get started!' }}
|
||||
labels={{
|
||||
title: 'ALwrity Co-Pilot',
|
||||
initial: !research
|
||||
? 'Hi! I can help you research, outline, and draft your blog. Just tell me what topic you want to write about and I\'ll get started!'
|
||||
: 'Great! I can see you have research data. Let me help you create an outline and generate content for your blog.'
|
||||
}}
|
||||
suggestions={suggestions}
|
||||
makeSystemMessage={(context: string, additional?: string) => {
|
||||
// Get current state information
|
||||
|
||||
68
frontend/src/components/BlogWriter/BlogWriterLanding.md
Normal file
68
frontend/src/components/BlogWriter/BlogWriterLanding.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# BlogWriterLanding Component
|
||||
|
||||
A beautiful, animated landing page for the ALwrity Blog Writer that utilizes the custom background image with artistic button placement and subtle animations.
|
||||
|
||||
## Features
|
||||
|
||||
### 🎨 **Visual Design**
|
||||
- **Full-screen background image** (`/blog-writer-bg.png`) with horizontal stretching (56% width) and left alignment
|
||||
- **Gradient overlays** for subtle depth
|
||||
- **Clean, minimal design** without decorative elements
|
||||
- **Glassmorphism effects** on secondary buttons
|
||||
|
||||
### ✨ **Interactions**
|
||||
- **Button hover effects** with smooth transitions
|
||||
- **Modal interactions** with clean transitions
|
||||
- **Responsive hover states** for all interactive elements
|
||||
|
||||
### 🚀 **Interactive Elements**
|
||||
- **Primary CTA Button**: "Chat/Write with ALwrity Copilot" with gradient background
|
||||
- **Secondary CTA Button**: "ALwrity Blog Writer SuperPowers" opens feature modal
|
||||
- **SuperPowers Modal**: Showcases 6 key features with hover effects
|
||||
- **Responsive design** that works on all screen sizes
|
||||
|
||||
### 🎯 **User Experience**
|
||||
- **Clear messaging** about the blog writing capabilities
|
||||
- **Feature showcase** in an engaging modal format
|
||||
- **Clean, focused messaging** without distracting text
|
||||
- **Clean transitions** between states
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import BlogWriterLanding from './BlogWriterLanding';
|
||||
|
||||
<BlogWriterLanding
|
||||
onStartWriting={() => {
|
||||
// Handle start writing action
|
||||
// This can trigger copilot interaction
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
- `onStartWriting: () => void` - Callback function called when user clicks "Chat/Write with ALwrity Copilot"
|
||||
|
||||
## Integration
|
||||
|
||||
The component integrates with:
|
||||
- **useCopilotTrigger hook** for copilot interaction
|
||||
- **BlogWriter main component** as the initial state
|
||||
- **Responsive design** that adapts to different screen sizes
|
||||
|
||||
## Styling
|
||||
|
||||
All styles are inline with CSS-in-JS approach for:
|
||||
- **Better performance** (no external CSS files)
|
||||
- **Component isolation** (styles don't leak)
|
||||
- **Dynamic theming** (easy to modify colors/effects)
|
||||
- **Animation control** (precise timing and effects)
|
||||
|
||||
## Accessibility
|
||||
|
||||
- **Semantic HTML** structure
|
||||
- **Keyboard navigation** support
|
||||
- **Screen reader** friendly
|
||||
- **High contrast** text and buttons
|
||||
- **Focus indicators** for interactive elements
|
||||
380
frontend/src/components/BlogWriter/BlogWriterLanding.tsx
Normal file
380
frontend/src/components/BlogWriter/BlogWriterLanding.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCopilotTrigger } from '../../hooks/useCopilotTrigger';
|
||||
|
||||
interface BlogWriterLandingProps {
|
||||
onStartWriting: () => void;
|
||||
}
|
||||
|
||||
const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting }) => {
|
||||
const [showSuperPowers, setShowSuperPowers] = useState(false);
|
||||
const { triggerResearch } = useCopilotTrigger();
|
||||
|
||||
const handleStartWriting = () => {
|
||||
// Open the copilot sidebar (same functionality as LinkedIn writer)
|
||||
const copilotButton = document.querySelector('.copilotkit-open-button') ||
|
||||
document.querySelector('[data-copilot-open]') ||
|
||||
document.querySelector('button[aria-label*="Open"]') ||
|
||||
document.querySelector('.alwrity-copilot-sidebar button');
|
||||
|
||||
if (copilotButton) {
|
||||
(copilotButton as HTMLElement).click();
|
||||
} else {
|
||||
// Fallback: scroll to bottom right where the button should be
|
||||
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// Also call the parent callback
|
||||
onStartWriting();
|
||||
};
|
||||
|
||||
const superPowers = [
|
||||
{
|
||||
icon: "🔍",
|
||||
title: "AI-Powered Research",
|
||||
description: "Comprehensive research with Google Search grounding, competitor analysis, and content gap identification"
|
||||
},
|
||||
{
|
||||
icon: "📝",
|
||||
title: "Intelligent Outline Generation",
|
||||
description: "AI-generated outlines with source mapping, grounding insights, and optimization recommendations"
|
||||
},
|
||||
{
|
||||
icon: "✨",
|
||||
title: "Content Enhancement",
|
||||
description: "Section-by-section content generation with SEO optimization and engagement improvements"
|
||||
},
|
||||
{
|
||||
icon: "🎯",
|
||||
title: "SEO Intelligence",
|
||||
description: "Advanced SEO analysis, metadata generation, and keyword optimization for maximum visibility"
|
||||
},
|
||||
{
|
||||
icon: "🔍",
|
||||
title: "Fact-Checking & Quality",
|
||||
description: "Hallucination detection, claim verification, and content quality assurance"
|
||||
},
|
||||
{
|
||||
icon: "🚀",
|
||||
title: "Multi-Platform Publishing",
|
||||
description: "Direct publishing to WordPress, Wix, and other platforms with scheduling capabilities"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
minHeight: '100vh',
|
||||
backgroundImage: 'url(/blog-writer-bg.png)',
|
||||
backgroundSize: '56% auto',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundColor: 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Animated overlay for subtle movement */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(135deg, rgba(25, 118, 210, 0.05) 0%, rgba(156, 39, 176, 0.05) 100%)'
|
||||
}} />
|
||||
|
||||
{/* Main content container */}
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
textAlign: 'center',
|
||||
maxWidth: '800px',
|
||||
padding: '40px 20px'
|
||||
}}>
|
||||
{/* Main heading */}
|
||||
<div style={{
|
||||
marginBottom: '40px'
|
||||
}}>
|
||||
<h1 style={{
|
||||
fontSize: '3.5rem',
|
||||
fontWeight: '700',
|
||||
background: 'linear-gradient(135deg, #1976d2 0%, #9c27b0 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
margin: '0 0 20px 0',
|
||||
textShadow: '0 4px 8px rgba(0,0,0,0.1)',
|
||||
lineHeight: '1.2'
|
||||
}}>
|
||||
Step1- Research Your Blog Topic
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
{/* Primary CTA Button */}
|
||||
<button
|
||||
onClick={handleStartWriting}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #1976d2 0%, #1565c0 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '18px 48px',
|
||||
borderRadius: '50px',
|
||||
fontSize: '1.2rem',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 8px 25px rgba(25, 118, 210, 0.3)',
|
||||
transition: 'all 0.3s ease',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
minWidth: '280px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-3px) scale(1.05)';
|
||||
e.currentTarget.style.boxShadow = '0 12px 35px rgba(25, 118, 210, 0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0) scale(1)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 25px rgba(25, 118, 210, 0.3)';
|
||||
}}
|
||||
>
|
||||
<span style={{ position: 'relative', zIndex: 2 }}>
|
||||
✨ Chat/Write with ALwrity Copilot
|
||||
</span>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '-100%',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent)',
|
||||
transition: 'left 0.5s ease'
|
||||
}} />
|
||||
</button>
|
||||
|
||||
{/* Secondary CTA Button */}
|
||||
<button
|
||||
onClick={() => setShowSuperPowers(true)}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
color: '#1976d2',
|
||||
border: '2px solid #1976d2',
|
||||
padding: '14px 36px',
|
||||
borderRadius: '50px',
|
||||
fontSize: '1rem',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 15px rgba(0,0,0,0.1)',
|
||||
transition: 'all 0.3s ease',
|
||||
backdropFilter: 'blur(10px)',
|
||||
minWidth: '280px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#1976d2';
|
||||
e.currentTarget.style.color = 'white';
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 20px rgba(25, 118, 210, 0.3)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.9)';
|
||||
e.currentTarget.style.color = '#1976d2';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 15px rgba(0,0,0,0.1)';
|
||||
}}
|
||||
>
|
||||
🚀 ALwrity Blog Writer SuperPowers
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SuperPowers Modal */}
|
||||
{showSuperPowers && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '20px',
|
||||
padding: '40px',
|
||||
maxWidth: '900px',
|
||||
width: '90%',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 25px 50px rgba(0, 0, 0, 0.3)'
|
||||
}}>
|
||||
{/* Modal Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '30px',
|
||||
paddingBottom: '20px',
|
||||
borderBottom: '2px solid #f0f0f0'
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '2rem',
|
||||
background: 'linear-gradient(135deg, #1976d2 0%, #9c27b0 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text'
|
||||
}}>
|
||||
🚀 ALwrity Blog Writer SuperPowers
|
||||
</h2>
|
||||
<p style={{ margin: 0, color: '#666', fontSize: '1.1rem' }}>
|
||||
Discover the powerful features that make ALwrity the ultimate blog writing assistant
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSuperPowers(false)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '2rem',
|
||||
cursor: 'pointer',
|
||||
color: '#999',
|
||||
padding: '8px',
|
||||
borderRadius: '50%',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f0f0f0';
|
||||
e.currentTarget.style.color = '#333';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#999';
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* SuperPowers Grid */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))',
|
||||
gap: '24px'
|
||||
}}>
|
||||
{superPowers.map((power, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
padding: '24px',
|
||||
borderRadius: '16px',
|
||||
border: '1px solid #e0e0e0',
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 25px rgba(0,0,0,0.1)';
|
||||
e.currentTarget.style.borderColor = '#1976d2';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
e.currentTarget.style.borderColor = '#e0e0e0';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '2rem',
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%)',
|
||||
borderRadius: '12px'
|
||||
}}>
|
||||
{power.icon}
|
||||
</div>
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: '1.3rem',
|
||||
color: '#333',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{power.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p style={{
|
||||
margin: 0,
|
||||
color: '#666',
|
||||
lineHeight: '1.6',
|
||||
fontSize: '1rem'
|
||||
}}>
|
||||
{power.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div style={{
|
||||
marginTop: '30px',
|
||||
paddingTop: '20px',
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<button
|
||||
onClick={handleStartWriting}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #1976d2 0%, #1565c0 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '16px 32px',
|
||||
borderRadius: '50px',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 6px 20px rgba(25, 118, 210, 0.3)',
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 25px rgba(25, 118, 210, 0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 20px rgba(25, 118, 210, 0.3)';
|
||||
}}
|
||||
>
|
||||
✨ Chat/Write with ALwrity Copilot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogWriterLanding;
|
||||
@@ -1,13 +1,27 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BlogOutlineSection } from '../../services/blogWriterApi';
|
||||
import { BlogOutlineSection, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage } from '../../services/blogWriterApi';
|
||||
import EnhancedOutlineInsights from './EnhancedOutlineInsights';
|
||||
import OutlineIntelligenceChips from './OutlineIntelligenceChips';
|
||||
|
||||
interface Props {
|
||||
outline: BlogOutlineSection[];
|
||||
onRefine: (operation: string, sectionId?: string, payload?: any) => void;
|
||||
research?: any; // Research data for context
|
||||
sourceMappingStats?: SourceMappingStats | null;
|
||||
groundingInsights?: GroundingInsights | null;
|
||||
optimizationResults?: OptimizationResults | null;
|
||||
researchCoverage?: ResearchCoverage | null;
|
||||
}
|
||||
|
||||
const EnhancedOutlineEditor: React.FC<Props> = ({ outline, onRefine, research }) => {
|
||||
const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
outline,
|
||||
onRefine,
|
||||
research,
|
||||
sourceMappingStats,
|
||||
groundingInsights,
|
||||
optimizationResults,
|
||||
researchCoverage
|
||||
}) => {
|
||||
const [editingSection, setEditingSection] = useState<string | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
|
||||
const [showAddSection, setShowAddSection] = useState(false);
|
||||
@@ -87,13 +101,25 @@ const EnhancedOutlineEditor: React.FC<Props> = ({ outline, onRefine, research })
|
||||
borderBottom: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, color: '#333', fontSize: '20px' }}>
|
||||
📋 Blog Outline
|
||||
</h2>
|
||||
<p style={{ margin: '4px 0 0 0', color: '#666', fontSize: '14px' }}>
|
||||
{outline.length} sections • {getTotalWords()} words total
|
||||
</p>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, color: '#333', fontSize: '20px' }}>
|
||||
📋 Blog Outline
|
||||
</h2>
|
||||
<p style={{ margin: '4px 0 0 0', color: '#666', fontSize: '14px' }}>
|
||||
{outline.length} sections • {getTotalWords()} words total
|
||||
</p>
|
||||
</div>
|
||||
{/* Intelligence Chips inline with title */}
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<OutlineIntelligenceChips
|
||||
sections={outline}
|
||||
sourceMappingStats={sourceMappingStats}
|
||||
groundingInsights={groundingInsights}
|
||||
optimizationResults={optimizationResults}
|
||||
researchCoverage={researchCoverage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddSection(!showAddSection)}
|
||||
@@ -116,6 +142,7 @@ const EnhancedOutlineEditor: React.FC<Props> = ({ outline, onRefine, research })
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Add Section Form */}
|
||||
{showAddSection && (
|
||||
<div style={{
|
||||
|
||||
469
frontend/src/components/BlogWriter/EnhancedOutlineInsights.tsx
Normal file
469
frontend/src/components/BlogWriter/EnhancedOutlineInsights.tsx
Normal file
@@ -0,0 +1,469 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BlogOutlineSection } from '../../services/blogWriterApi';
|
||||
|
||||
interface GroundingInsights {
|
||||
confidence_analysis?: {
|
||||
average_confidence: number;
|
||||
high_confidence_sources_count: number;
|
||||
confidence_distribution: { high: number; medium: number; low: number };
|
||||
};
|
||||
authority_analysis?: {
|
||||
average_authority_score: number;
|
||||
high_authority_sources: Array<{ title: string; url: string; score: number }>;
|
||||
};
|
||||
content_relationships?: {
|
||||
related_concepts: string[];
|
||||
content_gaps: string[];
|
||||
concept_coverage_score: number;
|
||||
};
|
||||
search_intent_insights?: {
|
||||
primary_intent: string;
|
||||
user_questions: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface SourceMappingStats {
|
||||
total_sources_mapped: number;
|
||||
coverage_percentage: number;
|
||||
average_relevance_score: number;
|
||||
high_confidence_mappings: number;
|
||||
}
|
||||
|
||||
interface OptimizationResults {
|
||||
overall_quality_score: number;
|
||||
improvements_made: string[];
|
||||
optimization_focus: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sections: BlogOutlineSection[];
|
||||
groundingInsights?: GroundingInsights;
|
||||
sourceMappingStats?: SourceMappingStats;
|
||||
optimizationResults?: OptimizationResults;
|
||||
researchCoverage?: {
|
||||
sources_utilized: number;
|
||||
content_gaps_identified: number;
|
||||
competitive_advantages: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const EnhancedOutlineInsights: React.FC<Props> = ({
|
||||
sections,
|
||||
groundingInsights,
|
||||
sourceMappingStats,
|
||||
optimizationResults,
|
||||
researchCoverage
|
||||
}) => {
|
||||
const [expandedInsights, setExpandedInsights] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleInsight = (insightType: string) => {
|
||||
const newExpanded = new Set(expandedInsights);
|
||||
if (newExpanded.has(insightType)) {
|
||||
newExpanded.delete(insightType);
|
||||
} else {
|
||||
newExpanded.add(insightType);
|
||||
}
|
||||
setExpandedInsights(newExpanded);
|
||||
};
|
||||
|
||||
const getConfidenceColor = (score: number) => {
|
||||
if (score >= 0.8) return '#4caf50'; // Green
|
||||
if (score >= 0.6) return '#ff9800'; // Orange
|
||||
return '#f44336'; // Red
|
||||
};
|
||||
|
||||
const getQualityGrade = (score: number) => {
|
||||
if (score >= 9) return { grade: 'A+', color: '#4caf50' };
|
||||
if (score >= 8) return { grade: 'A', color: '#4caf50' };
|
||||
if (score >= 7) return { grade: 'B+', color: '#8bc34a' };
|
||||
if (score >= 6) return { grade: 'B', color: '#ff9800' };
|
||||
if (score >= 5) return { grade: 'C', color: '#ff9800' };
|
||||
return { grade: 'D', color: '#f44336' };
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
margin: '20px 0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
padding: '16px 20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: '600' }}>
|
||||
🧠 Outline Intelligence & Insights
|
||||
</h3>
|
||||
<span style={{ fontSize: '12px', opacity: 0.9 }}>
|
||||
{sections.length} sections analyzed
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '20px' }}>
|
||||
{/* Research Coverage */}
|
||||
{researchCoverage && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
padding: '12px',
|
||||
backgroundColor: expandedInsights.has('research') ? '#e3f2fd' : 'white',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px'
|
||||
}}
|
||||
onClick={() => toggleInsight('research')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '18px' }}>📊</span>
|
||||
<span style={{ fontWeight: '600' }}>Research Data Utilization</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '14px', color: '#666' }}>
|
||||
{expandedInsights.has('research') ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expandedInsights.has('research') && (
|
||||
<div style={{ padding: '16px', backgroundColor: '#fafafa', borderTop: '1px solid #e0e0e0' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#1976d2' }}>
|
||||
{researchCoverage.sources_utilized}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Sources Utilized</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#ff9800' }}>
|
||||
{researchCoverage.content_gaps_identified}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Content Gaps Identified</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#4caf50' }}>
|
||||
{researchCoverage.competitive_advantages.length}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Competitive Advantages</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{researchCoverage.competitive_advantages.length > 0 && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Key Advantages:</h5>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{researchCoverage.competitive_advantages.map((advantage, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#e8f5e8',
|
||||
color: '#388e3c',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
{advantage}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Mapping Intelligence */}
|
||||
{sourceMappingStats && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
padding: '12px',
|
||||
backgroundColor: expandedInsights.has('mapping') ? '#e3f2fd' : 'white',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px'
|
||||
}}
|
||||
onClick={() => toggleInsight('mapping')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '18px' }}>🔗</span>
|
||||
<span style={{ fontWeight: '600' }}>Source Mapping Intelligence</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '14px', color: '#666' }}>
|
||||
{expandedInsights.has('mapping') ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expandedInsights.has('mapping') && (
|
||||
<div style={{ padding: '16px', backgroundColor: '#fafafa', borderTop: '1px solid #e0e0e0' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: '700', color: '#1976d2' }}>
|
||||
{sourceMappingStats.total_sources_mapped}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Sources Mapped</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: '700', color: getConfidenceColor(sourceMappingStats.coverage_percentage / 100) }}>
|
||||
{sourceMappingStats.coverage_percentage}%
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Coverage</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: '700', color: getConfidenceColor(sourceMappingStats.average_relevance_score) }}>
|
||||
{(sourceMappingStats.average_relevance_score * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Avg Relevance</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: '700', color: '#4caf50' }}>
|
||||
{sourceMappingStats.high_confidence_mappings}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>High Confidence</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grounding Insights */}
|
||||
{groundingInsights && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
padding: '12px',
|
||||
backgroundColor: expandedInsights.has('grounding') ? '#e3f2fd' : 'white',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px'
|
||||
}}
|
||||
onClick={() => toggleInsight('grounding')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '18px' }}>🧠</span>
|
||||
<span style={{ fontWeight: '600' }}>Grounding Metadata Insights</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '14px', color: '#666' }}>
|
||||
{expandedInsights.has('grounding') ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expandedInsights.has('grounding') && (
|
||||
<div style={{ padding: '16px', backgroundColor: '#fafafa', borderTop: '1px solid #e0e0e0' }}>
|
||||
{/* Confidence Analysis */}
|
||||
{groundingInsights.confidence_analysis && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Confidence Analysis</h5>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '700', color: getConfidenceColor(groundingInsights.confidence_analysis.average_confidence) }}>
|
||||
{(groundingInsights.confidence_analysis.average_confidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Avg Confidence</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '700', color: '#4caf50' }}>
|
||||
{groundingInsights.confidence_analysis.high_confidence_sources_count}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>High Confidence Sources</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Authority Analysis */}
|
||||
{groundingInsights.authority_analysis && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Authority Analysis</h5>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '700', color: getConfidenceColor(groundingInsights.authority_analysis.average_authority_score) }}>
|
||||
{(groundingInsights.authority_analysis.average_authority_score * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Avg Authority</div>
|
||||
</div>
|
||||
{groundingInsights.authority_analysis.high_authority_sources.length > 0 && (
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>Top Authority Sources:</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||
{groundingInsights.authority_analysis.high_authority_sources.slice(0, 3).map((source, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
{source.title.substring(0, 30)}...
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Relationships */}
|
||||
{groundingInsights.content_relationships && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Content Relationships</h5>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '700', color: getConfidenceColor(groundingInsights.content_relationships.concept_coverage_score) }}>
|
||||
{(groundingInsights.content_relationships.concept_coverage_score * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Concept Coverage</div>
|
||||
</div>
|
||||
{groundingInsights.content_relationships.related_concepts.length > 0 && (
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>Related Concepts:</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||
{groundingInsights.content_relationships.related_concepts.slice(0, 5).map((concept, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#fff3e0',
|
||||
color: '#f57c00',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
{concept}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Intent */}
|
||||
{groundingInsights.search_intent_insights && (
|
||||
<div>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Search Intent Analysis</h5>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '16px', fontWeight: '700', color: '#1976d2', textTransform: 'capitalize' }}>
|
||||
{groundingInsights.search_intent_insights.primary_intent}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Primary Intent</div>
|
||||
</div>
|
||||
{groundingInsights.search_intent_insights.user_questions.length > 0 && (
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>User Questions:</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||
{groundingInsights.search_intent_insights.user_questions.slice(0, 3).map((question, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#f3e5f5',
|
||||
color: '#7b1fa2',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
{question.substring(0, 40)}...
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optimization Results */}
|
||||
{optimizationResults && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
padding: '12px',
|
||||
backgroundColor: expandedInsights.has('optimization') ? '#e3f2fd' : 'white',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px'
|
||||
}}
|
||||
onClick={() => toggleInsight('optimization')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '18px' }}>🎯</span>
|
||||
<span style={{ fontWeight: '600' }}>Optimization Results</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '700',
|
||||
color: getQualityGrade(optimizationResults.overall_quality_score).color
|
||||
}}>
|
||||
{getQualityGrade(optimizationResults.overall_quality_score).grade}
|
||||
</span>
|
||||
<span style={{ fontSize: '14px', color: '#666' }}>
|
||||
{expandedInsights.has('optimization') ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedInsights.has('optimization') && (
|
||||
<div style={{ padding: '16px', backgroundColor: '#fafafa', borderTop: '1px solid #e0e0e0' }}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Quality Assessment</h5>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: getQualityGrade(optimizationResults.overall_quality_score).color
|
||||
}}>
|
||||
{optimizationResults.overall_quality_score}/10
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Overall Quality</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '16px', fontWeight: '700', color: '#1976d2', textTransform: 'capitalize' }}>
|
||||
{optimizationResults.optimization_focus}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Focus Area</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{optimizationResults.improvements_made.length > 0 && (
|
||||
<div>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Improvements Made:</h5>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px' }}>
|
||||
{optimizationResults.improvements_made.map((improvement, i) => (
|
||||
<li key={i} style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>
|
||||
{improvement}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedOutlineInsights;
|
||||
486
frontend/src/components/BlogWriter/EnhancedTitleSelector.tsx
Normal file
486
frontend/src/components/BlogWriter/EnhancedTitleSelector.tsx
Normal file
@@ -0,0 +1,486 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BlogOutlineSection } from '../../services/blogWriterApi';
|
||||
|
||||
interface EnhancedTitleSelectorProps {
|
||||
titleOptions: string[];
|
||||
selectedTitle?: string;
|
||||
onTitleSelect: (title: string) => void;
|
||||
onCustomTitle?: (title: string) => void;
|
||||
sections: BlogOutlineSection[];
|
||||
researchTitles?: string[];
|
||||
aiGeneratedTitles?: string[];
|
||||
}
|
||||
|
||||
const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
|
||||
titleOptions,
|
||||
selectedTitle,
|
||||
onTitleSelect,
|
||||
onCustomTitle,
|
||||
sections,
|
||||
researchTitles = [],
|
||||
aiGeneratedTitles = []
|
||||
}) => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [customTitle, setCustomTitle] = useState('');
|
||||
|
||||
const handleTitleSelect = (title: string) => {
|
||||
onTitleSelect(title);
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const handleCustomTitleSubmit = () => {
|
||||
if (customTitle.trim() && onCustomTitle) {
|
||||
onCustomTitle(customTitle.trim());
|
||||
setCustomTitle('');
|
||||
setShowModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getSectionSummary = () => {
|
||||
return sections.map(section => ({
|
||||
title: section.heading,
|
||||
wordCount: section.target_words || 0,
|
||||
subheadings: section.subheadings.length,
|
||||
keyPoints: section.key_points.length
|
||||
}));
|
||||
};
|
||||
|
||||
const sectionSummary = getSectionSummary();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main Title Display */}
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
padding: '20px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 8px 0', color: '#333', fontSize: '18px' }}>
|
||||
📝 Blog Title
|
||||
</h3>
|
||||
<p style={{
|
||||
margin: '0',
|
||||
color: '#666',
|
||||
fontSize: '14px',
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: '1.4',
|
||||
maxHeight: '60px',
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical'
|
||||
}}>
|
||||
{selectedTitle || 'No title selected'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
✨ ALwrity it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title Selection Modal */}
|
||||
{showModal && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
maxWidth: '900px',
|
||||
width: '95%',
|
||||
maxHeight: '85vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
{/* Modal Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '32px',
|
||||
paddingBottom: '16px',
|
||||
borderBottom: '2px solid #f3f4f6'
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{ margin: '0 0 4px 0', color: '#1f2937', fontSize: '24px', fontWeight: '600' }}>
|
||||
✨ ALwrity Title Suggestions
|
||||
</h2>
|
||||
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
|
||||
Choose from research-based content angles, AI-generated titles, or create your own
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '28px',
|
||||
cursor: 'pointer',
|
||||
color: '#9ca3af',
|
||||
padding: '4px',
|
||||
borderRadius: '6px',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f3f4f6';
|
||||
e.currentTarget.style.color = '#374151';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#9ca3af';
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Section Information */}
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#333' }}>
|
||||
📋 Current Outline Summary
|
||||
</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>Total Sections</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>{sections.length}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>Total Words</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>
|
||||
{sections.reduce((sum, section) => sum + (section.target_words || 0), 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>Subheadings</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>
|
||||
{sections.reduce((sum, section) => sum + section.subheadings.length, 0)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Details */}
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Section Details:</h5>
|
||||
<div style={{ display: 'grid', gap: '8px' }}>
|
||||
{sectionSummary.map((section, index) => (
|
||||
<div key={index} style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', fontWeight: '500' }}>{section.title}</span>
|
||||
<div style={{ display: 'flex', gap: '16px', fontSize: '12px', color: '#666' }}>
|
||||
<span>{section.wordCount} words</span>
|
||||
<span>{section.subheadings} subheadings</span>
|
||||
<span>{section.keyPoints} key points</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title Options */}
|
||||
<div style={{ display: 'grid', gap: '24px' }}>
|
||||
{/* Research Content Angles */}
|
||||
{researchTitles.length > 0 && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: '#e3f2fd',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px'
|
||||
}}>
|
||||
🔍
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 4px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>
|
||||
Research Content Angles
|
||||
</h4>
|
||||
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
|
||||
Titles derived from your research data and content angles
|
||||
</p>
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '16px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{researchTitles.length}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: '10px' }}>
|
||||
{researchTitles.map((title, index) => (
|
||||
<button
|
||||
key={`research-${index}`}
|
||||
onClick={() => handleTitleSelect(title)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '16px 20px',
|
||||
border: selectedTitle === title ? '2px solid #1976d2' : '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: selectedTitle === title ? '#f0f9ff' : 'white',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
fontSize: '15px',
|
||||
color: '#1f2937',
|
||||
transition: 'all 0.2s ease',
|
||||
lineHeight: '1.4',
|
||||
wordBreak: 'break-word'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
e.currentTarget.style.backgroundColor = '#f9fafb';
|
||||
e.currentTarget.style.borderColor = '#d1d5db';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
e.currentTarget.style.backgroundColor = 'white';
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI-Generated Titles */}
|
||||
{aiGeneratedTitles.length > 0 && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: '#f3e5f5',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px'
|
||||
}}>
|
||||
🤖
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 4px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>
|
||||
AI-Generated Titles
|
||||
</h4>
|
||||
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
|
||||
Creative titles generated by AI based on your research
|
||||
</p>
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
backgroundColor: '#7b1fa2',
|
||||
color: 'white',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '16px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{aiGeneratedTitles.length}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: '10px' }}>
|
||||
{aiGeneratedTitles.map((title, index) => (
|
||||
<button
|
||||
key={`ai-${index}`}
|
||||
onClick={() => handleTitleSelect(title)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '16px 20px',
|
||||
border: selectedTitle === title ? '2px solid #7b1fa2' : '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: selectedTitle === title ? '#faf5ff' : 'white',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
fontSize: '15px',
|
||||
color: '#1f2937',
|
||||
transition: 'all 0.2s ease',
|
||||
lineHeight: '1.4',
|
||||
wordBreak: 'break-word'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
e.currentTarget.style.backgroundColor = '#f9fafb';
|
||||
e.currentTarget.style.borderColor = '#d1d5db';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
e.currentTarget.style.backgroundColor = 'white';
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Title Input */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: '#fef3c7',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px'
|
||||
}}>
|
||||
✏️
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 4px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>
|
||||
Custom Title
|
||||
</h4>
|
||||
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
|
||||
Create your own unique title
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={customTitle}
|
||||
onChange={(e) => setCustomTitle(e.target.value)}
|
||||
placeholder="Enter your custom title..."
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '16px 20px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
fontSize: '15px',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleCustomTitleSubmit()}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = '#1976d2';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(25, 118, 210, 0.1)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCustomTitleSubmit}
|
||||
disabled={!customTitle.trim()}
|
||||
style={{
|
||||
padding: '16px 24px',
|
||||
backgroundColor: customTitle.trim() ? '#1976d2' : '#d1d5db',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
cursor: customTitle.trim() ? 'pointer' : 'not-allowed',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease',
|
||||
minWidth: '120px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (customTitle.trim()) {
|
||||
e.currentTarget.style.backgroundColor = '#1565c0';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (customTitle.trim()) {
|
||||
e.currentTarget.style.backgroundColor = '#1976d2';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
Use Title
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '12px',
|
||||
marginTop: '24px',
|
||||
paddingTop: '16px',
|
||||
borderTop: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
color: '#666',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedTitleSelector;
|
||||
76
frontend/src/components/BlogWriter/HallucinationChecker.tsx
Normal file
76
frontend/src/components/BlogWriter/HallucinationChecker.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import DiffPreview from './DiffPreview';
|
||||
|
||||
interface HallucinationCheckerProps {
|
||||
buildFullMarkdown: () => string;
|
||||
buildUpdatedMarkdownForClaim: (claimText: string, supportingUrl?: string) => {
|
||||
original: string;
|
||||
updated: string;
|
||||
updatedMarkdown: string;
|
||||
};
|
||||
applyClaimFix: (claimText: string, supportingUrl?: string) => void;
|
||||
}
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
export const HallucinationChecker: React.FC<HallucinationCheckerProps> = ({
|
||||
buildFullMarkdown,
|
||||
buildUpdatedMarkdownForClaim,
|
||||
applyClaimFix
|
||||
}) => {
|
||||
const [hallucinationResult, setHallucinationResult] = useState<any>(null);
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'runHallucinationCheck',
|
||||
description: 'Run hallucination detector on full draft and view claims',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
const content = buildFullMarkdown();
|
||||
const res = await fetch('/api/blog/quality/hallucination-check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: content })
|
||||
});
|
||||
const data = await res.json();
|
||||
setHallucinationResult(data);
|
||||
return { success: true, total_claims: data?.total_claims };
|
||||
},
|
||||
renderAndWaitForResponse: ({ respond, result }: any) => {
|
||||
if (!result) return null;
|
||||
const claims = hallucinationResult?.claims || [];
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Hallucination Check</div>
|
||||
<div>Total claims: {hallucinationResult?.total_claims ?? 0}</div>
|
||||
<ul>
|
||||
{claims.slice(0, 5).map((c: any, i: number) => {
|
||||
const supporting = (c.supporting_sources && c.supporting_sources[0]?.url) || undefined;
|
||||
const { original, updated } = buildUpdatedMarkdownForClaim(c.text, supporting);
|
||||
return (
|
||||
<li key={i} style={{ marginBottom: 10 }}>
|
||||
<div style={{ marginBottom: 4 }}>[{c.assessment}] {c.text} (conf: {Math.round((c.confidence || 0)*100)/100})</div>
|
||||
{original && updated ? (
|
||||
<DiffPreview
|
||||
original={original}
|
||||
updated={updated}
|
||||
onApply={() => { applyClaimFix(c.text, supporting); respond?.('applied'); }}
|
||||
onDiscard={() => { respond?.('discarded'); }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ fontStyle: 'italic', color: '#666' }}>No matching sentence found for preview.</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<button onClick={() => respond?.('ack')}>Close</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the copilot action
|
||||
};
|
||||
|
||||
export default HallucinationChecker;
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
import ResearchPollingHandler from './ResearchPollingHandler';
|
||||
import { researchCache } from '../../services/researchCache';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
@@ -139,6 +141,7 @@ const ResearchForm: React.FC<{
|
||||
};
|
||||
|
||||
export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsReceived, onResearchComplete }) => {
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||
|
||||
// Keyword input action with Human-in-the-Loop
|
||||
useCopilotActionTyped({
|
||||
@@ -190,7 +193,19 @@ export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsRe
|
||||
|
||||
const keywordList = keywords.includes(',')
|
||||
? keywords.split(',').map((k: string) => k.trim())
|
||||
: keywords.split(' ').filter((k: string) => k.length > 2).slice(0, 5);
|
||||
: [keywords.trim()]; // Preserve single phrases as-is
|
||||
|
||||
// Check frontend cache first
|
||||
const cachedResult = researchCache.getCachedResult(keywordList, 'General', 'General');
|
||||
if (cachedResult) {
|
||||
console.log('Frontend cache hit - returning cached result instantly');
|
||||
onResearchComplete?.(cachedResult);
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Found cached research for "${keywords}"! Results loaded instantly.`,
|
||||
cached: true
|
||||
};
|
||||
}
|
||||
|
||||
const payload: BlogResearchRequest = {
|
||||
keywords: keywordList,
|
||||
@@ -199,24 +214,14 @@ export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsRe
|
||||
word_count_target: parseInt(blogLength)
|
||||
};
|
||||
|
||||
const res = await blogWriterApi.research(payload);
|
||||
onResearchComplete?.(res);
|
||||
|
||||
const sourcesCount = res.sources?.length || 0;
|
||||
const queriesCount = res.search_queries?.length || 0;
|
||||
const anglesCount = res.suggested_angles?.length || 0;
|
||||
// Start async research
|
||||
const { task_id } = await blogWriterApi.startResearch(payload);
|
||||
setCurrentTaskId(task_id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `🔍 Research completed for "${keywords}"! Found ${sourcesCount} sources, ${queriesCount} search queries, and ${anglesCount} content angles. The research results are now displayed in the UI.`,
|
||||
research_summary: {
|
||||
topic: keywords,
|
||||
sources: sourcesCount,
|
||||
queries: queriesCount,
|
||||
angles: anglesCount,
|
||||
primary_keywords: res.keyword_analysis?.primary || [],
|
||||
search_intent: res.keyword_analysis?.search_intent || 'informational'
|
||||
}
|
||||
message: `🔍 Research started for "${keywords}"! Task ID: ${task_id}. Progress will be shown below.`,
|
||||
task_id: task_id
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Research failed: ${error}`);
|
||||
@@ -266,7 +271,22 @@ export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsRe
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the CopilotKit action, no UI
|
||||
return (
|
||||
<>
|
||||
{/* Polling handler for research progress */}
|
||||
<ResearchPollingHandler
|
||||
taskId={currentTaskId}
|
||||
onResearchComplete={(result) => {
|
||||
onResearchComplete?.(result);
|
||||
setCurrentTaskId(null);
|
||||
}}
|
||||
onError={(error) => {
|
||||
console.error('Research error:', error);
|
||||
setCurrentTaskId(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeywordInputForm;
|
||||
|
||||
102
frontend/src/components/BlogWriter/OutlineGenerator.tsx
Normal file
102
frontend/src/components/BlogWriter/OutlineGenerator.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
|
||||
interface OutlineGeneratorProps {
|
||||
research: BlogResearchResponse | null;
|
||||
onTaskStart: (taskId: string) => void;
|
||||
onPollingStart: (taskId: string) => void;
|
||||
}
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
export const OutlineGenerator: React.FC<OutlineGeneratorProps> = ({
|
||||
research,
|
||||
onTaskStart,
|
||||
onPollingStart
|
||||
}) => {
|
||||
useCopilotActionTyped({
|
||||
name: 'generateOutline',
|
||||
description: 'Generate outline from research results using AI analysis',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
if (!research) return { success: false, message: 'No research yet. Please research a topic first.' };
|
||||
|
||||
try {
|
||||
// Start async outline generation
|
||||
const { task_id } = await blogWriterApi.startOutlineGeneration({ research });
|
||||
onTaskStart(task_id);
|
||||
onPollingStart(task_id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `🧩 Outline generation started! Task ID: ${task_id}. Progress will be shown below.`,
|
||||
task_id: task_id
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Outline generation failed:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
// Provide more specific error messages based on the error type
|
||||
let userMessage = '❌ Outline generation failed. ';
|
||||
if (errorMessage.includes('503') || errorMessage.includes('overloaded')) {
|
||||
userMessage += 'The AI service is temporarily overloaded. Please try again in a few minutes.';
|
||||
} else if (errorMessage.includes('timeout')) {
|
||||
userMessage += 'The request timed out. Please try again.';
|
||||
} else if (errorMessage.includes('Invalid outline structure')) {
|
||||
userMessage += 'The AI generated an invalid response. Please try again with different research data.';
|
||||
} else {
|
||||
userMessage += `${errorMessage}. Please try again or contact support if the problem persists.`;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: userMessage
|
||||
};
|
||||
}
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
console.log('generateOutline render called with status:', status);
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #388e3c',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#388e3c' }}>🧩 Generating Outline</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Analyzing research results and content angles...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Structuring content based on keyword analysis...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Creating logical flow and section hierarchy...</p>
|
||||
<p style={{ margin: '0' }}>• Optimizing for SEO and reader engagement...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the copilot action
|
||||
};
|
||||
|
||||
export default OutlineGenerator;
|
||||
561
frontend/src/components/BlogWriter/OutlineIntelligenceChips.tsx
Normal file
561
frontend/src/components/BlogWriter/OutlineIntelligenceChips.tsx
Normal file
@@ -0,0 +1,561 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BlogOutlineSection, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage } from '../../services/blogWriterApi';
|
||||
|
||||
interface OutlineIntelligenceChipsProps {
|
||||
sections: BlogOutlineSection[];
|
||||
sourceMappingStats?: SourceMappingStats | null;
|
||||
groundingInsights?: GroundingInsights | null;
|
||||
optimizationResults?: OptimizationResults | null;
|
||||
researchCoverage?: ResearchCoverage | null;
|
||||
}
|
||||
|
||||
const OutlineIntelligenceChips: React.FC<OutlineIntelligenceChipsProps> = ({
|
||||
sections,
|
||||
sourceMappingStats,
|
||||
groundingInsights,
|
||||
optimizationResults,
|
||||
researchCoverage
|
||||
}) => {
|
||||
const [activeModal, setActiveModal] = useState<string | null>(null);
|
||||
|
||||
const getConfidenceColor = (score: number) => {
|
||||
if (score >= 0.8) return '#4caf50'; // Green
|
||||
if (score >= 0.6) return '#ff9800'; // Orange
|
||||
return '#f44336'; // Red
|
||||
};
|
||||
|
||||
const getQualityGrade = (score: number) => {
|
||||
if (score >= 9) return { grade: 'A+', color: '#4caf50' };
|
||||
if (score >= 8) return { grade: 'A', color: '#4caf50' };
|
||||
if (score >= 7) return { grade: 'B+', color: '#8bc34a' };
|
||||
if (score >= 6) return { grade: 'B', color: '#ff9800' };
|
||||
if (score >= 5) return { grade: 'C', color: '#ff9800' };
|
||||
return { grade: 'D', color: '#f44336' };
|
||||
};
|
||||
|
||||
const chips = [
|
||||
{
|
||||
id: 'research',
|
||||
label: 'Research Data',
|
||||
icon: '📊',
|
||||
color: '#e3f2fd',
|
||||
textColor: '#1976d2',
|
||||
data: researchCoverage,
|
||||
description: 'How well your research data is being utilized',
|
||||
metrics: researchCoverage ? [
|
||||
{ label: 'Sources Used', value: researchCoverage.sources_utilized, color: '#1976d2' },
|
||||
{ label: 'Content Gaps', value: researchCoverage.content_gaps_identified, color: '#ff9800' },
|
||||
{ label: 'Advantages', value: researchCoverage.competitive_advantages.length, color: '#4caf50' }
|
||||
] : []
|
||||
},
|
||||
{
|
||||
id: 'mapping',
|
||||
label: 'Source Mapping',
|
||||
icon: '🔗',
|
||||
color: '#f3e5f5',
|
||||
textColor: '#7b1fa2',
|
||||
data: sourceMappingStats,
|
||||
description: 'Intelligence in mapping sources to sections',
|
||||
metrics: sourceMappingStats ? [
|
||||
{ label: 'Mapped', value: sourceMappingStats.total_sources_mapped, color: '#7b1fa2' },
|
||||
{ label: 'Coverage', value: `${Math.round(sourceMappingStats.coverage_percentage)}%`, color: getConfidenceColor(sourceMappingStats.coverage_percentage / 100) },
|
||||
{ label: 'Relevance', value: `${Math.round(sourceMappingStats.average_relevance_score * 100)}%`, color: getConfidenceColor(sourceMappingStats.average_relevance_score) },
|
||||
{ label: 'High Conf', value: sourceMappingStats.high_confidence_mappings, color: '#4caf50' }
|
||||
] : []
|
||||
},
|
||||
{
|
||||
id: 'grounding',
|
||||
label: 'Grounding Insights',
|
||||
icon: '🧠',
|
||||
color: '#e8f5e8',
|
||||
textColor: '#2e7d32',
|
||||
data: groundingInsights,
|
||||
description: 'AI-powered insights from search grounding',
|
||||
metrics: groundingInsights ? [
|
||||
{
|
||||
label: 'Confidence',
|
||||
value: groundingInsights.confidence_analysis ? `${Math.round(groundingInsights.confidence_analysis.average_confidence * 100)}%` : 'N/A',
|
||||
color: groundingInsights.confidence_analysis ? getConfidenceColor(groundingInsights.confidence_analysis.average_confidence) : '#666'
|
||||
},
|
||||
{
|
||||
label: 'Authority',
|
||||
value: groundingInsights.authority_analysis ? `${Math.round(groundingInsights.authority_analysis.average_authority_score * 100)}%` : 'N/A',
|
||||
color: groundingInsights.authority_analysis ? getConfidenceColor(groundingInsights.authority_analysis.average_authority_score) : '#666'
|
||||
},
|
||||
{
|
||||
label: 'Coverage',
|
||||
value: groundingInsights.content_relationships ? `${Math.round(groundingInsights.content_relationships.concept_coverage_score * 100)}%` : 'N/A',
|
||||
color: groundingInsights.content_relationships ? getConfidenceColor(groundingInsights.content_relationships.concept_coverage_score) : '#666'
|
||||
}
|
||||
] : []
|
||||
},
|
||||
{
|
||||
id: 'optimization',
|
||||
label: 'Optimization',
|
||||
icon: '🎯',
|
||||
color: '#fff3e0',
|
||||
textColor: '#f57c00',
|
||||
data: optimizationResults,
|
||||
description: 'AI optimization and quality assessment',
|
||||
metrics: optimizationResults ? [
|
||||
{
|
||||
label: 'Quality',
|
||||
value: `${optimizationResults.overall_quality_score}/10`,
|
||||
color: getQualityGrade(optimizationResults.overall_quality_score).color
|
||||
},
|
||||
{
|
||||
label: 'Grade',
|
||||
value: getQualityGrade(optimizationResults.overall_quality_score).grade,
|
||||
color: getQualityGrade(optimizationResults.overall_quality_score).color
|
||||
},
|
||||
{
|
||||
label: 'Focus',
|
||||
value: optimizationResults.optimization_focus,
|
||||
color: '#f57c00'
|
||||
},
|
||||
{
|
||||
label: 'Improvements',
|
||||
value: optimizationResults.improvements_made.length,
|
||||
color: '#4caf50'
|
||||
}
|
||||
] : []
|
||||
}
|
||||
];
|
||||
|
||||
const renderModal = (chipId: string) => {
|
||||
const chip = chips.find(c => c.id === chipId);
|
||||
if (!chip || !chip.data) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
maxWidth: '800px',
|
||||
width: '95%',
|
||||
maxHeight: '85vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
{/* Modal Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px',
|
||||
paddingBottom: '16px',
|
||||
borderBottom: '2px solid #f3f4f6'
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{ margin: '0 0 4px 0', color: '#1f2937', fontSize: '24px', fontWeight: '600', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span style={{ fontSize: '28px' }}>{chip.icon}</span>
|
||||
{chip.label}
|
||||
</h2>
|
||||
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
|
||||
{chip.description}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setActiveModal(null)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '28px',
|
||||
cursor: 'pointer',
|
||||
color: '#9ca3af',
|
||||
padding: '4px',
|
||||
borderRadius: '6px',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f3f4f6';
|
||||
e.currentTarget.style.color = '#374151';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#9ca3af';
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div style={{ color: '#333' }}>
|
||||
{chipId === 'research' && researchCoverage && (
|
||||
<div>
|
||||
{/* Key Metrics */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937' }}>Research Utilization Metrics</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#1976d2', marginBottom: '8px' }}>
|
||||
{researchCoverage.sources_utilized}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Sources Utilized</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Research sources actively used in outline generation
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#ff9800', marginBottom: '8px' }}>
|
||||
{researchCoverage.content_gaps_identified}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Content Gaps Identified</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Missing topics that could strengthen your content
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#4caf50', marginBottom: '8px' }}>
|
||||
{researchCoverage.competitive_advantages.length}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Competitive Advantages</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Unique angles identified from research
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Competitive Advantages */}
|
||||
{researchCoverage.competitive_advantages.length > 0 && (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937' }}>Key Competitive Advantages</h3>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{researchCoverage.competitive_advantages.map((advantage, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#e8f5e8',
|
||||
color: '#388e3c',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
border: '1px solid #c8e6c9'
|
||||
}}>
|
||||
{advantage}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chipId === 'mapping' && sourceMappingStats && (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937' }}>Source Mapping Intelligence</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: '#7b1fa2', marginBottom: '8px' }}>
|
||||
{sourceMappingStats.total_sources_mapped}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Sources Mapped</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Research sources intelligently linked to sections
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: getConfidenceColor(sourceMappingStats.coverage_percentage / 100), marginBottom: '8px' }}>
|
||||
{Math.round(sourceMappingStats.coverage_percentage)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Coverage</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Percentage of sections with mapped sources
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: getConfidenceColor(sourceMappingStats.average_relevance_score), marginBottom: '8px' }}>
|
||||
{Math.round(sourceMappingStats.average_relevance_score * 100)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Avg Relevance</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
How well sources match section content
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: '#4caf50', marginBottom: '8px' }}>
|
||||
{sourceMappingStats.high_confidence_mappings}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>High Confidence</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Mappings with >80% confidence score
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chipId === 'grounding' && groundingInsights && (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937' }}>Grounding Metadata Insights</h3>
|
||||
|
||||
{/* Confidence Analysis */}
|
||||
{groundingInsights.confidence_analysis && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#1f2937' }}>Confidence Analysis</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: getConfidenceColor(groundingInsights.confidence_analysis.average_confidence), marginBottom: '8px' }}>
|
||||
{Math.round(groundingInsights.confidence_analysis.average_confidence * 100)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Avg Confidence</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Average confidence score across all sources
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: '#4caf50', marginBottom: '8px' }}>
|
||||
{groundingInsights.confidence_analysis.high_confidence_sources_count}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>High Confidence Sources</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Sources with >80% confidence score
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Authority Analysis */}
|
||||
{groundingInsights.authority_analysis && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#1f2937' }}>Authority Analysis</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: getConfidenceColor(groundingInsights.authority_analysis.average_authority_score), marginBottom: '8px' }}>
|
||||
{Math.round(groundingInsights.authority_analysis.average_authority_score * 100)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Avg Authority</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Average authority score of sources
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{groundingInsights.authority_analysis.high_authority_sources.length > 0 && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#1f2937' }}>Top Authority Sources:</h5>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{groundingInsights.authority_analysis.high_authority_sources.slice(0, 5).map((source, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
border: '1px solid #bbdefb'
|
||||
}}>
|
||||
{source.title.substring(0, 40)}...
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Relationships */}
|
||||
{groundingInsights.content_relationships && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#1f2937' }}>Content Relationships</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: getConfidenceColor(groundingInsights.content_relationships.concept_coverage_score), marginBottom: '8px' }}>
|
||||
{Math.round(groundingInsights.content_relationships.concept_coverage_score * 100)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Concept Coverage</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
How well concepts are covered across sections
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{groundingInsights.content_relationships.related_concepts.length > 0 && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#1f2937' }}>Related Concepts:</h5>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{groundingInsights.content_relationships.related_concepts.slice(0, 8).map((concept, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#fff3e0',
|
||||
color: '#f57c00',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
border: '1px solid #ffcc02'
|
||||
}}>
|
||||
{concept}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Intent */}
|
||||
{groundingInsights.search_intent_insights && (
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#1f2937' }}>Search Intent Analysis</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: '700', color: '#1976d2', marginBottom: '8px', textTransform: 'capitalize' }}>
|
||||
{groundingInsights.search_intent_insights.primary_intent}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Primary Intent</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Main user intent identified from search data
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{groundingInsights.search_intent_insights.user_questions.length > 0 && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#1f2937' }}>User Questions:</h5>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{groundingInsights.search_intent_insights.user_questions.slice(0, 5).map((question, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#f3e5f5',
|
||||
color: '#7b1fa2',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
border: '1px solid #ce93d8'
|
||||
}}>
|
||||
{question.substring(0, 50)}...
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chipId === 'optimization' && optimizationResults && (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937' }}>Optimization Results</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: getQualityGrade(optimizationResults.overall_quality_score).color, marginBottom: '8px' }}>
|
||||
{optimizationResults.overall_quality_score}/10
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Overall Quality</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
AI-assessed quality score of the outline
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: getQualityGrade(optimizationResults.overall_quality_score).color, marginBottom: '8px' }}>
|
||||
{getQualityGrade(optimizationResults.overall_quality_score).grade}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Quality Grade</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Letter grade based on quality assessment
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: '700', color: '#f57c00', marginBottom: '8px', textTransform: 'capitalize' }}>
|
||||
{optimizationResults.optimization_focus}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Focus Area</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Primary area of optimization focus
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#4caf50', marginBottom: '8px' }}>
|
||||
{optimizationResults.improvements_made.length}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Improvements Made</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Number of optimizations applied
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{optimizationResults.improvements_made.length > 0 && (
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#1f2937' }}>Improvements Made:</h4>
|
||||
<div style={{ backgroundColor: '#f8f9fa', borderRadius: '12px', padding: '16px', border: '1px solid #e5e7eb' }}>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px' }}>
|
||||
{optimizationResults.improvements_made.map((improvement, i) => (
|
||||
<li key={i} style={{ fontSize: '14px', color: '#374151', marginBottom: '8px', lineHeight: '1.5' }}>
|
||||
{improvement}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const availableChips = chips.filter(chip => chip.data);
|
||||
|
||||
if (availableChips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||
{availableChips.map(chip => (
|
||||
<button
|
||||
key={chip.id}
|
||||
onClick={() => setActiveModal(chip.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '12px 16px',
|
||||
backgroundColor: chip.color,
|
||||
color: chip.textColor,
|
||||
border: 'none',
|
||||
borderRadius: '24px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
minWidth: '140px',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.1)';
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>{chip.icon}</span>
|
||||
<span>{chip.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeModal && renderModal(activeModal)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutlineIntelligenceChips;
|
||||
35
frontend/src/components/BlogWriter/OutlineRefiner.tsx
Normal file
35
frontend/src/components/BlogWriter/OutlineRefiner.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogOutlineSection } from '../../services/blogWriterApi';
|
||||
|
||||
interface OutlineRefinerProps {
|
||||
outline: BlogOutlineSection[];
|
||||
onOutlineUpdated: (outline: BlogOutlineSection[]) => void;
|
||||
}
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
export const OutlineRefiner: React.FC<OutlineRefinerProps> = ({
|
||||
outline,
|
||||
onOutlineUpdated
|
||||
}) => {
|
||||
useCopilotActionTyped({
|
||||
name: 'refineOutline',
|
||||
description: 'Refine the outline (add/remove/move/merge)',
|
||||
parameters: [
|
||||
{ name: 'operation', type: 'string', description: 'add|remove|move|merge|rename', required: true },
|
||||
{ name: 'sectionId', type: 'string', description: 'Target section ID', required: false },
|
||||
{ name: 'payload', type: 'string', description: 'JSON payload for operation', required: false },
|
||||
],
|
||||
handler: async ({ operation, sectionId, payload }: { operation: string; sectionId?: string; payload?: string }) => {
|
||||
const payloadObj = payload ? (() => { try { return JSON.parse(payload); } catch { return {}; } })() : undefined;
|
||||
const res = await blogWriterApi.refineOutline({ outline, operation, section_id: sectionId, payload: payloadObj });
|
||||
if (res?.outline) onOutlineUpdated(res.outline);
|
||||
return { success: true };
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the copilot action
|
||||
};
|
||||
|
||||
export default OutlineRefiner;
|
||||
40
frontend/src/components/BlogWriter/Publisher.tsx
Normal file
40
frontend/src/components/BlogWriter/Publisher.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogSEOMetadataResponse } from '../../services/blogWriterApi';
|
||||
|
||||
interface PublisherProps {
|
||||
buildFullMarkdown: () => string;
|
||||
convertMarkdownToHTML: (md: string) => string;
|
||||
seoMetadata: BlogSEOMetadataResponse | null;
|
||||
}
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
export const Publisher: React.FC<PublisherProps> = ({
|
||||
buildFullMarkdown,
|
||||
convertMarkdownToHTML,
|
||||
seoMetadata
|
||||
}) => {
|
||||
useCopilotActionTyped({
|
||||
name: 'publishToPlatform',
|
||||
description: 'Publish the blog to Wix or WordPress',
|
||||
parameters: [
|
||||
{ name: 'platform', type: 'string', description: 'wix|wordpress', required: true },
|
||||
{ name: 'schedule_time', type: 'string', description: 'Optional ISO datetime', required: false }
|
||||
],
|
||||
handler: async ({ platform, schedule_time }: { platform: 'wix' | 'wordpress'; schedule_time?: string }) => {
|
||||
const md = buildFullMarkdown();
|
||||
const html = convertMarkdownToHTML(md);
|
||||
if (!seoMetadata) return { success: false, message: 'Generate SEO metadata first' };
|
||||
const res = await blogWriterApi.publish({ platform, html, metadata: seoMetadata, schedule_time });
|
||||
return { success: true, url: res.url };
|
||||
},
|
||||
render: ({ status, result }: any) => status === 'complete' ? (
|
||||
<div style={{ padding: 12 }}>Published: {result?.url || 'Success'}</div>
|
||||
) : null
|
||||
});
|
||||
|
||||
return null; // This component only provides the copilot action
|
||||
};
|
||||
|
||||
export default Publisher;
|
||||
@@ -1,6 +1,9 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
import { useResearchPolling } from '../../hooks/usePolling';
|
||||
import ResearchProgressModal from './ResearchProgressModal';
|
||||
import { researchCache } from '../../services/researchCache';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
@@ -9,6 +12,38 @@ interface ResearchActionProps {
|
||||
}
|
||||
|
||||
export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComplete }) => {
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||
const [currentMessage, setCurrentMessage] = useState<string>('');
|
||||
const [showProgressModal, setShowProgressModal] = useState<boolean>(false);
|
||||
|
||||
const polling = useResearchPolling({
|
||||
onProgress: (message) => {
|
||||
setCurrentMessage(message);
|
||||
},
|
||||
onComplete: (result) => {
|
||||
// Cache the result for future use
|
||||
if (result && result.keywords) {
|
||||
researchCache.cacheResult(
|
||||
result.keywords,
|
||||
result.industry || 'General',
|
||||
result.target_audience || 'General',
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
onResearchComplete?.(result);
|
||||
setCurrentTaskId(null);
|
||||
setCurrentMessage('');
|
||||
setShowProgressModal(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Research polling error:', error);
|
||||
setCurrentTaskId(null);
|
||||
setCurrentMessage('');
|
||||
setShowProgressModal(false);
|
||||
}
|
||||
});
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'researchTopic',
|
||||
description: 'Research topic with keywords and persona context using Google Search grounding',
|
||||
@@ -20,48 +55,43 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
],
|
||||
handler: async ({ keywords, industry, target_audience, blogLength }: { keywords: string; industry?: string; target_audience?: string; blogLength?: string }) => {
|
||||
try {
|
||||
// If keywords is a topic description, extract keywords from it
|
||||
// If keywords is a topic description, preserve as single phrase unless comma-separated
|
||||
const keywordList = keywords.includes(',')
|
||||
? keywords.split(',').map(k => k.trim())
|
||||
: keywords.split(' ').filter(k => k.length > 1).slice(0, 5); // Extract up to 5 meaningful words (including 2-char words like "AI")
|
||||
: [keywords.trim()]; // Preserve single phrases as-is
|
||||
|
||||
const payload: BlogResearchRequest = {
|
||||
keywords: keywordList,
|
||||
industry: industry || 'General',
|
||||
target_audience: target_audience || 'General',
|
||||
word_count_target: blogLength ? parseInt(blogLength) : 1000
|
||||
};
|
||||
const industryValue = industry || 'General';
|
||||
const audienceValue = target_audience || 'General';
|
||||
|
||||
const res = await blogWriterApi.research(payload);
|
||||
|
||||
// Check if research failed gracefully
|
||||
if (!res.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Research failed: ${res.error_message || 'Unknown error occurred'}. Please try again with different keywords or contact support if the problem persists.`,
|
||||
error_details: res.error_message
|
||||
// Check frontend cache first
|
||||
const cachedResult = researchCache.getCachedResult(keywordList, industryValue, audienceValue);
|
||||
if (cachedResult) {
|
||||
console.log('Frontend cache hit - returning cached result instantly');
|
||||
onResearchComplete?.(cachedResult);
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Found cached research for "${keywords}"! Results loaded instantly.`,
|
||||
cached: true
|
||||
};
|
||||
}
|
||||
|
||||
// Notify parent component
|
||||
onResearchComplete?.(res);
|
||||
const payload: BlogResearchRequest = {
|
||||
keywords: keywordList,
|
||||
industry: industryValue,
|
||||
target_audience: audienceValue,
|
||||
word_count_target: blogLength ? parseInt(blogLength) : 1000
|
||||
};
|
||||
|
||||
// Create detailed success message with research insights
|
||||
const sourcesCount = res.sources?.length || 0;
|
||||
const queriesCount = res.search_queries?.length || 0;
|
||||
const anglesCount = res.suggested_angles?.length || 0;
|
||||
// Start async research
|
||||
const { task_id } = await blogWriterApi.startResearch(payload);
|
||||
setCurrentTaskId(task_id);
|
||||
setShowProgressModal(true);
|
||||
polling.startPolling(task_id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `🔍 Research completed for "${keywords}"! Found ${sourcesCount} sources, ${queriesCount} search queries, and ${anglesCount} content angles. The research results are now displayed in the UI. You can explore the sources, keywords, and content angles to understand the topic better before we create an outline.`,
|
||||
research_summary: {
|
||||
topic: keywords,
|
||||
sources: sourcesCount,
|
||||
queries: queriesCount,
|
||||
angles: anglesCount,
|
||||
primary_keywords: res.keyword_analysis?.primary || [],
|
||||
search_intent: res.keyword_analysis?.search_intent || 'informational'
|
||||
}
|
||||
message: `🔍 Research started for "${keywords}"! Task ID: ${task_id}. Progress will be shown below.`,
|
||||
task_id: task_id
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Research failed: ${error}`);
|
||||
@@ -71,49 +101,19 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
};
|
||||
}
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #1976d2',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#1976d2' }}>🔍 Researching Your Topic</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Starting research operation...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Connecting to Google Search grounding...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Analyzing keywords and search intent...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Gathering relevant sources and statistics...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Generating content angles and search queries...</p>
|
||||
<p style={{ margin: '0', fontStyle: 'italic', color: '#888' }}>⏳ This may take 1-3 minutes. Please wait...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
render: () => null
|
||||
});
|
||||
|
||||
return null; // This component only provides the CopilotKit action, no UI
|
||||
return (
|
||||
<ResearchProgressModal
|
||||
open={showProgressModal}
|
||||
title="Research in progress"
|
||||
status={polling.currentStatus}
|
||||
messages={polling.progressMessages}
|
||||
error={polling.error}
|
||||
onClose={() => setShowProgressModal(false)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchAction;
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import { BlogResearchResponse } from '../../../services/blogWriterApi';
|
||||
|
||||
interface ResearchGroundingProps {
|
||||
research: BlogResearchResponse;
|
||||
}
|
||||
|
||||
export const ResearchGrounding: React.FC<ResearchGroundingProps> = ({ research }) => {
|
||||
const renderConfidenceScore = (score: number | undefined) => {
|
||||
const safeScore = score ?? 0.5;
|
||||
const percentage = Math.round(safeScore * 100);
|
||||
const color = safeScore >= 0.8 ? '#4CAF50' : safeScore >= 0.6 ? '#FF9800' : '#F44336';
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{
|
||||
width: '50px',
|
||||
height: '6px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${percentage}%`,
|
||||
height: '100%',
|
||||
backgroundColor: color,
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
<span style={{ fontSize: '11px', color: '#666' }}>{percentage}%</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!research.grounding_metadata) {
|
||||
return (
|
||||
<div style={{ padding: '16px', textAlign: 'center', color: '#666' }}>
|
||||
No grounding metadata available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { grounding_chunks, grounding_supports, citations, web_search_queries } = research.grounding_metadata;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px' }}>
|
||||
<h3 style={{ margin: '0 0 16px 0', color: '#333' }}>🔗 Google Grounding Metadata</h3>
|
||||
|
||||
{/* Grounding Chunks */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{ margin: '0 0 12px 0', color: '#555', fontSize: '16px' }}>
|
||||
📚 Grounding Chunks ({grounding_chunks.length})
|
||||
</h4>
|
||||
<div style={{ display: 'grid', gap: '8px' }}>
|
||||
{grounding_chunks.map((chunk, index) => (
|
||||
<div key={index} style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#fafafa'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px' }}>
|
||||
<h5 style={{ margin: 0, fontSize: '14px', fontWeight: '600', color: '#333' }}>
|
||||
{chunk.title}
|
||||
</h5>
|
||||
{chunk.confidence_score && renderConfidenceScore(chunk.confidence_score)}
|
||||
</div>
|
||||
<a
|
||||
href={chunk.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#1976d2',
|
||||
textDecoration: 'none',
|
||||
wordBreak: 'break-all'
|
||||
}}
|
||||
>
|
||||
{chunk.url}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grounding Supports */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{ margin: '0 0 12px 0', color: '#555', fontSize: '16px' }}>
|
||||
🎯 Grounding Supports ({grounding_supports.length})
|
||||
</h4>
|
||||
<div style={{ display: 'grid', gap: '8px' }}>
|
||||
{grounding_supports.map((support, index) => (
|
||||
<div key={index} style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#fafafa'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '12px', color: '#666' }}>
|
||||
Chunks: {support.grounding_chunk_indices.join(', ')}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
{support.confidence_scores.map((score, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<span style={{ fontSize: '10px', color: '#666' }}>C{i+1}:</span>
|
||||
{renderConfidenceScore(score)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
color: '#555',
|
||||
fontStyle: 'italic',
|
||||
backgroundColor: '#f0f0f0',
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #e0e0e0'
|
||||
}}>
|
||||
"{support.segment_text}"
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Citations */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{ margin: '0 0 12px 0', color: '#555', fontSize: '16px' }}>
|
||||
📝 Inline Citations ({citations.length})
|
||||
</h4>
|
||||
<div style={{ display: 'grid', gap: '8px' }}>
|
||||
{citations.map((citation, index) => (
|
||||
<div key={index} style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#fafafa'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
backgroundColor: '#e8f5e8',
|
||||
color: '#2e7d32',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{citation.citation_type}
|
||||
</span>
|
||||
<span style={{ fontSize: '12px', color: '#666' }}>
|
||||
{citation.reference}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
{citation.source_indices.map((sourceIdx, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
S{sourceIdx + 1}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
color: '#555',
|
||||
backgroundColor: '#f0f0f0',
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #e0e0e0',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
"{citation.text}"
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: '#999',
|
||||
marginTop: '4px',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
Position: {citation.start_index}-{citation.end_index}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchGrounding;
|
||||
@@ -0,0 +1,689 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { BlogResearchResponse } from '../../../services/blogWriterApi';
|
||||
|
||||
interface ResearchSourcesProps {
|
||||
research: BlogResearchResponse;
|
||||
}
|
||||
|
||||
interface KeywordChipGroupProps {
|
||||
title: string;
|
||||
keywords: string[];
|
||||
color: string;
|
||||
backgroundColor: string;
|
||||
icon: string;
|
||||
showCount: number;
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
const KeywordChipGroup: React.FC<KeywordChipGroupProps> = ({
|
||||
title,
|
||||
keywords,
|
||||
color,
|
||||
backgroundColor,
|
||||
icon,
|
||||
showCount,
|
||||
tooltip
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const visibleKeywords = isExpanded ? keywords : keywords.slice(0, showCount);
|
||||
const hasMore = keywords.length > showCount;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#ffffff',
|
||||
cursor: hasMore ? 'pointer' : 'default',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (hasMore) {
|
||||
setIsExpanded(true);
|
||||
e.currentTarget.style.boxShadow = '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)';
|
||||
e.currentTarget.style.borderColor = color;
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (hasMore) {
|
||||
setIsExpanded(false);
|
||||
e.currentTarget.style.boxShadow = '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)';
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px', paddingRight: '8px' }}>
|
||||
<span style={{ fontSize: '14px' }}>{icon}</span>
|
||||
<span style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
letterSpacing: '0.025em',
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
}}>
|
||||
{title}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: '#6b7280',
|
||||
backgroundColor: '#f3f4f6',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '12px',
|
||||
fontWeight: '500',
|
||||
border: '1px solid #e5e7eb',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{keywords.length}
|
||||
</span>
|
||||
{/* Help Icon */}
|
||||
<span
|
||||
onClick={() => setShowTooltip(!showTooltip)}
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#9ca3af',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
borderRadius: '50%',
|
||||
transition: 'all 0.2s ease',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '20px',
|
||||
minHeight: '20px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#6b7280';
|
||||
e.currentTarget.style.backgroundColor = '#f3f4f6';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#9ca3af';
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
❓
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{visibleKeywords.map((keyword: string, index: number) => (
|
||||
<span key={index} style={{
|
||||
backgroundColor: backgroundColor,
|
||||
color: '#374151',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
border: `1px solid ${color}40`,
|
||||
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
transition: 'all 0.2s ease'
|
||||
}}>
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
{hasMore && !isExpanded && (
|
||||
<span style={{
|
||||
backgroundColor: '#f9fafb',
|
||||
color: '#6b7280',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
border: '1px solid #d1d5db',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
+{keywords.length - showCount} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Professional Tooltip - Only show when clicked */}
|
||||
{showTooltip && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
marginBottom: '8px',
|
||||
backgroundColor: '#1f2937',
|
||||
color: '#f9fafb',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.5',
|
||||
maxWidth: '280px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
zIndex: 1000,
|
||||
border: '1px solid #374151'
|
||||
}}>
|
||||
<div style={{ fontWeight: '600', marginBottom: '4px', color: '#f3f4f6' }}>
|
||||
{title} Keywords
|
||||
</div>
|
||||
<div style={{ color: '#d1d5db' }}>
|
||||
{tooltip}
|
||||
</div>
|
||||
{/* Tooltip arrow */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '6px solid transparent',
|
||||
borderRight: '6px solid transparent',
|
||||
borderTop: '6px solid #1f2937'
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResearchSources: React.FC<ResearchSourcesProps> = ({ research }) => {
|
||||
const [showWebSearchHelp, setShowWebSearchHelp] = useState(false);
|
||||
|
||||
// Fix search widget overflow after render
|
||||
useEffect(() => {
|
||||
if (research.search_widget) {
|
||||
const searchWidget = document.querySelector('[data-search-widget]');
|
||||
if (searchWidget) {
|
||||
const allElements = searchWidget.querySelectorAll('*');
|
||||
allElements.forEach((el: any) => {
|
||||
el.style.maxWidth = '100%';
|
||||
el.style.overflow = 'hidden';
|
||||
el.style.wordWrap = 'break-word';
|
||||
el.style.whiteSpace = 'normal';
|
||||
el.style.boxSizing = 'border-box';
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [research.search_widget]);
|
||||
|
||||
const renderCredibilityScore = (score: number | undefined) => {
|
||||
const safeScore = score ?? 0.8; // Default to 0.8 if undefined
|
||||
const percentage = Math.round(safeScore * 100);
|
||||
const color = safeScore >= 0.8 ? '#4CAF50' : safeScore >= 0.6 ? '#FF9800' : '#F44336';
|
||||
const radius = 20;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const strokeDasharray = circumference;
|
||||
const strokeDashoffset = circumference - (safeScore * circumference);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ position: 'relative', width: '44px', height: '44px' }}>
|
||||
<svg width="44" height="44" style={{ transform: 'rotate(-90deg)' }}>
|
||||
<circle
|
||||
cx="22"
|
||||
cy="22"
|
||||
r={radius}
|
||||
stroke="#e0e0e0"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<circle
|
||||
cx="22"
|
||||
cy="22"
|
||||
r={radius}
|
||||
stroke={color}
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
style={{ transition: 'stroke-dashoffset 0.3s ease' }}
|
||||
/>
|
||||
</svg>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
fontSize: '10px',
|
||||
fontWeight: '600',
|
||||
color: color
|
||||
}}>
|
||||
{percentage}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '16px', padding: '16px', width: '100%', overflow: 'hidden' }}>
|
||||
{/* Keywords Sidebar - Moved to Left */}
|
||||
<div style={{ flex: 1, minWidth: '300px', maxWidth: '400px', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
backgroundColor: '#ffffff',
|
||||
position: 'sticky',
|
||||
top: '16px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
borderLeft: '4px solid #3b82f6'
|
||||
}}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
color: '#1f2937',
|
||||
fontSize: '18px',
|
||||
fontWeight: '700',
|
||||
letterSpacing: '-0.025em'
|
||||
}}>
|
||||
🎯 Keywords
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Progressive Disclosure Keyword Chips */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{/* Primary Keywords */}
|
||||
{research.keyword_analysis?.primary && research.keyword_analysis.primary.length > 0 && (
|
||||
<KeywordChipGroup
|
||||
title="Primary"
|
||||
keywords={research.keyword_analysis.primary}
|
||||
color="#1976d2"
|
||||
backgroundColor="#e3f2fd"
|
||||
icon="🎯"
|
||||
showCount={2}
|
||||
tooltip="Core keywords that directly match your main topic. These are the most important terms for SEO and should be naturally integrated throughout your content. Primary keywords typically have high search volume and strong commercial intent."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Secondary Keywords */}
|
||||
{research.keyword_analysis?.secondary && research.keyword_analysis.secondary.length > 0 && (
|
||||
<KeywordChipGroup
|
||||
title="Secondary"
|
||||
keywords={research.keyword_analysis.secondary}
|
||||
color="#7b1fa2"
|
||||
backgroundColor="#f3e5f5"
|
||||
icon="🔗"
|
||||
showCount={2}
|
||||
tooltip="Supporting keywords that complement your primary terms. These help create topic clusters and improve content depth. Secondary keywords often have lower competition but still drive valuable traffic and enhance topical authority."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Long-tail Keywords */}
|
||||
{research.keyword_analysis?.long_tail && research.keyword_analysis.long_tail.length > 0 && (
|
||||
<KeywordChipGroup
|
||||
title="Long-tail"
|
||||
keywords={research.keyword_analysis.long_tail}
|
||||
color="#2e7d32"
|
||||
backgroundColor="#e8f5e8"
|
||||
icon="📏"
|
||||
showCount={2}
|
||||
tooltip="Specific, longer phrases that users search for. These keywords have lower search volume but higher conversion rates and less competition. Long-tail keywords help capture users with specific intent and often lead to better engagement."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Semantic Keywords */}
|
||||
{research.keyword_analysis?.semantic_keywords && research.keyword_analysis.semantic_keywords.length > 0 && (
|
||||
<KeywordChipGroup
|
||||
title="Semantic"
|
||||
keywords={research.keyword_analysis.semantic_keywords}
|
||||
color="#f57c00"
|
||||
backgroundColor="#fff3e0"
|
||||
icon="🧠"
|
||||
showCount={2}
|
||||
tooltip="Contextually related terms that help search engines understand your content's meaning. These keywords improve semantic relevance and help with featured snippets. They're crucial for modern SEO and natural language processing algorithms."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Trending Terms */}
|
||||
{research.keyword_analysis?.trending_terms && research.keyword_analysis.trending_terms.length > 0 && (
|
||||
<KeywordChipGroup
|
||||
title="Trending"
|
||||
keywords={research.keyword_analysis.trending_terms}
|
||||
color="#c2185b"
|
||||
backgroundColor="#fce4ec"
|
||||
icon="📈"
|
||||
showCount={2}
|
||||
tooltip="Currently popular and rising search terms in your industry. These keywords can provide opportunities for timely content and increased visibility. Trending terms often have growing search volume and can help you capture emerging market interest."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content Gaps */}
|
||||
{research.keyword_analysis?.content_gaps && research.keyword_analysis.content_gaps.length > 0 && (
|
||||
<KeywordChipGroup
|
||||
title="Content Gaps"
|
||||
keywords={research.keyword_analysis.content_gaps}
|
||||
color="#c62828"
|
||||
backgroundColor="#ffebee"
|
||||
icon="🕳️"
|
||||
showCount={2}
|
||||
tooltip="Underserved topics and keywords that competitors aren't adequately covering. These represent opportunities to create unique, valuable content that can help you stand out. Content gaps often lead to easier ranking opportunities and less saturated markets."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Sources Content */}
|
||||
<div style={{ flex: 2, minWidth: 0, overflow: 'hidden' }}>
|
||||
<h3 style={{ margin: '0 0 16px 0', color: '#333' }}>🔍 Research Sources ({research.sources.length})</h3>
|
||||
|
||||
{/* Research Insights Section */}
|
||||
{research.keyword_analysis?.analysis_insights && (
|
||||
<div style={{
|
||||
marginBottom: '20px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
borderLeft: '4px solid #3b82f6'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '16px' }}>💡</span>
|
||||
<h4 style={{ margin: 0, color: '#1e40af', fontSize: '14px', fontWeight: '600' }}>Research Insights</h4>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics in Research Insights - Moved to right corner */}
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
{research.keyword_analysis?.search_intent && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
backgroundColor: '#f0f9ff',
|
||||
border: '1px solid #0ea5e9',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
<span style={{ color: '#0369a1', fontSize: '10px' }}>🎯</span>
|
||||
<span style={{ color: '#0369a1' }}>Search Intent:</span>
|
||||
<span style={{
|
||||
color: '#0c4a6e',
|
||||
fontWeight: '600',
|
||||
backgroundColor: '#e0f2fe',
|
||||
padding: '1px 4px',
|
||||
borderRadius: '3px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
{research.keyword_analysis.search_intent}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{research.keyword_analysis?.difficulty && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
backgroundColor: '#fef2f2',
|
||||
border: '1px solid #ef4444',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
<span style={{ color: '#dc2626', fontSize: '10px' }}>📊</span>
|
||||
<span style={{ color: '#dc2626' }}>Difficulty:</span>
|
||||
<span style={{
|
||||
color: '#991b1b',
|
||||
fontWeight: '600',
|
||||
backgroundColor: '#fee2e2',
|
||||
padding: '1px 4px',
|
||||
borderRadius: '3px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
{research.keyword_analysis.difficulty}/10
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{
|
||||
margin: 0,
|
||||
color: '#475569',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.6',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
{research.keyword_analysis.analysis_insights}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Interactive Web Search - Moved from Header */}
|
||||
{research.search_widget && (
|
||||
<div style={{ marginBottom: '20px', width: '100%', overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', position: 'relative' }}>
|
||||
<h4 style={{ margin: 0, color: '#555', fontSize: '16px' }}>
|
||||
🔍 Explore More Research Topics
|
||||
</h4>
|
||||
{/* Help Icon for Web Search */}
|
||||
<span
|
||||
onClick={() => setShowWebSearchHelp(!showWebSearchHelp)}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: '#9ca3af',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
borderRadius: '50%',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '24px',
|
||||
minHeight: '24px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#6b7280';
|
||||
e.currentTarget.style.backgroundColor = '#f3f4f6';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#9ca3af';
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
❓
|
||||
</span>
|
||||
|
||||
{/* Help Tooltip for Web Search */}
|
||||
{showWebSearchHelp && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: '0',
|
||||
marginTop: '8px',
|
||||
backgroundColor: '#1f2937',
|
||||
color: '#f9fafb',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.5',
|
||||
maxWidth: '300px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
zIndex: 1000,
|
||||
border: '1px solid #374151'
|
||||
}}>
|
||||
<div style={{ fontWeight: '600', marginBottom: '4px', color: '#f3f4f6' }}>
|
||||
Research Enhancement
|
||||
</div>
|
||||
<div style={{ color: '#d1d5db' }}>
|
||||
Click on any search suggestion below to explore additional research topics and gather more insights for your blog. These searches will open in a new tab to help you discover trending topics, expert opinions, and current statistics.
|
||||
</div>
|
||||
{/* Tooltip arrow */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: '20px',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '6px solid transparent',
|
||||
borderRight: '6px solid transparent',
|
||||
borderBottom: '6px solid #1f2937'
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#fafafa',
|
||||
maxHeight: '400px',
|
||||
overflow: 'auto',
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
boxSizing: 'border-box',
|
||||
overflowX: 'hidden',
|
||||
position: 'relative'
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Make all links open in new tabs
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'A' || target.closest('a')) {
|
||||
const link = target.tagName === 'A' ? target as HTMLAnchorElement : target.closest('a') as HTMLAnchorElement;
|
||||
if (link && link.href) {
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<div
|
||||
data-search-widget
|
||||
dangerouslySetInnerHTML={{ __html: research.search_widget }}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
overflowX: 'hidden',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
display: 'block',
|
||||
position: 'relative'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Custom CSS to make Google icon larger */}
|
||||
<style>
|
||||
{`
|
||||
[data-search-widget] svg {
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
}
|
||||
[data-search-widget] .logo-light,
|
||||
[data-search-widget] .logo-dark {
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gap: '12px',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||
width: '100%',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{research.sources.map((source, index) => (
|
||||
<div key={index} style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#fafafa',
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
boxSizing: 'border-box'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '6px' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px', flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
SERP Ranking {source.index !== undefined ? source.index + 1 : '?'}
|
||||
</span>
|
||||
<span style={{
|
||||
backgroundColor: '#f3e5f5',
|
||||
color: '#7b1fa2',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
Research Type: {source.source_type || 'web'}
|
||||
</span>
|
||||
{source.published_at && (
|
||||
<span style={{
|
||||
backgroundColor: '#e8f5e8',
|
||||
color: '#2e7d32',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
{source.published_at}
|
||||
</span>
|
||||
)}
|
||||
{!source.published_at && (
|
||||
<span style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
color: '#666',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
No date
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h4 style={{ margin: 0, fontSize: '14px', fontWeight: '600', color: '#333', lineHeight: '1.3' }}>
|
||||
{source.title}
|
||||
</h4>
|
||||
</div>
|
||||
{renderCredibilityScore(source.credibility_score)}
|
||||
</div>
|
||||
<p style={{
|
||||
margin: '0 0 6px 0',
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
{source.excerpt}
|
||||
</p>
|
||||
<div style={{ fontSize: '11px', color: '#666', marginTop: '6px' }}>
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: '#1976d2',
|
||||
textDecoration: 'none',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Source from {new URL(source.url).hostname}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchSources;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ResearchSources } from './ResearchSources';
|
||||
export { ResearchGrounding } from './ResearchGrounding';
|
||||
@@ -117,16 +117,14 @@ export const ResearchDataActions: React.FC<ResearchDataActionsProps> = ({
|
||||
custom_instructions: customInstructions
|
||||
};
|
||||
|
||||
const outlineResponse = await blogWriterApi.generateOutline(customOutlineRequest);
|
||||
onOutlineCreated(outlineResponse.outline);
|
||||
onTitleOptionsSet(outlineResponse.title_options);
|
||||
|
||||
// Start async outline generation
|
||||
const { task_id } = await blogWriterApi.startOutlineGeneration(customOutlineRequest);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Created custom outline with ${outlineResponse.outline.length} sections based on your instructions: "${customInstructions}"`,
|
||||
outline_sections: outlineResponse.outline.length,
|
||||
title_options: outlineResponse.title_options.length,
|
||||
next_step_suggestion: 'Great! Now you can enhance sections, generate content, or refine the outline further.'
|
||||
message: `Custom outline generation started! Task ID: ${task_id}. Progress will be shown below.`,
|
||||
task_id: task_id,
|
||||
next_step_suggestion: 'The outline is being generated based on your custom instructions. You can monitor progress below.'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useResearchPolling } from '../../hooks/usePolling';
|
||||
import ResearchProgressModal from './ResearchProgressModal';
|
||||
import { BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
import { researchCache } from '../../services/researchCache';
|
||||
|
||||
interface ResearchPollingHandlerProps {
|
||||
taskId: string | null;
|
||||
onResearchComplete: (result: BlogResearchResponse) => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export const ResearchPollingHandler: React.FC<ResearchPollingHandlerProps> = ({
|
||||
taskId,
|
||||
onResearchComplete,
|
||||
onError
|
||||
}) => {
|
||||
const [currentMessage, setCurrentMessage] = useState<string>('');
|
||||
|
||||
const polling = useResearchPolling({
|
||||
onProgress: (message) => {
|
||||
console.log('ResearchPollingHandler - Progress message received:', message);
|
||||
setCurrentMessage(message);
|
||||
},
|
||||
onComplete: (result) => {
|
||||
console.log('ResearchPollingHandler - Research completed:', result);
|
||||
|
||||
// Cache the result for future use
|
||||
if (result && result.keywords) {
|
||||
researchCache.cacheResult(
|
||||
result.keywords,
|
||||
result.industry || 'General',
|
||||
result.target_audience || 'General',
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
onResearchComplete(result);
|
||||
setCurrentMessage('');
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Research polling error:', error);
|
||||
onError?.(error);
|
||||
setCurrentMessage('');
|
||||
}
|
||||
});
|
||||
|
||||
// Start polling when taskId is provided
|
||||
useEffect(() => {
|
||||
if (taskId) {
|
||||
polling.startPolling(taskId);
|
||||
} else {
|
||||
polling.stopPolling();
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
polling.stopPolling();
|
||||
};
|
||||
}, []);
|
||||
|
||||
console.log('ResearchPollingHandler render:', {
|
||||
taskId,
|
||||
isPolling: polling.isPolling,
|
||||
status: polling.currentStatus,
|
||||
progressMessages: polling.progressMessages?.length,
|
||||
currentMessage,
|
||||
error: polling.error
|
||||
});
|
||||
|
||||
// Render the unified research progress modal when a task is present
|
||||
return (
|
||||
<ResearchProgressModal
|
||||
open={Boolean(taskId)}
|
||||
title="Research in progress"
|
||||
status={polling.currentStatus}
|
||||
messages={polling.progressMessages}
|
||||
error={polling.error}
|
||||
onClose={() => { /* modal is informational during processing; ignore manual close */ }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchPollingHandler;
|
||||
116
frontend/src/components/BlogWriter/ResearchProgressModal.tsx
Normal file
116
frontend/src/components/BlogWriter/ResearchProgressModal.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ResearchProgressModalProps {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
status?: string;
|
||||
messages: Array<{ timestamp: string; message: string }>;
|
||||
error?: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
|
||||
open,
|
||||
title = 'Research in progress',
|
||||
status,
|
||||
messages,
|
||||
error,
|
||||
onClose
|
||||
}) => {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 2000
|
||||
}}>
|
||||
<div style={{
|
||||
width: '92%',
|
||||
maxWidth: 900,
|
||||
maxHeight: '82vh',
|
||||
background: 'white',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 24px 60px rgba(0,0,0,0.3)',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
{/* Header with background illustration */}
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
padding: '28px 28px 24px 28px',
|
||||
background: '#f8fafc'
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: 'url(/blog-writer-bg.png)',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundSize: '38% auto',
|
||||
opacity: 0.12
|
||||
}} />
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 20, color: '#111827' }}>{title}</h3>
|
||||
<p style={{ margin: '6px 0 0 0', color: '#6b7280', fontSize: 13 }}>We are gathering sources, extracting insights, and preparing high‑quality research.</p>
|
||||
{status && (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#374151' }}>Status: {status}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 10,
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
color: '#374151'
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages list */}
|
||||
<div style={{ padding: 20 }}>
|
||||
<div style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
background: '#ffffff'
|
||||
}}>
|
||||
<div style={{ maxHeight: '48vh', overflowY: 'auto' }}>
|
||||
{messages.length === 0 && (
|
||||
<div style={{ padding: 16, color: '#6b7280', fontSize: 14 }}>Awaiting progress updates…</div>
|
||||
)}
|
||||
{messages.map((m, idx) => (
|
||||
<div key={idx} style={{ display: 'flex', gap: 12, padding: '12px 16px', borderTop: idx === 0 ? 'none' : '1px solid #f3f4f6' }}>
|
||||
<div style={{ color: '#9ca3af', minWidth: 120, fontSize: 12 }}>{new Date(m.timestamp).toLocaleTimeString()}</div>
|
||||
<div style={{ color: '#374151', fontSize: 14 }}>{m.message}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ marginTop: 12, color: '#b91c1c', fontSize: 13 }}>Error: {error}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchProgressModal;
|
||||
|
||||
|
||||
@@ -1,350 +1,546 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
import { ResearchSources, ResearchGrounding } from './ResearchComponents';
|
||||
|
||||
interface ResearchResultsProps {
|
||||
research: BlogResearchResponse;
|
||||
}
|
||||
|
||||
export const ResearchResults: React.FC<ResearchResultsProps> = ({ research }) => {
|
||||
const [activeTab, setActiveTab] = useState<'sources' | 'keywords' | 'angles' | 'queries'>('sources');
|
||||
const [showSearchWidget, setShowSearchWidget] = useState(false);
|
||||
const [showAnglesModal, setShowAnglesModal] = useState(false);
|
||||
const [showCompetitorModal, setShowCompetitorModal] = useState(false);
|
||||
const [showGroundingModal, setShowGroundingModal] = useState(false);
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
const renderCredibilityScore = (score: number | undefined) => {
|
||||
const safeScore = score ?? 0.8; // Default to 0.8 if undefined
|
||||
const percentage = Math.round(safeScore * 100);
|
||||
const color = safeScore >= 0.8 ? '#4CAF50' : safeScore >= 0.6 ? '#FF9800' : '#F44336';
|
||||
// Show toast message on component mount
|
||||
useEffect(() => {
|
||||
setShowToast(true);
|
||||
const timer = setTimeout(() => {
|
||||
setShowToast(false);
|
||||
}, 4000); // Show for 4 seconds
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const renderAnglesModal = () => {
|
||||
if (!showAnglesModal) return null;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{
|
||||
width: '60px',
|
||||
height: '8px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${percentage}%`,
|
||||
height: '100%',
|
||||
backgroundColor: color,
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}
|
||||
onClick={() => setShowAnglesModal(false)}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
maxWidth: '600px',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 10px 30px rgba(0, 0, 0, 0.3)'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<h3 style={{ margin: 0, color: '#333', fontSize: '18px' }}>💡 Content Angles ({research.suggested_angles.length})</h3>
|
||||
<button
|
||||
onClick={() => setShowAnglesModal(false)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
padding: '0',
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
{research.suggested_angles.map((angle, index) => (
|
||||
<div key={index} style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#fafafa',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f0f0f0';
|
||||
e.currentTarget.style.borderColor = '#1976d2';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#fafafa';
|
||||
e.currentTarget.style.borderColor = '#e0e0e0';
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<h4 style={{ margin: 0, fontSize: '14px', fontWeight: '500', color: '#333' }}>
|
||||
{angle}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: '12px', color: '#666' }}>{percentage}%</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSources = () => (
|
||||
<div style={{ padding: '16px' }}>
|
||||
<h3 style={{ margin: '0 0 16px 0', color: '#333' }}>🔍 Research Sources ({research.sources.length})</h3>
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
{research.sources.map((source, index) => (
|
||||
<div key={index} style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#fafafa'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px' }}>
|
||||
<h4 style={{ margin: 0, fontSize: '14px', fontWeight: '600', color: '#333' }}>
|
||||
{source.title}
|
||||
</h4>
|
||||
{renderCredibilityScore(source.credibility_score)}
|
||||
</div>
|
||||
<p style={{
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
{source.excerpt}
|
||||
</p>
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#1976d2',
|
||||
textDecoration: 'none',
|
||||
wordBreak: 'break-all'
|
||||
}}
|
||||
>
|
||||
{source.url}
|
||||
</a>
|
||||
<div style={{ fontSize: '11px', color: '#999', marginTop: '4px' }}>
|
||||
Published: {source.published_at}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderKeywordAnalysis = () => (
|
||||
<div style={{ padding: '16px' }}>
|
||||
<h3 style={{ margin: '0 0 16px 0', color: '#333' }}>🎯 Keyword Analysis</h3>
|
||||
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#1976d2' }}>Primary Keywords</h4>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{research.keyword_analysis.primary?.map((keyword: string, index: number) => (
|
||||
<span key={index} style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#388e3c' }}>Secondary Keywords</h4>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{research.keyword_analysis.secondary?.map((keyword: string, index: number) => (
|
||||
<span key={index} style={{
|
||||
backgroundColor: '#e8f5e8',
|
||||
color: '#388e3c',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#f57c00' }}>Long-tail Keywords</h4>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{research.keyword_analysis.long_tail?.map((keyword: string, index: number) => (
|
||||
<span key={index} style={{
|
||||
backgroundColor: '#fff3e0',
|
||||
color: '#f57c00',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Search Intent</h4>
|
||||
<span style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
color: '#333',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{research.keyword_analysis.search_intent || 'Informational'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Difficulty Score</h4>
|
||||
<span style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
color: '#333',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{research.keyword_analysis.difficulty || 'N/A'}/10
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderContentAngles = () => (
|
||||
<div style={{ padding: '16px' }}>
|
||||
<h3 style={{ margin: '0 0 16px 0', color: '#333' }}>💡 Content Angles ({research.suggested_angles.length})</h3>
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
{research.suggested_angles.map((angle, index) => (
|
||||
<div key={index} style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#fafafa',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f0f0f0';
|
||||
e.currentTarget.style.borderColor = '#1976d2';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#fafafa';
|
||||
e.currentTarget.style.borderColor = '#e0e0e0';
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '50%',
|
||||
|
||||
|
||||
|
||||
const renderCompetitorModal = () => {
|
||||
if (!showCompetitorModal) return null;
|
||||
|
||||
const ca = research.competitor_analysis || {} as any;
|
||||
const top_competitors: string[] = Array.isArray(ca.top_competitors) ? ca.top_competitors : [];
|
||||
const opportunities: string[] = Array.isArray(ca.opportunities) ? ca.opportunities : [];
|
||||
const competitive_advantages: string[] = Array.isArray(ca.competitive_advantages) ? ca.competitive_advantages : [];
|
||||
const market_positioning: string | undefined = typeof ca.market_positioning === 'string' ? ca.market_positioning : undefined;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}
|
||||
onClick={() => setShowCompetitorModal(false)}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
maxWidth: '800px',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<h3 style={{ margin: 0, color: '#1f2937', fontSize: '24px', fontWeight: '700' }}>📈 Competitor Analysis</h3>
|
||||
<button
|
||||
onClick={() => setShowCompetitorModal(false)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#6b7280',
|
||||
padding: '8px',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<h4 style={{ margin: 0, fontSize: '14px', fontWeight: '500', color: '#333' }}>
|
||||
{angle}
|
||||
</h4>
|
||||
</div>
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f3f4f6';
|
||||
e.currentTarget.style.color = '#374151';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#6b7280';
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSearchQueries = () => {
|
||||
const queries = research.search_queries || [];
|
||||
return (
|
||||
<div style={{ padding: '16px' }}>
|
||||
<h3 style={{ margin: '0 0 16px 0', color: '#333' }}>🔗 Search Queries ({queries.length})</h3>
|
||||
<div style={{ display: 'grid', gap: '8px' }}>
|
||||
{queries.map((query: string, index: number) => (
|
||||
<div key={index} style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#fafafa',
|
||||
fontSize: '13px',
|
||||
color: '#333'
|
||||
|
||||
<div style={{ display: 'grid', gap: '20px' }}>
|
||||
{/* Summary cards */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<span style={{ color: '#666', marginRight: '8px' }}>{index + 1}.</span>
|
||||
{query}
|
||||
<div style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)',
|
||||
borderLeft: '4px solid #0ea5e9'
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', color: '#0369a1', fontWeight: '600', marginBottom: '8px' }}>Top Competitors</div>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#0c4a6e' }}>{top_competitors.length}</div>
|
||||
</div>
|
||||
<div style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
background: 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)',
|
||||
borderLeft: '4px solid #22c55e'
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', color: '#15803d', fontWeight: '600', marginBottom: '8px' }}>Opportunities</div>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#166534' }}>{opportunities.length}</div>
|
||||
</div>
|
||||
<div style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
borderLeft: '4px solid #f59e0b'
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', color: '#d97706', fontWeight: '600', marginBottom: '8px' }}>Competitive Advantages</div>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#92400e' }}>{competitive_advantages.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Market positioning */}
|
||||
{market_positioning && (
|
||||
<div style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
background: '#ffffff',
|
||||
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>🎯 Market Positioning</h4>
|
||||
<p style={{ margin: 0, color: '#4b5563', lineHeight: '1.7', fontSize: '15px' }}>{market_positioning}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lists */}
|
||||
{top_competitors.length > 0 && (
|
||||
<div style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
background: '#ffffff',
|
||||
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>🏁 Top Competitors ({top_competitors.length})</h4>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{top_competitors.map((c, i) => (
|
||||
<span key={i} style={{
|
||||
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
|
||||
color: '#1e40af',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
border: '1px solid #93c5fd'
|
||||
}}>{c}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{opportunities.length > 0 && (
|
||||
<div style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
background: '#ffffff',
|
||||
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>🚀 Opportunities ({opportunities.length})</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px', color: '#4b5563', lineHeight: '1.7', fontSize: '15px' }}>
|
||||
{opportunities.map((o, i) => (
|
||||
<li key={i} style={{ marginBottom: '8px' }}>{o}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{competitive_advantages.length > 0 && (
|
||||
<div style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
background: '#ffffff',
|
||||
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>✅ Competitive Advantages ({competitive_advantages.length})</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px', color: '#4b5563', lineHeight: '1.7', fontSize: '15px' }}>
|
||||
{competitive_advantages.map((a, i) => (
|
||||
<li key={i} style={{ marginBottom: '8px' }}>{a}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSearchWidget = () => {
|
||||
if (!research.search_widget) return null;
|
||||
|
||||
const renderGroundingModal = () => {
|
||||
if (!showGroundingModal) return null;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<h3 style={{ margin: 0, color: '#333' }}>🎯 Interactive Search Widget</h3>
|
||||
<button
|
||||
onClick={() => setShowSearchWidget(!showSearchWidget)}
|
||||
style={{
|
||||
backgroundColor: showSearchWidget ? '#1976d2' : '#f5f5f5',
|
||||
color: showSearchWidget ? 'white' : '#333',
|
||||
border: 'none',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{showSearchWidget ? 'Hide Widget' : 'Show Widget'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showSearchWidget && (
|
||||
<div style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#fafafa',
|
||||
maxHeight: '400px',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
<div dangerouslySetInnerHTML={{ __html: research.search_widget }} />
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}
|
||||
onClick={() => setShowGroundingModal(false)}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
maxWidth: '900px',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<h3 style={{ margin: 0, color: '#1f2937', fontSize: '24px', fontWeight: '700' }}>🔗 Grounding Analysis</h3>
|
||||
<button
|
||||
onClick={() => setShowGroundingModal(false)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#6b7280',
|
||||
padding: '8px',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f3f4f6';
|
||||
e.currentTarget.style.color = '#374151';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#6b7280';
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grounding Content */}
|
||||
<ResearchGrounding research={research} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '16px 0'
|
||||
}}>
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '16px 0'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
backgroundColor: '#f8f9fa'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, color: '#333', fontSize: '18px' }}>
|
||||
📊 Research Results
|
||||
📊 Research Results for {research.keywords?.join(', ') || 'Your Topic'}
|
||||
</h2>
|
||||
<p style={{ margin: '4px 0 0 0', color: '#666', fontSize: '14px' }}>
|
||||
Google Search grounding analysis completed with {research.sources.length} sources and {research.search_queries?.length || 0} search queries
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Chips */}
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
{/* Competitor Analysis Chip */}
|
||||
<div
|
||||
onClick={() => setShowCompetitorModal(true)}
|
||||
style={{
|
||||
backgroundColor: '#f0f9ff',
|
||||
color: '#1e40af',
|
||||
border: '1px solid #3b82f6',
|
||||
borderRadius: '20px',
|
||||
padding: '6px 16px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#dbeafe';
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f0f9ff';
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
📈 Competitor Analysis
|
||||
</div>
|
||||
|
||||
{/* Grounding Analysis Chip */}
|
||||
<div
|
||||
onClick={() => setShowGroundingModal(true)}
|
||||
style={{
|
||||
backgroundColor: '#faf5ff',
|
||||
color: '#7c3aed',
|
||||
border: '1px solid #8b5cf6',
|
||||
borderRadius: '20px',
|
||||
padding: '6px 16px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f3e8ff';
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#faf5ff';
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
🔗 Grounding Analysis
|
||||
</div>
|
||||
|
||||
{/* Use Research Blog Topics Chip */}
|
||||
<div
|
||||
onClick={() => setShowAnglesModal(true)}
|
||||
style={{
|
||||
backgroundColor: '#e8f5e8',
|
||||
color: '#2e7d32',
|
||||
border: '1px solid #4caf50',
|
||||
borderRadius: '20px',
|
||||
padding: '6px 16px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#c8e6c9';
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#e8f5e8';
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
📝 Use Research Blog Topics
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
backgroundColor: '#f8f9fa'
|
||||
}}>
|
||||
{[
|
||||
{ id: 'sources', label: 'Sources', icon: '🔍' },
|
||||
{ id: 'keywords', label: 'Keywords', icon: '🎯' },
|
||||
{ id: 'angles', label: 'Angles', icon: '💡' },
|
||||
{ id: 'queries', label: 'Queries', icon: '🔗' }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
border: 'none',
|
||||
backgroundColor: activeTab === tab.id ? 'white' : 'transparent',
|
||||
color: activeTab === tab.id ? '#1976d2' : '#666',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: activeTab === tab.id ? '600' : '400',
|
||||
borderBottom: activeTab === tab.id ? '2px solid #1976d2' : '2px solid transparent',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
{tab.icon} {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Toast Message */}
|
||||
{showToast && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
backgroundColor: '#10b981',
|
||||
color: 'white',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
animation: 'slideInRight 0.3s ease-out'
|
||||
}}>
|
||||
<span>✅</span>
|
||||
<span>Google Search grounding analysis completed with {research.sources.length} sources and {research.search_queries?.length || 0} search queries</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === 'sources' && renderSources()}
|
||||
{activeTab === 'keywords' && renderKeywordAnalysis()}
|
||||
{activeTab === 'angles' && renderContentAngles()}
|
||||
{activeTab === 'queries' && renderSearchQueries()}
|
||||
|
||||
{/* Search Widget */}
|
||||
{renderSearchWidget()}
|
||||
</div>
|
||||
<ResearchSources research={research} />
|
||||
|
||||
{/* Modals */}
|
||||
{renderAnglesModal()}
|
||||
{renderCompetitorModal()}
|
||||
{renderGroundingModal()}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
85
frontend/src/components/BlogWriter/SEOProcessor.tsx
Normal file
85
frontend/src/components/BlogWriter/SEOProcessor.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogSEOMetadataResponse } from '../../services/blogWriterApi';
|
||||
|
||||
interface SEOProcessorProps {
|
||||
buildFullMarkdown: () => string;
|
||||
seoMetadata: BlogSEOMetadataResponse | null;
|
||||
onSEOAnalysis: (analysis: any) => void;
|
||||
onSEOMetadata: (metadata: BlogSEOMetadataResponse) => void;
|
||||
}
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
export const SEOProcessor: React.FC<SEOProcessorProps> = ({
|
||||
buildFullMarkdown,
|
||||
seoMetadata,
|
||||
onSEOAnalysis,
|
||||
onSEOMetadata
|
||||
}) => {
|
||||
useCopilotActionTyped({
|
||||
name: 'runSEOAnalyze',
|
||||
description: 'Analyze SEO for the full draft',
|
||||
parameters: [ { name: 'keywords', type: 'string', description: 'Comma-separated keywords', required: false } ],
|
||||
handler: async ({ keywords }: { keywords?: string }) => {
|
||||
const content = buildFullMarkdown();
|
||||
const res = await blogWriterApi.seoAnalyze({ content, keywords: keywords ? keywords.split(',').map(k => k.trim()) : [] });
|
||||
onSEOAnalysis(res);
|
||||
return { success: true, seo_score: res.seo_score };
|
||||
},
|
||||
render: ({ status, result }: any) => status === 'complete' ? (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div>SEO Score: {result?.seo_score ?? '—'}</div>
|
||||
</div>
|
||||
) : null
|
||||
});
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'generateSEOMetadata',
|
||||
description: 'Generate SEO metadata for the full draft',
|
||||
parameters: [ { name: 'title', type: 'string', description: 'Preferred title', required: false } ],
|
||||
handler: async ({ title }: { title?: string }) => {
|
||||
const content = buildFullMarkdown();
|
||||
const res = await blogWriterApi.seoMetadata({ content, title, keywords: [] });
|
||||
onSEOMetadata(res);
|
||||
return { success: true };
|
||||
},
|
||||
renderAndWaitForResponse: ({ respond }: any) => (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>SEO Metadata Ready</div>
|
||||
<div style={{ marginBottom: 8 }}>Review the generated title, meta description, and OG/Twitter tags in the editor.</div>
|
||||
<button onClick={() => respond?.('accept')}>Accept Metadata</button>
|
||||
</div>
|
||||
)
|
||||
});
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'optimizeSection',
|
||||
description: 'Optimize a section for readability/EEAT/examples/data with HITL diff',
|
||||
parameters: [
|
||||
{ name: 'sectionId', type: 'string', description: 'Section ID', required: true },
|
||||
{ name: 'goals', type: 'string', description: 'Comma-separated goals', required: false },
|
||||
],
|
||||
handler: async ({ sectionId, goals }: { sectionId: string; goals?: string }) => {
|
||||
const current = buildFullMarkdown();
|
||||
if (!current) return { success: false, message: 'No content yet for this section' };
|
||||
const res = await blogWriterApi.seoAnalyze({ content: current, keywords: [] });
|
||||
onSEOAnalysis(res);
|
||||
return { success: true, message: 'Analysis ready' };
|
||||
},
|
||||
renderAndWaitForResponse: ({ respond, args, status }: any) => {
|
||||
if (status === 'complete') return <div>Optimization applied.</div>;
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Optimization preview</div>
|
||||
<div style={{ marginBottom: 8 }}>Goals: {args.goals || 'readability, EEAT'}</div>
|
||||
<button onClick={() => respond?.('apply')}>Apply Changes</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the copilot actions
|
||||
};
|
||||
|
||||
export default SEOProcessor;
|
||||
114
frontend/src/components/BlogWriter/SectionGenerator.tsx
Normal file
114
frontend/src/components/BlogWriter/SectionGenerator.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogOutlineSection, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
|
||||
interface SectionGeneratorProps {
|
||||
outline: BlogOutlineSection[];
|
||||
research: BlogResearchResponse | null;
|
||||
genMode: 'draft' | 'polished';
|
||||
onSectionGenerated: (sectionId: string, markdown: string) => void;
|
||||
onContinuityRefresh: () => void;
|
||||
}
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
export const SectionGenerator: React.FC<SectionGeneratorProps> = ({
|
||||
outline,
|
||||
research,
|
||||
genMode,
|
||||
onSectionGenerated,
|
||||
onContinuityRefresh
|
||||
}) => {
|
||||
useCopilotActionTyped({
|
||||
name: 'generateSection',
|
||||
description: 'Generate content for a specific section using research and outline',
|
||||
parameters: [ { name: 'sectionId', type: 'string', description: 'Section ID', required: true } ],
|
||||
handler: async ({ sectionId }: { sectionId: string }) => {
|
||||
const section = outline.find(s => s.id === sectionId);
|
||||
if (!section) return { success: false, message: 'Section not found. Please generate an outline first.' };
|
||||
|
||||
try {
|
||||
const res = await blogWriterApi.generateSection({ section, mode: genMode });
|
||||
if (res?.markdown) {
|
||||
onSectionGenerated(sectionId, res.markdown);
|
||||
onContinuityRefresh();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `✍️ Content generated for "${section.heading}"! The section incorporates your research findings and primary keywords. You can now review the content, run SEO analysis, or generate more sections.`,
|
||||
section_summary: {
|
||||
heading: section.heading,
|
||||
content_length: res.markdown.length,
|
||||
primary_keywords: research?.keyword_analysis?.primary || []
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Section generation failed:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Content generation failed for "${section.heading}": ${errorMessage}. Please try again or contact support if the problem persists.`
|
||||
};
|
||||
}
|
||||
return { success: false, message: 'Failed to generate section content. Please try again.' };
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #f57c00',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#f57c00' }}>✍️ Generating Section Content</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Analyzing section requirements and research data...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Incorporating primary keywords and SEO best practices...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Writing engaging content with proper structure...</p>
|
||||
<p style={{ margin: '0' }}>• Ensuring factual accuracy and readability...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'generateAllSections',
|
||||
description: 'Generate content for every section in the outline',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
for (const s of outline) {
|
||||
const res = await blogWriterApi.generateSection({ section: s, mode: genMode });
|
||||
onSectionGenerated(s.id, res.markdown);
|
||||
onContinuityRefresh();
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
render: ({ status }: any) => (status === 'inProgress' || status === 'executing') ? <div>Generating all sections…</div> : null
|
||||
});
|
||||
|
||||
return null; // This component only provides the copilot actions
|
||||
};
|
||||
|
||||
export default SectionGenerator;
|
||||
51
frontend/src/components/BlogWriter/SuggestionsGenerator.tsx
Normal file
51
frontend/src/components/BlogWriter/SuggestionsGenerator.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { BlogOutlineSection, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
|
||||
interface SuggestionsGeneratorProps {
|
||||
research: BlogResearchResponse | null;
|
||||
outline: BlogOutlineSection[];
|
||||
}
|
||||
|
||||
export const useSuggestions = (research: BlogResearchResponse | null, outline: BlogOutlineSection[]) => {
|
||||
return useMemo(() => {
|
||||
const items = [] as { title: string; message: string }[];
|
||||
|
||||
if (!research) {
|
||||
items.push({ title: '🔎 Start research', message: "I want to research a topic for my blog" });
|
||||
} else if (research && outline.length === 0) {
|
||||
// Research completed, guide user to outline creation
|
||||
items.push({
|
||||
title: '🧩 Create Outline',
|
||||
message: 'Let\'s proceed to create an outline based on the research results'
|
||||
});
|
||||
items.push({
|
||||
title: '💬 Chat with Research Data',
|
||||
message: 'I want to explore the research data and ask questions about the findings'
|
||||
});
|
||||
items.push({
|
||||
title: '🎨 Create Custom Outline',
|
||||
message: 'I want to create an outline with my own specific instructions and requirements'
|
||||
});
|
||||
} else if (outline.length > 0) {
|
||||
// Outline created, focus on content generation
|
||||
items.push({ title: '📝 Generate all sections', message: 'Generate all sections of my blog post' });
|
||||
outline.forEach(s => items.push({ title: `✍️ Generate ${s.heading}`, message: `Generate the section: ${s.heading}` }));
|
||||
items.push({ title: '🔧 Refine outline', message: 'Help me refine the outline structure' });
|
||||
items.push({ title: '✨ Enhance outline', message: 'Optimize the entire outline for better flow and engagement' });
|
||||
items.push({ title: '⚖️ Rebalance word counts', message: 'Rebalance word count distribution across sections' });
|
||||
items.push({ title: '📈 Run SEO analysis', message: 'Analyze SEO for my blog post' });
|
||||
items.push({ title: '🧾 Generate SEO metadata', message: 'Generate SEO metadata and title' });
|
||||
items.push({ title: '🧪 Hallucination check', message: 'Check for any false claims in my content' });
|
||||
items.push({ title: '🚀 Publish to WordPress', message: 'Publish my blog to WordPress' });
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [research, outline]);
|
||||
};
|
||||
|
||||
export const SuggestionsGenerator: React.FC<SuggestionsGeneratorProps> = ({ research, outline }) => {
|
||||
const suggestions = useSuggestions(research, outline);
|
||||
return null; // This is just a utility component
|
||||
};
|
||||
|
||||
export default SuggestionsGenerator;
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { ResearchAction } from '../ResearchAction';
|
||||
import { KeywordInputForm } from '../KeywordInputForm';
|
||||
import { blogWriterApi } from '../../../services/blogWriterApi';
|
||||
|
||||
// Mock the API
|
||||
jest.mock('../../../services/blogWriterApi', () => ({
|
||||
blogWriterApi: {
|
||||
startResearch: jest.fn(),
|
||||
pollResearchStatus: jest.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock CopilotKit
|
||||
jest.mock('@copilotkit/react-core', () => ({
|
||||
useCopilotAction: jest.fn(() => ({
|
||||
name: 'testAction',
|
||||
handler: jest.fn(),
|
||||
render: jest.fn()
|
||||
}))
|
||||
}));
|
||||
|
||||
describe('Polling Integration', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should use async polling endpoints for research', async () => {
|
||||
const mockStartResearch = blogWriterApi.startResearch as jest.Mock;
|
||||
const mockPollStatus = blogWriterApi.pollResearchStatus as jest.Mock;
|
||||
|
||||
// Mock successful research start
|
||||
mockStartResearch.mockResolvedValue({
|
||||
task_id: 'test-task-123',
|
||||
status: 'started'
|
||||
});
|
||||
|
||||
// Mock polling responses
|
||||
mockPollStatus
|
||||
.mockResolvedValueOnce({
|
||||
task_id: 'test-task-123',
|
||||
status: 'running',
|
||||
progress_messages: [
|
||||
{ timestamp: '2024-01-01T10:00:00Z', message: 'Starting research...' }
|
||||
]
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
task_id: 'test-task-123',
|
||||
status: 'completed',
|
||||
result: {
|
||||
success: true,
|
||||
sources: [],
|
||||
keyword_analysis: {},
|
||||
competitor_analysis: {},
|
||||
suggested_angles: []
|
||||
}
|
||||
});
|
||||
|
||||
const onResearchComplete = jest.fn();
|
||||
|
||||
render(<ResearchAction onResearchComplete={onResearchComplete} />);
|
||||
|
||||
// Verify that startResearch was called (this would be triggered by CopilotKit action)
|
||||
expect(mockStartResearch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle polling errors gracefully', async () => {
|
||||
const mockStartResearch = blogWriterApi.startResearch as jest.Mock;
|
||||
const mockPollStatus = blogWriterApi.pollResearchStatus as jest.Mock;
|
||||
|
||||
mockStartResearch.mockResolvedValue({
|
||||
task_id: 'test-task-123',
|
||||
status: 'started'
|
||||
});
|
||||
|
||||
mockPollStatus.mockRejectedValue(new Error('Polling failed'));
|
||||
|
||||
const onResearchComplete = jest.fn();
|
||||
const onError = jest.fn();
|
||||
|
||||
render(<KeywordInputForm onResearchComplete={onResearchComplete} />);
|
||||
|
||||
// The component should handle the error gracefully
|
||||
expect(mockStartResearch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user