diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/BRAVE_SEARCH_IMPLEMENTATION_PLAN.md b/BRAVE_SEARCH_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..3463d17 --- /dev/null +++ b/BRAVE_SEARCH_IMPLEMENTATION_PLAN.md @@ -0,0 +1,1293 @@ +# WP Agentic Writer: Brave Search Integration Implementation Plan + +**Date:** January 29, 2026 +**Status:** Planning Phase +**Integration Type:** Seamless addition to existing plugin architecture + +--- + +## Executive Summary + +This document outlines the complete implementation plan for integrating **Brave Search API** into WP Agentic Writer as an alternative web search provider alongside the existing OpenRouter `:online` models. The integration follows the plugin's existing architecture patterns and provides users with flexible, cost-effective web research capabilities. + +**Key Design Principle:** Brave Search API is positioned as an **alternative provider** to OpenRouter's online models, giving users choice based on cost, performance, and feature requirements. + +--- + +## Table of Contents + +1. [Architecture Overview](#architecture-overview) +2. [Settings Integration](#settings-integration) +3. [Database Schema](#database-schema) +4. [Provider Architecture](#provider-architecture) +5. [REST API Endpoints](#rest-api-endpoints) +6. [Frontend Integration](#frontend-integration) +7. [Agent Integration](#agent-integration) +8. [Cost Tracking Integration](#cost-tracking-integration) +9. [Implementation Phases](#implementation-phases) +10. [File Structure](#file-structure) +11. [Testing Strategy](#testing-strategy) + +--- + +## Architecture Overview + +### Current Plugin Structure + +``` +wp-agentic-writer/ +├── includes/ +│ ├── class-openrouter-provider.php ← Existing AI provider +│ ├── class-gutenberg-sidebar.php ← Main REST API handler +│ ├── class-settings.php ← Settings management +│ ├── class-cost-tracker.php ← Cost tracking system +│ └── class-markdown-parser.php ← Content parsing +├── assets/ +│ └── js/ +│ └── sidebar.js ← Frontend React app +└── views/ + └── settings/ + └── tab-models.php ← Model settings UI +``` + +### Integration Points + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ SETTINGS PANEL │ +│ │ +│ API Configuration │ +│ ├── OpenRouter API Key [sk-or-v1-...] │ +│ └── Brave Search API Key [BSA...] ← NEW │ +│ │ +│ Web Search Provider │ +│ ├── ○ OpenRouter :online models (perplexity, etc) │ +│ └── ○ Brave Search API (independent index) ← NEW │ +│ │ +│ [Only show if Brave API key is set] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Settings Integration + +### 1. Settings Schema Extension + +**File:** `includes/class-settings.php` + +Add Brave Search settings to existing settings array: + +```php +// In sanitize_settings() method - line ~320 +$sanitized['openrouter_api_key'] = trim( $input['openrouter_api_key'] ?? '' ); + +// ADD NEW: +$sanitized['brave_api_key'] = trim( $input['brave_api_key'] ?? '' ); +$sanitized['brave_api_tier'] = sanitize_text_field( $input['brave_api_tier'] ?? 'base_ai' ); +$sanitized['web_search_provider'] = sanitize_text_field( $input['web_search_provider'] ?? 'openrouter' ); +$sanitized['brave_search_enabled'] = isset( $input['brave_search_enabled'] ) ? 1 : 0; +$sanitized['brave_cache_enabled'] = isset( $input['brave_cache_enabled'] ) ? 1 : 0; +$sanitized['brave_cache_duration_days'] = absint( $input['brave_cache_duration_days'] ?? 30 ); +$sanitized['brave_monthly_budget'] = floatval( $input['brave_monthly_budget'] ?? 50.00 ); +$sanitized['brave_include_citations'] = isset( $input['brave_include_citations'] ) ? 1 : 0; +``` + +### 2. Settings UI - API Keys Section + +**File:** `views/settings/tab-models.php` + +Add Brave API key field after OpenRouter API key (around line 480): + +```php + +
+ Brave Search API. Free tier: 2,000 queries/month.', 'wp-agentic-writer' ) ), + 'https://brave.com/search/api/' + ); ?> +
+Reuses search results for identical queries. Can save 40-60% on costs.
+How long to keep cached search results (1-90 days).
+Stop searches when monthly cost exceeds this amount.
+([\s\S]*?)<\/code>/i);
@@ -1924,7 +2054,7 @@
.replace(/"/g, '"');
}
}
-
+
// Handle table blocks - extract head and body from innerHTML
if (block.blockName === 'core/table' && block.innerHTML) {
const headMatch = block.innerHTML.match(/([\s\S]*?)<\/thead>/i);
@@ -1932,7 +2062,7 @@
if (headMatch || bodyMatch) {
attrs.head = [];
attrs.body = [];
-
+
// Parse thead rows
if (headMatch) {
const headRows = headMatch[1].match(/([\s\S]*?)<\/tr>/gi) || [];
@@ -1946,7 +2076,7 @@
if (cells.length > 0) attrs.head.push({ cells });
});
}
-
+
// Parse tbody rows
if (bodyMatch) {
const bodyRows = bodyMatch[1].match(/ ([\s\S]*?)<\/tr>/gi) || [];
@@ -1962,7 +2092,7 @@
}
}
}
-
+
// Handle button blocks from [CTA:...] syntax
if (block.blockName === 'core/buttons' || block.blockName === 'core/button') {
if (block.blockName === 'core/button') {
@@ -1971,7 +2101,7 @@
]);
}
}
-
+
if (block.innerBlocks && block.innerBlocks.length > 0) {
const innerBlocks = block.innerBlocks.map((innerBlock) => (
createBlocksFromSerialized(innerBlock)
@@ -3030,13 +3160,13 @@
// Check for Writing mode notes warning
if (agentMode === 'writing' && currentPlanRef.current) {
setInput('');
- setMessages(prev => [...prev,
- { role: 'user', content: userMessage },
- {
- role: 'system',
- type: 'info',
- content: '💡 Note: Messages in Writing mode are for discussion only. To modify the outline, switch to Planning mode.'
- }
+ setMessages(prev => [...prev,
+ { role: 'user', content: userMessage },
+ {
+ role: 'system',
+ type: 'info',
+ content: '💡 Note: Messages in Writing mode are for discussion only. To modify the outline, switch to Planning mode.'
+ }
]);
return;
}
@@ -3086,6 +3216,8 @@
setMessages([...messages, { role: 'user', content: userMessage }]);
setIsLoading(true);
+ // User message is NOT an AI suggestion - don't extract from user input
+
// Store for retry
lastChatRequestRef.current = { message: userMessage };
@@ -3173,24 +3305,37 @@
return newMessages;
});
}
- } else if (data.type === 'complete' && data.totalCost) {
- setCost({ ...cost, session: cost.session + data.totalCost });
+ } else if (data.type === 'complete') {
+ if (data.totalCost) {
+ setCost({ ...cost, session: cost.session + data.totalCost });
+ }
+ // Extract ALL focus keyword suggestions from AI response
+ setMessages(prev => {
+ const lastAssistantMsg = prev.filter(m => m.role === 'assistant').pop();
+ if (lastAssistantMsg && lastAssistantMsg.content) {
+ const suggestions = extractFocusKeywordSuggestions(lastAssistantMsg.content);
+ if (suggestions.length > 0) {
+ addFocusKeywordSuggestions(suggestions);
+ }
+ }
+ return prev;
+ });
}
} catch (parseError) {
console.error('Failed to parse streaming data:', line, parseError);
}
}
}
-
+
// Detect intent after chat completes
try {
const intentResult = await detectUserIntent(userMessage);
-
+
// Track intent detection cost
if (intentResult.cost > 0) {
setCost(prev => ({ ...prev, session: prev.session + intentResult.cost }));
}
-
+
if (intentResult.intent && intentResult.intent !== 'continue_chat') {
setMessages(prev => {
const newMessages = [...prev];
@@ -3210,7 +3355,7 @@
setMessages(prev => [...prev, {
role: 'system',
type: 'error',
- content: isRateLimit
+ content: isRateLimit
? 'Rate limit exceeded. Please wait a moment and try again.'
: 'Error: ' + errorMsg,
canRetry: true,
@@ -3525,12 +3670,12 @@
} else if (data.type === 'error') {
clearTimeout(timeout);
setMessages(prev => [...prev, {
- role: 'system',
- type: 'error',
- content: data.message || 'An error occurred during article generation',
- canRetry: true
- }]);
- setIsLoading(false);
+ role: 'system',
+ type: 'error',
+ content: data.message || 'An error occurred during article generation',
+ canRetry: true
+ }]);
+ setIsLoading(false);
}
} catch (parseError) {
console.error('Failed to parse streaming data:', line, parseError);
@@ -3754,14 +3899,27 @@
return newMessages;
});
- // Trigger duplicate cleanup
- setTimeout(() => {
- const allBlocks = select('core/block-editor').getBlocks();
- const cleanedBlocks = removeDuplicateHeadings(allBlocks);
- if (cleanedBlocks.length < allBlocks.length) {
- dispatch('core/block-editor').resetBlocks(cleanedBlocks);
- }
- }, 500);
+ // Check for image placeholders and open modal if found
+ if (agentMode !== 'planning') {
+ setTimeout(() => {
+ const blocks = select('core/block-editor').getBlocks();
+ const imagePlaceholders = blocks.filter(
+ block => block.name === 'core/image' &&
+ block.attributes['data-agent-image-id']
+ );
+
+ if (imagePlaceholders.length > 0) {
+ window.dispatchEvent(
+ new CustomEvent('wpaw:open-image-review-modal', {
+ detail: {
+ postId: postId,
+ imageCount: imagePlaceholders.length
+ }
+ })
+ );
+ }
+ }, 500);
+ }
} else if (data.type === 'error') {
throw new Error(data.message);
}
@@ -3808,7 +3966,7 @@
if (answers.config_all) {
try {
const configData = JSON.parse(answers.config_all);
-
+
// Apply config to post config
if (configData.web_search !== undefined) {
updatePostConfig('web_search', configData.web_search);
@@ -4182,7 +4340,7 @@
configData = {};
}
}
-
+
// Set defaults from field definitions if not already set
const fields = currentQuestion.fields || [];
fields.forEach(field => {
@@ -4208,7 +4366,7 @@
}
return wp.element.createElement('div', { key: idx, className: 'wpaw-config-field' },
- field.type === 'toggle' ?
+ field.type === 'toggle' ?
wp.element.createElement(React.Fragment, null,
wp.element.createElement('label', { className: 'wpaw-config-label' },
wp.element.createElement('span', { className: 'wpaw-config-label-text' }, field.label),
@@ -4229,26 +4387,26 @@
wp.element.createElement('span', { className: 'wpaw-toggle-slider' })
)
)
- : wp.element.createElement(React.Fragment, null,
- wp.element.createElement('label', { className: 'wpaw-config-label' },
- wp.element.createElement('span', { className: 'wpaw-config-label-text' }, field.label),
- field.description && wp.element.createElement('span', { className: 'wpaw-config-description' }, field.description)
- ),
- wp.element.createElement('input', {
- type: 'text',
- className: 'wpaw-config-text-input',
- placeholder: field.placeholder || '',
- value: fieldValue || '',
- maxLength: field.max_length || 200,
- onChange: (e) => {
- const newConfig = { ...configData };
- newConfig[field.id] = e.target.value;
- const newAnswers = { ...answers };
- newAnswers[currentQuestion.id] = JSON.stringify(newConfig);
- setAnswers(newAnswers);
- }
- })
- )
+ : wp.element.createElement(React.Fragment, null,
+ wp.element.createElement('label', { className: 'wpaw-config-label' },
+ wp.element.createElement('span', { className: 'wpaw-config-label-text' }, field.label),
+ field.description && wp.element.createElement('span', { className: 'wpaw-config-description' }, field.description)
+ ),
+ wp.element.createElement('input', {
+ type: 'text',
+ className: 'wpaw-config-text-input',
+ placeholder: field.placeholder || '',
+ value: fieldValue || '',
+ maxLength: field.max_length || 200,
+ onChange: (e) => {
+ const newConfig = { ...configData };
+ newConfig[field.id] = e.target.value;
+ const newAnswers = { ...answers };
+ newAnswers[currentQuestion.id] = JSON.stringify(newConfig);
+ setAnswers(newAnswers);
+ }
+ })
+ )
);
})
);
@@ -4288,40 +4446,84 @@
wp.element.createElement('h4', null, currentQuestion.question),
answerInput,
wp.element.createElement('div', { className: 'wpaw-quiz-actions' },
- // Previous button
- currentQuestionIndex > 0 && wp.element.createElement(Button, {
- isSecondary: true,
- onClick: () => setCurrentQuestionIndex(currentQuestionIndex - 1),
- disabled: isLoading,
- }, 'Previous'),
- // Skip button for optional questions
- wp.element.createElement(Button, {
- isSecondary: true,
- onClick: () => {
- const newAnswers = { ...answers };
- newAnswers[currentQuestion.id] = '__skipped__';
- setAnswers(newAnswers);
- if (currentQuestionIndex === questions.length - 1) {
- submitAnswers();
- } else {
- setCurrentQuestionIndex(currentQuestionIndex + 1);
+ // Previous button
+ currentQuestionIndex > 0 && wp.element.createElement(Button, {
+ isSecondary: true,
+ onClick: () => setCurrentQuestionIndex(currentQuestionIndex - 1),
+ disabled: isLoading,
+ }, 'Previous'),
+ // Skip button for optional questions
+ wp.element.createElement(Button, {
+ isSecondary: true,
+ onClick: () => {
+ const newAnswers = { ...answers };
+ newAnswers[currentQuestion.id] = '__skipped__';
+ setAnswers(newAnswers);
+ if (currentQuestionIndex === questions.length - 1) {
+ submitAnswers();
+ } else {
+ setCurrentQuestionIndex(currentQuestionIndex + 1);
+ }
+ },
+ disabled: isLoading,
+ }, 'Skip'),
+ // Continue/Finish button
+ wp.element.createElement(Button, {
+ isPrimary: true,
+ onClick: () => {
+ if (currentQuestionIndex === questions.length - 1) {
+ submitAnswers();
+ } else {
+ setCurrentQuestionIndex(currentQuestionIndex + 1);
+ }
+ },
+ disabled: isLoading || (!currentAnswer.trim() && currentAnswer !== '__custom__'),
+ }, currentQuestionIndex === questions.length - 1 ? 'Finish' : 'Next')
+ )
+ )
+ );
+ };
+
+ // Render Welcome Screen (chatty, friendly)
+ const renderWelcomeScreen = () => {
+ return wp.element.createElement('div', { className: 'wpaw-welcome-screen' },
+ wp.element.createElement('div', { className: 'wpaw-welcome-content' },
+ wp.element.createElement('span', {
+ className: 'wpaw-welcome-icon',
+ dangerouslySetInnerHTML: { __html: '' }
+ }),
+ wp.element.createElement('h2', { className: 'wpaw-welcome-title' }, 'Welcome to Agentic Writer'),
+ wp.element.createElement('p', { className: 'wpaw-welcome-subtitle' }, "What's your concern today?"),
+ // Focus keyword input
+ wp.element.createElement('input', {
+ type: 'text',
+ className: 'wpaw-welcome-input',
+ placeholder: 'Your focus keyword (optional)',
+ value: welcomeKeywordInput,
+ onChange: (e) => setWelcomeKeywordInput(e.target.value),
+ onKeyDown: (e) => {
+ if (e.key === 'Enter') {
+ handleWelcomeStart();
}
- },
- disabled: isLoading,
- }, 'Skip'),
- // Continue/Finish button
+ }
+ }),
+ // Mode pills
+ wp.element.createElement('div', { className: 'wpaw-welcome-pills' },
+ wp.element.createElement('button', {
+ className: 'wpaw-welcome-pill' + (welcomeStartMode === 'chat' ? ' active' : ''),
+ onClick: () => setWelcomeStartMode('chat')
+ }, '💬 Chat First'),
+ wp.element.createElement('button', {
+ className: 'wpaw-welcome-pill' + (welcomeStartMode === 'planning' ? ' active' : ''),
+ onClick: () => setWelcomeStartMode('planning')
+ }, '📝 Make an Outline')
+ ),
+ // Start button
wp.element.createElement(Button, {
isPrimary: true,
- onClick: () => {
- if (currentQuestionIndex === questions.length - 1) {
- submitAnswers();
- } else {
- setCurrentQuestionIndex(currentQuestionIndex + 1);
- }
- },
- disabled: isLoading || (!currentAnswer.trim() && currentAnswer !== '__custom__'),
- }, currentQuestionIndex === questions.length - 1 ? 'Finish' : 'Next')
- )
+ onClick: handleWelcomeStart,
+ className: 'wpaw-welcome-start-btn'
+ }, 'Start')
)
);
};
@@ -4330,12 +4532,12 @@
const renderWritingEmptyState = () => {
return wp.element.createElement('div', { className: 'wpaw-writing-empty-state' },
wp.element.createElement('div', { className: 'wpaw-empty-state-content' },
- wp.element.createElement('span', {
+ wp.element.createElement('span', {
className: 'wpaw-empty-state-icon',
- dangerouslySetInnerHTML: { __html: '' }
- }),
- wp.element.createElement('h3', null, 'No Outline Yet'),
- wp.element.createElement('p', null, 'Writing mode requires an outline to structure your article.'),
+ dangerouslySetInnerHTML: { __html: '' }
+ }),
+ wp.element.createElement('h3', null, 'Create an Outline First'),
+ wp.element.createElement('p', null, 'Before writing, you need to create an outline to structure your article. This ensures better content organization and prevents wasted costs.'),
wp.element.createElement(Button, {
isPrimary: true,
onClick: () => setAgentMode('planning'),
@@ -4359,70 +4561,148 @@
d: "M16 5H3m13 7H3m8 7H3m12-1l2 2l4-4"
})
),
- 'Create Outline First'
+ 'Switch to Planning Mode'
)
),
- wp.element.createElement('p', { className: 'wpaw-empty-state-hint' },
- 'Or switch to ',
- wp.element.createElement('button', {
- onClick: () => setAgentMode('chat'),
- className: 'wpaw-link-button'
- }, 'Chat mode'),
- ' to discuss your ideas.'
+ wp.element.createElement('p', { className: 'wpaw-empty-state-hint', style: { marginTop: '16px', fontSize: '13px', color: '#a7aaad' } },
+ '💡 Tip: Planning mode helps you brainstorm and structure your content before writing.'
)
)
);
};
- // Render context indicator
- const renderContextIndicator = () => {
- const chatMessages = messages.filter(m => m.role !== 'system');
- const messageCount = chatMessages.length;
- const estimatedTokens = messageCount * 500;
-
- // if (messageCount === 0) return null;
-
- return wp.element.createElement('div', { className: 'wpaw-context-indicator' },
- wp.element.createElement('div', { className: 'wpaw-context-info' },
- wp.element.createElement('span', { className: 'wpaw-context-count' },
- `💬 ${messageCount} messages`
+ // Render Focus Keyword Bar (replaces context indicator)
+ const renderFocusKeywordBar = () => {
+ const hasKeyword = selectedFocusKeyword && selectedFocusKeyword.length > 0;
+
+ // Expanded mode
+ if (isTextareaExpanded) {
+ return wp.element.createElement('div', { className: 'wpaw-focus-keyword-bar wpaw-expanded' },
+ // Header
+ wp.element.createElement('div', { className: 'wpaw-fk-header' },
+ wp.element.createElement('span', null, '🎯 FOCUS KEYWORD'),
+ wp.element.createElement('button', {
+ className: 'wpaw-fk-collapse',
+ onClick: () => setIsTextareaExpanded(false),
+ title: 'Collapse'
+ }, '↓')
),
- wp.element.createElement('span', { className: 'wpaw-context-tokens' },
- `~${estimatedTokens} tokens`
+ // Main input - always show input field in expanded mode
+ wp.element.createElement('div', { className: 'wpaw-fk-main-input' },
+ wp.element.createElement('input', {
+ type: 'text',
+ className: 'wpaw-fk-custom-input',
+ placeholder: hasKeyword ? 'Edit focus keyword...' : 'Enter focus keyword...',
+ value: selectedFocusKeyword || '',
+ onChange: (e) => {
+ const value = e.target.value;
+ setSelectedFocusKeyword(value);
+ },
+ onBlur: (e) => {
+ // Save on blur
+ if (e.target.value !== postConfig.focus_keyword) {
+ handleFocusKeywordChange(e.target.value);
+ }
+ },
+ onKeyDown: (e) => {
+ if (e.key === 'Enter' && e.target.value.trim()) {
+ handleFocusKeywordChange(e.target.value.trim());
+ e.target.blur();
+ }
+ }
+ })
),
- wp.element.createElement('span', { className: 'wpaw-context-cost' },
- `💰 $${cost.session.toFixed(4)}`
+ // Suggestions list
+ focusKeywordSuggestions.length > 0 && wp.element.createElement('div', { className: 'wpaw-fk-suggestions' },
+ wp.element.createElement('div', { className: 'wpaw-fk-suggestions-label' }, '📝 AI Suggestions:'),
+ focusKeywordSuggestions.map((kw, i) =>
+ wp.element.createElement('div', {
+ key: i,
+ className: 'wpaw-fk-suggestion-item' + (kw === selectedFocusKeyword ? ' selected' : ''),
+ onClick: () => handleFocusKeywordChange(kw)
+ },
+ wp.element.createElement('span', { className: 'wpaw-fk-radio' },
+ kw === selectedFocusKeyword ? '●' : '○'
+ ),
+ wp.element.createElement('span', { className: 'wpaw-fk-suggestion-text' }, kw),
+ wp.element.createElement('span', { className: 'wpaw-fk-suggestion-source' },
+ `(#${i + 1})`
+ )
+ )
+ )
+ ),
+ // Stats
+ wp.element.createElement('div', { className: 'wpaw-fk-stats' },
+ wp.element.createElement('span', null, `💰 $${(cost.session || 0).toFixed(4)}`),
+ wp.element.createElement('span', { className: 'wpaw-fk-divider' }, '│'),
+ wp.element.createElement('span', null, `📊 ~${messages.filter(m => m.role !== 'system').length * 500} tokens`)
)
+ );
+ }
+
+ // Compact mode (default) - use input instead of dropdown
+ return wp.element.createElement('div', { className: 'wpaw-focus-keyword-bar wpaw-compact' },
+ wp.element.createElement('div', { className: 'wpaw-fk-left' },
+ wp.element.createElement('span', { className: 'wpaw-fk-icon' }, '🎯'),
+ wp.element.createElement('input', {
+ type: 'text',
+ className: 'wpaw-fk-input',
+ placeholder: 'Enter focus keyword...',
+ value: selectedFocusKeyword || '',
+ onChange: (e) => {
+ const value = e.target.value;
+ setSelectedFocusKeyword(value);
+ // Debounce save to config
+ if (configSaveTimeoutRef.current) {
+ clearTimeout(configSaveTimeoutRef.current);
+ }
+ configSaveTimeoutRef.current = setTimeout(() => {
+ handleFocusKeywordChange(value);
+ }, 500);
+ },
+ onBlur: (e) => {
+ // Save immediately on blur
+ if (e.target.value !== postConfig.focus_keyword) {
+ handleFocusKeywordChange(e.target.value);
+ }
+ },
+ disabled: isLoading
+ })
+ ),
+ wp.element.createElement('span', { className: 'wpaw-fk-cost' },
+ `$${(cost.session || 0).toFixed(4)}`
),
wp.element.createElement('button', {
- className: 'wpaw-context-toggle',
- onClick: () => setIsTextareaExpanded(!isTextareaExpanded),
- title: isTextareaExpanded ? 'Collapse textarea' : 'Expand textarea'
+ className: 'wpaw-fk-expand',
+ onClick: () => setIsTextareaExpanded(true),
+ title: 'Expand'
},
wp.element.createElement('svg', {
xmlns: "http://www.w3.org/2000/svg",
- width: "18",
- height: "18",
- viewBox: "0 0 24 24",
- style: { verticalAlign: 'middle', marginBottom: '0' }
+ width: "16",
+ height: "16",
+ viewBox: "0 0 24 24"
},
wp.element.createElement('path', {
fill: "none",
stroke: "currentColor",
strokeLinecap: "round",
strokeLinejoin: "round",
- strokeWidth: "1",
- d: isTextareaExpanded ? "m7 20l5-5l5 5M7 4l5 5l5-5" : "m7 15l5 5l5-5M7 9l5-5l5 5"
+ strokeWidth: "1.5",
+ d: "m7 15l5 5l5-5M7 9l5-5l5 5"
})
)
)
);
};
+ // Keep old function name for backward compatibility
+ const renderContextIndicator = renderFocusKeywordBar;
+
// Render contextual action card
const renderContextualAction = (intent) => {
if (!intent || intent === 'continue_chat') return null;
-
+
const actions = {
create_outline: {
icon: '📝',
@@ -4430,18 +4710,205 @@
description: 'I\'ll generate a structured outline based on our conversation.',
button: 'Create Outline Now',
onClick: async () => {
- // Switch to planning mode first
+ // Switch to planning mode
setAgentMode('planning');
-
- // Set the input message
- const outlineMessage = 'Create an outline based on our discussion';
- setInput(outlineMessage);
-
- // Trigger the send message function directly with planning mode
- // This ensures proper flow through detect_intent -> clarity_check -> generate_plan
- setTimeout(() => {
- sendMessage();
- }, 100);
+
+ // Get topic from focus keyword or chat history
+ const focusKw = selectedFocusKeyword || postConfig.focus_keyword || postConfig.seo_focus_keyword;
+ const firstUserMsg = messages.find(m => m.role === 'user');
+ const topic = focusKw || (firstUserMsg ? firstUserMsg.content.substring(0, 100) : '');
+
+ // Don't add any user message - directly trigger outline generation
+ setInput('');
+ setIsLoading(true);
+
+ // Add timeline entry
+ setMessages(prev => [...deactivateActiveTimelineEntries(prev), {
+ role: 'system',
+ type: 'timeline',
+ status: 'checking',
+ message: 'Analyzing request...',
+ timestamp: new Date()
+ }]);
+
+ // Call clarity check - MANDATORY before outline generation
+ try {
+ console.log('[WPAW] Calling clarity check with topic:', topic);
+ const clarityResponse = await fetch(wpAgenticWriter.apiUrl + '/check-clarity', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-WP-Nonce': wpAgenticWriter.nonce,
+ },
+ body: JSON.stringify({
+ topic: topic || 'article outline',
+ answers: [],
+ postId: postId,
+ mode: 'generation',
+ postConfig: postConfig,
+ chatHistory: messages.filter(m => m.role === 'user' || m.role === 'assistant').slice(-10),
+ }),
+ });
+
+ console.log('[WPAW] Clarity response status:', clarityResponse.status);
+
+ if (!clarityResponse.ok) {
+ const errorText = await clarityResponse.text();
+ console.error('[WPAW] Clarity check failed:', errorText);
+ throw new Error('Clarity check failed: ' + errorText);
+ }
+
+ const clarityData = await clarityResponse.json();
+ const clarityResult = clarityData.result;
+ console.log('[WPAW] Clarity result:', clarityResult);
+
+ if (clarityResult.detected_language) {
+ setDetectedLanguage(clarityResult.detected_language);
+ }
+
+ // MANDATORY: Always show quiz if questions exist
+ if (clarityResult.questions && clarityResult.questions.length > 0) {
+ console.log('[WPAW] Showing quiz with', clarityResult.questions.length, 'questions');
+ setQuestions(clarityResult.questions);
+ setInClarification(true);
+ setCurrentQuestionIndex(0);
+ setAnswers([]);
+ setIsLoading(false);
+
+ setMessages(prev => {
+ const newMessages = [...prev];
+ const lastTimelineIndex = findLastActiveTimelineIndex(newMessages);
+ if (lastTimelineIndex !== -1) {
+ newMessages[lastTimelineIndex] = {
+ ...newMessages[lastTimelineIndex],
+ status: 'waiting',
+ message: 'Waiting for clarification...'
+ };
+ }
+ return newMessages;
+ });
+ return; // Stop here - quiz must be completed first
+ } else {
+ console.warn('[WPAW] No questions returned from clarity check!');
+ }
+ } catch (clarityError) {
+ console.error('[WPAW] Clarity check error:', clarityError);
+ // Show error to user instead of silently proceeding
+ setMessages(prev => [...prev, {
+ role: 'system',
+ type: 'error',
+ content: 'Clarity check failed. Please try again.',
+ canRetry: true
+ }]);
+ setIsLoading(false);
+ return; // Don't proceed without clarity check
+ }
+
+ // Proceed with plan generation
+ setMessages(prev => {
+ const newMessages = [...prev];
+ const lastTimelineIndex = findLastActiveTimelineIndex(newMessages);
+ if (lastTimelineIndex !== -1) {
+ newMessages[lastTimelineIndex] = {
+ ...newMessages[lastTimelineIndex],
+ status: 'starting',
+ message: 'Creating outline...'
+ };
+ }
+ return newMessages;
+ });
+
+ try {
+ const response = await fetch(wpAgenticWriter.apiUrl + '/generate-plan', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-WP-Nonce': wpAgenticWriter.nonce,
+ },
+ body: JSON.stringify({
+ topic: topic || 'article outline',
+ context: '',
+ postId: postId,
+ answers: [],
+ autoExecute: false,
+ stream: true,
+ articleLength: postConfig.article_length,
+ detectedLanguage: detectedLanguage,
+ postConfig: postConfig,
+ chatHistory: messages.filter(m => m.role === 'user' || m.role === 'assistant').slice(-10),
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ setMessages(prev => [...prev, {
+ role: 'system',
+ type: 'error',
+ content: 'Error: ' + (error.message || 'Failed to generate outline'),
+ canRetry: true
+ }]);
+ setIsLoading(false);
+ return;
+ }
+
+ // Handle streaming response
+ streamTargetRef.current = null;
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ const chunk = decoder.decode(value, { stream: true });
+ const lines = chunk.split('\n');
+
+ for (const line of lines) {
+ if (line.startsWith('data: ')) {
+ try {
+ const data = JSON.parse(line.slice(6));
+
+ if (data.type === 'plan') {
+ setCost(prev => ({ ...prev, session: prev.session + (data.cost || 0) }));
+ if (data.plan) {
+ updateOrCreatePlanMessage(data.plan);
+ }
+ } else if (data.type === 'status') {
+ if (data.status === 'complete') {
+ continue;
+ }
+ setMessages(prev => {
+ const newMessages = [...prev];
+ const lastTimelineIndex = findLastActiveTimelineIndex(newMessages);
+ if (lastTimelineIndex !== -1) {
+ newMessages[lastTimelineIndex] = {
+ ...newMessages[lastTimelineIndex],
+ status: data.status,
+ message: data.message,
+ icon: data.icon
+ };
+ }
+ return newMessages;
+ });
+ }
+ } catch (parseError) {
+ console.error('Failed to parse streaming data:', parseError);
+ }
+ }
+ }
+ }
+
+ setIsLoading(false);
+ } catch (error) {
+ const errorMsg = error.message || 'Failed to generate outline';
+ setMessages(prev => [...prev, {
+ role: 'system',
+ type: 'error',
+ content: 'Error: ' + errorMsg,
+ canRetry: true
+ }]);
+ setIsLoading(false);
+ }
}
},
start_writing: {
@@ -4457,22 +4924,22 @@
}
}
};
-
- const action = actions[intent];
- if (!action) return null;
-
- return wp.element.createElement('div', { className: 'wpaw-contextual-action' },
- wp.element.createElement('div', { className: 'wpaw-action-icon' }, action.icon),
- wp.element.createElement('div', { className: 'wpaw-action-content' },
- wp.element.createElement('h4', null, action.title),
- wp.element.createElement('p', null, action.description),
- wp.element.createElement(Button, {
- isPrimary: true,
- onClick: action.onClick
- }, action.button)
- )
- );
- };
+
+ const action = actions[intent];
+ if (!action) return null;
+
+ return wp.element.createElement('div', { className: 'wpaw-contextual-action' },
+ wp.element.createElement('div', { className: 'wpaw-action-icon' }, action.icon),
+ wp.element.createElement('div', { className: 'wpaw-action-content' },
+ wp.element.createElement('h4', null, action.title),
+ wp.element.createElement('p', null, action.description),
+ wp.element.createElement(Button, {
+ isPrimary: true,
+ onClick: action.onClick
+ }, action.button)
+ )
+ );
+ };
// Render chat messages with timeline
const renderMessages = () => {
@@ -4797,8 +5264,16 @@
wp.element.createElement('span', null, 'Processing updates…')
),
!showProcessing && isLoading && isLastGroup && isLastItem && wp.element.createElement('div', {
- className: 'wpaw-streaming-indicator',
- }, streamingLabel)
+ className: 'wpaw-typing-indicator',
+ 'aria-label': 'Agent is typing',
+ },
+ streamingLabel,
+ wp.element.createElement('span', { className: 'wpaw-typing-dots' },
+ wp.element.createElement('span', null),
+ wp.element.createElement('span', null),
+ wp.element.createElement('span', null)
+ )
+ )
)
);
}
@@ -4821,13 +5296,13 @@
// Build config summary
const configSummary = [];
- const languageLabel = postConfig.language === 'auto' ? 'Auto-detect' :
+ const languageLabel = postConfig.language === 'auto' ? 'Auto-detect' :
postConfig.language.charAt(0).toUpperCase() + postConfig.language.slice(1);
configSummary.push(`🌍 Language: ${languageLabel}`);
-
+
const lengthLabels = { short: 'Short (~800 words)', medium: 'Medium (~1500 words)', long: 'Long (~2500 words)' };
configSummary.push(`📏 Length: ${lengthLabels[postConfig.article_length] || 'Medium'}`);
-
+
if (postConfig.audience) {
configSummary.push(`👥 Audience: ${postConfig.audience}`);
}
@@ -4988,8 +5463,16 @@
},
wp.element.createElement('div', { className: 'wpaw-response-content' }, renderMessageContent(message.content, true)),
isLoading && isLastGroup && isLastItem && wp.element.createElement('div', {
- className: 'wpaw-streaming-indicator',
- }, streamingLabel),
+ className: 'wpaw-typing-indicator',
+ 'aria-label': 'Agent is typing',
+ },
+ streamingLabel,
+ wp.element.createElement('span', { className: 'wpaw-typing-dots' },
+ wp.element.createElement('span', null),
+ wp.element.createElement('span', null),
+ wp.element.createElement('span', null)
+ )
+ ),
message.detectedIntent && renderContextualAction(message.detectedIntent),
message.showResumeActions && wp.element.createElement('div', { className: 'wpaw-resume-actions' },
wp.element.createElement(Button, {
@@ -5051,53 +5534,53 @@
),
wp.element.createElement('div', { className: 'wpaw-config-section' },
wp.element.createElement('label', null, 'ARTICLE LENGTH'),
- wp.element.createElement('select', {
- value: postConfig.article_length,
- onChange: (e) => updatePostConfig('article_length', e.target.value),
- disabled: isConfigDisabled,
- className: 'wpaw-select'
- },
- wp.element.createElement('option', { value: 'short' }, 'Short (500-800 words)'),
- wp.element.createElement('option', { value: 'medium' }, 'Medium (800-1500 words)'),
- wp.element.createElement('option', { value: 'long' }, 'Long (1500-2500 words)')
- )
- ),
- wp.element.createElement('div', { className: 'wpaw-config-section' },
- wp.element.createElement('label', null, 'Language'),
- wp.element.createElement('select', {
- value: postConfig.language,
- onChange: (e) => updatePostConfig('language', e.target.value),
- disabled: isConfigDisabled,
- className: 'wpaw-select'
- },
- (() => {
- const preferredLanguages = settings.preferred_languages || ['auto', 'English', 'Indonesian'];
- const customLanguages = settings.custom_languages || [];
- const allLanguages = [...preferredLanguages, ...customLanguages];
- return allLanguages.map((lang) => {
- const langLower = lang.toLowerCase();
- const displayName = lang === 'auto' ? 'Auto-detect' : lang;
- return wp.element.createElement('option', { key: langLower, value: langLower }, displayName);
- });
- })()
+ wp.element.createElement('select', {
+ value: postConfig.article_length,
+ onChange: (e) => updatePostConfig('article_length', e.target.value),
+ disabled: isConfigDisabled,
+ className: 'wpaw-select'
+ },
+ wp.element.createElement('option', { value: 'short' }, 'Short (500-800 words)'),
+ wp.element.createElement('option', { value: 'medium' }, 'Medium (800-1500 words)'),
+ wp.element.createElement('option', { value: 'long' }, 'Long (1500-2500 words)')
+ )
),
- wp.element.createElement('p', { className: 'description' },
- 'Overrides the detected language when writing or refining.'
- )
- ),
- wp.element.createElement('div', { className: 'wpaw-config-section' },
- wp.element.createElement(TextControl, {
- label: 'Tone',
- value: postConfig.tone,
- onChange: (value) => updatePostConfig('tone', value),
- disabled: isConfigDisabled,
- placeholder: 'e.g., Friendly, persuasive, professional',
- }),
- wp.element.createElement('p', { className: 'description' },
- 'Use this to consistently guide the writing tone.'
- )
- ),
- wp.element.createElement('div', { className: 'wpaw-config-section' },
+ wp.element.createElement('div', { className: 'wpaw-config-section' },
+ wp.element.createElement('label', null, 'Language'),
+ wp.element.createElement('select', {
+ value: postConfig.language,
+ onChange: (e) => updatePostConfig('language', e.target.value),
+ disabled: isConfigDisabled,
+ className: 'wpaw-select'
+ },
+ (() => {
+ const preferredLanguages = settings.preferred_languages || ['auto', 'English', 'Indonesian'];
+ const customLanguages = settings.custom_languages || [];
+ const allLanguages = [...preferredLanguages, ...customLanguages];
+ return allLanguages.map((lang) => {
+ const langLower = lang.toLowerCase();
+ const displayName = lang === 'auto' ? 'Auto-detect' : lang;
+ return wp.element.createElement('option', { key: langLower, value: langLower }, displayName);
+ });
+ })()
+ ),
+ wp.element.createElement('p', { className: 'description' },
+ 'Overrides the detected language when writing or refining.'
+ )
+ ),
+ wp.element.createElement('div', { className: 'wpaw-config-section' },
+ wp.element.createElement(TextControl, {
+ label: 'Tone',
+ value: postConfig.tone,
+ onChange: (value) => updatePostConfig('tone', value),
+ disabled: isConfigDisabled,
+ placeholder: 'e.g., Friendly, persuasive, professional',
+ }),
+ wp.element.createElement('p', { className: 'description' },
+ 'Use this to consistently guide the writing tone.'
+ )
+ ),
+ wp.element.createElement('div', { className: 'wpaw-config-section' },
wp.element.createElement('label', null, 'Experience Level'),
wp.element.createElement('select', {
value: postConfig.experience_level,
@@ -5183,7 +5666,7 @@
rows: 3,
}),
wp.element.createElement('div', { className: 'wpaw-meta-info' },
- wp.element.createElement('span', {
+ wp.element.createElement('span', {
className: (postConfig.seo_meta_description?.length || 0) >= 120 && (postConfig.seo_meta_description?.length || 0) <= 160 ? 'good' : 'warning'
}, `${postConfig.seo_meta_description?.length || 0}/160 chars`),
wp.element.createElement(Button, {
@@ -5191,30 +5674,30 @@
isSmall: true,
onClick: () => generateMetaDescription(),
disabled: isConfigDisabled || isGeneratingMeta,
- },
- isGeneratingMeta ?
- wp.element.createElement('span', {
- style: { display: 'flex', alignItems: 'center', gap: '5px' }
- },
+ },
+ isGeneratingMeta ?
wp.element.createElement('span', {
- className: 'wpaw-spinning-icon',
- dangerouslySetInnerHTML: {
- __html: ''
- }
- }),
- ' Generating...'
- ) :
- wp.element.createElement('span', {
- style: { display: 'flex', alignItems: 'center', gap: '5px' }
- },
+ style: { display: 'flex', alignItems: 'center', gap: '5px' }
+ },
+ wp.element.createElement('span', {
+ className: 'wpaw-spinning-icon',
+ dangerouslySetInnerHTML: {
+ __html: ''
+ }
+ }),
+ ' Generating...'
+ ) :
wp.element.createElement('span', {
- className: 'wpaw-svg-wrapper',
- dangerouslySetInnerHTML: {
- __html: ''
- }
- }),
- ' Generate'
- )
+ style: { display: 'flex', alignItems: 'center', gap: '5px' }
+ },
+ wp.element.createElement('span', {
+ className: 'wpaw-svg-wrapper',
+ dangerouslySetInnerHTML: {
+ __html: ''
+ }
+ }),
+ ' Generate'
+ )
)
)
),
@@ -5228,34 +5711,34 @@
isSmall: true,
onClick: () => runSeoAudit(),
disabled: isConfigDisabled || isSeoAuditing,
- },
- isSeoAuditing ?
- wp.element.createElement('span', {
- style: { display: 'flex', alignItems: 'center', gap: '5px' }
- },
+ },
+ isSeoAuditing ?
wp.element.createElement('span', {
- className: 'wpaw-spinning-icon',
- style: { display: 'inline-flex', lineHeight: '0' },
- dangerouslySetInnerHTML: {
- // Icon Loader/Circle-slashed untuk kesan analyzing
- __html: ''
- }
- }),
- ' Analyzing...'
- ) :
- wp.element.createElement('span', {
- style: { display: 'flex', alignItems: 'center', gap: '5px' }
- },
+ style: { display: 'flex', alignItems: 'center', gap: '5px' }
+ },
+ wp.element.createElement('span', {
+ className: 'wpaw-spinning-icon',
+ style: { display: 'inline-flex', lineHeight: '0' },
+ dangerouslySetInnerHTML: {
+ // Icon Loader/Circle-slashed untuk kesan analyzing
+ __html: ''
+ }
+ }),
+ ' Analyzing...'
+ ) :
wp.element.createElement('span', {
- className: 'wpaw-svg-wrapper',
- style: { display: 'inline-flex', lineHeight: '0' },
- dangerouslySetInnerHTML: {
- // Icon Bar-Chart untuk "Run Audit"
- __html: ''
- }
- }),
- ' Run Audit'
- )
+ style: { display: 'flex', alignItems: 'center', gap: '5px' }
+ },
+ wp.element.createElement('span', {
+ className: 'wpaw-svg-wrapper',
+ style: { display: 'inline-flex', lineHeight: '0' },
+ dangerouslySetInnerHTML: {
+ // Icon Bar-Chart untuk "Run Audit"
+ __html: ''
+ }
+ }),
+ ' Run Audit'
+ )
)
),
seoAudit && wp.element.createElement('div', { className: 'wpaw-seo-audit-results' },
@@ -5276,7 +5759,7 @@
seoAudit.checks && wp.element.createElement('div', { className: 'wpaw-seo-checks' },
seoAudit.checks.map((check, idx) => {
const isPassed = check.status === 'good' || check.status === 'ok';
- return wp.element.createElement('div', {
+ return wp.element.createElement('div', {
key: idx,
className: 'wpaw-seo-check ' + (isPassed ? 'passed' : 'failed')
},
@@ -5324,7 +5807,7 @@
renderClarification(),
!inClarification && wp.element.createElement('div', { className: 'wpaw-chat-container' },
// Status Bar
- wp.element.createElement('div', { className: 'wpaw-status-bar' },
+ wp.element.createElement('div', { className: 'wpaw-status-bar', role: 'status', 'aria-live': 'polite' },
wp.element.createElement('div', { className: 'wpaw-status-indicator' },
wp.element.createElement('span', { className: 'wpaw-status-dot ' + agentStatus }),
wp.element.createElement('span', { className: 'wpaw-status-label' }, statusLabels[agentStatus])
@@ -5338,9 +5821,9 @@
disabled: isLoading
}, '↩️'),
// Cost Label
- wp.element.createElement('span', { className: 'wpaw-status-cost' },
- 'Session: $' + cost.session.toFixed(4)
- ),
+ // wp.element.createElement('span', { className: 'wpaw-status-cost' },
+ // 'Session: $' + cost.session.toFixed(4)
+ // ),
// Config Icon Button
wp.element.createElement('button', {
className: 'wpaw-status-icon-btn',
@@ -5359,39 +5842,41 @@
),
// Editor Lock Banner
isEditorLocked && wp.element.createElement('div', { className: 'wpaw-editor-lock-banner' },
- 'Writing in progress — please wait until the article finishes.'
- ),
- // Writing Mode Empty State
- shouldShowWritingEmptyState() && renderWritingEmptyState(),
- // Activity Log
- !shouldShowWritingEmptyState() && wp.element.createElement('div', { className: 'wpaw-messages wpaw-activity-log' },
- wp.element.createElement('div', { className: 'wpaw-messages-inner', ref: messagesContainerRef },
- renderMessages(),
- wp.element.createElement('div', { ref: messagesEndRef })
- )
- ),
- // Context Indicator (moved above textarea)
- renderContextIndicator(),
- // Command Input Area
- wp.element.createElement('div', { className: 'wpaw-command-area', style: { position: 'relative' } },
- // Removed Toolbar from Top
- wp.element.createElement('div', {
- className: 'wpaw-command-input-wrapper' + (isTextareaExpanded ? ' expanded' : '')
- },
- wp.element.createElement('span', { className: 'wpaw-command-prefix' }, '>'),
- wp.element.createElement(TextareaControl, {
- ref: inputRef,
- value: input,
- onChange: handleInputChange,
- onKeyDown: handleKeyDown,
- rows: isTextareaExpanded ? 20 : 3,
- placeholder: agentMode === 'planning'
- ? 'Describe what you want to write about...'
- : agentMode === 'chat'
- ? 'Ask me anything about your content...'
- : 'Tell me what to write. Use @block to refine.'
- })
+ 'Writing in progress — please wait until the article finishes.'
),
+ // Welcome Screen (first time)
+ showWelcome && !isEditorLocked && renderWelcomeScreen(),
+ // Writing Mode Empty State
+ !showWelcome && shouldShowWritingEmptyState() && renderWritingEmptyState(),
+ // Activity Log
+ !showWelcome && !shouldShowWritingEmptyState() && wp.element.createElement('div', { className: 'wpaw-messages wpaw-activity-log' },
+ wp.element.createElement('div', { className: 'wpaw-messages-inner', ref: messagesContainerRef },
+ renderMessages(),
+ wp.element.createElement('div', { ref: messagesEndRef })
+ )
+ ),
+ // Context Indicator (moved above textarea) - hide when showing empty state or welcome
+ !showWelcome && !shouldShowWritingEmptyState() && renderContextIndicator(),
+ // Command Input Area - hide when showing empty state or welcome
+ !showWelcome && !shouldShowWritingEmptyState() && wp.element.createElement('div', { className: 'wpaw-command-area', style: { position: 'relative' } },
+ // Removed Toolbar from Top
+ wp.element.createElement('div', {
+ className: 'wpaw-command-input-wrapper' + (isTextareaExpanded ? ' expanded' : '')
+ },
+ wp.element.createElement('span', { className: 'wpaw-command-prefix' }, '>'),
+ wp.element.createElement(TextareaControl, {
+ ref: inputRef,
+ value: input,
+ onChange: handleInputChange,
+ onKeyDown: handleKeyDown,
+ rows: isTextareaExpanded ? 20 : 3,
+ placeholder: agentMode === 'planning'
+ ? 'Describe what you want to write about...'
+ : agentMode === 'chat'
+ ? 'Ask me anything about your content...'
+ : 'Tell me what to write. Use @block to refine.'
+ })
+ ),
showMentionAutocomplete && mentionOptions.length > 0 && wp.element.createElement('div', {
className: 'wpaw-mention-autocomplete',
style: {
@@ -5467,7 +5952,7 @@
wp.element.createElement('div', { className: 'wpaw-command-actions' },
wp.element.createElement('div', { className: 'wpaw-command-actions-group' },
-
+
// Mode Selector (Bottom Left)
wp.element.createElement('div', { className: 'wpaw-command-mode-wrapper' },
wp.element.createElement('span', { className: 'wpaw-command-label' }, 'MODE:'),
@@ -5485,22 +5970,44 @@
),
// Web Search Toggle (next to mode)
- wp.element.createElement('label', {
- className: 'wpaw-web-search-toggle',
- title: 'Enable web search for current data (costs ~$0.02/search)',
- },
- wp.element.createElement('input', {
- type: 'checkbox',
- checked: postConfig.web_search || false,
- onChange: (e) => updatePostConfig('web_search', e.target.checked),
- disabled: isLoading,
- }),
- wp.element.createElement('span', {
- className: 'wpaw-web-search-icon',
- dangerouslySetInnerHTML: { __html: '' }
- }),
- wp.element.createElement('span', { className: 'wpaw-web-search-label' }, 'Search')
- ),
+ (() => {
+ // Determine if web search is available for the current provider
+ const taskProviders = settings.task_providers || {};
+ const currentProvider = taskProviders[agentMode] || 'openrouter';
+ const isNonOpenRouter = currentProvider === 'local_backend' || currentProvider === 'codex';
+ const hasBraveKey = Boolean(settings.brave_search_api_key);
+ const searchBlocked = isNonOpenRouter && !hasBraveKey;
+ const tooltipText = searchBlocked
+ ? 'Web Search unavailable — Brave API Key required for ' + currentProvider.replace('_', ' ') + '. Configure in Settings > General.'
+ : isNonOpenRouter
+ ? 'Web search via Brave Search API (free tier: 2,000 req/mo)'
+ : 'Web search via OpenRouter (~$0.02/search)';
+
+ return wp.element.createElement('label', {
+ className: 'wpaw-web-search-toggle' + (searchBlocked ? ' wpaw-search-blocked' : ''),
+ title: tooltipText,
+ onClick: searchBlocked ? (e) => {
+ e.preventDefault();
+ alert('Web Search for ' + currentProvider.replace('_', ' ') + ' requires a Brave Search API Key.\n\nGet a free key (2,000 requests/month) and configure it in:\nWP Agentic Writer Settings → General → Brave Search API Key');
+ } : undefined,
+ },
+ wp.element.createElement('input', {
+ type: 'checkbox',
+ checked: searchBlocked ? false : (postConfig.web_search || false),
+ onChange: searchBlocked ? () => { } : (e) => {
+ updatePostConfig('web_search', e.target.checked);
+ },
+ disabled: isLoading || searchBlocked,
+ }),
+ wp.element.createElement('span', {
+ className: 'wpaw-web-search-icon',
+ dangerouslySetInnerHTML: { __html: '' }
+ }),
+ wp.element.createElement('span', { className: 'wpaw-web-search-label' },
+ searchBlocked ? 'Search ✕' : 'Search'
+ )
+ );
+ })(),
),
wp.element.createElement('div', { className: 'wpaw-command-actions-group' },
@@ -5535,6 +6042,11 @@
}
})
)
+ ),
+ wp.element.createElement('div', { className: 'wpaw-keyboard-hints', 'aria-hidden': 'true' },
+ wp.element.createElement('span', { className: 'wpaw-kbd' }, wp.element.createElement('kbd', null, /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl'), '+', wp.element.createElement('kbd', null, '↵'), ' Send'),
+ wp.element.createElement('span', { className: 'wpaw-kbd' }, wp.element.createElement('kbd', null, '@'), ' Blocks'),
+ wp.element.createElement('span', { className: 'wpaw-kbd' }, wp.element.createElement('kbd', null, '/'), ' Commands')
)
)
)
@@ -5581,7 +6093,7 @@
className: 'wpaw-back-btn',
onClick: () => setActiveTab('chat')
}, '← Back'),
- wp.element.createElement('h3', null, 'COST TRACKING'),
+ wp.element.createElement('h3', null, 'OPENROUTER COST'),
wp.element.createElement('button', {
className: 'wpaw-refresh-btn',
dangerouslySetInnerHTML: { __html: '' },
@@ -5625,7 +6137,7 @@
className: 'wpaw-budget-warning ' + budgetStatus,
}, budgetPercent >= 100 ? '⚠️ Budget exceeded!' : '⚠️ Approaching budget limit'),
costHistory.length > 0 && wp.element.createElement('div', { className: 'wpaw-cost-history' },
- wp.element.createElement('h4', null, 'Cost History'),
+ wp.element.createElement('h4', null, 'OpenRouter Cost History'),
wp.element.createElement('div', { className: 'wpaw-cost-table-wrapper' },
wp.element.createElement('table', { className: 'wpaw-cost-table' },
wp.element.createElement('thead', null,
@@ -5634,7 +6146,7 @@
wp.element.createElement('th', null, 'Action'),
wp.element.createElement('th', null, 'Model'),
wp.element.createElement('th', null, 'Tokens'),
- wp.element.createElement('th', null, 'Cost')
+ wp.element.createElement('th', null, 'Cost(US$)')
)
),
wp.element.createElement('tbody', null,
@@ -5659,7 +6171,7 @@
href: settings.settings_url || '/wp-admin/options-general.php?page=wp-agentic-writer',
target: '_blank',
className: 'wpaw-cost-settings-link'
- },
+ },
wp.element.createElement('span', {
dangerouslySetInnerHTML: { __html: ' Manage Budget Settings' }
}),
@@ -5669,8 +6181,8 @@
};
// Main render.
- return wp.element.createElement(PluginSidebar, {
- name: 'wp-agentic-writer',
+ return wp.element.createElement(PluginSidebar, {
+ name: 'wp-agentic-writer',
title: wp.element.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
wp.element.createElement('img', {
src: wpAgenticWriter.pluginUrl + '/assets/img/icon.svg',
diff --git a/brave_search_integration.md b/brave_search_integration.md
new file mode 100644
index 0000000..aeeb45a
--- /dev/null
+++ b/brave_search_integration.md
@@ -0,0 +1,1220 @@
+# WP Agentic Writer: Brave Search API Integration Guide
+
+## Executive Summary
+
+This document defines the **complete integration** of **Brave Search API** into WP Agentic Writer to enhance article generation with real-time web research, citations, and fact-grounding.
+
+**Key principle:** During article generation, the writing agent calls Brave Search API to fetch current data, verify facts, gather citations, and ground responses in real sources. All search results are logged, cached, and attributed.
+
+---
+
+## Table of Contents
+
+1. [Overview & Architecture](#overview--architecture)
+2. [Brave Search API Fundamentals](#brave-search-api-fundamentals)
+3. [Data Model & Database Schema](#data-model--database-schema)
+4. [Flow 1: Agent Research Planning](#flow-1-agent-research-planning)
+5. [Flow 2: Brave Search API Integration](#flow-2-brave-search-api-integration)
+6. [Flow 3: Citation Management](#flow-3-citation-management)
+7. [Flow 4: Search Result Caching](#flow-4-search-result-caching)
+8. [Flow 5: Admin Dashboard & Analytics](#flow-5-admin-dashboard--analytics)
+9. [REST API Endpoints](#rest-api-endpoints)
+10. [Configuration & Settings](#configuration--settings)
+11. [Cost Optimization & Budget Management](#cost-optimization--budget-management)
+12. [Implementation Checklist](#implementation-checklist)
+
+---
+
+## Overview & Architecture
+
+### Core Concept
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ USER: "Write article about N8n automation workflows" │
+└────────────────────┬────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────────┐
+│ WRITING AGENT receives request │
+│ - Analyzes: topic, target audience, depth needed │
+│ - Plans research: "Need current N8n features, pricing, users" │
+└────────────────────┬────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────────┐
+│ AGENT → Brave Search API (Multiple searches) │
+│ │
+│ Search 1: "N8n automation platform 2024" │
+│ Search 2: "N8n pricing plans features" │
+│ Search 3: "N8n vs Zapier Make comparison" │
+│ Search 4: "N8n self-hosted deployment guide" │
+│ │
+│ Each search: Get 10-15 results with snippets + URLs │
+└────────────────────┬────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────────┐
+│ BACKEND: Store searches in DB │
+│ │
+│ wp_agentic_searches table: │
+│ - post_id, search_query, results (serialized), cost │
+│ - cache_enabled (reuse for 30 days) │
+│ │
+│ wp_agentic_citations table: │
+│ - post_id, citation_text, url, source_title, position │
+│ - [1], [2], [3] numbered references │
+└────────────────────┬────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────────┐
+│ AGENT: Generates article WITH research │
+│ │
+│ "N8n is an open-source workflow automation platform[1] │
+│ enabling teams to automate tasks[2]. In 2024, N8n added[3]... │
+│ │
+│ Key features include visual workflow builder[1], │
+│ 500+ integrations[2], and self-hosting options[3]." │
+│ │
+│ Returns: Article blocks + citations (with sources) │
+└────────────────────┬────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────────┐
+│ PLUGIN: Create Gutenberg post with: │
+│ - Article content │
+│ - Inline citations [1], [2], [3] │
+│ - References section at end (with URLs) │
+│ - Search context sidebar (optional) │
+└────────────────────┬────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────────┐
+│ ADMIN DASHBOARD: "Generated Searches" │
+│ │
+│ Show: │
+│ - All searches per post + results count │
+│ - Cost per search ($0.009 per query for AI tier) │
+│ - Cache status (reusable until expiry) │
+│ - Citation coverage (% of facts cited) │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## Brave Search API Fundamentals
+
+### API Plans & Pricing (2024)
+
+**Free Tier (for testing):**
+- 1 query/second
+- 2,000 queries/month (~$0)
+- Great for development & testing
+
+**Data for AI - Base ($5 per 1,000 requests):**
+- 20 queries/second
+- Up to 20M queries/month
+- Up to 5 snippets per result
+- Extra alternate snippets for AI
+- Rights to use in AI apps
+
+**Data for AI - Pro ($9 per 1,000 requests):**
+- 50 queries/second
+- Unlimited queries/month
+- Same features as Base + schema-enriched results
+- Recommended for production AI apps
+
+### Key Advantages Over Alternatives
+
+| Feature | Brave Search | Google Search | Bing Search |
+|---------|-----------|---------|---------|
+| **Cost** | $3-9 CPM | ~$20 CPM | ~$15 CPM |
+| **Independent Index** | Yes (30B+ pages) | No (proprietary) | No (proprietary) |
+| **Privacy** | Yes (no tracking) | No | No |
+| **AI Rights** | Explicit in plan | Unclear | Unclear |
+| **Fresh Data** | 100M updates/day | Daily | Daily |
+| **Latency** | ~2-3s average | Fast | Fast |
+
+---
+
+## Data Model & Database Schema
+
+### Table: `wp_agentic_searches`
+
+Stores all Brave Search API calls made during article generation.
+
+```sql
+CREATE TABLE wp_agentic_searches (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ post_id BIGINT NOT NULL,
+
+ -- Search details
+ search_query VARCHAR(500) NOT NULL, -- "N8n automation features"
+ search_number INT, -- 1st, 2nd, 3rd search
+ total_searches_for_post INT, -- "Need 3 searches for this post"
+
+ -- Brave API response
+ results_count INT, -- How many results returned
+ results_json LONGTEXT, -- Serialized JSON from Brave
+ top_result_title VARCHAR(255), -- First result title
+ top_result_url VARCHAR(500), -- First result URL
+
+ -- Cost tracking
+ cost DECIMAL(10, 4), -- $0.009 per query (AI Base)
+ api_tier VARCHAR(50), -- 'free', 'base_ai', 'pro_ai'
+
+ -- Caching
+ cache_enabled TINYINT DEFAULT 1, -- Can this be reused?
+ cache_expires_at TIMESTAMP, -- Expires in 30 days
+ cache_hit TINYINT DEFAULT 0, -- 1 if used cached result
+
+ -- Metadata
+ search_category VARCHAR(100), -- 'features', 'pricing', 'competitors', 'news'
+ agent_decision TEXT, -- Why agent chose this search
+ relevance_score DECIMAL(3,2), -- Agent rating 0-1.0 of relevance
+ status VARCHAR(30) DEFAULT 'completed', -- completed, failed, rate_limited
+ error_message TEXT, -- If failed
+
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+
+ KEY idx_post (post_id),
+ KEY idx_query (search_query),
+ KEY idx_cache_expires (cache_expires_at),
+ KEY idx_status (status),
+ KEY idx_created (created_at)
+);
+```
+
+### Table: `wp_agentic_citations`
+
+Tracks every citation [1], [2], [3]... in the article with source URL.
+
+```sql
+CREATE TABLE wp_agentic_citations (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ post_id BIGINT NOT NULL,
+
+ -- Citation numbering
+ citation_number INT NOT NULL, -- [1], [2], [3]...
+ citation_text VARCHAR(500), -- Text that was cited
+ context_excerpt TEXT, -- Sentence containing citation
+
+ -- Source information
+ search_id BIGINT, -- Reference to wp_agentic_searches
+ source_url VARCHAR(500) NOT NULL, -- Full URL of source
+ source_title VARCHAR(255), -- "N8n Pricing | Official Website"
+ source_domain VARCHAR(100), -- "n8n.io"
+ source_type VARCHAR(50), -- 'official_website', 'blog', 'news', 'doc'
+
+ -- Citation credibility
+ source_authority INT, -- 1-100 (domain authority estimate)
+ result_position INT, -- Was this the 1st, 5th, 10th result?
+ snippet_match_score DECIMAL(3,2), -- % match with snippet
+
+ -- Article placement
+ article_section VARCHAR(100), -- "Introduction", "Features", "Pricing"
+ paragraph_number INT, -- Which paragraph in section
+ sentence_number INT, -- Which sentence in paragraph
+ inline_position INT, -- Position of [N] in sentence
+
+ -- Metadata
+ added_by VARCHAR(50), -- 'agent_automatic' or 'user_manual'
+ verified TINYINT DEFAULT 0, -- User confirmed source is correct
+ is_required TINYINT DEFAULT 1, -- Essential citation vs optional
+
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+
+ KEY idx_post (post_id),
+ KEY idx_citation_number (post_id, citation_number),
+ KEY idx_source_domain (source_domain),
+ KEY idx_created (created_at)
+);
+```
+
+### Table: `wp_agentic_search_cache`
+
+Cache layer for frequently searched topics.
+
+```sql
+CREATE TABLE wp_agentic_search_cache (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+
+ -- Cache key
+ search_query_normalized VARCHAR(500) NOT NULL, -- Lowercase, trimmed
+ search_category VARCHAR(100), -- Optional: 'news', 'trends'
+ cache_key VARCHAR(64), -- SHA1 hash of query
+
+ -- Cached data
+ results_json LONGTEXT, -- Complete Brave response
+ result_count INT,
+ cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ expires_at TIMESTAMP, -- 30 days from creation
+
+ -- Usage tracking
+ hit_count INT DEFAULT 0, -- Times this cache was used
+ cost_saved DECIMAL(10, 4), -- Cumulative cost avoided
+
+ -- Quality
+ quality_score DECIMAL(3,2), -- 0-1.0 relevance rating
+
+ UNIQUE KEY unique_query_category (search_query_normalized, search_category),
+ KEY idx_expires (expires_at),
+ KEY idx_hit_count (hit_count)
+);
+```
+
+---
+
+## Flow 1: Agent Research Planning
+
+### How the Agent Decides What to Search
+
+```php
+ "$topic what is",
+ 'category' => 'definition',
+ 'priority' => 'critical',
+ 'intent' => 'Define what N8n is, core features'
+ ];
+
+ if ($depth === 'medium' || $depth === 'deep') {
+ // Tier 2: Current state & news
+ $searches[] = [
+ 'query' => "$topic latest news 2024",
+ 'category' => 'news',
+ 'priority' => 'high',
+ 'intent' => 'Recent developments, updates'
+ ];
+
+ // Tier 3: Pricing & comparison
+ $searches[] = [
+ 'query' => "$topic pricing plans features",
+ 'category' => 'pricing',
+ 'priority' => 'high',
+ 'intent' => 'Current pricing, plan comparison'
+ ];
+
+ // Tier 4: Alternatives & comparison
+ $searches[] = [
+ 'query' => "$topic vs alternatives competitors",
+ 'category' => 'comparison',
+ 'priority' => 'medium',
+ 'intent' => 'How does it compare to Zapier, Make, etc'
+ ];
+ }
+
+ if ($depth === 'deep') {
+ // Tier 5: Use cases & case studies
+ $searches[] = [
+ 'query' => "$topic use cases examples case studies",
+ 'category' => 'examples',
+ 'priority' => 'medium',
+ 'intent' => 'Real-world examples and success stories'
+ ];
+
+ // Tier 6: Technical implementation
+ $searches[] = [
+ 'query' => "$topic documentation api integration",
+ 'category' => 'technical',
+ 'priority' => 'medium',
+ 'intent' => 'How to implement, API docs, guides'
+ ];
+ }
+
+ return $searches;
+ }
+}
+```
+
+---
+
+## Flow 2: Brave Search API Integration
+
+### Backend: Brave Search Client
+
+```php
+api_key = $api_key ?? get_option('agentic_brave_api_key');
+ $this->rate_limiter = new RateLimiter();
+ }
+
+ /**
+ * Main search method
+ * Handles caching, rate limiting, cost tracking
+ */
+ public function search($query, $options = []) {
+ // 1. Check cache first
+ $cached = $this->check_cache($query, $options['category'] ?? null);
+ if ($cached) {
+ return [
+ 'results' => $cached['results_json'],
+ 'from_cache' => true,
+ 'cache_age_hours' => $cached['age']
+ ];
+ }
+
+ // 2. Rate limit check
+ if (!$this->rate_limiter->allow_request($this->api_key)) {
+ return new WP_Error(
+ 'rate_limit_exceeded',
+ 'Brave Search API rate limit exceeded. Try again in ' .
+ $this->rate_limiter->get_reset_time() . ' seconds'
+ );
+ }
+
+ // 3. Call Brave API
+ $start_time = microtime(true);
+ $response = $this->call_brave_api($query, $options);
+ $execution_time = microtime(true) - $start_time;
+
+ if (is_wp_error($response)) {
+ return $response;
+ }
+
+ // 4. Store search in database
+ $cost = $this->calculate_cost($this->get_tier());
+ $search_id = $this->store_search(
+ $query,
+ $response,
+ $cost,
+ $options
+ );
+
+ // 5. Cache the results
+ $this->cache_results($query, $response, $options);
+
+ // 6. Track cost
+ update_option(
+ 'agentic_brave_total_cost',
+ (float)get_option('agentic_brave_total_cost', 0) + $cost
+ );
+
+ return [
+ 'results' => $response,
+ 'from_cache' => false,
+ 'search_id' => $search_id,
+ 'cost' => $cost,
+ 'execution_time' => $execution_time,
+ 'result_count' => count($response['web']['results'] ?? [])
+ ];
+ }
+
+ /**
+ * Call Brave Search API
+ */
+ private function call_brave_api($query, $options = []) {
+ $endpoint = $this->api_base . '/web/search';
+
+ $params = [
+ 'q' => $query,
+ 'count' => $options['count'] ?? 10,
+ 'safesearch' => 'moderate',
+ 'search_lang' => $options['language'] ?? 'en',
+ 'country' => $options['country'] ?? 'US',
+ ];
+
+ // Optional: Use Search Goggles for custom ranking
+ if (!empty($options['goggle'])) {
+ $params['goggles_id'] = $options['goggle'];
+ }
+
+ $response = wp_remote_get(
+ add_query_arg($params, $endpoint),
+ [
+ 'headers' => [
+ 'X-Subscription-Token' => $this->api_key,
+ 'Accept' => 'application/json'
+ ],
+ 'timeout' => 30
+ ]
+ );
+
+ if (is_wp_error($response)) {
+ return new WP_Error(
+ 'api_request_failed',
+ 'Brave Search API request failed: ' . $response->get_error_message()
+ );
+ }
+
+ $status = wp_remote_retrieve_response_code($response);
+ $body = json_decode(wp_remote_retrieve_body($response), true);
+
+ // Handle API errors
+ if ($status === 429) {
+ // Rate limited
+ return new WP_Error('rate_limited', 'API rate limit hit');
+ } elseif ($status === 401) {
+ return new WP_Error('invalid_api_key', 'Invalid Brave API key');
+ } elseif ($status !== 200) {
+ return new WP_Error(
+ 'api_error',
+ 'Brave API returned status ' . $status,
+ ['response_body' => $body]
+ );
+ }
+
+ return $body;
+ }
+
+ /**
+ * Check if results are cached
+ */
+ private function check_cache($query, $category = null) {
+ global $wpdb;
+
+ $cache_key = sha1(strtolower(trim($query)));
+
+ $cached = $wpdb->get_row($wpdb->prepare(
+ "SELECT * FROM {$wpdb->prefix}agentic_search_cache
+ WHERE cache_key = %s
+ AND expires_at > NOW()
+ AND (search_category = %s OR %s IS NULL)
+ ORDER BY hit_count DESC",
+ $cache_key,
+ $category,
+ $category
+ ));
+
+ if ($cached) {
+ // Update cache stats
+ $wpdb->update(
+ $wpdb->prefix . 'agentic_search_cache',
+ [
+ 'hit_count' => $cached->hit_count + 1,
+ 'cost_saved' => $cached->cost_saved + 0.009
+ ],
+ ['id' => $cached->id]
+ );
+
+ $age_seconds = strtotime('now') - strtotime($cached->cached_at);
+ return [
+ 'results_json' => json_decode($cached->results_json, true),
+ 'age' => ceil($age_seconds / 3600)
+ ];
+ }
+
+ return null;
+ }
+
+ /**
+ * Store search in database
+ */
+ private function store_search($query, $response, $cost, $options = []) {
+ global $wpdb;
+
+ $top_result = null;
+ if (!empty($response['web']['results'])) {
+ $top_result = $response['web']['results'][0];
+ }
+
+ $wpdb->insert(
+ $wpdb->prefix . 'agentic_searches',
+ [
+ 'post_id' => $options['post_id'] ?? 0,
+ 'search_query' => $query,
+ 'search_number' => $options['search_number'] ?? 1,
+ 'total_searches_for_post' => $options['total_searches'] ?? 1,
+ 'results_count' => count($response['web']['results'] ?? []),
+ 'results_json' => wp_json_encode($response),
+ 'top_result_title' => $top_result['title'] ?? null,
+ 'top_result_url' => $top_result['url'] ?? null,
+ 'cost' => $cost,
+ 'api_tier' => $this->get_tier(),
+ 'search_category' => $options['category'] ?? 'general',
+ 'status' => 'completed'
+ ]
+ );
+
+ return $wpdb->insert_id;
+ }
+
+ /**
+ * Cache search results for 30 days
+ */
+ private function cache_results($query, $response, $options = []) {
+ global $wpdb;
+
+ $cache_key = sha1(strtolower(trim($query)));
+
+ $wpdb->insert(
+ $wpdb->prefix . 'agentic_search_cache',
+ [
+ 'search_query_normalized' => strtolower(trim($query)),
+ 'search_category' => $options['category'] ?? null,
+ 'cache_key' => $cache_key,
+ 'results_json' => wp_json_encode($response),
+ 'result_count' => count($response['web']['results'] ?? []),
+ 'expires_at' => gmdate('Y-m-d H:i:s', strtotime('+30 days')),
+ 'quality_score' => 0.9 // Agent can rate later
+ ],
+ ['%s', '%s', '%s', '%s', '%d', '%s', '%f']
+ );
+ }
+
+ /**
+ * Get current API tier
+ */
+ private function get_tier() {
+ return get_option('agentic_brave_api_tier', 'base_ai');
+ }
+
+ /**
+ * Calculate cost per request
+ */
+ private function calculate_cost($tier) {
+ $tiers = [
+ 'free' => 0,
+ 'base_ai' => 0.005, // $5 per 1000
+ 'pro_ai' => 0.009 // $9 per 1000
+ ];
+
+ return $tiers[$tier] ?? 0;
+ }
+}
+```
+
+---
+
+## Flow 3: Citation Management
+
+### Agent: Extract and Number Citations
+
+```php
+ $citation_number,
+ 'source' => $source_info
+ ];
+
+ $citation_number++;
+ }
+
+ // 5. Add References section to article
+ $references_section = self::generate_references_section($citations);
+ $article_content .= $references_section;
+
+ return [
+ 'content' => $article_content,
+ 'citations' => $citations,
+ 'citation_count' => count($citations)
+ ];
+ }
+
+ /**
+ * Find source in search results by marker
+ */
+ private static function find_source_by_marker($marker, $search_results) {
+ // Examples:
+ // "n8n_official_docs" → Find URL from n8n.io/docs search
+ // "zapier_pricing" → Find from "N8n vs Zapier" search result
+
+ // Parse marker to understand what it's looking for
+ $parts = explode('_', $marker);
+ $topic = $parts[0];
+ $type = $parts[1] ?? null;
+
+ foreach ($search_results as $search) {
+ if (empty($search['results']['web']['results'])) {
+ continue;
+ }
+
+ foreach ($search['results']['web']['results'] as $result) {
+ $domain = parse_url($result['url'], PHP_URL_HOST);
+
+ // Match: does URL match the topic?
+ if (stripos($domain, $topic) !== false ||
+ stripos($result['title'], $topic) !== false) {
+
+ return [
+ 'url' => $result['url'],
+ 'title' => $result['title'],
+ 'domain' => $domain,
+ 'snippet' => $result['snippet'] ?? '',
+ 'description' => $result['description'] ?? '',
+ 'position' => array_search($result, $search['results']['web']['results'])
+ ];
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Store citation in database
+ */
+ private static function store_citation(
+ $post_id,
+ $citation_number,
+ $source_info,
+ $source_marker
+ ) {
+ global $wpdb;
+
+ // Detect source type
+ $source_type = self::detect_source_type($source_info['domain']);
+
+ $wpdb->insert(
+ $wpdb->prefix . 'agentic_citations',
+ [
+ 'post_id' => $post_id,
+ 'citation_number' => $citation_number,
+ 'source_url' => $source_info['url'],
+ 'source_title' => $source_info['title'],
+ 'source_domain' => $source_info['domain'],
+ 'source_type' => $source_type,
+ 'result_position' => $source_info['position'],
+ 'added_by' => 'agent_automatic',
+ 'created_at' => current_time('mysql')
+ ]
+ );
+
+ return $wpdb->insert_id;
+ }
+
+ /**
+ * Generate References section
+ */
+ private static function generate_references_section($citations) {
+ $html = 'References
';
+
+ foreach ($citations as $citation) {
+ $source = $citation['source'];
+ $html .= sprintf(
+ '- %s - %s
',
+ esc_url($source['url']),
+ esc_html($source['title']),
+ esc_html($source['domain'])
+ );
+ }
+
+ $html .= '
';
+
+ return $html;
+ }
+
+ /**
+ * Classify source credibility
+ */
+ private static function detect_source_type($domain) {
+ if (stripos($domain, 'docs.') === 0 || stripos($domain, '.io') === 0) {
+ return 'official_documentation';
+ } elseif (stripos($domain, 'blog.') === 0) {
+ return 'official_blog';
+ } elseif (in_array($domain, ['medium.com', 'dev.to', 'hashnode.com'])) {
+ return 'tech_blog';
+ } elseif (in_array($domain, ['techcrunch.com', 'theverge.com', 'forbes.com'])) {
+ return 'news';
+ } elseif (stripos($domain, 'github.com') === 0) {
+ return 'github';
+ } else {
+ return 'general_web';
+ }
+ }
+}
+```
+
+---
+
+## Flow 4: Search Result Caching
+
+### Cache Strategy
+
+**Why caching is critical:**
+- A single article generation might trigger 3-5 searches
+- Multiple articles on similar topics (e.g., "Automation tools") will search same keywords
+- Brave API charges per query: **$5-9 per 1,000 queries**
+- Caching can reduce costs by 40-60%
+
+**Cache rules:**
+- Cache all successful searches for **30 days**
+- Reuse cache for identical queries (case-insensitive, trimmed)
+- Group by category (pricing, news, features) for better relevance
+- Track cache hits for analytics
+
+### Cache Implementation
+
+```php
+ 'cache',
+ 'results' => $cached,
+ 'age_days' => self::get_cache_age_days($cached)
+ ];
+ }
+
+ // Fetch fresh
+ $client = new BraveSearchClient();
+ $fresh = $client->search($query, $options);
+
+ return [
+ 'source' => 'api',
+ 'results' => $fresh
+ ];
+ }
+
+ /**
+ * Cleanup expired cache entries
+ * Run daily via cron
+ */
+ public static function cleanup_expired_cache() {
+ global $wpdb;
+
+ $deleted = $wpdb->query(
+ "DELETE FROM {$wpdb->prefix}agentic_search_cache
+ WHERE expires_at < NOW()"
+ );
+
+ error_log("Cleaned up $deleted expired search cache entries");
+ }
+
+ /**
+ * Manual cache invalidation (if search results become stale)
+ */
+ public static function invalidate_cache($query, $category = null) {
+ global $wpdb;
+
+ $cache_key = self::generate_cache_key($query, $category);
+
+ $wpdb->update(
+ $wpdb->prefix . 'agentic_search_cache',
+ ['expires_at' => current_time('mysql')], // Set to now (expired)
+ ['cache_key' => $cache_key]
+ );
+
+ return true;
+ }
+
+ private static function generate_cache_key($query, $category = null) {
+ $normalized = strtolower(trim($query));
+ return sha1($normalized . ($category ? "_$category" : ''));
+ }
+}
+
+// Schedule cache cleanup daily
+if (!wp_next_scheduled('agentic_search_cache_cleanup')) {
+ wp_schedule_event(
+ time(),
+ 'daily',
+ 'agentic_search_cache_cleanup'
+ );
+}
+
+add_action('agentic_search_cache_cleanup', [
+ 'SearchCacheManager',
+ 'cleanup_expired_cache'
+]);
+```
+
+---
+
+## Flow 5: Admin Dashboard & Analytics
+
+### Search Analytics Tab
+
+```
+Plugin Settings → Research & Citations → Search Analytics
+
+┌────────────────────────────────────────────────────────┐
+│ Search Performance & Cost Analytics │
+├────────────────────────────────────────────────────────┤
+│ │
+│ COST SUMMARY │
+│ ───────────────────────────────────────────────────── │
+│ Total searches: 156 │
+│ Cached hits: 89 (57%) │
+│ Fresh API calls: 67 (43%) │
+│ Total cost: $0.60 (would be $1.40 without cache) │
+│ Monthly budget: $50.00 │
+│ Budget used: 1.2% ✓ │
+│ │
+│ TOP SEARCHES (by frequency) │
+│ ───────────────────────────────────────────────────── │
+│ 1. "n8n automation features" → 23 hits (13 cached) │
+│ Cost: $0.09 | Last: Jan 28, 2:30 PM │
+│ │
+│ 2. "workflow automation comparison" → 18 hits (11 cache│
+│ Cost: $0.09 | Last: Jan 27, 5:15 PM │
+│ │
+│ 3. "zapier pricing 2024" → 12 hits (7 cached) │
+│ Cost: $0.05 | Last: Jan 27, 1:20 PM │
+│ │
+│ CACHE PERFORMANCE │
+│ ───────────────────────────────────────────────────── │
+│ Cache hit rate: 57% │
+│ Cache age (avg): 8 days │
+│ Cost saved by cache: $0.80 (57% reduction) │
+│ │
+│ [Clear Cache] [Invalidate > 30 days] [Download Report] │
+│ │
+└────────────────────────────────────────────────────────┘
+```
+
+---
+
+## REST API Endpoints
+
+### Public REST Endpoints
+
+```php
+/**
+ * POST /wp-json/agentic-writer/v1/search
+ * Perform a web search using Brave API
+ *
+ * Request:
+ * {
+ * "query": "N8n automation features",
+ * "category": "features",
+ * "count": 10,
+ * "country": "US"
+ * }
+ *
+ * Response:
+ * {
+ * "success": true,
+ * "results": [...],
+ * "from_cache": false,
+ * "cost": 0.009,
+ * "result_count": 10
+ * }
+ */
+
+register_rest_route('agentic-writer/v1', '/search', [
+ 'methods' => 'POST',
+ 'callback' => 'agentic_writer_rest_search',
+ 'permission_callback' => 'is_user_logged_in',
+ 'args' => [
+ 'query' => ['required' => true, 'type' => 'string'],
+ 'category' => ['type' => 'string'],
+ 'count' => ['type' => 'integer', 'default' => 10]
+ ]
+]);
+
+function agentic_writer_rest_search($request) {
+ $query = $request->get_param('query');
+ $client = new BraveSearchClient();
+
+ return $client->search($query, [
+ 'category' => $request->get_param('category'),
+ 'count' => $request->get_param('count'),
+ 'post_id' => $request->get_param('post_id')
+ ]);
+}
+
+/**
+ * GET /wp-json/agentic-writer/v1/searches?post_id=123
+ * List all searches for a post with citations
+ */
+
+register_rest_route('agentic-writer/v1', '/searches', [
+ 'methods' => 'GET',
+ 'callback' => 'agentic_writer_rest_list_searches',
+ 'permission_callback' => 'is_user_logged_in',
+ 'args' => ['post_id' => ['required' => true, 'type' => 'integer']]
+]);
+
+function agentic_writer_rest_list_searches($request) {
+ global $wpdb;
+
+ $post_id = $request->get_param('post_id');
+
+ if (!current_user_can('edit_post', $post_id)) {
+ return new WP_Error('unauthorized', 'Not allowed', ['status' => 403]);
+ }
+
+ $searches = $wpdb->get_results($wpdb->prepare(
+ "SELECT * FROM {$wpdb->prefix}agentic_searches
+ WHERE post_id = %d
+ ORDER BY created_at DESC",
+ $post_id
+ ));
+
+ $citations = $wpdb->get_results($wpdb->prepare(
+ "SELECT * FROM {$wpdb->prefix}agentic_citations
+ WHERE post_id = %d
+ ORDER BY citation_number ASC",
+ $post_id
+ ));
+
+ return [
+ 'searches' => $searches,
+ 'citations' => $citations,
+ 'total_cost' => array_sum(array_map(function($s) {
+ return floatval($s->cost);
+ }, $searches))
+ ];
+}
+```
+
+---
+
+## Configuration & Settings
+
+### Settings Panel Integration
+
+Add new tab to WP Agentic Writer settings:
+
+```php
+'brave_search_settings' => [
+ 'brave_api_key' => '', // Required
+ 'brave_api_tier' => 'base_ai', // free, base_ai, pro_ai
+ 'brave_search_enabled' => true, // Toggle feature on/off
+ 'enable_search_caching' => true, // Cache results
+ 'cache_duration_days' => 30, // How long to keep cache
+ 'auto_search_enabled' => true, // Auto-search during generation
+ 'search_count_per_article' => 3, // How many searches per article
+ 'include_citations' => true, // Add [1], [2]... to article
+ 'include_references_section' => true, // Add References section
+ 'monthly_budget_limit' => 50.00, // Dollar limit per month
+ 'budget_alert_threshold' => 75, // Alert at 75% of budget
+ 'auto_invalidate_cache_older_than_days' => 45, // Auto-cleanup old cache
+ 'search_result_quality_threshold' => 0.6, // Minimum relevance score
+]
+```
+
+---
+
+## Cost Optimization & Budget Management
+
+### Real-time Cost Tracking
+
+```php
+= floatval(get_option('agentic_brave_budget_alert', 75))) {
+ do_action('agentic_brave_budget_alert', [
+ 'monthly_cost' => $new_cost,
+ 'budget_limit' => $budget_limit,
+ 'percentage' => $percentage
+ ]);
+ }
+
+ // Block searches if over budget
+ if ($new_cost >= $budget_limit) {
+ return new WP_Error(
+ 'budget_exceeded',
+ sprintf(
+ 'Monthly budget of $%.2f exceeded (current: $%.2f)',
+ $budget_limit,
+ $new_cost
+ )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Get current month's total cost
+ */
+ private static function get_monthly_cost() {
+ global $wpdb;
+
+ $result = $wpdb->get_var(
+ "SELECT COALESCE(SUM(cost), 0) FROM {$wpdb->prefix}agentic_searches
+ WHERE MONTH(created_at) = MONTH(NOW())
+ AND YEAR(created_at) = YEAR(NOW())"
+ );
+
+ return floatval($result);
+ }
+
+ /**
+ * Generate cost report
+ */
+ public static function get_cost_report() {
+ global $wpdb;
+
+ return [
+ 'today' => self::get_period_cost('today'),
+ 'week' => self::get_period_cost('week'),
+ 'month' => self::get_period_cost('month'),
+ 'all_time' => self::get_all_time_cost(),
+ 'top_searches' => self::get_top_searches_by_cost(),
+ 'cache_savings' => self::get_cache_savings()
+ ];
+ }
+
+ /**
+ * Calculate how much cache saved
+ */
+ private static function get_cache_savings() {
+ global $wpdb;
+
+ return $wpdb->get_var(
+ "SELECT COALESCE(SUM(cost_saved), 0) FROM {$wpdb->prefix}agentic_search_cache"
+ );
+ }
+}
+
+// Hook for budget alerts
+add_action('agentic_brave_budget_alert', function($data) {
+ // Send admin email
+ wp_mail(
+ get_option('admin_email'),
+ 'WP Agentic Writer: Budget Alert',
+ sprintf(
+ "Brave Search API budget is at %.0f%% ($%.2f / $%.2f)\n\nGo to Settings to adjust limits.",
+ $data['percentage'],
+ $data['monthly_cost'],
+ $data['budget_limit']
+ )
+ );
+});
+```
+
+---
+
+## Implementation Checklist
+
+### Phase 1: Core Integration (Week 1-2)
+- [ ] Create database tables: `wp_agentic_searches`, `wp_agentic_citations`, `wp_agentic_search_cache`
+- [ ] Build BraveSearchClient class with API authentication
+- [ ] Implement basic search → cache → cost tracking flow
+- [ ] Create REST endpoint: POST `/search`
+- [ ] Add settings panel for API key & tier selection
+- [ ] Test with sample searches
+
+### Phase 2: Agent Integration (Week 2-3)
+- [ ] Build ResearchPlanner (automatic search strategy)
+- [ ] Integrate searches into article generation workflow
+- [ ] Build CitationManager (extract, number, reference)
+- [ ] Add citations to generated articles ([1], [2]... References)
+- [ ] Test end-to-end: Topic → Searches → Article with Citations
+
+### Phase 3: Caching & Optimization (Week 3-4)
+- [ ] Implement SearchCacheManager with 30-day expiry
+- [ ] Add cache hit tracking & cost savings calculation
+- [ ] Build cache cleanup cron job
+- [ ] Implement cache invalidation endpoint
+- [ ] Test cache reuse scenarios
+
+### Phase 4: Admin & Analytics (Week 4-5)
+- [ ] Build admin dashboard with Search Analytics tab
+- [ ] Add cost tracking & monthly budget management
+- [ ] Create search performance reports
+- [ ] Add budget alert system (email)
+- [ ] Build cache management UI (view, invalidate, cleanup)
+
+### Phase 5: Security & Polish (Week 5)
+- [ ] Add rate limiting per user
+- [ ] Sanitize search queries
+- [ ] Add audit logging (who searched what, when)
+- [ ] Implement permission checks
+- [ ] Add error handling for API failures
+- [ ] Test with high volume searches
+
+---
+
+## Next Steps
+
+1. Set up Brave Search API account (free tier first)
+2. Create database migrations for the three new tables
+3. Begin Phase 1 implementation (BraveSearchClient)
+4. Test API connectivity and response parsing
+5. Move to Phase 2 once API is reliable
diff --git a/downloads/.gitignore b/downloads/.gitignore
new file mode 100644
index 0000000..7ffb7c3
--- /dev/null
+++ b/downloads/.gitignore
@@ -0,0 +1,7 @@
+# Ignore node_modules in local backend package
+agentic-writer-local-backend/node_modules/
+agentic-writer-local-backend/proxy.log
+agentic-writer-local-backend/proxy.pid
+
+# Keep the distributable ZIP
+!agentic-writer-local-backend.zip
diff --git a/downloads/agentic-writer-local-backend.zip b/downloads/agentic-writer-local-backend.zip
new file mode 100644
index 0000000..62f40a3
Binary files /dev/null and b/downloads/agentic-writer-local-backend.zip differ
diff --git a/downloads/agentic-writer-local-backend/README.md b/downloads/agentic-writer-local-backend/README.md
new file mode 100644
index 0000000..67f122d
--- /dev/null
+++ b/downloads/agentic-writer-local-backend/README.md
@@ -0,0 +1,170 @@
+# Agentic Writer Local Backend
+
+Run unlimited AI content generation on your own machine using your Claude CLI + Z.ai/Anthropic account.
+
+## Prerequisites
+
+Before starting, ensure you have:
+
+- ✅ **Claude CLI** installed and configured
+ - Get it: [https://claude.ai/code](https://claude.ai/code) or [https://z.ai](https://z.ai)
+ - Verify: `claude --version` or `which claude`
+- ✅ **Node.js 18+** installed
+ - Download: [https://nodejs.org](https://nodejs.org)
+ - Verify: `node --version`
+- ✅ **Z.ai Coding Plan** or **Anthropic API key** configured in Claude CLI
+
+## Quick Start
+
+### 1. Extract Package
+
+```bash
+unzip agentic-writer-local-backend.zip
+cd agentic-writer-local-backend
+```
+
+### 2. Start the Proxy
+
+```bash
+chmod +x *.sh
+./start-proxy.sh
+```
+
+You'll see:
+
+```
+═══════════════════════════════════════════════════
+✅ Local Backend Running!
+═══════════════════════════════════════════════════
+
+Your Configuration:
+ Base URL: http://192.168.1.105:8080
+ API Key: dummy
+ Model: claude-local
+```
+
+### 3. Configure WordPress Plugin
+
+1. Open **WP Admin** → **Agentic Writer** → **Settings** → **Local Backend**
+2. Paste the **Base URL** shown above
+3. API Key: `dummy`
+4. Click **Test Connection** → should show ✅
+5. Start generating content!
+
+## Commands
+
+```bash
+./start-proxy.sh # Start proxy (runs in background)
+./stop-proxy.sh # Stop proxy
+./test-connection.sh # Test if proxy responds
+./get-local-ip.sh # Find your local IP address
+tail -f proxy.log # View real-time logs
+```
+
+## Firewall Setup
+
+The proxy needs to accept connections from your WordPress site.
+
+### macOS
+
+1. **System Settings** → **Network** → **Firewall**
+2. Click **Options** → **Add** → Select `node`
+3. Set to **Allow incoming connections**
+
+### Linux (ufw)
+
+```bash
+sudo ufw allow 8080/tcp
+sudo ufw reload
+```
+
+### Windows
+
+1. **Windows Defender Firewall** → **Advanced Settings**
+2. **Inbound Rules** → **New Rule**
+3. **Port** → TCP **8080** → **Allow**
+
+## How It Works
+
+```
+WordPress Plugin → HTTP POST → Local Proxy (port 8080)
+ ↓
+ Spawns Claude CLI
+ ↓
+ Returns AI Response
+```
+
+**Benefits:**
+- 🆓 **Free**: Uses your existing Z.ai/Anthropic subscription
+- 🔒 **Private**: Content never leaves your network
+- ⚡ **Fast**: LAN latency (~50-200ms)
+- 🚀 **Unlimited**: No rate limits, no token counting
+
+## Troubleshooting
+
+See [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for detailed solutions.
+
+### Quick Fixes
+
+**"Connection failed" in plugin:**
+```bash
+# Check proxy is running
+ps aux | grep claude-proxy
+
+# Restart if needed
+./stop-proxy.sh && ./start-proxy.sh
+```
+
+**"Claude CLI not found":**
+```bash
+# Verify Claude is installed
+which claude
+claude --version
+
+# Test Claude works
+echo "Hello" | claude
+```
+
+**"Wrong IP address":**
+```bash
+# Find your correct IP
+./get-local-ip.sh
+
+# Or manually:
+# macOS: ipconfig getifaddr en0
+# Linux: ip route get 1 | awk '{print $7}'
+```
+
+**Port 8080 already in use:**
+```bash
+# Find what's using it
+lsof -i :8080
+
+# Change port (edit claude-proxy.js)
+PORT=9000 node claude-proxy.js
+# Update plugin Base URL to: http://your-ip:9000
+```
+
+## Security Notes
+
+- Proxy binds to `0.0.0.0` (all network interfaces) for LAN access
+- No authentication by design (LAN trust model)
+- All request prompts are logged to `proxy.log`
+- For internet exposure, use ngrok/reverse proxy with authentication
+
+## Environment Variables
+
+```bash
+PORT=9000 ./start-proxy.sh # Use different port
+NODE_ENV=production # Production mode
+```
+
+## Support
+
+- **Documentation**: [Plugin Docs](https://github.com/your/plugin)
+- **Issues**: [GitHub Issues](https://github.com/your/plugin/issues)
+- **Community**: [Discord](https://discord.gg/your-server)
+
+## License
+
+GPL-2.0+ - Same as WP Agentic Writer plugin
diff --git a/downloads/agentic-writer-local-backend/TROUBLESHOOTING.md b/downloads/agentic-writer-local-backend/TROUBLESHOOTING.md
new file mode 100644
index 0000000..41e822d
--- /dev/null
+++ b/downloads/agentic-writer-local-backend/TROUBLESHOOTING.md
@@ -0,0 +1,339 @@
+# Troubleshooting Guide
+
+Common issues and solutions for Agentic Writer Local Backend.
+
+## Connection Issues
+
+### "Connection timeout" in Plugin
+
+**Symptoms:**
+- Plugin shows "Connection timeout" error
+- Test connection fails
+
+**Solutions:**
+
+1. **Check proxy is running:**
+ ```bash
+ ps aux | grep claude-proxy
+ ```
+
+2. **Restart proxy:**
+ ```bash
+ ./stop-proxy.sh
+ ./start-proxy.sh
+ ```
+
+3. **Check logs:**
+ ```bash
+ tail -f proxy.log
+ ```
+
+4. **Verify IP address:**
+ ```bash
+ ./get-local-ip.sh
+ ```
+
+### "Connection refused"
+
+**Cause:** Proxy not running or wrong IP
+
+**Solutions:**
+
+1. **Start proxy:**
+ ```bash
+ ./start-proxy.sh
+ ```
+
+2. **Check firewall:**
+ - macOS: System Settings → Network → Firewall → Allow Node.js
+ - Linux: `sudo ufw allow 8080/tcp`
+ - Windows: Defender Firewall → Allow port 8080
+
+3. **Test locally first:**
+ ```bash
+ curl http://localhost:8080/ping
+ # Should return: pong
+ ```
+
+## Claude CLI Issues
+
+### "Claude CLI not found"
+
+**Verify installation:**
+```bash
+which claude
+# macOS: /opt/homebrew/bin/claude or /usr/local/bin/claude
+# Linux: ~/.local/bin/claude or /usr/bin/claude
+```
+
+**Fix PATH:**
+```bash
+# Add to ~/.zshrc or ~/.bashrc
+export PATH="/opt/homebrew/bin:$PATH"
+source ~/.zshrc
+```
+
+**Reinstall Claude CLI:**
+- Visit: [https://claude.ai/code](https://claude.ai/code)
+- Follow installation instructions
+
+### "No response from Claude"
+
+**Test Claude manually:**
+```bash
+echo "Hello, reply with: Test successful" | claude
+```
+
+**Check authentication:**
+```bash
+claude --version
+# Should show version and auth status
+```
+
+**Reconfigure Claude:**
+- Check Z.ai account: [https://z.ai](https://z.ai)
+- Or Anthropic API key setup
+
+## Network Issues
+
+### Wrong IP Address Detected
+
+**Find correct IP:**
+```bash
+# macOS
+ipconfig getifaddr en0 # WiFi
+ipconfig getifaddr en1 # Ethernet
+
+# Linux
+ip route get 1 | awk '{print $7}'
+hostname -I
+
+# Windows
+ipconfig
+# Look for "IPv4 Address" under active adapter
+```
+
+**Update plugin settings:**
+- Use the correct IP in Base URL: `http://CORRECT-IP:8080`
+
+### Port 8080 Already in Use
+
+**Find what's using it:**
+```bash
+lsof -i :8080
+# or
+netstat -anp | grep 8080
+```
+
+**Change port:**
+
+1. Edit `claude-proxy.js`:
+ ```javascript
+ const PORT = process.env.PORT || 9000; // Change 8080 to 9000
+ ```
+
+2. Restart proxy:
+ ```bash
+ ./stop-proxy.sh
+ PORT=9000 ./start-proxy.sh
+ ```
+
+3. Update plugin Base URL: `http://your-ip:9000`
+
+## Performance Issues
+
+### Slow Response Times
+
+**Normal latency:**
+- Local network: 50-200ms
+- Claude CLI processing: 2-30 seconds depending on prompt
+
+**If consistently slow:**
+
+1. **Check network:**
+ ```bash
+ ping 192.168.1.105 # Your proxy IP
+ ```
+
+2. **Monitor logs:**
+ ```bash
+ tail -f proxy.log
+ ```
+
+3. **Check machine resources:**
+ - CPU usage: Claude CLI is CPU-intensive
+ - Memory: Ensure sufficient RAM available
+
+### Proxy Crashes
+
+**Check logs:**
+```bash
+cat proxy.log | tail -50
+```
+
+**Common causes:**
+- Out of memory: Close other applications
+- Claude CLI timeout: Increase timeout in `claude-proxy.js`
+- Malformed requests: Check plugin version compatibility
+
+**Restart with clean state:**
+```bash
+./stop-proxy.sh
+rm proxy.log
+./start-proxy.sh
+```
+
+## Plugin Integration Issues
+
+### "Invalid response format"
+
+**Cause:** Claude response doesn't match expected JSON format
+
+**Debug:**
+1. Check `proxy.log` for actual Claude output
+2. Test manually:
+ ```bash
+ curl -X POST http://localhost:8080/v1/messages \
+ -H "Content-Type: application/json" \
+ -d '{"messages":[{"role":"user","content":"Hello"}]}'
+ ```
+
+3. Update Claude CLI if outdated:
+ ```bash
+ claude --version
+ # Upgrade if needed
+ ```
+
+### Cost Tracking Shows $0
+
+**Expected behavior:** Local backend is free, plugin should show `$0.00 (Local)`
+
+**If concerned:**
+- This is correct - local backend has no API costs
+- Dashboard should show "X requests local (free)"
+
+## Advanced Troubleshooting
+
+### Enable Debug Logging
+
+Edit `claude-proxy.js`:
+```javascript
+const DEBUG = true; // Add at top of file
+
+// In /v1/messages handler:
+if (DEBUG) {
+ console.log('Full request:', JSON.stringify(req.body, null, 2));
+ console.log('Full response:', output);
+}
+```
+
+### Test with curl
+
+**Ping:**
+```bash
+curl http://localhost:8080/ping
+# Expected: pong
+```
+
+**Inference:**
+```bash
+curl -X POST http://localhost:8080/v1/messages \
+ -H "Content-Type: application/json" \
+ -d '{
+ "messages": [
+ {"role": "user", "content": "Reply with: Test successful"}
+ ]
+ }'
+```
+
+**Expected response:**
+```json
+{
+ "id": "local-1234567890",
+ "object": "chat.completion",
+ "model": "claude-local",
+ "choices": [{
+ "message": {
+ "content": "Test successful"
+ }
+ }]
+}
+```
+
+### Permissions Issues (macOS)
+
+**Make scripts executable:**
+```bash
+chmod +x start-proxy.sh stop-proxy.sh test-connection.sh get-local-ip.sh
+```
+
+**If "permission denied":**
+```bash
+# Check file permissions
+ls -la *.sh
+
+# Reset if needed
+chmod 755 *.sh
+```
+
+## Still Having Issues?
+
+1. **Check system requirements:**
+ - Node.js 18+: `node --version`
+ - Claude CLI installed: `which claude`
+ - Sufficient disk space: `df -h`
+
+2. **Collect diagnostic info:**
+ ```bash
+ echo "Node version:" $(node --version)
+ echo "Claude path:" $(which claude)
+ echo "Local IP:" $(./get-local-ip.sh)
+ echo "Proxy status:" $(ps aux | grep claude-proxy)
+ tail -20 proxy.log
+ ```
+
+3. **Reset everything:**
+ ```bash
+ ./stop-proxy.sh
+ rm -rf node_modules proxy.log proxy.pid
+ npm install
+ ./start-proxy.sh
+ ```
+
+4. **Get help:**
+ - GitHub Issues: [Report Bug](https://github.com/your/plugin/issues)
+ - Discord Community: [Join Chat](https://discord.gg/your-server)
+ - Include: OS, Node version, Claude CLI version, error logs
+
+## Environment-Specific Notes
+
+### macOS
+
+- Default Claude path: `/opt/homebrew/bin/claude`
+- Firewall: System Settings → Network → Firewall
+- IP detection: `ipconfig getifaddr en0`
+
+### Linux
+
+- Default Claude path: `~/.local/bin/claude`
+- Firewall: `sudo ufw allow 8080/tcp`
+- IP detection: `ip route get 1 | awk '{print $7}'`
+
+### Windows
+
+- Claude path varies, check `where claude`
+- Firewall: Windows Defender → Allow port 8080
+- IP detection: `ipconfig` (look for IPv4)
+- Scripts: Use Git Bash or WSL to run `.sh` scripts
+
+## Security Best Practices
+
+1. **LAN only:** Don't expose proxy to internet without authentication
+2. **Firewall:** Restrict to specific IPs if on shared network
+3. **Logs:** `proxy.log` contains all prompts - review periodically
+4. **Updates:** Keep Node.js and Claude CLI updated
+
+---
+
+**Last Updated:** 2025-02-27
+**Version:** 1.0.0
diff --git a/downloads/agentic-writer-local-backend/claude-proxy.js b/downloads/agentic-writer-local-backend/claude-proxy.js
new file mode 100644
index 0000000..0c7ff37
--- /dev/null
+++ b/downloads/agentic-writer-local-backend/claude-proxy.js
@@ -0,0 +1,122 @@
+const express = require('express');
+const { spawn } = require('child_process');
+const app = express();
+
+app.use(express.json());
+
+// Health check endpoint
+app.get('/ping', (req, res) => {
+ res.send('pong');
+});
+
+// Main inference endpoint (OpenAI-compatible format)
+app.post('/v1/messages', async (req, res) => {
+ const { messages } = req.body;
+
+ if (!messages || !Array.isArray(messages) || messages.length === 0) {
+ return res.status(400).json({
+ error: {
+ message: 'Invalid request: messages array required'
+ }
+ });
+ }
+
+ // Extract the last user message as the prompt
+ const lastMessage = messages[messages.length - 1];
+ const prompt = lastMessage.content;
+
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
+ console.log('Request from:', req.ip);
+ console.log('Prompt length:', prompt.length, 'chars');
+ console.log('Prompt preview:', prompt.substring(0, 150) + '...');
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
+
+ // Spawn Claude CLI process
+ const claude = spawn('claude', [], {
+ stdio: ['pipe', 'pipe', 'pipe']
+ });
+
+ let output = '';
+ let errorOutput = '';
+
+ claude.stdout.on('data', (data) => {
+ output += data.toString();
+ process.stdout.write('.');
+ });
+
+ claude.stderr.on('data', (data) => {
+ errorOutput += data.toString();
+ console.error('Claude stderr:', data.toString());
+ });
+
+ claude.on('close', (code) => {
+ console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
+ console.log('Claude exit code:', code);
+ console.log('Response length:', output.length, 'chars');
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
+
+ if (code !== 0 || !output.trim()) {
+ return res.status(500).json({
+ error: {
+ message: 'Claude CLI error',
+ details: errorOutput || 'No response from Claude'
+ }
+ });
+ }
+
+ // Return OpenAI-compatible response format
+ res.json({
+ id: 'local-' + Date.now(),
+ object: 'chat.completion',
+ created: Math.floor(Date.now() / 1000),
+ model: 'claude-local',
+ choices: [{
+ index: 0,
+ message: {
+ role: 'assistant',
+ content: output.trim()
+ },
+ finish_reason: 'stop'
+ }],
+ usage: {
+ prompt_tokens: 0,
+ completion_tokens: 0,
+ total_tokens: 0
+ }
+ });
+ });
+
+ claude.on('error', (err) => {
+ console.error('Failed to spawn Claude CLI:', err);
+ res.status(500).json({
+ error: {
+ message: 'Failed to spawn Claude CLI',
+ details: err.message
+ }
+ });
+ });
+
+ // Send prompt to Claude after brief pause
+ setTimeout(() => {
+ claude.stdin.write(prompt + '\n');
+ claude.stdin.end();
+ }, 100);
+});
+
+const PORT = process.env.PORT || 8080;
+app.listen(PORT, '0.0.0.0', () => {
+ console.log('═══════════════════════════════════════════════════');
+ console.log('🚀 Agentic Writer Local Backend Started!');
+ console.log('═══════════════════════════════════════════════════');
+ console.log(`Local: http://localhost:${PORT}`);
+ console.log(`Network: http://YOUR-IP:${PORT}`);
+ console.log('');
+ console.log('Plugin Configuration:');
+ console.log(` Base URL: http://YOUR-IP:${PORT}`);
+ console.log(` API Key: dummy`);
+ console.log(` Model: claude-local`);
+ console.log('');
+ console.log('Health check: GET /ping');
+ console.log('Inference: POST /v1/messages');
+ console.log('═══════════════════════════════════════════════════');
+});
diff --git a/downloads/agentic-writer-local-backend/get-local-ip.sh b/downloads/agentic-writer-local-backend/get-local-ip.sh
new file mode 100644
index 0000000..62b3c08
--- /dev/null
+++ b/downloads/agentic-writer-local-backend/get-local-ip.sh
@@ -0,0 +1,34 @@
+#!/bin/bash
+
+echo "Detecting your local IP address..."
+echo ""
+
+# Detect local IP based on OS
+if [[ "$OSTYPE" == "darwin"* ]]; then
+ # macOS - try en0 (WiFi) then en1 (Ethernet)
+ IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "")
+ INTERFACE=$(ifconfig en0 &>/dev/null && echo "en0 (WiFi)" || echo "en1 (Ethernet)")
+elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
+ # Linux
+ IP=$(ip route get 1 | awk '{print $7;exit}' 2>/dev/null || hostname -I | awk '{print $1}')
+ INTERFACE="default"
+else
+ # Windows or unknown
+ IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "")
+ INTERFACE="unknown"
+fi
+
+if [ -z "$IP" ]; then
+ echo "❌ Could not detect IP address automatically"
+ echo ""
+ echo "Manual detection:"
+ echo " macOS: ipconfig getifaddr en0"
+ echo " Linux: ip route get 1 | awk '{print \$7}'"
+ echo " Windows: ipconfig (look for IPv4 Address)"
+ exit 1
+fi
+
+echo "✅ Your local IP: $IP ($INTERFACE)"
+echo ""
+echo "Use this in your plugin settings:"
+echo " Base URL: http://$IP:8080"
diff --git a/downloads/agentic-writer-local-backend/package.json b/downloads/agentic-writer-local-backend/package.json
new file mode 100644
index 0000000..af80dad
--- /dev/null
+++ b/downloads/agentic-writer-local-backend/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "agentic-writer-local-backend",
+ "version": "1.0.0",
+ "description": "Local backend proxy for WP Agentic Writer using Claude CLI",
+ "main": "claude-proxy.js",
+ "scripts": {
+ "start": "node claude-proxy.js",
+ "test": "curl http://localhost:8080/ping"
+ },
+ "keywords": [
+ "wordpress",
+ "ai",
+ "claude",
+ "proxy"
+ ],
+ "author": "WP Agentic Writer",
+ "license": "GPL-2.0+",
+ "dependencies": {
+ "express": "^4.18.2"
+ }
+}
diff --git a/downloads/agentic-writer-local-backend/start-proxy.sh b/downloads/agentic-writer-local-backend/start-proxy.sh
new file mode 100644
index 0000000..b4fc7e0
--- /dev/null
+++ b/downloads/agentic-writer-local-backend/start-proxy.sh
@@ -0,0 +1,77 @@
+#!/bin/bash
+
+echo "🚀 Starting Agentic Writer Local Backend..."
+echo ""
+
+# Check dependencies
+if ! command -v node &> /dev/null; then
+ echo "❌ Node.js not found. Install from https://nodejs.org/"
+ exit 1
+fi
+
+if ! command -v claude &> /dev/null; then
+ echo "❌ Claude CLI not found. Install and configure first."
+ echo " Check: https://claude.ai/code or https://z.ai"
+ exit 1
+fi
+
+# Auto-install express if needed
+if [ ! -d "node_modules" ]; then
+ echo "📦 Installing dependencies..."
+ npm install
+fi
+
+# Detect local IP
+if [[ "$OSTYPE" == "darwin"* ]]; then
+ # macOS
+ LOCAL_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "127.0.0.1")
+elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
+ # Linux
+ LOCAL_IP=$(ip route get 1 | awk '{print $7;exit}' 2>/dev/null || echo "127.0.0.1")
+else
+ # Windows/other
+ LOCAL_IP="127.0.0.1"
+fi
+
+echo "✅ Dependencies OK"
+echo "✅ Claude CLI found: $(which claude)"
+echo ""
+echo "Starting proxy server..."
+echo ""
+
+# Start server in background
+nohup node claude-proxy.js > proxy.log 2>&1 &
+PID=$!
+echo $PID > proxy.pid
+
+# Wait for server to boot
+sleep 2
+
+# Test if running
+if kill -0 $PID 2>/dev/null; then
+ echo "═══════════════════════════════════════════════════"
+ echo "✅ Local Backend Running!"
+ echo "═══════════════════════════════════════════════════"
+ echo ""
+ echo "Your Configuration:"
+ echo " Base URL: http://$LOCAL_IP:8080"
+ echo " API Key: dummy"
+ echo " Model: claude-local"
+ echo ""
+ echo "Next Steps:"
+ echo " 1. Open your WordPress Admin"
+ echo " 2. Go to Agentic Writer → Settings → Local Backend"
+ echo " 3. Paste the Base URL above"
+ echo " 4. Click 'Test Connection'"
+ echo ""
+ echo "Commands:"
+ echo " Logs: tail -f proxy.log"
+ echo " Stop: ./stop-proxy.sh"
+ echo " Test: ./test-connection.sh"
+ echo "═══════════════════════════════════════════════════"
+else
+ echo "❌ Failed to start. Check proxy.log for errors."
+ cat proxy.log
+ rm -f proxy.pid
+ exit 1
+fi
diff --git a/downloads/agentic-writer-local-backend/stop-proxy.sh b/downloads/agentic-writer-local-backend/stop-proxy.sh
new file mode 100644
index 0000000..2327056
--- /dev/null
+++ b/downloads/agentic-writer-local-backend/stop-proxy.sh
@@ -0,0 +1,21 @@
+#!/bin/bash
+
+if [ -f proxy.pid ]; then
+ PID=$(cat proxy.pid)
+ if kill -0 $PID 2>/dev/null; then
+ kill $PID
+ rm proxy.pid
+ echo "🛑 Local Backend stopped (PID: $PID)"
+ else
+ echo "⚠️ No process found with PID: $PID"
+ rm proxy.pid
+ fi
+else
+ # Fallback: kill by process name
+ pkill -f claude-proxy.js
+ if [ $? -eq 0 ]; then
+ echo "🛑 Stopped all claude-proxy processes"
+ else
+ echo "ℹ️ No claude-proxy processes running"
+ fi
+fi
diff --git a/downloads/agentic-writer-local-backend/test-connection.sh b/downloads/agentic-writer-local-backend/test-connection.sh
new file mode 100644
index 0000000..cf51782
--- /dev/null
+++ b/downloads/agentic-writer-local-backend/test-connection.sh
@@ -0,0 +1,42 @@
+#!/bin/bash
+
+echo "Testing local backend connection..."
+echo ""
+
+# Test /ping endpoint
+echo "1. Testing health check..."
+PING_RESPONSE=$(curl -s http://localhost:8080/ping 2>&1)
+
+if [ "$PING_RESPONSE" = "pong" ]; then
+ echo " ✅ Health check passed"
+else
+ echo " ❌ Health check failed"
+ echo " Response: $PING_RESPONSE"
+ echo ""
+ echo "Is the proxy running? Check: ps aux | grep claude-proxy"
+ exit 1
+fi
+
+# Test /v1/messages endpoint
+echo "2. Testing inference..."
+RESPONSE=$(curl -s -X POST http://localhost:8080/v1/messages \
+ -H "Content-Type: application/json" \
+ -d '{"messages":[{"role":"user","content":"Reply with exactly: Test successful"}]}' 2>&1)
+
+echo " Response: $RESPONSE"
+
+if echo "$RESPONSE" | grep -q "choices"; then
+ echo " ✅ Inference endpoint working"
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "✅ Local Backend is working correctly!"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+else
+ echo " ❌ Inference test failed"
+ echo ""
+ echo "Troubleshooting:"
+ echo " 1. Check Claude CLI: echo 'test' | claude"
+ echo " 2. Check logs: tail -f proxy.log"
+ echo " 3. Restart proxy: ./stop-proxy.sh && ./start-proxy.sh"
+ exit 1
+fi
diff --git a/hybrid-local-cloud-ai-provider-b09890.md b/hybrid-local-cloud-ai-provider-b09890.md
new file mode 100644
index 0000000..a76f294
--- /dev/null
+++ b/hybrid-local-cloud-ai-provider-b09890.md
@@ -0,0 +1,556 @@
+# Local Backend + Codex Provider System with Cloud Fallback
+
+Implement a provider system allowing text generation via Local Backend (Claude CLI proxy) and Codex API, while keeping image generation on OpenRouter's cloud API.
+
+## Architecture Overview (Based on local-backend-feature.md)
+
+**Current State:**
+- Plugin uses `WP_Agentic_Writer_OpenRouter_Provider` for all AI tasks
+- All requests go to cloud APIs (OpenRouter)
+- Costs per token, rate limits apply
+- 23+ files directly call provider singleton
+
+**New State:**
+- **Local Backend**: User runs Node.js proxy on their machine (Claude CLI)
+- **Codex Provider**: Direct integration with OpenAI Codex API
+- **OpenRouter**: Fallback + image generation only
+- **Provider Manager**: Routes tasks to appropriate provider
+
+**Flow:**
+```
+WordPress Plugin → Provider Manager → Local Backend (http://user-ip:8080)
+ → Codex API (https://api.openai.com)
+ → OpenRouter (images + fallback)
+```
+
+## Provider Architecture
+
+### 1. Provider Interface (Common Contract)
+
+```php
+interface WP_Agentic_Writer_AI_Provider_Interface {
+ public function chat($messages, $options, $type);
+ public function chat_stream($messages, $options, $type, $callback);
+ public function generate_image($prompt, $model, $options);
+ public function is_configured();
+ public function test_connection();
+ public function supports_task_type($type);
+}
+```
+
+### 2. Provider Manager (Router)
+
+```php
+class WP_Agentic_Writer_Provider_Manager {
+ public static function get_provider_for_task($type) {
+ $settings = get_option('wp_agentic_writer_settings');
+ $task_providers = $settings['task_providers'] ?? [];
+
+ $provider_name = $task_providers[$type] ?? 'openrouter';
+
+ switch ($provider_name) {
+ case 'local_backend':
+ return new WP_Agentic_Writer_Local_Backend_Provider();
+ case 'codex':
+ return new WP_Agentic_Writer_Codex_Provider();
+ case 'openrouter':
+ default:
+ return WP_Agentic_Writer_OpenRouter_Provider::get_instance();
+ }
+ }
+}
+```
+
+### 3. Provider Implementations
+
+**A. Local Backend Provider** (Primary for text tasks)
+- **File**: `includes/class-local-backend-provider.php`
+- **Endpoint**: `http://192.168.x.x:8080` (user's machine)
+- **Protocol**: HTTP POST to `/v1/messages` (OpenAI-compatible)
+- **Backend**: Node.js proxy → Claude CLI
+- **Supports**: `chat`, `clarity`, `planning`, `writing`, `refinement`
+- **Cost**: $0 (uses user's Claude CLI + Z.ai/Anthropic)
+
+**B. Codex Provider** (Alternative text provider)
+- **File**: `includes/class-codex-provider.php`
+- **Endpoint**: `https://api.openai.com/v1/chat/completions`
+- **Protocol**: Standard OpenAI API
+- **Supports**: `chat`, `clarity`, `planning`, `writing`, `refinement`
+- **Cost**: Per OpenAI pricing
+
+**C. OpenRouter Provider** (Existing, for images + fallback)
+- **File**: `includes/class-openrouter-provider.php` (existing)
+- **Endpoint**: `https://openrouter.ai/api/v1/chat/completions`
+- **Supports**: ALL task types (fallback when local unavailable)
+- **Primary use**: `image` generation only in hybrid mode
+
+### Configuration Strategy
+
+#### Settings Structure
+
+```php
+'wp_agentic_writer_settings' => [
+ // Provider routing
+ 'provider_mode' => 'hybrid', // 'cloud', 'local', 'hybrid'
+ 'task_providers' => [
+ 'chat' => 'local_backend',
+ 'clarity' => 'local_backend',
+ 'planning' => 'local_backend',
+ 'writing' => 'local_backend',
+ 'refinement' => 'codex', // Or local_backend
+ 'image' => 'openrouter' // Always OpenRouter
+ ],
+
+ // Local Backend settings
+ 'local_backend_url' => 'http://192.168.1.105:8080',
+ 'local_backend_key' => 'dummy',
+ 'local_backend_model' => 'claude-via-cli',
+ 'local_backend_enabled' => true,
+
+ // Codex settings
+ 'codex_api_key' => 'sk-...',
+ 'codex_model' => 'gpt-4',
+ 'codex_enabled' => true,
+
+ // OpenRouter (existing)
+ 'openrouter_api_key' => 'sk-or-...',
+ 'image_model' => 'black-forest-labs/flux-1.1-pro',
+]
+```
+
+#### Recommended Configuration
+
+**Optimal Hybrid Setup:**
+```
+chat → Local Backend (free, private, fast)
+clarity → Local Backend (free, fast)
+planning → Local Backend (free, fast)
+writing → Local Backend (free, unlimited)
+refinement → Codex (cloud quality when needed)
+image → OpenRouter (only option for FLUX/Recraft)
+```
+
+**Benefits:**
+- 80%+ requests via Local Backend = $0 cost
+- Privacy for all text content
+- Codex as quality alternative
+- Images via best models (OpenRouter)
+
+## Implementation Components
+
+### 1. Local Backend Package (Separate Distribution)
+
+**Package:** `agentic-writer-local-backend.zip`
+
+**Contents:**
+```
+agentic-writer-local-backend/
+├── claude-proxy.js # Node.js HTTP server
+├── start-proxy.sh # Launch with IP detection
+├── stop-proxy.sh # Clean shutdown
+├── test-connection.sh # Verify proxy works
+├── get-local-ip.sh # Find machine IP
+├── package.json # Express dependency
+├── README.md # Setup guide
+└── TROUBLESHOOTING.md # Common issues
+```
+
+**Proxy Server (`claude-proxy.js`):**
+- Spawns user's Claude CLI for each request
+- OpenAI-compatible `/v1/messages` endpoint
+- Health check `/ping` endpoint
+- Binds to `0.0.0.0:8080` for LAN access
+- Logs requests for debugging
+
+**User Flow:**
+1. Download ZIP from plugin settings
+2. Extract and run `./start-proxy.sh`
+3. Copy displayed Base URL (e.g., `http://192.168.1.105:8080`)
+4. Paste into plugin settings
+5. Test connection → generate content
+
+### 2. Plugin Integration Files
+
+**New Files:**
+```
+includes/class-local-backend-provider.php
+includes/class-codex-provider.php
+includes/class-provider-manager.php
+includes/interface-ai-provider.php
+views/settings/tab-local-backend.php
+admin/js/test-local-backend.js
+downloads/agentic-writer-local-backend.zip
+```
+
+**Modified Files:**
+```
+includes/class-openrouter-provider.php
+ → Implement WP_Agentic_Writer_AI_Provider_Interface
+ → No behavior changes
+
+includes/class-gutenberg-sidebar.php
+ → Replace: WP_Agentic_Writer_OpenRouter_Provider::get_instance()
+ → With: WP_Agentic_Writer_Provider_Manager::get_provider_for_task($type)
+
++ 20 other files with provider calls
+```
+
+### 3. Settings UI
+
+**New Tab:** "Local Backend"
+- Download local backend package
+- Base URL input
+- API Key input (dummy)
+- Model selector
+- "Test Connection" button
+- Connection status indicator
+- Troubleshooting guide
+
+**Per-Task Routing (Advanced):**
+- Simple mode: Enable/Disable Local Backend (uses for all text)
+- Advanced mode: Task routing matrix
+
+### 4. Migration & Backwards Compatibility
+
+**Phase 1: Abstraction (Non-Breaking)**
+- Create `interface-ai-provider.php`
+- Create `class-provider-manager.php`
+- OpenRouter implements interface
+- All calls route through manager → defaults to OpenRouter
+- **100% backwards compatible, no settings changes**
+
+**Phase 2: Local Backend Provider**
+- Implement `class-local-backend-provider.php`
+- Create proxy package (claude-proxy.js + scripts)
+- Add "Local Backend" settings tab
+- Implement connection test handler
+- Test with user's local setup
+
+**Phase 3: Codex Provider**
+- Implement `class-codex-provider.php`
+- Add Codex API key to settings
+- Add Codex as task routing option
+- Test Codex integration
+
+**Phase 4: Update All Provider Calls**
+- Update 23+ files to use Provider Manager
+- Test all task types (chat, clarity, planning, writing, refinement, image)
+- Ensure streaming works with all providers
+- Verify cost tracking
+
+## Key Technical Decisions
+
+### Local Backend Protocol
+
+**Why OpenAI-compatible format:**
+- Plugin already uses message-based format
+- Easy to proxy to Claude CLI
+- Future-proof for other local models
+
+**Request Format:**
+```json
+POST http://192.168.1.105:8080/v1/messages
+{
+ "messages": [
+ {"role": "user", "content": "Write about AI"}
+ ]
+}
+```
+
+**Response Format:**
+```json
+{
+ "id": "local-1234567890",
+ "object": "chat.completion",
+ "model": "claude-local",
+ "choices": [{
+ "message": {
+ "role": "assistant",
+ "content": "Article content..."
+ },
+ "finish_reason": "stop"
+ }]
+}
+```
+
+### Codex Integration
+
+**Direct API Calls:**
+- Use OpenAI PHP library or `wp_remote_post`
+- Standard chat completions endpoint
+- Same format as OpenRouter
+
+**Why Codex:**
+- High quality for coding/technical content
+- Alternative to Local Backend
+- Cloud-based when user's machine offline
+
+## Cost Tracking Integration
+
+**Challenge:** Local Backend = $0, Codex/OpenRouter = cost
+
+**Solution:**
+```php
+// Provider returns cost data
+$result = $provider->chat($messages, $options, $type);
+$cost = $result['cost'] ?? 0;
+
+if ($cost > 0 && $post_id > 0) {
+ do_action('wp_aw_after_api_request',
+ $post_id,
+ $result['model'] ?? 'unknown',
+ $type,
+ $result['input_tokens'] ?? 0,
+ $result['output_tokens'] ?? 0,
+ $cost
+ );
+}
+```
+
+**Dashboard Display:**
+```
+Session Cost: $0.15
+ - Local Backend: 12 requests (free)
+ - Codex: 3 requests ($0.10)
+ - OpenRouter: 2 images ($0.05)
+
+Today: $2.40
+Month: $45.00
+```
+
+## Error Handling & Fallbacks
+
+### Local Backend Unreachable
+
+```php
+$local_provider = new WP_Agentic_Writer_Local_Backend_Provider();
+
+if (!$local_provider->is_available()) {
+ // Fallback to OpenRouter
+ error_log('Local Backend unavailable, using OpenRouter fallback');
+ return WP_Agentic_Writer_OpenRouter_Provider::get_instance();
+}
+```
+
+**Admin Notice:**
+"⚠️ Local Backend unreachable. Using OpenRouter fallback. Check proxy: `./start-proxy.sh`"
+
+### Connection Test Results
+
+```
+✅ Connected! Proxy responding correctly.
+❌ Connection timeout. Is proxy running? Check: ps aux | grep claude-proxy
+❌ Connection refused. Start proxy: ./start-proxy.sh
+❌ Wrong IP. Find correct IP: ./get-local-ip.sh
+❌ Claude CLI not responding. Test: echo "test" | claude
+```
+
+## UI/UX Considerations
+
+### Settings Page Flow
+
+1. **Tab: Local Backend**
+ - Big download button for proxy package
+ - Prerequisites checklist
+ - Base URL input (pre-filled from clipboard?)
+ - Test connection button
+ - Status: 🟢 Connected / 🔴 Offline
+
+2. **Tab: Providers**
+ - Simple mode: "Use Local Backend" toggle
+ - Advanced mode: Task routing matrix
+ - Provider status indicators
+
+3. **Tab: Models** (existing)
+ - Add Codex models
+ - Show provider per model
+
+### Sidebar Indicators
+
+**Provider Badge:**
+```
+🏠 Local (Free)
+🔗 Codex ($0.02)
+☁️ OpenRouter ($0.05)
+```
+
+**Connection Status:**
+```
+🟢 Local Backend: Connected
+🔴 Local Backend: Offline (using OpenRouter)
+```
+
+## Testing Strategy
+
+**Test Cases:**
+1. Cloud-only mode (existing behavior)
+2. Local-only mode (Ollama for all text)
+3. Hybrid mode (recommended config)
+4. Fallback when Ollama unavailable
+5. Streaming works with both providers
+6. Cost tracking accurate
+7. Model selection per provider
+
+## Performance Implications
+
+**Local Backend:**
+- **Latency**: ~50-200ms LAN vs ~500-2000ms cloud
+- **Throughput**: Limited by Claude CLI (~20-30 tokens/sec)
+- **Concurrency**: One request at a time (spawn per request)
+- **Quality**: Same as cloud Claude (uses same models)
+
+**Codex:**
+- **Latency**: Standard OpenAI API latency
+- **Quality**: High for technical/coding content
+- **Cost**: Per-token pricing
+
+**OpenRouter:**
+- **Image Generation**: Only option for FLUX/Recraft
+- **Fallback**: When local backend offline
+- **Cost**: Per-token pricing
+
+## Deployment Scenarios
+
+### Scenario 1: Local Development (User's Machine)
+
+**Setup:**
+- WordPress on Local by Flywheel (bricks.local)
+- Node.js proxy on same machine (localhost:8080)
+- Claude CLI configured with Z.ai
+
+**Config:**
+```
+Local Backend URL: http://localhost:8080
+All text tasks: Local Backend
+Images: OpenRouter
+Cost: ~$0 for text, ~$0.05/image
+```
+
+### Scenario 2: Local Dev + Cloud Production
+
+**Dev:**
+- Use Local Backend for free development
+- Test with real Claude quality
+
+**Production:**
+- Auto-switch to OpenRouter when local unavailable
+- Seamless fallback
+
+### Scenario 3: Agency with Shared Local Backend
+
+**Setup:**
+- One machine runs proxy on LAN
+- Multiple WordPress sites connect to it
+- All sites share one Z.ai account
+
+**Config:**
+```
+Local Backend URL: http://192.168.1.50:8080
+Cost: Free for entire team
+```
+
+## Implementation Phases
+
+### Phase 1: Core Infrastructure (Week 1)
+- [ ] Create provider interface
+- [ ] Create provider manager
+- [ ] OpenRouter implements interface
+- [ ] Update 3-5 files to use manager (test)
+- [ ] Verify backwards compatibility
+
+### Phase 2: Local Backend Package (Week 1)
+- [ ] Create `claude-proxy.js` with `/v1/messages` endpoint
+- [ ] Create startup/shutdown scripts
+- [ ] Test with actual Claude CLI
+- [ ] Package as ZIP
+- [ ] Write README with setup guide
+
+### Phase 3: Local Backend Provider (Week 2)
+- [ ] Implement `class-local-backend-provider.php`
+- [ ] Add settings tab UI
+- [ ] Implement connection test
+- [ ] Add ZIP download from settings
+- [ ] Test end-to-end flow
+
+### Phase 4: Codex Provider (Week 2)
+- [ ] Implement `class-codex-provider.php`
+- [ ] Add Codex API key to settings
+- [ ] Test Codex integration
+- [ ] Add to task routing options
+
+### Phase 5: Full Rollout (Week 3)
+- [ ] Update all 23+ files to use provider manager
+- [ ] Test all task types
+- [ ] Verify streaming works
+- [ ] Test cost tracking
+- [ ] Documentation
+
+### Phase 6: Polish (Week 3)
+- [ ] Connection status widget
+- [ ] Auto-fallback logic
+- [ ] Error messages with actionable guidance
+- [ ] Video tutorial
+- [ ] Troubleshooting guide
+
+## Implementation Estimate
+
+**Phase 1 (Infrastructure):** 4-5 hours
+- Provider interface, manager, OpenRouter refactor
+- Test with 3-5 files
+
+**Phase 2 (Local Backend Package):** 6-8 hours
+- Node.js proxy development
+- Scripts (start, stop, test)
+- ZIP packaging
+- Documentation
+
+**Phase 3 (Local Backend Integration):** 8-10 hours
+- Provider class
+- Settings UI
+- Connection test
+- End-to-end testing
+
+**Phase 4 (Codex):** 4-6 hours
+- Provider implementation
+- Settings integration
+- Testing
+
+**Phase 5 (Full Rollout):** 8-10 hours
+- Update 23+ files
+- Test all scenarios
+- Cost tracking
+- Documentation
+
+**Phase 6 (Polish):** 4-6 hours
+- UI improvements
+- Error handling
+- Video tutorial
+- Troubleshooting docs
+
+**Total:** 34-45 hours (~1-1.5 weeks)
+
+## Success Criteria
+
+✅ User can download local backend package
+✅ User can start proxy on their machine
+✅ Plugin connects to local backend successfully
+✅ All text tasks work via local backend ($0 cost)
+✅ Images work via OpenRouter
+✅ Codex works as alternative provider
+✅ Automatic fallback to OpenRouter when local offline
+✅ Cost tracking shows local = $0, cloud = actual cost
+✅ Streaming works with all providers
+✅ 100% backwards compatible (defaults to OpenRouter)
+
+## Ready to Implement
+
+This plan matches `local-backend-feature.md` requirements:
+- ✅ Claude CLI proxy via Node.js
+- ✅ HTTP-based local backend
+- ✅ Codex integration
+- ✅ OpenRouter for images
+- ✅ Provider abstraction system
+- ✅ Fallback logic
+- ✅ Complete UI/UX flow
+
+Confirm to proceed with implementation.
diff --git a/image-best-flow-recommendation.md b/image-best-flow-recommendation.md
new file mode 100644
index 0000000..ba5fb13
--- /dev/null
+++ b/image-best-flow-recommendation.md
@@ -0,0 +1,363 @@
+# WP Agentic Writer: Recommended Best Flow for Images (Cost-Optimized)
+
+## The Challenge You Asked About
+
+**Your question:**
+> "After article generation, how do we get image placement with alt by writing agent, then generate recommended images? Need to be cost-efficient with image prompts."
+
+**The answer:** Use the **writing agent itself** to analyze placement + generate prompts (tiny cost), then show user a preview before spending on image generation.
+
+---
+
+## Table of Contents
+
+1. [Recommended Best Flow (Option A - SAFEST)](#recommended-best-flow-option-a---safest)
+2. [Alternative Flows (B & C)](#alternative-flows-b--c)
+3. [Your Configuration (from screenshot)](#your-configuration-from-screenshot)
+4. [Cost Breakdown](#cost-breakdown)
+5. [Implementation Priority](#implementation-priority)
+
+---
+
+## Recommended Best Flow (Option A - SAFEST)
+
+This is the flow I recommend for **maximum cost control + quality** based on your plugin's design.
+
+### Step-by-Step
+
+```
+┌──────────────────────────────────────────────────────────┐
+│ USER ACTION: Generate Article │
+│ (Using Writing Model: Claude 3.5 Sonnet from preset) │
+└─────────────────┬────────────────────────────────────────┘
+ ↓
+┌──────────────────────────────────────────────────────────┐
+│ PLUGIN AUTOMATIC (Backend) │
+├──────────────────────────────────────────────────────────┤
+│ Step 1: ANALYZE PLACEMENT │
+│ • Model: Same Writing Model (Claude 3.5 Sonnet) │
+│ • Input: Full article markdown │
+│ • Output: JSON with placement points │
+│ • Cost: $0.0008 (tiny token call) │
+│ │
+│ Step 2: GENERATE IMAGE PROMPTS │
+│ • Model: Same Writing Model │
+│ • Input: Article + placement points │
+│ • Output: 3 image specs (prompt + alt + placement) │
+│ • Cost: $0.0015 (tiny token call) │
+│ │
+│ Status: "Analyzing images..." → "Ready to review" │
+└─────────────────┬────────────────────────────────────────┘
+ ↓
+┌──────────────────────────────────────────────────────────┐
+│ MODAL: IMAGE PREVIEW (User Review - $0 cost) │
+├──────────────────────────────────────────────────────────┤
+│ │
+│ "3 images planned for your article" │
+│ │
+│ ╔════════════════════════════════════════════════════╗ │
+│ ║ IMAGE 1: HERO (After Introduction) ║ │
+│ ║ ║ │
+│ ║ Placement: After intro, before "Getting Started" ║ │
+│ ║ Type: Hero/Dashboard ║ │
+│ ║ ║ │
+│ ║ Prompt (EDITABLE): ║ │
+│ ║ "N8n workflow automation dashboard screenshot, ║ │
+│ ║ showing colorful nodes on blue background, ║ │
+│ ║ modern minimalist SaaS interface" ║ │
+│ ║ ║ │
+│ ║ Alt Text: "N8n automation dashboard with nodes" ║ │
+│ ║ ║ │
+│ ║ [Edit Prompt ✎] [Generate $0.03] [Skip] ║ │
+│ ╚════════════════════════════════════════════════════╝ │
+│ │
+│ ╔════════════════════════════════════════════════════╗ │
+│ ║ IMAGE 2: DIAGRAM (After Section 1) ║ │
+│ ║ ║ │
+│ ║ Placement: After "Understanding Workflows" ║ │
+│ ║ Type: Technical Diagram ║ │
+│ ║ ║ │
+│ ║ Prompt (EDITABLE): ║ │
+│ ║ "Workflow architecture diagram showing trigger, ║ │
+│ ║ condition, action components with arrows, ║ │
+│ ║ technical line-art style, blue palette" ║ │
+│ ║ ║ │
+│ ║ Alt Text: "Workflow trigger-condition-action flow" ║ │
+│ ║ ║ │
+│ ║ [Edit Prompt ✎] [Generate $0.03] [Skip] ║ │
+│ ╚════════════════════════════════════════════════════╝ │
+│ │
+│ ╔════════════════════════════════════════════════════╗ │
+│ ║ IMAGE 3: SCREENSHOT (Before Conclusion) ║ │
+│ ║ ║ │
+│ ║ Placement: Before "Conclusion" ║ │
+│ ║ Type: Product Screenshot ║ │
+│ ║ ║ │
+│ ║ Prompt (EDITABLE): ║ │
+│ ║ "N8n real-time monitoring dashboard showing ║ │
+│ ║ workflow execution logs, status indicators, ║ │
+│ ║ professional SaaS product design" ║ │
+│ ║ ║ │
+│ ║ Alt Text: "N8n real-time monitoring interface" ║ │
+│ ║ ║ │
+│ ║ [Edit Prompt ✎] [Generate $0.03] [Skip] ║ │
+│ ╚════════════════════════════════════════════════════╝ │
+│ │
+│ ───────────────────────────────────────────────────── │
+│ Cost Estimate: Individual generation │
+│ • Generate all 3: $0.09–0.21 (based on image tier) │
+│ • Generate 2: $0.06–0.14 │
+│ • Generate 1: $0.03–0.07 │
+│ │
+│ [Generate All 3] [Generate Selected] [Skip Images] │
+│ [Cancel] │
+└──────────────────┬───────────────────────────────────────┘
+ ↓
+ USER CHOOSES (examples):
+ • Click [Generate All 3] → All images generated now
+ • Click [Generate] on Image 1 only → Hero only
+ • Edit Image 1 prompt, then [Generate] → Custom prompt
+ • Click [Skip Images] → No images, save cost
+ ↓
+┌──────────────────────────────────────────────────────────┐
+│ AUTOMATIC IMAGE INSERTION │
+├──────────────────────────────────────────────────────────┤
+│ For each generated image: │
+│ 1. Download image from FLUX.2/image model │
+│ 2. Upload to WordPress media library │
+│ 3. Insert into article at placement point │
+│ 4. Add alt text automatically │
+│ │
+│ Status: "Inserting images..." → "Done!" │
+└─────────────────┬────────────────────────────────────────┘
+ ↓
+┌──────────────────────────────────────────────────────────┐
+│ FINAL RESULT: Article with Images │
+├──────────────────────────────────────────────────────────┤
+│ │
+│ # Getting Started with N8n Automation │
+│ │
+│ Introduction paragraph... │
+│ │
+│  │
+│ │
+│ ## Getting Started │
+│ Content... │
+│ │
+│ ## Understanding Workflows │
+│ Content... │
+│ │
+│  │
+│ │
+│ ## Advanced Monitoring │
+│ Content... │
+│ │
+│  │
+│ │
+│ [Preview in Gutenberg] [Publish] [Download MD] │
+└──────────────────────────────────────────────────────────┘
+```
+
+### Key Features of Option A
+
+✅ **Cost control:** User sees cost before spending
+✅ **Quality control:** Can edit prompts before generation
+✅ **Flexibility:** Generate 0, 1, 2, or 3 images
+✅ **User review:** Know exactly what images they'll get
+✅ **Selective generation:** Generate only what matters
+✅ **Smart placement:** Analyzed by writing agent (best understanding)
+✅ **Efficient prompts:** Precise, contextual, no trial-and-error
+
+### Costs with Option A
+
+| Scenario | Analysis | Prompts | Images | Total |
+|----------|----------|---------|--------|-------|
+| User generates all 3 | $0.0008 | $0.0015 | $0.09–0.21 | $0.092–0.212 |
+| User generates 2 | $0.0008 | $0.0015 | $0.06–0.14 | $0.063–0.142 |
+| User generates 1 (hero) | $0.0008 | $0.0015 | $0.03–0.07 | $0.032–0.072 |
+| User skips images | $0.0008 | $0.0015 | $0 | $0.0023 |
+
+**Best case:** User generates 1 hero = **$0.032–0.072/article** (vs $0.21–0.70 with trial-and-error)
+
+---
+
+## Alternative Flows (B & C)
+
+### Option B: Automatic Full Generation (FASTEST)
+
+```
+Article generated
+ ↓
+Plugin automatically generates ALL images without review
+ ↓
+"Article + images ready!" (1-2 minutes total)
+```
+
+**Pros:** One-click, minimal user interaction
+**Cons:** Always costs full image budget (no user control)
+**Cost:** Full $0.12–0.35 (analysis + all images always generated)
+
+**Use when:** User has unlimited budget OR you offer it as "premium fast mode"
+
+---
+
+### Option C: Smart Selective with Recommendations (BALANCED)
+
+```
+Similar to Option A, but plugin recommends:
+- "Hero image has best impact/cost ratio" [Generate hero]
+- "Diagrams help understanding" [Generate diagram?]
+- "Screenshot is optional" [Generate?]
+```
+
+**Pros:** Guides user toward cost-effective choices
+**Cons:** Slightly more UI complexity
+**Cost:** User-controlled (guided)
+
+**Use when:** You want to educate users about cost-benefit tradeoffs
+
+---
+
+## Your Configuration (from screenshot)
+
+Based on your current model configuration:
+
+```
+Chat Model: Google: Gemini 2.5 Flash
+Clarity Model: Google: Gemini 2.5 Flash
+Planning Model: Google: Gemini 2.5 Flash
+Writing Model: Anthropic: Claude 3.5 Sonnet
+Refinement Model: Anthropic: Claude 3.5 Sonnet
+Image Model: Gpt 4o (or FLUX.2 from preset)
+```
+
+### Recommended Implementation
+
+```php
+// Option A implementation (safest, recommended)
+
+// 1. After article generation, automatically:
+$placement_data = analyze_article_for_images(
+ $article,
+ 'anthropic/claude-3.5-sonnet' // Use same writing model
+);
+
+// 2. Generate prompts
+$image_specs = generate_image_prompts(
+ $article,
+ $placement_data,
+ 'anthropic/claude-3.5-sonnet' // Same model
+);
+
+// 3. Show UI (don't generate images yet)
+show_image_review_modal($image_specs);
+
+// 4. User clicks [Generate All] or individual [Generate]
+// 5. Only then call image generation
+
+// Cost so far: $0.0023 (tiny)
+// User controls image generation cost: $0.03–0.21
+```
+
+---
+
+## Cost Breakdown
+
+### Analysis + Prompt Generation (Automatic, Non-Optional)
+
+| Task | Tokens In | Tokens Out | Cost |
+|------|-----------|------------|------|
+| Placement analysis | 2,000 | 800 | $0.0008 |
+| Prompt generation | 3,000 | 1,000 | $0.0015 |
+| **Total** | **5,000** | **1,800** | **$0.0023** |
+
+**This is already paid by article generation (uses writing model already called).**
+
+### Image Generation (User-Controlled)
+
+**Per image (based on model tier):**
+
+| Image Model | Cost/Image | 3 Images |
+|------------|-----------|----------|
+| FLUX.2 klein (Budget) | $0.03–0.05 | $0.09–0.15 |
+| Riverflow/FLUX.2 Pro (Balanced) | $0.06–0.10 | $0.18–0.30 |
+| FLUX.2 max (Premium) | $0.07–0.21 | $0.21–0.63 |
+
+### Total Article Cost
+
+| Scenario | Text | Analysis | Prompts | Images | Total |
+|----------|------|----------|---------|--------|-------|
+| Article only | $0.03–0.07 | $0.0008 | $0.0015 | $0 | **$0.032–0.072** |
+| Article + 1 hero | $0.03–0.07 | $0.0008 | $0.0015 | $0.03–0.21 | **$0.062–0.292** |
+| Article + 2 images | $0.03–0.07 | $0.0008 | $0.0015 | $0.06–0.42 | **$0.092–0.492** |
+| Article + 3 images | $0.03–0.07 | $0.0008 | $0.0015 | $0.09–0.63 | **$0.122–0.702** |
+
+---
+
+## Implementation Priority
+
+### Phase 1: Core Logic (3-4 hours)
+
+```php
+✓ analyze_article_for_images() // Identify placements
+✓ generate_image_prompts() // Create specs
+✓ generate_image_from_prompt() // Call image model
+✓ insert_images_into_article() // Embed in markdown
+```
+
+### Phase 2: User Interface (4-5 hours)
+
+```php
+✓ Image review modal UI // Show 3 specs
+✓ [Generate] button per image // Individual generation
+✓ [Generate All] button // Batch generation
+✓ [Edit Prompt] capability // Let users customize
+✓ Cost calculator display // Show estimated cost
+```
+
+### Phase 3: Polish (2-3 hours)
+
+```php
+✓ Image preview before insertion // Show user the image
+✓ Error handling + retry logic // Handle failures
+✓ Success notifications // Feedback
+✓ Progress indicators // "Generating image 2/3..."
+```
+
+---
+
+## Why Option A is Best for Your Plugin
+
+1. **User controls costs** → They see preview before spending
+2. **Respects budgets** → Budget tier users generate 1 image
+3. **Quality focus** → Users can edit prompts if needed
+4. **Flexible** → Some users skip images entirely (saves costs)
+5. **Educational** → Users learn what good prompts look like
+6. **Smart prompts** → Using writing agent (best context understanding)
+
+---
+
+## Summary: Recommended Best Flow
+
+```
+AUTOMATIC (Backend):
+1. Analyze article for placement → $0.0008
+2. Generate image specs/prompts → $0.0015
+3. Show user preview modal → $0 (free review)
+
+MANUAL (User Selects):
+4. User clicks [Generate] on images → User controls cost
+5. Plugin inserts into article → Automatic
+
+RESULT:
+- Article + images ready for Gutenberg
+- User spent only what they wanted
+- Total cost: $0.032–0.702 (user-controlled)
+- Quality: High (smart placement + customizable prompts)
+```
+
+---
+
+**Document version:** 1.0
+**Date:** January 27, 2026
+**Status:** Ready for Implementation
diff --git a/image-gen-flow.md b/image-gen-flow.md
new file mode 100644
index 0000000..40f2f79
--- /dev/null
+++ b/image-gen-flow.md
@@ -0,0 +1,1345 @@
+# WP Agentic Writer: Image Generation & Selection Flow
+
+## Executive Summary
+
+This document defines the **complete lifecycle** of images from agent recommendation → generation → variant management → final WordPress Media upload.
+
+**Key principle:** Regenerate creates NEW variants (doesn't delete old ones). All temp images belong to a post. Users see variants in modal, select one, and commit to WordPress Media with recommended alt text.
+
+---
+
+## Table of Contents
+
+1. [Overview & Architecture](#overview--architecture)
+2. [Data Model](#data-model)
+3. [Flow 1: Article Generation & Image Recommendations](#flow-1-article-generation--image-recommendations)
+4. [Flow 2: Image Block Toolbar & Modal](#flow-2-image-block-toolbar--modal)
+5. [Flow 3: Image Generation (Variants)](#flow-3-image-generation-variants)
+6. [Flow 4: Variant Selection & Media Upload](#flow-4-variant-selection--media-upload)
+7. [Flow 5: Temp Image Management](#flow-5-temp-image-management)
+8. [Admin Page: Image Library](#admin-page-image-library)
+9. [REST API Endpoints](#rest-api-endpoints)
+10. [Implementation Checklist](#implementation-checklist)
+
+---
+
+## Overview & Architecture
+
+### Core concept
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ WRITING AGENT generates article + 3 image recommendations │
+└────────────────────┬────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────────┐
+│ Plugin converts recommendations → 3 core/image blocks │
+│ Each block has: data-agent-image-id="img_X" │
+│ Stores recommendations in wp_agentic_images table │
+└────────────────────┬────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────────┐
+│ USER EDITS IN GUTENBERG EDITOR │
+│ │
+│ [Image block] [Image block] [Image block] │
+│ ↓ Generate ↓ Generate ↓ Generate │
+│ ↓ (Toolbar btn) ↓ (Toolbar btn) ↓ (Toolbar btn) │
+│ │
+│ Each opens YOUR modal with prompt + alt editable │
+└────────────────────┬────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────────┐
+│ USER ACTIONS IN MODAL │
+│ │
+│ [View Prompt] [Edit Prompt] │
+│ [View Alt] [Edit Alt] │
+│ [Generate] → generates 1-3 variants │
+│ stores ALL in /wp-content/agentic-writer-temp/ │
+│ │
+│ [Regenerate] → generates MORE variants (doesn't delete old) │
+│ adds to same image_id pool │
+│ │
+│ [Use Media Library] → opens core media modal │
+│ with pre-filled alt suggestion │
+└────────────────────┬────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────────┐
+│ USER SELECTS A VARIANT │
+│ │
+│ [Variant 1] [Variant 2] [Variant 3] [Variant 4] │
+│ [Select] [Select] [Select] [Select] │
+│ │
+│ Other variants stay in temp folder + DB │
+└────────────────────┬────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────────┐
+│ BACKEND: Commit Selected Variant to WP Media │
+│ │
+│ 1. media_handle_sideload(temp_image_path) │
+│ 2. Set attachment alt → recommended alt (or user-edited) │
+│ 3. Update wp_agentic_images: status='committed' │
+│ attachment_id=123 │
+│ 4. Return attachment ID + URL │
+└────────────────────┬────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────────┐
+│ FRONTEND: Update Gutenberg Image Block │
+│ │
+│ updateBlockAttributes(block.clientId, { │
+│ id: attachment_id, │
+│ url: attachment_url, │
+│ alt: recommended_alt │
+│ }) │
+│ │
+│ Remove data-agent-image-id (no longer a placeholder) │
+└────────────────────────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────────┐
+│ TEMP IMAGE CLEANUP (Manual + Auto) │
+│ │
+│ Admin page "Generated Images" tab shows: │
+│ - All temp images (by post, by status) │
+│ - [Delete selected] [Auto-cleanup old (>7 days)] │
+│ │
+│ Cron job: wp_schedule_event() → delete temps > 7 days │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## Data Model
+
+### Table: `wp_agentic_images`
+
+Stores recommendations + generation history for each image per post.
+
+```sql
+CREATE TABLE wp_agentic_images (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ post_id BIGINT NOT NULL,
+
+ -- Recommendation from agent
+ agent_image_id VARCHAR(50) NOT NULL, -- e.g., "img_hero_1"
+ placement VARCHAR(100), -- "intro_hero", "after_section_2"
+ section_title VARCHAR(255), -- "Introduction to n8n"
+
+ -- Original recommendation
+ prompt_initial TEXT NOT NULL, -- Agent's initial prompt
+ alt_text_initial TEXT, -- Agent's suggested alt
+
+ -- User edits (nullable)
+ prompt_edited TEXT, -- Null if user didn't edit
+ alt_text_edited TEXT, -- Null if user didn't edit
+
+ -- Committed image (when user selects a variant)
+ attachment_id BIGINT, -- WP attachment ID (null until committed)
+ status VARCHAR(30) DEFAULT 'pending', -- pending, generating, committed, discarded
+
+ -- Cost tracking
+ cost_estimate DECIMAL(10, 4), -- Based on image model pricing
+ cost_actual DECIMAL(10, 4), -- Updated after generation
+ image_model VARCHAR(100), -- Which model was used
+
+ -- Metadata
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+
+ KEY idx_post (post_id),
+ KEY idx_agent_image_id (post_id, agent_image_id),
+ KEY idx_status (status),
+ KEY idx_created (created_at)
+);
+```
+
+### Table: `wp_agentic_images_variants`
+
+Tracks all generated variants (temp images) for each agent_image_id.
+
+```sql
+CREATE TABLE wp_agentic_images_variants (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+
+ -- Reference to main image record
+ agentic_image_id BIGINT NOT NULL,
+ post_id BIGINT NOT NULL,
+ agent_image_id VARCHAR(50) NOT NULL, -- e.g., "img_hero_1"
+
+ -- Variant details
+ variant_number INT DEFAULT 1, -- 1st, 2nd, 3rd generation attempt
+ temp_file_path VARCHAR(500) NOT NULL, -- /wp-content/agentic-writer-temp/xxx.jpg
+ temp_file_url VARCHAR(500) NOT NULL, -- URL to temp image
+ file_size INT, -- In bytes
+
+ -- Generation details
+ prompt_used TEXT, -- Exact prompt sent to image model
+ image_model_used VARCHAR(100), -- Which model generated this
+ generation_time INT, -- Seconds to generate
+ cost DECIMAL(10, 4), -- Cost of this generation
+
+ -- Selection status
+ is_selected TINYINT DEFAULT 0, -- 1 if user selected this variant
+ selected_at TIMESTAMP NULL,
+
+ -- Lifecycle
+ status VARCHAR(30) DEFAULT 'temp', -- temp, selected, discarded, auto_deleted
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ deleted_at TIMESTAMP NULL,
+
+ KEY idx_agentic_image (agentic_image_id),
+ KEY idx_post (post_id),
+ KEY idx_status (status),
+ KEY idx_created (created_at)
+);
+```
+
+### File system: Temp images
+
+```
+/wp-content/agentic-writer-temp/
+├── post_/
+│ ├── img_hero_1/
+│ │ ├── variant_1.jpg
+│ │ ├── variant_2.jpg
+│ │ └── variant_3.jpg
+│ ├── img_diag_1/
+│ │ └── variant_1.jpg
+│ └── img_section_2/
+│ ├── variant_1.jpg
+│ └── variant_2.jpg
+└── [cleanup cron removes files 7+ days old]
+```
+
+---
+
+## Flow 1: Article Generation & Image Recommendations
+
+### What the agent returns
+
+After writing article, agent provides JSON response:
+
+```json
+{
+ "status": "article_complete",
+ "article_blocks": [
+ {
+ "blockName": "core/paragraph",
+ "attrs": {},
+ "innerBlocks": [],
+ "innerHTML": "Introduction text...
"
+ },
+ {
+ "blockName": "core/image",
+ "attrs": {
+ "id": null,
+ "url": null,
+ "alt": "",
+ "data-agent-image-id": "img_hero_1"
+ }
+ }
+ ],
+ "images": [
+ {
+ "agent_image_id": "img_hero_1",
+ "placement": "intro_hero",
+ "section_title": "Introduction",
+ "prompt": "N8n workflow automation dashboard...",
+ "alt": "N8n automation dashboard with workflow nodes",
+ "image_model": "sourceful/riverflow-v2-max"
+ },
+ {
+ "agent_image_id": "img_diag_1",
+ "placement": "after_section_2",
+ "section_title": "How Workflows Run",
+ "prompt": "Workflow architecture diagram...",
+ "alt": "Workflow trigger-condition-action diagram",
+ "image_model": "sourceful/riverflow-v2-max"
+ },
+ {
+ "agent_image_id": "img_section_4",
+ "placement": "before_conclusion",
+ "section_title": "Real-world Example",
+ "prompt": "Developer using N8n dashboard...",
+ "alt": "Developer working with N8n automation dashboard",
+ "image_model": "sourceful/riverflow-v2-max"
+ }
+ ]
+}
+```
+
+### Backend handling
+
+```php
+ $post_id,
+ 'post_content' => $post_content,
+ 'post_status' => 'draft'
+ ]);
+
+ // 2. Save each image recommendation
+ foreach ( $agent_response['images'] as $image_spec ) {
+ self::save_image_recommendation(
+ $post_id,
+ $image_spec
+ );
+ }
+
+ // 3. Return success message for chat
+ return [
+ 'status' => 'article_complete',
+ 'post_id' => $post_id,
+ 'message' => sprintf(
+ 'Article created. %d image suggestions ready in the Images panel.',
+ count( $agent_response['images'] )
+ )
+ ];
+}
+
+/**
+ * Store individual image recommendation
+ */
+private static function save_image_recommendation( $post_id, $image_spec ) {
+ global $wpdb;
+
+ $wpdb->insert(
+ $wpdb->prefix . 'agentic_images',
+ [
+ 'post_id' => $post_id,
+ 'agent_image_id' => $image_spec['agent_image_id'],
+ 'placement' => $image_spec['placement'],
+ 'section_title' => $image_spec['section_title'],
+ 'prompt_initial' => $image_spec['prompt'],
+ 'alt_text_initial' => $image_spec['alt'],
+ 'image_model' => $image_spec['image_model'],
+ 'status' => 'pending'
+ ],
+ [ '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ]
+ );
+}
+```
+
+---
+
+## Flow 2: Image Block Toolbar & Modal
+
+### Block toolbar button
+
+In Gutenberg, each `core/image` block with `data-agent-image-id` gets a toolbar button:
+
+```jsx
+// registerPlugin('agentic-image-toolbar', {
+// render() {
+// return
+// }
+// })
+
+function ImageBlockToolbar() {
+ const { selectedBlockClientId } = useSelect(blockEditorStore);
+ const block = useSelect(
+ select => select(blockEditorStore).getBlock(selectedBlockClientId),
+ [selectedBlockClientId]
+ );
+
+ if (!block || block.name !== 'core/image') return null;
+
+ const agentImageId = block.attributes['data-agent-image-id'];
+ if (!agentImageId) return null; // Not an agent placeholder
+
+ return (
+
+ openImageModal(agentImageId, block)}
+ icon="image"
+ />
+
+ );
+}
+
+/**
+ * Opens YOUR custom modal (not WP media modal)
+ */
+function openImageModal(agentImageId, block) {
+ wp.data.dispatch('agentic-writer').openImageGenerationModal({
+ agentImageId,
+ blockClientId: block.clientId,
+ postId: wp.data.select('core/editor').getCurrentPostId()
+ });
+}
+```
+
+### Your custom modal
+
+```jsx
+function ImageGenerationModal({ agentImageId, blockClientId, postId }) {
+ const [prompt, setPrompt] = useState(initialPrompt);
+ const [alt, setAlt] = useState(initialAlt);
+ const [variants, setVariants] = useState([]);
+ const [isGenerating, setIsGenerating] = useState(false);
+ const [step, setStep] = useState('edit'); // 'edit' | 'generating' | 'select'
+
+ const handleGenerate = async () => {
+ setIsGenerating(true);
+ setStep('generating');
+
+ try {
+ const response = await fetch('/wp-json/agentic-writer/v1/generate-image', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ post_id: postId,
+ agent_image_id: agentImageId,
+ prompt: prompt, // User-edited if changed
+ alt: alt // User-edited if changed
+ })
+ });
+
+ const result = await response.json();
+ setVariants(result.variants);
+ setStep('select');
+ } catch (error) {
+ console.error('Generation failed:', error);
+ } finally {
+ setIsGenerating(false);
+ }
+ };
+
+ const handleRegenerate = async () => {
+ // Re-generate: creates MORE variants
+ // Does NOT delete existing ones
+ await handleGenerate();
+ };
+
+ const handleSelect = async (variantId) => {
+ // Commit this variant to WP Media
+ const response = await fetch('/wp-json/agentic-writer/v1/commit-image', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ post_id: postId,
+ agent_image_id: agentImageId,
+ variant_id: variantId,
+ alt: alt // Final alt text
+ })
+ });
+
+ const result = await response.json();
+
+ // Update Gutenberg block
+ wp.data.dispatch('core/block-editor').updateBlockAttributes(
+ blockClientId,
+ {
+ id: result.attachment_id,
+ url: result.attachment_url,
+ alt: result.alt,
+ 'data-agent-image-id': undefined // Remove placeholder marker
+ }
+ );
+
+ // Close modal
+ wp.data.dispatch('agentic-writer').closeImageGenerationModal();
+ };
+
+ if (step === 'edit') {
+ return (
+
+
+
+
+
+
+
+
+ setAlt(e.target.value)}
+ placeholder="Alt text for accessibility"
+ />
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ if (step === 'generating') {
+ return (
+ {}}>
+
+ Generating image variants...
+
+
+ );
+ }
+
+ // step === 'select'
+ return (
+
+
+ {variants.map(variant => (
+
+
+
+ Generation: {variant.variant_number}
+ Cost: ${variant.cost.toFixed(3)}
+
+
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
+```
+
+---
+
+## Flow 3: Image Generation (Variants)
+
+### Backend: Generate endpoint
+
+```php
+get_param('post_id');
+ $agent_image_id = $request->get_param('agent_image_id');
+ $prompt = $request->get_param('prompt');
+ $alt = $request->get_param('alt');
+
+ // Validate post ownership
+ if (!current_user_can('edit_post', $post_id)) {
+ return new WP_Error('unauthorized', 'Not allowed', ['status' => 403]);
+ }
+
+ // Get image record
+ $image_record = self::get_image_record($post_id, $agent_image_id);
+ if (!$image_record) {
+ return new WP_Error('not_found', 'Image not found', ['status' => 404]);
+ }
+
+ // Get config (model, preset, etc)
+ $image_model = $image_record->image_model;
+ $num_variants = apply_filters('agentic_writer_num_variants', 3); // Default: 3
+
+ // Create temp directory for this image
+ $temp_dir = self::get_temp_dir_for_image($post_id, $agent_image_id);
+ if (!is_dir($temp_dir)) {
+ wp_mkdir_p($temp_dir);
+ }
+
+ // Update status
+ self::update_image_record($image_record->id, ['status' => 'generating']);
+
+ $variants = [];
+ $total_cost = 0;
+
+ // Generate N variants (sequentially or in batch)
+ for ($i = 1; $i <= $num_variants; $i++) {
+ $variant_result = self::generate_single_variant(
+ $image_model,
+ $prompt,
+ $temp_dir,
+ $i,
+ $post_id,
+ $agent_image_id
+ );
+
+ if (is_wp_error($variant_result)) {
+ // Log error but continue (partial success ok)
+ error_log("Variant $i failed: " . $variant_result->get_error_message());
+ continue;
+ }
+
+ $variants[] = $variant_result;
+ $total_cost += $variant_result['cost'];
+ }
+
+ // Update main image record
+ self::update_image_record($image_record->id, [
+ 'prompt_edited' => $prompt, // User's edited prompt (if changed from initial)
+ 'alt_text_edited' => $alt, // User's edited alt (if changed from initial)
+ 'cost_actual' => $total_cost,
+ 'status' => 'pending' // Waiting for user to select variant
+ ]);
+
+ return new WP_REST_Response([
+ 'success' => true,
+ 'variants' => $variants,
+ 'total_cost' => $total_cost,
+ 'message' => sprintf(
+ 'Generated %d variants. Total cost: $%.3f',
+ count($variants),
+ $total_cost
+ )
+ ]);
+}
+
+/**
+ * Generate a single variant and save to temp folder
+ */
+private static function generate_single_variant(
+ $image_model,
+ $prompt,
+ $temp_dir,
+ $variant_number,
+ $post_id,
+ $agent_image_id
+) {
+ // Get generation time start
+ $start_time = microtime(true);
+
+ // Call image generation API
+ $api_response = self::call_image_api(
+ $image_model,
+ $prompt
+ );
+
+ if (is_wp_error($api_response)) {
+ return $api_response;
+ }
+
+ $generation_time = microtime(true) - $start_time;
+
+ // Download image and save to temp folder
+ $temp_filename = sprintf(
+ 'variant_%d_%d.jpg',
+ $variant_number,
+ time()
+ );
+ $temp_filepath = $temp_dir . '/' . $temp_filename;
+
+ $download_result = self::download_image(
+ $api_response['url'],
+ $temp_filepath
+ );
+
+ if (is_wp_error($download_result)) {
+ return $download_result;
+ }
+
+ $file_size = filesize($temp_filepath);
+ $cost = self::calculate_image_cost($image_model, $file_size);
+ $temp_file_url = str_replace(
+ ABSPATH,
+ site_url() . '/',
+ $temp_filepath
+ );
+
+ // Save variant record to DB
+ global $wpdb;
+ $variant_id = $wpdb->insert_id = $wpdb->insert(
+ $wpdb->prefix . 'agentic_images_variants',
+ [
+ 'agentic_image_id' => $image_record->id,
+ 'post_id' => $post_id,
+ 'agent_image_id' => $agent_image_id,
+ 'variant_number' => $variant_number,
+ 'temp_file_path' => $temp_filepath,
+ 'temp_file_url' => $temp_file_url,
+ 'file_size' => $file_size,
+ 'prompt_used' => $prompt,
+ 'image_model_used' => $image_model,
+ 'generation_time' => $generation_time,
+ 'cost' => $cost,
+ 'status' => 'temp'
+ ]
+ );
+
+ return [
+ 'id' => $variant_id,
+ 'temp_file_url' => $temp_file_url,
+ 'variant_number' => $variant_number,
+ 'cost' => $cost,
+ 'generation_time' => $generation_time
+ ];
+}
+
+/**
+ * Call OpenRouter image generation API
+ */
+private static function call_image_api($image_model, $prompt) {
+ $api_key = get_option('agentic_writer_openrouter_api_key');
+
+ $response = wp_remote_post(
+ 'https://openrouter.ai/api/v1/images/generations',
+ [
+ 'headers' => [
+ 'Authorization' => 'Bearer ' . $api_key,
+ 'Content-Type' => 'application/json',
+ ],
+ 'body' => wp_json_encode([
+ 'model' => $image_model,
+ 'prompt' => $prompt,
+ 'n' => 1,
+ 'size' => '1024x576', // Standard blog size
+ 'quality' => 'hd'
+ ]),
+ 'timeout' => 60
+ ]
+ );
+
+ if (is_wp_error($response)) {
+ return $response;
+ }
+
+ $body = json_decode(wp_remote_retrieve_body($response), true);
+
+ if (!isset($body['data'][0]['url'])) {
+ return new WP_Error(
+ 'image_generation_failed',
+ 'Image API error: ' . ($body['error']['message'] ?? 'Unknown error')
+ );
+ }
+
+ return [
+ 'url' => $body['data'][0]['url'],
+ 'cost' => $body['usage']['cost'] ?? 0.03 // Fallback estimate
+ ];
+}
+
+/**
+ * Download image from URL to temp folder
+ */
+private static function download_image($url, $filepath) {
+ $response = wp_remote_get($url);
+
+ if (is_wp_error($response)) {
+ return $response;
+ }
+
+ $body = wp_remote_retrieve_body($response);
+
+ if (file_put_contents($filepath, $body) === false) {
+ return new WP_Error('file_write_failed', 'Could not write temp image file');
+ }
+
+ return true;
+}
+
+/**
+ * IMPORTANT: Regenerate does NOT delete old variants
+ * It just adds new ones
+ */
+private static function get_temp_dir_for_image($post_id, $agent_image_id) {
+ return WP_CONTENT_DIR . '/agentic-writer-temp/post_' . $post_id . '/' . $agent_image_id;
+}
+```
+
+---
+
+## Flow 4: Variant Selection & Media Upload
+
+### Backend: Commit endpoint
+
+```php
+get_param('post_id');
+ $agent_image_id = $request->get_param('agent_image_id');
+ $variant_id = $request->get_param('variant_id');
+ $alt = $request->get_param('alt');
+
+ // Validate
+ if (!current_user_can('edit_post', $post_id)) {
+ return new WP_Error('unauthorized', 'Not allowed', ['status' => 403]);
+ }
+
+ // Get variant
+ $variant = self::get_variant($variant_id);
+ if (!$variant || $variant->post_id != $post_id) {
+ return new WP_Error('not_found', 'Variant not found', ['status' => 404]);
+ }
+
+ // Check temp file exists
+ if (!file_exists($variant->temp_file_path)) {
+ return new WP_Error('file_not_found', 'Temp image not found');
+ }
+
+ // Upload to WordPress Media using sideload
+ $attachment_id = self::sideload_image_to_media(
+ $variant->temp_file_path,
+ $post_id,
+ $alt
+ );
+
+ if (is_wp_error($attachment_id)) {
+ return $attachment_id;
+ }
+
+ // Get attachment URL
+ $attachment_url = wp_get_attachment_url($attachment_id);
+
+ // Update main image record
+ self::update_image_record_by_agent_image_id($post_id, $agent_image_id, [
+ 'attachment_id' => $attachment_id,
+ 'status' => 'committed'
+ ]);
+
+ // Mark this variant as selected
+ self::update_variant($variant_id, [
+ 'is_selected' => 1,
+ 'selected_at' => current_time('mysql'),
+ 'status' => 'selected'
+ ]);
+
+ return new WP_REST_Response([
+ 'success' => true,
+ 'attachment_id' => $attachment_id,
+ 'attachment_url' => $attachment_url,
+ 'alt' => $alt,
+ 'message' => 'Image committed to WordPress Media.'
+ ]);
+}
+
+/**
+ * Sideload temp image to WordPress Media
+ */
+private static function sideload_image_to_media(
+ $temp_filepath,
+ $post_id,
+ $alt_text
+) {
+ // Use WordPress sideload
+ require_once(ABSPATH . 'wp-admin/includes/image.php');
+ require_once(ABSPATH . 'wp-admin/includes/file.php');
+ require_once(ABSPATH . 'wp-admin/includes/media.php');
+
+ $filename = basename($temp_filepath);
+ $file_array = [
+ 'name' => $filename,
+ 'tmp_name' => $temp_filepath
+ ];
+
+ $attachment_id = media_handle_sideload(
+ $file_array,
+ $post_id
+ );
+
+ if (is_wp_error($attachment_id)) {
+ return $attachment_id;
+ }
+
+ // Set alt text
+ update_post_meta($attachment_id, '_wp_attachment_image_alt', $alt_text);
+
+ return $attachment_id;
+}
+```
+
+---
+
+## Flow 5: Temp Image Management
+
+### Temp image lifecycle
+
+```
+1. Generated → stored in /agentic-writer-temp/post_X/img_Y/variant_N.jpg
+ Status: 'temp'
+
+2a. User selects variant → moved to WP Media Library
+ Status: 'selected' (in wp_agentic_images_variants)
+ Status: 'committed' (in wp_agentic_images)
+
+2b. User regenerates (doesn't delete old variants)
+ → New variants created in SAME folder
+ → Old variants status remains 'temp'
+
+3. Non-selected temps persist in DB until:
+ - User manually deletes from admin page
+ - Cron job runs (auto-cleanup > 7 days)
+
+ Status transitions: 'temp' → 'discarded' → deleted from disk
+```
+
+### Backend: List temp images endpoint
+
+```php
+get_param('post_id');
+
+ if (!current_user_can('edit_post', $post_id)) {
+ return new WP_Error('unauthorized', 'Not allowed', ['status' => 403]);
+ }
+
+ global $wpdb;
+
+ $temps = $wpdb->get_results($wpdb->prepare(
+ "SELECT v.*, i.agent_image_id, i.prompt_initial, i.alt_text_initial
+ FROM {$wpdb->prefix}agentic_images_variants v
+ JOIN {$wpdb->prefix}agentic_images i ON v.agentic_image_id = i.id
+ WHERE v.post_id = %d
+ AND v.status IN ('temp', 'discarded')
+ ORDER BY v.created_at DESC",
+ $post_id
+ ));
+
+ return new WP_REST_Response([
+ 'total' => count($temps),
+ 'images' => $temps
+ ]);
+}
+
+/**
+ * DELETE /wp-json/agentic-writer/v1/temp-images/
+ *
+ * Manually delete a temp image
+ */
+public static function rest_delete_temp_image( WP_REST_Request $request ) {
+ $variant_id = $request->get_param('variant_id');
+
+ $variant = self::get_variant($variant_id);
+ if (!$variant) {
+ return new WP_Error('not_found', 'Variant not found');
+ }
+
+ if (!current_user_can('edit_post', $variant->post_id)) {
+ return new WP_Error('unauthorized', 'Not allowed');
+ }
+
+ // Delete file
+ if (file_exists($variant->temp_file_path)) {
+ unlink($variant->temp_file_path);
+ }
+
+ // Mark as deleted in DB
+ self::update_variant($variant_id, [
+ 'status' => 'discarded',
+ 'deleted_at' => current_time('mysql')
+ ]);
+
+ return new WP_REST_Response([
+ 'success' => true,
+ 'message' => 'Temp image deleted.'
+ ]);
+}
+
+/**
+ * Cron job: Auto-cleanup old temps (> 7 days)
+ */
+public static function cleanup_old_temp_images() {
+ global $wpdb;
+
+ $cutoff_date = date('Y-m-d H:i:s', strtotime('-7 days'));
+
+ // Get old temp images
+ $old_temps = $wpdb->get_results($wpdb->prepare(
+ "SELECT * FROM {$wpdb->prefix}agentic_images_variants
+ WHERE status = 'temp'
+ AND created_at < %s",
+ $cutoff_date
+ ));
+
+ foreach ($old_temps as $temp) {
+ // Delete file
+ if (file_exists($temp->temp_file_path)) {
+ unlink($temp->temp_file_path);
+ }
+
+ // Mark as auto-deleted
+ $wpdb->update(
+ $wpdb->prefix . 'agentic_images_variants',
+ [
+ 'status' => 'auto_deleted',
+ 'deleted_at' => current_time('mysql')
+ ],
+ [ 'id' => $temp->id ]
+ );
+ }
+}
+
+// Hook into WordPress cron
+add_action('agentic_writer_cleanup_temps', [__CLASS__, 'cleanup_old_temp_images']);
+
+// Schedule once
+if (!wp_next_scheduled('agentic_writer_cleanup_temps')) {
+ wp_schedule_event(
+ time(),
+ 'daily',
+ 'agentic_writer_cleanup_temps'
+ );
+}
+```
+
+---
+
+## Admin Page: Image Library
+
+### New tab in plugin settings
+
+```
+Plugin Settings → Generated Images (new tab)
+
+┌────────────────────────────────────────────────────────┐
+│ Generated Images Library │
+├────────────────────────────────────────────────────────┤
+│ │
+│ Filter by: [Post ▼] [Status ▼] [Date ▼] │
+│ │
+│ [Select All] [Delete Selected] [Auto-cleanup Old] │
+│ │
+│ ───────────────────────────────────────────────────── │
+│ │
+│ [Thumb] Post: "Article Title" │
+│ Agent Image ID: img_hero_1 │
+│ Status: temp │
+│ Created: Jan 27, 2026 2:30 PM │
+│ Cost: $0.03 │
+│ [ ] [View] [Delete] │
+│ │
+│ [Thumb] Post: "Article Title" │
+│ Agent Image ID: img_diag_1 │
+│ Status: selected (used in Media Library) │
+│ Created: Jan 27, 2026 2:25 PM │
+│ Cost: $0.03 │
+│ [ ] [View] │
+│ │
+│ ───────────────────────────────────────────────────── │
+│ │
+│ Total cost (all time): $123.45 │
+│ Total temps (>7 days): 12 images (ready for cleanup) │
+│ │
+│ [Auto-cleanup Now] │
+│ │
+└────────────────────────────────────────────────────────┘
+```
+
+### Implementation
+
+```php
+prefix}agentic_images_variants v
+ JOIN {$wpdb->prefix}agentic_images i ON v.agentic_image_id = i.id
+ JOIN {$wpdb->posts} p ON v.post_id = p.ID
+ WHERE 1=1";
+
+ if ($post_id) {
+ $query .= $wpdb->prepare(" AND v.post_id = %d", $post_id);
+ }
+
+ if ($status) {
+ $query .= $wpdb->prepare(" AND v.status = %s", $status);
+ }
+
+ $query .= " ORDER BY v.created_at DESC";
+
+ $images = $wpdb->get_results($query);
+
+ $total_cost = $wpdb->get_var(
+ "SELECT SUM(cost) FROM {$wpdb->prefix}agentic_images_variants"
+ );
+
+ ?>
+
+ Generated Images Library
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Total Cost (all time): $
+ Temp Images Ready for Cleanup (>7 days):
+ get_var(
+ "SELECT COUNT(*) FROM {$wpdb->prefix}agentic_images_variants
+ WHERE status = 'temp' AND created_at < DATE_SUB(NOW(), INTERVAL 7 DAY)"
+ ); ?>
+
+
+
+
+ ` | DELETE | Delete a specific temp image |
+
+### Endpoint definitions
+
+```php
+ 'POST',
+ 'callback' => [Agentic_Writer_Images::class, 'rest_generate_image'],
+ 'permission_callback' => '__return_true' // Checked inside function
+ ]);
+
+ register_rest_route('agentic-writer/v1', '/commit-image', [
+ 'methods' => 'POST',
+ 'callback' => [Agentic_Writer_Images::class, 'rest_commit_image'],
+ 'permission_callback' => '__return_true'
+ ]);
+
+ register_rest_route('agentic-writer/v1', '/temp-images', [
+ 'methods' => 'GET',
+ 'callback' => [Agentic_Writer_Images::class, 'rest_list_temp_images'],
+ 'permission_callback' => '__return_true'
+ ]);
+
+ register_rest_route('agentic-writer/v1', '/temp-images/(?P\d+)', [
+ 'methods' => 'DELETE',
+ 'callback' => [Agentic_Writer_Images::class, 'rest_delete_temp_image'],
+ 'permission_callback' => '__return_true'
+ ]);
+});
+```
+
+---
+
+## Implementation Checklist
+
+### Phase 1: Database & Data Model
+- [ ] Create `wp_agentic_images` table
+- [ ] Create `wp_agentic_images_variants` table
+- [ ] Add migration script
+
+### Phase 2: Agent Response Handling
+- [ ] Update agent execution to return `images` array
+- [ ] Save recommendations to `wp_agentic_images`
+- [ ] Insert `core/image` blocks with `data-agent-image-id`
+
+### Phase 3: Gutenberg Integration
+- [ ] Register plugin for block toolbar button
+- [ ] Create modal component (Edit → Generate → Select flow)
+- [ ] Implement `updateBlockAttributes` to commit selection
+
+### Phase 4: Backend Image Generation
+- [ ] Implement `POST /generate-image` endpoint
+- [ ] Handle variant generation (1-3 images per call)
+- [ ] Save variants to temp folder + DB
+- [ ] **Note:** Regenerate creates NEW variants, doesn't delete old
+
+### Phase 5: Media Upload
+- [ ] Implement `POST /commit-image` endpoint
+- [ ] Use `media_handle_sideload()` to upload
+- [ ] Set attachment alt from user-edited value
+- [ ] Update Gutenberg block attributes
+
+### Phase 6: Temp Management
+- [ ] Implement `GET /temp-images` endpoint
+- [ ] Implement `DELETE /temp-images/` endpoint
+- [ ] Set up cron job for auto-cleanup (>7 days)
+- [ ] Create cleanup directory function
+
+### Phase 7: Admin Page
+- [ ] Add "Generated Images" tab in plugin settings
+- [ ] List all temps/selected images with filters
+- [ ] Bulk delete action
+- [ ] Manual cleanup button
+- [ ] Cost summary display
+
+### Phase 8: Testing & UX Polish
+- [ ] Test full generation → selection → upload flow
+- [ ] Test regenerate (old variants persist)
+- [ ] Test cleanup (old temps auto-delete at 7+ days)
+- [ ] Test admin page filtering + deletion
+- [ ] Verify cost tracking accuracy
+
+---
+
+## Key Notes
+
+### Regenerate Behavior
+
+```
+User clicks "Regenerate" in modal
+→ Calls POST /generate-image AGAIN with SAME prompt
+→ Backend generates 3 NEW variants
+→ NEW variants saved to DB (variant_number = 2, 3, 4, etc)
+→ OLD variants (variant_number = 1) remain in DB with status='temp'
+→ Temp folder now has: variant_1.jpg, variant_2.jpg, variant_3.jpg, variant_4.jpg, ...
+→ Modal shows ALL variants (user can pick from any)
+→ Selected variant gets attached to post
+→ Non-selected remain as 'temp' until manual delete or cron cleanup
+```
+
+### Cost Transparency
+
+```
+Each variant_number records:
+- cost: Individual image cost
+- prompt_used: Exact prompt for that generation
+- generation_time: How long it took
+
+Main image record:
+- cost_actual: SUM of all variants generated
+- image_model: Which model was used
+- prompt_edited: User's final (possibly edited) prompt
+- alt_text_edited: User's final alt
+```
+
+### File Structure (Example)
+
+```
+/wp-content/agentic-writer-temp/
+├── post_123/
+│ ├── img_hero_1/
+│ │ ├── variant_1_1706364000.jpg (first generation)
+│ │ ├── variant_2_1706364015.jpg (first generation, variant 2)
+│ │ ├── variant_3_1706364030.jpg (first generation, variant 3)
+│ │ ├── variant_1_1706364060.jpg (second generation, regenerate)
+│ │ ├── variant_2_1706364075.jpg (second generation, regenerate)
+│ │ └── variant_3_1706364090.jpg (second generation, regenerate)
+│ └── img_diag_1/
+│ └── variant_1_1706364120.jpg (only 1 generated)
+└── post_456/
+ └── img_hero_1/
+ └── variant_1_1706364150.jpg
+```
+
+---
+
+**Document version:** 1.0
+**Date:** January 27, 2026
+**Status:** Ready for Phase 1 implementation
diff --git a/image-model-recommendations.md b/image-model-recommendations.md
new file mode 100644
index 0000000..233427d
--- /dev/null
+++ b/image-model-recommendations.md
@@ -0,0 +1,541 @@
+# WP Agentic Writer: Image Model Recommendations by Preset
+
+## Executive Summary
+
+**Your question:** "Which image model should we use? We need to match the prompt style to avoid wasting money on bad images."
+
+**The answer:** Different image models have different "prompt languages" and reasoning capabilities. Choose the right model for your preset, then **generate prompts specifically for that model's strengths**.
+
+**Recommended models (by preset):**
+- **Budget:** FLUX.2 [klein] 4B (fast, cheap, handles simple prompts well)
+- **Balanced:** Riverflow V2 Max Preview (excellent prompt adherence, context-aware)
+- **Premium:** FLUX.2 [max] (frontier quality, best photorealism + complex prompts)
+
+---
+
+## Table of Contents
+
+1. [Image Model Comparison Matrix](#image-model-comparison-matrix)
+2. [Budget Preset: FLUX.2 [klein] Recommendation](#budget-preset-flux2-klein-recommendation)
+3. [Balanced Preset: Riverflow V2 Max Recommendation](#balanced-preset-riverflow-v2-max-recommendation)
+4. [Premium Preset: FLUX.2 [max] Recommendation](#premium-preset-flux2-max-recommendation)
+5. [Prompt Styles by Model](#prompt-styles-by-model)
+6. [Implementation: Adjust Prompts per Model](#implementation-adjust-prompts-per-model)
+
+---
+
+## Image Model Comparison Matrix
+
+| Aspect | FLUX.2 [klein] | Riverflow V2 Max | FLUX.2 [max] |
+|--------|---|---|---|
+| **Architecture** | 4B parameters (lightweight) | Medium (optimized) | 32B parameters (frontier) |
+| **Best at** | Speed, cost, diagrams, simple scenes | Photorealism, prompt understanding, details | Complex scenes, photorealism, consistency |
+| **Prompt complexity** | Simple (2-3 sentences) | Medium (detailed) | Complex (very detailed, technical specs) |
+| **Text in images** | Poor | Decent | Excellent |
+| **Multi-reference** | No | No | Yes (up to 10 images) |
+| **Cost/image** | $0.014–0.042 | $0.03 flat | $0.07–0.21 |
+| **Speed** | ⚡⚡⚡ Fast | ⚡⚡ Medium | ⚡ Slower (higher quality) |
+| **Photorealism** | Good | Very good | Excellent |
+| **Consistency** | Good | Very good | Excellent |
+| **Prompt adherence** | Good (simple prompts) | Excellent | Excellent (complex prompts) |
+| **For blog articles** | ✅ Hero images, diagrams | ✅ Professional photos, diagrams | ✅ Flagship/hero images |
+| **Failure modes** | Complex scenes, text | Rare | Rare |
+| **When to use** | Budget tier, speed matters | Balanced tier, default | Premium tier, quality paramount |
+
+---
+
+## Budget Preset: FLUX.2 [klein] Recommendation
+
+### Why FLUX.2 [klein]?
+
+```
+✅ Cheapest: $0.014/MP (first megapixel)
+✅ Fastest: Generates in ~3 seconds
+✅ Good enough: Handles simple prompts very well
+✅ Diagrams: Excellent for technical diagrams, dashboards
+✅ Illustrations: Good for minimalist, illustrated styles
+❌ Not ideal: Complex photorealistic scenes, text in images
+❌ Not ideal: Multiple objects with precise spatial relationships
+```
+
+**Price:** $0.014 (first MP) + $0.001 (each additional MP)
+- **Standard 1024×576 (16:9):** ~$0.015–0.020
+- **Square 512×512 (1:1):** ~$0.011–0.015
+
+### Prompt Style for FLUX.2 [klein]
+
+**Template (SIMPLE, 2-3 sentences MAX):**
+
+```
+[Subject/Dashboard/Diagram name], [key elements], [style], [colors]
+```
+
+**✅ Good prompts for klein:**
+
+```
+1. HERO IMAGE (Dashboard)
+"N8n workflow automation dashboard showing colorful nodes
+and connections on blue background, minimalist modern interface,
+professional SaaS design"
+
+2. DIAGRAM (Simple technical)
+"Workflow architecture diagram showing trigger, action, condition
+components with arrows, clean lines, blue and purple palette,
+technical illustration style"
+
+3. ILLUSTRATION (Minimalist)
+"Minimalist illustration of automation concept with interconnected
+gears and nodes, flat design, blues and greens, modern tech aesthetic"
+
+4. SCREENSHOT (Simple)
+"N8n interface showing workflow execution panel with status
+indicators, clean layout, professional dashboard view"
+```
+
+**❌ BAD prompts for klein (will waste money):**
+
+```
+✗ "A hyper-detailed photorealistic photo of a developer working
+with N8n, cinematic lighting with volumetric fog, 4K quality,
+shot on RED camera with lut grading..."
+↳ TOO COMPLEX: klein will fail or produce mediocre results
+
+✗ "Dashboard showing text 'AUTOMATION HUB' in neon letters,
+very detailed typography, complex sci-fi design..."
+↳ TEXT RENDERING: klein struggles with readable text
+
+✗ "Intricate 3D render of a neural network architecture with
+thousands of nodes, each labeled with precise colors..."
+↳ IMPOSSIBLE: klein can't handle this complexity
+```
+
+### Implementation for Budget Preset
+
+```php
+// Budget preset prompt generation
+$prompt_klein = "N8n workflow dashboard screenshot, showing " .
+ "colorful workflow nodes and connections on blue background, " .
+ "minimalist professional interface";
+
+// Keep it short and simple
+$image_spec = [
+ 'model' => 'black-forest-labs/flux.2-klein',
+ 'prompt' => $prompt_klein, // 2-3 sentences
+ 'size' => '1024x576', // Standard blog size
+ 'guidance_scale' => 3.0, // Default for klein
+];
+```
+
+---
+
+## Balanced Preset: Riverflow V2 Max Recommendation
+
+### Why Riverflow V2 Max?
+
+```
+✅ Excellent prompt adherence: Understands nuanced instructions
+✅ Photorealistic: Produces professional, polished images
+✅ Context-aware: Handles detailed specifications well
+✅ Details: Sharp textures, consistent lighting, realistic materials
+✅ Speed: Reasonable (~8-15 seconds)
+✅ Cost: Flat $0.03/image regardless of size (predictable)
+❌ Slightly more expensive: $0.03 vs FLUX.2 klein's $0.014
+❌ No multi-reference: Can't maintain consistency across multiple images
+```
+
+**Price:** $0.03 flat per image (regardless of size)
+- **Any size:** Consistent $0.03
+
+### Prompt Style for Riverflow V2 Max
+
+**Template (DETAILED but CONCISE, 3-4 sentences):**
+
+```
+[Subject details], [action/context], [style/mood], [lighting], [technical specs]
+```
+
+**✅ Good prompts for Riverflow:**
+
+```
+1. HERO IMAGE (Professional dashboard)
+"N8n automation dashboard interface displaying real-time workflow
+execution with colorful nodes and connections. Clean minimalist design
+with blue accent colors, modern SaaS aesthetic. Professional product
+photography style with studio lighting, sharp details, clean layout"
+
+2. DIAGRAM (Technical with style)
+"Technical architecture diagram visualizing workflow components:
+trigger module, conditional routing, action nodes. Components connected
+with clean lines and arrows. Flat design style, blue and purple color
+palette, professional technical illustration, clear readability"
+
+3. PROFESSIONAL PHOTO (Realistic context)
+"A developer's laptop screen showing N8n automation workflows with
+detailed node visualization. Warm office lighting with subtle desk lamp,
+shallow depth of field, professional product photography, clear screen
+details visible, modern workspace setup"
+
+4. INFOGRAPHIC (Educational)
+"Educational infographic showing 'How N8n Automation Works' with
+step-by-step visual flow. Icons and diagrams arranged in logical sequence,
+minimalist design language, blue and grey colors, professional presentation
+style, clean typography and spacing"
+```
+
+**❌ LESS EFFECTIVE for Riverflow:**
+
+```
+✗ "Hyper-detailed 8K photorealistic RAW file of a futuristic
+neural network with quantum computing effects..."
+↳ OVERKILL: Riverflow is excellent but not designed for sci-fi/fantasy
+
+✗ "Complex scene with 47 different UI elements, each precisely
+positioned with specific pixel values..."
+↳ TOO PRESCRIPTIVE: Riverflow works best with conceptual direction,
+not pixel-perfect specs
+
+✗ "Neon text glowing in the dark, cinematic with fog..."
+↳ LESS IDEAL: Not Riverflow's strongest point (use FLUX.2 max for this)
+```
+
+### Implementation for Balanced Preset
+
+```php
+// Balanced preset prompt generation
+$prompt_riverflow = "N8n automation dashboard interface displaying " .
+ "real-time workflow execution with colorful nodes and connections. " .
+ "Clean minimalist design with blue accent colors, modern SaaS aesthetic. " .
+ "Professional product photography style with studio lighting, " .
+ "sharp details, clean layout";
+
+$image_spec = [
+ 'model' => 'sourceful/riverflow-v2-max',
+ 'prompt' => $prompt_riverflow, // 3-4 sentences, detailed
+ 'size' => '1024x576', // Standard blog size
+ 'guidance_scale' => 3.5, // Moderate adherence
+];
+```
+
+---
+
+## Premium Preset: FLUX.2 [max] Recommendation
+
+### Why FLUX.2 [max]?
+
+```
+✅ Frontier quality: Best-in-class photorealism and detail
+✅ Complex prompts: Handles intricate specifications excellently
+✅ Text rendering: Can generate readable text in images
+✅ Multi-reference: Maintains consistency across up to 10 reference images
+✅ Photorealism: Superior material properties, lighting, spatial logic
+✅ Professional: Production-grade results for flagship content
+❌ Expensive: $0.07–0.21 per image (highest cost)
+❌ Slower: Takes ~20-30 seconds (but worth it for quality)
+```
+
+**Price:** $0.07 (first MP) + $0.03 (each additional MP)
+- **Standard 1024×576 (16:9):** ~$0.20–0.25
+- **High res 2048×2048 (4K):** ~$0.60–0.80
+
+### Prompt Style for FLUX.2 [max]
+
+**Template (VERY DETAILED, 4-6 sentences with technical specs):**
+
+```
+[Technical foundation], [main subject + action], [environment/context],
+[lighting + mood], [style + aesthetics], [technical specifications]
+```
+
+**✅ Good prompts for FLUX.2 [max]:**
+
+```
+1. HERO IMAGE (Flagship dashboard)
+"Professional product photography of N8n automation dashboard interface
+on a modern laptop screen. The dashboard displays a real-time workflow
+with multiple interconnected nodes in blue, purple, and teal colors.
+The scene is set in a minimalist tech office with warm tungsten lighting
+creating soft shadows on black marble desk. Shot with shallow depth of field,
+sharp focus on the screen, bokeh background. Commercial photography style
+with crisp details, accurate color reproduction, professional white-balance.
+4K resolution with film-grain aesthetics"
+
+2. CINEMATIC DIAGRAM (Complex technical)
+"Technical architecture diagram for N8n automation system rendered in
+3D isometric perspective. The diagram shows trigger events (red nodes),
+conditional logic (blue nodes), and action outputs (green nodes) connected
+with flowing animated pathways. Modern tech aesthetic with gradient backgrounds,
+volumetric lighting effects, subtle motion blur on connector lines.
+Professional technical illustration merged with cinematic rendering.
+Rendered with physically-based materials, global illumination, and
+ambient occlusion for depth. 3D product visualization style"
+
+3. HERO ILLUSTRATION (Complex artistic)
+"Conceptual illustration of automation workflow represented as flowing
+water streams. Multiple streams of different colors merge and split,
+representing workflow logic and data routing. Streams flow through modern
+architectural elements (nodes, connections). Watercolor painting style
+blended with digital rendering. Cool color palette with blues and teals,
+warm accent lights. Wide-angle perspective showing expansive workflow.
+Ethereal, professional, educational aesthetic"
+
+4. LIFESTYLE + PRODUCT (Complex scene)
+"A product lifestyle photograph showing N8n dashboard on a developer's
+laptop alongside coffee, notebook, and desk setup in a modern home office.
+Natural morning sunlight streams through large windows creating warm golden
+hour lighting. The scene includes shallow depth of field with sharp focus on
+the laptop screen showing the N8n interface. Modern Scandinavian aesthetic,
+minimalist desk setup with wood and metal surfaces. Shot on full-frame camera
+with 35mm lens, warm color grading, professional lifestyle photography,
+authentic and aspirational atmosphere"
+```
+
+**✅ ADVANCED: Using FLUX.2 [max]'s strengths:**
+
+```
+JSON Prompting (FLUX.2 max supports structured prompts):
+{
+ "scene": "Professional product photography",
+ "subject": "N8n dashboard on laptop",
+ "environment": "Modern tech office, minimalist desk",
+ "lighting": "Warm tungsten, side-lighting, soft shadows",
+ "style": "Commercial product photography",
+ "color_palette": ["#003D9B", "#6F42C1", "#17A2B8"],
+ "technical_specs": "4K, shallow DOF, f/2.8, 85mm lens",
+ "mood": "Professional, modern, trustworthy"
+}
+```
+
+### Implementation for Premium Preset
+
+```php
+// Premium preset prompt generation
+$prompt_flux_max = "Professional product photography of N8n automation " .
+ "dashboard displayed on a modern developer's laptop screen. " .
+ "The dashboard shows real-time workflow execution with colorful " .
+ "interconnected nodes. Scene set in minimalist tech office with " .
+ "warm tungsten studio lighting creating soft shadows on black marble " .
+ "desk surface. Shallow depth of field with sharp focus on screen, " .
+ "bokeh background. Commercial photography style with crisp details, " .
+ "accurate colors, white-balanced. 4K resolution";
+
+$image_spec = [
+ 'model' => 'black-forest-labs/flux.2-max',
+ 'prompt' => $prompt_flux_max, // 4-6 sentences, very detailed
+ 'size' => '1024x576', // Can also do 2048×2048 for premium
+ 'guidance_scale' => 4.0, // High adherence for complex prompts
+];
+```
+
+---
+
+## Prompt Styles by Model
+
+### Quick Reference: How to Structure Prompts
+
+| Model | Length | Complexity | Style | Strength |
+|-------|--------|-----------|--------|-----------|
+| **FLUX.2 [klein]** | 1-2 sentences | Simple | Functional description | Speed + cost |
+| **Riverflow V2 Max** | 3-4 sentences | Medium | Detailed but concise | Photorealism + clarity |
+| **FLUX.2 [max]** | 4-6 sentences | Complex | Very detailed with specs | Quality + complexity handling |
+
+### Model-Specific Prompt Tips
+
+#### FLUX.2 [klein] Tips
+```
+✓ Front-load the main subject (Klein prioritizes early tokens)
+✓ Use simple adjectives: "minimalist", "clean", "blue"
+✓ Avoid: "volumetric fog", "subsurface scattering", "ray-traced"
+✓ Best for: Diagrams, simple dashboards, minimalist illustrations
+✓ Template: [Main subject], [2 key details], [style]
+```
+
+#### Riverflow V2 Max Tips
+```
+✓ Include context and environment details
+✓ Specify lighting style: "studio lighting", "golden hour"
+✓ Use photography terms: "shallow DOF", "bokeh", "35mm lens"
+✓ Can include moderate technical specs
+✓ Best for: Professional photos, detailed diagrams, product shots
+✓ Template: [Subject + context], [details], [lighting], [style]
+```
+
+#### FLUX.2 [max] Tips
+```
+✓ Can use very specific technical vocabulary
+✓ Specify exact materials and properties
+✓ Include color codes (HEX) for brand accuracy
+✓ Can describe complex spatial relationships
+✓ Use JSON prompting for highest precision
+✓ Front-load important elements (tokens matter)
+✓ Best for: Hero images, complex scenes, flagship content
+✓ Template: [Technical specs], [subject], [environment], [lighting], [style], [resolution/format]
+```
+
+---
+
+## Implementation: Adjust Prompts per Model
+
+### Phase 1: Update Prompt Generation System
+
+Modify the prompt generation agent to output **model-specific prompts** based on selected image model:
+
+```php
+ $article_markdown,
+ 'placement_points' => $placement_data['image_placement_points'],
+ 'style_preference' => $style_preference,
+ 'image_count' => $placement_data['recommended_image_count'],
+ 'target_image_model' => $image_model, // NEW: Tell agent which model
+ ]);
+
+ // ... rest of API call
+}
+
+/**
+ * Return system prompt customized for image model
+ */
+private static function get_prompt_generation_system_prompt_for_model( $image_model ) {
+ $model_configs = [
+ 'black-forest-labs/flux.2-klein' => [
+ 'name' => 'FLUX.2 [klein]',
+ 'prompt_length' => '1-2 sentences',
+ 'complexity' => 'simple',
+ 'guidance' => 'Keep prompts short and simple. Focus on main subject,
+ key details, and style. Avoid complex scenes or technical specifications.',
+ 'template' => 'Subject, key elements, style, color palette'
+ ],
+ 'sourceful/riverflow-v2-max' => [
+ 'name' => 'Riverflow V2 Max',
+ 'prompt_length' => '3-4 sentences',
+ 'complexity' => 'medium-detailed',
+ 'guidance' => 'Include context, environment details, lighting style,
+ and photographic specifications. Model excels at photorealism.',
+ 'template' => 'Subject + context, environment details, lighting style,
+ photography style, technical specs'
+ ],
+ 'black-forest-labs/flux.2-max' => [
+ 'name' => 'FLUX.2 [max]',
+ 'prompt_length' => '4-6 sentences',
+ 'complexity' => 'very-detailed-technical',
+ 'guidance' => 'Use detailed technical vocabulary. Include exact materials,
+ color codes (HEX), spatial relationships, and specifications.
+ Can use JSON prompting for maximum precision.',
+ 'template' => 'Technical foundation, main subject + action, environment,
+ lighting + mood, style + aesthetics, technical specifications'
+ ]
+ ];
+
+ $config = $model_configs[ $image_model ] ?? $model_configs['sourceful/riverflow-v2-max'];
+
+ return << $value ) {
if ( $key === 'date' ) {
- $new_columns['wp_aw_cost'] = '💰 AI Cost';
+ $new_columns['wp_aw_cost'] = '💰 OpenRouter Cost';
}
$new_columns[ $key ] = $value;
}
diff --git a/includes/class-brave-search-api.php b/includes/class-brave-search-api.php
new file mode 100644
index 0000000..aca7e37
--- /dev/null
+++ b/includes/class-brave-search-api.php
@@ -0,0 +1,154 @@
+ urlencode( $query ),
+ 'count' => absint( $count ),
+ 'text_decorations' => 0, // Disable HTML tags in descriptions
+ 'spellcheck' => 1,
+ ),
+ $this->api_endpoint
+ );
+
+ $response = wp_remote_get(
+ $url,
+ array(
+ 'headers' => array(
+ 'Accept' => 'application/json',
+ 'Accept-Encoding' => 'gzip',
+ 'X-Subscription-Token' => $api_key,
+ ),
+ 'timeout' => 15,
+ )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ }
+
+ $http_code = wp_remote_retrieve_response_code( $response );
+ $body = json_decode( wp_remote_retrieve_body( $response ), true );
+
+ if ( 200 !== $http_code ) {
+ return new WP_Error(
+ 'brave_api_error',
+ sprintf(
+ /* translators: %1$d is HTTP status code, %2$s is error message */
+ __( 'Brave Search API Error %1$d: %2$s', 'wp-agentic-writer' ),
+ $http_code,
+ $body['message'] ?? __( 'Unknown Error', 'wp-agentic-writer' )
+ )
+ );
+ }
+
+ if ( empty( $body['web']['results'] ) ) {
+ return array(); // No results found
+ }
+
+ $formatted_results = array();
+ foreach ( $body['web']['results'] as $result ) {
+ $formatted_results[] = array(
+ 'title' => $result['title'] ?? '',
+ 'url' => $result['url'] ?? '',
+ 'description' => $result['description'] ?? '',
+ );
+ }
+
+ // Cache results for 1 hour to prevent redundant API calls
+ set_transient( $cache_key, $formatted_results, HOUR_IN_SECONDS );
+
+ return $formatted_results;
+ }
+
+ /**
+ * Formats search results into a markdown context block for LLM System Prompt injection.
+ *
+ * @since 0.1.0
+ * @param array $results Search results array.
+ * @param string $query Original query.
+ * @return string Formatted markdown context string.
+ */
+ public function format_results_for_llm( $results, $query ) {
+ if ( empty( $results ) || is_wp_error( $results ) ) {
+ return "No reliable web search results found for: {$query}";
+ }
+
+ $markdown = "## LIVE WEB SEARCH CONTEXT\n";
+ $markdown .= "> You successfully searched the internet for: \"{$query}\"\n";
+ $markdown .= "> Please incorporate the following real-time data into your answer:\n\n";
+
+ $counter = 1;
+ foreach ( $results as $item ) {
+ $markdown .= "{$counter}. **{$item['title']}**\n";
+ $markdown .= " URL: {$item['url']}\n";
+ $markdown .= " Summary: {$item['description']}\n\n";
+ $counter++;
+ }
+
+ $markdown .= "---------------------------\n";
+
+ return $markdown;
+ }
+}
diff --git a/includes/class-codex-provider.php b/includes/class-codex-provider.php
new file mode 100644
index 0000000..c187af0
--- /dev/null
+++ b/includes/class-codex-provider.php
@@ -0,0 +1,384 @@
+api_key = $settings['codex_api_key'] ?? '';
+ $this->model = $settings['codex_model'] ?? 'gpt-4o';
+ }
+
+ /**
+ * Non-streaming chat completion
+ *
+ * @param array $messages Array of message objects.
+ * @param array $options Optional parameters.
+ * @param string $type Task type.
+ * @return array|WP_Error Response with content, model, tokens, cost.
+ */
+ public function chat( $messages, $options = array(), $type = 'planning' ) {
+ if ( ! $this->is_configured() ) {
+ return new WP_Error(
+ 'not_configured',
+ __( 'Codex API key not configured.', 'wp-agentic-writer' )
+ );
+ }
+
+ $model = $options['model'] ?? $this->model;
+ $temperature = $options['temperature'] ?? 0.7;
+ $max_tokens = $options['max_tokens'] ?? null;
+
+ $body = array(
+ 'model' => $model,
+ 'messages' => $messages,
+ 'temperature' => $temperature,
+ );
+
+ if ( $max_tokens ) {
+ $body['max_tokens'] = $max_tokens;
+ }
+
+ $start_time = microtime( true );
+
+ $response = wp_remote_post(
+ $this->api_endpoint,
+ array(
+ 'headers' => array(
+ 'Authorization' => 'Bearer ' . $this->api_key,
+ 'Content-Type' => 'application/json',
+ ),
+ 'body' => wp_json_encode( $body ),
+ 'timeout' => 120,
+ )
+ );
+
+ $generation_time = microtime( true ) - $start_time;
+
+ if ( is_wp_error( $response ) ) {
+ return new WP_Error(
+ 'connection_failed',
+ sprintf(
+ /* translators: %s: error message */
+ __( 'Codex connection failed: %s', 'wp-agentic-writer' ),
+ $response->get_error_message()
+ )
+ );
+ }
+
+ $code = wp_remote_retrieve_response_code( $response );
+ if ( 200 !== $code ) {
+ $body_response = wp_remote_retrieve_body( $response );
+ $error_data = json_decode( $body_response, true );
+ return new WP_Error(
+ 'api_error',
+ sprintf(
+ /* translators: %1$d: HTTP status code, %2$s: error message */
+ __( 'Codex error (%1$d): %2$s', 'wp-agentic-writer' ),
+ $code,
+ $error_data['error']['message'] ?? $body_response
+ )
+ );
+ }
+
+ $response_body = json_decode( wp_remote_retrieve_body( $response ), true );
+
+ if ( ! isset( $response_body['choices'][0]['message']['content'] ) ) {
+ return new WP_Error(
+ 'invalid_response',
+ __( 'Invalid response format from Codex', 'wp-agentic-writer' )
+ );
+ }
+
+ $content = $response_body['choices'][0]['message']['content'];
+ $usage = $response_body['usage'] ?? array();
+
+ // Calculate cost based on OpenAI pricing
+ $cost = $this->calculate_cost( $model, $usage );
+
+ return array(
+ 'content' => $content,
+ 'model' => $model,
+ 'input_tokens' => $usage['prompt_tokens'] ?? 0,
+ 'output_tokens' => $usage['completion_tokens'] ?? 0,
+ 'total_tokens' => $usage['total_tokens'] ?? 0,
+ 'cost' => $cost,
+ 'generation_time' => $generation_time,
+ );
+ }
+
+ /**
+ * Streaming chat completion
+ *
+ * @param array $messages Array of message objects.
+ * @param array $options Optional parameters.
+ * @param string $type Task type.
+ * @param callable $callback Function to call with each chunk.
+ * @return array|WP_Error Response or error.
+ */
+ public function chat_stream( $messages, $options = array(), $type = 'planning', $callback = null ) {
+ if ( ! $this->is_configured() ) {
+ return new WP_Error(
+ 'not_configured',
+ __( 'Codex API key not configured.', 'wp-agentic-writer' )
+ );
+ }
+
+ $model = $options['model'] ?? $this->model;
+ $temperature = $options['temperature'] ?? 0.7;
+
+ $body = array(
+ 'model' => $model,
+ 'messages' => $messages,
+ 'temperature' => $temperature,
+ 'stream' => true,
+ );
+
+ if ( isset( $options['max_tokens'] ) ) {
+ $body['max_tokens'] = $options['max_tokens'];
+ }
+
+ $accumulated_content = '';
+ $accumulated_usage = array();
+ $buffer = '';
+
+ $accumulating_callback = function( $chunk, $is_complete ) use ( &$accumulated_content, $callback ) {
+ if ( ! $is_complete && ! empty( $chunk ) ) {
+ $accumulated_content .= $chunk;
+ }
+
+ if ( $callback ) {
+ call_user_func( $callback, $chunk, $is_complete, $accumulated_content );
+ }
+ };
+
+ $ch = curl_init( $this->api_endpoint );
+
+ curl_setopt_array( $ch, array(
+ CURLOPT_POST => true,
+ CURLOPT_RETURNTRANSFER => false,
+ CURLOPT_WRITEFUNCTION => function( $curl, $data ) use ( &$buffer, $accumulating_callback, &$accumulated_usage ) {
+ $buffer .= $data;
+
+ while ( true ) {
+ $newline_pos = strpos( $buffer, "\n" );
+ if ( false === $newline_pos ) {
+ break;
+ }
+
+ $line = substr( $buffer, 0, $newline_pos );
+ $buffer = substr( $buffer, $newline_pos + 1 );
+
+ $line = trim( $line );
+ if ( empty( $line ) || ! str_starts_with( $line, 'data: ' ) ) {
+ continue;
+ }
+
+ $json_str = substr( $line, 6 );
+
+ if ( '[DONE]' === $json_str ) {
+ call_user_func( $accumulating_callback, '', true );
+ return strlen( $data );
+ }
+
+ $chunk = json_decode( $json_str, true );
+ if ( isset( $chunk['choices'][0]['delta']['content'] ) ) {
+ $content = $chunk['choices'][0]['delta']['content'];
+ call_user_func( $accumulating_callback, $content, false );
+ }
+
+ if ( isset( $chunk['usage'] ) ) {
+ $accumulated_usage = $chunk['usage'];
+ }
+ }
+
+ return strlen( $data );
+ },
+ CURLOPT_HTTPHEADER => array(
+ 'Authorization: Bearer ' . $this->api_key,
+ 'Content-Type: application/json',
+ ),
+ CURLOPT_POSTFIELDS => wp_json_encode( $body ),
+ CURLOPT_TIMEOUT => 180,
+ ) );
+
+ $start_time = microtime( true );
+ $result = curl_exec( $ch );
+ $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
+ $curl_error = curl_error( $ch );
+ curl_close( $ch );
+
+ if ( false === $result && ! empty( $curl_error ) ) {
+ return new WP_Error( 'curl_error', 'cURL error: ' . $curl_error );
+ }
+
+ if ( $http_code >= 400 ) {
+ return new WP_Error( 'api_error', sprintf( 'API error (%d)', $http_code ) );
+ }
+
+ $cost = $this->calculate_cost( $model, $accumulated_usage );
+
+ return array(
+ 'content' => $accumulated_content,
+ 'model' => $model,
+ 'input_tokens' => $accumulated_usage['prompt_tokens'] ?? 0,
+ 'output_tokens' => $accumulated_usage['completion_tokens'] ?? 0,
+ 'total_tokens' => $accumulated_usage['total_tokens'] ?? 0,
+ 'cost' => $cost,
+ 'generation_time' => microtime( true ) - $start_time,
+ );
+ }
+
+ /**
+ * Generate image (not supported by Codex)
+ *
+ * @param string $prompt Image prompt.
+ * @param string $model Model to use.
+ * @param array $options Optional parameters.
+ * @return WP_Error Error indicating not supported.
+ */
+ public function generate_image( $prompt, $model = null, $options = array() ) {
+ return new WP_Error(
+ 'not_supported',
+ __( 'Image generation not supported by Codex. Use OpenRouter for images.', 'wp-agentic-writer' )
+ );
+ }
+
+ /**
+ * Check if provider is configured
+ *
+ * @return bool True if API key is set.
+ */
+ public function is_configured() {
+ return ! empty( $this->api_key );
+ }
+
+ /**
+ * Test connection to Codex API
+ *
+ * @return array|WP_Error Success array or error.
+ */
+ public function test_connection() {
+ if ( ! $this->is_configured() ) {
+ return new WP_Error( 'not_configured', 'Codex API key not configured' );
+ }
+
+ $response = wp_remote_post(
+ $this->api_endpoint,
+ array(
+ 'headers' => array(
+ 'Authorization' => 'Bearer ' . $this->api_key,
+ 'Content-Type' => 'application/json',
+ ),
+ 'body' => wp_json_encode(
+ array(
+ 'model' => $this->model,
+ 'messages' => array(
+ array(
+ 'role' => 'user',
+ 'content' => 'Reply with: Test successful',
+ ),
+ ),
+ 'max_tokens' => 10,
+ )
+ ),
+ 'timeout' => 15,
+ )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ }
+
+ $code = wp_remote_retrieve_response_code( $response );
+ if ( 200 !== $code ) {
+ $body = json_decode( wp_remote_retrieve_body( $response ), true );
+ return new WP_Error(
+ 'connection_failed',
+ $body['error']['message'] ?? sprintf( 'API returned status %d', $code )
+ );
+ }
+
+ return array(
+ 'success' => true,
+ 'message' => 'Connected to OpenAI Codex successfully',
+ );
+ }
+
+ /**
+ * Check if provider supports task type
+ *
+ * @param string $type Task type.
+ * @return bool True if supported (all text tasks).
+ */
+ public function supports_task_type( $type ) {
+ return in_array(
+ $type,
+ array( 'chat', 'clarity', 'planning', 'writing', 'refinement' ),
+ true
+ );
+ }
+
+ /**
+ * Calculate cost based on OpenAI pricing
+ *
+ * @param string $model Model identifier.
+ * @param array $usage Usage data with prompt_tokens and completion_tokens.
+ * @return float Cost in USD.
+ */
+ private function calculate_cost( $model, $usage ) {
+ if ( empty( $usage['prompt_tokens'] ) && empty( $usage['completion_tokens'] ) ) {
+ return 0;
+ }
+
+ // OpenAI pricing (as of 2024, per 1M tokens)
+ $pricing = array(
+ 'gpt-4o' => array( 'input' => 2.50, 'output' => 10.00 ),
+ 'gpt-4o-mini' => array( 'input' => 0.15, 'output' => 0.60 ),
+ 'gpt-4-turbo' => array( 'input' => 10.00, 'output' => 30.00 ),
+ 'gpt-4' => array( 'input' => 30.00, 'output' => 60.00 ),
+ 'gpt-3.5-turbo' => array( 'input' => 0.50, 'output' => 1.50 ),
+ );
+
+ $rates = $pricing[ $model ] ?? $pricing['gpt-4o'];
+
+ $input_cost = ( $usage['prompt_tokens'] ?? 0 ) * ( $rates['input'] / 1000000 );
+ $output_cost = ( $usage['completion_tokens'] ?? 0 ) * ( $rates['output'] / 1000000 );
+
+ return $input_cost + $output_cost;
+ }
+}
diff --git a/includes/class-gutenberg-sidebar.php b/includes/class-gutenberg-sidebar.php
index c9276c9..e56490d 100644
--- a/includes/class-gutenberg-sidebar.php
+++ b/includes/class-gutenberg-sidebar.php
@@ -136,6 +136,39 @@ class WP_Agentic_Writer_Gutenberg_Sidebar {
true
);
+ // Enqueue image block toolbar script.
+ $block_image_script_path = WP_AGENTIC_WRITER_DIR . 'assets/js/block-image-generate.js';
+ wp_enqueue_script(
+ 'wp-agentic-writer-block-image-generate',
+ WP_AGENTIC_WRITER_URL . 'assets/js/block-image-generate.js',
+ array(
+ 'wp-block-editor',
+ 'wp-components',
+ 'wp-compose',
+ 'wp-data',
+ 'wp-element',
+ 'wp-hooks',
+ 'wp-i18n',
+ ),
+ file_exists( $block_image_script_path ) ? filemtime( $block_image_script_path ) : WP_AGENTIC_WRITER_VERSION,
+ true
+ );
+
+ // Enqueue image modal script.
+ $image_modal_script_path = WP_AGENTIC_WRITER_DIR . 'assets/js/image-modal.js';
+ wp_enqueue_script(
+ 'wp-agentic-writer-image-modal',
+ WP_AGENTIC_WRITER_URL . 'assets/js/image-modal.js',
+ array(
+ 'wp-components',
+ 'wp-element',
+ 'wp-data',
+ 'wp-block-editor',
+ ),
+ file_exists( $image_modal_script_path ) ? filemtime( $image_modal_script_path ) : WP_AGENTIC_WRITER_VERSION,
+ true
+ );
+
// Enqueue sidebar styles.
$style_path = WP_AGENTIC_WRITER_DIR . 'assets/css/sidebar.css';
wp_enqueue_style(
@@ -464,6 +497,37 @@ class WP_Agentic_Writer_Gutenberg_Sidebar {
'permission_callback' => array( $this, 'check_permissions' ),
)
);
+
+ // Image generation endpoints.
+ register_rest_route(
+ 'wp-agentic-writer/v1',
+ '/image-recommendations/(?P\d+)',
+ array(
+ 'methods' => 'GET',
+ 'callback' => array( $this, 'handle_get_image_recommendations' ),
+ 'permission_callback' => array( $this, 'check_permissions' ),
+ )
+ );
+
+ register_rest_route(
+ 'wp-agentic-writer/v1',
+ '/generate-image',
+ array(
+ 'methods' => 'POST',
+ 'callback' => array( $this, 'handle_generate_image' ),
+ 'permission_callback' => array( $this, 'check_permissions' ),
+ )
+ );
+
+ register_rest_route(
+ 'wp-agentic-writer/v1',
+ '/commit-image',
+ array(
+ 'methods' => 'POST',
+ 'callback' => array( $this, 'handle_commit_image' ),
+ 'permission_callback' => array( $this, 'check_permissions' ),
+ )
+ );
}
/**
@@ -498,17 +562,39 @@ class WP_Agentic_Writer_Gutenberg_Sidebar {
$stored_language = get_post_meta( $post_id, '_wpaw_detected_language', true );
$effective_language = $this->resolve_language_preference( $post_config, $detected_from_message ?: $stored_language );
+ // Extract focus keyword for context anchoring
+ $focus_keyword = '';
+ if ( ! empty( $post_config['focus_keyword'] ) ) {
+ $focus_keyword = sanitize_text_field( $post_config['focus_keyword'] );
+ } elseif ( ! empty( $post_config['seo_focus_keyword'] ) ) {
+ $focus_keyword = sanitize_text_field( $post_config['seo_focus_keyword'] );
+ } elseif ( $post_id > 0 ) {
+ $focus_keyword = get_post_meta( $post_id, '_wpaw_focus_keyword', true );
+ }
+
+ // Build focus keyword instruction for chat
+ $focus_keyword_instruction = '';
+ if ( ! empty( $focus_keyword ) ) {
+ $focus_keyword_instruction = "
+CONTEXT ANCHOR: The user is working on an article about \"{$focus_keyword}\".
+Keep your responses relevant to this primary topic. If the conversation drifts, gently guide it back to \"{$focus_keyword}\".
+
+At the END of your response, if you identify a good focus keyword from the discussion, suggest it in this format:
+**Focus Keyword Suggestion:** [your suggested keyword]
+";
+ }
+
$language_instruction = $this->build_language_instruction( $effective_language, 'chat responses' );
$system_prompt = "You are a helpful writing assistant. Answer clearly, with concise structure and practical suggestions.
-
+{$focus_keyword_instruction}
CRITICAL LANGUAGE REQUIREMENT:
{$language_instruction}
{$post_config_context}";
$messages = $this->prepend_system_prompt( $messages, $system_prompt );
- // Get OpenRouter provider.
- $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
+ // Get provider for this task type.
+ $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( $type );
if ( $stream ) {
$web_search_options = $this->get_web_search_options( $post_config );
@@ -567,33 +653,55 @@ CRITICAL LANGUAGE REQUIREMENT:
header( 'Cache-Control: no-cache' );
header( 'X-Accel-Buffering: no' );
- if ( ob_get_level() > 0 ) {
+ // Aggressively disable ALL output buffering layers (WordPress nests multiple)
+ @ini_set( 'output_buffering', 'Off' );
+ @ini_set( 'zlib.output_compression', false );
+ while ( ob_get_level() > 0 ) {
ob_end_flush();
}
flush();
- $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
+ $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( $type );
$accumulated_content = '';
$total_cost = 0;
+ $chunks_emitted = 0;
+
+ $this->maybe_inject_brave_search( $messages, $provider, $web_search_options );
$response = $provider->chat_stream(
$messages,
$web_search_options,
$type,
- function( $chunk, $is_complete, $full_content ) use ( &$accumulated_content ) {
+ function( $chunk, $is_complete, $full_content ) use ( &$accumulated_content, &$chunks_emitted ) {
$accumulated_content = $full_content;
if ( '' !== $chunk ) {
+ $chunks_emitted++;
echo "data: " . wp_json_encode(
array(
'type' => 'conversational_stream',
'content' => $accumulated_content,
)
) . "\n\n";
+ if ( ob_get_level() > 0 ) {
+ ob_end_flush();
+ }
flush();
}
}
);
+ // Fallback: if streaming produced no chunks but we have accumulated content, emit it now
+ if ( 0 === $chunks_emitted && ! is_wp_error( $response ) && ! empty( $response['content'] ) ) {
+ $accumulated_content = $response['content'];
+ echo "data: " . wp_json_encode(
+ array(
+ 'type' => 'conversational_stream',
+ 'content' => $accumulated_content,
+ )
+ ) . "\n\n";
+ flush();
+ }
+
if ( is_wp_error( $response ) ) {
echo "data: " . wp_json_encode(
array(
@@ -1020,11 +1128,12 @@ CRITICAL LANGUAGE REQUIREMENT:
}
// If fallback is provided and not empty, use it
- if ( ! empty( $fallback ) && 'auto' !== $fallback ) {
- return $fallback;
+ if ( ! empty( $fallback ) && 'auto' !== strtolower( $fallback ) ) {
+ return strtolower( $fallback );
}
- return 'english';
+ // Default to 'auto' instead of 'english' to let AI detect from context
+ return 'auto';
}
/**
@@ -1040,7 +1149,7 @@ CRITICAL LANGUAGE REQUIREMENT:
// If auto or empty, let AI detect from context
if ( empty( $language ) || 'auto' === strtolower( $language ) ) {
- return "Write the {$context} in the most appropriate language based on the topic and context.";
+ return "CRITICAL: Detect the language from the conversation history and topic. Write ALL {$context} in the SAME language as the user's input. If the user wrote in Indonesian, write in Indonesian. If English, write in English. Match the user's language exactly.";
}
// Pass any language name directly to AI - AI models understand all languages
@@ -1072,6 +1181,63 @@ CRITICAL LANGUAGE REQUIREMENT:
return $messages;
}
+ /**
+ * Physically scrapes the web and injects the results as a system prompt if applicable.
+ *
+ * @since 0.1.0
+ * @param array &$messages Chat messages (passed by reference).
+ * @param object $provider AI Provider instance.
+ * @param array $web_search_options Web search options.
+ * @return void
+ */
+ private function maybe_inject_brave_search( &$messages, $provider, $web_search_options ) {
+ if ( empty( $web_search_options['web_search_enabled'] ) ) {
+ return;
+ }
+
+ // Only inject if the provider doesn't natively support OpenRouter's web search routing plugins
+ if ( $provider instanceof WP_Agentic_Writer_OpenRouter_Provider ) {
+ return;
+ }
+
+ $last_query = '';
+ foreach ( array_reverse( $messages ) as $msg ) {
+ if ( 'user' === $msg['role'] ) {
+ $last_query = (string) $msg['content'];
+ break;
+ }
+ }
+
+ if ( empty( $last_query ) ) {
+ return;
+ }
+
+ $brave_search = WP_Agentic_Writer_Brave_Search_API::get_instance();
+ $results = $brave_search->search( $last_query, 3 );
+
+ if ( ! is_wp_error( $results ) && ! empty( $results ) ) {
+ $context_markdown = $brave_search->format_results_for_llm( $results, $last_query );
+
+ $injection_message = array(
+ 'role' => 'system',
+ 'content' => $context_markdown
+ );
+
+ $injected = false;
+ for( $i = count( $messages ) - 1; $i >= 0; $i-- ) {
+ if ( 'user' === $messages[ $i ]['role'] ) {
+ array_splice( $messages, $i, 0, array( $injection_message ) );
+ $injected = true;
+ break;
+ }
+ }
+
+ if ( ! $injected ) {
+ array_unshift( $messages, $injection_message );
+ }
+ }
+ }
+
/**
* Build web search option overrides.
*
@@ -1206,12 +1372,22 @@ CRITICAL LANGUAGE REQUIREMENT:
return $this->stream_generate_plan( $topic, $context, $post_id, $auto_execute, $article_length, $clarification_answers, $effective_language, $post_config, $chat_history );
}
- // Get OpenRouter provider.
- $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
+ // Get provider for planning task.
+ $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' );
// Build prompt for plan generation.
$plan_language_instruction = $this->build_language_instruction( $effective_language, 'article plan (title, section headings, descriptions)' );
- $system_prompt = "You are an expert content strategist and technical writer. Your task is to create a detailed article plan/outline based on the user's topic and context.
+ $system_prompt = "You are an Information Architect and SEO/GEO Strategist. Your task is to outline a high-information-density article based on the user's topic and context.
+
+ANTI-ROBOT RULES:
+- Never use generic intros or 'throat-clearing' fluff.
+- Avoid academic, pompous, or 'expert' posturing.
+- Headings must provide direct value or ask specific questions the article will answer.
+
+GEO/SEO STRATEGY:
+- Design the outline for Generative Engine Optimization (GEO): sections must flow logically to answer the user's core intent comprehensively.
+- Suggest strategic use of tables, bullet points, and Q&A formats where they maximize information density.
+- Incorporate secondary entities and related concepts naturally to show topical depth.
CRITICAL LANGUAGE REQUIREMENT:
{$plan_language_instruction}
@@ -1256,6 +1432,7 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar
);
// Generate plan.
+ $this->maybe_inject_brave_search( $messages, $provider, $web_search_options );
$response = $provider->chat( $messages, array_merge( array( 'temperature' => 0.7 ), $web_search_options ), 'planning' );
if ( is_wp_error( $response ) ) {
@@ -1350,7 +1527,7 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar
);
}
- $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
+ $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' );
$memory_context = $this->get_post_memory_context( $post_id );
$system_prompt = "You are an expert content strategist. Revise the provided outline based on the user's instruction.
@@ -1402,6 +1579,8 @@ Rules:
),
);
+ // Generate revised plan.
+ $this->maybe_inject_brave_search( $messages, $provider, $web_search_options );
$response = $provider->chat( $messages, array_merge( array( 'temperature' => 0.6 ), $web_search_options ), 'planning' );
if ( is_wp_error( $response ) ) {
@@ -1576,13 +1755,26 @@ Rules:
}
flush();
- $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
+ $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' );
$total_cost = 0;
$post_config = $this->sanitize_post_config( wp_parse_args( $post_config, $this->get_default_post_config() ) );
$post_config_context = $this->build_post_config_context( $post_config );
$web_search_options = $this->get_web_search_options( $post_config );
$effective_language = $this->resolve_language_preference( $post_config, $detected_language );
+ // Extract focus keyword for context anchoring
+ $focus_keyword = '';
+ if ( ! empty( $post_config['focus_keyword'] ) ) {
+ $focus_keyword = sanitize_text_field( $post_config['focus_keyword'] );
+ } elseif ( ! empty( $post_config['seo_focus_keyword'] ) ) {
+ $focus_keyword = sanitize_text_field( $post_config['seo_focus_keyword'] );
+ }
+
+ // Save focus keyword to post meta for persistence
+ if ( $post_id > 0 && ! empty( $focus_keyword ) ) {
+ update_post_meta( $post_id, '_wpaw_focus_keyword', $focus_keyword );
+ }
+
try {
// Note: Clarity check should be done BEFORE calling this streaming endpoint
// The frontend is responsible for checking clarity first via /check-clarity
@@ -1656,8 +1848,22 @@ Rules:
// Determine language instruction for plan generation
$plan_language_instruction = $this->build_language_instruction( $effective_language, 'article plan (title, section headings, descriptions)' );
- $system_prompt = "You are an expert content strategist and technical writer. Your task is to create a detailed article plan/outline based on the user's topic and context.
+ // Build focus keyword anchor instruction
+ $focus_keyword_instruction = '';
+ if ( ! empty( $focus_keyword ) ) {
+ $focus_keyword_instruction = "
+PRIMARY TOPIC ANCHOR: \"{$focus_keyword}\"
+CRITICAL: This article MUST be about \"{$focus_keyword}\".
+- The title MUST include or clearly relate to \"{$focus_keyword}\"
+- All sections MUST support this primary topic
+- Recent conversation refinements are meant to ENHANCE this topic, not REPLACE it
+- If user discussed sub-topics, treat them as ASPECTS of the primary topic \"{$focus_keyword}\"
+";
+ }
+
+ $system_prompt = "You are an expert content strategist and technical writer. Your task is to create a detailed article plan/outline based on the user's topic and context.
+{$focus_keyword_instruction}
CRITICAL LANGUAGE REQUIREMENT:
{$plan_language_instruction}
@@ -1706,6 +1912,7 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar
error_log( 'WP Agentic Writer: Calling OpenRouter API for planning. Topic: ' . substr( $topic, 0, 100 ) );
error_log( 'WP Agentic Writer: Detected language: ' . $detected_language );
+ $this->maybe_inject_brave_search( $messages, $provider, $web_search_options );
$response = $provider->chat( $messages, array_merge( array( 'temperature' => 0.7 ), $web_search_options ), 'planning' );
error_log( 'WP Agentic Writer: OpenRouter API response received' );
@@ -2105,7 +2312,31 @@ Remember: You MUST include the ~~~ARTICLE~~~ divider to separate your conversati
// NOW parse the complete markdown content and send blocks
if ( ! empty( $markdown_content ) ) {
- $markdown_blocks = WP_Agentic_Writer_Markdown_Parser::parse( $markdown_content );
+ // Extract image placeholders and generate IDs
+ $image_placeholders = array();
+ if ( preg_match_all( '/\[IMAGE:\s*(.+?)\]/i', $markdown_content, $matches ) ) {
+ $image_manager = WP_Agentic_Writer_Image_Manager::get_instance();
+
+ foreach ( $matches[1] as $index => $description ) {
+ $agent_image_id = 'img_' . $post_id . '_' . time() . '_' . ( $index + 1 );
+ $image_placeholders[] = array(
+ 'agent_image_id' => $agent_image_id,
+ 'description' => trim( $description ),
+ );
+
+ // Save to database
+ $image_manager->save_image_recommendation(
+ $post_id,
+ $agent_image_id,
+ 'section_' . $section_id,
+ $heading,
+ trim( $description ),
+ trim( $description )
+ );
+ }
+ }
+
+ $markdown_blocks = WP_Agentic_Writer_Markdown_Parser::parse( $markdown_content, $image_placeholders );
foreach ( $markdown_blocks as $block ) {
echo "data: " . wp_json_encode(
@@ -2221,8 +2452,8 @@ Remember: You MUST include the ~~~ARTICLE~~~ divider to separate your conversati
}
}
- // Get OpenRouter provider.
- $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
+ // Get provider for writing task.
+ $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' );
$image_instruction = "IMAGE SUGGESTIONS:
- Suggest where images would enhance understanding
@@ -2282,7 +2513,18 @@ Remember: You MUST include the ~~~ARTICLE~~~ divider to separate your conversati
}
// Build system prompt for article generation.
- $system_prompt = "You are an expert technical writer and blogger. Your task is to write engaging, clear, and well-structured content based on the provided article plan.
+ $system_prompt = "You are an industry practitioner sharing insights with a colleague. Write engaging, high-information-density content based on the provided article plan.
+
+ANTI-ROBOT RULES:
+- BANNED WORDS: delve, furthermore, moreover, crucial, paramount, landscape, testament, in today's digital world, in conclusion.
+- BANNED PATTERNS: Do not use transition words to start paragraphs. Do not summarize what you are about to say. Do not summarize what you just said.
+- BURSTINESS: Mix very short, punchy sentences (3-5 words) with longer, descriptive ones. Avoid uniform sentence length.
+- TONE: Conversational, direct, pragmatic. Do not sound like an academic 'expert' or textbook.
+
+GEO/SEO STRATEGY:
+- Answer the implicit user intent directly and immediately in the first paragraph.
+- Maximize information density: high ratio of facts/insights to total word count. Remove filler adjectives.
+- Use bullet points or numbered lists where they make data easier to scan.
CRITICAL LANGUAGE REQUIREMENT:
{$language_instruction}
@@ -2291,22 +2533,17 @@ CRITICAL LANGUAGE REQUIREMENT:
Follow these guidelines:
- Use the tone specified in POST CONFIG if provided; otherwise be conversational but professional
-- Use clear examples and analogies
-- For code blocks, use proper syntax highlighting
-- Keep paragraphs concise (2-3 sentences)
-- Use transitions between sections
-- Write for the specified difficulty level
-- Code formatting: Any code/config snippets MUST be in fenced code blocks with a language tag (e.g., ```php). Never place code inline in paragraphs.
+- Embed secondary keywords naturally as concepts, without forcing exact matches
+- For code blocks, use proper syntax highlighting (e.g., ```php)
- Code typography: Use plain ASCII quotes inside code. Do NOT use smart quotes.
+- Write for the specified difficulty level
{$seo_instruction}
{$internal_links_instruction}
IMAGE SUGGESTIONS:
-- Suggest where images would enhance understanding
-- Place image suggestions on their own line using this format: [IMAGE: descriptive alt text]
-- Be strategic: only suggest images where they add real value (diagrams, screenshots, visual examples)
-- Good places for images: after introductions, before complex explanations, to show examples
-- Maximum 1-2 image suggestions per section
+- Suggest where images would enhance understanding (diagrams, screenshots)
+- Place image suggestions on their own line: [IMAGE: descriptive alt text]
+- Maximum 1 image per section
{$image_instruction}";
// Generate content for each section.
@@ -2435,7 +2672,7 @@ IMAGE SUGGESTIONS:
}
flush();
- $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
+ $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' );
$total_cost = 0;
$post_config = $this->sanitize_post_config( wp_parse_args( $post_config, $this->get_default_post_config() ) );
$post_config_context = $this->build_post_config_context( $post_config );
@@ -2527,7 +2764,18 @@ IMAGE SUGGESTIONS:
}
}
- $system_prompt = "You are an expert technical writer and blogger. Your task is to write engaging, clear, and well-structured content based on the provided article plan.
+ $system_prompt = "You are an industry practitioner sharing insights with a colleague. Write engaging, high-information-density content based on the provided article plan.
+
+ANTI-ROBOT RULES:
+- BANNED WORDS: delve, furthermore, moreover, crucial, paramount, landscape, testament, in today's digital world, in conclusion.
+- BANNED PATTERNS: Do not use transition words to start paragraphs. Do not summarize what you are about to say. Do not summarize what you just said.
+- BURSTINESS: Mix very short, punchy sentences (3-5 words) with longer, descriptive ones. Avoid uniform sentence length.
+- TONE: Conversational, direct, pragmatic. Do not sound like an academic 'expert' or textbook.
+
+GEO/SEO STRATEGY:
+- Answer the implicit user intent directly and immediately in the first paragraph.
+- Maximize information density: high ratio of facts/insights to total word count. Remove filler adjectives.
+- Use bullet points or numbered lists where they make data easier to scan.
CRITICAL LANGUAGE REQUIREMENT:
{$language_instruction}
@@ -2535,22 +2783,17 @@ CRITICAL LANGUAGE REQUIREMENT:
Follow these guidelines:
- Use the tone specified in POST CONFIG if provided; otherwise be conversational but professional
-- Use clear examples and analogies
-- For code blocks, use proper syntax highlighting
-- Keep paragraphs concise (2-3 sentences)
-- Use transitions between sections
-- Write for the specified difficulty level
-- Code formatting: Any code/config snippets MUST be in fenced code blocks with a language tag (e.g., ```php). Never place code inline in paragraphs.
+- Embed secondary keywords naturally as concepts, without forcing exact matches
+- For code blocks, use proper syntax highlighting (e.g., ```php)
- Code typography: Use plain ASCII quotes inside code. Do NOT use smart quotes.
+- Write for the specified difficulty level
{$seo_instruction}
{$internal_links_instruction}
IMAGE SUGGESTIONS:
-- Suggest where images would enhance understanding
-- Place image suggestions on their own line using this format: [IMAGE: descriptive alt text]
-- Be strategic: only suggest images where they add real value (diagrams, screenshots, visual examples)
-- Good places for images: after introductions, before complex explanations, to show examples
-- Maximum 1-2 image suggestions per section
+- Suggest where images would enhance understanding (diagrams, screenshots)
+- Place image suggestions on their own line: [IMAGE: descriptive alt text]
+- Maximum 1 image per section
{$image_instruction}";
$section_index = 0;
@@ -2795,8 +3038,8 @@ IMAGE SUGGESTIONS:
);
}
- // Get OpenRouter provider.
- $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
+ // Get provider for writing task.
+ $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' );
$messages = array(
array(
@@ -2944,7 +3187,7 @@ IMAGE SUGGESTIONS:
);
}
- $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
+ $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' );
// Get settings.
$settings = get_option( 'wp_agentic_writer_settings', array() );
@@ -2960,15 +3203,21 @@ IMAGE SUGGESTIONS:
'pov',
);
- // If quiz is disabled, always return clear.
+ // If quiz is disabled, skip AI questions but still add MANDATORY config questions
if ( ! $enabled ) {
+ $result = array(
+ 'is_clear' => true,
+ 'confidence' => 1.0,
+ 'questions' => array(),
+ );
+ // MANDATORY: Always add config questions (language, focus keyword)
+ $result['questions'] = $this->append_config_questions( $result['questions'], $post_config );
+ if ( ! empty( $result['questions'] ) ) {
+ $result['is_clear'] = false; // Force quiz for config questions
+ }
return new WP_REST_Response(
array(
- 'result' => array(
- 'is_clear' => true,
- 'confidence' => 1.0,
- 'questions' => array(),
- ),
+ 'result' => $result,
'cost' => 0,
),
200
@@ -3079,9 +3328,11 @@ QUESTION TYPES:
CONFIDENCE CALCULATION:
- Start at 100% (1.0)
-- Subtract 20% for each missing HIGH-PRIORITY category (topic_scope, target_platform, specific_focus)
+- Subtract 15% for each missing HIGH-PRIORITY category (topic_scope, target_platform, specific_focus)
- Subtract 10% for each missing SECONDARY category (target_outcome, target_audience, content_depth)
+- CRITICAL: If chat history exists with detailed discussion, ADD 20% confidence bonus (user already provided context)
- If confidence < {$threshold}, generate questions starting with HIGH-PRIORITY
+- MANDATORY: Even if confidence >= threshold, ALWAYS ask at least 1-2 system config questions (language, SEO settings)
QUESTION GENERATION STRATEGY:
1. Always detect user language first and match it
@@ -3118,6 +3369,11 @@ No markdown, no explanation - just JSON.";
// Log error and use default questions instead of failing.
error_log( 'WP Agentic Writer: Clarity check API error - ' . $response->get_error_message() );
$result = $this->get_default_clarification_questions( $topic );
+ // MANDATORY: Always add config questions
+ $result['questions'] = $this->append_config_questions( $result['questions'] ?? array(), $post_config );
+ if ( ! empty( $result['questions'] ) ) {
+ $result['is_clear'] = false;
+ }
return new WP_REST_Response(
array(
'result' => $result,
@@ -3135,6 +3391,11 @@ No markdown, no explanation - just JSON.";
// Log parse error and use default questions instead of failing.
error_log( 'WP Agentic Writer: Failed to parse clarity check JSON' );
$result = $this->get_default_clarification_questions( $topic );
+ // MANDATORY: Always add config questions
+ $result['questions'] = $this->append_config_questions( $result['questions'] ?? array(), $post_config );
+ if ( ! empty( $result['questions'] ) ) {
+ $result['is_clear'] = false;
+ }
return new WP_REST_Response(
array(
'result' => $result,
@@ -3156,15 +3417,15 @@ No markdown, no explanation - just JSON.";
$response['cost'] ?? 0
);
- // Always add configuration questions, even if no clarity questions
+ // MANDATORY: Always add configuration questions
if ( ! isset( $result['questions'] ) || ! is_array( $result['questions'] ) ) {
$result['questions'] = array();
}
$result['questions'] = $this->append_config_questions( $result['questions'], $post_config );
- // If only config questions exist and clarity is clear, still show config
- if ( empty( $result['questions'] ) === false && $result['is_clear'] === true ) {
- $result['is_clear'] = false; // Force quiz to show for config questions
+ // CRITICAL: Always show quiz if config questions exist (system questions are MANDATORY)
+ if ( ! empty( $result['questions'] ) ) {
+ $result['is_clear'] = false; // Force quiz to show - config questions are mandatory
}
return new WP_REST_Response(
@@ -3312,7 +3573,7 @@ No markdown, no explanation - just JSON.";
return $this->stream_block_refine( $block_id, $block_type, $block_content, $refinement_request, $article_context, $post_id, $post_config );
}
- $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
+ $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'refinement' );
// Build context from article structure.
$context_str = "\n\nArticle Context:\n";
@@ -3450,7 +3711,7 @@ Keep the same block type (paragraph, heading, list, etc.).";
}
flush();
- $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
+ $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'refinement' );
$post_config = $this->sanitize_post_config( wp_parse_args( $post_config, $this->get_default_post_config() ) );
$post_config_context = $this->build_post_config_context( $post_config );
$stored_language = get_post_meta( $post_id, '_wpaw_detected_language', true );
@@ -3473,10 +3734,16 @@ Keep the same block type (paragraph, heading, list, etc.).";
$context_str .= "Next section: " . $article_context['nextBlock']['heading'] . "\n";
}
- $system_prompt = "You are an expert content refiner. Your task is to rewrite and improve content based on the user's request.
+ $system_prompt = "You are a precise content editor. Your task is to refine the provided content based strictly on the user's request.
+
+ANTI-ROBOT RULES:
+- BANNED WORDS: delve, furthermore, moreover, crucial, paramount, landscape, testament.
+- Do not add introductory throat-clearing sentences or summarizing conclusions unless explicitly requested.
+- Increase specificity and information density. Do not just increase word count with conversational filler.
CRITICAL LANGUAGE REQUIREMENT:
{$language_instruction}
+
{$post_config_context}
{$context_str}
@@ -4057,7 +4324,7 @@ No markdown, no explanation - just JSON.";
);
}
- $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
+ $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' );
$post_config = $this->sanitize_post_config( wp_parse_args( $post_config, $this->get_default_post_config() ) );
$post_config_context = $this->build_post_config_context( $post_config );
$stored_language = get_post_meta( $post_id, '_wpaw_detected_language', true );
@@ -4225,7 +4492,12 @@ Blocks:
$context_str .= "Next section: " . $article_context['nextBlock']['heading'] . "\n";
}
- $system_prompt = "You are an expert content refiner. Your task is to rewrite and improve content based on the user's request.
+ $system_prompt = "You are a precise content editor. Your task is to refine the provided content based strictly on the user's request.
+
+ANTI-ROBOT RULES:
+- BANNED WORDS: delve, furthermore, moreover, crucial, paramount, landscape, testament.
+- Do not add introductory throat-clearing sentences or summarizing conclusions unless explicitly requested.
+- Increase specificity and information density. Do not just increase word count with conversational filler.
CRITICAL LANGUAGE REQUIREMENT:
{$language_instruction}
@@ -5026,7 +5298,7 @@ Output format:
}
$language_instruction = $this->build_language_instruction( $effective_language, 'meta description' );
- $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
+ $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' );
$prompt = "Generate a compelling meta description for SEO. Requirements:\n";
$prompt .= "- Length: MAXIMUM 155 characters (STRICT - count every character including spaces)\n";
@@ -5138,7 +5410,7 @@ Output format:
}
}
- $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
+ $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' );
$prompt = "Generate a compelling meta description for SEO. Requirements:\n";
$prompt .= "- Length: MAXIMUM 155 characters (STRICT - count every character including spaces)\n";
@@ -5303,8 +5575,8 @@ PREFERENCES: [any specific requirements]
Conversation:
{$history_text}";
- // Call AI with cheap model
- $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
+ // Call AI with clarity model for language detection
+ $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' );
$messages = array(
array(
'role' => 'user',
@@ -5385,8 +5657,8 @@ User's message: \"{$last_message}\"
Respond with ONLY the intent code (e.g., \"create_outline\"). No explanation.";
- // Call AI with cheap model
- $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
+ // Call AI with clarity model for intent detection
+ $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' );
$messages = array(
array(
'role' => 'user',
@@ -5429,4 +5701,79 @@ Respond with ONLY the intent code (e.g., \"create_outline\"). No explanation.";
200
);
}
+
+ /**
+ * Handle get image recommendations request.
+ *
+ * @param WP_REST_Request $request REST request.
+ * @return WP_REST_Response|WP_Error Response.
+ */
+ public function handle_get_image_recommendations( $request ) {
+ $post_id = $request->get_param( 'post_id' );
+
+ $image_manager = WP_Agentic_Writer_Image_Manager::get_instance();
+ $images = $image_manager->get_image_recommendations( $post_id );
+
+ return new WP_REST_Response(
+ array( 'images' => $images ),
+ 200
+ );
+ }
+
+ /**
+ * Handle generate image request.
+ *
+ * @param WP_REST_Request $request REST request.
+ * @return WP_REST_Response|WP_Error Response.
+ */
+ public function handle_generate_image( $request ) {
+ $post_id = $request->get_param( 'post_id' );
+ $agent_image_id = $request->get_param( 'agent_image_id' );
+ $prompt = $request->get_param( 'prompt' );
+ $variant_count = $request->get_param( 'variant_count' ) ?? 2;
+
+ $image_manager = WP_Agentic_Writer_Image_Manager::get_instance();
+ $variants = $image_manager->generate_image_variants(
+ $post_id,
+ $agent_image_id,
+ $prompt,
+ $variant_count
+ );
+
+ if ( is_wp_error( $variants ) ) {
+ return $variants;
+ }
+
+ return new WP_REST_Response(
+ array( 'variants' => $variants ),
+ 200
+ );
+ }
+
+ /**
+ * Handle commit image request.
+ *
+ * @param WP_REST_Request $request REST request.
+ * @return WP_REST_Response|WP_Error Response.
+ */
+ public function handle_commit_image( $request ) {
+ $post_id = $request->get_param( 'post_id' );
+ $agent_image_id = $request->get_param( 'agent_image_id' );
+ $variant_id = $request->get_param( 'variant_id' );
+ $alt_text = $request->get_param( 'alt' );
+
+ $image_manager = WP_Agentic_Writer_Image_Manager::get_instance();
+ $result = $image_manager->commit_image_variant(
+ $post_id,
+ $agent_image_id,
+ $variant_id,
+ $alt_text
+ );
+
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ return new WP_REST_Response( $result, 200 );
+ }
}
diff --git a/includes/class-image-manager.php b/includes/class-image-manager.php
new file mode 100644
index 0000000..6905e03
--- /dev/null
+++ b/includes/class-image-manager.php
@@ -0,0 +1,689 @@
+get_charset_collate();
+
+ // Table 1: wp_wpaw_images
+ $table_images = $wpdb->prefix . 'wpaw_images';
+ $sql_images = "CREATE TABLE IF NOT EXISTS `{$table_images}` (
+ `id` bigint(20) NOT NULL AUTO_INCREMENT,
+ `post_id` bigint(20) NOT NULL,
+ `agent_image_id` varchar(50) NOT NULL,
+ `placement` varchar(100) DEFAULT NULL,
+ `section_title` varchar(255) DEFAULT NULL,
+ `prompt_initial` text NOT NULL,
+ `alt_text_initial` text DEFAULT NULL,
+ `prompt_edited` text DEFAULT NULL,
+ `alt_text_edited` text DEFAULT NULL,
+ `attachment_id` bigint(20) DEFAULT NULL,
+ `status` varchar(30) DEFAULT 'pending',
+ `cost_estimate` decimal(10, 4) DEFAULT NULL,
+ `cost_actual` decimal(10, 4) DEFAULT NULL,
+ `image_model` varchar(100) DEFAULT NULL,
+ `created_at` datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_post` (`post_id`),
+ KEY `idx_agent_image_id` (`post_id`, `agent_image_id`),
+ KEY `idx_status` (`status`),
+ KEY `idx_created` (`created_at`)
+ ) {$charset_collate};";
+
+ // Table 2: wp_wpaw_images_variants
+ $table_variants = $wpdb->prefix . 'wpaw_images_variants';
+ $sql_variants = "CREATE TABLE IF NOT EXISTS `{$table_variants}` (
+ `id` bigint(20) NOT NULL AUTO_INCREMENT,
+ `agentic_image_id` bigint(20) NOT NULL,
+ `post_id` bigint(20) NOT NULL,
+ `agent_image_id` varchar(50) NOT NULL,
+ `variant_number` int(11) DEFAULT 1,
+ `temp_file_path` varchar(500) NOT NULL,
+ `temp_file_url` varchar(500) NOT NULL,
+ `file_size` int(11) DEFAULT NULL,
+ `prompt_used` text DEFAULT NULL,
+ `image_model_used` varchar(100) DEFAULT NULL,
+ `generation_time` int(11) DEFAULT NULL,
+ `cost` decimal(10, 4) DEFAULT NULL,
+ `is_selected` tinyint(1) DEFAULT 0,
+ `selected_at` datetime DEFAULT NULL,
+ `status` varchar(30) DEFAULT 'temp',
+ `created_at` datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ `deleted_at` datetime DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_agentic_image` (`agentic_image_id`),
+ KEY `idx_post` (`post_id`),
+ KEY `idx_status` (`status`),
+ KEY `idx_created` (`created_at`)
+ ) {$charset_collate};";
+
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+ dbDelta( $sql_images );
+ dbDelta( $sql_variants );
+
+ // Create temp directory.
+ $this->create_temp_directory();
+ }
+
+ /**
+ * Create temp directory for image storage.
+ */
+ private function create_temp_directory() {
+ $upload_dir = wp_upload_dir();
+ $temp_dir = $upload_dir['basedir'] . '/wpaw';
+
+ if ( ! file_exists( $temp_dir ) ) {
+ wp_mkdir_p( $temp_dir );
+
+ // Add .htaccess to prevent direct access.
+ $htaccess = $temp_dir . '/.htaccess';
+ if ( ! file_exists( $htaccess ) ) {
+ file_put_contents( $htaccess, "Options -Indexes\n" );
+ }
+
+ // Add index.php for security.
+ $index = $temp_dir . '/index.php';
+ if ( ! file_exists( $index ) ) {
+ file_put_contents( $index, " 'user',
+ 'content' => "Analyze this article for image placement:\n\n" . $article_markdown,
+ ),
+ );
+
+ $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' );
+ $response = $provider->chat( $messages, array( 'temperature' => 0.3 ), 'planning' );
+
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ }
+
+ // Extract JSON from response.
+ $json_match = array();
+ if ( preg_match( '/\{[\s\S]*\}/m', $response['content'], $json_match ) ) {
+ $placement_data = json_decode( $json_match[0], true );
+ if ( JSON_ERROR_NONE === json_last_error() ) {
+ return $placement_data;
+ }
+ }
+
+ return new WP_Error( 'parse_error', 'Failed to parse placement analysis' );
+ }
+
+ /**
+ * Generate image prompts optimized for specific image model.
+ *
+ * @param string $article_markdown Article content.
+ * @param array $placement_data Placement analysis.
+ * @param int $post_id Post ID.
+ * @return array|WP_Error Image specifications or error.
+ */
+ public function generate_image_prompts( $article_markdown, $placement_data, $post_id ) {
+ $settings = get_option( 'wp_agentic_writer_settings', array() );
+ $writing_model = $settings['writing_model'] ?? 'anthropic/claude-3.5-sonnet';
+ $image_model = $settings['image_model'] ?? 'openai/gpt-4o';
+
+ // Get model-specific prompt guidance.
+ $prompt_guidance = $this->get_prompt_guidance_for_model( $image_model );
+
+ $system_prompt = "You are an Image Prompt Engineer specializing in {$prompt_guidance['model_name']}.
+
+TARGET MODEL: {$prompt_guidance['model_name']}
+PROMPT LENGTH: {$prompt_guidance['prompt_length']}
+COMPLEXITY: {$prompt_guidance['complexity']}
+
+{$prompt_guidance['guidance']}
+
+TEMPLATE: {$prompt_guidance['template']}
+
+Generate precise, cost-efficient prompts that exploit this model's strengths.
+
+Return JSON:
+{
+ \"images\": [
+ {
+ \"agent_image_id\": \"img_hero_1\",
+ \"placement\": \"after_introduction\",
+ \"section_title\": \"Introduction\",
+ \"prompt\": \"[Model-optimized prompt]\",
+ \"alt\": \"Descriptive alt text\",
+ \"image_model\": \"{$image_model}\"
+ }
+ ]
+}";
+
+ $user_input = wp_json_encode(
+ array(
+ 'article' => $article_markdown,
+ 'placement_points' => $placement_data['image_placement_points'],
+ 'image_count' => $placement_data['recommended_image_count'],
+ 'target_image_model' => $image_model,
+ )
+ );
+
+ $messages = array(
+ array(
+ 'role' => 'user',
+ 'content' => "Generate image prompts:\n\n" . $user_input,
+ ),
+ );
+
+ $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' );
+ $response = $provider->chat( $messages, array( 'temperature' => 0.7 ), 'planning' );
+
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ }
+
+ // Extract JSON.
+ $json_match = array();
+ if ( preg_match( '/\{[\s\S]*\}/m', $response['content'], $json_match ) ) {
+ $image_specs = json_decode( $json_match[0], true );
+ if ( JSON_ERROR_NONE === json_last_error() ) {
+ // Save to database.
+ $this->save_image_recommendations( $post_id, $image_specs['images'] );
+ return $image_specs;
+ }
+ }
+
+ return new WP_Error( 'parse_error', 'Failed to parse image prompts' );
+ }
+
+ /**
+ * Get prompt guidance for specific image model.
+ *
+ * @param string $image_model Image model ID.
+ * @return array Model configuration.
+ */
+ private function get_prompt_guidance_for_model( $image_model ) {
+ $model_configs = array(
+ 'black-forest-labs/flux.2-klein' => array(
+ 'model_name' => 'FLUX.2 [klein]',
+ 'prompt_length' => '1-2 sentences',
+ 'complexity' => 'simple',
+ 'guidance' => 'Keep prompts short and simple. Focus on main subject, key details, and style. Avoid complex scenes or technical specifications.',
+ 'template' => 'Subject, key elements, style, color palette',
+ ),
+ 'sourceful/riverflow-v2-max' => array(
+ 'model_name' => 'Riverflow V2 Max',
+ 'prompt_length' => '3-4 sentences',
+ 'complexity' => 'medium-detailed',
+ 'guidance' => 'Include context, environment details, lighting style, and photographic specifications. Model excels at photorealism.',
+ 'template' => 'Subject + context, environment details, lighting style, photography style, technical specs',
+ ),
+ 'black-forest-labs/flux.2-max' => array(
+ 'model_name' => 'FLUX.2 [max]',
+ 'prompt_length' => '4-6 sentences',
+ 'complexity' => 'very-detailed-technical',
+ 'guidance' => 'Use detailed technical vocabulary. Include exact materials, color codes (HEX), spatial relationships, and specifications.',
+ 'template' => 'Technical foundation, main subject + action, environment, lighting + mood, style + aesthetics, technical specifications',
+ ),
+ );
+
+ // Default to Riverflow if model not found.
+ return $model_configs[ $image_model ] ?? $model_configs['sourceful/riverflow-v2-max'];
+ }
+
+ /**
+ * Save image recommendations to database.
+ *
+ * @param int $post_id Post ID.
+ * @param array $images Image specifications.
+ */
+ private function save_image_recommendations( $post_id, $images ) {
+ global $wpdb;
+ $table = $wpdb->prefix . 'wpaw_images';
+
+ foreach ( $images as $image_spec ) {
+ $wpdb->insert(
+ $table,
+ array(
+ 'post_id' => $post_id,
+ 'agent_image_id' => $image_spec['agent_image_id'],
+ 'placement' => $image_spec['placement'],
+ 'section_title' => $image_spec['section_title'],
+ 'prompt_initial' => $image_spec['prompt'],
+ 'alt_text_initial' => $image_spec['alt'],
+ 'image_model' => $image_spec['image_model'],
+ 'status' => 'pending',
+ ),
+ array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s' )
+ );
+ }
+ }
+
+ /**
+ * Save single image recommendation to database.
+ *
+ * @param int $post_id Post ID.
+ * @param string $agent_image_id Unique image identifier.
+ * @param string $placement Placement location.
+ * @param string $section_title Section title.
+ * @param string $prompt Image prompt/description.
+ * @param string $alt_text Alt text for image.
+ * @return int|false Insert ID or false on failure.
+ */
+ public function save_image_recommendation( $post_id, $agent_image_id, $placement, $section_title, $prompt, $alt_text ) {
+ global $wpdb;
+ $table = $wpdb->prefix . 'wpaw_images';
+
+ $settings = get_option( 'wp_agentic_writer_settings', array() );
+ $image_model = $settings['image_model'] ?? 'openai/gpt-4o';
+
+ $result = $wpdb->insert(
+ $table,
+ array(
+ 'post_id' => $post_id,
+ 'agent_image_id' => $agent_image_id,
+ 'placement' => $placement,
+ 'section_title' => $section_title,
+ 'prompt_initial' => $prompt,
+ 'alt_text_initial' => $alt_text,
+ 'image_model' => $image_model,
+ 'status' => 'pending',
+ ),
+ array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s' )
+ );
+
+ if ( $result ) {
+ return $wpdb->insert_id;
+ }
+
+ return false;
+ }
+
+ /**
+ * Get image recommendations for a post.
+ *
+ * @param int $post_id Post ID.
+ * @return array Image recommendations.
+ */
+ public function get_image_recommendations( $post_id ) {
+ global $wpdb;
+ $table = $wpdb->prefix . 'wpaw_images';
+
+ $results = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT * FROM {$table} WHERE post_id = %d ORDER BY created_at ASC",
+ $post_id
+ ),
+ ARRAY_A
+ );
+
+ return $results;
+ }
+
+ /**
+ * Generate image variants.
+ *
+ * @param int $post_id Post ID.
+ * @param string $agent_image_id Agent image ID.
+ * @param string $prompt Image prompt.
+ * @param int $variant_count Number of variants to generate.
+ * @return array|WP_Error Generated variants or error.
+ */
+ public function generate_image_variants( $post_id, $agent_image_id, $prompt, $variant_count = 2 ) {
+ $settings = get_option( 'wp_agentic_writer_settings', array() );
+ $image_model = $settings['image_model'] ?? 'openai/gpt-4o';
+
+ $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'image' );
+
+ $variants = array();
+
+ for ( $i = 1; $i <= $variant_count; $i++ ) {
+ $result = $provider->generate_image(
+ $prompt,
+ $image_model,
+ array(
+ 'size' => '1024x576',
+ 'quality' => 'hd',
+ 'n' => 1,
+ )
+ );
+
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ // Download image to temp directory.
+ $temp_file = $this->download_temp_image( $post_id, $agent_image_id, $result['url'], $i );
+
+ if ( is_wp_error( $temp_file ) ) {
+ return $temp_file;
+ }
+
+ // Save variant to database.
+ $variant_id = $this->save_variant(
+ $post_id,
+ $agent_image_id,
+ $i,
+ $temp_file,
+ $prompt,
+ $image_model,
+ $result
+ );
+
+ $variants[] = array(
+ 'id' => $variant_id,
+ 'variant_number' => $i,
+ 'temp_file_url' => $temp_file['url'],
+ 'cost' => $result['cost'],
+ 'generation_time' => $result['generation_time'],
+ 'image_model_used' => $image_model,
+ );
+ }
+
+ return $variants;
+ }
+
+ /**
+ * Download image to temp directory.
+ *
+ * @param int $post_id Post ID.
+ * @param string $agent_image_id Agent image ID.
+ * @param string $image_url Image URL.
+ * @param int $variant_number Variant number.
+ * @return array|WP_Error File info or error.
+ */
+ private function download_temp_image( $post_id, $agent_image_id, $image_url, $variant_number ) {
+ $upload_dir = wp_upload_dir();
+ $temp_dir = $upload_dir['basedir'] . '/wpaw/' . $post_id;
+
+ if ( ! file_exists( $temp_dir ) ) {
+ wp_mkdir_p( $temp_dir );
+ }
+
+ // Download image.
+ $response = wp_remote_get( $image_url, array( 'timeout' => 30 ) );
+
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ }
+
+ $image_data = wp_remote_retrieve_body( $response );
+
+ // Determine file extension from content type.
+ $content_type = wp_remote_retrieve_header( $response, 'content-type' );
+ $extension = 'jpg';
+ if ( strpos( $content_type, 'png' ) !== false ) {
+ $extension = 'png';
+ }
+
+ $filename = sprintf(
+ '%s_variant_%d_%d.%s',
+ $agent_image_id,
+ $variant_number,
+ time(),
+ $extension
+ );
+
+ $file_path = $temp_dir . '/' . $filename;
+ file_put_contents( $file_path, $image_data );
+
+ $file_url = $upload_dir['baseurl'] . '/wpaw/' . $post_id . '/' . $filename;
+
+ return array(
+ 'path' => $file_path,
+ 'url' => $file_url,
+ 'size' => filesize( $file_path ),
+ );
+ }
+
+ /**
+ * Save variant to database.
+ *
+ * @param int $post_id Post ID.
+ * @param string $agent_image_id Agent image ID.
+ * @param int $variant_number Variant number.
+ * @param array $temp_file Temp file info.
+ * @param string $prompt Prompt used.
+ * @param string $image_model Image model used.
+ * @param array $generation_result Generation result.
+ * @return int Variant ID.
+ */
+ private function save_variant( $post_id, $agent_image_id, $variant_number, $temp_file, $prompt, $image_model, $generation_result ) {
+ global $wpdb;
+
+ // Get agentic_image_id from wp_wpaw_images.
+ $table_images = $wpdb->prefix . 'wpaw_images';
+ $agentic_image_id = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT id FROM {$table_images} WHERE post_id = %d AND agent_image_id = %s",
+ $post_id,
+ $agent_image_id
+ )
+ );
+
+ $table_variants = $wpdb->prefix . 'wpaw_images_variants';
+
+ $wpdb->insert(
+ $table_variants,
+ array(
+ 'agentic_image_id' => $agentic_image_id,
+ 'post_id' => $post_id,
+ 'agent_image_id' => $agent_image_id,
+ 'variant_number' => $variant_number,
+ 'temp_file_path' => $temp_file['path'],
+ 'temp_file_url' => $temp_file['url'],
+ 'file_size' => $temp_file['size'],
+ 'prompt_used' => $prompt,
+ 'image_model_used' => $image_model,
+ 'generation_time' => $generation_result['generation_time'],
+ 'cost' => $generation_result['cost'],
+ 'status' => 'temp',
+ ),
+ array( '%d', '%d', '%s', '%d', '%s', '%s', '%d', '%s', '%s', '%d', '%f', '%s' )
+ );
+
+ return $wpdb->insert_id;
+ }
+
+ /**
+ * Commit image variant to WordPress Media Library.
+ *
+ * @param int $post_id Post ID.
+ * @param string $agent_image_id Agent image ID.
+ * @param int $variant_id Variant ID.
+ * @param string $alt_text Alt text.
+ * @return array|WP_Error Attachment info or error.
+ */
+ public function commit_image_variant( $post_id, $agent_image_id, $variant_id, $alt_text ) {
+ global $wpdb;
+
+ // Get variant info.
+ $table_variants = $wpdb->prefix . 'wpaw_images_variants';
+ $variant = $wpdb->get_row(
+ $wpdb->prepare(
+ "SELECT * FROM {$table_variants} WHERE id = %d",
+ $variant_id
+ ),
+ ARRAY_A
+ );
+
+ if ( ! $variant ) {
+ return new WP_Error( 'variant_not_found', 'Variant not found' );
+ }
+
+ // Upload to Media Library.
+ require_once ABSPATH . 'wp-admin/includes/image.php';
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ require_once ABSPATH . 'wp-admin/includes/media.php';
+
+ $file_array = array(
+ 'name' => basename( $variant['temp_file_path'] ),
+ 'tmp_name' => $variant['temp_file_path'],
+ );
+
+ $attachment_id = media_handle_sideload( $file_array, $post_id );
+
+ if ( is_wp_error( $attachment_id ) ) {
+ return $attachment_id;
+ }
+
+ // Set alt text.
+ update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $alt_text ) );
+
+ // Update wp_wpaw_images table.
+ $table_images = $wpdb->prefix . 'wpaw_images';
+ $wpdb->update(
+ $table_images,
+ array(
+ 'attachment_id' => $attachment_id,
+ 'status' => 'committed',
+ ),
+ array(
+ 'post_id' => $post_id,
+ 'agent_image_id' => $agent_image_id,
+ ),
+ array( '%d', '%s' ),
+ array( '%d', '%s' )
+ );
+
+ // Mark variant as selected.
+ $wpdb->update(
+ $table_variants,
+ array(
+ 'is_selected' => 1,
+ 'selected_at' => current_time( 'mysql' ),
+ 'status' => 'selected',
+ ),
+ array( 'id' => $variant_id ),
+ array( '%d', '%s', '%s' ),
+ array( '%d' )
+ );
+
+ $attachment_url = wp_get_attachment_url( $attachment_id );
+
+ return array(
+ 'attachment_id' => $attachment_id,
+ 'attachment_url' => $attachment_url,
+ 'alt' => $alt_text,
+ );
+ }
+
+ /**
+ * Cleanup old temp images (7+ days old).
+ */
+ public function cleanup_old_temp_images() {
+ global $wpdb;
+
+ $table_variants = $wpdb->prefix . 'wpaw_images_variants';
+
+ // Get temp images older than 7 days.
+ $old_variants = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT * FROM {$table_variants}
+ WHERE status = 'temp'
+ AND created_at < DATE_SUB(NOW(), INTERVAL %d DAY)",
+ 7
+ ),
+ ARRAY_A
+ );
+
+ foreach ( $old_variants as $variant ) {
+ // Delete file.
+ if ( file_exists( $variant['temp_file_path'] ) ) {
+ unlink( $variant['temp_file_path'] );
+ }
+
+ // Update status.
+ $wpdb->update(
+ $table_variants,
+ array(
+ 'status' => 'auto_deleted',
+ 'deleted_at' => current_time( 'mysql' ),
+ ),
+ array( 'id' => $variant['id'] ),
+ array( '%s', '%s' ),
+ array( '%d' )
+ );
+ }
+ }
+}
diff --git a/includes/class-keyword-suggester.php b/includes/class-keyword-suggester.php
index 505c06f..203b767 100644
--- a/includes/class-keyword-suggester.php
+++ b/includes/class-keyword-suggester.php
@@ -30,13 +30,14 @@ class WP_Agentic_Writer_Keyword_Suggester {
* @return array|WP_Error Array with focus_keyword, secondary_keywords, reasoning, and cost.
*/
public static function suggest_keywords( $title, $sections, $language = 'english', $post_id = 0 ) {
- $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
+ $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' );
// Build outline text from sections
$outline_text = '';
if ( is_array( $sections ) ) {
foreach ( $sections as $section ) {
- $section_title = $section['title'] ?? '';
+ // Support both 'heading' (new format) and 'title' (legacy)
+ $section_title = $section['heading'] ?? $section['title'] ?? '';
if ( ! empty( $section_title ) ) {
$outline_text .= "- {$section_title}\n";
}
diff --git a/includes/class-local-backend-provider.php b/includes/class-local-backend-provider.php
new file mode 100644
index 0000000..85f1951
--- /dev/null
+++ b/includes/class-local-backend-provider.php
@@ -0,0 +1,416 @@
+base_url = $settings['local_backend_url'] ?? '';
+ $this->api_key = $settings['local_backend_key'] ?? 'dummy';
+ $this->model = $settings['local_backend_model'] ?? 'claude-local';
+ }
+
+ /**
+ * Non-streaming chat completion
+ *
+ * @param array $messages Array of message objects.
+ * @param array $options Optional parameters.
+ * @param string $type Task type.
+ * @return array|WP_Error Response with content, model, tokens, cost.
+ */
+ public function chat( $messages, $options = array(), $type = 'planning' ) {
+ if ( ! $this->is_configured() ) {
+ return new WP_Error(
+ 'not_configured',
+ __( 'Local Backend URL not configured.', 'wp-agentic-writer' )
+ );
+ }
+
+ $start_time = microtime( true );
+
+ $response = wp_remote_post(
+ $this->base_url . '/v1/messages',
+ array(
+ 'headers' => array(
+ 'Content-Type' => 'application/json',
+ 'Authorization' => 'Bearer ' . $this->api_key,
+ ),
+ 'body' => wp_json_encode(
+ array(
+ 'messages' => $messages,
+ )
+ ),
+ 'timeout' => 120, // Long timeout for local processing
+ 'sslverify' => false, // Local network
+ )
+ );
+
+ $generation_time = microtime( true ) - $start_time;
+
+ if ( is_wp_error( $response ) ) {
+ return new WP_Error(
+ 'connection_failed',
+ sprintf(
+ /* translators: %s: error message */
+ __( 'Local Backend connection failed: %s', 'wp-agentic-writer' ),
+ $response->get_error_message()
+ )
+ );
+ }
+
+ $code = wp_remote_retrieve_response_code( $response );
+ if ( 200 !== $code ) {
+ $body = wp_remote_retrieve_body( $response );
+ return new WP_Error(
+ 'api_error',
+ sprintf(
+ /* translators: %1$d: HTTP status code, %2$s: response body */
+ __( 'Local Backend error (%1$d): %2$s', 'wp-agentic-writer' ),
+ $code,
+ $body
+ )
+ );
+ }
+
+ $body = json_decode( wp_remote_retrieve_body( $response ), true );
+
+ if ( ! isset( $body['choices'][0]['message']['content'] ) ) {
+ return new WP_Error(
+ 'invalid_response',
+ __( 'Invalid response format from Local Backend', 'wp-agentic-writer' )
+ );
+ }
+
+ $content = $body['choices'][0]['message']['content'];
+
+ return array(
+ 'content' => $content,
+ 'model' => $this->model,
+ 'input_tokens' => 0, // Local backend doesn't track tokens
+ 'output_tokens' => 0,
+ 'total_tokens' => 0,
+ 'cost' => 0, // Free for local backend
+ 'generation_time' => $generation_time,
+ );
+ }
+
+ /**
+ * Streaming chat completion (not supported yet)
+ *
+ * @param array $messages Array of message objects.
+ * @param array $options Optional parameters.
+ * @param string $type Task type.
+ * @param callable $callback Function to call with each chunk.
+ * @return array|WP_Error Response or error.
+ */
+ public function chat_stream( $messages, $options = array(), $type = 'planning', $callback = null ) {
+ if ( ! $this->is_configured() ) {
+ return new WP_Error(
+ 'not_configured',
+ __( 'Local Backend URL not configured.', 'wp-agentic-writer' )
+ );
+ }
+
+ $body = array(
+ 'messages' => $messages,
+ 'stream' => true,
+ );
+
+ $accumulated_content = '';
+ $accumulated_usage = array();
+ $buffer = '';
+
+ $accumulating_callback = function( $chunk, $is_complete ) use ( &$accumulated_content, $callback ) {
+ if ( ! $is_complete && ! empty( $chunk ) ) {
+ $accumulated_content .= $chunk;
+ }
+
+ if ( $callback ) {
+ call_user_func( $callback, $chunk, $is_complete, $accumulated_content );
+ }
+ };
+
+ $ch = curl_init( $this->base_url . '/v1/messages' );
+
+ curl_setopt_array( $ch, array(
+ CURLOPT_POST => true,
+ CURLOPT_RETURNTRANSFER => false,
+ CURLOPT_SSL_VERIFYPEER => false,
+ CURLOPT_SSL_VERIFYHOST => false,
+ CURLOPT_WRITEFUNCTION => function( $curl, $data ) use ( &$buffer, $accumulating_callback, &$accumulated_usage ) {
+ $buffer .= $data;
+
+ while ( true ) {
+ $newline_pos = strpos( $buffer, "\n" );
+ if ( false === $newline_pos ) {
+ break;
+ }
+
+ $line = substr( $buffer, 0, $newline_pos );
+ $buffer = substr( $buffer, $newline_pos + 1 );
+
+ $line = trim( $line );
+ if ( empty( $line ) || ! str_starts_with( $line, 'data: ' ) ) {
+ continue;
+ }
+
+ $json_str = substr( $line, 6 );
+
+ if ( '[DONE]' === $json_str || '"[DONE]"' === $json_str ) {
+ call_user_func( $accumulating_callback, '', true );
+ return strlen( $data );
+ }
+
+ $chunk = json_decode( $json_str, true );
+ if ( isset( $chunk['choices'][0]['delta']['content'] ) && is_string( $chunk['choices'][0]['delta']['content'] ) ) {
+ $content = $chunk['choices'][0]['delta']['content'];
+ call_user_func( $accumulating_callback, $content, false );
+ }
+ // Also support Anthropic format if proxy uses it
+ if ( isset( $chunk['type'] ) && 'content_block_delta' === $chunk['type'] && isset( $chunk['delta']['text'] ) ) {
+ $content = $chunk['delta']['text'];
+ call_user_func( $accumulating_callback, $content, false );
+ }
+
+ if ( isset( $chunk['usage'] ) ) {
+ $accumulated_usage = $chunk['usage'];
+ }
+ }
+
+ return strlen( $data );
+ },
+ CURLOPT_HTTPHEADER => array(
+ 'Content-Type: application/json',
+ 'Authorization: Bearer ' . $this->api_key,
+ ),
+ CURLOPT_POSTFIELDS => wp_json_encode( $body ),
+ CURLOPT_TIMEOUT => 300,
+ ) );
+
+ $start_time = microtime( true );
+ $result = curl_exec( $ch );
+ $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
+ $curl_error = curl_error( $ch );
+ curl_close( $ch );
+
+ // Debug logging
+ error_log( 'WPAW Local Backend chat_stream: HTTP=' . $http_code . ', curl_result=' . ( $result ? 'true' : 'false' ) . ', curl_error=' . $curl_error . ', accumulated_content_len=' . strlen( $accumulated_content ) . ', buffer_len=' . strlen( $buffer ) );
+
+ if ( false === $result && ! empty( $curl_error ) ) {
+ return new WP_Error( 'curl_error', 'cURL error: ' . $curl_error );
+ }
+
+ if ( $http_code >= 400 ) {
+ return new WP_Error( 'api_error', sprintf( 'API error (%d): %s', $http_code, $buffer ) );
+ }
+
+ // FALLBACK: If no SSE chunks were parsed, the proxy likely returned a plain JSON response.
+ // Try to parse the leftover buffer as a standard OpenAI/Anthropic JSON response.
+ if ( empty( $accumulated_content ) && ! empty( $buffer ) ) {
+ error_log( 'WPAW Local Backend: No SSE chunks parsed. Attempting raw JSON fallback. Buffer preview: ' . substr( $buffer, 0, 500 ) );
+ $raw_json = json_decode( $buffer, true );
+ if ( is_array( $raw_json ) ) {
+ // OpenAI format: choices[0].message.content
+ if ( isset( $raw_json['choices'][0]['message']['content'] ) ) {
+ $accumulated_content = $raw_json['choices'][0]['message']['content'];
+ error_log( 'WPAW Local Backend: Extracted content via OpenAI format fallback (' . strlen( $accumulated_content ) . ' chars)' );
+ }
+ // Anthropic format: content[0].text
+ elseif ( isset( $raw_json['content'][0]['text'] ) ) {
+ $accumulated_content = $raw_json['content'][0]['text'];
+ error_log( 'WPAW Local Backend: Extracted content via Anthropic format fallback (' . strlen( $accumulated_content ) . ' chars)' );
+ }
+ // Simple format: content string
+ elseif ( isset( $raw_json['content'] ) && is_string( $raw_json['content'] ) ) {
+ $accumulated_content = $raw_json['content'];
+ error_log( 'WPAW Local Backend: Extracted content via simple format fallback (' . strlen( $accumulated_content ) . ' chars)' );
+ }
+
+ if ( ! empty( $accumulated_content ) && $callback ) {
+ // Emit the full content as a single chunk so the SSE handler picks it up
+ call_user_func( $callback, $accumulated_content, false, $accumulated_content );
+ call_user_func( $callback, '', true, $accumulated_content );
+ }
+
+ // Extract usage if available
+ if ( isset( $raw_json['usage'] ) ) {
+ $accumulated_usage = $raw_json['usage'];
+ }
+ } else {
+ error_log( 'WPAW Local Backend: Buffer is not valid JSON. First 300 chars: ' . substr( $buffer, 0, 300 ) );
+ }
+ }
+
+ return array(
+ 'content' => $accumulated_content,
+ 'model' => $this->model,
+ 'input_tokens' => $accumulated_usage['prompt_tokens'] ?? 0,
+ 'output_tokens' => $accumulated_usage['completion_tokens'] ?? 0,
+ 'total_tokens' => $accumulated_usage['total_tokens'] ?? 0,
+ 'cost' => 0,
+ 'generation_time' => microtime( true ) - $start_time,
+ );
+ }
+
+ /**
+ * Generate image (not supported by local backend)
+ *
+ * @param string $prompt Image prompt.
+ * @param string $model Model to use.
+ * @param array $options Optional parameters.
+ * @return WP_Error Error indicating not supported.
+ */
+ public function generate_image( $prompt, $model = null, $options = array() ) {
+ return new WP_Error(
+ 'not_supported',
+ __( 'Image generation not supported by Local Backend. Use OpenRouter for images.', 'wp-agentic-writer' )
+ );
+ }
+
+ /**
+ * Check if provider is configured
+ *
+ * @return bool True if base URL is set.
+ */
+ public function is_configured() {
+ return ! empty( $this->base_url );
+ }
+
+ /**
+ * Test connection to local backend
+ *
+ * @return array|WP_Error Success array or error.
+ */
+ public function test_connection() {
+ if ( ! $this->is_configured() ) {
+ return new WP_Error(
+ 'not_configured',
+ __( 'Local Backend URL not configured', 'wp-agentic-writer' )
+ );
+ }
+
+ // Test /ping endpoint
+ $ping_response = wp_remote_get(
+ $this->base_url . '/ping',
+ array(
+ 'timeout' => 5,
+ 'sslverify' => false,
+ )
+ );
+
+ if ( is_wp_error( $ping_response ) ) {
+ return new WP_Error(
+ 'ping_failed',
+ sprintf(
+ /* translators: %s: error message */
+ __( 'Cannot reach proxy: %s. Is it running?', 'wp-agentic-writer' ),
+ $ping_response->get_error_message()
+ )
+ );
+ }
+
+ $ping_body = wp_remote_retrieve_body( $ping_response );
+ if ( 'pong' !== $ping_body ) {
+ return new WP_Error(
+ 'invalid_ping',
+ __( 'Proxy responded but with unexpected format', 'wp-agentic-writer' )
+ );
+ }
+
+ // Test actual inference with simple prompt
+ $test_response = wp_remote_post(
+ $this->base_url . '/v1/messages',
+ array(
+ 'headers' => array(
+ 'Content-Type' => 'application/json',
+ ),
+ 'body' => wp_json_encode(
+ array(
+ 'messages' => array(
+ array(
+ 'role' => 'user',
+ 'content' => 'Reply with exactly: Connection test successful',
+ ),
+ ),
+ )
+ ),
+ 'timeout' => 30,
+ 'sslverify' => false,
+ )
+ );
+
+ if ( is_wp_error( $test_response ) ) {
+ return new WP_Error(
+ 'inference_failed',
+ sprintf(
+ /* translators: %s: error message */
+ __( 'Inference test failed: %s', 'wp-agentic-writer' ),
+ $test_response->get_error_message()
+ )
+ );
+ }
+
+ $test_body = json_decode( wp_remote_retrieve_body( $test_response ), true );
+ if ( ! isset( $test_body['choices'][0]['message']['content'] ) ) {
+ return new WP_Error(
+ 'invalid_response',
+ __( 'Claude CLI not responding correctly. Check proxy logs.', 'wp-agentic-writer' )
+ );
+ }
+
+ return array(
+ 'success' => true,
+ 'message' => __( 'Connected! Proxy responding correctly.', 'wp-agentic-writer' ),
+ 'sample_response' => $test_body['choices'][0]['message']['content'],
+ );
+ }
+
+ /**
+ * Check if provider supports task type
+ *
+ * @param string $type Task type.
+ * @return bool True if supported (all text tasks).
+ */
+ public function supports_task_type( $type ) {
+ // Local backend supports all text tasks, but not images
+ return in_array(
+ $type,
+ array( 'chat', 'clarity', 'planning', 'writing', 'refinement' ),
+ true
+ );
+ }
+}
diff --git a/includes/class-markdown-parser.php b/includes/class-markdown-parser.php
index dc038dc..e0ec2b9 100644
--- a/includes/class-markdown-parser.php
+++ b/includes/class-markdown-parser.php
@@ -23,9 +23,10 @@ class WP_Agentic_Writer_Markdown_Parser {
*
* @since 0.1.0
* @param string $markdown Markdown content.
+ * @param array $image_placeholders Optional. Array of image placeholder data with agent_image_id.
* @return array Array of Gutenberg blocks.
*/
- public static function parse( $markdown ) {
+ public static function parse( $markdown, $image_placeholders = array() ) {
$markdown = self::normalize_markdown( $markdown );
$blocks = array();
$lines = explode( "\n", $markdown );
@@ -39,6 +40,7 @@ class WP_Agentic_Writer_Markdown_Parser {
$in_auto_code_block = false;
$auto_code_lines = array();
$auto_code_language = 'text';
+ $image_index = 0;
$is_code_like_line = function( $trimmed ) {
if ( '' === $trimmed ) {
return false;
@@ -92,8 +94,15 @@ class WP_Agentic_Writer_Markdown_Parser {
$in_list = false;
}
- // Create image placeholder block.
- $blocks[] = self::create_image_placeholder_block( $matches[1] );
+ // Get agent_image_id from placeholders array if available
+ $agent_image_id = null;
+ if ( ! empty( $image_placeholders[ $image_index ] ) ) {
+ $agent_image_id = $image_placeholders[ $image_index ]['agent_image_id'] ?? null;
+ }
+
+ // Create image placeholder block with agent_image_id
+ $blocks[] = self::create_image_placeholder_block( $matches[1], $agent_image_id );
+ $image_index++;
continue;
}
@@ -258,6 +267,23 @@ class WP_Agentic_Writer_Markdown_Parser {
continue;
}
+ // Handle numbered items with bold title (treat as paragraph, not list).
+ if ( preg_match( '/^(\d+)\.\s+\*\*(.+?)\*\*\s*$/', $trimmed, $matches ) ) {
+ if ( ! empty( $current_paragraph ) ) {
+ $blocks[] = self::create_paragraph_block( $current_paragraph );
+ $current_paragraph = '';
+ }
+ if ( $in_list ) {
+ $blocks[] = self::create_list_block( $list_type, $list_items );
+ $list_items = array();
+ $in_list = false;
+ }
+ // Create paragraph with manual numbering and bold title.
+ $content = $matches[1] . '. ' . self::parse_inline_markdown( $matches[2] ) . '';
+ $blocks[] = self::create_paragraph_block( $content );
+ continue;
+ }
+
// Handle ordered lists.
if ( preg_match( '/^\d+\.\s+(.+)$/', $trimmed, $matches ) ) {
if ( ! empty( $current_paragraph ) ) {
@@ -639,20 +665,35 @@ class WP_Agentic_Writer_Markdown_Parser {
*
* @since 0.1.0
* @param string $description Image description/alt text.
+ * @param string $agent_image_id Optional. Agent-assigned image ID for tracking.
* @return array Gutenberg block.
*/
- private static function create_image_placeholder_block( $description ) {
+ private static function create_image_placeholder_block( $description, $agent_image_id = null ) {
$alt = trim( $description );
+
+ // Build className with agent image ID (WordPress preserves className reliably)
+ $class_name = '';
+ if ( ! empty( $agent_image_id ) ) {
+ $class_name = 'wpaw-agent-img-' . esc_attr( $agent_image_id );
+ }
+
$attrs = array(
'id' => 0,
'url' => '',
- 'alt' => $alt,
+ 'alt' => '[Image: ' . $alt . ']', // Mark as placeholder
'caption' => '',
'sizeSlug' => 'large',
'linkDestination' => 'none',
);
- $html = '![' . esc_attr( $alt ) . ']()
';
+ // Add className and data attribute if agent_image_id provided
+ if ( ! empty( $agent_image_id ) ) {
+ $attrs['className'] = $class_name;
+ $attrs['data-agent-image-id'] = $agent_image_id;
+ }
+
+ $figure_class = 'wp-block-image size-large' . ( $class_name ? ' ' . $class_name : '' );
+ $html = '![[Image: ' . esc_attr( $alt ) . ']]()
';
return array(
'blockName' => 'core/image',
diff --git a/includes/class-openrouter-provider.php b/includes/class-openrouter-provider.php
index 11853b7..613c2e0 100644
--- a/includes/class-openrouter-provider.php
+++ b/includes/class-openrouter-provider.php
@@ -17,7 +17,7 @@ if ( ! defined( 'ABSPATH' ) ) {
*
* @since 0.1.0
*/
-class WP_Agentic_Writer_OpenRouter_Provider {
+class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Provider_Interface {
/**
* API key.
@@ -598,51 +598,130 @@ class WP_Agentic_Writer_OpenRouter_Provider {
}
/**
- * Generate image.
+ * Generate image using OpenRouter image generation API.
*
* @since 0.1.0
* @param string $prompt Image prompt.
- * @return array|WP_Error Response array with image URL or WP_Error on failure.
+ * @param string $model Image model (optional, uses default if not provided).
+ * @param array $options Additional options (size, quality, n).
+ * @return array|WP_Error Response with image URL or error.
*/
- public function generate_image( $prompt ) {
- // Check API key.
+ public function generate_image( $prompt, $model = null, $options = array() ) {
if ( empty( $this->api_key ) ) {
- return new WP_Error(
- 'no_api_key',
- __( 'OpenRouter API key is not configured.', 'wp-agentic-writer' )
- );
+ return new WP_Error( 'no_api_key', 'OpenRouter API key not configured' );
}
- $messages = array(
+ $model = $model ?? $this->image_model;
+ $size = $options['size'] ?? '1024x576';
+ $quality = $options['quality'] ?? 'hd';
+ $n = $options['n'] ?? 1;
+
+ $start_time = microtime( true );
+
+ $response = wp_remote_post(
+ 'https://openrouter.ai/api/v1/images/generations',
array(
- 'role' => 'user',
- 'content' => sprintf(
- 'Generate an image based on this prompt: %s. Return only the image URL.',
- $prompt
+ 'headers' => array(
+ 'Authorization' => 'Bearer ' . $this->api_key,
+ 'Content-Type' => 'application/json',
+ 'HTTP-Referer' => home_url(),
+ 'X-Title' => get_bloginfo( 'name' ),
),
- ),
+ 'body' => wp_json_encode(
+ array(
+ 'model' => $model,
+ 'prompt' => $prompt,
+ 'n' => $n,
+ 'size' => $size,
+ 'quality' => $quality,
+ )
+ ),
+ 'timeout' => 60,
+ )
);
- $response = $this->chat( $messages, array( 'model' => $this->image_model ), 'image' );
+ $generation_time = microtime( true ) - $start_time;
if ( is_wp_error( $response ) ) {
return $response;
}
- // Extract image URL from response.
- $content = $response['content'];
- $url = '';
+ $body = json_decode( wp_remote_retrieve_body( $response ), true );
- // Try to extract URL from content.
- if ( preg_match( '/https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp)/i', $content, $matches ) ) {
- $url = $matches[0];
+ if ( ! isset( $body['data'][0]['url'] ) ) {
+ return new WP_Error(
+ 'image_generation_failed',
+ $body['error']['message'] ?? 'Unknown error'
+ );
}
return array(
- 'url' => $url,
- 'prompt' => $prompt,
- 'cost' => $response['cost'],
- 'model' => $response['model'],
+ 'url' => $body['data'][0]['url'],
+ 'cost' => $body['usage']['cost'] ?? 0.03,
+ 'generation_time' => $generation_time,
+ 'model' => $model,
+ 'prompt' => $prompt,
+ );
+ }
+
+ /**
+ * Check if provider is configured
+ *
+ * @return bool True if API key is set.
+ */
+ public function is_configured() {
+ return ! empty( $this->api_key );
+ }
+
+ /**
+ * Test connection to OpenRouter API
+ *
+ * @return array|WP_Error Success array or error.
+ */
+ public function test_connection() {
+ if ( ! $this->is_configured() ) {
+ return new WP_Error( 'not_configured', 'OpenRouter API key not configured' );
+ }
+
+ $response = wp_remote_get(
+ 'https://openrouter.ai/api/v1/models',
+ array(
+ 'headers' => array(
+ 'Authorization' => 'Bearer ' . $this->api_key,
+ ),
+ 'timeout' => 10,
+ )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ }
+
+ $code = wp_remote_retrieve_response_code( $response );
+ if ( 200 !== $code ) {
+ return new WP_Error(
+ 'connection_failed',
+ sprintf( 'OpenRouter API returned status %d', $code )
+ );
+ }
+
+ return array(
+ 'success' => true,
+ 'message' => 'Connected to OpenRouter successfully',
+ );
+ }
+
+ /**
+ * Check if provider supports task type
+ *
+ * @param string $type Task type.
+ * @return bool True (OpenRouter supports all task types).
+ */
+ public function supports_task_type( $type ) {
+ return in_array(
+ $type,
+ array( 'chat', 'clarity', 'planning', 'writing', 'refinement', 'image' ),
+ true
);
}
}
diff --git a/includes/class-provider-manager.php b/includes/class-provider-manager.php
new file mode 100644
index 0000000..3e46dcc
--- /dev/null
+++ b/includes/class-provider-manager.php
@@ -0,0 +1,152 @@
+is_configured() ) {
+ error_log( "Provider '{$provider_name}' not available for task '{$type}', using OpenRouter fallback" );
+ return WP_Agentic_Writer_OpenRouter_Provider::get_instance();
+ }
+
+ return $provider;
+ }
+
+ /**
+ * Get provider instance by name
+ *
+ * @param string $provider_name Provider identifier.
+ * @param string $task_type Task type for validation.
+ * @return WP_Agentic_Writer_AI_Provider_Interface|null Provider instance or null.
+ */
+ private static function get_provider_instance( $provider_name, $task_type ) {
+ switch ( $provider_name ) {
+ case 'local_backend':
+ if ( ! class_exists( 'WP_Agentic_Writer_Local_Backend_Provider' ) ) {
+ require_once plugin_dir_path( __FILE__ ) . 'class-local-backend-provider.php';
+ }
+ $provider = new WP_Agentic_Writer_Local_Backend_Provider();
+ break;
+
+ case 'codex':
+ if ( ! class_exists( 'WP_Agentic_Writer_Codex_Provider' ) ) {
+ require_once plugin_dir_path( __FILE__ ) . 'class-codex-provider.php';
+ }
+ $provider = new WP_Agentic_Writer_Codex_Provider();
+ break;
+
+ case 'openrouter':
+ default:
+ $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
+ break;
+ }
+
+ // Validate provider supports this task type
+ if ( $provider && ! $provider->supports_task_type( $task_type ) ) {
+ error_log( "Provider '{$provider_name}' does not support task type '{$task_type}'" );
+ return null;
+ }
+
+ return $provider;
+ }
+
+ /**
+ * Get all available providers with their status
+ *
+ * @return array Array of provider info with name, status, supported tasks.
+ */
+ public static function get_available_providers() {
+ $providers = array();
+
+ // OpenRouter (always available)
+ $openrouter = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
+ $providers['openrouter'] = array(
+ 'name' => 'OpenRouter',
+ 'configured' => $openrouter->is_configured(),
+ 'supports' => array( 'chat', 'clarity', 'planning', 'writing', 'refinement', 'image' ),
+ 'icon' => '☁️',
+ );
+
+ // Local Backend
+ if ( class_exists( 'WP_Agentic_Writer_Local_Backend_Provider' ) ) {
+ $local = new WP_Agentic_Writer_Local_Backend_Provider();
+ $providers['local_backend'] = array(
+ 'name' => 'Local Backend',
+ 'configured' => $local->is_configured(),
+ 'supports' => array( 'chat', 'clarity', 'planning', 'writing', 'refinement' ),
+ 'icon' => '🏠',
+ );
+ }
+
+ // Codex
+ if ( class_exists( 'WP_Agentic_Writer_Codex_Provider' ) ) {
+ $codex = new WP_Agentic_Writer_Codex_Provider();
+ $providers['codex'] = array(
+ 'name' => 'Codex (OpenAI)',
+ 'configured' => $codex->is_configured(),
+ 'supports' => array( 'chat', 'clarity', 'planning', 'writing', 'refinement' ),
+ 'icon' => '🔗',
+ );
+ }
+
+ return $providers;
+ }
+
+ /**
+ * Test all configured providers
+ *
+ * @return array Results of connection tests.
+ */
+ public static function test_all_providers() {
+ $results = array();
+ $providers = self::get_available_providers();
+
+ foreach ( $providers as $key => $info ) {
+ if ( ! $info['configured'] ) {
+ $results[ $key ] = array(
+ 'success' => false,
+ 'message' => 'Not configured',
+ );
+ continue;
+ }
+
+ $provider = self::get_provider_instance( $key, 'chat' );
+ if ( $provider ) {
+ $test_result = $provider->test_connection();
+ $results[ $key ] = is_wp_error( $test_result )
+ ? array(
+ 'success' => false,
+ 'message' => $test_result->get_error_message(),
+ )
+ : $test_result;
+ }
+ }
+
+ return $results;
+ }
+}
diff --git a/includes/class-seo-schema.php b/includes/class-seo-schema.php
new file mode 100644
index 0000000..4d92a96
--- /dev/null
+++ b/includes/class-seo-schema.php
@@ -0,0 +1,234 @@
+post_type, $allowed_types, true ) ) {
+ return;
+ }
+
+ $content = $post->post_content;
+ if ( empty( $content ) ) {
+ delete_post_meta( $post_id, '_wpaw_faq_schema' );
+ return;
+ }
+
+ // Strip Gutenberg block HTML comments to make regex matching easier.
+ $clean_content = preg_replace( '//s', '', $content );
+ $clean_content = preg_replace( '//s', '', $clean_content );
+
+ // Regex to find H2, H3, or H4 that contain a question mark, immediately followed by a paragraph.
+ // Matches: Question? Answer
+ $pattern = '/]*>(.*?\?)<\/h\1>[\s]*]*>(.*?)<\/p>/is';
+
+ $faqs = array();
+
+ if ( preg_match_all( $pattern, $clean_content, $matches, PREG_SET_ORDER ) ) {
+ foreach ( $matches as $match ) {
+ $question = wp_strip_all_tags( $match[2] );
+ $answer = wp_strip_all_tags( $match[3] );
+
+ // Basic validation: question must not be too short, answer must have some length.
+ if ( strlen( $question ) > 10 && strlen( $answer ) > 15 ) {
+ $faqs[] = array(
+ '@type' => 'Question',
+ 'name' => $question,
+ 'acceptedAnswer' => array(
+ '@type' => 'Answer',
+ 'text' => $answer,
+ ),
+ );
+ }
+ }
+ }
+
+ // Only save schema if we actually detected 1 or more valid FAQ pairs.
+ if ( ! empty( $faqs ) ) {
+ $schema = array(
+ '@context' => 'https://schema.org',
+ '@type' => 'FAQPage',
+ 'mainEntity' => $faqs,
+ );
+ update_post_meta( $post_id, '_wpaw_faq_schema', wp_json_encode( $schema, JSON_UNESCAPED_UNICODE ) );
+ } else {
+ delete_post_meta( $post_id, '_wpaw_faq_schema' );
+ }
+ }
+
+ /**
+ * Output the JSON-LD schema script in the frontend
.
+ * This serves as a fallback for sites without a primary SEO plugin.
+ */
+ public function output_faq_schema_in_head() {
+ if ( ! is_singular() ) {
+ return;
+ }
+
+ // Abort if feature is manually disabled in Settings
+ $settings = get_option( 'wp_agentic_writer_settings', array() );
+ if ( isset( $settings['enable_faq_schema'] ) && ! $settings['enable_faq_schema'] ) {
+ return;
+ }
+
+ // Prevent duplicate output if a major SEO plugin is active and handling our schema
+ if ( defined( 'WPSEO_VERSION' ) || class_exists( 'RankMath' ) ) {
+ return;
+ }
+
+ $post_id = get_the_ID();
+
+ // Attempt to fetch schema generated by WP Agentic Writer.
+ $schema_json = get_post_meta( $post_id, '_wpaw_faq_schema', true );
+
+ if ( ! empty( $schema_json ) ) {
+ echo "\n\n";
+ echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ echo "\n\n";
+ }
+ }
+
+ /**
+ * Inject the FAQ schema directly into Yoast SEO's JSON-LD graph.
+ * This prevents disjointed schema and consolidates everything into Yoast's payload.
+ *
+ * @param array $graph The Yoast schema graph array.
+ * @return array
+ */
+ public function inject_into_yoast_schema( $graph ) {
+ if ( ! is_singular() ) {
+ return $graph;
+ }
+
+ // Abort if feature is manually disabled in Settings
+ $settings = get_option( 'wp_agentic_writer_settings', array() );
+ if ( isset( $settings['enable_faq_schema'] ) && ! $settings['enable_faq_schema'] ) {
+ return $graph;
+ }
+
+ $post_id = get_the_ID();
+
+ // Fetch our pre-computed schema
+ $schema_json = get_post_meta( $post_id, '_wpaw_faq_schema', true );
+
+ if ( ! empty( $schema_json ) ) {
+ $schema_array = json_decode( $schema_json, true );
+ if ( is_array( $schema_array ) ) {
+ // Yoast specifically expects nodes with an @id property
+ $schema_array['@id'] = get_permalink( $post_id ) . '#wpaw-faq';
+
+ // Yoast wraps all schemas in a global @context, so we can unset our local one to remain clean
+ if ( isset( $schema_array['@context'] ) ) {
+ unset( $schema_array['@context'] );
+ }
+
+ // Append our FAQ schema snippet to Yoast's massive graph
+ $graph[] = $schema_array;
+ }
+ }
+
+ return $graph;
+ }
+
+ /**
+ * Inject the FAQ schema directly into RankMath's JSON-LD output.
+ * This prevents disjointed schema and consolidates everything into RankMath's payload.
+ *
+ * @param array $data The RankMath JSON-LD data array.
+ * @param object $jsonld The RankMath JsonLd object.
+ * @return array
+ */
+ public function inject_into_rankmath_schema( $data, $jsonld ) {
+ if ( ! is_singular() ) {
+ return $data;
+ }
+
+ // Abort if feature is manually disabled in Settings
+ $settings = get_option( 'wp_agentic_writer_settings', array() );
+ if ( isset( $settings['enable_faq_schema'] ) && ! $settings['enable_faq_schema'] ) {
+ return $data;
+ }
+
+ $post_id = get_the_ID();
+
+ // Fetch our pre-computed schema
+ $schema_json = get_post_meta( $post_id, '_wpaw_faq_schema', true );
+
+ if ( ! empty( $schema_json ) ) {
+ $schema_array = json_decode( $schema_json, true );
+ if ( is_array( $schema_array ) && ! empty( $schema_array['mainEntity'] ) ) {
+ // RankMath expects a keyed array for each schema type
+ $data['WPAWFaqPage'] = array(
+ '@type' => 'FAQPage',
+ 'mainEntity' => $schema_array['mainEntity'],
+ );
+ }
+ }
+
+ return $data;
+ }
+}
diff --git a/includes/class-settings-v2.php b/includes/class-settings-v2.php
index 0ef8d34..bd3b892 100644
--- a/includes/class-settings-v2.php
+++ b/includes/class-settings-v2.php
@@ -50,6 +50,7 @@ class WP_Agentic_Writer_Settings_V2 {
add_action( 'wp_ajax_wpaw_debug_models', array( $this, 'ajax_debug_models' ) );
add_action( 'wp_ajax_wpaw_save_custom_model', array( $this, 'ajax_save_custom_model' ) );
add_action( 'wp_ajax_wpaw_delete_custom_model', array( $this, 'ajax_delete_custom_model' ) );
+ add_action( 'wp_ajax_wpaw_test_local_backend', array( $this, 'ajax_test_local_backend' ) );
}
/**
@@ -79,9 +80,17 @@ class WP_Agentic_Writer_Settings_V2 {
wp_enqueue_style( 'wpaw-agentic-components', WP_AGENTIC_WRITER_URL . 'assets/css/agentic-components.css', array( 'wpaw-agentic-variables' ), WP_AGENTIC_WRITER_VERSION );
// Legacy plugin styles
- wp_enqueue_style( 'wp-agentic-writer-admin-v2', WP_AGENTIC_WRITER_URL . 'assets/css/admin-v2.css', array( 'bootstrap', 'select2-bootstrap-5' ), WP_AGENTIC_WRITER_VERSION );
- wp_enqueue_style( 'wp-agentic-writer-settings-v2', WP_AGENTIC_WRITER_URL . 'assets/css/settings-v2.css', array( 'wpaw-agentic-components' ), WP_AGENTIC_WRITER_VERSION );
- wp_enqueue_style( 'wp-agentic-writer-cost-log-grouped', WP_AGENTIC_WRITER_URL . 'assets/css/cost-log-grouped.css', array( 'wp-agentic-writer-settings-v2' ), WP_AGENTIC_WRITER_VERSION );
+ $css_admin_path = WP_AGENTIC_WRITER_DIR . 'assets/css/admin-v2.css';
+ $css_settings_path = WP_AGENTIC_WRITER_DIR . 'assets/css/settings-v2.css';
+ $css_log_path = WP_AGENTIC_WRITER_DIR . 'assets/css/cost-log-grouped.css';
+
+ $ver_admin = file_exists($css_admin_path) ? filemtime($css_admin_path) : WP_AGENTIC_WRITER_VERSION;
+ $ver_settings = file_exists($css_settings_path) ? filemtime($css_settings_path) : WP_AGENTIC_WRITER_VERSION;
+ $ver_log = file_exists($css_log_path) ? filemtime($css_log_path) : WP_AGENTIC_WRITER_VERSION;
+
+ wp_enqueue_style( 'wp-agentic-writer-admin-v2', WP_AGENTIC_WRITER_URL . 'assets/css/admin-v2.css', array( 'bootstrap', 'select2-bootstrap-5' ), $ver_admin );
+ wp_enqueue_style( 'wp-agentic-writer-settings-v2', WP_AGENTIC_WRITER_URL . 'assets/css/settings-v2.css', array( 'wpaw-agentic-components' ), $ver_settings );
+ wp_enqueue_style( 'wp-agentic-writer-cost-log-grouped', WP_AGENTIC_WRITER_URL . 'assets/css/cost-log-grouped.css', array( 'wp-agentic-writer-settings-v2' ), $ver_log );
// Plugin scripts
wp_enqueue_script( 'wp-agentic-writer-settings-v2', WP_AGENTIC_WRITER_URL . 'assets/js/settings-v2.js', array( 'jquery', 'bootstrap', 'select2' ), WP_AGENTIC_WRITER_VERSION, true );
@@ -968,8 +977,13 @@ class WP_Agentic_Writer_Settings_V2 {
public function sanitize_settings( $input ) {
$sanitized = array();
- // Sanitize API key
- $sanitized['openrouter_api_key'] = trim( $input['openrouter_api_key'] ?? '' );
+ // Sanitize API keys (allow empty values to clear them)
+ if ( isset( $input['openrouter_api_key'] ) ) {
+ $sanitized['openrouter_api_key'] = trim( $input['openrouter_api_key'] );
+ }
+ if ( isset( $input['brave_search_api_key'] ) ) {
+ $sanitized['brave_search_api_key'] = trim( $input['brave_search_api_key'] );
+ }
// Sanitize model names (6 models)
$sanitized['chat_model'] = sanitize_text_field( $input['chat_model'] ?? 'google/gemini-2.5-flash' );
@@ -988,6 +1002,7 @@ class WP_Agentic_Writer_Settings_V2 {
$sanitized['web_search_enabled'] = isset( $input['web_search_enabled'] ) && '1' === $input['web_search_enabled'];
$sanitized['cost_tracking_enabled'] = isset( $input['cost_tracking_enabled'] ) && '1' === $input['cost_tracking_enabled'];
$sanitized['enable_clarification_quiz'] = isset( $input['enable_clarification_quiz'] ) && '1' === $input['enable_clarification_quiz'];
+ $sanitized['enable_faq_schema'] = isset( $input['enable_faq_schema'] ) ? '1' === $input['enable_faq_schema'] : false;
// Sanitize search options
$sanitized['search_engine'] = in_array( $input['search_engine'] ?? '', array( 'auto', 'native', 'exa' ), true )
@@ -1030,6 +1045,38 @@ class WP_Agentic_Writer_Settings_V2 {
$sanitized['custom_languages'] = array();
}
+ // Sanitize Local Backend settings (Fix for settings wiping out)
+ if ( isset( $input['local_backend_url'] ) ) {
+ $sanitized['local_backend_url'] = esc_url_raw( trim( $input['local_backend_url'] ) );
+ }
+ if ( isset( $input['local_backend_key'] ) ) {
+ $sanitized['local_backend_key'] = sanitize_text_field( trim( $input['local_backend_key'] ) );
+ }
+ if ( isset( $input['local_backend_model'] ) ) {
+ $sanitized['local_backend_model'] = sanitize_text_field( trim( $input['local_backend_model'] ) );
+ }
+
+ // Sanitize Task Providers Routing
+ if ( isset( $input['task_providers'] ) && is_array( $input['task_providers'] ) ) {
+ $sanitized_providers = array();
+ $allowed_tasks = array( 'chat', 'clarity', 'planning', 'writing', 'refinement', 'image' );
+ $allowed_providers_text = array( 'openrouter', 'local_backend', 'codex' );
+
+ foreach ( $input['task_providers'] as $task => $provider ) {
+ $task = sanitize_text_field( $task );
+ $provider = sanitize_text_field( $provider );
+
+ if ( in_array( $task, $allowed_tasks, true ) ) {
+ if ( 'image' === $task && 'openrouter' === $provider ) {
+ $sanitized_providers[ $task ] = $provider;
+ } elseif ( 'image' !== $task && in_array( $provider, $allowed_providers_text, true ) ) {
+ $sanitized_providers[ $task ] = $provider;
+ }
+ }
+ }
+ $sanitized['task_providers'] = $sanitized_providers;
+ }
+
return $sanitized;
}
@@ -1058,6 +1105,7 @@ class WP_Agentic_Writer_Settings_V2 {
private function prepare_view_data( $settings ) {
// Extract settings (6 models)
$api_key = $settings['openrouter_api_key'] ?? '';
+ $brave_search_api_key = $settings['brave_search_api_key'] ?? '';
$chat_model = $settings['chat_model'] ?? 'google/gemini-2.5-flash';
$clarity_model = $settings['clarity_model'] ?? 'google/gemini-2.5-flash';
$planning_model = $settings['planning_model'] ?? 'google/gemini-2.5-flash';
@@ -1071,6 +1119,7 @@ class WP_Agentic_Writer_Settings_V2 {
$monthly_budget = $settings['monthly_budget'] ?? 600;
$chat_history_limit = $settings['chat_history_limit'] ?? 20;
$enable_clarification_quiz = $settings['enable_clarification_quiz'] ?? true;
+ $enable_faq_schema = $settings['enable_faq_schema'] ?? false;
$clarity_confidence_threshold = $settings['clarity_confidence_threshold'] ?? '0.6';
$required_context_categories = $settings['required_context_categories'] ?? array(
'target_outcome',
@@ -1085,6 +1134,12 @@ class WP_Agentic_Writer_Settings_V2 {
$custom_languages = $settings['custom_languages'] ?? array();
$custom_models = get_option( 'wp_agentic_writer_custom_models', array() );
+ // Local Backend settings
+ $local_backend_url = $settings['local_backend_url'] ?? '';
+ $local_backend_key = $settings['local_backend_key'] ?? 'dummy';
+ $local_backend_model = $settings['local_backend_model'] ?? 'claude-local';
+ $task_providers = $settings['task_providers'] ?? array();
+
// Get cost tracking data
$cost_tracker = WP_Agentic_Writer_Cost_Tracker::get_instance();
$monthly_used = $cost_tracker->get_monthly_total();
@@ -1093,6 +1148,7 @@ class WP_Agentic_Writer_Settings_V2 {
return compact(
'api_key',
+ 'brave_search_api_key',
'chat_model',
'clarity_model',
'planning_model',
@@ -1106,6 +1162,7 @@ class WP_Agentic_Writer_Settings_V2 {
'monthly_budget',
'chat_history_limit',
'enable_clarification_quiz',
+ 'enable_faq_schema',
'clarity_confidence_threshold',
'required_context_categories',
'preferred_languages',
@@ -1113,7 +1170,12 @@ class WP_Agentic_Writer_Settings_V2 {
'custom_models',
'monthly_used',
'budget_percent',
- 'budget_status'
+ 'budget_status',
+ 'local_backend_url',
+ 'local_backend_key',
+ 'local_backend_model',
+ 'task_providers',
+ 'settings'
);
}
@@ -1151,4 +1213,37 @@ class WP_Agentic_Writer_Settings_V2 {
'Swedish' => 'Swedish (Svenska)',
);
}
+
+ /**
+ * AJAX handler: Test local backend connection
+ *
+ * @since 0.2.0
+ */
+ public function ajax_test_local_backend() {
+ check_ajax_referer( 'wpaw_test_local_backend', 'nonce' );
+
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_send_json_error( array( 'message' => 'Insufficient permissions' ) );
+ }
+
+ $url = sanitize_text_field( wp_unslash( $_POST['url'] ?? '' ) );
+
+ if ( empty( $url ) ) {
+ wp_send_json_error( array( 'message' => 'URL required' ) );
+ }
+
+ // Temporarily create provider with this URL
+ $temp_settings = get_option( 'wp_agentic_writer_settings', array() );
+ $temp_settings['local_backend_url'] = $url;
+ update_option( 'wp_agentic_writer_settings', $temp_settings );
+
+ $provider = new WP_Agentic_Writer_Local_Backend_Provider();
+ $result = $provider->test_connection();
+
+ if ( is_wp_error( $result ) ) {
+ wp_send_json_error( array( 'message' => $result->get_error_message() ) );
+ }
+
+ wp_send_json_success( $result );
+ }
}
diff --git a/includes/class-settings.php b/includes/class-settings.php
index 381ac9f..f861375 100644
--- a/includes/class-settings.php
+++ b/includes/class-settings.php
@@ -318,8 +318,9 @@ class WP_Agentic_Writer_Settings {
public function sanitize_settings( $input ) {
$sanitized = array();
- // Sanitize API key (don't strip tags, but trim).
+ // Sanitize API keys (don't strip tags, but trim).
$sanitized['openrouter_api_key'] = trim( $input['openrouter_api_key'] ?? '' );
+ $sanitized['brave_search_api_key'] = trim( $input['brave_search_api_key'] ?? '' );
// Sanitize model names (6 models as per model-preset-brief.md).
$sanitized['chat_model'] = sanitize_text_field( $input['chat_model'] ?? 'google/gemini-2.5-flash' );
@@ -392,6 +393,7 @@ class WP_Agentic_Writer_Settings {
// Extract settings (6 models as per model-preset-brief.md).
$api_key = $settings['openrouter_api_key'] ?? '';
+ $brave_api_key = $settings['brave_search_api_key'] ?? '';
$chat_model = $settings['chat_model'] ?? 'google/gemini-2.5-flash';
$clarity_model = $settings['clarity_model'] ?? 'google/gemini-2.5-flash';
$planning_model = $settings['planning_model'] ?? 'google/gemini-2.5-flash';
@@ -1029,7 +1031,7 @@ class WP_Agentic_Writer_Settings {
planning: 'google/gemini-2.5-flash',
writing: 'mistralai/mistral-small-creative',
refinement: 'google/gemini-2.5-flash',
- image: 'openai/gpt-4o'
+ image: 'black-forest-labs/flux.2-klein'
},
balanced: {
chat: 'google/gemini-2.5-flash',
@@ -1037,7 +1039,7 @@ class WP_Agentic_Writer_Settings {
planning: 'google/gemini-2.5-flash',
writing: 'anthropic/claude-3.5-sonnet',
refinement: 'anthropic/claude-3.5-sonnet',
- image: 'openai/gpt-4o'
+ image: 'sourceful/riverflow-v2-max'
},
premium: {
chat: 'google/gemini-3-flash-preview',
@@ -1045,7 +1047,7 @@ class WP_Agentic_Writer_Settings {
planning: 'google/gemini-3-flash-preview',
writing: 'openai/gpt-4.1',
refinement: 'openai/gpt-4.1',
- image: 'openai/gpt-4o'
+ image: 'black-forest-labs/flux.2-max'
}
};
diff --git a/includes/interface-ai-provider.php b/includes/interface-ai-provider.php
new file mode 100644
index 0000000..8fa3e6d
--- /dev/null
+++ b/includes/interface-ai-provider.php
@@ -0,0 +1,67 @@
+ res.send('pong'));
+
+// Main inference endpoint
+app.post('/v1/messages', async (req, res) => {
+ const { messages } = req.body;
+ const prompt = messages[messages.length - 1].content;
+
+ console.log('Request from:', req.ip);
+ console.log('Prompt:', prompt.substring(0, 100) + '...');
+
+ const claude = spawn('claude', []);
+ let output = '';
+
+ claude.stdout.on('data', (data) => {
+ output += data.toString();
+ console.log('Claude response chunk:', data.toString().length, 'bytes');
+ });
+
+ claude.stderr.on('data', (data) => {
+ console.error('Claude stderr:', data.toString());
+ });
+
+ claude.on('close', (code) => {
+ console.log('Claude exit code:', code);
+ res.json({
+ id: 'local-' + Date.now(),
+ object: 'chat.completion',
+ created: Math.floor(Date.now() / 1000),
+ model: 'claude-local',
+ choices: [{
+ index: 0,
+ message: {
+ role: 'assistant',
+ content: output.trim() || 'No response from Claude'
+ },
+ finish_reason: 'stop'
+ }]
+ });
+ });
+
+ // Send prompt after brief pause (let Claude boot)
+ setTimeout(() => {
+ claude.stdin.write(prompt + '\n');
+ claude.stdin.end();
+ }, 500);
+});
+
+const PORT = process.env.PORT || 8080;
+app.listen(PORT, '0.0.0.0', () => {
+ console.log('═══════════════════════════════════════════════════');
+ console.log('🚀 Agentic Writer Local Backend Started!');
+ console.log('═══════════════════════════════════════════════════');
+ console.log(`Local: http://localhost:${PORT}`);
+ console.log(`Network: http://YOUR-IP:${PORT}`);
+ console.log('');
+ console.log('Plugin Configuration:');
+ console.log(` Base URL: http://YOUR-IP:${PORT}`);
+ console.log(` API Key: dummy`);
+ console.log(` Model: glm-4-7 (or your Claude model)`);
+ console.log('═══════════════════════════════════════════════════');
+});
+```
+
+**Key Features**:
+- Spawns user's local `claude` CLI for each request
+- Captures stdout/stderr for debugging
+- OpenAI-compatible `/v1/messages` endpoint
+- Health check `/ping` for connection testing
+- Logs all requests for transparency
+
+#### 3. Startup Script (`start-proxy.sh`)
+```bash
+#!/bin/bash
+
+echo "🚀 Starting Agentic Writer Local Backend..."
+echo ""
+
+# Check dependencies
+if ! command -v node &> /dev/null; then
+ echo "❌ Node.js not found. Install from https://nodejs.org/"
+ exit 1
+fi
+
+if ! command -v claude &> /dev/null; then
+ echo "❌ Claude CLI not found. Install and configure first."
+ echo " Check: https://claude.ai/code or https://z.ai"
+ exit 1
+fi
+
+# Auto-install express if needed
+if [ ! -d "node_modules" ]; then
+ echo "📦 Installing dependencies..."
+ npm install express
+fi
+
+# Detect local IP
+if [[ "$OSTYPE" == "darwin"* ]]; then
+ # macOS
+ LOCAL_IP=$(ifconfig en0 | grep "inet " | awk '{print $2}')
+elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
+ # Linux
+ LOCAL_IP=$(ip route get 1 | awk '{print $7;exit}')
+else
+ # Windows/other
+ LOCAL_IP="YOUR-IP"
+fi
+
+echo "✅ Dependencies OK"
+echo "✅ Claude CLI found"
+echo ""
+echo "Starting proxy server..."
+echo ""
+
+# Start server in background
+nohup node claude-proxy.js > proxy.log 2>&1 &
+PID=$!
+echo $PID > proxy.pid
+
+# Wait for server to boot
+sleep 2
+
+# Test if running
+if kill -0 $PID 2>/dev/null; then
+ echo "═══════════════════════════════════════════════════"
+ echo "✅ Local Backend Running!"
+ echo "═══════════════════════════════════════════════════"
+ echo ""
+ echo "Your Configuration:"
+ echo " Base URL: http://$LOCAL_IP:8080"
+ echo " API Key: dummy"
+ echo " Model: glm-4-7"
+ echo ""
+ echo "Next Steps:"
+ echo " 1. Open your WordPress Admin"
+ echo " 2. Go to Agentic Writer → Settings → Local Backend"
+ echo " 3. Paste the Base URL above"
+ echo " 4. Click 'Test Connection'"
+ echo ""
+ echo "Logs: tail -f proxy.log"
+ echo "Stop: ./stop-proxy.sh"
+ echo "═══════════════════════════════════════════════════"
+else
+ echo "❌ Failed to start. Check proxy.log for errors."
+ cat proxy.log
+ exit 1
+fi
+```
+
+#### 4. Stop Script (`stop-proxy.sh`)
+```bash
+#!/bin/bash
+
+if [ -f proxy.pid ]; then
+ PID=$(cat proxy.pid)
+ if kill -0 $PID 2>/dev/null; then
+ kill $PID
+ rm proxy.pid
+ echo "🛑 Local Backend stopped (PID: $PID)"
+ else
+ echo "⚠️ No process found with PID: $PID"
+ rm proxy.pid
+ fi
+else
+ # Fallback: kill by process name
+ pkill -f claude-proxy.js
+ echo "🛑 Stopped all claude-proxy processes"
+fi
+```
+
+#### 5. Connection Test Script (`test-connection.sh`)
+```bash
+#!/bin/bash
+
+echo "Testing local backend connection..."
+
+RESPONSE=$(curl -s -X POST http://localhost:8080/v1/messages \
+ -H "Content-Type: application/json" \
+ -d '{"messages":[{"role":"user","content":"Reply with: Test successful"}]}')
+
+if echo "$RESPONSE" | grep -q "Test successful"; then
+ echo "✅ Local Backend working correctly!"
+ echo "Response: $RESPONSE"
+else
+ echo "❌ Test failed. Response:"
+ echo "$RESPONSE"
+ echo ""
+ echo "Troubleshooting:"
+ echo " 1. Check proxy is running: ps aux | grep claude-proxy"
+ echo " 2. Check logs: tail -f proxy.log"
+ echo " 3. Verify Claude CLI: claude --version"
+fi
+```
+
+---
+
+## Plugin Integration (WordPress Side)
+
+### 1. Settings Page - New "Local Backend" Tab
+
+**Location**: WP Admin → Agentic Writer → Settings → Local Backend
+
+**UI Elements**:
+
+```php
+// Add to plugin settings page
+function render_local_backend_settings() {
+ ?>
+
+
+
+
+
+ 📦 Step 1: Download Local Backend Package
+ Run AI inference on your own machine with your Claude CLI + Z.ai account.
+
+
+ Download Local Backend (v1.0.0)
+
+
+
+ Prerequisites
+
+ - ✅ Claude CLI installed (Get Claude Code or Z.ai)
+ - ✅ Node.js 18+ (Download)
+ - ✅ Z.ai Coding Plan or Anthropic API key configured in Claude CLI
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Enter the URL from your Local Backend startup message (e.g., http://YOUR-IP:8080)
+
+
+
+
+
+
+
+
+
+
+ Use "dummy" for local backend (ignored by proxy)
+
+
+
+
+
+
+
+
+
+
+ Model identifier (informational only, proxy uses your Claude CLI default)
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🛠️ Troubleshooting
+
+ - Connection failed? Ensure proxy is running:
./start-proxy.sh
+ - Wrong IP? Run
./get-local-ip.sh to find correct address
+ - Firewall blocking? Allow Node.js on port 8080 (System Preferences → Network → Firewall)
+ - Claude not found? Check Claude CLI:
which claude or claude --version
+
+
+
+
+
+
+ 5,
+ 'sslverify' => false // Local network
+ ]);
+
+ if (is_wp_error($response)) {
+ wp_send_json_error([
+ 'message' => 'Cannot reach proxy: ' . $response->get_error_message()
+ ]);
+ }
+
+ $body = wp_remote_retrieve_body($response);
+
+ if ($body === 'pong') {
+ // Test actual inference
+ $test_response = wp_remote_post($url . '/v1/messages', [
+ 'headers' => ['Content-Type' => 'application/json'],
+ 'body' => json_encode([
+ 'messages' => [
+ ['role' => 'user', 'content' => 'Reply with: Connection test successful']
+ ]
+ ]),
+ 'timeout' => 30
+ ]);
+
+ if (!is_wp_error($test_response)) {
+ $result = json_decode(wp_remote_retrieve_body($test_response), true);
+ if (isset($result['choices'][0]['message']['content'])) {
+ wp_send_json_success([
+ 'message' => 'Proxy responding correctly',
+ 'sample' => $result['choices'][0]['message']['content']
+ ]);
+ }
+ }
+ }
+
+ wp_send_json_error(['message' => 'Proxy not responding correctly']);
+}
+```
+
+### 3. Provider Integration
+
+**Add to existing provider system**:
+
+```php
+// In your provider factory/registry
+class LocalBackendProvider extends BaseProvider {
+
+ public function generate_content($prompt, $options = []) {
+ $url = get_option('agentic_writer_local_backend_url');
+ $api_key = get_option('agentic_writer_local_backend_key', 'dummy');
+
+ if (empty($url)) {
+ return new WP_Error('no_url', 'Local Backend URL not configured');
+ }
+
+ $response = wp_remote_post($url . '/v1/messages', [
+ 'headers' => [
+ 'Content-Type' => 'application/json',
+ 'Authorization' => 'Bearer ' . $api_key
+ ],
+ 'body' => json_encode([
+ 'model' => get_option('agentic_writer_local_backend_model', 'glm-4-7'),
+ 'messages' => [
+ ['role' => 'user', 'content' => $prompt]
+ ]
+ ]),
+ 'timeout' => 120 // Long timeout for content generation
+ ]);
+
+ if (is_wp_error($response)) {
+ return $response;
+ }
+
+ $body = json_decode(wp_remote_retrieve_body($response), true);
+
+ if (isset($body['choices'][0]['message']['content'])) {
+ return $body['choices'][0]['message']['content'];
+ }
+
+ return new WP_Error('invalid_response', 'Invalid response from local backend');
+ }
+
+ public function is_configured() {
+ return !empty(get_option('agentic_writer_local_backend_url'));
+ }
+}
+
+// Register provider
+add_filter('agentic_writer_providers', function($providers) {
+ $providers['local_backend'] = [
+ 'name' => 'Local Backend (Your Machine)',
+ 'class' => 'LocalBackendProvider',
+ 'icon' => '🖥️',
+ 'description' => 'Use your own Claude CLI + Z.ai for unlimited private generation'
+ ];
+ return $providers;
+});
+```
+
+---
+
+## User Documentation
+
+### README.md (Included in ZIP)
+
+```markdown
+# Agentic Writer Local Backend
+
+Run unlimited AI content generation on your own machine using your Claude CLI.
+
+## Prerequisites
+
+- ✅ Claude CLI installed and configured
+ - Get it: https://claude.ai/code or https://z.ai
+- ✅ Node.js 18+ installed
+ - Download: https://nodejs.org
+- ✅ Z.ai Coding Plan, OpenRouter, or Anthropic API key (configured in Claude CLI)
+
+## Quick Start
+
+### 1. Extract this package
+```bash
+unzip agentic-writer-local-backend.zip
+cd agentic-writer-local-backend
+```
+
+### 2. Start the proxy
+```bash
+chmod +x start-proxy.sh
+./start-proxy.sh
+```
+
+You'll see:
+```
+═══════════════════════════════════════════════════
+✅ Local Backend Running!
+═══════════════════════════════════════════════════
+
+Your Configuration:
+ Base URL: http://192.168.1.105:8080
+ API Key: dummy
+ Model: glm-4-7
+```
+
+### 3. Configure WordPress plugin
+
+1. Open WP Admin → Agentic Writer → Settings → **Local Backend**
+2. Paste the **Base URL** shown above
+3. API Key: `dummy`
+4. Click **Test Connection** → should show ✅
+5. Start generating content!
+
+## Commands
+
+```bash
+./start-proxy.sh # Start proxy (runs in background)
+./stop-proxy.sh # Stop proxy
+./test-connection.sh # Test if proxy responds
+tail -f proxy.log # View real-time logs
+```
+
+## Firewall Setup
+
+### macOS
+System Settings → Network → Firewall → Options → Add `node` → Allow incoming
+
+### Linux (ufw)
+```bash
+sudo ufw allow 8080/tcp
+```
+
+### Windows
+Windows Defender Firewall → Advanced Settings → Inbound Rules → New Rule → Port 8080
+
+## Troubleshooting
+
+### "Connection failed" in plugin
+- ✅ Check proxy is running: `ps aux | grep claude-proxy`
+- ✅ Test manually: `./test-connection.sh`
+- ✅ Check IP is correct: `./get-local-ip.sh`
+
+### "Claude CLI not found"
+```bash
+# Verify Claude is installed
+which claude
+claude --version
+
+# If not found, check installation:
+# - macOS: /opt/homebrew/bin/claude
+# - Linux: ~/.local/bin/claude
+```
+
+### "No response from Claude"
+- ✅ Check Claude CLI works: `echo "Hello" | claude`
+- ✅ Verify Z.ai/API key is configured: `claude --help` (shows auth status)
+- ✅ Check logs: `tail -f proxy.log`
+
+### Port 8080 already in use
+```bash
+# Find what's using port
+lsof -i :8080
+
+# Change port (edit claude-proxy.js)
+PORT=9000 node claude-proxy.js
+# Update plugin Base URL: http://your-ip:9000
+```
+
+## Security Notes
+
+- Proxy binds to `0.0.0.0` (all interfaces) for LAN access
+- No authentication by design (LAN trust model)
+- For internet exposure, use ngrok/reverse proxy with auth
+- Logs contain request prompts (for debugging)
+
+## Support
+
+- Full docs: https://docs.your-site.com/local-backend
+- Issues: https://github.com/your/plugin/issues
+- Discord: https://discord.gg/your-server
+```
+
+---
+
+## Benefits & Use Cases
+
+### Key Benefits
+
+1. **Zero API Costs**: Use prepaid Z.ai Coding Plan or existing Anthropic sub
+2. **Unlimited Generation**: No rate limits, no token counting
+3. **Privacy**: Content never leaves your network
+4. **Speed**: LAN latency << internet API calls
+5. **Offline Ready**: Works without internet (if local WP)
+6. **Model Flexibility**: Switch Claude models via CLI config
+
+### Target Use Cases
+
+| Use Case | Why Local Backend |
+|----------|------------------|
+| **High-volume content creation** | Avoid per-token costs, no rate limits |
+| **Sensitive/NDA content** | Never hits external APIs |
+| **Development/Testing** | Iterate rapidly without API spend |
+| **Agency workflows** | One Z.ai account → unlimited client sites |
+| **Offline scenarios** | Local WP + local AI = fully offline |
+
+---
+
+## Technical Considerations
+
+### Performance
+
+- **Latency**: ~50-200ms LAN vs ~500-2000ms internet API
+- **Throughput**: Limited by Claude CLI speed (~20-30 tokens/sec on M1)
+- **Concurrency**: One request at a time (spawn per request)
+- **Scalability**: Single-user/dev-team, not multi-tenant SaaS
+
+### Security
+
+- **Network**: Runs on LAN, accessible to any device on network
+- **Authentication**: None (trust LAN devices)
+- **Logging**: All prompts logged to `proxy.log` (GDPR consideration)
+- **Recommendation**: Firewall to specific IPs if multi-user network
+
+### Limitations
+
+- Requires Node.js on user machine (technical barrier)
+- User must maintain Claude CLI (updates, auth refresh)
+- No built-in retry/failover (single point of failure)
+- Not suitable for public/shared hosting (security)
+
+---
+
+## Implementation Checklist
+
+### Phase 1: Core Proxy (Week 1)
+- [ ] Create `claude-proxy.js` with `/v1/messages` endpoint
+- [ ] Add `/ping` health check
+- [ ] Create `start-proxy.sh` with IP detection
+- [ ] Create `stop-proxy.sh`
+- [ ] Create `test-connection.sh`
+- [ ] Write `README.md` with setup guide
+- [ ] Package as `agentic-writer-local-backend.zip`
+
+### Phase 2: Plugin Integration (Week 1)
+- [ ] Add "Local Backend" settings tab
+- [ ] Implement ZIP download from plugin settings
+- [ ] Add Base URL / API Key / Model inputs
+- [ ] Create AJAX "Test Connection" handler
+- [ ] Add `LocalBackendProvider` class
+- [ ] Register provider in provider factory
+- [ ] Update main generation flow to support local backend
+
+### Phase 3: Documentation (Week 2)
+- [ ] Write full setup guide (with screenshots)
+- [ ] Create video tutorial (5-min screencast)
+- [ ] Add troubleshooting section to docs
+- [ ] Create FAQ page
+- [ ] Add to plugin welcome wizard
+
+### Phase 4: Polish (Week 2)
+- [ ] Auto-detect if proxy running (plugin UI indicator)
+- [ ] Add "Start Proxy" button (launch via system command)
+- [ ] Connection status widget (green/red indicator)
+- [ ] Proxy version check (ensure compatibility)
+- [ ] Error message improvements (actionable guidance)
+
+### Phase 5: Advanced (Future)
+- [ ] Multi-model support (detect available Claude models)
+- [ ] Request queue (handle concurrent generation)
+- [ ] WebSocket streaming (real-time output)
+- [ ] Docker image option (one-click deployment)
+- [ ] Windows .exe wrapper (no Node.js install needed)
+
+---
+
+## Success Metrics
+
+### User Adoption
+- **Target**: 15% of active users enable Local Backend within 3 months
+- **Measure**: Track `local_backend_url` option set count
+
+### Performance
+- **Target**: <100ms LAN latency for content generation
+- **Measure**: Log round-trip time in plugin
+
+### Support
+- **Target**: <5% support ticket rate for Local Backend setup
+- **Measure**: Track "Local Backend" tagged tickets
+
+### Cost Savings
+- **Target**: 30% reduction in external API spend for heavy users
+- **Measure**: Compare pre/post API usage in analytics
+
+---
+
+## Marketing Angle
+
+### Positioning
+**"Unlimited Private AI Content Generation"**
+
+Run WP Agentic Writer with your own Claude CLI + Z.ai account. Zero per-token costs. Complete privacy. Unlimited throughput.
+
+### Key Messages
+- 🚀 **Unlimited**: No rate limits, no token counting, generate 24/7
+- 🔒 **Private**: Your content never leaves your network
+- 💰 **Free**: Use existing Z.ai/Anthropic subscription, no extra API costs
+- ⚡ **Fast**: LAN speed beats internet API latency
+- 🛠️ **Developer-Friendly**: Full control, local logs, easy debugging
+
+### Competitive Advantage
+No other WordPress AI plugin offers seamless local LLM integration with enterprise-grade models (Claude via Z.ai). Competitors force cloud API usage = ongoing costs + privacy concerns.
+
+---
+
+## Risk Assessment
+
+| Risk | Impact | Mitigation |
+|------|--------|------------|
+| **Technical barrier** (Node.js install) | Medium | Provide video tutorial, one-click installers (future) |
+| **Support burden** (networking issues) | Medium | Comprehensive troubleshooting docs, community Discord |
+| **Security misconfiguration** | Low | Clear warnings in docs, bind to 127.0.0.1 by default option |
+| **Claude CLI breaking changes** | Low | Version pinning, update notifications |
+| **User expects 100% uptime** | Low | Docs clearly state "dev/team use, not production SaaS" |
+
+---
+
+## Future Enhancements
+
+### V2 Features
+1. **Auto-start on boot** (launchd/systemd/Task Scheduler)
+2. **Multi-user support** (API key auth per WP site)
+3. **Load balancing** (multiple Claude CLI instances)
+4. **Model switching** (UI to select Claude Opus/Sonnet/Haiku)
+5. **Monitoring dashboard** (requests/sec, uptime, error rate)
+
+### V3 Features
+1. **Docker image** (one-command deployment)
+2. **GUI app** (macOS/Windows tray icon + config UI)
+3. **Cloud sync** (fallback to OpenRouter if local offline)
+4. **Team mode** (shared proxy for agency/team)
+5. **Plugin marketplace** (middleware for image gen, RAG, etc.)
+
+---
+
+## Conclusion
+
+**Local Backend Mode** positions WP Agentic Writer as the only WordPress AI plugin that gives users complete control over their inference stack. By leveraging users' existing Claude CLI + Z.ai setups, we unlock unlimited content generation without sacrificing quality or incurring per-token costs.
+
+**Differentiator**: Privacy + Cost + Control in one feature.
+**Target**: Developer-savvy users, agencies, high-volume creators.
+**Effort**: 2 weeks MVP, ongoing maintenance minimal.
+
+**Go/No-Go Decision**: ✅ GO
+- Low implementation cost (10 lines Node.js + settings UI)
+- High perceived value (unlimited AI = killer feature)
+- Strong market differentiation (no competitor offers this)
+- Aligns with dev-first positioning of plugin
+
+---
+
+## Appendix
+
+### File Manifest (Deliverables)
+
+```
+WordPress Plugin Files:
+├── includes/providers/class-local-backend-provider.php
+├── admin/views/settings-local-backend.php
+├── admin/js/test-local-backend.js
+└── downloads/agentic-writer-local-backend.zip
+ ├── claude-proxy.js
+ ├── start-proxy.sh
+ ├── stop-proxy.sh
+ ├── test-connection.sh
+ ├── get-local-ip.sh
+ ├── package.json
+ ├── README.md
+ ├── TROUBLESHOOTING.md
+ └── examples/
+ └── plugin-config-screenshot.png
+```
+
+### Sample Error Messages
+
+```php
+// Connection errors with actionable guidance
+$errors = [
+ 'timeout' => 'Connection timeout. Is the proxy running? Check with: ps aux | grep claude-proxy',
+ 'refused' => 'Connection refused. Ensure proxy started successfully: ./start-proxy.sh',
+ 'wrong_ip' => 'Cannot reach this IP. Run ./get-local-ip.sh to find correct address.',
+ 'no_claude' => 'Claude CLI not responding. Test manually: echo "test" | claude',
+ 'invalid_response' => 'Proxy returned invalid data. Check logs: tail -f proxy.log'
+];
+```
+
+### Version History
+
+- **v1.0.0** (Initial): Core proxy + plugin integration
+- **v1.1.0** (Planned): Auto-start, connection monitoring
+- **v2.0.0** (Future): Docker image, GUI app, team mode
+
+---
+
+**Document Version**: 1.0
+**Last Updated**: 2026-02-27
+**Author**: Implementation Brief for WP Agentic Writer
+**Status**: Ready for Development
diff --git a/views/settings-backup/layout.php b/views/settings-backup/layout.php
new file mode 100644
index 0000000..693cc2f
--- /dev/null
+++ b/views/settings-backup/layout.php
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+
+
+
+
+
+ v ·
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ✨
+
+
+
+
+
+
diff --git a/views/settings-backup/tab-cost-log.php b/views/settings-backup/tab-cost-log.php
new file mode 100644
index 0000000..528bdda
--- /dev/null
+++ b/views/settings-backup/tab-cost-log.php
@@ -0,0 +1,171 @@
+
+
+
+
+
+
+
+
+ 📊
+
+
+
+
+
+
+
+
+
+
+ $0.0000
+
+
+
+
+
+ $0.0000
+
+
+
+
+
+ $0.0000
+
+
+
+
+
+ $0.0000
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🔍
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 📋
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/views/settings-backup/tab-general.php b/views/settings-backup/tab-general.php
new file mode 100644
index 0000000..6ddd002
--- /dev/null
+++ b/views/settings-backup/tab-general.php
@@ -0,0 +1,315 @@
+get_available_languages();
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ OpenRouter ', 'wp-agentic-writer' ) ), 'https://openrouter.ai/keys' ); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $
+
+
+
+
+
+
+
+ %
+
+
+
+
+
+ $ / $
+
+
+
+
+
+ $
+
+
+
+
+
+
+
+
+
+
+ $
+
+
+
+
+
+
+
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ />
+
+
+
+
+
+
+
+
+
+
+
+ __( 'Target Outcome', 'wp-agentic-writer' ),
+ 'target_audience' => __( 'Target Audience', 'wp-agentic-writer' ),
+ 'tone' => __( 'Tone of Voice', 'wp-agentic-writer' ),
+ 'content_depth' => __( 'Content Depth', 'wp-agentic-writer' ),
+ 'expertise_level' => __( 'Expertise Level', 'wp-agentic-writer' ),
+ 'content_type' => __( 'Content Type', 'wp-agentic-writer' ),
+ 'pov' => __( 'Point of View', 'wp-agentic-writer' ),
+ );
+ foreach ( $categories as $value => $label ) :
+ ?>
+
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $label ) : ?>
+
+
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/views/settings-backup/tab-guide.php b/views/settings-backup/tab-guide.php
new file mode 100644
index 0000000..8fd543b
--- /dev/null
+++ b/views/settings-backup/tab-guide.php
@@ -0,0 +1,229 @@
+
+
+
+
+
+
+
+
+ 📖
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ~$0.001
+
+
+
+
+
+ ~$0.0005
+
+
+
+
+
+ ~$0.05-0.15
+
+
+
+
+
+ ~$0.001
+
+
+
+
+
+ ~$0.01-0.03
+
+
+
+
+
+ ~$0.02
+
+
+
+
+
+ $0.003-0.04
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ⭐
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Gemini 2.5 Flash
+
+ $0.15 / $0.60
+ ⭐
+
+
+ Gemini 3 Flash Preview
+
+ $0.50 / $3.00
+ 🏆
+
+
+ Claude 3.5 Sonnet
+
+ $3.00 / $15.00
+ ⭐
+
+
+ Mistral Small Creative
+
+ $0.10 / $0.30
+ 💰
+
+
+ GPT-4.1
+
+ $2.00 / $8.00
+ 🏆
+
+
+ Claude Sonnet 4
+
+ $3.00 / $15.00
+ 🏆
+
+
+ GPT-4o
+
+ $2.50 / $10.00
+ ⭐
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 💡
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - : ~$0.001
+ - : ~$0.001
+ - : ~$0.10
+ - : ~$0.003
+
+
+
+ ~$0.10-0.15
+
+
+
+
+
+
+
+
+
+
+
+ - : ~$0.05
+ - : ~$0.20
+
+
+
+ ~$1.50-2.00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ~$0.02-0.04
+
+
+
+
+
+
+
+
+
diff --git a/views/settings-backup/tab-local-backend.php b/views/settings-backup/tab-local-backend.php
new file mode 100644
index 0000000..7579a26
--- /dev/null
+++ b/views/settings-backup/tab-local-backend.php
@@ -0,0 +1,307 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ __( 'Chat & Discussion', 'wp-agentic-writer' ),
+ 'clarity' => __( 'Clarity Check & Quiz', 'wp-agentic-writer' ),
+ 'planning' => __( 'Outline Planning', 'wp-agentic-writer' ),
+ 'writing' => __( 'Article Writing', 'wp-agentic-writer' ),
+ 'refinement' => __( 'Content Refinement', 'wp-agentic-writer' ),
+ 'image' => __( 'Image Generation', 'wp-agentic-writer' ),
+ );
+
+ $task_providers = $settings['task_providers'] ?? array();
+ ?>
+
+
+
+
+
+
+
+
+
+ $task_label ) : ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
./start-proxy.sh
+
+ -
+
+
+
./get-local-ip.sh
+
+
+ -
+
+
+
+ -
+
+
+
which claude
+
+ claude --version
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/views/settings-backup/tab-models.php b/views/settings-backup/tab-models.php
new file mode 100644
index 0000000..6dae961
--- /dev/null
+++ b/views/settings-backup/tab-models.php
@@ -0,0 +1,267 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 💰 ~$0.06/article
+
+
+ Chat/Clarity/Planning/Refinement: Gemini 2.5 Flash
+ Writing: Mistral Small Creative
+ Image: FLUX.2 klein
+
+
+
+
+
+
+
+
+
+
+ ⭐ ~$0.14/article
+
+
+ Chat/Clarity/Planning: Gemini 2.5 Flash
+ Writing/Refinement: Claude 3.5 Sonnet
+ Image: Riverflow V2 Max
+
+
+
+
+
+
+
+
+
+
+ ✨ ~$0.31/article
+
+
+ Chat/Planning: Gemini 3 Flash Preview
+ Clarity: Claude Sonnet 4
+ Writing/Refinement: GPT-4.1
+ Image: FLUX.2 max
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ~$0.00
+
+
+
+
+
+
+
+
diff --git a/views/settings/layout.php b/views/settings/layout.php
index 1e79e69..95bcd6c 100644
--- a/views/settings/layout.php
+++ b/views/settings/layout.php
@@ -14,49 +14,56 @@ if ( ! defined( 'ABSPATH' ) ) {
extract( $view_data );
?>
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
diff --git a/views/settings/tab-general.php b/views/settings/tab-general.php
index 6ddd002..94505b3 100644
--- a/views/settings/tab-general.php
+++ b/views/settings/tab-general.php
@@ -31,8 +31,8 @@ $available_languages = $settings_instance->get_available_languages();
-
-
+
+
@@ -71,11 +71,11 @@ $available_languages = $settings_instance->get_available_languages();
-
-
+
+
-
+
@@ -84,13 +84,13 @@ $available_languages = $settings_instance->get_available_languages();
-
+
$
-
+
%
@@ -104,7 +104,7 @@ $available_languages = $settings_instance->get_available_languages();
-
+
$
@@ -122,7 +122,7 @@ $available_languages = $settings_instance->get_available_languages();
-
+
/>
+
-
-
+
+
@@ -240,8 +266,8 @@ $available_languages = $settings_instance->get_available_languages();
-
-
+
+
@@ -292,8 +318,8 @@ $available_languages = $settings_instance->get_available_languages();
-
-
+
+
@@ -303,6 +329,15 @@ $available_languages = $settings_instance->get_available_languages();
+
+
+ />
+
+
+
+
+
+
diff --git a/views/settings/tab-guide.php b/views/settings/tab-guide.php
index 8fd543b..3ceb434 100644
--- a/views/settings/tab-guide.php
+++ b/views/settings/tab-guide.php
@@ -13,8 +13,8 @@ if ( ! defined( 'ABSPATH' ) ) {
-
-
+
+
📖
@@ -86,8 +86,8 @@ if ( ! defined( 'ABSPATH' ) ) {
-
-
+
+
⭐
@@ -159,8 +159,8 @@ if ( ! defined( 'ABSPATH' ) ) {
-
-
+
+
💡
diff --git a/views/settings/tab-local-backend.php b/views/settings/tab-local-backend.php
new file mode 100644
index 0000000..f91664f
--- /dev/null
+++ b/views/settings/tab-local-backend.php
@@ -0,0 +1,307 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ __( 'Chat & Discussion', 'wp-agentic-writer' ),
+ 'clarity' => __( 'Clarity Check & Quiz', 'wp-agentic-writer' ),
+ 'planning' => __( 'Outline Planning', 'wp-agentic-writer' ),
+ 'writing' => __( 'Article Writing', 'wp-agentic-writer' ),
+ 'refinement' => __( 'Content Refinement', 'wp-agentic-writer' ),
+ 'image' => __( 'Image Generation', 'wp-agentic-writer' ),
+ );
+
+ $task_providers = $settings['task_providers'] ?? array();
+ ?>
+
+
+
+
+
+
+
+
+
+ $task_label ) : ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
./start-proxy.sh
+
+ -
+
+
+
./get-local-ip.sh
+
+
+ -
+
+
+
+ -
+
+
+
which claude
+
+ claude --version
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/views/settings/tab-models.php b/views/settings/tab-models.php
index 85234c1..a6de103 100644
--- a/views/settings/tab-models.php
+++ b/views/settings/tab-models.php
@@ -15,13 +15,26 @@
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
+
+if ( ! function_exists( 'wpaw_get_provider_badge' ) ) {
+ function wpaw_get_provider_badge( $task, $task_providers ) {
+ $provider = $task_providers[ $task ] ?? ( 'image' === $task ? 'openrouter' : 'openrouter' );
+ if ( 'local_backend' === $provider ) {
+ return 'Local Backend';
+ } elseif ( 'codex' === $provider ) {
+ return 'Codex';
+ } else {
+ return 'OpenRouter';
+ }
+ }
+}
?>
-
-
+
+
@@ -33,7 +46,7 @@ if ( ! defined( 'ABSPATH' ) ) {
-
+
@@ -42,14 +55,14 @@ if ( ! defined( 'ABSPATH' ) ) {
Chat/Clarity/Planning/Refinement: Gemini 2.5 Flash
Writing: Mistral Small Creative
- Image: GPT-4o
+ Image: FLUX.2 klein
-
+
@@ -58,14 +71,14 @@ if ( ! defined( 'ABSPATH' ) ) {
Chat/Clarity/Planning: Gemini 2.5 Flash
Writing/Refinement: Claude 3.5 Sonnet
- Image: GPT-4o
+ Image: Riverflow V2 Max
-
+
@@ -75,7 +88,7 @@ if ( ! defined( 'ABSPATH' ) ) {
Chat/Planning: Gemini 3 Flash Preview
Clarity: Claude Sonnet 4
Writing/Refinement: GPT-4.1
- Image: GPT-4o
+ Image: FLUX.2 max
@@ -87,8 +100,8 @@ if ( ! defined( 'ABSPATH' ) ) {
-
-
+
+
@@ -112,6 +125,7 @@ if ( ! defined( 'ABSPATH' ) ) {
Discussion
+