Added blog writer implementation - WIP
This commit is contained in:
581
frontend/src/components/BlogWriter/BlogWriter.tsx
Normal file
581
frontend/src/components/BlogWriter/BlogWriter.tsx
Normal file
@@ -0,0 +1,581 @@
|
||||
import React, { useMemo, useState } 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 EnhancedOutlineEditor from './EnhancedOutlineEditor';
|
||||
import TitleSelector from './TitleSelector';
|
||||
import DiffPreview from './DiffPreview';
|
||||
import SEOMiniPanel from './SEOMiniPanel';
|
||||
import ResearchResults from './ResearchResults';
|
||||
import KeywordInputForm from './KeywordInputForm';
|
||||
import ResearchAction from './ResearchAction';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
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 [seoMetadata, setSeoMetadata] = useState<BlogSEOMetadataResponse | null>(null);
|
||||
const [hallucinationResult, setHallucinationResult] = useState<any>(null);
|
||||
|
||||
const buildFullMarkdown = () => {
|
||||
if (!outline.length) return '';
|
||||
return outline.map(s => `## ${s.heading}\n\n${sections[s.id] || ''}`).join('\n\n');
|
||||
};
|
||||
|
||||
// Sentence-level claim mapping and patching helpers
|
||||
const normalized = (s: string) => s.toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
|
||||
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';
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Outline generation failed: ${errorMessage}. The AI system encountered an issue while creating your outline. Please try again or contact support if the problem persists.`
|
||||
};
|
||||
}
|
||||
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 });
|
||||
if (res?.markdown) {
|
||||
setSections(prev => ({ ...prev, [sectionId]: res.markdown }));
|
||||
|
||||
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 });
|
||||
setSections(prev => ({ ...prev, [s.id]: res.markdown }));
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 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" });
|
||||
if (research && outline.length === 0) items.push({ title: '🧩 Create Outline', message: 'Let\'s proceed to create an outline based on the research results' });
|
||||
if (outline.length > 0) {
|
||||
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: '📈 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' }}>
|
||||
{/* Extracted Components */}
|
||||
<KeywordInputForm onResearchComplete={handleResearchComplete} />
|
||||
<ResearchAction onResearchComplete={handleResearchComplete} />
|
||||
|
||||
<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>
|
||||
)}
|
||||
{research && outline.length === 0 && <ResearchResults research={research} />}
|
||||
{outline.length > 0 && (
|
||||
<div>
|
||||
{/* Title Selection */}
|
||||
{titleOptions.length > 0 && (
|
||||
<TitleSelector
|
||||
titleOptions={titleOptions}
|
||||
selectedTitle={selectedTitle}
|
||||
onTitleSelect={setSelectedTitle}
|
||||
onCustomTitle={(title) => {
|
||||
setTitleOptions(prev => [...prev, title]);
|
||||
setSelectedTitle(title);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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))}
|
||||
/>
|
||||
|
||||
{outline.map(s => (
|
||||
<div key={s.id} style={{ marginBottom: 16 }}>
|
||||
<h4>{s.heading}</h4>
|
||||
{sections[s.id] ? (
|
||||
<>
|
||||
<pre style={{ whiteSpace: 'pre-wrap' }}>{sections[s.id]}</pre>
|
||||
<SEOMiniPanel analysis={seoAnalysis} />
|
||||
</>
|
||||
) : (
|
||||
<div style={{ fontStyle: 'italic', color: '#666' }}>Ask the copilot to generate this section.</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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!' }}
|
||||
suggestions={suggestions}
|
||||
makeSystemMessage={(context: string, additional?: string) => {
|
||||
// Get current state information
|
||||
const hasResearch = research !== null;
|
||||
const hasOutline = outline.length > 0;
|
||||
const researchInfo = hasResearch ? {
|
||||
sources: research.sources?.length || 0,
|
||||
queries: research.search_queries?.length || 0,
|
||||
angles: research.suggested_angles?.length || 0,
|
||||
primaryKeywords: research.keyword_analysis?.primary || [],
|
||||
searchIntent: research.keyword_analysis?.search_intent || 'informational'
|
||||
} : null;
|
||||
|
||||
const toolGuide = `
|
||||
You are the ALwrity Blog Writing Assistant. You MUST call the appropriate frontend actions (tools) to fulfill user requests.
|
||||
|
||||
CURRENT STATE:
|
||||
${hasResearch && researchInfo ? `
|
||||
✅ RESEARCH COMPLETED:
|
||||
- Found ${researchInfo.sources} sources with Google Search grounding
|
||||
- Generated ${researchInfo.queries} search queries
|
||||
- Created ${researchInfo.angles} content angles
|
||||
- Primary keywords: ${researchInfo.primaryKeywords.join(', ')}
|
||||
- Search intent: ${researchInfo.searchIntent}
|
||||
` : '❌ No research completed yet'}
|
||||
|
||||
${hasOutline ? `✅ OUTLINE GENERATED: ${outline.length} sections created` : '❌ No outline generated yet'}
|
||||
|
||||
Available tools:
|
||||
- getResearchKeywords(prompt?: string) - Get keywords from user for research
|
||||
- performResearch(formData: string) - Perform research with collected keywords (formData is JSON string with keywords and blogLength)
|
||||
- researchTopic(keywords: string, industry?: string, target_audience?: string)
|
||||
- generateOutline()
|
||||
- generateSection(sectionId: string)
|
||||
- generateAllSections()
|
||||
- refineOutline(operation: add|remove|move|merge|rename, sectionId?: string, payload?: object)
|
||||
- runSEOAnalyze(keywords?: string)
|
||||
- generateSEOMetadata(title?: string)
|
||||
- runHallucinationCheck()
|
||||
- publishToPlatform(platform: 'wix'|'wordpress', schedule_time?: string)
|
||||
|
||||
CRITICAL BEHAVIOR:
|
||||
- When user wants to research ANY topic, IMMEDIATELY call getResearchKeywords() to get their input
|
||||
- When user asks to research something, call getResearchKeywords() first to collect their keywords
|
||||
- After getResearchKeywords() completes, IMMEDIATELY call performResearch() with the collected data
|
||||
- When user asks for outline, call generateOutline()
|
||||
- When user asks to generate content, call generateSection or generateAllSections
|
||||
- DO NOT ask for clarification - take action immediately with the information provided
|
||||
- Always call the appropriate tool instead of just talking about what you could do
|
||||
- Be aware of the current state and reference research results when relevant
|
||||
- Guide users through the process: Research → Outline → Content → SEO → Publish
|
||||
`;
|
||||
return [toolGuide, additional].filter(Boolean).join('\n\n');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogWriter;
|
||||
51
frontend/src/components/BlogWriter/DiffPreview.tsx
Normal file
51
frontend/src/components/BlogWriter/DiffPreview.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
original: string;
|
||||
updated: string;
|
||||
onApply: () => void;
|
||||
onDiscard: () => void;
|
||||
}
|
||||
|
||||
function highlightDiff(a: string, b: string) {
|
||||
// Simple common prefix/suffix highlighting
|
||||
let i = 0;
|
||||
while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
||||
let j = 0;
|
||||
while (j < a.length - i && j < b.length - i && a[a.length - 1 - j] === b[b.length - 1 - j]) j++;
|
||||
const aMid = a.substring(i, a.length - j);
|
||||
const bMid = b.substring(i, b.length - j);
|
||||
const aHtml = `${escapeHtml(a.substring(0, i))}<span style="background:#ffe5e5;text-decoration:line-through;">${escapeHtml(aMid)}</span>${escapeHtml(a.substring(a.length - j))}`;
|
||||
const bHtml = `${escapeHtml(b.substring(0, i))}<span style="background:#e6ffed;">${escapeHtml(bMid)}</span>${escapeHtml(b.substring(b.length - j))}`;
|
||||
return { aHtml, bHtml };
|
||||
}
|
||||
|
||||
function escapeHtml(s: string) {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
const DiffPreview: React.FC<Props> = ({ original, updated, onApply, onDiscard }) => {
|
||||
const { aHtml, bHtml } = highlightDiff(original, updated);
|
||||
return (
|
||||
<div style={{ border: '1px solid #ddd', padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Preview Changes</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, background: '#fafafa', padding: 8 }} dangerouslySetInnerHTML={{ __html: aHtml }} />
|
||||
<div style={{ flex: 1, background: '#f5fff5', padding: 8 }} dangerouslySetInnerHTML={{ __html: bHtml }} />
|
||||
</div>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
|
||||
<button onClick={onApply}>Apply</button>
|
||||
<button onClick={onDiscard}>Discard</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffPreview;
|
||||
|
||||
|
||||
535
frontend/src/components/BlogWriter/EnhancedOutlineEditor.tsx
Normal file
535
frontend/src/components/BlogWriter/EnhancedOutlineEditor.tsx
Normal file
@@ -0,0 +1,535 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BlogOutlineSection } from '../../services/blogWriterApi';
|
||||
|
||||
interface Props {
|
||||
outline: BlogOutlineSection[];
|
||||
onRefine: (operation: string, sectionId?: string, payload?: any) => void;
|
||||
research?: any; // Research data for context
|
||||
}
|
||||
|
||||
const EnhancedOutlineEditor: React.FC<Props> = ({ outline, onRefine, research }) => {
|
||||
const [editingSection, setEditingSection] = useState<string | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
|
||||
const [showAddSection, setShowAddSection] = useState(false);
|
||||
const [newSectionData, setNewSectionData] = useState({
|
||||
heading: '',
|
||||
subheadings: '',
|
||||
key_points: '',
|
||||
target_words: 300
|
||||
});
|
||||
|
||||
const toggleExpanded = (sectionId: string) => {
|
||||
const newExpanded = new Set(expandedSections);
|
||||
if (newExpanded.has(sectionId)) {
|
||||
newExpanded.delete(sectionId);
|
||||
} else {
|
||||
newExpanded.add(sectionId);
|
||||
}
|
||||
setExpandedSections(newExpanded);
|
||||
};
|
||||
|
||||
const handleRename = (sectionId: string, newHeading: string) => {
|
||||
if (newHeading.trim()) {
|
||||
onRefine('rename', sectionId, { heading: newHeading.trim() });
|
||||
}
|
||||
setEditingSection(null);
|
||||
};
|
||||
|
||||
const handleMove = (sectionId: string, direction: 'up' | 'down') => {
|
||||
onRefine('move', sectionId, { direction });
|
||||
};
|
||||
|
||||
const handleAddSection = () => {
|
||||
if (newSectionData.heading.trim()) {
|
||||
const subheadings = newSectionData.subheadings
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
|
||||
const keyPoints = newSectionData.key_points
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
|
||||
onRefine('add', undefined, {
|
||||
heading: newSectionData.heading.trim(),
|
||||
subheadings,
|
||||
key_points: keyPoints,
|
||||
target_words: newSectionData.target_words
|
||||
});
|
||||
|
||||
setNewSectionData({
|
||||
heading: '',
|
||||
subheadings: '',
|
||||
key_points: '',
|
||||
target_words: 300
|
||||
});
|
||||
setShowAddSection(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getTotalWords = () => {
|
||||
return outline.reduce((total, section) => total + (section.target_words || 0), 0);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
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>
|
||||
<button
|
||||
onClick={() => setShowAddSection(!showAddSection)}
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
➕ Add Section
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Section Form */}
|
||||
{showAddSection && (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#f0f8ff',
|
||||
borderBottom: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0', color: '#333' }}>Add New Section</h3>
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Section Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSectionData.heading}
|
||||
onChange={(e) => setNewSectionData({...newSectionData, heading: e.target.value})}
|
||||
placeholder="Enter section title..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Subheadings (one per line)
|
||||
</label>
|
||||
<textarea
|
||||
value={newSectionData.subheadings}
|
||||
onChange={(e) => setNewSectionData({...newSectionData, subheadings: e.target.value})}
|
||||
placeholder="Subheading 1 Subheading 2 Subheading 3"
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Key Points (one per line)
|
||||
</label>
|
||||
<textarea
|
||||
value={newSectionData.key_points}
|
||||
onChange={(e) => setNewSectionData({...newSectionData, key_points: e.target.value})}
|
||||
placeholder="Key point 1 Key point 2 Key point 3"
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Target Words
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newSectionData.target_words}
|
||||
onChange={(e) => setNewSectionData({...newSectionData, target_words: parseInt(e.target.value) || 300})}
|
||||
min="100"
|
||||
max="2000"
|
||||
style={{
|
||||
width: '120px',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={handleAddSection}
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Add Section
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddSection(false)}
|
||||
style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
color: '#666',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Outline Sections */}
|
||||
<div style={{ padding: '0' }}>
|
||||
{outline.map((section, index) => (
|
||||
<div key={section.id} style={{
|
||||
borderBottom: index < outline.length - 1 ? '1px solid #f0f0f0' : 'none',
|
||||
transition: 'all 0.2s ease'
|
||||
}}>
|
||||
{/* Section Header */}
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
backgroundColor: expandedSections.has(section.id) ? '#f8f9fa' : 'white',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
onClick={() => toggleExpanded(section.id)}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1 }}>
|
||||
<div style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{editingSection === section.id ? (
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={section.heading}
|
||||
onBlur={(e) => handleRename(section.id, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleRename(section.id, e.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
border: '1px solid #1976d2',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
backgroundColor: 'white'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
flex: 1
|
||||
}}>
|
||||
{section.heading}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{section.target_words || 300} words
|
||||
</span>
|
||||
|
||||
{section.references && section.references.length > 0 && (
|
||||
<span style={{
|
||||
backgroundColor: '#e8f5e8',
|
||||
color: '#388e3c',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{section.references.length} sources
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingSection(section.id);
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: '#666'
|
||||
}}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMove(section.id, 'up');
|
||||
}}
|
||||
disabled={index === 0}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: index === 0 ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
color: index === 0 ? '#ccc' : '#666',
|
||||
opacity: index === 0 ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
⬆️
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMove(section.id, 'down');
|
||||
}}
|
||||
disabled={index === outline.length - 1}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: index === outline.length - 1 ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
color: index === outline.length - 1 ? '#ccc' : '#666',
|
||||
opacity: index === outline.length - 1 ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
⬇️
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (window.confirm(`Are you sure you want to remove "${section.heading}"?`)) {
|
||||
onRefine('remove', section.id);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #f44336',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: '#f44336'
|
||||
}}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
|
||||
<div style={{
|
||||
transform: expandedSections.has(section.id) ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s ease',
|
||||
fontSize: '14px',
|
||||
color: '#666'
|
||||
}}>
|
||||
▼
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Section Content */}
|
||||
{expandedSections.has(section.id) && (
|
||||
<div style={{ padding: '0 20px 20px 52px', backgroundColor: '#fafafa' }}>
|
||||
{/* Subheadings */}
|
||||
{section.subheadings && section.subheadings.length > 0 && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
|
||||
📝 Subheadings
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px' }}>
|
||||
{section.subheadings.map((subheading, i) => (
|
||||
<li key={i} style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
{subheading}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key Points */}
|
||||
{section.key_points && section.key_points.length > 0 && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
|
||||
🎯 Key Points
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px' }}>
|
||||
{section.key_points.map((point, i) => (
|
||||
<li key={i} style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
{point}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keywords */}
|
||||
{section.keywords && section.keywords.length > 0 && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
|
||||
🎯 SEO Keywords
|
||||
</h4>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{section.keywords.map((keyword, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* References */}
|
||||
{section.references && section.references.length > 0 && (
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
|
||||
📚 Sources ({section.references.length})
|
||||
</h4>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{section.references.map((ref, i) => (
|
||||
<div key={i} style={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
maxWidth: '200px'
|
||||
}}>
|
||||
<div style={{ fontWeight: '500', marginBottom: '2px' }}>
|
||||
{ref.title}
|
||||
</div>
|
||||
<div style={{ color: '#999' }}>
|
||||
Credibility: {Math.round((ref.credibility_score || 0.8) * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderTop: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
💡 Tip: Click on any section to expand and see details. Use the controls to reorder, edit, or remove sections.
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
Total: {getTotalWords()} words
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedOutlineEditor;
|
||||
292
frontend/src/components/BlogWriter/KeywordInputForm.tsx
Normal file
292
frontend/src/components/BlogWriter/KeywordInputForm.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
interface KeywordInputFormProps {
|
||||
onKeywordsReceived?: (data: { keywords: string; blogLength: string }) => void;
|
||||
onResearchComplete?: (researchData: BlogResearchResponse) => void;
|
||||
}
|
||||
|
||||
export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsReceived, onResearchComplete }) => {
|
||||
// State for button enable/disable only
|
||||
const [hasInput, setHasInput] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const selectRef = useRef<HTMLSelectElement>(null);
|
||||
|
||||
// Focus input when form appears
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, 100);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
// Keyword input action with Human-in-the-Loop
|
||||
useCopilotActionTyped({
|
||||
name: 'getResearchKeywords',
|
||||
description: 'Get keywords from user for blog research',
|
||||
parameters: [
|
||||
{ name: 'prompt', type: 'string', description: 'Prompt to show user', required: false }
|
||||
],
|
||||
renderAndWaitForResponse: ({ respond, args, status }: { respond?: (value: string) => void; args: { prompt?: string }; status: string }) => {
|
||||
if (status === 'complete') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f0f8ff',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #1976d2'
|
||||
}}>
|
||||
<p style={{ margin: 0, color: '#1976d2', fontWeight: '500' }}>
|
||||
✅ Research keywords received! Starting research...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
key="keyword-input-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}
|
||||
>
|
||||
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>
|
||||
🔍 Let's Research Your Blog Topic
|
||||
</h4>
|
||||
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px' }}>
|
||||
{args.prompt || 'Please provide the keywords or topic you want to research for your blog:'}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Keywords or Topic *
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
defaultValue=""
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
// Update state for button enable/disable
|
||||
setHasInput(value.trim().length > 0);
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.select();
|
||||
}}
|
||||
placeholder="e.g., artificial intelligence, machine learning, AI trends"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
backgroundColor: 'white',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Blog Length (words)
|
||||
</label>
|
||||
<select
|
||||
ref={selectRef}
|
||||
defaultValue="1000"
|
||||
onChange={(e) => {
|
||||
// No state update needed for select
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
backgroundColor: 'white',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
<option value="500">500 words (Short blog)</option>
|
||||
<option value="1000">1000 words (Medium blog)</option>
|
||||
<option value="1500">1500 words (Long blog)</option>
|
||||
<option value="2000">2000+ words (Comprehensive guide)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '16px' }}>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const kw = (inputRef.current?.value || '').trim();
|
||||
const len = (selectRef.current?.value || '1000');
|
||||
if (kw) {
|
||||
const formData = {
|
||||
keywords: kw,
|
||||
blogLength: len
|
||||
};
|
||||
|
||||
// Notify parent component if callback provided
|
||||
onKeywordsReceived?.(formData);
|
||||
|
||||
// Send to CopilotKit to trigger performResearch action
|
||||
respond?.(JSON.stringify(formData));
|
||||
}
|
||||
}}
|
||||
disabled={!hasInput}
|
||||
style={{
|
||||
backgroundColor: hasInput ? '#1976d2' : '#f5f5f5',
|
||||
color: hasInput ? 'white' : '#999',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 20px',
|
||||
cursor: hasInput ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
🚀 Start Research
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
respond?.('CANCEL');
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#666'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Research action that actually performs the research
|
||||
useCopilotActionTyped({
|
||||
name: 'performResearch',
|
||||
description: 'Perform research with collected keywords and blog length',
|
||||
parameters: [
|
||||
{ name: 'formData', type: 'string', description: 'JSON string with keywords and blogLength', required: true }
|
||||
],
|
||||
handler: async ({ formData }: { formData: string }) => {
|
||||
try {
|
||||
const data = JSON.parse(formData);
|
||||
const { keywords, blogLength } = data;
|
||||
|
||||
// If keywords is a topic description, extract keywords from it
|
||||
const keywordList = keywords.includes(',')
|
||||
? keywords.split(',').map((k: string) => k.trim())
|
||||
: keywords.split(' ').filter((k: string) => k.length > 2).slice(0, 5);
|
||||
|
||||
const payload: BlogResearchRequest = {
|
||||
keywords: keywordList,
|
||||
industry: 'General',
|
||||
target_audience: 'General',
|
||||
word_count_target: parseInt(blogLength)
|
||||
};
|
||||
|
||||
const res = await blogWriterApi.research(payload);
|
||||
|
||||
// Notify parent component
|
||||
onResearchComplete?.(res);
|
||||
|
||||
const sourcesCount = res.sources?.length || 0;
|
||||
const queriesCount = res.search_queries?.length || 0;
|
||||
const anglesCount = res.suggested_angles?.length || 0;
|
||||
|
||||
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'
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Research failed: ${error}`);
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Research failed: ${error}. Please try again with different keywords.`
|
||||
};
|
||||
}
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
console.log('performResearch 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 #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' }}>• 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' }}>• Generating content angles and search queries...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the CopilotKit action, no UI
|
||||
};
|
||||
|
||||
export default KeywordInputForm;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
export const RegisterBlogWriterActions: React.FC = () => {
|
||||
useCopilotActionTyped({
|
||||
name: 'Generate All Sections of Outline',
|
||||
description: 'Generate content for every section in the current outline',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
// Frontend-only placeholder; generation handled via individual actions in UI for now
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default RegisterBlogWriterActions;
|
||||
|
||||
|
||||
108
frontend/src/components/BlogWriter/ResearchAction.tsx
Normal file
108
frontend/src/components/BlogWriter/ResearchAction.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
interface ResearchActionProps {
|
||||
onResearchComplete?: (research: BlogResearchResponse) => void;
|
||||
}
|
||||
|
||||
export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComplete }) => {
|
||||
useCopilotActionTyped({
|
||||
name: 'researchTopic',
|
||||
description: 'Research topic with keywords and persona context using Google Search grounding',
|
||||
parameters: [
|
||||
{ name: 'keywords', type: 'string', description: 'Comma-separated keywords or topic description', required: true },
|
||||
{ name: 'industry', type: 'string', description: 'Industry', required: false },
|
||||
{ name: 'target_audience', type: 'string', description: 'Target audience', required: false },
|
||||
{ name: 'blogLength', type: 'string', description: 'Target blog length in words', required: false }
|
||||
],
|
||||
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
|
||||
const keywordList = keywords.includes(',')
|
||||
? keywords.split(',').map(k => k.trim())
|
||||
: keywords.split(' ').filter(k => k.length > 2).slice(0, 5); // Extract up to 5 meaningful words
|
||||
|
||||
const payload: BlogResearchRequest = {
|
||||
keywords: keywordList,
|
||||
industry: industry || 'General',
|
||||
target_audience: target_audience || 'General',
|
||||
word_count_target: blogLength ? parseInt(blogLength) : 1000
|
||||
};
|
||||
|
||||
const res = await blogWriterApi.research(payload);
|
||||
|
||||
// Notify parent component
|
||||
onResearchComplete?.(res);
|
||||
|
||||
// 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;
|
||||
|
||||
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'
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Research failed: ${error}`);
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Research failed: ${error}. The AI research system encountered an issue. Please try again with different keywords or contact support if the problem persists.`
|
||||
};
|
||||
}
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
if (status === 'inProgress') {
|
||||
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' }}>• 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' }}>• Generating content angles and search queries...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the CopilotKit action, no UI
|
||||
};
|
||||
|
||||
export default ResearchAction;
|
||||
351
frontend/src/components/BlogWriter/ResearchResults.tsx
Normal file
351
frontend/src/components/BlogWriter/ResearchResults.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
|
||||
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 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';
|
||||
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>
|
||||
<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%',
|
||||
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>
|
||||
);
|
||||
|
||||
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'
|
||||
}}>
|
||||
<span style={{ color: '#666', marginRight: '8px' }}>{index + 1}.</span>
|
||||
{query}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSearchWidget = () => {
|
||||
if (!research.search_widget) 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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '16px 0'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
backgroundColor: '#f8f9fa'
|
||||
}}>
|
||||
<h2 style={{ margin: 0, color: '#333', fontSize: '18px' }}>
|
||||
📊 Research Results
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === 'sources' && renderSources()}
|
||||
{activeTab === 'keywords' && renderKeywordAnalysis()}
|
||||
{activeTab === 'angles' && renderContentAngles()}
|
||||
{activeTab === 'queries' && renderSearchQueries()}
|
||||
|
||||
{/* Search Widget */}
|
||||
{renderSearchWidget()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchResults;
|
||||
25
frontend/src/components/BlogWriter/SEOMiniPanel.tsx
Normal file
25
frontend/src/components/BlogWriter/SEOMiniPanel.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { BlogSEOAnalyzeResponse } from '../../services/blogWriterApi';
|
||||
|
||||
interface Props {
|
||||
analysis?: BlogSEOAnalyzeResponse | null;
|
||||
}
|
||||
|
||||
const SEOMiniPanel: React.FC<Props> = ({ analysis }) => {
|
||||
if (!analysis) return null;
|
||||
return (
|
||||
<div style={{ border: '1px solid #eee', padding: 8, marginTop: 8 }}>
|
||||
<div style={{ fontWeight: 600 }}>SEO Mini Panel</div>
|
||||
<div>Score: {analysis.seo_score}</div>
|
||||
{!!analysis.recommendations?.length && (
|
||||
<ul>
|
||||
{analysis.recommendations.slice(0, 3).map((r, i) => (<li key={i}>{r}</li>))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SEOMiniPanel;
|
||||
|
||||
|
||||
195
frontend/src/components/BlogWriter/TitleSelector.tsx
Normal file
195
frontend/src/components/BlogWriter/TitleSelector.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface TitleSelectorProps {
|
||||
titleOptions: string[];
|
||||
selectedTitle?: string;
|
||||
onTitleSelect: (title: string) => void;
|
||||
onCustomTitle?: (title: string) => void;
|
||||
}
|
||||
|
||||
const TitleSelector: React.FC<TitleSelectorProps> = ({
|
||||
titleOptions,
|
||||
selectedTitle,
|
||||
onTitleSelect,
|
||||
onCustomTitle
|
||||
}) => {
|
||||
const [showCustomInput, setShowCustomInput] = useState(false);
|
||||
const [customTitle, setCustomTitle] = useState('');
|
||||
|
||||
const handleCustomTitleSubmit = () => {
|
||||
if (customTitle.trim() && onCustomTitle) {
|
||||
onCustomTitle(customTitle.trim());
|
||||
setCustomTitle('');
|
||||
setShowCustomInput(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
padding: '20px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0', color: '#333', fontSize: '18px' }}>
|
||||
📝 Choose Your Blog Title
|
||||
</h3>
|
||||
<p style={{ margin: '0 0 20px 0', color: '#666', fontSize: '14px' }}>
|
||||
Select from AI-generated options or create your own custom title.
|
||||
</p>
|
||||
|
||||
{/* AI-Generated Title Options */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '14px', color: '#333', fontWeight: '600' }}>
|
||||
AI-Generated Options
|
||||
</h4>
|
||||
<div style={{ display: 'grid', gap: '8px' }}>
|
||||
{titleOptions.map((title, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => onTitleSelect(title)}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
border: selectedTitle === title ? '2px solid #1976d2' : '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: selectedTitle === title ? '#f0f8ff' : 'white',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '14px',
|
||||
color: '#333'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
e.currentTarget.style.backgroundColor = '#f8f9fa';
|
||||
e.currentTarget.style.borderColor = '#1976d2';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
e.currentTarget.style.backgroundColor = 'white';
|
||||
e.currentTarget.style.borderColor = '#e0e0e0';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{selectedTitle === title && (
|
||||
<span style={{ color: '#1976d2', fontSize: '16px' }}>✓</span>
|
||||
)}
|
||||
<span style={{ fontWeight: selectedTitle === title ? '600' : '400' }}>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Title Input */}
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '14px', color: '#333', fontWeight: '600' }}>
|
||||
Custom Title
|
||||
</h4>
|
||||
|
||||
{!showCustomInput ? (
|
||||
<button
|
||||
onClick={() => setShowCustomInput(true)}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px dashed #1976d2',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#1976d2',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
✏️ Create Custom Title
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={customTitle}
|
||||
onChange={(e) => setCustomTitle(e.target.value)}
|
||||
placeholder="Enter your custom title..."
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none'
|
||||
}}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCustomTitleSubmit();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleCustomTitleSubmit}
|
||||
disabled={!customTitle.trim()}
|
||||
style={{
|
||||
backgroundColor: customTitle.trim() ? '#1976d2' : '#f5f5f5',
|
||||
color: customTitle.trim() ? 'white' : '#999',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
cursor: customTitle.trim() ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCustomInput(false);
|
||||
setCustomTitle('');
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#666'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title Tips */}
|
||||
<div style={{
|
||||
marginTop: '20px',
|
||||
padding: '12px 16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#333', fontWeight: '600' }}>
|
||||
💡 Title Tips
|
||||
</h5>
|
||||
<ul style={{ margin: 0, paddingLeft: '16px', fontSize: '12px', color: '#666' }}>
|
||||
<li>Keep it under 60 characters for better SEO</li>
|
||||
<li>Include your primary keyword naturally</li>
|
||||
<li>Make it compelling and click-worthy</li>
|
||||
<li>Consider your target audience</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TitleSelector;
|
||||
Reference in New Issue
Block a user