Added blog writer implementation - WIP

This commit is contained in:
ajaysi
2025-09-12 10:26:08 +05:30
parent 1b65a9487b
commit c0a366269d
38 changed files with 4948 additions and 98 deletions

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

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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;

View 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&#10;Subheading 2&#10;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&#10;Key point 2&#10;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;

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

View File

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

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

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

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

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