ALwrity AI Blog Writer - Added Google Grounding UI Implementation

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

View File

@@ -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

View 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

View 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;

View File

@@ -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={{

View 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;

View 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;

View 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;

View File

@@ -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;

View 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;

View 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 &gt;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 &gt;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;

View 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;

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
export { ResearchSources } from './ResearchSources';
export { ResearchGrounding } from './ResearchGrounding';

View File

@@ -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 {

View File

@@ -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;

View 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 highquality 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;

View File

@@ -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>
</>
);
};

View 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;

View 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;

View 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;

View File

@@ -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();
});
});