diff --git a/assets/css/sidebar.css b/assets/css/sidebar.css index f750852..cc2b3d0 100644 --- a/assets/css/sidebar.css +++ b/assets/css/sidebar.css @@ -66,7 +66,9 @@ color: #a7aaad; text-transform: uppercase; letter-spacing: 0.05em; - transition: color 0.1s ease, border-color 0.1s ease; + transition: + color 0.1s ease, + border-color 0.1s ease; margin-bottom: -1px; } @@ -176,6 +178,188 @@ background: #525b6b; } +.wpaw-agent-workspace-card { + background: linear-gradient(135deg, #111827 0%, #1e293b 100%); + border: 1px solid #334155; + border-radius: 12px; + margin: 10px 10px 8px; + padding: 12px; + color: #e5e7eb; + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.28); +} + +.wpaw-agent-workspace-card.is-collapsed { + padding: 9px 10px; +} + +.wpaw-agent-workspace-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.wpaw-agent-workspace-card.is-collapsed .wpaw-agent-workspace-header { + align-items: center; + margin-bottom: 0; +} + +.wpaw-agent-workspace-heading { + min-width: 0; +} + +.wpaw-agent-workspace-kicker { + color: #93c5fd; + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 700; + margin-bottom: 3px; +} + +.wpaw-agent-workspace-title { + color: #f8fafc; + font-size: 13px; + font-weight: 700; + line-height: 1.3; +} + +.wpaw-agent-workspace-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.wpaw-agent-workspace-status { + white-space: nowrap; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.35); + color: #cbd5e1; + background: rgba(15, 23, 42, 0.7); + padding: 4px 8px; + font-size: 10px; + text-transform: capitalize; +} + +.wpaw-agent-workspace-toggle { + border: 1px solid rgba(147, 197, 253, 0.35); + border-radius: 999px; + background: rgba(15, 23, 42, 0.42); + color: #bfdbfe; + cursor: pointer; + font-size: 10px; + font-weight: 700; + line-height: 1; + padding: 5px 8px; +} + +.wpaw-agent-workspace-toggle:hover, +.wpaw-agent-workspace-toggle:focus { + border-color: #60a5fa; + color: #eff6ff; + outline: none; +} + +.wpaw-agent-workspace-status.status-in_progress, +.wpaw-agent-workspace-status.status-paused, +.wpaw-agent-workspace-status.status-running, +.wpaw-agent-workspace-status.status-stopping { + color: #fbbf24; + border-color: rgba(251, 191, 36, 0.45); + background: rgba(113, 63, 18, 0.28); +} + +.wpaw-agent-workspace-status.status-completed { + color: #86efac; + border-color: rgba(134, 239, 172, 0.45); + background: rgba(20, 83, 45, 0.28); +} + +.wpaw-agent-workspace-status.status-failed { + color: #fca5a5; + border-color: rgba(248, 113, 113, 0.45); + background: rgba(127, 29, 29, 0.28); +} + +.wpaw-agent-context-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.wpaw-agent-context-item { + min-width: 0; + background: rgba(15, 23, 42, 0.52); + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 9px; + padding: 8px; +} + +.wpaw-agent-context-item span { + display: block; + color: #94a3b8; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 4px; +} + +.wpaw-agent-context-item strong { + display: block; + color: #f8fafc; + font-size: 12px; + line-height: 1.35; + overflow: hidden; + text-overflow: ellipsis; +} + +.wpaw-agent-keyword-input { + width: 100%; + min-height: 26px; + border: 1px solid rgba(147, 197, 253, 0.32); + border-radius: 7px; + background: rgba(15, 23, 42, 0.78); + color: #f8fafc; + font-size: 12px; + padding: 4px 7px; +} + +.wpaw-agent-keyword-input:focus { + outline: none; + border-color: #60a5fa; + box-shadow: 0 0 0 1px rgba(96, 165, 250, 0.35); +} + +.wpaw-agent-resume-card { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + margin-top: 10px; + padding: 9px; + border-radius: 9px; + background: rgba(59, 130, 246, 0.12); + border: 1px solid rgba(96, 165, 250, 0.28); +} + +.wpaw-agent-resume-card strong, +.wpaw-agent-resume-card span { + display: block; +} + +.wpaw-agent-resume-card strong { + color: #bfdbfe; + font-size: 12px; +} + +.wpaw-agent-resume-card span { + color: #93c5fd; + font-size: 11px; + margin-top: 2px; +} + .wpaw-input-area { background: #1e2128; padding: 12px; @@ -581,7 +765,7 @@ input.wpaw-plan-section-check:checked::before { } .wpaw-block-refining::before { - content: 'REFINING'; + content: "REFINING"; position: absolute; top: -12px; right: 8px; @@ -618,7 +802,7 @@ input.wpaw-plan-section-check:checked::before { padding: 12px; border-radius: 8px; overflow-x: auto; - font-family: ui-monospace, 'SF Mono', Menlo, monospace; + font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12px; border: 1px solid #2d3139; color: #c8cdd5; @@ -629,16 +813,18 @@ input.wpaw-plan-section-check:checked::before { color: #a5d6ff; padding: 2px 5px; border-radius: 4px; - font-family: ui-monospace, 'SF Mono', Menlo, monospace; + font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12px; } -.wpaw-editor-locked .admin-ui-navigable-region.interface-interface-skeleton__content { +.wpaw-editor-locked + .admin-ui-navigable-region.interface-interface-skeleton__content { position: relative; } -.wpaw-editor-locked .admin-ui-navigable-region.interface-interface-skeleton__content::after { - content: ''; +.wpaw-editor-locked + .admin-ui-navigable-region.interface-interface-skeleton__content::after { + content: ""; position: absolute; inset: 0; background: rgba(255, 255, 255, 0.55); @@ -894,7 +1080,7 @@ input.wpaw-plan-section-check:checked::before { } .wpaw-ai-response .wpaw-response::before { - content: ''; + content: ""; position: absolute; left: -15px; top: 20px; @@ -913,7 +1099,7 @@ input.wpaw-plan-section-check:checked::before { } .wpaw-streaming-indicator::after { - content: '...'; + content: "..."; display: inline-block; width: 18px; animation: wpaw-ellipsis 1.1s infinite; @@ -951,19 +1137,19 @@ input.wpaw-plan-section-check:checked::before { @keyframes wpaw-ellipsis { 0% { - content: '.'; + content: "."; } 33% { - content: '..'; + content: ".."; } 66% { - content: '...'; + content: "..."; } 100% { - content: '.'; + content: "."; } } @@ -1073,7 +1259,7 @@ input.wpaw-plan-section-check:checked::before { border-color: #e2e8f0; } -.wpaw-plan-section-row input[type=checkbox] { +.wpaw-plan-section-row input[type="checkbox"] { transform: translateY(3px); } @@ -1093,7 +1279,8 @@ input.wpaw-plan-section-check:checked::before { .wpaw-timeline-content { flex: 1; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; color: #c8cdd5; } @@ -1181,7 +1368,7 @@ input.wpaw-plan-section-check:checked::before { z-index: 2; } -.wpaw-config-tab>*:nth-child(2) { +.wpaw-config-tab > *:nth-child(2) { margin-top: 60px; } @@ -1293,14 +1480,16 @@ input.wpaw-plan-section-check:checked::before { margin-bottom: 10px; } -.wpaw-budget-bar~.description { +.wpaw-budget-bar ~ .description { padding: 0 12px; } .wpaw-budget-fill { height: 100%; background: linear-gradient(90deg, #4caf50, #66bb6a); - transition: width 0.5s ease, background 0.3s ease; + transition: + width 0.5s ease, + background 0.3s ease; } .wpaw-budget-fill.warning { @@ -1602,7 +1791,7 @@ input.wpaw-plan-section-check:checked::before { font-size: 13px; } -.wpaw-previous-answers>div:last-child .wpaw-answer-text { +.wpaw-previous-answers > div:last-child .wpaw-answer-text { margin-bottom: 0; } @@ -1722,11 +1911,11 @@ input.wpaw-plan-section-check:checked::before { transition: 0.3s; } -.wpaw-config-toggle input:checked+.wpaw-toggle-slider { +.wpaw-config-toggle input:checked + .wpaw-toggle-slider { background-color: #2271b1; } -.wpaw-config-toggle input:checked+.wpaw-toggle-slider:before { +.wpaw-config-toggle input:checked + .wpaw-toggle-slider:before { transform: translateX(24px); } @@ -1755,11 +1944,14 @@ input.wpaw-plan-section-check:checked::before { display: flex; } -.wpaw-question-card .wpaw-config-form .wpaw-config-label .wpaw-config-description { +.wpaw-question-card + .wpaw-config-form + .wpaw-config-label + .wpaw-config-description { font-size: 11px; } -.wpaw-question-card .wpaw-config-field:has(input[type=text]) { +.wpaw-question-card .wpaw-config-field:has(input[type="text"]) { flex-direction: column; } @@ -1786,7 +1978,7 @@ input.wpaw-plan-section-check:checked::before { padding-right: 20px; } -.wpaw-question-card .wpaw-config-form .wpaw-config-field input[type=text] { +.wpaw-question-card .wpaw-config-form .wpaw-config-field input[type="text"] { background-color: #1a1a1a !important; } @@ -1812,13 +2004,12 @@ input.wpaw-plan-section-check:checked::before { } .dark-theme .wpaw-question-card textarea::placeholder { - color: #6c6c6c + color: #6c6c6c; } .dark-theme .wpaw-question-card textarea::focus, .dark-theme .wpaw-question-card textarea::active { border-color: #252830 !important; - ; } /* =========================== @@ -1861,7 +2052,6 @@ input.wpaw-plan-section-check:checked::before { } @keyframes wpaw-pulse { - 0%, 100% { box-shadow: 0 0 0 0px rgba(34, 113, 177, 0.2); @@ -1920,8 +2110,9 @@ input.wpaw-plan-section-check:checked::before { } @media (max-width: 482px) { - - .interface-complementary-area__fill:has(#wp-agentic-writer\:wp-agentic-writer), + .interface-complementary-area__fill:has( + #wp-agentic-writer\:wp-agentic-writer + ), #wp-agentic-writer\:wp-agentic-writer { width: 100vw !important; } @@ -1933,7 +2124,9 @@ input.wpaw-plan-section-check:checked::before { height: 6px; border-radius: 50%; background-color: #3b82f6; - box-shadow: 12px 0 #3b82f6, -12px 0 #3b82f6; + box-shadow: + 12px 0 #3b82f6, + -12px 0 #3b82f6; position: relative; animation: wpaw-flash 0.5s ease-out infinite alternate; margin: 0 20px 0 16px; @@ -1943,17 +2136,23 @@ input.wpaw-plan-section-check:checked::before { @keyframes wpaw-flash { 0% { background-color: #93c5fd; - box-shadow: 12px 0 #93c5fd, -12px 0 #3b82f6; + box-shadow: + 12px 0 #93c5fd, + -12px 0 #3b82f6; } 50% { background-color: #3b82f6; - box-shadow: 12px 0 #93c5fd, -12px 0 #93c5fd; + box-shadow: + 12px 0 #93c5fd, + -12px 0 #93c5fd; } 100% { background-color: #93c5fd; - box-shadow: 12px 0 #3b82f6, -12px 0 #93c5fd; + box-shadow: + 12px 0 #3b82f6, + -12px 0 #93c5fd; } } @@ -1977,7 +2176,8 @@ input.wpaw-plan-section-check:checked::before { padding: 8px 12px; background: #1d2227; color: #fff; - font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-family: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 12px; border-bottom: 1px solid #3c3c3c; } @@ -2004,11 +2204,22 @@ input.wpaw-plan-section-check:checked::before { animation: statusPulse 1s infinite; } +.wpaw-status-dot.checking, +.wpaw-status-dot.refining { + background: #60a5fa; + animation: statusPulse 0.8s infinite; +} + .wpaw-status-dot.writing { background: #2271b1; animation: statusPulse 0.8s infinite; } +.wpaw-status-dot.stopping { + background: #f97316; + animation: statusPulse 0.55s infinite; +} + .wpaw-status-dot.complete { background: #00a32a; } @@ -2018,7 +2229,6 @@ input.wpaw-plan-section-check:checked::before { } @keyframes statusPulse { - 0%, 100% { opacity: 1; @@ -2035,6 +2245,22 @@ input.wpaw-plan-section-check:checked::before { font-weight: 500; } +.wpaw-memanto-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + background: rgba(99, 155, 255, 0.15); + color: #93b8ff; + border: 1px solid rgba(99, 155, 255, 0.25); + cursor: default; + white-space: nowrap; +} + .wpaw-status-cost { color: #a7aaad; font-size: 11px; @@ -2047,7 +2273,8 @@ input.wpaw-plan-section-check:checked::before { flex-direction: column; overflow-y: auto; background: #fff; - font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-family: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 13px; line-height: 1.5; } @@ -2127,7 +2354,9 @@ input.wpaw-plan-section-check:checked::before { /* Agent Response (prose) */ .wpaw-log-entry.agent-response { border-left-color: #dcdcde; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, + Ubuntu, Cantarell, "Helvetica Neue", sans-serif; } /* Command Input Area */ @@ -2287,7 +2516,9 @@ input.wpaw-plan-section-check:checked::before { font-size: 14px; padding: 2px; line-height: 1; - transition: color 0.1s ease, transform 0.1s ease; + transition: + color 0.1s ease, + transform 0.1s ease; } .wpaw-status-icon-btn:hover { @@ -2391,11 +2622,11 @@ input.wpaw-plan-section-check:checked::before { transition: opacity 0.15s ease; } -.wpaw-web-search-toggle input:checked+.wpaw-web-search-icon { +.wpaw-web-search-toggle input:checked + .wpaw-web-search-icon { opacity: 1; } -.wpaw-web-search-toggle input:checked+.wpaw-web-search-icon * { +.wpaw-web-search-toggle input:checked + .wpaw-web-search-icon * { stroke: #4caf50; } @@ -2408,7 +2639,7 @@ input.wpaw-plan-section-check:checked::before { transition: color 0.15s ease; } -.wpaw-web-search-toggle input:checked~.wpaw-web-search-label { +.wpaw-web-search-toggle input:checked ~ .wpaw-web-search-label { color: #4caf50; } @@ -2513,6 +2744,17 @@ input.wpaw-plan-section-check:checked::before { transform: scale(1.05); } +.wpaw-stop-circle-btn.is-stopping, +.wpaw-stop-circle-btn.is-stopping:hover { + background: #f97316; + cursor: wait; + transform: none; +} + +.wpaw-stop-spinner { + animation: wpaw-spin 0.85s linear infinite; +} + .wpaw-command-circle-btn svg { width: 20px !important; height: 20px !important; @@ -2827,10 +3069,37 @@ input.wpaw-plan-section-check:checked::before { } .wpaw-seo-check .check-label { + flex: 1; color: #a7aaad; + min-width: 0; } -.wpaw-meta-info>button.components-button.is-secondary.is-small { +.wpaw-seo-fix-button.components-button.is-secondary.is-small { + border-color: rgba(96, 165, 250, 0.72); + box-shadow: none !important; + color: #bfdbfe; + flex-shrink: 0; + height: 24px; + min-width: 44px; + padding: 0 8px; +} + +.wpaw-seo-fix-button.components-button.is-secondary.is-small:hover:not( + :disabled + ), +.wpaw-seo-fix-button.components-button.is-secondary.is-small:focus:not( + :disabled + ) { + border-color: #60a5fa; + color: #eff6ff; +} + +.wpaw-seo-fix-button.components-button.is-secondary.is-small.is-fixing { + border-color: #fbbf24; + color: #fde68a; +} + +.wpaw-meta-info > button.components-button.is-secondary.is-small { outline: unset !important; color: #fbbf24; border: 1px solid #fbbf24; @@ -3368,7 +3637,9 @@ input.wpaw-plan-section-check:checked::before { border-radius: 4px; font-size: 12px; cursor: pointer; - transition: border-color 0.2s, background 0.2s; + transition: + border-color 0.2s, + background 0.2s; } .wpaw-fk-select { @@ -3386,7 +3657,9 @@ input.wpaw-plan-section-check:checked::before { padding: 6px 10px; border-radius: 4px; font-size: 12px; - transition: border-color 0.2s, background 0.2s; + transition: + border-color 0.2s, + background 0.2s; } .wpaw-fk-input:focus { @@ -3411,7 +3684,9 @@ input.wpaw-plan-section-check:checked::before { padding: 10px 12px; border-radius: 6px; font-size: 14px; - transition: border-color 0.2s, background 0.2s; + transition: + border-color 0.2s, + background 0.2s; } .wpaw-fk-custom-input:focus { @@ -3460,7 +3735,9 @@ input.wpaw-plan-section-check:checked::before { display: flex; align-items: center; justify-content: center; - transition: background 0.2s, color 0.2s; + transition: + background 0.2s, + color 0.2s; } .wpaw-fk-expand:hover, @@ -3618,7 +3895,9 @@ input.wpaw-plan-section-check:checked::before { font-size: 14px; margin-bottom: 1rem; box-sizing: border-box; - transition: border-color 0.2s, background 0.2s; + transition: + border-color 0.2s, + background 0.2s; } .wpaw-welcome-input:focus { @@ -3919,7 +4198,6 @@ input.wpaw-plan-section-check:checked::before { P2: TYPING ANIMATION =========================== */ @keyframes wpaw-typewriter-cursor { - 0%, 100% { border-color: transparent; @@ -3962,7 +4240,6 @@ input.wpaw-plan-section-check:checked::before { } @keyframes wpaw-typing-bounce { - 0%, 60%, 100% { @@ -4030,8 +4307,13 @@ input.wpaw-plan-section-check:checked::before { } @keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } } .wpaw-suggestion-item { @@ -4169,7 +4451,9 @@ input.wpaw-plan-section-check:checked::before { max-width: 90vw; background: #2d2d2d; border-radius: 12px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1); + box-shadow: + 0 20px 60px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(255, 255, 255, 0.1); overflow: hidden; animation: wpaw-palette-slide-in 0.15s ease-out; } @@ -4550,7 +4834,6 @@ input.wpaw-plan-section-check:checked::before { color: #f59e0b; } - /* =========================== AUDIT FIXES: Mode Indicator Badge =========================== */ @@ -4567,10 +4850,20 @@ input.wpaw-plan-section-check:checked::before { margin-bottom: 0.5em !important; letter-spacing: normal !important; } +.wpaw-response-content h1 { + font-size: 20px !important; + color: #e8ecf2 !important; + font-weight: bold; + margin-top: 1.5rem; + margin-bottom: 1rem; +} .wpaw-response-content h2 { font-size: 17px !important; color: #e8ecf2 !important; + font-weight: bold; + margin-top: 1rem; + margin-bottom: 1rem; } .wpaw-response-content h4, @@ -4580,6 +4873,17 @@ input.wpaw-plan-section-check:checked::before { color: #d0d5dd !important; } +.wpaw-response-content table { + border-collapse: collapse; + width: 100%; +} + +.wpaw-response-content table th, +table td { + border: 1px solid #dce0e8 !important; + padding: 4px 6px; +} + .wpaw-mode-badge { display: inline-flex; align-items: center; @@ -4763,7 +5067,7 @@ input.wpaw-plan-section-check:checked::before { color: #dce0e8; } -.wpaw-response-content>* { +.wpaw-response-content > * { padding: 1rem; } @@ -4801,7 +5105,8 @@ input.wpaw-plan-section-check:checked::before { .wpaw-config-summary-item { color: #9aa5b4; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; margin-bottom: 4px; font-size: 11.5px; } diff --git a/assets/js/sidebar.js b/assets/js/sidebar.js index 21d91ba..0be378d 100644 --- a/assets/js/sidebar.js +++ b/assets/js/sidebar.js @@ -5,7616 +5,11788 @@ */ (function (wp) { - const { registerPlugin } = wp.plugins; - const { PluginSidebarMoreMenuItem } = wp.editPost; - const { PluginSidebar } = wp.editPost; - const { Panel, TextareaControl, TextControl, CheckboxControl, Button } = wp.components; - const { dispatch, select } = wp.data; - const { RawHTML } = wp.element; - - // Debug logger - only logs when SCRIPT_DEBUG is enabled - const isDebug = typeof wpAgenticWriter !== 'undefined' && wpAgenticWriter.debug; - const wpawLog = { - log: (...args) => { if (isDebug) console.log('[WPAW]', ...args); }, - error: (...args) => console.error('[WPAW]', ...args), // Always log errors - info: (...args) => { if (isDebug) console.info('[WPAW]', ...args); }, - warn: (...args) => { if (isDebug) console.warn('[WPAW]', ...args); }, - }; - const pluginIcon = wp.element.createElement('img', { - src: wpAgenticWriter.pluginUrl + '/assets/img/icon.svg', - alt: 'WP Agentic Writer', - style: { width: '20px', height: '20px' } - }); - - // Sidebar Component. - const AgenticWriterSidebar = ({ postId }) => { - // Get settings from wpAgenticWriter global. - const settings = typeof wpAgenticWriter !== 'undefined' ? wpAgenticWriter.settings : {}; - const formatAiErrorMessage = (error, fallback = 'The AI request failed.') => { - const rawMessage = typeof error === 'string' - ? error - : (error?.message || fallback); - const cleanMessage = String(rawMessage || fallback).replace(/^API error:\s*/i, '').trim(); - const lowerMessage = cleanMessage.toLowerCase(); - - // Returns structured object { title, detail, actionUrl, actionLabel } - const structured = (title, detail, actionUrl, actionLabel) => { - return { title, detail, actionUrl: actionUrl || '', actionLabel: actionLabel || '' }; - }; - - if ( - lowerMessage.includes('no allowed providers are available') - || (lowerMessage.includes('allowed providers') && lowerMessage.includes('selected model')) - ) { - const routedProvider = settings?.openrouter_provider_slug && settings.openrouter_provider_slug !== 'auto' - ? ` Pinned: ${settings.openrouter_provider_slug}.` - : ''; - return structured( - 'Model unavailable from current provider', - `The pinned provider routing doesn't support this model.${routedProvider} Change provider routing or select a compatible model.`, - settings?.settings_url || '', - 'Open Settings' - ); - } - - if (cleanMessage.includes('429') || lowerMessage.includes('rate limit')) { - return structured('Rate limit exceeded', 'The AI provider is throttling requests. Wait a moment and try again.'); - } - - if ( - cleanMessage.includes('cURL error 28') - || lowerMessage.includes('operation timed out') - || lowerMessage.includes('timed out after') - ) { - return structured( - 'Request timed out', - 'The model took too long to respond. Try a faster model, reduce content length, or check your provider routing.', - settings?.settings_url || '', - 'Open Settings' - ); - } - - if (cleanMessage.startsWith('HTTP 401') || lowerMessage.includes('unauthorized')) { - return structured( - 'API key rejected', - 'The provider rejected your API key. Check your key in settings.', - settings?.settings_url || '', - 'Open Settings' - ); - } - - if (cleanMessage.startsWith('HTTP 402') || lowerMessage.includes('insufficient credits')) { - return structured('Insufficient credits', 'Your provider account has no remaining credits or quota.'); - } - - if (lowerMessage.includes('api key is not configured') || lowerMessage.includes('no_api_key')) { - return structured( - 'API key not configured', - 'Add your OpenRouter API key in plugin settings to start using AI features.', - settings?.settings_url || '', - 'Configure API Key' - ); - } - - return structured(cleanMessage || fallback, ''); - }; - - // Tab state - const [activeTab, setActiveTab] = React.useState('chat'); - - // Chat state - const [messages, setMessages] = React.useState([]); - const [input, setInput] = React.useState(''); - const [isLoading, setIsLoading] = React.useState(false); - const [currentSessionId, setCurrentSessionId] = React.useState(''); - const [availableSessions, setAvailableSessions] = React.useState([]); - const [isSessionActionLoading, setIsSessionActionLoading] = React.useState(false); - const [agentMode, setAgentMode] = React.useState(() => { - try { - return window.localStorage.getItem('wpawAgentMode') || 'chat'; - } catch (error) { - return 'chat'; - } - }); - - // Config state - const defaultPostConfig = React.useMemo(() => ({ - article_length: 'medium', - language: 'auto', - tone: '', - audience: '', - experience_level: 'general', - include_images: true, - web_search: Boolean(settings.web_search_enabled), - default_mode: 'chat', - // SEO fields - seo_focus_keyword: '', - seo_secondary_keywords: '', - seo_meta_description: '', - seo_enabled: true, - }), [settings.web_search_enabled]); - const [postConfig, setPostConfig] = React.useState(defaultPostConfig); - const [isConfigLoading, setIsConfigLoading] = React.useState(false); - const [isConfigSaving, setIsConfigSaving] = React.useState(false); - const [configError, setConfigError] = React.useState(''); - const configHydratedRef = React.useRef(false); - const lastSavedConfigRef = React.useRef(''); - const configSaveTimeoutRef = React.useRef(null); - const appliedDefaultModeRef = React.useRef(false); - - // Cost state - const [cost, setCost] = React.useState({ session: 0, today: 0, monthlyUsed: 0 }); - const [monthlyBudget, setMonthlyBudget] = React.useState(settings.monthly_budget || 600); - - // Provider info state for transparency display - const [providerInfo, setProviderInfo] = React.useState(null); - - // Helper to extract and apply provider metadata from any AI response - const applyProviderMetadata = (data) => { - if (!data) return; - if (data.session_id) { - setCurrentSessionId(data.session_id); - } - - // Support both nested provider_metadata and top-level provider fields - const meta = data.provider_metadata || data; - const provider = meta.provider || meta.selected_provider || meta.provider; - - if (provider) { - setProviderInfo({ - provider: provider, - model: meta.model, - fallbackUsed: meta.fallback_used || meta.fallbackUsed, - warnings: meta.warnings || [] - }); - } - }; - - const [isEditorLocked, setIsEditorLocked] = React.useState(false); - const [isRefinementLocked, setIsRefinementLocked] = React.useState(false); - const [refiningBlockIds, setRefiningBlockIds] = React.useState([]); - const refinementDecoratedIdsRef = React.useRef([]); - const lockedEditableNodesRef = React.useRef([]); - const lockedBlockIdsRef = React.useRef([]); - const REFINEMENT_ALL_CONFIRM_THRESHOLD = 25; - const [refineAllConfirm, setRefineAllConfirm] = React.useState({ - isOpen: false, - blockCount: 0, - dontAskAgain: false, - }); - const refineAllConfirmResolverRef = React.useRef(null); - const skipRefineAllConfirmRef = React.useRef(false); - - // SEO audit state - const [seoAudit, setSeoAudit] = React.useState(null); - const [isSeoAuditing, setIsSeoAuditing] = React.useState(false); - - // Clarification state. - const [inClarification, setInClarification] = React.useState(false); - const [questions, setQuestions] = React.useState([]); - const [currentQuestionIndex, setCurrentQuestionIndex] = React.useState(0); - const [answers, setAnswers] = React.useState([]); - const [detectedLanguage, setDetectedLanguage] = React.useState('english'); - const [clarificationMode, setClarificationMode] = React.useState('generation'); - const [pendingRefinement, setPendingRefinement] = React.useState(null); - const [pendingEditPlan, setPendingEditPlan] = React.useState(null); - const lastGenerationRequestRef = React.useRef(null); - const currentPlanRef = React.useRef(null); - const lastExecuteRequestRef = React.useRef(null); - const sectionInsertIndexRef = React.useRef({}); - const activeSectionIdRef = React.useRef(null); - const sectionBlocksRef = React.useRef({}); - const blockSectionRef = React.useRef({}); - const markdownRendererRef = React.useRef(null); - const lastRefineRequestRef = React.useRef(null); - const lastChatRequestRef = React.useRef(null); - const stopExecutionRef = React.useRef(false); - const [executionStopped, setExecutionStopped] = React.useState(false); - - // Mention autocomplete state - const [showMentionAutocomplete, setShowMentionAutocomplete] = React.useState(false); - const [mentionQuery, setMentionQuery] = React.useState(''); - const [mentionOptions, setMentionOptions] = React.useState([]); - const [mentionCursorIndex, setMentionCursorIndex] = React.useState(0); - const [showSlashAutocomplete, setShowSlashAutocomplete] = React.useState(false); - const [slashQuery, setSlashQuery] = React.useState(''); - const [slashOptions, setSlashOptions] = React.useState([]); - const [slashCursorIndex, setSlashCursorIndex] = React.useState(0); - const [isTextareaExpanded, setIsTextareaExpanded] = React.useState(false); - const inputRef = React.useRef(null); - const streamTargetRef = React.useRef(null); - - // Focus keyword state - const [focusKeywordSuggestions, setFocusKeywordSuggestions] = React.useState([]); - const [selectedFocusKeyword, setSelectedFocusKeyword] = React.useState(''); - const [showCustomKeywordInput, setShowCustomKeywordInput] = React.useState(false); - const [customKeywordInput, setCustomKeywordInput] = React.useState(''); - const messagesSaveTimeoutRef = React.useRef(null); - const lastPersistedMessagesRef = React.useRef(''); - const isHydratingSessionRef = React.useRef(false); - - // Welcome screen state - const [showWelcome, setShowWelcome] = React.useState(true); - const [welcomeKeywordInput, setWelcomeKeywordInput] = React.useState(''); - const [welcomeStartMode, setWelcomeStartMode] = React.useState('chat'); // 'chat' or 'planning' - - // Undo stack for AI operations - const [aiUndoStack, setAiUndoStack] = React.useState([]); - const MAX_UNDO_STACK = 10; - React.useEffect(() => { - try { - window.localStorage.setItem('wpawAgentMode', agentMode); - } catch (error) { - // Ignore storage errors in restricted environments. - } - }, [agentMode]); - - React.useEffect(() => { - if (agentMode === 'writing' && !isLoading) { - setAgentMode(currentPlanRef.current ? 'planning' : 'chat'); - } - }, [agentMode, isLoading]); - - React.useEffect(() => { - if (!postId) { - return; - } - - appliedDefaultModeRef.current = false; - setIsConfigLoading(true); - fetch(`${wpAgenticWriter.apiUrl}/post-config/${postId}`, { - headers: { - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - }) - .then((response) => response.ok ? response.json() : Promise.reject(response)) - .then((data) => { - const merged = { ...defaultPostConfig, ...data }; - setPostConfig(merged); - lastSavedConfigRef.current = JSON.stringify(merged); - configHydratedRef.current = true; - if (merged.default_mode && !appliedDefaultModeRef.current) { - setAgentMode(merged.default_mode === 'writing' ? 'chat' : merged.default_mode); - appliedDefaultModeRef.current = true; - } - }) - .catch(() => { - configHydratedRef.current = true; - }) - .finally(() => { - setIsConfigLoading(false); - }); - }, [postId, defaultPostConfig]); - - const savePostConfig = React.useCallback(async (config) => { - if (!postId) { - return; - } - - setIsConfigSaving(true); - setConfigError(''); - try { - const response = await fetch(`${wpAgenticWriter.apiUrl}/post-config/${postId}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - body: JSON.stringify({ postConfig: config }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.message || 'Failed to save post configuration'); - } - - const data = await response.json(); - lastSavedConfigRef.current = JSON.stringify(data); - // Don't update state if data matches current - prevents focus loss - setPostConfig((prev) => { - const newConfig = { ...prev, ...data }; - if (JSON.stringify(prev) === JSON.stringify(newConfig)) { - return prev; // Return same reference to prevent re-render - } - return newConfig; - }); - } catch (error) { - setConfigError(error.message || 'Failed to save post configuration'); - } finally { - setIsConfigSaving(false); - } - }, [postId]); - - React.useEffect(() => { - if (!configHydratedRef.current || isConfigLoading) { - return; - } - - const serialized = JSON.stringify(postConfig); - if (serialized === lastSavedConfigRef.current) { - return; - } - - if (configSaveTimeoutRef.current) { - clearTimeout(configSaveTimeoutRef.current); - } - - configSaveTimeoutRef.current = setTimeout(() => { - savePostConfig(postConfig); - }, 600); - - return () => { - if (configSaveTimeoutRef.current) { - clearTimeout(configSaveTimeoutRef.current); - } - }; - }, [postConfig, isConfigLoading, savePostConfig]); - - React.useEffect(() => { - if (!settings.cost_tracking_enabled || !postId) { - return; - } - - fetch(`${wpAgenticWriter.apiUrl}/cost-tracking/${postId}`, { - headers: { - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - }) - .then((response) => response.json()) - .then((data) => { - if (data && typeof data.session === 'number') { - setCost({ - session: data.session, - today: data.today?.total?.cost || 0, - monthlyUsed: data.monthly?.used || 0, - }); - } - if (data?.monthly?.budget) { - setMonthlyBudget(data.monthly.budget); - } - }) - .catch(() => { }); - }, [postId]); - - // Chat messages container ref for auto-scroll - const messagesEndRef = React.useRef(null); - const messagesContainerRef = React.useRef(null); - - // Auto-scroll to bottom when messages change - React.useEffect(() => { - if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); - } - }, [messages]); - - const progressRegex = /^(I'll|Writing|Now|Creating|Adding|Let me|I'll write|Saya|Saya akan|Sedang menulis|Sedang membuat|Menulis tentang|Membuat tentang|Thinking|Analyzing|Reviewing|Refining|Checking|Updating|Planning|Searching|Querying|Generated|Drafting|Reading|Context|Processing)/i; - const activeTimelineStatuses = new Set([ - 'active', - 'starting', - 'refining', - 'checking', - 'waiting', - 'planning', - 'plan_complete', - 'writing', - 'writing_section', - ]); - const writingTimelineStatuses = new Set(['writing', 'writing_section']); - const findLastActiveTimelineIndex = (items) => { - for (let i = items.length - 1; i >= 0; i--) { - if (items[i].type === 'timeline' && activeTimelineStatuses.has(items[i].status)) { - return i; - } - } - - return -1; - }; - const deactivateActiveTimelineEntries = (items) => { - return items.map((item) => { - if (item.type === 'timeline' && activeTimelineStatuses.has(item.status)) { - return { - ...item, - status: 'inactive', - }; - } - - return item; - }); - }; - const updateOrCreateTimelineEntry = (message) => { - setMessages(prev => { - const newMessages = [...prev]; - const timelineIndex = findLastActiveTimelineIndex(newMessages); - - if (timelineIndex === -1) { - newMessages.push({ - role: 'system', - type: 'timeline', - status: 'active', - message: message, - timestamp: new Date() - }); - } else { - newMessages[timelineIndex] = { - ...newMessages[timelineIndex], - message: message - }; - } - - return newMessages; - }); - }; - const requestRefineAllConfirmation = React.useCallback((blockCount) => { - if (skipRefineAllConfirmRef.current) { - return Promise.resolve(true); - } - - return new Promise((resolve) => { - refineAllConfirmResolverRef.current = resolve; - setRefineAllConfirm({ - isOpen: true, - blockCount: Number(blockCount) || 0, - dontAskAgain: false, - }); - }); - }, []); - const resolveRefineAllConfirmation = React.useCallback((approved) => { - const resolver = refineAllConfirmResolverRef.current; - refineAllConfirmResolverRef.current = null; - setRefineAllConfirm((prev) => ({ ...prev, isOpen: false })); - if (resolver) { - resolver(Boolean(approved)); - } - }, []); - - // Undo helper functions - const captureEditorSnapshot = (label = 'AI Operation') => { - const allBlocks = select('core/block-editor').getBlocks(); - const serializedBlocks = allBlocks.map((block) => wp.blocks.serialize(block)).join('\n'); - return { - label, - timestamp: new Date(), - blocks: serializedBlocks, - }; - }; - - const pushUndoSnapshot = (label = 'AI Operation') => { - const snapshot = captureEditorSnapshot(label); - setAiUndoStack((prev) => { - const newStack = [...prev, snapshot]; - if (newStack.length > MAX_UNDO_STACK) { - return newStack.slice(-MAX_UNDO_STACK); - } - return newStack; - }); - }; - - const undoLastAiOperation = () => { - if (aiUndoStack.length === 0) { - return; - } - - const lastSnapshot = aiUndoStack[aiUndoStack.length - 1]; - const { resetBlocks } = dispatch('core/block-editor'); - - try { - const parsedBlocks = wp.blocks.parse(lastSnapshot.blocks); - resetBlocks(parsedBlocks); - - setAiUndoStack((prev) => prev.slice(0, -1)); - - setMessages((prev) => [...prev, { - role: 'system', - type: 'timeline', - status: 'complete', - message: `Undid: ${lastSnapshot.label}`, - timestamp: new Date(), - }]); - } catch (error) { - wpawLog.error('Failed to undo AI operation:', error); - setMessages((prev) => [...prev, { - role: 'system', - type: 'error', - content: 'Failed to undo operation: ' + error.message, - }]); - } - }; - - React.useEffect(() => { - const lastTimelineIndex = findLastActiveTimelineIndex(messages); - const lastTimeline = lastTimelineIndex !== -1 ? messages[lastTimelineIndex] : null; - const isWritingActive = Boolean( - isLoading - && lastTimeline - && writingTimelineStatuses.has(lastTimeline.status) - ); - - if (isWritingActive && !isEditorLocked) { - dispatch('core/editor').lockPostSaving('wpaw-writing'); - document.body.classList.add('wpaw-editor-locked'); - setIsEditorLocked(true); - } else if (!isWritingActive && isEditorLocked) { - dispatch('core/editor').unlockPostSaving('wpaw-writing'); - document.body.classList.remove('wpaw-editor-locked'); - setIsEditorLocked(false); - } - }, [messages, isLoading, isEditorLocked]); - React.useEffect(() => { - if (isRefinementLocked) { - dispatch('core/editor').lockPostSaving('wpaw-refining'); - document.body.classList.add('wpaw-refining-locked'); - } else { - dispatch('core/editor').unlockPostSaving('wpaw-refining'); - document.body.classList.remove('wpaw-refining-locked'); - } - }, [isRefinementLocked]); - React.useEffect(() => { - const blockEditorDispatch = dispatch('core/block-editor'); - if (!blockEditorDispatch || typeof blockEditorDispatch.setBlockEditingMode !== 'function') { - return undefined; - } - - if (isRefinementLocked) { - const allBlocks = select('core/block-editor').getBlocks(); - const ids = []; - const collectIds = (blocks) => { - blocks.forEach((block) => { - if (!block?.clientId) { - return; - } - ids.push(block.clientId); - if (Array.isArray(block.innerBlocks) && block.innerBlocks.length > 0) { - collectIds(block.innerBlocks); - } - }); - }; - collectIds(allBlocks); - lockedBlockIdsRef.current = ids; - ids.forEach((id) => blockEditorDispatch.setBlockEditingMode(id, 'disabled')); - } else if (lockedBlockIdsRef.current.length > 0) { - lockedBlockIdsRef.current.forEach((id) => blockEditorDispatch.setBlockEditingMode(id, 'default')); - lockedBlockIdsRef.current = []; - } - - return () => { - if (lockedBlockIdsRef.current.length > 0) { - lockedBlockIdsRef.current.forEach((id) => blockEditorDispatch.setBlockEditingMode(id, 'default')); - lockedBlockIdsRef.current = []; - } - }; - }, [isRefinementLocked, messages]); - - React.useEffect(() => { - const prevIds = refinementDecoratedIdsRef.current || []; - prevIds.forEach((id) => { - const node = document.querySelector(`[data-block="${id}"]`); - if (node) { - node.classList.remove('wpaw-block-refining'); - } - }); - - if (isRefinementLocked && Array.isArray(refiningBlockIds)) { - refiningBlockIds.forEach((id) => { - const node = document.querySelector(`[data-block="${id}"]`); - if (node) { - node.classList.add('wpaw-block-refining'); - } - }); - refinementDecoratedIdsRef.current = [...refiningBlockIds]; - } else { - refinementDecoratedIdsRef.current = []; - } - - return () => { - const cleanupIds = refinementDecoratedIdsRef.current || []; - cleanupIds.forEach((id) => { - const node = document.querySelector(`[data-block="${id}"]`); - if (node) { - node.classList.remove('wpaw-block-refining'); - } - }); - }; - }, [isRefinementLocked, refiningBlockIds, messages]); - - React.useEffect(() => { - if (!isRefinementLocked) { - return undefined; - } - - const shouldBlockEditorInput = (eventTarget) => { - if (!eventTarget || !(eventTarget instanceof Element)) { - return false; - } - if (eventTarget.closest('.wpaw-sidebar, .wpaw-command-area, .wpaw-messages')) { - return false; - } - return Boolean(eventTarget.closest('.interface-interface-skeleton__content, .editor-styles-wrapper, .block-editor-writing-flow')); - }; - - const keydownHandler = (event) => { - if (!shouldBlockEditorInput(event.target)) { - return; - } - if (event.metaKey || event.ctrlKey || event.altKey) { - return; - } - const blockedKeys = new Set(['Enter', 'Backspace', 'Delete', 'Tab']); - if ((typeof event.key === 'string' && event.key.length === 1) || blockedKeys.has(event.key)) { - event.preventDefault(); - event.stopPropagation(); - } - }; - - const blockMutationEvent = (event) => { - if (!shouldBlockEditorInput(event.target)) { - return; - } - event.preventDefault(); - event.stopPropagation(); - }; - - document.addEventListener('keydown', keydownHandler, true); - document.addEventListener('paste', blockMutationEvent, true); - document.addEventListener('drop', blockMutationEvent, true); - document.addEventListener('cut', blockMutationEvent, true); - - return () => { - document.removeEventListener('keydown', keydownHandler, true); - document.removeEventListener('paste', blockMutationEvent, true); - document.removeEventListener('drop', blockMutationEvent, true); - document.removeEventListener('cut', blockMutationEvent, true); - }; - }, [isRefinementLocked]); - React.useEffect(() => { - if (isRefinementLocked) { - const editableNodes = Array.from(document.querySelectorAll('.editor-styles-wrapper [contenteditable="true"]')); - lockedEditableNodesRef.current = editableNodes.map((node) => ({ - node, - prev: node.getAttribute('contenteditable'), - })); - lockedEditableNodesRef.current.forEach(({ node }) => { - node.setAttribute('contenteditable', 'false'); - }); - } else { - (lockedEditableNodesRef.current || []).forEach(({ node, prev }) => { - if (!node) return; - if (prev === null) { - node.removeAttribute('contenteditable'); - } else { - node.setAttribute('contenteditable', prev); - } - }); - lockedEditableNodesRef.current = []; - } - - return () => { - (lockedEditableNodesRef.current || []).forEach(({ node, prev }) => { - if (!node) return; - if (prev === null) { - node.removeAttribute('contenteditable'); - } else { - node.setAttribute('contenteditable', prev); - } - }); - lockedEditableNodesRef.current = []; - }; - }, [isRefinementLocked, messages]); - const toTextValue = (value) => { - if (value === null || value === undefined) { - return ''; - } - if (typeof value === 'string' || typeof value === 'number') { - return String(value); - } - return ''; - }; - const updatePostConfig = (key, value) => { - setPostConfig((prev) => ({ ...prev, [key]: value })); - }; - - // Focus keyword handlers - const handleFocusKeywordChange = (keyword) => { - setSelectedFocusKeyword(keyword); - updatePostConfig('focus_keyword', keyword); - updatePostConfig('seo_focus_keyword', keyword); - setShowCustomKeywordInput(false); - setCustomKeywordInput(''); - }; - - const handleKeywordSelect = (e) => { - const value = e.target.value; - if (value === '__custom__') { - setShowCustomKeywordInput(true); - } else { - handleFocusKeywordChange(value); - } - }; - - // Extract ALL focus keyword suggestions from AI response (returns array) - const extractFocusKeywordSuggestions = (aiResponse) => { - if (!aiResponse || typeof aiResponse !== 'string') return []; - - const suggestions = []; - - // Method 1: Bullet list after "Fokus Keyword Suggestion:" or "Focus Keyword Suggestion:" - // Matches: - "Keyword Here" or * "Keyword Here" or - Keyword Here - const bulletListMatch = aiResponse.match(/(?:fokus|focus)\s+keyword\s+suggestion[s]?\s*:\s*([\s\S]*?)(?=\n\n|Pilih|$)/i); - if (bulletListMatch) { - const listContent = bulletListMatch[1]; - // Extract items from bullet list (- or *) - const bulletItems = listContent.match(/[-*]\s*["']?([^"'\n]+)["']?/g); - if (bulletItems) { - bulletItems.forEach(item => { - const cleaned = item.replace(/^[-*]\s*["']?/, '').replace(/["']?$/, '').trim(); - if (cleaned.length > 2 && cleaned.length < 60) { - suggestions.push(cleaned); - } - }); - } - } - - // Method 2: Single line "Focus Keyword Suggestion: keyword" - if (suggestions.length === 0) { - const singleMatch = aiResponse.match(/(?:fokus|focus)\s+keyword\s+suggestion[s]?\s*:\s*["']?([^"'\n]+)["']?/i); - if (singleMatch && !singleMatch[1].includes('-') && !singleMatch[1].includes('*')) { - const kw = singleMatch[1].trim(); - if (kw.length > 2 && kw.length < 60) { - suggestions.push(kw); - } - } - } - - return suggestions; - }; - - // Legacy single extraction (for backward compatibility) - const extractFocusKeywordSuggestion = (aiResponse) => { - const suggestions = extractFocusKeywordSuggestions(aiResponse); - return suggestions.length > 0 ? suggestions[0] : null; - }; - - const addFocusKeywordSuggestion = (suggestion) => { - if (!suggestion) return; - setFocusKeywordSuggestions(prev => { - if (prev.includes(suggestion)) return prev; - const updated = [...prev, suggestion]; - return updated.slice(-5); // Keep max 5 suggestions - }); - // Don't auto-select - let user choose - }; - - // Add multiple suggestions at once - const addFocusKeywordSuggestions = (suggestions) => { - if (!suggestions || !Array.isArray(suggestions)) return; - suggestions.forEach(s => addFocusKeywordSuggestion(s)); - }; - - // Load focus keyword from postConfig on mount - React.useEffect(() => { - if (postConfig.focus_keyword && !selectedFocusKeyword) { - setSelectedFocusKeyword(postConfig.focus_keyword); - } else if (postConfig.seo_focus_keyword && !selectedFocusKeyword) { - setSelectedFocusKeyword(postConfig.seo_focus_keyword); - } - }, [postConfig.focus_keyword, postConfig.seo_focus_keyword]); - - // Check if should show welcome screen (no messages yet) - React.useEffect(() => { - if (messages.length > 0 || currentPlanRef.current) { - setShowWelcome(false); - } - }, [messages.length]); - - // Welcome screen start handler - const handleWelcomeStart = () => { - // Set focus keyword if provided (but don't add to AI suggestions - it's user input) - if (welcomeKeywordInput.trim()) { - const keyword = welcomeKeywordInput.trim(); - handleFocusKeywordChange(keyword); - // NOT adding to suggestions - user input is NOT AI suggestion - } - // Set mode and hide welcome - setAgentMode(welcomeStartMode); - setShowWelcome(false); - // Focus the input - setTimeout(() => { - if (inputRef.current) { - inputRef.current.focus(); - } - }, 100); - }; - - // Run SEO Audit - const runSeoAudit = async () => { - if (isSeoAuditing || !postId) return; - setIsSeoAuditing(true); - try { - const response = await fetch(`${wpAgenticWriter.apiUrl}/seo-audit/${postId}`, { - headers: { - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.message || 'Failed to run SEO audit'); - } - setSeoAudit(data); - } catch (error) { - wpawLog.error('SEO Audit error:', error); - setMessages((prev) => [...prev, { - role: 'assistant', - content: `SEO Audit error: ${error.message}`, - type: 'error', - }]); - } finally { - setIsSeoAuditing(false); - } - }; - - // Generate meta description using AI - const [isGeneratingMeta, setIsGeneratingMeta] = wp.element.useState(false); - - const generateMetaDescription = async () => { - if (isGeneratingMeta) return; - setIsGeneratingMeta(true); - try { - const response = await fetch(`${wpAgenticWriter.apiUrl}/generate-meta`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - body: JSON.stringify({ - postId: postId, - sessionId: currentSessionId, - focusKeyword: postConfig.seo_focus_keyword, - chatHistory: messages.filter(m => m.role !== 'system'), - }), - }); - - if (!response.ok) { - const data = await response.json(); - throw new Error(data.message || 'Failed to generate meta description'); - } - - const data = await response.json(); - applyProviderMetadata(data); - if (data.meta_description) { - updatePostConfig('seo_meta_description', data.meta_description); - setMessages((prev) => [...prev, { - role: 'assistant', - content: `✅ Meta description generated successfully`, - type: 'success', - }]); - } else { - throw new Error('No meta description returned from API'); - } - } catch (error) { - wpawLog.error('Error generating meta description:', error); - setMessages((prev) => [...prev, { - role: 'system', - content: `❌ Failed to generate meta description: ${error.message}`, - type: 'error', - }]); - } finally { - setIsGeneratingMeta(false); - } - }; - - const extractBlockPreview = (block) => { - const direct = toTextValue( - block.attributes?.content - || block.attributes?.value - || block.attributes?.caption - || block.attributes?.title - || '' - ); - - if (direct) { - return direct; - } - - if (wp.blocks && typeof wp.blocks.getBlockContent === 'function') { - const html = wp.blocks.getBlockContent(block); - if (html) { - const temp = document.createElement('div'); - temp.innerHTML = html; - return toTextValue(temp.textContent); - } - } - - return ''; - }; - const getBlockPreviewById = (clientId) => { - if (!clientId) { - return ''; - } - const allBlocks = select('core/block-editor').getBlocks(); - const block = allBlocks.find((entry) => entry.clientId === clientId); - if (!block) { - return ''; - } - return extractBlockPreview(block); - }; - - // Auto-scroll to bottom when new messages arrive - React.useEffect(() => { - if (messagesContainerRef.current) { - const container = messagesContainerRef.current; - container.scrollTop = container.scrollHeight; - } - }, [messages, isLoading]); - - React.useEffect(() => { - const handleBeforeUnload = (event) => { - if (!isLoading) { - return; - } - event.preventDefault(); - event.returnValue = ''; - return ''; - }; - window.addEventListener('beforeunload', handleBeforeUnload); - return () => window.removeEventListener('beforeunload', handleBeforeUnload); - }, [isLoading]); - - React.useEffect(() => { - loadSectionBlocks(); - }, [postId]); - - React.useEffect(() => { - if (!postId) { - return; - } - try { - const savedSession = window.localStorage.getItem(`wpawSessionId_${postId}`); - if (savedSession) { - setCurrentSessionId(savedSession); - } - } catch (error) { - // Ignore storage read errors. - } - }, [postId]); - - React.useEffect(() => { - if (!postId || !currentSessionId) { - return; - } - try { - window.localStorage.setItem(`wpawSessionId_${postId}`, currentSessionId); - } catch (error) { - // Ignore storage write errors. - } - }, [postId, currentSessionId]); - - const sanitizeMessagesForStorage = React.useCallback((items) => { - if (!Array.isArray(items)) { - return []; - } - - const MAX_MESSAGES = 300; - const clipped = items.slice(-MAX_MESSAGES); - - return clipped.map((msg) => { - const out = {}; - out.role = typeof msg?.role === 'string' ? msg.role : 'assistant'; - - if (typeof msg?.content === 'string') { - out.content = msg.content; - } - if (typeof msg?.type === 'string') { - out.type = msg.type; - } - if (typeof msg?.status === 'string') { - out.status = msg.status; - } - if (msg?.timestamp) { - out.timestamp = msg.timestamp; - } - if (Array.isArray(msg?.sections)) { - out.sections = msg.sections; - } - if (msg?.meta && typeof msg.meta === 'object') { - out.meta = msg.meta; - } - if (msg?.plan && typeof msg.plan === 'object') { - out.plan = msg.plan; - } - - return out; - }); - }, []); - - const hydrateSessionStateFromMessages = React.useCallback((sessionMessages) => { - if (!Array.isArray(sessionMessages) || sessionMessages.length === 0) { - currentPlanRef.current = null; - setAgentMode('chat'); - return; - } - - let latestPlan = null; - for (let i = sessionMessages.length - 1; i >= 0; i -= 1) { - if (sessionMessages[i]?.type === 'plan' && sessionMessages[i]?.plan) { - latestPlan = ensurePlanTasks(sessionMessages[i].plan); - break; - } - } - - currentPlanRef.current = latestPlan; - if (latestPlan) { - setAgentMode('planning'); - } else { - setAgentMode('chat'); - } - setShowWelcome(false); - }, []); - - const persistSessionMessages = React.useCallback(async (sessionId, items) => { - if (!sessionId) { - return; - } - const sanitized = sanitizeMessagesForStorage(items); - const serialized = JSON.stringify(sanitized); - if (serialized === lastPersistedMessagesRef.current) { - return; - } - - try { - const response = await fetch(`${wpAgenticWriter.apiUrl}/conversations/${sessionId}/messages`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - body: JSON.stringify({ messages: sanitized }), - }); - if (!response.ok) { - throw new Error('Failed to persist session messages'); - } - lastPersistedMessagesRef.current = serialized; - } catch (error) { - // Non-fatal: keep editor responsive, but do not mark this state persisted. - window.console?.warn?.('WP Agentic Writer: failed to persist session messages.', error); - } - }, [sanitizeMessagesForStorage]); - - // Flush pending message persistence on page unload to prevent data loss - React.useEffect(() => { - const flushOnUnload = () => { - if (messagesSaveTimeoutRef.current) { - clearTimeout(messagesSaveTimeoutRef.current); - } - if (!currentSessionId || isHydratingSessionRef.current) { - return; - } - const sanitized = sanitizeMessagesForStorage(messages); - const serialized = JSON.stringify(sanitized); - if (serialized === lastPersistedMessagesRef.current) { - return; - } - // Synchronous XHR as last resort during unload (sendBeacon can't set headers) - try { - const xhr = new XMLHttpRequest(); - xhr.open('POST', `${wpAgenticWriter.apiUrl}/conversations/${currentSessionId}/messages`, false); // sync - xhr.setRequestHeader('Content-Type', 'application/json'); - xhr.setRequestHeader('X-WP-Nonce', wpAgenticWriter.nonce); - xhr.send(JSON.stringify({ messages: sanitized })); - } catch (e) { - // Best effort - ignore errors during unload - } - }; - window.addEventListener('beforeunload', flushOnUnload); - window.addEventListener('pagehide', flushOnUnload); - return () => { - window.removeEventListener('beforeunload', flushOnUnload); - window.removeEventListener('pagehide', flushOnUnload); - }; - }, [currentSessionId, messages, sanitizeMessagesForStorage]); - - React.useEffect(() => { - if (!currentSessionId) { - return; - } - if (isHydratingSessionRef.current) { - return; - } - - if (messagesSaveTimeoutRef.current) { - clearTimeout(messagesSaveTimeoutRef.current); - } - - messagesSaveTimeoutRef.current = setTimeout(() => { - persistSessionMessages(currentSessionId, messages); - }, 700); - - return () => { - if (messagesSaveTimeoutRef.current) { - clearTimeout(messagesSaveTimeoutRef.current); - } - }; - }, [currentSessionId, messages, persistSessionMessages]); - - React.useEffect(() => { - const loadChatHistory = async () => { - // Skip if we already have a session loaded (e.g., from openSessionById) - if (messages.length > 0 || isHydratingSessionRef.current) { - return; - } - - try { - const headers = { - 'X-WP-Nonce': wpAgenticWriter.nonce, - }; - let historyMessages = []; - let resolvedSessionId = ''; - - // Primary source: merged sessions list (post sessions + unassigned sessions). - const sessions = await loadPostSessions(); - if (sessions.length > 0) { - if (sessions.length > 0) { - let selected = sessions[0]; - const preferred = (() => { - try { - return window.localStorage.getItem(`wpawSessionId_${postId}`) || ''; - } catch (error) { - return ''; - } - })(); - if (preferred) { - const match = sessions.find((s) => s?.session_id === preferred); - if (match) { - selected = match; - } - } - resolvedSessionId = selected?.session_id || ''; - if (Array.isArray(selected?.messages) && selected.messages.length > 0) { - historyMessages = selected.messages; - } - } - } - - // Canonical single-session endpoint fallback. - if (postId && !resolvedSessionId) { - const primary = await fetch(`${wpAgenticWriter.apiUrl}/conversation/${postId}`, { - method: 'GET', - headers, - }); - if (primary.ok) { - const data = await primary.json(); - if (data?.session_id) { - resolvedSessionId = data.session_id; - } - if (data && Array.isArray(data.messages) && data.messages.length > 0) { - historyMessages = data.messages; - } - } - } - - // Legacy endpoint fallback - only if no session found at all. - if (postId && historyMessages.length === 0 && !resolvedSessionId) { - const legacy = await fetch(`${wpAgenticWriter.apiUrl}/chat-history/${postId}`, { - method: 'GET', - headers, - }); - if (legacy.ok) { - const legacyData = await legacy.json(); - if (legacyData && Array.isArray(legacyData.messages) && legacyData.messages.length > 0) { - historyMessages = legacyData.messages; - } - } - } - - if (historyMessages.length > 0) { - isHydratingSessionRef.current = true; - lastPersistedMessagesRef.current = JSON.stringify(sanitizeMessagesForStorage(historyMessages)); - hydrateSessionStateFromMessages(historyMessages); - setMessages(historyMessages); - setTimeout(() => { - isHydratingSessionRef.current = false; - }, 0); - } - if (resolvedSessionId) { - setCurrentSessionId(resolvedSessionId); - } - } catch (error) { - // Ignore history load failures. - } - }; - loadChatHistory(); - // Only run on mount / postId change — NOT on currentSessionId change - // Session switches are handled by openSessionById directly - }, [postId]); - - const loadPostSessions = async () => { - const headers = { - 'X-WP-Nonce': wpAgenticWriter.nonce, - }; - let postSessions = []; - let unassignedSessions = []; - const currentPostStatus = String( - wp?.data?.select('core/editor')?.getCurrentPost?.()?.status || '' - ).toLowerCase(); - - if (postId) { - const postRes = await fetch(`${wpAgenticWriter.apiUrl}/conversations/post/${postId}`, { - method: 'GET', - headers, - }); - if (postRes.ok) { - const postData = await postRes.json(); - postSessions = Array.isArray(postData?.sessions) ? postData.sessions : []; - } - - // Fallback: if this post has no linked sessions, surface active unassigned sessions - // so users can continue unfinished work from other tabs or prior page loads. - // This covers auto-draft AND draft posts that haven't had a session linked yet. - if (postSessions.length === 0) { - const activeRes = await fetch(`${wpAgenticWriter.apiUrl}/conversations?status=active&limit=50`, { - method: 'GET', - headers, - }); - if (activeRes.ok) { - const activeData = await activeRes.json(); - const allActive = Array.isArray(activeData?.sessions) ? activeData.sessions : []; - unassignedSessions = allActive.filter((s) => { - const pid = Number(s?.post_id || 0); - const postStatus = String(s?.post_status || '').toLowerCase(); - // Show sessions that are unassigned, or linked to auto-drafts/drafts - return pid === 0 || postStatus === 'auto-draft' || postStatus === ''; - }); - } - } - } else { - // New post flow: include unassigned/auto-draft sessions for recovery. - const activeRes = await fetch(`${wpAgenticWriter.apiUrl}/conversations?status=active&limit=50`, { - method: 'GET', - headers, - }); - if (activeRes.ok) { - const activeData = await activeRes.json(); - const allActive = Array.isArray(activeData?.sessions) ? activeData.sessions : []; - unassignedSessions = allActive.filter((s) => { - const pid = Number(s?.post_id || 0); - const postStatus = String(s?.post_status || '').toLowerCase(); - return pid === 0 || postStatus === 'auto-draft'; - }); - } - } - - const merged = [...postSessions, ...unassignedSessions]; - const deduped = []; - const seen = new Set(); - merged.forEach((session) => { - const sid = session?.session_id || ''; - if (!sid || seen.has(sid)) { - return; - } - const storedMessageCount = Number( - session?.message_count ?? (Array.isArray(session?.messages) ? session.messages.length : 0) - ); - if (storedMessageCount <= 0) { - return; - } - seen.add(sid); - deduped.push(session); - }); - - setAvailableSessions(deduped); - return deduped; - }; - const openSessionById = async (sessionId) => { - if (!sessionId) { - return; - } - const headers = { - 'X-WP-Nonce': wpAgenticWriter.nonce, - }; - setIsSessionActionLoading(true); - try { - const response = await fetch(`${wpAgenticWriter.apiUrl}/conversations/${sessionId}`, { - method: 'GET', - headers, - }); - if (!response.ok) { - throw new Error('Failed to load session'); - } - const data = await response.json(); - isHydratingSessionRef.current = true; - setCurrentSessionId(sessionId); - const sessionMessages = Array.isArray(data?.messages) ? data.messages : []; - - // If session has no messages, try fetching from the conversations/post endpoint - // as a recovery mechanism (messages may be stored under post relationship) - if (sessionMessages.length === 0 && data?.post_id && Number(data.post_id) > 0) { - wpawLog.warn('Session has 0 messages, attempting post-based recovery:', sessionId); - try { - const postSessionRes = await fetch(`${wpAgenticWriter.apiUrl}/conversation/${data.post_id}`, { - method: 'GET', - headers, - }); - if (postSessionRes.ok) { - const postSessionData = await postSessionRes.json(); - if (Array.isArray(postSessionData?.messages) && postSessionData.messages.length > 0) { - sessionMessages.push(...postSessionData.messages); - } - } - } catch (e) { - // Non-fatal recovery attempt - } - } - - lastPersistedMessagesRef.current = JSON.stringify(sanitizeMessagesForStorage(sessionMessages)); - hydrateSessionStateFromMessages(sessionMessages); - setMessages(sessionMessages); - setShowWelcome(false); - - // Auto-link unassigned session to current post for continuity - const sessionPostId = Number(data?.post_id || 0); - if (postId && postId > 0 && sessionPostId === 0) { - fetch(`${wpAgenticWriter.apiUrl}/conversations/${sessionId}/link-post`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - body: JSON.stringify({ postId: postId }), - }).catch(() => {}); // Non-blocking - } - - setTimeout(() => { - isHydratingSessionRef.current = false; - }, 0); - } catch (error) { - isHydratingSessionRef.current = false; - setMessages((prev) => [...prev, { - role: 'system', - type: 'error', - content: 'Error: Failed to load selected session.', - }]); - } finally { - setIsSessionActionLoading(false); - } - }; - - const resolveStreamTarget = (content) => { - if (progressRegex.test(content)) { - return 'timeline'; - } - - if (content.length >= 6 || /[\s.!?]/.test(content)) { - return 'assistant'; - } - - return null; - }; - const normalizeMentionToken = (token) => { - if (!token) { - return ''; - } - - return token - .replace(/[\u2010-\u2015\u2212]/g, '-') - .replace(/[.,;:!?)]*$/g, '') - .toLowerCase(); - }; - const extractMentionsFromText = (text) => { - const tokens = []; - const mentionRegex = /@([^\s]+)/g; - let match; - - while ((match = mentionRegex.exec(text))) { - const normalized = normalizeMentionToken(match[1]); - if (normalized) { - tokens.push('@' + normalized); - } - } - - return tokens; - }; - const stripMentionsFromText = (text) => { - if (!text) { - return ''; - } - - return text - .replace(/@[\w-]+/g, '') - .replace(/\s{2,}/g, ' ') - .trim(); - }; - const hasTitleMention = (mentionTokens) => { - return Array.isArray(mentionTokens) - && mentionTokens.some((token) => normalizeMentionToken(String(token).replace('@', '')) === 'title'); - }; - const handleTitleRefinement = async (rawMessage, mentionTokens, options = {}) => { - const { skipUserMessage = false } = options; - const instruction = stripMentionsFromText(rawMessage || ''); - - if (!instruction) { - setMessages((prev) => [...prev, { - role: 'system', - type: 'error', - content: 'Please add title instruction after @title. Example: @title tulis ulang, gunakan focus keyword di awal.' - }]); - return false; - } - - if (!skipUserMessage) { - setMessages((prev) => [...prev, { role: 'user', content: rawMessage }]); - } - - setIsLoading(true); - setMessages((prev) => [...deactivateActiveTimelineEntries(prev), { - role: 'system', - type: 'timeline', - status: 'refining', - message: 'Refining title...', - timestamp: new Date() - }]); - - try { - const response = await fetch(`${wpAgenticWriter.apiUrl}/refine-title`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - body: JSON.stringify({ - postId: postId, - sessionId: currentSessionId, - instruction: instruction, - }), - }); - - const data = await response.json(); - if (!response.ok) { - throw new Error(data?.message || 'Failed to refine title'); - } - - if (data?.title) { - dispatch('core/editor').editPost({ title: data.title }); - } - - if (data?.cost) { - setCost({ ...cost, session: cost.session + Number(data.cost || 0) }); - } - applyProviderMetadata(data); - setMessages((prev) => { - const next = [...prev]; - const timelineIndex = findLastActiveTimelineIndex(next); - if (timelineIndex !== -1) { - next[timelineIndex] = { - ...next[timelineIndex], - status: 'complete', - message: 'Title refined successfully.', - completedAt: new Date(), - }; - } - next.push({ - role: 'assistant', - content: `Updated title: ${data.title || ''}` - }); - return next; - }); - return true; - } catch (error) { - setMessages((prev) => [...prev, { - role: 'system', - type: 'error', - content: 'Error: ' + (error.message || 'Failed to refine title'), - }]); - return false; - } finally { - setIsLoading(false); - } - }; - const parseInsertCommand = (text) => { - const commands = [ - { mode: 'add_below', regex: /^\s*(?:\/)?add below\b[:\-]?\s*/i }, - { mode: 'add_above', regex: /^\s*(?:\/)?add above\b[:\-]?\s*/i }, - { mode: 'append_code', regex: /^\s*(?:\/)?append code block\b[:\-]?\s*/i }, - { mode: 'append_code', regex: /^\s*(?:\/)?append code\b[:\-]?\s*/i }, - { mode: 'append_code', regex: /^\s*(?:\/)?add code block\b[:\-]?\s*/i }, - ]; - - for (const command of commands) { - if (command.regex.test(text)) { - return { - mode: command.mode, - message: text.replace(command.regex, '').trim() - }; - } - } - - return null; - }; - const getSlashOptions = (query) => { - const options = [ - { - id: 'add-below', - label: 'add below', - sublabel: 'Insert a new paragraph below the target block', - insertText: 'add below @' - }, - { - id: 'add-above', - label: 'add above', - sublabel: 'Insert a new paragraph above the target block', - insertText: 'add above @' - }, - { - id: 'append-code-block', - label: 'append code block', - sublabel: 'Insert a code block below the target block', - insertText: 'append code block @' - }, - { - id: 'reformat', - label: 'reformat', - sublabel: 'Convert markdown-like text into blocks', - insertText: 'reformat @' - }, - ]; - - if (!query) { - return options; - } - - const queryLower = query.toLowerCase(); - return options.filter((option) => option.label.includes(queryLower)); - }; - const getBlockIndex = (clientId) => { - const blockIndex = select('core/block-editor').getBlockIndex - ? select('core/block-editor').getBlockIndex(clientId) - : -1; - if (blockIndex !== -1) { - return blockIndex; - } - - const allBlocks = select('core/block-editor').getBlocks(); - return allBlocks.findIndex((block) => block.clientId === clientId); - }; - const resolveTargetBlockId = (mentionTokens) => { - if (mentionTokens.length > 0) { - const resolved = resolveBlockMentions(mentionTokens); - if (resolved.length > 0) { - return resolved[0]; - } - } - - const selectedBlockId = select('core/block-editor').getSelectedBlockClientId(); - if (selectedBlockId) { - return selectedBlockId; - } - - const allBlocks = select('core/block-editor').getBlocks(); - return allBlocks.length > 0 ? allBlocks[allBlocks.length - 1].clientId : null; - }; - const insertRefinementBlock = async (mode, message, mentionTokens, originalMessage) => { - const initialTargetBlockId = resolveTargetBlockId(mentionTokens); - const initialTargetBlock = initialTargetBlockId - ? select('core/block-editor').getBlock(initialTargetBlockId) - : null; - const listParentId = initialTargetBlock?.name === 'core/list-item' - ? getParentListId(initialTargetBlockId) - : null; - const targetBlockId = listParentId || initialTargetBlockId; - if (!targetBlockId) { - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'No target block found. Select a block or mention one with @paragraph-1.' - }]); - setIsLoading(false); - return; - } - - const insertIndexBase = getBlockIndex(targetBlockId); - const insertIndex = insertIndexBase === -1 - ? undefined - : insertIndexBase + (mode === 'add_above' ? 0 : 1); - const { insertBlocks } = dispatch('core/block-editor'); - const blockType = mode === 'append_code' ? 'core/code' : 'core/paragraph'; - const newBlock = wp.blocks.createBlock( - blockType, - mode === 'append_code' ? { content: '', language: 'text' } : { content: '' } - ); - - insertBlocks(newBlock, insertIndex); - - let refinementMessage = stripMentionsFromText(message); - - if (initialTargetBlock?.name === 'core/list-item') { - const listItemText = extractBlockPreview(initialTargetBlock); - if (listItemText) { - refinementMessage = refinementMessage - ? `${refinementMessage}\n\nAdd a short description for: "${listItemText}".` - : `Add a short description for: "${listItemText}".`; - } - } - - const contextSnippets = getContextFromMentions(mentionTokens, initialTargetBlockId); - if (!contextSnippets.length) { - const headingContext = getHeadingContextForBlock(targetBlockId); - if (headingContext) { - contextSnippets.push(`Heading: ${headingContext}`); - } - getNearbyParagraphContext(targetBlockId, 2).forEach((snippet, index) => { - contextSnippets.push(`Paragraph ${index + 1}: ${snippet}`); - }); - } - - if (contextSnippets.length) { - refinementMessage = `${refinementMessage}\n\nContext snippets:\n${contextSnippets.map((snippet) => `- ${snippet}`).join('\n')}`; - } - - const requestedBlockType = blockType; - refinementMessage = `${refinementMessage}\n\nReturn only JSON: {"content":"...","blockType":"${requestedBlockType}"} with no extra text.`; - - if (mode === 'append_code') { - refinementMessage += ' Put the code in "content" only, no backticks.'; - } - - setInput(''); - setMessages(prev => [...prev, { role: 'user', content: originalMessage }]); - await handleChatRefinement( - refinementMessage, - [newBlock.clientId], - { skipUserMessage: true, useDiffPlan: false } - ); - }; - const streamGeneratePlan = async (request, options = {}) => { - const { resume = false } = options; - const normalizedRequest = { ...request, postConfig: postConfig, chatHistory: buildChatHistoryPayload() }; - lastGenerationRequestRef.current = normalizedRequest; - setIsLoading(true); - - // Capture snapshot before generation (only if not resuming) - if (!resume) { - pushUndoSnapshot('Article Generation'); - } - - try { - const response = await fetch(wpAgenticWriter.apiUrl + '/generate-plan', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - body: JSON.stringify({ ...normalizedRequest, resume: resume }), - }); - - if (!response.ok) { - const error = await response.json(); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: formatAiErrorMessage(error, 'Failed to generate article'), - canRetry: true, - retryType: 'generation' - }]); - return; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - const timeout = setTimeout(() => { - if (isLoading) { - wpawLog.error('Generation timeout - no response received'); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: formatAiErrorMessage('cURL error 28: Operation timed out after 120000 milliseconds', 'Failed to generate article'), - canRetry: true, - retryType: 'generation' - }]); - setIsLoading(false); - reader.cancel(); - } - }, 120000); - - 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({ ...cost, session: cost.session + data.cost }); - if (agentMode === 'planning' && data.plan) { - updateOrCreatePlanMessage(data.plan); - } - } else if (data.type === 'title_update') { - dispatch('core/editor').editPost({ title: data.title }); - } 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; - }); - } else if (data.type === 'conversational' || data.type === 'conversational_stream') { - const cleanContent = (data.content || '') - .replace(/~~~ARTICLE~+/g, '') - .replace(/~~~ARTICLE~~~[\r\n]*/g, '') - .trim(); - - if (!cleanContent || shouldSkipPlanningCompletion(cleanContent)) { - continue; - } - - const streamTarget = streamTargetRef.current || resolveStreamTarget(cleanContent); - if (!streamTarget) { - continue; - } - - streamTargetRef.current = streamTarget; - - if (streamTarget === 'timeline') { - updateOrCreateTimelineEntry(cleanContent); - } else if (data.type === 'conversational') { - setMessages(prev => [...prev, { role: 'assistant', content: cleanContent }]); - } else { - setMessages(prev => { - const newMessages = [...prev]; - const lastIdx = newMessages.length - 1; - if (newMessages[lastIdx] && newMessages[lastIdx].role === 'assistant') { - newMessages[lastIdx] = { ...newMessages[lastIdx], content: cleanContent }; - } else { - newMessages.push({ role: 'assistant', content: cleanContent }); - } - return newMessages; - }); - } - } else if (data.type === 'block') { - const { insertBlocks } = dispatch('core/block-editor'); - let newBlock; - - if (data.block.blockName === 'core/paragraph') { - const content = data.block.innerHTML?.match(/

(.*?)<\/p>/)?.[1] || ''; - newBlock = wp.blocks.createBlock('core/paragraph', { content: content }); - } else if (data.block.blockName === 'core/heading') { - const level = data.block.attrs?.level || 2; - const content = data.block.innerHTML?.match(/(.*?)<\/h[1-6]>/)?.[1] || ''; - newBlock = wp.blocks.createBlock('core/heading', { level: level, content: content }); - } else if (data.block.blockName === 'core/list') { - const listItems = (data.block.innerBlocks || []).map(item => { - const content = item.innerHTML?.match(/

  • (.*?)<\/li>/)?.[1] || ''; - return wp.blocks.createBlock('core/list-item', { content: content }); - }); - newBlock = wp.blocks.createBlock('core/list', { - ...(data.block.attrs || {}), - ordered: data.block.attrs?.ordered || false - }, listItems); - } else if (data.block.blockName === 'core/quote') { - const content = data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ''; - newBlock = wp.blocks.createBlock('core/quote', { value: content }); - } else if (data.block.blockName === 'core/image') { - newBlock = wp.blocks.createBlock('core/image', data.block.attrs || {}); - } else if (data.block.blockName === 'core/code') { - newBlock = wp.blocks.createBlock('core/code', data.block.attrs || {}); - } - - if (newBlock) { - insertBlocks(newBlock); - } - } else if (data.type === 'complete') { - applyProviderMetadata(data); - clearTimeout(timeout); - setCost({ ...cost, session: cost.session + data.totalCost }); - - setMessages(prev => { - const newMessages = [...prev]; - const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); - if (lastTimelineIndex !== -1) { - newMessages[lastTimelineIndex] = { - ...newMessages[lastTimelineIndex], - status: 'complete', - message: agentMode === 'planning' ? 'Outline ready.' : 'Article generated successfully!', - completedAt: new Date() - }; - } - return newMessages; - }); - } else if (data.type === 'error') { - clearTimeout(timeout); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: formatAiErrorMessage(data.message || 'An error occurred during article generation', 'Failed to generate article'), - canRetry: true, - retryType: 'generation' - }]); - } - } catch (parseError) { - wpawLog.error('Failed to parse streaming data:', line, parseError); - } - } - } - } - - clearTimeout(timeout); - } catch (error) { - wpawLog.error('Article generation error:', error); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: formatAiErrorMessage(error, 'Failed to generate article'), - canRetry: true, - retryType: 'generation' - }]); - } finally { - setIsLoading(false); - } - }; - const retryLastGeneration = () => { - if (!lastGenerationRequestRef.current) { - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'Cannot retry because the original generation request is no longer available. Please send the request again.', - }]); - return; - } - setMessages(prev => [...deactivateActiveTimelineEntries(prev), { - role: 'system', - type: 'timeline', - status: 'starting', - message: 'Resuming generation...', - timestamp: new Date() - }]); - streamGeneratePlan(lastGenerationRequestRef.current, { resume: true }); - }; - const retryLastExecute = () => { - if (!lastExecuteRequestRef.current) { - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'Cannot retry because the original writing request is no longer available. Please start writing again.', - }]); - return; - } - executePlanFromCard({ retry: true }); - }; - const retryLastRefinement = () => { - if (!lastRefineRequestRef.current) { - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'Cannot retry because the original refinement request is no longer available. Please send the refinement again.', - }]); - return; - } - setMessages(prev => [...deactivateActiveTimelineEntries(prev), { - role: 'system', - type: 'timeline', - status: 'starting', - message: 'Retrying refinement...', - timestamp: new Date() - }]); - handleChatRefinement( - lastRefineRequestRef.current.message, - lastRefineRequestRef.current.blocksOverride, - lastRefineRequestRef.current.options - ); - }; - const retryLastChat = async () => { - if (!lastChatRequestRef.current) { - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'Cannot retry because the original chat request is no longer available. Please send the message again.', - }]); - return; - } - const userMessage = lastChatRequestRef.current.message; - - // Remove the last error message - setMessages(prev => prev.filter(m => !(m.type === 'error' && m.retryType === 'chat'))); - setIsLoading(true); - - try { - const chatHistory = messages - .filter((m) => m.role === 'user' || m.role === 'assistant') - .map((m) => ({ role: m.role, content: m.content })); - - const response = await fetch(wpAgenticWriter.apiUrl + '/chat', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - body: JSON.stringify({ - messages: [...chatHistory, { role: 'user', content: userMessage }], - postId: postId, - sessionId: currentSessionId, - type: 'chat', - stream: true, - postConfig: postConfig, - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.message || 'Failed to chat'); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let streamBuffer = ''; - let fullContent = ''; - let streamError = null; - let lastDataTime = Date.now(); - let heartbeatShown = false; - - // Heartbeat: show reassurance if no data for 30s - const heartbeatInterval = setInterval(() => { - if (Date.now() - lastDataTime > 30000 && !heartbeatShown) { - heartbeatShown = true; - setMessages(prev => [...prev, { - role: 'system', - type: 'timeline', - status: 'active', - message: '⏳ Still waiting for response — the model is processing...', - timestamp: new Date() - }]); - } - }, 10000); - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - lastDataTime = Date.now(); - heartbeatShown = false; - - streamBuffer += decoder.decode(value, { stream: true }); - const lines = streamBuffer.split('\n'); - streamBuffer = lines.pop() || ''; - - for (const line of lines) { - if (!line.startsWith('data: ')) continue; - try { - const data = JSON.parse(line.slice(6)); - if (data.type === 'error') { - streamError = new Error(data.message || 'Chat error'); - break; - } - if (data.type === 'conversational_stream' || data.type === 'conversational') { - fullContent = data.content; - setMessages(prev => { - const lastMsg = prev[prev.length - 1]; - if (lastMsg && lastMsg.role === 'assistant' && lastMsg.isStreaming) { - return [...prev.slice(0, -1), { ...lastMsg, content: fullContent }]; - } - return [...prev, { role: 'assistant', content: fullContent, isStreaming: true }]; - }); - } else if (data.type === 'complete') { - // Apply provider metadata from completion. - applyProviderMetadata(data); - - setMessages(prev => { - const lastMsg = prev[prev.length - 1]; - if (lastMsg && lastMsg.role === 'assistant') { - return [...prev.slice(0, -1), { ...lastMsg, isStreaming: false }]; - } - return prev; - }); - // Extract ALL focus keyword suggestions from completed response. - if (fullContent) { - const suggestions = extractFocusKeywordSuggestions(fullContent); - if (suggestions.length > 0) { - addFocusKeywordSuggestions(suggestions); - } - } - } else if (data.type === 'provider' && data.fallback_used) { - // Show in-chat provider fallback warning - setMessages(prev => [...prev, { - role: 'system', - type: 'timeline', - status: 'active', - message: `⚠️ ${data.selectedProvider || 'Selected provider'} unavailable — using ${data.provider || 'fallback'}`, - timestamp: new Date() - }]); - } - } catch (e) { - wpawLog.error('Failed to parse retry streaming data:', line, e); - } - } - - if (streamError) { - throw streamError; - } - } - } finally { - clearInterval(heartbeatInterval); - } - } catch (error) { - const errorMsg = formatAiErrorMessage(error, 'Failed to chat'); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: errorMsg, - canRetry: true, - retryType: 'chat' - }]); - } finally { - setIsLoading(false); - } - }; - const createBlockFromPlan = (action) => { - const blockType = action.blockType || 'core/paragraph'; - const content = action.content || ''; - - if (blockType === 'core/image') { - const match = content.match(/^!\[(.*?)\]\(([^)\s]+)(?:\s+"[^"]*")?\)\s*$/); - const alt = match ? match[1] : ''; - const url = match ? match[2] : ''; - return wp.blocks.createBlock('core/image', { - id: 0, - url: url, - alt: alt, - caption: '', - sizeSlug: 'large', - linkDestination: 'none' - }); - } - - if (blockType === 'core/heading') { - return wp.blocks.createBlock('core/heading', { level: action.level || 2, content: content }); - } - - if (blockType === 'core/list') { - const items = content.split('\n').map((line) => line.trim()).filter(Boolean); - const listItems = items.map((item) => wp.blocks.createBlock('core/list-item', { content: item })); - return wp.blocks.createBlock('core/list', { - ordered: action.ordered || false, - ...(action.start ? { start: parseInt(action.start, 10) } : {}) - }, listItems); - } - - if (blockType === 'core/code') { - return wp.blocks.createBlock('core/code', { content: content, language: action.language || 'text' }); - } - - return wp.blocks.createBlock(blockType, { content: content }); - }; - const normalizePlanActions = (plan) => { - if (!plan || !plan.actions) { - return []; - } - if (Array.isArray(plan.actions)) { - return plan.actions; - } - return Object.values(plan.actions); - }; - const buildPlanPreviewItem = (action, index) => { - if (!action || !action.action) { - return { title: 'Unknown action' }; - } - - const type = action.blockType ? ` (${action.blockType.replace('core/', '')})` : ''; - const content = (action.content || '').replace(/\s+/g, ' ').trim(); - const contentPreview = content ? `"${content.substring(0, 80)}${content.length > 80 ? '...' : ''}"` : ''; - const before = getBlockPreviewById(action.blockId); - const beforePreview = before ? `"${before.substring(0, 80)}${before.length > 80 ? '...' : ''}"` : ''; - const targetLabel = before ? ` "${before.substring(0, 40)}${before.length > 40 ? '...' : ''}"` : ''; - const targetPreview = beforePreview || '"Target block not found"'; - const blockId = action.blockId || null; - - switch (action.action) { - case 'keep': - return { title: 'Keep' }; - case 'delete': - return { - title: `Delete${targetLabel}`, - target: targetPreview, - targetLabel: 'Target', - blockId, - }; - case 'replace': - return { - title: `Replace${targetLabel}${type}`, - before: beforePreview, - after: contentPreview, - blockId, - }; - case 'change_type': - return { - title: `Change type${targetLabel}${type}`, - before: beforePreview, - after: contentPreview, - blockId, - }; - case 'insert_before': - return { - title: `Insert before${targetLabel}${type}`, - target: targetPreview, - targetLabel: 'Target', - after: contentPreview, - blockId, - }; - case 'insert_after': - return { - title: `Insert after${targetLabel}${type}`, - target: targetPreview, - targetLabel: 'Target', - after: contentPreview, - blockId, - }; - default: - return { - title: `${action.action}${targetLabel}${type}`, - after: contentPreview, - blockId, - }; - } - }; - const normalizePlanSectionTitle = (section) => { - const heading = (section?.heading || section?.title || '').toString(); - return heading.replace(/<[^>]+>/g, '').trim().toLowerCase(); - }; - const upsertSectionBlock = (sectionId, blockId) => { - if (!sectionId || !blockId) { - return; - } - - const sectionMap = sectionBlocksRef.current[sectionId] || []; - if (!sectionMap.includes(blockId)) { - sectionBlocksRef.current[sectionId] = [...sectionMap, blockId]; - } - blockSectionRef.current[blockId] = sectionId; - }; - const removeSectionBlock = (sectionId, blockId) => { - if (!sectionId || !blockId) { - return; - } - const sectionMap = sectionBlocksRef.current[sectionId] || []; - sectionBlocksRef.current[sectionId] = sectionMap.filter((id) => id !== blockId); - delete blockSectionRef.current[blockId]; - }; - const loadSectionBlocks = async () => { - if (!postId) { - return; - } - try { - const response = await fetch(`${wpAgenticWriter.apiUrl}/section-blocks/${postId}`, { - method: 'GET', - headers: { - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - }); - - if (!response.ok) { - return; - } - - const data = await response.json(); - if (data && data.sectionBlocks && typeof data.sectionBlocks === 'object') { - sectionBlocksRef.current = data.sectionBlocks; - blockSectionRef.current = {}; - Object.entries(data.sectionBlocks).forEach(([sectionId, blockIds]) => { - if (Array.isArray(blockIds)) { - blockIds.forEach((blockId) => { - blockSectionRef.current[blockId] = sectionId; - }); - } - }); - } - } catch (error) { - // Ignore load failures for section mapping. - } - }; - const saveSectionBlocks = async (sectionId) => { - if (!sectionId || !postId) { - return; - } - const blockIds = sectionBlocksRef.current[sectionId] || []; - try { - await fetch(`${wpAgenticWriter.apiUrl}/section-blocks`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - body: JSON.stringify({ - postId: postId, - sessionId: currentSessionId, - sectionId: sectionId, - blockIds: blockIds, - }), - }); - } catch (error) { - // Ignore save failures for section mapping. - } - }; - const ensurePlanTasks = (plan) => { - if (!plan || !Array.isArray(plan.sections)) { - return plan; - } - - const nextSections = plan.sections.map((section, index) => { - const id = section?.id || `section-${index + 1}`; - const status = section?.status || 'pending'; - return { ...section, id, status }; - }); - - return { ...plan, sections: nextSections }; - }; - const getTargetedRefinementBlocks = (message) => { - if (!message) { - return null; - } - const codeKeywords = /(kode|coding|code|script|snippet|skrip)/i; - if (!codeKeywords.test(message)) { - return null; - } - const allBlocks = select('core/block-editor').getBlocks(); - const codeBlocks = allBlocks.filter((block) => block.name === 'core/code'); - if (codeBlocks.length === 0) { - return null; - } - const affectedSections = new Set(); - codeBlocks.forEach((block) => { - const sectionId = blockSectionRef.current[block.clientId]; - if (sectionId) { - affectedSections.add(sectionId); - } - }); - if (affectedSections.size === 0) { - return null; - } - const targetIds = []; - affectedSections.forEach((sectionId) => { - const blockIds = sectionBlocksRef.current[sectionId] || []; - blockIds.forEach((blockId) => { - targetIds.push(blockId); - }); - }); - return [...new Set(targetIds)]; - }; - const findBestPlanSectionMatch = (message) => { - const plan = currentPlanRef.current; - if (!plan || !Array.isArray(plan.sections) || !message) { - return null; - } - - const stopwords = new Set([ - 'dalam', 'poin', 'bagian', 'yang', 'dan', 'atau', 'untuk', 'dengan', 'ada', 'tidak', - 'lebih', 'ini', 'itu', 'seperti', 'agar', 'akan', 'jadi', 'fokus', 'tulis', 'ulang', - 'hapus', 'tambahkan', 'pembahasan', 'pada', 'berikan', 'gunakan', 'jelaskan', 'buat', - ]); - const tokens = message - .toLowerCase() - .replace(/[^a-z0-9\s]/g, ' ') - .split(/\s+/) - .filter((token) => token.length > 3 && !stopwords.has(token)); - - if (tokens.length === 0) { - return null; - } - - let best = null; - let bestScore = 0; - - plan.sections.forEach((section) => { - const sectionText = [ - section?.heading, - section?.title, - section?.description, - Array.isArray(section?.content) && section.content.length > 0 ? section.content[0]?.content : '', - ].filter(Boolean).join(' ').toLowerCase(); - - if (!sectionText) { - return; - } - - let score = 0; - tokens.forEach((token) => { - if (sectionText.includes(token)) { - score += 1; - } - }); - - if (score > bestScore) { - bestScore = score; - best = section; - } - }); - - if (!best || bestScore < 2) { - return null; - } - - return best; - }; - const updatePlanSectionStatus = (sectionId, status) => { - if (!sectionId) { - return; - } - setMessages(prev => { - const newMessages = [...prev]; - for (let i = newMessages.length - 1; i >= 0; i--) { - if (newMessages[i].type === 'plan' && newMessages[i].plan?.sections) { - const sections = newMessages[i].plan.sections.map((section) => { - if (section.id === sectionId) { - return { ...section, status: status }; - } - return section; - }); - const plan = { ...newMessages[i].plan, sections }; - newMessages[i] = { ...newMessages[i], plan }; - currentPlanRef.current = plan; - break; - } - } - return newMessages; - }); - }; - const findSectionInsertIndex = (plan, sectionId) => { - const allBlocks = select('core/block-editor').getBlocks(); - if (!plan || !Array.isArray(plan.sections) || !sectionId) { - return allBlocks.length; - } - - const sections = plan.sections; - const sectionIndex = sections.findIndex((section) => section.id === sectionId); - if (sectionIndex === -1) { - return allBlocks.length; - } - - for (let i = sectionIndex + 1; i < sections.length; i++) { - const nextSection = sections[i]; - const nextStatus = nextSection?.status || 'pending'; - if (nextStatus !== 'done') { - continue; - } - const nextHeading = normalizePlanSectionTitle(nextSection); - if (!nextHeading) { - continue; - } - const anchorIndex = allBlocks.findIndex((block) => { - if (block.name !== 'core/heading') { - return false; - } - const content = normalizePlanSectionTitle({ heading: block.attributes?.content }); - return content === nextHeading; - }); - if (anchorIndex !== -1) { - return anchorIndex; - } - } - - return allBlocks.length; - }; - - // Check if Writing mode needs empty state - const shouldShowWritingEmptyState = () => { - if (agentMode !== 'writing') return false; - if (currentPlanRef.current) return false; - - // Check if editor has content blocks - const allBlocks = select('core/block-editor').getBlocks(); - const hasContent = allBlocks.length > 0; - - // Only show empty state if no plan AND no content in editor - return !hasContent; - }; - - // Summarize chat history for token optimization - const summarizeChatHistory = async () => { - const chatMessages = messages.filter(m => m.role !== 'system'); - - if (chatMessages.length < 4) { - return { summary: '', useFullHistory: true, cost: 0 }; - } - - try { - const response = await fetch(wpAgenticWriter.apiUrl + '/summarize-context', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - body: JSON.stringify({ - chatHistory: chatMessages, - postId: postId, - sessionId: currentSessionId, - }), - }); - - if (!response.ok) { - throw new Error('Summarization failed'); - } - - const data = await response.json(); - applyProviderMetadata(data); - - if (data.tokens_saved > 0) { - wpawLog.log(`Context optimized: ~${data.tokens_saved} tokens saved (~$${(data.tokens_saved * 0.0000002).toFixed(4)})`); - } - - return { - summary: data.summary || '', - useFullHistory: data.use_full_history || false, - cost: data.cost || 0, - tokensSaved: data.tokens_saved || 0, - }; - } catch (error) { - wpawLog.error('Summarization error:', error); - return { summary: '', useFullHistory: true, cost: 0 }; - } - }; - - // Detect user intent for contextual actions - const detectUserIntent = async (lastMessage) => { - if (!lastMessage || lastMessage.trim().length === 0) { - return { intent: 'continue_chat', cost: 0 }; - } - - try { - const response = await fetch(wpAgenticWriter.apiUrl + '/detect-intent', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - body: JSON.stringify({ - lastMessage: lastMessage, - hasPlan: Boolean(currentPlanRef.current), - currentMode: agentMode, - postId: postId, - sessionId: currentSessionId, - }), - }); - - if (!response.ok) { - let message = 'Intent detection failed'; - try { - const error = await response.json(); - message = error?.message || message; - } catch (parseError) { - // Keep the fallback message if the error response is not JSON. - } - throw new Error(message); - } - - const data = await response.json(); - applyProviderMetadata(data); - return { - intent: data.intent || 'continue_chat', - cost: data.cost || 0, - }; - } catch (error) { - wpawLog.error('Intent detection error:', formatAiErrorMessage(error, 'Intent detection failed')); - return { intent: 'continue_chat', cost: 0 }; - } - }; - - // Build optimized context (full or summarized) - const buildOptimizedContext = async () => { - const result = await summarizeChatHistory(); - - if (result.useFullHistory) { - return { - type: 'full', - messages: messages.filter(m => m.role !== 'system'), - cost: 0, - }; - } - - return { - type: 'summary', - summary: result.summary, - cost: result.cost, - tokensSaved: result.tokensSaved, - }; - }; - - // Handle reset/clear command - const handleResetCommand = async () => { - if (!confirm('Clear all conversation history? This cannot be undone.')) { - return; - } - - try { - // Clear frontend state - setMessages([]); - currentPlanRef.current = null; - - // Clear backend chat history - await fetch(wpAgenticWriter.apiUrl + '/clear-context', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - body: JSON.stringify({ postId: postId }), - }); - - setMessages([{ - role: 'system', - type: 'info', - content: '✅ Context cleared. Starting fresh conversation.' - }]); - } catch (error) { - wpawLog.error('Reset error:', error); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'Failed to clear context. Please try again.' - }]); - } - }; - - const updateOrCreatePlanMessage = (plan, options = {}) => { - const { append = false } = options; - const normalizedPlan = ensurePlanTasks(plan); - currentPlanRef.current = normalizedPlan; - setMessages((prev) => { - const newMessages = [...prev]; - if (!append) { - for (let i = newMessages.length - 1; i >= 0; i--) { - if (newMessages[i].type === 'plan') { - newMessages[i] = { ...newMessages[i], plan: normalizedPlan }; - return newMessages; - } - } - } - newMessages.push({ role: 'assistant', type: 'plan', plan: normalizedPlan }); - return newMessages; - }); - - // Auto-suggest keywords after outline is generated - if (agentMode === 'planning' && normalizedPlan) { - suggestKeywordsFromPlan(normalizedPlan); - } - }; - - const suggestKeywordsFromPlan = async (plan) => { - if (!plan || !plan.title || !plan.sections) { - return; - } - - try { - const response = await fetch(wpAgenticWriter.apiUrl + '/suggest-keywords', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - body: JSON.stringify({ - postId: postId, - sessionId: currentSessionId, - title: plan.title, - sections: plan.sections, - }), - }); - - if (!response.ok) { - throw new Error('Failed to suggest keywords'); - } - - const data = await response.json(); - - // Update post config with suggested keywords - if (data.focus_keyword) { - updatePostConfig('seo_focus_keyword', data.focus_keyword); - } - if (data.secondary_keywords && Array.isArray(data.secondary_keywords)) { - updatePostConfig('seo_secondary_keywords', data.secondary_keywords.join(', ')); - } - - // Track cost and apply provider metadata - if (data.cost) { - setCost({ ...cost, session: cost.session + data.cost }); - } - applyProviderMetadata(data); - - // Add assistant message about keyword suggestions - setMessages(prev => [...prev, { - role: 'assistant', - content: `🎯 **SEO Keywords Suggested:**\n\n**Focus Keyword:** ${data.focus_keyword}\n\n**Secondary Keywords:** ${data.secondary_keywords.join(', ')}\n\n${data.reasoning || ''}\n\nYou can review and edit these in the Config panel before writing.` - }]); - } catch (error) { - wpawLog.error('Keyword suggestion error:', error); - // Silently fail - don't interrupt the workflow - } - }; - - const buildChatHistoryPayload = React.useCallback(() => { - return messages - .filter((m) => (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string' && m.content.trim()) - .filter((m) => m.type !== 'plan') - .map((m) => ({ role: m.role, content: m.content.trim().slice(0, 2000) })) - .slice(-10); - }, [messages]); - - const getLastUserMessageText = React.useCallback(() => { - for (let i = messages.length - 1; i >= 0; i -= 1) { - const m = messages[i]; - if (m?.role === 'user' && typeof m.content === 'string' && m.content.trim()) { - return m.content.trim(); - } - } - return ''; - }, [messages]); - - const shouldSkipPlanningCompletion = (content) => { - if (agentMode !== 'planning') { - return false; - } - - const text = String(content || '').toLowerCase(); - return text.includes('article generation complete') - || text.includes('content has been added to your editor') - || text.includes('article generated successfully'); - }; - const executePlanFromCard = async (options = {}) => { - if (isLoading) { - return; - } - - // Check if plan exists - if (!currentPlanRef.current) { - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: '⚠️ No outline found. Please create an outline first by switching to Planning mode.' - }]); - setIsLoading(false); - return; - } - - const plan = currentPlanRef.current; - - // Confirmation: warn if editor already has content blocks - const existingBlocks = select('core/block-editor').getBlocks(); - const hasExistingContent = existingBlocks.some(b => - b.name !== 'core/paragraph' || (b.attributes?.content && b.attributes.content.trim().length > 0) - ); - if (hasExistingContent && !options.skipConfirm) { - const pendingSections = Array.isArray(plan?.sections) - ? plan.sections.filter((section) => section.status !== 'done').length - : 0; - const confirmed = window.confirm( - `This will write ${pendingSections} sections into the editor. Existing content will be preserved below the new content.\n\nContinue?` - ); - if (!confirmed) { - return; - } - } - - setAgentMode('writing'); - const pendingCount = Array.isArray(plan?.sections) - ? plan.sections.filter((section) => section.status !== 'done').length - : null; - if (pendingCount === 0) { - setMessages(prev => [...deactivateActiveTimelineEntries(prev), { - role: 'system', - type: 'timeline', - status: 'complete', - message: 'All outline items are already written.', - timestamp: new Date() - }]); - setAgentMode('chat'); - return; - } - - const { retry = false } = options; - lastExecuteRequestRef.current = { - postId: postId, - sessionId: currentSessionId, - stream: true, - postConfig: postConfig, - detectedLanguage: detectedLanguage, - chatHistory: messages.filter(m => m.role !== 'system'), - }; - - // Reset stop flag - stopExecutionRef.current = false; - setExecutionStopped(false); - - setIsLoading(true); - setMessages(prev => [...deactivateActiveTimelineEntries(prev), { - role: 'system', - type: 'timeline', - status: 'writing', - message: retry ? 'Retrying outline...' : 'Writing from outline...', - timestamp: new Date() - }]); - sectionInsertIndexRef.current = {}; - activeSectionIdRef.current = null; - - try { - const response = await fetch(wpAgenticWriter.apiUrl + '/execute-article', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - body: JSON.stringify(lastExecuteRequestRef.current), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.message || 'Failed to execute outline'); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let streamBuffer = ''; - const timeout = setTimeout(() => { - if (isLoading) { - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'Request timeout. The AI is taking too long to respond. Please try again.' - }]); - setIsLoading(false); - reader.cancel(); - } - }, 120000); - - while (true) { - // Check if execution should stop - if (stopExecutionRef.current) { - reader.cancel(); - clearTimeout(timeout); - setExecutionStopped(true); - setIsLoading(false); - - // Calculate completed sections - const plan = currentPlanRef.current; - const completedCount = plan?.sections?.filter(s => s.status === 'done').length || 0; - const totalCount = plan?.sections?.length || 0; - const pendingCount = totalCount - completedCount; - - setMessages(prev => [...deactivateActiveTimelineEntries(prev), - { - role: 'system', - type: 'timeline', - status: 'stopped', - message: `⏸️ Execution stopped (${completedCount}/${totalCount} sections completed)`, - timestamp: new Date() - }, - { - role: 'assistant', - content: `**Execution Paused**\n\n✅ Completed: ${completedCount} section${completedCount !== 1 ? 's' : ''}\n⏳ Pending: ${pendingCount} section${pendingCount !== 1 ? 's' : ''}\n\nYour generated content has been preserved in the editor.`, - showResumeActions: true, - pendingCount: pendingCount - } - ]); - break; - } - - const { done, value } = await reader.read(); - if (done) break; - - streamBuffer += decoder.decode(value, { stream: true }); - const lines = streamBuffer.split('\n'); - streamBuffer = lines.pop() || ''; - - for (const line of lines) { - if (!line.startsWith('data: ')) { - continue; - } - - try { - const data = JSON.parse(line.slice(6)); - if (data.type === 'title_update') { - dispatch('core/editor').editPost({ title: data.title }); - } else if (data.type === 'section_start') { - activeSectionIdRef.current = data.sectionId || null; - const insertIndex = findSectionInsertIndex(currentPlanRef.current, data.sectionId); - if (data.sectionId) { - sectionInsertIndexRef.current[data.sectionId] = insertIndex; - sectionBlocksRef.current[data.sectionId] = sectionBlocksRef.current[data.sectionId] || []; - } - updatePlanSectionStatus(data.sectionId, 'in_progress'); - } 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; - }); - } else if (data.type === 'block') { - const { insertBlocks } = dispatch('core/block-editor'); - const newBlock = createBlocksFromSerialized(data.block); - if (newBlock) { - const sectionId = data.sectionId || activeSectionIdRef.current; - const insertIndex = sectionId ? sectionInsertIndexRef.current[sectionId] : undefined; - if (typeof insertIndex === 'number') { - insertBlocks(newBlock, insertIndex); - sectionInsertIndexRef.current[sectionId] = insertIndex + 1; - } else { - insertBlocks(newBlock); - } - if (sectionId) { - upsertSectionBlock(sectionId, newBlock.clientId); - } - } - } else if (data.type === 'section_complete') { - updatePlanSectionStatus(data.sectionId, 'done'); - saveSectionBlocks(data.sectionId); - // Check if execution should stop after section completes - if (stopExecutionRef.current) { - reader.cancel(); - clearTimeout(timeout); - setExecutionStopped(true); - setIsLoading(false); - setMessages(prev => [...deactivateActiveTimelineEntries(prev), { - role: 'system', - type: 'timeline', - status: 'stopped', - message: '⏸️ Execution stopped by user', - timestamp: new Date() - }]); - break; - } - } else if (data.type === 'assistant_message') { - // Add assistant message to chat - setMessages(prev => [...prev, { role: 'assistant', content: data.message }]); - } else if (data.type === 'complete') { - clearTimeout(timeout); - if (data.totalCost) { - setCost({ ...cost, session: cost.session + data.totalCost }); - } - applyProviderMetadata(data); - setMessages(prev => { - const newMessages = [...prev]; - const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); - if (lastTimelineIndex !== -1) { - newMessages[lastTimelineIndex] = { - ...newMessages[lastTimelineIndex], - status: 'complete', - message: 'Article generated successfully!', - completedAt: new Date() - }; - } - return newMessages; - }); - setAgentMode('chat'); - setIsLoading(false); - } else if (data.type === 'error') { - clearTimeout(timeout); - throw new Error(data.message || 'Failed to execute outline'); - } - } catch (parseError) { - wpawLog.error('Failed to parse streaming data:', line, parseError); - } - } - } - clearTimeout(timeout); - // If stream ended without a 'complete' event, deactivate lingering timeline entries - setMessages(prev => { - const hasActive = prev.some(m => m.type === 'timeline' && m.status && !['complete', 'inactive', 'stopped'].includes(m.status)); - if (hasActive) { - return deactivateActiveTimelineEntries(prev); - } - return prev; - }); - } catch (error) { - setAgentMode(currentPlanRef.current ? 'planning' : 'chat'); - setMessages(prev => [...deactivateActiveTimelineEntries(prev), { - role: 'system', - type: 'error', - content: formatAiErrorMessage(error, 'Failed to execute outline'), - canRetry: true, - retryType: 'execute', - }]); - } finally { - setIsLoading(false); - } - }; - - const handleStopExecution = () => { - if (!isLoading) return; - - stopExecutionRef.current = true; - }; - - const clearChatContext = async () => { - if (isLoading) { - return; - } - - const confirmMessage = 'Start a new conversation for this post? Your current conversation history will be kept and you can return to it later.'; - if (!window.confirm(confirmMessage)) { - return; - } - - try { - setIsSessionActionLoading(true); - if (currentSessionId) { - await fetch(`${wpAgenticWriter.apiUrl}/conversations/${currentSessionId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - body: JSON.stringify({ status: 'completed' }), - }); - } - const response = await fetch(wpAgenticWriter.apiUrl + '/conversations', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - body: JSON.stringify({ post_id: postId }), - }); - if (!response.ok) { - throw new Error('Failed to create a new conversation'); - } - const data = await response.json(); - if (data?.session_id) { - setCurrentSessionId(data.session_id); - } - await loadPostSessions(); - setMessages([]); - setInClarification(false); - setQuestions([]); - setCurrentQuestionIndex(0); - setAnswers([]); - setPendingRefinement(null); - setPendingEditPlan(null); - streamTargetRef.current = null; - } catch (error) { - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'Error: Failed to start a new conversation.', - }]); - } finally { - setIsSessionActionLoading(false); - } - }; - const createBlocksFromSerialized = (block) => { - if (!block || !block.blockName) { - return null; - } - - const attrs = { ...(block.attrs || {}) }; - - // Handle code blocks - if (block.blockName === 'core/code' && !attrs.content && block.innerHTML) { - const match = block.innerHTML.match(/([\s\S]*?)<\/code>/i); - if (match && match[1]) { - attrs.content = match[1] - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/g, '&') - .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); - const bodyMatch = block.innerHTML.match(/([\s\S]*?)<\/tbody>/i); - if (headMatch || bodyMatch) { - attrs.head = []; - attrs.body = []; - - // Parse thead rows - if (headMatch) { - const headRows = headMatch[1].match(/([\s\S]*?)<\/tr>/gi) || []; - headRows.forEach(row => { - const cells = []; - const cellMatches = row.match(/([\s\S]*?)<\/t[hd]>/gi) || []; - cellMatches.forEach(cell => { - const content = cell.replace(/<\/?t[hd]>/gi, ''); - cells.push({ content, tag: 'th' }); - }); - if (cells.length > 0) attrs.head.push({ cells }); - }); - } - - // Parse tbody rows - if (bodyMatch) { - const bodyRows = bodyMatch[1].match(/([\s\S]*?)<\/tr>/gi) || []; - bodyRows.forEach(row => { - const cells = []; - const cellMatches = row.match(/([\s\S]*?)<\/td>/gi) || []; - cellMatches.forEach(cell => { - const content = cell.replace(/<\/?td>/gi, ''); - cells.push({ content, tag: 'td' }); - }); - if (cells.length > 0) attrs.body.push({ cells }); - }); - } - } - } - - // Handle button blocks from [CTA:...] syntax - if (block.blockName === 'core/buttons' || block.blockName === 'core/button') { - if (block.blockName === 'core/button') { - return wp.blocks.createBlock('core/buttons', {}, [ - wp.blocks.createBlock('core/button', attrs) - ]); - } - } - - if (block.innerBlocks && block.innerBlocks.length > 0) { - const innerBlocks = block.innerBlocks.map((innerBlock) => ( - createBlocksFromSerialized(innerBlock) - )).filter(Boolean); - return wp.blocks.createBlock(block.blockName, attrs, innerBlocks); - } - - return wp.blocks.createBlock(block.blockName, attrs); - }; - const reformatBlocks = async (blocksToReformat, originalMessage) => { - if (isLoading) { - return; - } - - if (!blocksToReformat || blocksToReformat.length === 0) { - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'No blocks found to reformat.' - }]); - return; - } - - setIsLoading(true); - setMessages(prev => [...deactivateActiveTimelineEntries(prev), { - role: 'system', - type: 'timeline', - status: 'refining', - message: `Reformatting ${blocksToReformat.length} block(s)...`, - timestamp: new Date() - }]); - - try { - const response = await fetch(wpAgenticWriter.apiUrl + '/reformat-blocks', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - body: JSON.stringify({ - blocks: blocksToReformat, - postId: postId, - sessionId: currentSessionId, - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.message || 'Failed to reformat blocks'); - } - - const data = await response.json(); - applyProviderMetadata(data); - const results = data.results || []; - const { replaceBlocks } = dispatch('core/block-editor'); - const currentTitle = select('core/editor').getEditedPostAttribute('title') || ''; - - results.forEach((result) => { - const newBlocks = (result.blocks || []).map(createBlocksFromSerialized).filter(Boolean); - if (newBlocks.length > 0) { - replaceBlocks(result.clientId, newBlocks); - } - }); - - setMessages(prev => [...prev, { - role: 'system', - type: 'timeline', - status: 'complete', - message: `Reformatted ${results.length} block(s).`, - timestamp: new Date(), - completedAt: new Date() - }]); - if (data.recommended_title) { - setMessages(prev => [...prev, { - role: 'assistant', - content: `Suggested title: ${data.recommended_title}` - }]); - if (data.title_updated || !currentTitle) { - dispatch('core/editor').editPost({ title: data.recommended_title }); - } - } - } catch (error) { - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'Error: ' + (error.message || 'Failed to reformat blocks') - }]); - } finally { - setIsLoading(false); - } - }; - const revisePlanFromPrompt = async (instruction) => { - if (isLoading) { - return; - } - const existingPlan = currentPlanRef.current; - if (!existingPlan) { - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'No outline found to revise. Generate an outline first.' - }]); - return; - } - - setIsLoading(true); - setMessages(prev => [...deactivateActiveTimelineEntries(prev), { - role: 'system', - type: 'timeline', - status: 'planning', - message: 'Updating outline...', - timestamp: new Date() - }]); - - try { - const response = await fetch(wpAgenticWriter.apiUrl + '/revise-plan', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - body: JSON.stringify({ - instruction: instruction, - plan: existingPlan, - postId: postId, - sessionId: currentSessionId, - postConfig: postConfig, - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.message || 'Failed to revise outline'); - } - - const data = await response.json(); - if (data.plan) { - updateOrCreatePlanMessage(data.plan, { append: true }); - } - - if (data.cost) { - setCost({ ...cost, session: cost.session + data.cost }); - } - applyProviderMetadata(data); - - setMessages(prev => { - const newMessages = [...prev]; - const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); - if (lastTimelineIndex !== -1) { - newMessages[lastTimelineIndex] = { - ...newMessages[lastTimelineIndex], - status: 'complete', - message: 'Outline updated.', - completedAt: new Date() - }; - } - return newMessages; - }); - } catch (error) { - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'Error: ' + (error.message || 'Failed to revise outline') - }]); - } finally { - setIsLoading(false); - } - }; - const applyEditPlan = (plan) => { - const actions = normalizePlanActions(plan); - if (actions.length === 0) { - setPendingEditPlan(null); - return; - } - - // Capture snapshot before applying changes - pushUndoSnapshot('Apply Edit Plan'); - - const { replaceBlocks, insertBlocks, removeBlocks } = dispatch('core/block-editor'); - const allBlocks = select('core/block-editor').getBlocks(); - const baseIndexById = new Map(allBlocks.map((block, index) => [block.clientId, index])); - const insertOffsets = {}; - const existingIds = new Set(allBlocks.map((block) => block.clientId)); - - actions.forEach((action) => { - if (action.action === 'keep') { - return; - } - if (action.blockId && !existingIds.has(action.blockId)) { - return; - } - - if (action.action === 'delete' && action.blockId) { - removeBlocks(action.blockId); - return; - } - - if (action.action === 'change_type' && action.blockId) { - const newBlock = createBlockFromPlan(action); - replaceBlocks(action.blockId, newBlock); - return; - } - - if (action.action === 'replace' && action.blockId) { - const newBlock = createBlockFromPlan(action); - replaceBlocks(action.blockId, newBlock); - return; - } - - if ((action.action === 'insert_after' || action.action === 'insert_before') && action.blockId) { - const baseIndex = baseIndexById.get(action.blockId); - const offsets = insertOffsets[action.blockId] || { before: 0, after: 0 }; - let insertIndex; - - if (typeof baseIndex === 'number') { - if (action.action === 'insert_before') { - insertIndex = baseIndex + offsets.before; - offsets.before += 1; - } else { - insertIndex = baseIndex + offsets.before + 1 + offsets.after; - offsets.after += 1; - } - } - insertOffsets[action.blockId] = offsets; - - const newBlock = createBlockFromPlan(action); - insertBlocks(newBlock, insertIndex); - } - }); - - setPendingEditPlan(null); - setMessages(prev => [...prev, { - role: 'system', - type: 'timeline', - status: 'complete', - message: 'Changes applied.', - }]); - }; - const cancelEditPlan = () => { - setPendingEditPlan(null); - setMessages(prev => [...prev, { - role: 'system', - type: 'timeline', - status: 'inactive', - message: 'Changes cancelled.', - }]); - }; - - const formatClarificationContext = (questionsList, answersMap) => { - if (!questionsList || questionsList.length === 0) { - return ''; - } - - const lines = []; - questionsList.forEach((question) => { - const answer = answersMap[question.id]; - if (!answer) { - return; - } - lines.push(`- ${question.question || question.prompt || 'Question'}: ${answer}`); - }); - - if (lines.length === 0) { - return ''; - } - - return `\n\nClarification Answers:\n${lines.join('\n')}`; - }; - - // Auto-select first option when question changes - React.useEffect(() => { - if (inClarification && questions.length > 0 && questions[currentQuestionIndex]) { - const currentQuestion = questions[currentQuestionIndex]; - if (currentQuestion.type === 'single_choice' && currentQuestion.options && currentQuestion.options.length > 0 && !answers[currentQuestion.id]) { - const newAnswers = { ...answers }; - newAnswers[currentQuestion.id] = currentQuestion.options[0].value; - setAnswers(newAnswers); - } - } - }, [currentQuestionIndex, questions, inClarification]); - - /** - * Remove duplicate adjacent heading blocks - */ - const removeDuplicateHeadings = (blocks) => { - if (!blocks || blocks.length === 0) { - return blocks; - } - - const cleanedBlocks = []; - let lastHeadingContent = null; - - for (const block of blocks) { - if (block.name === 'core/heading') { - const currentHeading = (block.attributes?.content || '').trim().toLowerCase(); - - if (currentHeading === lastHeadingContent) { - wpawLog.log('WP Agentic Writer: Removed duplicate heading:', block.attributes.content); - continue; - } - - lastHeadingContent = currentHeading; - } else { - lastHeadingContent = null; - } - - cleanedBlocks.push(block); - } - - return cleanedBlocks; - }; - - // Send message and generate article. - // Resolve block mentions to client IDs - const getRefineableBlocks = (options = {}) => { - const { textOnly = false } = options; - const allBlocks = select('core/block-editor').getBlocks(); - const textBlockTypes = new Set([ - 'core/paragraph', - 'core/heading', - 'core/list', - 'core/quote', - 'core/pullquote', - 'core/code', - 'core/preformatted', - 'core/table', - ]); - return allBlocks.filter((block) => { - if (!block.name || !block.name.startsWith('core/')) { - return false; - } - if (textOnly && !textBlockTypes.has(block.name)) { - return false; - } - // Filter out empty blocks (e.g., default empty paragraph on new posts) - const content = block.attributes?.content || ''; - const hasInnerBlocks = block.innerBlocks && block.innerBlocks.length > 0; - // Consider block as refineable only if it has content or inner blocks - return content.trim().length > 0 || hasInnerBlocks; - }); - }; - const getListItemBlocks = () => { - const allBlocks = select('core/block-editor').getBlocks(); - const listItems = []; - let listBlockIndex = 0; - - allBlocks.forEach((block) => { - if (block.name !== 'core/list') { - return; - } - - listBlockIndex += 1; - const innerItems = Array.isArray(block.innerBlocks) ? block.innerBlocks : []; - innerItems.forEach((itemBlock, itemIndex) => { - if (itemBlock.name !== 'core/list-item') { - return; - } - - listItems.push({ - block: itemBlock, - parentId: block.clientId, - listIndex: listBlockIndex, - itemIndex: itemIndex - }); - }); - }); - - return listItems; - }; - const resolveExplicitListItem = (listIndex, itemIndex) => { - const listItems = getListItemBlocks(); - return listItems.find( - (item) => item.listIndex === listIndex && item.itemIndex === itemIndex - ); - }; - const getParentListId = (blockId) => { - const getParents = select('core/block-editor').getBlockParents; - if (!getParents) { - return null; - } - - const parentIds = getParents(blockId); - for (const parentId of parentIds) { - const parentBlock = select('core/block-editor').getBlock(parentId); - if (parentBlock?.name === 'core/list') { - return parentId; - } - } - - return null; - }; - const getBlockContentForContext = (blockId) => { - const block = blockId ? select('core/block-editor').getBlock(blockId) : null; - if (!block) { - return ''; - } - - const content = extractBlockPreview(block); - return content ? content.trim() : ''; - }; - const getHeadingContextForBlock = (blockId) => { - const allBlocks = select('core/block-editor').getBlocks(); - const startIndex = allBlocks.findIndex((block) => block.clientId === blockId); - if (startIndex === -1) { - return ''; - } - - for (let i = startIndex - 1; i >= 0; i -= 1) { - if (allBlocks[i].name === 'core/heading') { - return extractBlockPreview(allBlocks[i]) || ''; - } - } - - return ''; - }; - const getNearbyParagraphContext = (blockId, limit = 2) => { - const allBlocks = select('core/block-editor').getBlocks(); - const startIndex = allBlocks.findIndex((block) => block.clientId === blockId); - if (startIndex === -1) { - return []; - } - - const snippets = []; - for (let i = startIndex - 1; i >= 0 && snippets.length < limit; i -= 1) { - if (allBlocks[i].name === 'core/paragraph') { - const preview = extractBlockPreview(allBlocks[i]); - if (preview) { - snippets.push(preview.trim()); - } - } - if (allBlocks[i].name === 'core/heading') { - break; - } - } - - return snippets.reverse(); - }; - const getContextFromMentions = (mentionTokens, excludeId) => { - const mentionIds = resolveBlockMentions(mentionTokens).filter((id) => id && id !== excludeId); - const uniqueIds = [...new Set(mentionIds)]; - return uniqueIds - .map((id) => getBlockContentForContext(id)) - .filter((content) => content); - }; - const extractQuotedTermsFromMessage = (message) => { - if (!message || typeof message !== 'string') { - return []; - } - const terms = []; - const quoteRegex = /"([^"]+)"|'([^']+)'/g; - let match; - while ((match = quoteRegex.exec(message)) !== null) { - const term = (match[1] || match[2] || '').trim().toLowerCase(); - if (term && term.length <= 40) { - terms.push(term); - } - } - return [...new Set(terms)]; - }; - const getAllTextRefineableBlocks = () => getRefineableBlocks({ textOnly: true }); - const selectLikelySlangBlocks = (message) => { - const textBlocks = getAllTextRefineableBlocks(); - const quotedTerms = extractQuotedTermsFromMessage(message); - if (!quotedTerms.length) { - return textBlocks; - } - const matches = textBlocks.filter((block) => { - const content = (extractBlockPreview(block) || '').toLowerCase(); - return quotedTerms.some((term) => content.includes(term)); - }); - // Fallback to all text blocks when heuristic finds nothing. - return matches.length > 0 ? matches : textBlocks; - }; - - const resolveBlockMentions = (mentions) => { - const allBlocks = select('core/block-editor').getBlocks(); - const selectedBlockId = select('core/block-editor').getSelectedBlockClientId(); - const resolved = []; - const listItems = getListItemBlocks(); - - mentions.forEach(mention => { - const type = normalizeMentionToken(mention.replace('@', '')); - const match = type.match(/^([a-z0-9-]+)-(\d+)$/i); - const listItemMatch = type.match(/^(?:listitem|list-item|li)-(\d+)$/i); - const explicitListItemMatch = type.match(/^list-(\d+)\.list-item-(\d+)$/i); - - switch (type) { - case 'this': - if (selectedBlockId) { - resolved.push(selectedBlockId); - } - break; - - case 'previous': - if (selectedBlockId) { - const selectedIndex = allBlocks.findIndex(b => b.clientId === selectedBlockId); - if (selectedIndex > 0) { - resolved.push(allBlocks[selectedIndex - 1].clientId); - } - } - break; - - case 'next': - if (selectedBlockId) { - const selectedIndex = allBlocks.findIndex(b => b.clientId === selectedBlockId); - if (selectedIndex < allBlocks.length - 1) { - resolved.push(allBlocks[selectedIndex + 1].clientId); - } - } - break; - - case 'all': - // @all intentionally targets text-based content blocks only. - getAllTextRefineableBlocks().forEach((block) => { - resolved.push(block.clientId); - }); - break; - - default: - if (explicitListItemMatch) { - const listIndex = parseInt(explicitListItemMatch[1], 10); - const itemIndex = parseInt(explicitListItemMatch[2], 10); - const item = resolveExplicitListItem(listIndex, itemIndex); - if (item) { - resolved.push(item.block.clientId); - } - break; - } - - if (listItemMatch) { - const rawIndex = parseInt(listItemMatch[1], 10); - const targetIndex = rawIndex <= 0 ? 1 : rawIndex; - const listItem = listItems[targetIndex - 1]; - if (listItem) { - resolved.push(listItem.block.clientId); - } - break; - } - - // Handle "paragraph-1", "heading-2", "list-1" format - if (match) { - const blockType = 'core/' + match[1]; - const blockIndex = parseInt(match[2]) - 1; // 1-based to 0-based - - let currentIndex = 0; - allBlocks.forEach((block) => { - if (block.name === blockType) { - if (currentIndex === blockIndex) { - resolved.push(block.clientId); - } - currentIndex++; - } - }); - } - break; - } - }); - - return [...new Set(resolved)]; // Remove duplicates - }; - - // Handle chat-based refinement - const handleChatRefinement = async (message, blocksOverride = null, options = {}) => { - const { skipUserMessage = false, useDiffPlan = true } = options; - lastRefineRequestRef.current = { message, blocksOverride, options }; - - // Capture snapshot before refinement - pushUndoSnapshot('Block Refinement'); - - // Parse mentions from message - const mentionRegex = /@([a-z0-9-]+(?:-\d+)?|this|previous|next|all)/gi; - const mentionMatches = [...message.matchAll(mentionRegex)]; - const mentions = mentionMatches.map(m => '@' + m[1]); - - // Resolve to block client IDs - const blocksToRefine = blocksOverride || resolveBlockMentions(mentions); - const hasAllMention = mentions.some((token) => normalizeMentionToken(token.replace('@', '')) === 'all'); - let resolvedIds = blocksToRefine; - if (hasAllMention && !blocksOverride) { - const likelyBlocks = selectLikelySlangBlocks(message); - resolvedIds = likelyBlocks.map((block) => block.clientId); - setMessages(prev => [...prev, { - role: 'system', - type: 'timeline', - status: 'inactive', - message: `@all scope narrowed to ${resolvedIds.length} likely block(s) based on requested terms.`, - timestamp: new Date(), - }]); - } - - if (resolvedIds.length === 0) { - // No valid mentions found - alert user - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'No valid blocks found to refine. Try @this, @previous, @next, @all, @paragraph-1, @listitem-3, or @list-3.list-item-0.' - }]); - setIsLoading(false); - return; - } - - if (hasAllMention && resolvedIds.length >= REFINEMENT_ALL_CONFIRM_THRESHOLD) { - const confirmed = await requestRefineAllConfirmation(resolvedIds.length); - if (!confirmed) { - setMessages(prev => [...prev, { - role: 'system', - type: 'timeline', - status: 'inactive', - message: `Cancelled @all refinement (${resolvedIds.length} target blocks).`, - timestamp: new Date() - }]); - setIsLoading(false); - return; - } - } - const effectiveUseDiffPlan = hasAllMention ? false : useDiffPlan; - - const serializeBlockForApi = (block) => { - if (!block) { - return null; - } - - return { - clientId: block.clientId, - name: block.name, - attributes: block.attributes || {}, - innerBlocks: Array.isArray(block.innerBlocks) - ? block.innerBlocks.map(serializeBlockForApi).filter(Boolean) - : [], - }; - }; - - // Get actual block data snapshot from editor - const allBlocksSnapshot = select('core/block-editor').getBlocks(); - const normalizedAllBlocks = allBlocksSnapshot - .map(serializeBlockForApi) - .filter(Boolean); - const blocksToRefineData = resolvedIds - .map((clientId) => normalizedAllBlocks.find((block) => block.clientId === clientId)) - .filter(Boolean); - - // Add user message to chat - if (!skipUserMessage) { - setMessages(prev => [...prev, { role: 'user', content: message }]); - } - - // Add timeline entry - setMessages(prev => [...deactivateActiveTimelineEntries(prev), { - role: 'system', - type: 'timeline', - status: 'refining', - message: `Refining ${resolvedIds.length} block(s)...`, - timestamp: new Date() - }]); - - setIsRefinementLocked(true); - setRefiningBlockIds(resolvedIds); - setIsLoading(true); - - try { - // Get selected block - const selectedBlockId = select('core/block-editor').getSelectedBlockClientId(); - - // Call refinement endpoint with actual block data - const response = await fetch(wpAgenticWriter.apiUrl + '/refine-from-chat', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - body: JSON.stringify({ - topic: message, - context: message, - selectedBlockClientId: selectedBlockId, - blocksToRefine: blocksToRefineData, // Send actual block objects - allBlocks: normalizedAllBlocks, - postId: postId, - sessionId: currentSessionId, - stream: true, - diffPlan: effectiveUseDiffPlan, - selectiveRefine: hasAllMention, - postConfig: postConfig, - chatHistory: messages.filter(m => m.role !== 'system'), - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.message || 'Refinement failed'); - } - - // Handle streaming response - streamTargetRef.current = null; - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let streamBuffer = ''; - let refinedCount = 0; - const updatedSectionIds = new Set(); - const { replaceBlocks } = dispatch('core/block-editor'); - let refinementFailed = false; - let refinementErrorMessage = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - streamBuffer += decoder.decode(value, { stream: true }); - const lines = streamBuffer.split('\n'); - streamBuffer = lines.pop() || ''; - - for (const line of lines) { - if (line.startsWith('data: ')) { - try { - const data = JSON.parse(line.slice(6)); - - if (data.type === 'error') { - refinementFailed = true; - refinementErrorMessage = data.message || 'Refinement failed.'; - break; - } else if (data.type === 'status') { - setMessages(prev => { - const newMessages = [...prev]; - const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); - if (lastTimelineIndex !== -1) { - newMessages[lastTimelineIndex] = { - ...newMessages[lastTimelineIndex], - message: data.message || newMessages[lastTimelineIndex].message, - timestamp: new Date(), - }; - } - return newMessages; - }); - } else if (data.type === 'edit_plan') { - setPendingEditPlan(data.plan); - setMessages(prev => [...prev, { - role: 'system', - type: 'edit_plan', - plan: data.plan, - }]); - } else if (data.type === 'block') { - // Replace block in editor - const blockData = data.block; - - if (blockData.blockName && blockData.attrs) { - let newBlock; - - // Create block using WordPress createBlock API - if (blockData.innerBlocks && blockData.innerBlocks.length > 0) { - // For lists with inner blocks - const innerBlocks = blockData.innerBlocks.map(innerB => { - return wp.blocks.createBlock( - innerB.blockName, - innerB.attrs - ); - }); - - newBlock = wp.blocks.createBlock( - blockData.blockName, - blockData.attrs, - innerBlocks - ); - } else { - // For simple blocks (paragraph, heading) - newBlock = wp.blocks.createBlock( - blockData.blockName, - blockData.attrs - ); - } - - // Replace the target block - if (newBlock && newBlock.name) { - const sectionId = blockSectionRef.current[blockData.clientId]; - replaceBlocks(blockData.clientId, newBlock); - setRefiningBlockIds((prevIds) => prevIds.map((id) => id === blockData.clientId ? newBlock.clientId : id)); - if (sectionId) { - removeSectionBlock(sectionId, blockData.clientId); - upsertSectionBlock(sectionId, newBlock.clientId); - updatedSectionIds.add(sectionId); - } - } - } - - refinedCount++; - } else if (data.type === 'complete') { - // Apply provider metadata from completion - applyProviderMetadata(data); - - // Update timeline - setMessages(prev => { - const newMessages = [...prev]; - const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); - if (lastTimelineIndex !== -1) { - const failedLabel = Number(data.failed || 0) > 0 - ? `, ${Number(data.failed)} failed` - : ''; - newMessages[lastTimelineIndex] = { - ...newMessages[lastTimelineIndex], - status: data.aborted ? 'error' : 'complete', - message: data.aborted - ? `Refinement stopped early: ${refinedCount} updated${failedLabel}` - : `Refined ${refinedCount} block(s) successfully${failedLabel}`, - timestamp: new Date() - }; - } - return newMessages; - }); - - // Show completion message - setMessages(prev => [...prev, { - role: 'assistant', - content: data.aborted - ? `⚠️ I stopped early after provider errors. Updated ${refinedCount} block(s)${Number(data.failed || 0) > 0 ? `, ${Number(data.failed)} failed` : ''}.` - : `✅ Done! I've refined ${refinedCount} block(s) as requested${Number(data.failed || 0) > 0 ? `, with ${Number(data.failed)} failed attempts` : ''}.` - }]); - - // Update cost - if (data.totalCost) { - setCost({ ...cost, session: cost.session + data.totalCost }); - } - updatedSectionIds.forEach((sectionId) => { - saveSectionBlocks(sectionId); - }); - } - } catch (e) { - wpawLog.error('Failed to parse streaming data:', line, e); - } - } - if (refinementFailed) { - break; - } - } - if (refinementFailed) { - break; - } - } - - if (refinementFailed) { - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: `Refinement stopped: ${refinementErrorMessage}`, - canRetry: true, - retryType: 'refine' - }]); - - setMessages(prev => { - const newMessages = [...prev]; - const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); - if (lastTimelineIndex !== -1) { - newMessages[lastTimelineIndex] = { - ...newMessages[lastTimelineIndex], - status: 'error', - message: 'Refinement stopped (edit plan failed)', - }; - } - return newMessages; - }); - } - - } catch (error) { - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'Error: ' + error.message, - canRetry: true, - retryType: 'refine' - }]); - - // Update timeline to show error - setMessages(prev => { - const newMessages = [...prev]; - const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); - if (lastTimelineIndex !== -1) { - newMessages[lastTimelineIndex] = { - ...newMessages[lastTimelineIndex], - status: 'error', - message: 'Refinement failed', - }; - } - return newMessages; - }); - } finally { - setIsRefinementLocked(false); - setRefiningBlockIds([]); - setIsLoading(false); - } - }; - const renderRefineAllConfirmModal = () => { - if (!refineAllConfirm.isOpen) { - return null; - } - - return wp.element.createElement('div', { - className: 'wpaw-refine-confirm-overlay', - role: 'dialog', - 'aria-modal': 'true', - 'aria-label': 'Confirm large @all refinement', - }, - wp.element.createElement('div', { className: 'wpaw-refine-confirm-modal' }, - wp.element.createElement('div', { className: 'wpaw-refine-confirm-title' }, 'Confirm @all Refinement'), - wp.element.createElement('div', { className: 'wpaw-refine-confirm-body' }, - `This will refine ${refineAllConfirm.blockCount} text block(s) in batches of 5. ` - + 'This may take time and consume API credits.' - ), - wp.element.createElement(CheckboxControl, { - label: 'Don’t ask again for this session', - checked: refineAllConfirm.dontAskAgain, - onChange: (checked) => { - setRefineAllConfirm((prev) => ({ ...prev, dontAskAgain: Boolean(checked) })); - }, - }), - wp.element.createElement('div', { className: 'wpaw-refine-confirm-actions' }, - wp.element.createElement(Button, { - isSecondary: true, - onClick: () => resolveRefineAllConfirmation(false), - }, 'Cancel'), - wp.element.createElement(Button, { - isPrimary: true, - onClick: () => { - if (refineAllConfirm.dontAskAgain) { - skipRefineAllConfirmRef.current = true; - } - resolveRefineAllConfirmation(true); - }, - }, 'Continue') - ) - ) - ); - }; - - // Get mention options for autocomplete - const getMentionOptions = (query) => { - const allBlocks = select('core/block-editor').getBlocks(); - const selectedBlockId = select('core/block-editor').getSelectedBlockClientId(); - const options = []; - - // Add special mentions - if (!query || 'this'.includes(query.toLowerCase())) { - options.push({ - id: 'this', - label: '@this', - sublabel: 'Currently selected block', - type: 'special' - }); - } - if (!query || 'previous'.includes(query.toLowerCase())) { - options.push({ - id: 'previous', - label: '@previous', - sublabel: 'Block before current selection', - type: 'special' - }); - } - if (!query || 'next'.includes(query.toLowerCase())) { - options.push({ - id: 'next', - label: '@next', - sublabel: 'Block after current selection', - type: 'special' - }); - } - if (!query || 'all'.includes(query.toLowerCase())) { - options.push({ - id: 'all', - label: '@all', - sublabel: 'All content blocks', - type: 'special' - }); - } - if (!query || 'title'.includes(query.toLowerCase())) { - options.push({ - id: 'title', - label: '@title', - sublabel: 'Refine post title with instruction', - type: 'special' - }); - } - - // Add numbered blocks for core blocks - const blockCounters = {}; - const queryLower = query.toLowerCase(); - let listItemIndex = 0; - let listBlockIndex = 0; - - allBlocks.forEach((block) => { - if (!block.name || !block.name.startsWith('core/')) { - return; - } - - const typeName = block.name.replace('core/', ''); - blockCounters[typeName] = (blockCounters[typeName] || 0) + 1; - const blockLabel = `@${typeName}-${blockCounters[typeName]}`; - - const content = extractBlockPreview(block); - const contentLower = content.toLowerCase(); - if (!query || blockLabel.includes(queryLower) || contentLower.startsWith(queryLower)) { - const truncatedContent = content.length > 40 ? content.substring(0, 40) + '...' : content; - options.push({ - id: blockLabel, - label: String(blockLabel), - sublabel: truncatedContent || String(typeName), - type: 'block', - clientId: block.clientId - }); - } - - if (block.name === 'core/list') { - listBlockIndex += 1; - const innerItems = Array.isArray(block.innerBlocks) ? block.innerBlocks : []; - innerItems.forEach((itemBlock, itemIndex) => { - if (itemBlock.name !== 'core/list-item') { - return; - } - - listItemIndex += 1; - const itemLabel = `@listitem-${listItemIndex}`; - const explicitLabel = `@list-${listBlockIndex}.list-item-${itemIndex}`; - const itemContent = extractBlockPreview(itemBlock); - const itemLower = itemContent.toLowerCase(); - if (!query || itemLabel.includes(queryLower) || explicitLabel.includes(queryLower) || itemLower.startsWith(queryLower)) { - const truncatedItem = itemContent.length > 40 - ? itemContent.substring(0, 40) + '...' - : itemContent; - options.push({ - id: itemLabel, - label: String(explicitLabel), - sublabel: truncatedItem ? `List ${listBlockIndex}: ${truncatedItem}` : `List ${listBlockIndex} item`, - type: 'list-item', - clientId: itemBlock.clientId, - parentClientId: block.clientId - }); - } - }); - } - }); - - return options; - }; - - React.useEffect(() => { - const handleInsertMention = (event) => { - const token = event?.detail?.token; - if (!token) { - return; - } - - setActiveTab('chat'); - setInput((prev) => { - const prefix = prev && !/\s$/.test(prev) ? prev + ' ' : prev; - return `${prefix}${token}`; - }); - - setTimeout(() => { - const inputNode = inputRef.current?.textarea || inputRef.current; - if (inputNode) { - inputNode.focus(); - inputNode.selectionStart = inputNode.selectionEnd = inputNode.value.length; - } - - const mentionOptionsList = getMentionOptions(''); - setMentionOptions(mentionOptionsList); - setShowMentionAutocomplete(mentionOptionsList.length > 0); - }, 0); - }; - - window.addEventListener('wpaw:insert-mention', handleInsertMention); - return () => window.removeEventListener('wpaw:insert-mention', handleInsertMention); - }, [getMentionOptions]); - - // Handle input change for mention detection - const handleInputChange = (value) => { - setInput(value); - - // Check if user is typing a mention - const inputNode = inputRef.current?.textarea || inputRef.current; - const cursorPosition = typeof inputNode?.selectionStart === 'number' ? inputNode.selectionStart : value.length; - const textBeforeCursor = value.substring(0, cursorPosition); - const mentionMatch = textBeforeCursor.match(/@(\w*)$/); - const slashMatch = textBeforeCursor.match(/\/([\w\s]*)$/); - - if (mentionMatch) { - const query = mentionMatch[1]; - setMentionQuery(query); - const options = getMentionOptions(query); - setMentionOptions(options); - setShowMentionAutocomplete(options.length > 0); - setMentionCursorIndex(0); - setShowSlashAutocomplete(false); - setSlashOptions([]); - } else if (slashMatch) { - const query = slashMatch[1]; - setSlashQuery(query); - const options = getSlashOptions(query); - setSlashOptions(options); - setShowSlashAutocomplete(options.length > 0); - setSlashCursorIndex(0); - setShowMentionAutocomplete(false); - setMentionOptions([]); - } else { - setShowMentionAutocomplete(false); - setMentionOptions([]); - setShowSlashAutocomplete(false); - setSlashOptions([]); - } - }; - - // Handle keyboard navigation in autocomplete - const handleKeyDown = (e) => { - if (!showMentionAutocomplete && !showSlashAutocomplete) { - if (e.keyCode === 13 && (e.metaKey || e.ctrlKey)) { - sendMessage(); - } - return; - } - - if (showMentionAutocomplete && e.keyCode === 40) { // Down arrow - e.preventDefault(); - setMentionCursorIndex(prev => (prev + 1) % mentionOptions.length); - } else if (showMentionAutocomplete && e.keyCode === 38) { // Up arrow - e.preventDefault(); - setMentionCursorIndex(prev => (prev - 1 + mentionOptions.length) % mentionOptions.length); - } else if (showMentionAutocomplete && e.keyCode === 13) { // Enter - e.preventDefault(); - if (mentionOptions[mentionCursorIndex]) { - insertMention(mentionOptions[mentionCursorIndex]); - } - } else if (showSlashAutocomplete && e.keyCode === 40) { // Down arrow - e.preventDefault(); - setSlashCursorIndex(prev => (prev + 1) % slashOptions.length); - } else if (showSlashAutocomplete && e.keyCode === 38) { // Up arrow - e.preventDefault(); - setSlashCursorIndex(prev => (prev - 1 + slashOptions.length) % slashOptions.length); - } else if (showSlashAutocomplete && e.keyCode === 13) { // Enter - e.preventDefault(); - if (slashOptions[slashCursorIndex]) { - insertSlashCommand(slashOptions[slashCursorIndex]); - } - } else if (e.keyCode === 27) { // Escape - e.preventDefault(); - setShowMentionAutocomplete(false); - setShowSlashAutocomplete(false); - } - }; - - // Insert selected mention - const insertMention = (option) => { - const value = input; - const inputNode = inputRef.current?.textarea || inputRef.current; - const cursorPosition = typeof inputNode?.selectionStart === 'number' ? inputNode.selectionStart : value.length; - const textBeforeCursor = value.substring(0, cursorPosition); - const mentionStart = textBeforeCursor.lastIndexOf('@'); - - const beforeMention = value.substring(0, mentionStart); - const afterMention = value.substring(cursorPosition); - const newValue = beforeMention + option.label + ' ' + afterMention; - - setInput(newValue); - setShowMentionAutocomplete(false); - setMentionOptions([]); - - // Focus back on input - setTimeout(() => { - if (inputRef.current) { - inputRef.current.focus(); - } - }, 0); - }; - const insertSlashCommand = (option) => { - const value = input; - const inputNode = inputRef.current?.textarea || inputRef.current; - const cursorPosition = typeof inputNode?.selectionStart === 'number' ? inputNode.selectionStart : value.length; - const textBeforeCursor = value.substring(0, cursorPosition); - const slashStart = textBeforeCursor.lastIndexOf('/'); - - const beforeSlash = value.substring(0, slashStart); - const afterSlash = value.substring(cursorPosition); - const newValue = beforeSlash + option.insertText + afterSlash; - - setInput(newValue); - setShowSlashAutocomplete(false); - setSlashOptions([]); - if (option.insertText.endsWith('@')) { - const mentionOptionsList = getMentionOptions(''); - setMentionQuery(''); - setMentionOptions(mentionOptionsList); - setShowMentionAutocomplete(mentionOptionsList.length > 0); - setMentionCursorIndex(0); - } - - setTimeout(() => { - if (inputRef.current) { - inputRef.current.focus(); - } - }, 0); - }; - - const sendMessage = async () => { - if (!input.trim() || isLoading) { - return; - } - - const userMessage = input.trim(); - // Collapse textarea to give more space for response - setIsTextareaExpanded(false); - - // Check for reset command - if (/^\s*(\/reset|\/clear)\s*$/i.test(userMessage)) { - setInput(''); - await handleResetCommand(); - return; - } - - // 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.' - } - ]); - return; - } - - const parsedCommand = parseInsertCommand(userMessage); - const commandMessage = parsedCommand ? parsedCommand.message : userMessage; - const mentionTokens = extractMentionsFromText(commandMessage); - const hasMentions = mentionTokens.length > 0; - const titleMentioned = hasTitleMention(mentionTokens); - const refineableBlocks = getRefineableBlocks(); - const shouldShowPlan = agentMode === 'planning'; - const generationLabel = agentMode === 'planning' ? 'Creating outline...' : 'Generating article...'; - const reformatCommand = /^\s*(?:\/)?reformat\b/i; - - if (parsedCommand) { - setIsLoading(true); - setMessages(prev => [...deactivateActiveTimelineEntries(prev), { - role: 'system', - type: 'timeline', - status: 'refining', - message: 'Preparing insertion...', - timestamp: new Date() - }]); - await insertRefinementBlock(parsedCommand.mode, commandMessage, mentionTokens, userMessage); - setIsLoading(false); - return; - } - - if (reformatCommand.test(userMessage)) { - setInput(''); - setMessages(prev => [...prev, { role: 'user', content: userMessage }]); - const targetIds = hasMentions ? resolveBlockMentions(mentionTokens) : getRefineableBlocks().map((block) => block.clientId); - const allBlocks = select('core/block-editor').getBlocks(); - const blocksToReformat = allBlocks.filter((block) => targetIds.includes(block.clientId)); - await reformatBlocks(blocksToReformat, userMessage); - return; - } - - if (titleMentioned) { - setInput(''); - await handleTitleRefinement(userMessage, mentionTokens); - return; - } - - if (agentMode === 'planning' && !hasMentions && currentPlanRef.current) { - setInput(''); - setMessages(prev => [...prev, { role: 'user', content: userMessage }]); - await revisePlanFromPrompt(userMessage); - return; - } - - if (agentMode === 'chat' && !hasMentions) { - setInput(''); - setMessages(prev => [...prev, { 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 }; - - try { - const chatHistory = messages - .filter((m) => m.role === 'user' || m.role === 'assistant') - .map((m) => ({ role: m.role, content: m.content })); - - const response = await fetch(wpAgenticWriter.apiUrl + '/chat', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - body: JSON.stringify({ - messages: [...chatHistory, { role: 'user', content: userMessage }], - postId: postId, - sessionId: currentSessionId, - type: 'chat', - stream: true, - postConfig: postConfig, - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.message || 'Failed to chat'); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let streamBuffer = ''; - let streamError = null; - streamTargetRef.current = null; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - streamBuffer += decoder.decode(value, { stream: true }); - const lines = streamBuffer.split('\n'); - streamBuffer = lines.pop() || ''; - - for (const line of lines) { - if (!line.startsWith('data: ')) { - continue; - } - - try { - const data = JSON.parse(line.slice(6)); - if (data.type === 'error') { - streamError = new Error(data.message || 'Failed to chat'); - break; - } - if (data.type === 'conversational' || data.type === 'conversational_stream') { - const cleanContent = (data.content || '').trim(); - if (!cleanContent) { - continue; - } - - const streamTarget = streamTargetRef.current || resolveStreamTarget(cleanContent); - if (!streamTarget) { - continue; - } - - streamTargetRef.current = streamTarget; - - if (data.type === 'conversational') { - setMessages(prev => { - const newMessages = [...prev]; - const lastIdx = newMessages.length - 1; - const lastMessage = newMessages[lastIdx]; - if (lastMessage && lastMessage.role === 'assistant' && lastMessage.content === cleanContent) { - return newMessages; - } - newMessages.push({ role: 'assistant', content: cleanContent }); - return newMessages; - }); - } else { - setMessages(prev => { - const newMessages = [...prev]; - const lastIdx = newMessages.length - 1; - if (newMessages[lastIdx] && newMessages[lastIdx].role === 'assistant') { - newMessages[lastIdx] = { ...newMessages[lastIdx], content: cleanContent }; - } else { - newMessages.push({ role: 'assistant', content: cleanContent }); - } - return newMessages; - }); - } - } else if (data.type === 'complete') { - if (data.totalCost) { - setCost({ ...cost, session: cost.session + data.totalCost }); - } - applyProviderMetadata(data); - // 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) { - wpawLog.error('Failed to parse streaming data:', line, parseError); - } - } - - if (streamError) { - throw streamError; - } - } - - // 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]; - const lastIdx = newMessages.length - 1; - if (newMessages[lastIdx] && newMessages[lastIdx].role === 'assistant') { - newMessages[lastIdx] = { ...newMessages[lastIdx], detectedIntent: intentResult.intent }; - } - return newMessages; - }); - } - } catch (intentError) { - wpawLog.error('Intent detection failed:', formatAiErrorMessage(intentError, 'Intent detection failed')); - } - } catch (error) { - const errorMsg = formatAiErrorMessage(error, 'Failed to chat'); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: errorMsg, - canRetry: true, - retryType: 'chat' - }]); - } - - setIsLoading(false); - return; - } - - if (!hasMentions && refineableBlocks.length > 0) { - // Content exists - run clarity check before full-article refinement - const targetedBlocks = getTargetedRefinementBlocks(userMessage); - const matchedSection = !targetedBlocks ? findBestPlanSectionMatch(userMessage) : null; - const matchedSectionBlocks = matchedSection - ? sectionBlocksRef.current[matchedSection.id] || [] - : []; - setInput(''); - setMessages(prev => [...prev, { role: 'user', content: userMessage }]); - if (matchedSectionBlocks.length > 0) { - setMessages(prev => [...prev, { - role: 'assistant', - content: `Targeting section: ${matchedSection.heading || matchedSection.title || 'Selected section'} (${matchedSectionBlocks.length} block(s)).` - }]); - } - setIsLoading(true); - setMessages(prev => [...deactivateActiveTimelineEntries(prev), { - role: 'system', - type: 'timeline', - status: 'checking', - message: matchedSection - ? `Analyzing request (targeting: ${matchedSection.heading || matchedSection.title || 'section'})...` - : 'Analyzing request...', - timestamp: new Date() - }]); - - const fallbackBlocks = refineableBlocks.map((block) => block.clientId); - await handleChatRefinement( - userMessage, - (targetedBlocks && targetedBlocks.length > 0) - ? targetedBlocks - : (matchedSectionBlocks.length > 0 ? matchedSectionBlocks : fallbackBlocks), - { skipUserMessage: true } - ); - return; - } - - if (!hasMentions) { - // No mentions - check clarity first before article generation - setInput(''); - setMessages(prev => [...prev, { role: 'user', content: userMessage }]); - setIsLoading(true); - - // Check clarity first - setMessages(prev => [...deactivateActiveTimelineEntries(prev), { - role: 'system', - type: 'timeline', - status: 'checking', - message: 'Analyzing request...', - timestamp: new Date() - }]); - - // First try clarity check - try { - const clarityResponse = await fetch(wpAgenticWriter.apiUrl + '/check-clarity', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - body: JSON.stringify({ - topic: userMessage, - answers: [], - postId: postId, - sessionId: currentSessionId, - mode: 'generation', - postConfig: postConfig, - chatHistory: buildChatHistoryPayload(), - }), - }); - - if (clarityResponse.ok) { - const clarityData = await clarityResponse.json(); - const clarityResult = clarityData.result; - - // Store detected language for article generation - if (clarityResult.detected_language) { - setDetectedLanguage(clarityResult.detected_language); - } - - if (!clarityResult.is_clear && clarityResult.questions && clarityResult.questions.length > 0) { - // Need clarification - show quiz - setQuestions(clarityResult.questions); - setInClarification(true); - setCurrentQuestionIndex(0); - setAnswers([]); - setIsLoading(false); - - // Update timeline - 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; - } - } - // If clarity check fails, proceed with generation anyway - } catch (clarityError) { - wpawLog.warn('Clarity check failed, proceeding with generation:', clarityError); - // Continue to article generation - } - - // Clear enough - proceed with article generation - // Update timeline - setMessages(prev => { - const newMessages = [...prev]; - const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); - if (lastTimelineIndex !== -1) { - newMessages[lastTimelineIndex] = { - ...newMessages[lastTimelineIndex], - status: 'starting', - message: generationLabel - }; - } - return newMessages; - }); - - // Now call generate-plan - 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: userMessage, - context: '', - postId: postId, - sessionId: currentSessionId, - answers: [], - autoExecute: agentMode !== 'planning', - stream: true, - articleLength: postConfig.article_length, - detectedLanguage: detectedLanguage, - postConfig: postConfig, - chatHistory: buildChatHistoryPayload(), - }), - }); - - if (!response.ok) { - const error = await response.json(); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: formatAiErrorMessage(error, 'Failed to generate article'), - canRetry: true, - retryType: 'generation' - }]); - setIsLoading(false); - return; - } - - // Handle streaming response - streamTargetRef.current = null; - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - - // Add timeout to detect hanging responses - const timeout = setTimeout(() => { - if (isLoading) { - wpawLog.error('Generation timeout - no response received'); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: formatAiErrorMessage('cURL error 28: Operation timed out after 120000 milliseconds', 'Failed to generate article'), - canRetry: true, - retryType: 'generation' - }]); - setIsLoading(false); - reader.cancel(); - } - }, 120000); // 2 minute timeout - - 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({ ...cost, session: cost.session + data.cost }); - if (shouldShowPlan && data.plan) { - updateOrCreatePlanMessage(data.plan); - } - } else if (data.type === 'title_update') { - dispatch('core/editor').editPost({ title: data.title }); - } else if (data.type === 'status') { - if (data.status === 'complete') { - continue; - } - - // Update timeline - 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; - }); - } else if (data.type === 'conversational' || data.type === 'conversational_stream') { - // Remove article marker and clean content - const cleanContent = (data.content || '') - .replace(/~~~ARTICLE~+/g, '') - .replace(/~~~ARTICLE~~~[\r\n]*/g, '') - .trim(); - - // Skip if content is empty after cleaning - if (!cleanContent || shouldSkipPlanningCompletion(cleanContent)) { - continue; - } - - const streamTarget = streamTargetRef.current || resolveStreamTarget(cleanContent); - - if (!streamTarget) { - continue; - } - - streamTargetRef.current = streamTarget; - - if (streamTarget === 'timeline') { - updateOrCreateTimelineEntry(cleanContent); - } else { - // This is actual conversational content - add as chat bubble - if (data.type === 'conversational') { - setMessages(prev => [...prev, { role: 'assistant', content: cleanContent }]); - } else { - setMessages(prev => { - const newMessages = [...prev]; - const lastIdx = newMessages.length - 1; - if (newMessages[lastIdx] && newMessages[lastIdx].role === 'assistant') { - newMessages[lastIdx] = { ...newMessages[lastIdx], content: cleanContent }; - } else { - newMessages.push({ role: 'assistant', content: cleanContent }); - } - return newMessages; - }); - } - } - } else if (data.type === 'block') { - const { insertBlocks } = dispatch('core/block-editor'); - let newBlock; - - if (data.block.blockName === 'core/paragraph') { - const content = data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ''; - newBlock = wp.blocks.createBlock('core/paragraph', { content: content }); - } else if (data.block.blockName === 'core/heading') { - const level = data.block.attrs?.level || 2; - const content = data.block.innerHTML?.match(/(.*?)<\/h[1-6]>/)?.[1] || ''; - newBlock = wp.blocks.createBlock('core/heading', { level: level, content: content }); - } else if (data.block.blockName === 'core/list') { - const listItems = (data.block.innerBlocks || []).map(item => { - const content = item.innerHTML?.match(/

  • (.*?)<\/li>/)?.[1] || ''; - return wp.blocks.createBlock('core/list-item', { content: content }); - }); - newBlock = wp.blocks.createBlock('core/list', { - ...(data.block.attrs || {}), - ordered: data.block.attrs?.ordered || false - }, listItems); - } else if (data.block.blockName === 'core/quote') { - const content = data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ''; - newBlock = wp.blocks.createBlock('core/quote', { value: content }); - } else if (data.block.blockName === 'core/image') { - newBlock = wp.blocks.createBlock('core/image', data.block.attrs || {}); - } - - if (newBlock) { - insertBlocks(newBlock); - } - } else if (data.type === 'complete') { - applyProviderMetadata(data); - clearTimeout(timeout); - setCost({ ...cost, session: cost.session + data.totalCost }); - - // Update timeline to complete - setMessages(prev => { - const newMessages = [...prev]; - const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); - if (lastTimelineIndex !== -1) { - newMessages[lastTimelineIndex] = { - ...newMessages[lastTimelineIndex], - status: 'complete', - message: agentMode === 'planning' ? 'Outline ready.' : 'Article generated successfully!' - }; - } - return newMessages; - }); - setIsLoading(false); - } else if (data.type === 'error') { - clearTimeout(timeout); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: formatAiErrorMessage(data.message || 'An error occurred during article generation', 'Failed to generate article'), - canRetry: true, - retryType: 'generation' - }]); - setIsLoading(false); - } - } catch (parseError) { - wpawLog.error('Failed to parse streaming data:', line, parseError); - } - } - - if (streamError) { - throw streamError; - } - } - } - // Clear timeout when streaming completes normally - clearTimeout(timeout); - } catch (error) { - clearTimeout(timeout); - wpawLog.error('Article generation error:', error); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: formatAiErrorMessage(error, 'Failed to generate article'), - canRetry: true, - retryType: 'generation' - }]); - setIsLoading(false); - } - - return; - } - - // Has mentions - check if mentioned blocks exist - let blocksToRefine = []; - if (hasMentions) { - blocksToRefine = resolveBlockMentions(mentionTokens); - } - - if (blocksToRefine.length > 0) { - // Blocks exist - this is a refinement request - setInput(''); - await handleChatRefinement(userMessage); - return; - } - - if (refineableBlocks.length > 0) { - if (userMessage.includes('@')) { - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'No valid blocks found to refine. Try @this, @previous, @next, @all, or @paragraph-1.' - }]); - setIsLoading(false); - return; - } - // No valid mentions, but content exists - refine the whole article - setInput(''); - await handleChatRefinement(userMessage, refineableBlocks.map((block) => block.clientId)); - return; - } - - // Blocks don't exist yet - this is article generation - // User is specifying structure for new article - setInput(''); - setMessages(prev => [...prev, { role: 'user', content: userMessage }]); - setIsLoading(true); - - // Add loading timeline entry - setMessages(prev => [...deactivateActiveTimelineEntries(prev), { - role: 'system', - type: 'timeline', - status: 'starting', - message: 'Initializing...', - timestamp: new Date() - }]); - - 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: userMessage, - context: '', - postId: postId, - sessionId: currentSessionId, - answers: [], - autoExecute: agentMode !== 'planning', - stream: true, - articleLength: postConfig.article_length, - postConfig: postConfig, - chatHistory: buildChatHistoryPayload(), - }), - }); - - if (!response.ok) { - const error = await response.json(); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: formatAiErrorMessage(error, 'Failed to generate article'), - canRetry: true, - retryType: 'generation' - }]); - setIsLoading(false); - return; - } - - // Handle streaming response - 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({ ...cost, session: cost.session + data.cost }); - if (agentMode === 'planning' && data.plan) { - updateOrCreatePlanMessage(data.plan); - } - } else if (data.type === 'title_update') { - dispatch('core/editor').editPost({ title: data.title }); - } else if (data.type === 'status') { - if (data.status === 'complete') { - continue; - } - - // Update timeline - 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; - }); - } else if (data.type === 'conversational' || data.type === 'conversational_stream') { - const cleanContent = (data.content || '') - .replace(/~~~ARTICLE~+/g, '') - .replace(/~~~ARTICLE~~~[\r\n]*/g, '') - .trim(); - - if (!cleanContent || shouldSkipPlanningCompletion(cleanContent)) { - continue; - } - - const streamTarget = streamTargetRef.current || resolveStreamTarget(cleanContent); - - if (!streamTarget) { - continue; - } - - streamTargetRef.current = streamTarget; - - if (streamTarget === 'timeline') { - updateOrCreateTimelineEntry(cleanContent); - } else if (data.type === 'conversational') { - setMessages(prev => [...prev, { role: 'assistant', content: cleanContent }]); - } else { - setMessages(prev => { - const newMessages = [...prev]; - const lastIdx = newMessages.length - 1; - if (newMessages[lastIdx] && newMessages[lastIdx].role === 'assistant') { - newMessages[lastIdx] = { ...newMessages[lastIdx], content: cleanContent }; - } else { - newMessages.push({ role: 'assistant', content: cleanContent }); - } - return newMessages; - }); - } - } else if (data.type === 'block') { - const { insertBlocks } = dispatch('core/block-editor'); - let newBlock; - - if (data.block.blockName === 'core/paragraph') { - const content = data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ''; - newBlock = wp.blocks.createBlock('core/paragraph', { content: content }); - } else if (data.block.blockName === 'core/heading') { - const level = data.block.attrs?.level || 2; - const content = data.block.innerHTML?.match(/(.*?)<\/h[1-6]>/)?.[1] || ''; - newBlock = wp.blocks.createBlock('core/heading', { level: level, content: content }); - } else if (data.block.blockName === 'core/list') { - const listItems = (data.block.innerBlocks || []).map(item => { - const content = item.innerHTML?.match(/

  • (.*?)<\/li>/)?.[1] || ''; - return wp.blocks.createBlock('core/list-item', { content: content }); - }); - newBlock = wp.blocks.createBlock('core/list', { - ...(data.block.attrs || {}), - ordered: data.block.attrs?.ordered || false - }, listItems); - } else if (data.block.blockName === 'core/quote') { - const content = data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ''; - newBlock = wp.blocks.createBlock('core/quote', { value: content }); - } else if (data.block.blockName === 'core/image') { - newBlock = wp.blocks.createBlock('core/image', data.block.attrs || {}); - } else { - const parsed = wp.blocks.parse(data.block.innerHTML); - newBlock = parsed && parsed.length > 0 ? parsed[0] : null; - } - - if (newBlock) { - insertBlocks(newBlock); - } - } else if (data.type === 'complete') { - applyProviderMetadata(data); - setCost({ ...cost, session: cost.session + data.totalCost }); - - // Update timeline to complete - setMessages(prev => { - const newMessages = [...prev]; - const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); - if (lastTimelineIndex !== -1) { - newMessages[lastTimelineIndex] = { - ...newMessages[lastTimelineIndex], - status: 'complete', - message: agentMode === 'planning' ? 'Outline ready.' : 'Article generation complete!' - }; - } - return newMessages; - }); - - // 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, - sessionId: currentSessionId, - imageCount: imagePlaceholders.length - } - }) - ); - } - }, 500); - } - } else if (data.type === 'error') { - throw new Error(data.message); - } - } catch (parseError) { - wpawLog.error('Failed to parse streaming data:', line, parseError); - } - } - } - } - - setTimeout(() => { - setIsLoading(false); - }, 1500); - } catch (error) { - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'Error: ' + error.message - }]); - setIsLoading(false); - } - }; - - // Submit answers and continue generation. - const submitAnswers = async () => { - if (isLoading) { - return; - } - - // Process config answers and update post config - // Handle language selection - if (answers.config_language) { - let languageValue = answers.config_language; - // Handle custom language input - if (languageValue === '__custom__' && answers.config_language_custom) { - languageValue = answers.config_language_custom.toLowerCase().trim(); - } - if (languageValue && languageValue !== '__skipped__') { - updatePostConfig('language', languageValue); - } - } - - // Handle other config settings - 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); - } - if (configData.seo !== undefined) { - updatePostConfig('seo_enabled', configData.seo); - } - if (configData.focus_keyword) { - updatePostConfig('seo_focus_keyword', configData.focus_keyword); - } - if (configData.secondary_keywords) { - updatePostConfig('seo_secondary_keywords', configData.secondary_keywords); - } - } catch (e) { - wpawLog.error('Failed to parse config answers:', e); - } - } - - if (clarificationMode === 'refinement' && pendingRefinement) { - setInClarification(false); - const clarificationContext = formatClarificationContext(questions, answers); - const refinedMessage = `${pendingRefinement.message}${clarificationContext}`; - const blocks = pendingRefinement.blocks || []; - setPendingRefinement(null); - setClarificationMode('generation'); - await handleChatRefinement(refinedMessage, blocks, { skipUserMessage: true }); - return; - } - - setIsLoading(true); - - // Exit quiz mode and return to chat immediately so user can see progress - setInClarification(false); - - // Add timeline entry showing generation is starting - setMessages(prev => [...deactivateActiveTimelineEntries(prev), { - role: 'system', - type: 'timeline', - status: 'starting', - message: agentMode === 'planning' ? 'Creating outline...' : 'Generating article...', - timestamp: new Date() - }]); - - try { - const topic = getLastUserMessageText() || messages.map((m) => (typeof m.content === 'string' ? m.content : '')).filter(Boolean).join('\n'); - - 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, - context: '', - postId: postId, - sessionId: currentSessionId, - clarificationAnswers: answers, - autoExecute: agentMode !== 'planning', - stream: true, - articleLength: postConfig.article_length, - detectedLanguage: detectedLanguage, - postConfig: postConfig, - chatHistory: buildChatHistoryPayload(), - }), - }); - - if (!response.ok) { - const error = await response.json(); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: formatAiErrorMessage(error, 'Failed to generate plan'), - canRetry: true, - retryType: 'generation' - }]); - setIsLoading(false); - return; - } - - // Handle streaming response (similar to sendMessage) - streamTargetRef.current = null; - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - - // Add timeout to detect hanging responses - const timeout = setTimeout(() => { - if (isLoading) { - wpawLog.error('Generation timeout - no response received'); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: formatAiErrorMessage('cURL error 28: Operation timed out after 120000 milliseconds', 'Failed to generate plan'), - canRetry: true, - retryType: 'generation' - }]); - setIsLoading(false); - reader.cancel(); - } - }, 120000); // 2 minute timeout - - 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({ ...cost, session: cost.session + data.cost }); - if (agentMode === 'planning' && data.plan) { - updateOrCreatePlanMessage(data.plan); - } - } else if (data.type === 'title_update') { - dispatch('core/editor').editPost({ title: data.title }); - } else if (data.type === 'status') { - if (data.status === 'complete') { - continue; - } - - // Update timeline - 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; - }); - } else if (data.type === 'conversational' || data.type === 'conversational_stream') { - // Remove article marker and clean content - const cleanContent = (data.content || '') - .replace(/~~~ARTICLE~+/g, '') - .replace(/~~~ARTICLE~~~[\r\n]*/g, '') - .trim(); - - // Skip if content is empty after cleaning - if (!cleanContent || shouldSkipPlanningCompletion(cleanContent)) { - continue; - } - - const streamTarget = streamTargetRef.current || resolveStreamTarget(cleanContent); - - if (!streamTarget) { - continue; - } - - streamTargetRef.current = streamTarget; - - if (streamTarget === 'timeline') { - updateOrCreateTimelineEntry(cleanContent); - } else { - // This is actual conversational content - add as chat bubble - if (data.type === 'conversational') { - setMessages(prev => [...prev, { role: 'assistant', content: cleanContent }]); - } else { - setMessages(prev => { - const newMessages = [...prev]; - const lastIdx = newMessages.length - 1; - if (newMessages[lastIdx] && newMessages[lastIdx].role === 'assistant') { - newMessages[lastIdx] = { ...newMessages[lastIdx], content: cleanContent }; - } else { - newMessages.push({ role: 'assistant', content: cleanContent }); - } - return newMessages; - }); - } - } - } else if (data.type === 'block') { - // Insert blocks (same as above) - const { insertBlocks } = dispatch('core/block-editor'); - let newBlock; - - if (data.block.blockName === 'core/paragraph') { - const content = data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ''; - newBlock = wp.blocks.createBlock('core/paragraph', { content: content }); - } else if (data.block.blockName === 'core/heading') { - const level = data.block.attrs?.level || 2; - const content = data.block.innerHTML?.match(/(.*?)<\/h[1-6]>/)?.[1] || ''; - newBlock = wp.blocks.createBlock('core/heading', { level: level, content: content }); - } else if (data.block.blockName === 'core/list') { - const listItems = (data.block.innerBlocks || []).map(item => { - const content = item.innerHTML?.match(/

  • (.*?)<\/li>/)?.[1] || ''; - return wp.blocks.createBlock('core/list-item', { content: content }); - }); - newBlock = wp.blocks.createBlock('core/list', { - ...(data.block.attrs || {}), - ordered: data.block.attrs?.ordered || false - }, listItems); - } else if (data.block.blockName === 'core/quote') { - const content = data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ''; - newBlock = wp.blocks.createBlock('core/quote', { value: content }); - } else if (data.block.blockName === 'core/image') { - newBlock = wp.blocks.createBlock('core/image', data.block.attrs || {}); - } - - if (newBlock) { - insertBlocks(newBlock); - } - } else if (data.type === 'complete') { - applyProviderMetadata(data); - clearTimeout(timeout); - setCost({ ...cost, session: cost.session + data.totalCost }); - - // Update timeline to complete - setMessages(prev => { - const newMessages = [...prev]; - const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); - if (lastTimelineIndex !== -1) { - newMessages[lastTimelineIndex] = { - ...newMessages[lastTimelineIndex], - status: 'complete', - message: agentMode === 'planning' ? 'Outline ready.' : 'Article generated successfully!' - }; - } - return newMessages; - }); - setIsLoading(false); - } else if (data.type === 'error') { - clearTimeout(timeout); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: formatAiErrorMessage(data.message || 'An error occurred during article generation', 'Failed to generate plan'), - canRetry: true, - retryType: 'generation' - }]); - setIsLoading(false); - } - } catch (parseError) { - wpawLog.error('Failed to parse streaming data:', line, parseError); - } - } - } - // Clear timeout when streaming completes normally - clearTimeout(timeout); - } - } catch (error) { - clearTimeout(timeout); - wpawLog.error('Article generation error:', error); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: formatAiErrorMessage(error, 'Failed to generate article'), - canRetry: true, - retryType: 'generation' - }]); - setIsLoading(false); - } - }; - - // Render clarification quiz UI. - const renderClarification = () => { - if (!inClarification || questions.length === 0) { - return null; - } - - const currentQuestion = questions[currentQuestionIndex]; - const currentAnswer = answers[currentQuestion.id] || ''; - - // Helper to render single choice options - const renderSingleChoice = () => { - const customInputKey = `${currentQuestion.id}_custom`; - const customValue = answers[customInputKey] || ''; - const isCustomSelected = currentAnswer === '__custom__'; - - return wp.element.createElement('div', { className: 'wpaw-answer-options' }, - currentQuestion.options.map((option, idx) => { - const isSelected = currentAnswer === option.value; - return wp.element.createElement('label', { key: idx }, - wp.element.createElement('input', { - type: 'radio', - name: currentQuestion.id, - checked: isSelected, - onChange: () => { - const newAnswers = { ...answers }; - newAnswers[currentQuestion.id] = option.value; - setAnswers(newAnswers); - }, - }), - wp.element.createElement('span', null, option.value) - ); - }), - // Add custom text input option - wp.element.createElement('div', { className: 'wpaw-custom-answer-wrapper', key: 'custom' }, - wp.element.createElement('label', null, - wp.element.createElement('input', { - type: 'radio', - name: currentQuestion.id, - checked: isCustomSelected, - onChange: () => { - const newAnswers = { ...answers }; - newAnswers[currentQuestion.id] = '__custom__'; - setAnswers(newAnswers); - }, - }), - wp.element.createElement('span', null, 'Other (specify):') - ), - isCustomSelected && wp.element.createElement('input', { - type: 'text', - className: 'wpaw-custom-text-input', - placeholder: 'Type your answer here...', - value: customValue, - onChange: (e) => { - const newAnswers = { ...answers }; - newAnswers[customInputKey] = e.target.value; - setAnswers(newAnswers); - }, - autoFocus: true - }) - ) - ); - }; - - // Helper to render multiple choice options - const renderMultipleChoice = () => { - const selectedValues = currentAnswer ? currentAnswer.split(', ') : []; - - return wp.element.createElement('div', { className: 'wpaw-answer-options' }, - currentQuestion.options.map((option, idx) => { - const isSelected = selectedValues.includes(option.value); - return wp.element.createElement('label', { key: idx }, - wp.element.createElement('input', { - type: 'checkbox', - checked: isSelected, - onChange: () => { - const newAnswers = { ...answers }; - let newSelected = isSelected - ? selectedValues.filter(v => v !== option.value) - : [...selectedValues, option.value]; - newAnswers[currentQuestion.id] = newSelected.join(', '); - setAnswers(newAnswers); - }, - }), - wp.element.createElement('span', null, option.value) - ); - }) - ); - }; - - // Helper to render open text textarea - const renderOpenText = () => { - return wp.element.createElement('div', { className: 'wpaw-answer-options' }, - wp.element.createElement(TextareaControl, { - placeholder: currentQuestion.placeholder || 'Type your answer here...', - value: currentAnswer, - onChange: (value) => { - const newAnswers = { ...answers }; - newAnswers[currentQuestion.id] = value; - setAnswers(newAnswers); - }, - rows: 4, - maxLength: currentQuestion.max_length || 500, - }) - ); - }; - - // Helper to render config form (consolidated config page) - const renderConfigForm = () => { - // Initialize with defaults if no answer exists - let configData = {}; - if (currentAnswer) { - try { - configData = JSON.parse(currentAnswer); - } catch (e) { - configData = {}; - } - } - - // Set defaults from field definitions if not already set - const fields = currentQuestion.fields || []; - fields.forEach(field => { - if (configData[field.id] === undefined && field.default !== undefined) { - configData[field.id] = field.default; - } - }); - - // Initialize answer with defaults on first render - if (!currentAnswer && Object.keys(configData).length > 0) { - const newAnswers = { ...answers }; - newAnswers[currentQuestion.id] = JSON.stringify(configData); - setAnswers(newAnswers); - } - - return wp.element.createElement('div', { className: 'wpaw-config-form' }, - fields.map((field, idx) => { - const fieldValue = configData[field.id] !== undefined ? configData[field.id] : field.default; - const isConditional = field.conditional && !configData[field.conditional]; - - if (isConditional) { - return null; - } - - return wp.element.createElement('div', { key: idx, className: 'wpaw-config-field' }, - 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), - field.description && wp.element.createElement('span', { className: 'wpaw-config-description' }, field.description) - ), - wp.element.createElement('label', { className: 'wpaw-config-toggle' }, - wp.element.createElement('input', { - type: 'checkbox', - checked: fieldValue || false, - onChange: (e) => { - const newConfig = { ...configData }; - newConfig[field.id] = e.target.checked; - const newAnswers = { ...answers }; - newAnswers[currentQuestion.id] = JSON.stringify(newConfig); - setAnswers(newAnswers); - } - }), - 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); - } - }) - ) - ); - }) - ); - }; - - // Render appropriate input type based on question type - let answerInput; - switch (currentQuestion.type) { - case 'single_choice': - answerInput = renderSingleChoice(); - break; - case 'multiple_choice': - answerInput = renderMultipleChoice(); - break; - case 'open_text': - answerInput = renderOpenText(); - break; - case 'config_form': - answerInput = renderConfigForm(); - break; - default: - answerInput = renderSingleChoice(); - } - - return wp.element.createElement('div', { className: 'wpaw-clarification-quiz dark-theme' }, - wp.element.createElement('div', { className: 'wpaw-quiz-header' }, - wp.element.createElement('h3', null, ' Clarification Questions'), - wp.element.createElement('div', { className: 'wpaw-progress-bar' }, - wp.element.createElement('div', { - className: 'wpaw-progress-fill', - style: { width: ((currentQuestionIndex + 1) / questions.length * 100) + '%' } - }) - ), - wp.element.createElement('span', null, `${currentQuestionIndex + 1} of ${questions.length}`) - ), - wp.element.createElement('div', { className: 'wpaw-question-card' }, - 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); - } - }, - 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') - ) - ) - ); - }; - - const startNewConversation = async () => { - if (isLoading || isSessionActionLoading) { - return; - } - try { - setIsSessionActionLoading(true); - const response = await fetch(wpAgenticWriter.apiUrl + '/conversations', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - body: JSON.stringify({ post_id: postId || 0 }), - }); - if (!response.ok) { - throw new Error('Failed to create a new conversation'); - } - const data = await response.json(); - // Fully reset state for clean slate - isHydratingSessionRef.current = true; - if (data?.session_id) { - setCurrentSessionId(data.session_id); - } - lastPersistedMessagesRef.current = JSON.stringify([]); - setMessages([]); - currentPlanRef.current = null; - setAgentMode('chat'); - setShowWelcome(false); - setFocusKeywordSuggestions([]); - setSelectedFocusKeyword(''); - setProviderInfo(null); - await loadPostSessions(); - setTimeout(() => { - isHydratingSessionRef.current = false; - // Focus input - if (inputRef.current) { - inputRef.current.focus(); - } - }, 50); - } catch (error) { - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'Error: Failed to start a new conversation.', - }]); - } finally { - setIsSessionActionLoading(false); - } - }; - - const deleteConversationSession = async (sessionId) => { - if (!sessionId || isSessionActionLoading) { - return; - } - if (!window.confirm('Delete this session permanently?')) { - return; - } - try { - setIsSessionActionLoading(true); - const response = await fetch(`${wpAgenticWriter.apiUrl}/conversations/${sessionId}`, { - method: 'DELETE', - headers: { - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - }); - if (!response.ok) { - throw new Error('Failed to delete session'); - } - const sessions = await loadPostSessions(); - if (currentSessionId === sessionId) { - const replacement = sessions[0]?.session_id || ''; - setCurrentSessionId(replacement); - setMessages(Array.isArray(sessions[0]?.messages) ? sessions[0].messages : []); - } - } catch (error) { - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'Error: Failed to delete session.', - }]); - } finally { - setIsSessionActionLoading(false); - } - }; - - const getSessionDisplayTitle = (session, index) => { - if (session?.title && session.title.trim()) { - return session.title.trim(); - } - const firstUser = Array.isArray(session?.messages) - ? session.messages.find((m) => m?.role === 'user' && typeof m?.content === 'string' && m.content.trim()) - : null; - if (firstUser?.content) { - return firstUser.content.trim().slice(0, 56); - } - const updatedRaw = session?.updated_at || session?.last_activity || ''; - if (updatedRaw) { - const d = new Date(updatedRaw); - if (!Number.isNaN(d.getTime())) { - return `Session ${index + 1} - ${d.toLocaleDateString()}`; - } - } - return `Session ${index + 1}`; - }; - const getSessionDebugMeta = (session) => { - const id = Number(session?.id || 0); - const sid = String(session?.session_id || '-'); - const pid = Number(session?.post_id || 0); - const postStatus = String(session?.post_status || '').toLowerCase(); - const statusLabel = pid === 0 - ? 'unassigned' - : (postStatus || 'unknown'); - return `id: ${id || '-'} | sid: ${sid} | post: ${pid} | status: ${statusLabel}`; - }; - - // Render Welcome Screen (chatty, friendly) - const renderWelcomeScreen = () => { - const recentSession = availableSessions.length > 0 ? availableSessions[0] : null; - - 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' }, 'Agentic Writer'), - wp.element.createElement('p', { className: 'wpaw-welcome-subtitle' }, "What are we writing today?"), - // Show single "Continue last conversation" button if available - recentSession && wp.element.createElement('button', { - className: 'wpaw-welcome-pill', - style: { width: '100%', marginBottom: '12px' }, - disabled: isSessionActionLoading, - onClick: () => openSessionById(recentSession.session_id || '') - }, `↩ Continue: ${getSessionDisplayTitle(recentSession, 0)}`), - // Show older sessions in collapsible - availableSessions.length > 1 && wp.element.createElement('details', { - style: { marginBottom: '12px', width: '100%' } - }, - wp.element.createElement('summary', { - style: { fontSize: '12px', color: '#8b95a5', cursor: 'pointer', marginBottom: '8px' } - }, `${availableSessions.length - 1} more session${availableSessions.length > 2 ? 's' : ''}`), - wp.element.createElement('div', { className: 'wpaw-session-list' }, - ...availableSessions.slice(1).map((session, idx) => - wp.element.createElement('div', { - key: session.session_id || idx, - className: 'wpaw-welcome-pill', - style: { - width: '100%', - marginBottom: '6px', - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - gap: '8px' - } - }, - wp.element.createElement('button', { - type: 'button', - disabled: isSessionActionLoading, - className: 'wpaw-session-open-btn', - style: { - flex: 1, - background: 'transparent', - border: 'none', - color: 'inherit', - textAlign: 'left', - cursor: isSessionActionLoading ? 'wait' : 'pointer' - }, - onClick: () => openSessionById(session.session_id || '') - }, - wp.element.createElement('div', null, getSessionDisplayTitle(session, idx + 1)), - wp.element.createElement('div', { style: { opacity: 0.7, fontSize: '11px' } }, - `${Number(session?.message_count ?? (Array.isArray(session?.messages) ? session.messages.length : 0))} msgs` - ) - ), - wp.element.createElement('button', { - type: 'button', - title: 'Delete session', - disabled: isSessionActionLoading, - style: { - background: 'transparent', - border: '1px solid rgba(255,255,255,0.25)', - color: 'inherit', - borderRadius: '6px', - padding: '2px 6px', - cursor: 'pointer' - }, - onClick: () => deleteConversationSession(session.session_id) - }, '×') - ) - ) - ) - ), - // Focus keyword input - wp.element.createElement('input', { - type: 'text', - className: 'wpaw-welcome-input', - placeholder: 'Focus keyword (optional)', - value: welcomeKeywordInput, - onChange: (e) => setWelcomeKeywordInput(e.target.value), - onKeyDown: (e) => { - if (e.key === 'Enter') { - handleWelcomeStart(); - } - } - }), - // 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') - }, '📝 Create Outline') - ), - // Start button - wp.element.createElement(Button, { - isPrimary: true, - onClick: handleWelcomeStart, - className: 'wpaw-welcome-start-btn' - }, 'Start Writing') - ) - ); - }; - - // Render Writing mode empty state - 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', { - className: 'wpaw-empty-state-icon', - 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'), - className: 'wpaw-empty-state-button' - }, - wp.element.createElement('div', { - style: { display: 'inline-flex', alignItems: 'center', gap: '8px' } - }, - wp.element.createElement('svg', { - xmlns: "http://www.w3.org/2000/svg", - width: "18", - height: "18", - viewBox: "0 0 24 24" - }, - wp.element.createElement('path', { - fill: "none", - stroke: "currentColor", - strokeLinecap: "round", - strokeLinejoin: "round", - strokeWidth: "1", - d: "M16 5H3m13 7H3m8 7H3m12-1l2 2l4-4" - }) - ), - 'Switch to Planning Mode' - ) - ), - 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 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' - }, '↓') - ), - // 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(); - } - } - }) - ), - // 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)}`), - providerInfo && wp.element.createElement('span', { - className: 'wpaw-provider-info', - title: providerInfo.warnings.length > 0 ? providerInfo.warnings.join('; ') : 'AI provider used' - }, - providerInfo.fallbackUsed ? ' ⚠️ ' + (providerInfo.provider || 'fallback') : ' 📡 ' + (providerInfo.provider || 'AI') - ), - 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)}`, - providerInfo && wp.element.createElement('span', { - className: 'wpaw-provider-badge', - title: providerInfo.warnings.length > 0 ? providerInfo.warnings.join('; ') : 'AI provider' - }, - providerInfo.fallbackUsed ? '⚠' : '📡' - ) - ), - wp.element.createElement('button', { - className: 'wpaw-fk-expand', - onClick: () => setIsTextareaExpanded(true), - title: 'Expand' - }, - wp.element.createElement('svg', { - xmlns: "http://www.w3.org/2000/svg", - width: "16", - height: "16", - viewBox: "0 0 24 24" - }, - wp.element.createElement('path', { - fill: "none", - stroke: "currentColor", - strokeLinecap: "round", - strokeLinejoin: "round", - 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: '📝', - title: 'Ready to create an outline?', - description: 'I\'ll generate a structured outline based on our conversation.', - button: 'Create Outline Now', - onClick: async () => { - // Switch to planning mode - setAgentMode('planning'); - - // 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 { - wpawLog.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, - sessionId: currentSessionId, - mode: 'generation', - postConfig: postConfig, - chatHistory: buildChatHistoryPayload(), - }), - }); - - wpawLog.log('[WPAW] Clarity response status:', clarityResponse.status); - - if (!clarityResponse.ok) { - const errorText = await clarityResponse.text(); - wpawLog.error('[WPAW] Clarity check failed:', errorText); - throw new Error('Clarity check failed: ' + errorText); - } - - const clarityData = await clarityResponse.json(); - applyProviderMetadata(clarityData); - const clarityResult = clarityData.result; - wpawLog.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) { - wpawLog.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 { - wpawLog.warn('[WPAW] No questions returned from clarity check!'); - } - } catch (clarityError) { - wpawLog.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, - sessionId: currentSessionId, - answers: [], - autoExecute: false, - stream: true, - articleLength: postConfig.article_length, - detectedLanguage: detectedLanguage, - postConfig: postConfig, - chatHistory: buildChatHistoryPayload(), - }), - }); - - if (!response.ok) { - const error = await response.json(); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: formatAiErrorMessage(error, 'Failed to generate outline'), - canRetry: true, - retryType: 'generation' - }]); - 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) { - wpawLog.error('Failed to parse streaming data:', parseError); - } - } - } - } - - setIsLoading(false); - } catch (error) { - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: formatAiErrorMessage(error, 'Failed to generate outline'), - canRetry: true, - retryType: 'generation' - }]); - setIsLoading(false); - } - } - } - }; - - 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 = () => { - const normalizeMessageContent = (content) => { - if (content === null || content === undefined) { - return ''; - } - if (typeof content === 'string' || typeof content === 'number') { - return String(content); - } - return JSON.stringify(content); - }; - const escapeHtml = (value) => { - return String(value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - }; - const inlineMarkdownToHtml = (text) => { - let html = escapeHtml(text); - html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, url) => ( - `${label}` - )); - html = html.replace(/`([^`]+)`/g, (match, code) => `${escapeHtml(code)}`); - html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); - html = html.replace(/__([^_]+)__/g, '$1'); - html = html.replace(/\*([^*]+)\*/g, '$1'); - html = html.replace(/_([^_]+)_/g, '$1'); - return html; - }; - const markdownToHtml = (markdown) => { - const raw = normalizeMessageContent(markdown); - if (!raw) { - return ''; - } - - if (window.markdownit && window.DOMPurify) { - if (!markdownRendererRef.current) { - const renderer = window.markdownit({ - html: false, - linkify: true, - breaks: false, - }); - if (window.markdownitTaskLists) { - renderer.use(window.markdownitTaskLists, { enabled: true, label: true, labelAfter: true }); - } - const defaultLinkOpen = renderer.renderer.rules.link_open || function (tokens, idx, options, env, self) { - return self.renderToken(tokens, idx, options); - }; - renderer.renderer.rules.link_open = function (tokens, idx, options, env, self) { - const token = tokens[idx]; - const targetIndex = token.attrIndex('target'); - if (targetIndex < 0) { - token.attrPush(['target', '_blank']); - } else { - token.attrs[targetIndex][1] = '_blank'; - } - const relIndex = token.attrIndex('rel'); - if (relIndex < 0) { - token.attrPush(['rel', 'noopener noreferrer']); - } else { - token.attrs[relIndex][1] = 'noopener noreferrer'; - } - return defaultLinkOpen(tokens, idx, options, env, self); - }; - markdownRendererRef.current = renderer; - } - - const rendered = markdownRendererRef.current.render(raw); - return window.DOMPurify.sanitize(rendered, { - USE_PROFILES: { html: true }, - ADD_TAGS: ['input', 'label'], - ADD_ATTR: ['type', 'checked', 'disabled', 'class'], - }); - } - - const codeBlocks = []; - let text = raw.replace(/```(\w+)?\n([\s\S]*?)```/g, (match, lang, code) => { - const safeLang = lang ? ` class="language-${escapeHtml(lang)}"` : ''; - const index = codeBlocks.length; - codeBlocks.push(`

    ${escapeHtml(code)}
    `); - return `@@CODEBLOCK${index}@@`; - }); - - const lines = text.split(/\r?\n/); - let html = ''; - let paragraph = []; - let list = null; - let detailBreak = false; - let lastLineWasListItem = false; - - const flushParagraph = () => { - if (paragraph.length) { - html += `

    ${inlineMarkdownToHtml(paragraph.join(' '))}

    `; - paragraph = []; - } - }; - const flushList = () => { - if (list) { - const items = list.items.map((item) => { - const details = item.details && item.details.length > 0 - ? item.details.map((detail) => `

    ${inlineMarkdownToHtml(detail)}

    `).join('') - : ''; - const children = item.children && item.children.length > 0 - ? `` - : ''; - return `
  • ${inlineMarkdownToHtml(item.content)}${details}${children}
  • `; - }).join(''); - html += `<${list.type}>${items}`; - list = null; - } - }; - const addListItem = (targetList, value) => { - targetList.items.push({ content: value, children: [], details: [] }); - lastLineWasListItem = true; - }; - const addDetailToLastItem = (targetList, value, newParagraph) => { - const lastItem = targetList.items[targetList.items.length - 1]; - if (!lastItem) { - return; - } - if (newParagraph || lastItem.details.length === 0) { - lastItem.details.push(value); - } else { - lastItem.details[lastItem.details.length - 1] += ` ${value}`; - } - lastLineWasListItem = false; - }; - - const getListType = (value) => { - if (/^\d+\.\s+/.test(value)) { - return 'ol'; - } - if (/^[-*+]\s+/.test(value)) { - return 'ul'; - } - return null; - }; - - for (let i = 0; i < lines.length; i++) { - const trimmed = lines[i].trim(); - if (trimmed === '') { - let nextIndex = i + 1; - while (nextIndex < lines.length && lines[nextIndex].trim() === '') { - nextIndex += 1; - } - const nextLine = nextIndex < lines.length ? lines[nextIndex].trim() : ''; - const nextType = getListType(nextLine); - if (list && nextType && nextType === list.type) { - continue; - } - if ( - list - && list.type === 'ol' - && nextLine - && !nextType - && !nextLine.startsWith('@@CODEBLOCK') - && ! /^(#{1,6})\s+/.test(nextLine) - ) { - detailBreak = true; - lastLineWasListItem = false; - continue; - } - flushList(); - flushParagraph(); - lastLineWasListItem = false; - continue; - } - - if (trimmed.startsWith('@@CODEBLOCK')) { - flushList(); - flushParagraph(); - html += trimmed; - lastLineWasListItem = false; - continue; - } - - const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/); - if (headingMatch) { - flushList(); - flushParagraph(); - const level = headingMatch[1].length; - html += `${inlineMarkdownToHtml(headingMatch[2])}`; - lastLineWasListItem = false; - continue; - } - - const unorderedMatch = trimmed.match(/^[-*+]\s+(.*)$/); - const orderedMatch = trimmed.match(/^\d+\.\s+(.*)$/); - if (unorderedMatch || orderedMatch) { - flushParagraph(); - detailBreak = false; - const type = orderedMatch ? 'ol' : 'ul'; - let value = (orderedMatch ? orderedMatch[1] : unorderedMatch[1]) || ''; - if (orderedMatch) { - value = value.replace(/^\d+\.\s+/, ''); - } - if (!orderedMatch && list && list.type === 'ol' && list.items.length > 0) { - list.items[list.items.length - 1].children.push(value); - continue; - } - if (!list || list.type !== type) { - flushList(); - list = { type, items: [] }; - } - addListItem(list, value); - continue; - } - - if (list && list.type === 'ol' && (lastLineWasListItem || detailBreak)) { - addDetailToLastItem(list, trimmed, detailBreak); - detailBreak = false; - continue; - } - - if (list) { - flushList(); - } - paragraph.push(trimmed); - lastLineWasListItem = false; - } - - flushList(); - flushParagraph(); - - codeBlocks.forEach((block, index) => { - html = html.replace(`@@CODEBLOCK${index}@@`, block); - }); - - return html; - }; - const renderMessageContent = (content, allowMarkdown) => { - if (!allowMarkdown) { - return normalizeMessageContent(content); - } - return wp.element.createElement(RawHTML, null, markdownToHtml(content)); - }; - - const lastActiveTimelineIndex = findLastActiveTimelineIndex(messages); - const groups = []; - let currentAiGroup = null; - - messages.forEach((message, index) => { - if (message.role === 'user') { - groups.push({ type: 'user', message, key: `user-${index}` }); - currentAiGroup = null; - return; - } - - if (!currentAiGroup) { - currentAiGroup = { type: 'ai', items: [], key: `ai-${index}` }; - groups.push(currentAiGroup); - } - - currentAiGroup.items.push({ message, index }); - }); - - return groups.map((group, groupIndex) => { - if (group.type === 'user') { - return wp.element.createElement('div', { - key: group.key, - className: 'wpaw-message wpaw-message-user', - }, - wp.element.createElement('div', { className: 'wpaw-message-content' }, renderMessageContent(group.message.content, false)) - ); - } - - const isLastGroup = groupIndex === groups.length - 1; - let streamingLabel = 'Streaming...'; - for (let i = group.items.length - 1; i >= 0; i--) { - const item = group.items[i].message; - if (item.type === 'timeline' && item.status) { - if (item.status === 'checking') { - streamingLabel = 'Analyzing...'; - } else if (item.status === 'planning' || item.status === 'plan_complete') { - streamingLabel = 'Planning...'; - } else if (item.status === 'writing' || item.status === 'writing_section') { - streamingLabel = 'Writing...'; - } else if (item.status === 'refining') { - streamingLabel = 'Refining...'; - } else { - streamingLabel = 'Streaming...'; - } - break; - } - } - - return wp.element.createElement('div', { - key: group.key, - className: 'wpaw-ai-response', - }, - group.items.map((item, itemIndex) => { - const message = item.message; - const index = item.index; - const isLastItem = itemIndex === group.items.length - 1; - - if (message.type === 'timeline') { - const statusClass = message.status === 'complete' - ? 'complete' - : message.status === 'inactive' - ? 'inactive' - : 'active'; - const showProcessing = isLoading && message.status === 'refining'; - const elapsedTime = message.status === 'complete' && message.timestamp && message.completedAt - ? ((new Date(message.completedAt) - new Date(message.timestamp)) / 1000).toFixed(1) + 's' - : null; - return wp.element.createElement('div', { - key: `timeline-${index}`, - className: 'wpaw-ai-item wpaw-timeline-entry ' + statusClass + (index === lastActiveTimelineIndex ? ' is-current' : ''), - }, - wp.element.createElement('div', { className: 'wpaw-timeline-dot', 'aria-hidden': 'true' }), - wp.element.createElement('div', { className: 'wpaw-timeline-content' }, - wp.element.createElement('div', { className: 'wpaw-timeline-message' }, normalizeMessageContent(message.message)), - message.status === 'complete' && wp.element.createElement('div', { className: 'wpaw-timeline-complete' }, - '✓ Complete', - elapsedTime && wp.element.createElement('span', { className: 'wpaw-timeline-elapsed' }, ` (${elapsedTime})`) - ), - showProcessing && wp.element.createElement('div', { className: 'wpaw-processing-indicator' }, - wp.element.createElement('span', { className: 'wpaw-dots-loader' }), - wp.element.createElement('span', null, 'Processing updates…') - ), - !showProcessing && isLoading && isLastGroup && isLastItem && wp.element.createElement('div', { - 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) - ) - ) - ) - ); - } - - if (message.type === 'plan') { - const plan = ensurePlanTasks(message.plan); - const sections = Array.isArray(plan?.sections) ? plan.sections : []; - const getSectionSummary = (section) => { - if (section.description) { - return section.description; - } - if (Array.isArray(section.content) && section.content.length > 0) { - const firstItem = section.content.find((item) => item && item.content); - return firstItem ? firstItem.content : ''; - } - return ''; - }; - const pendingCount = sections.filter((section) => section.status !== 'done').length; - const buttonLabel = pendingCount ? `Write ${pendingCount} Pending` : 'Write Article'; - - // Build config summary - const configSummary = []; - 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}`); - } - if (postConfig.web_search) { - configSummary.push('🔍 Web Search: Enabled'); - } - if (postConfig.seo_enabled) { - const seoDetails = []; - if (postConfig.seo_focus_keyword) { - seoDetails.push(`Focus: "${postConfig.seo_focus_keyword}"`); - } - if (postConfig.seo_secondary_keywords) { - seoDetails.push(`Secondary: "${postConfig.seo_secondary_keywords}"`); - } - configSummary.push(`📊 SEO: Enabled${seoDetails.length ? ' (' + seoDetails.join(', ') + ')' : ''}`); - } - - return wp.element.createElement('div', { - key: `plan-${index}`, - className: 'wpaw-ai-item wpaw-plan-card', - }, - wp.element.createElement('div', { className: 'wpaw-plan-title' }, plan?.title || 'Proposed Outline'), - wp.element.createElement('div', { className: 'wpaw-plan-config-summary' }, - configSummary.map((item, idx) => wp.element.createElement('div', { key: idx, className: 'wpaw-config-summary-item' }, item)) - ), - sections.length > 0 && wp.element.createElement('ol', { className: 'wpaw-plan-sections' }, - sections.map((section, sectionIndex) => wp.element.createElement('li', { - key: `plan-section-${sectionIndex}`, - className: `wpaw-plan-section ${section.status || 'pending'}`, - }, - wp.element.createElement('div', { className: 'wpaw-plan-section-row' }, - wp.element.createElement('input', { - className: 'wpaw-plan-section-check', - type: 'checkbox', - checked: section.status === 'done', - readOnly: true, - disabled: true, - }), - wp.element.createElement('div', { className: 'wpaw-plan-section-body' }, - wp.element.createElement('div', { className: 'wpaw-plan-section-title' }, section.title || section.heading || `Section ${sectionIndex + 1}`), - getSectionSummary(section) && wp.element.createElement('div', { className: 'wpaw-plan-section-desc' }, getSectionSummary(section)) - ), - wp.element.createElement('div', { className: 'wpaw-plan-section-status' }, section.status === 'done' ? 'Done' : section.status === 'in_progress' ? 'Writing' : 'Pending') - ) - )) - ), - !sections.length && plan?.summary && wp.element.createElement('div', { className: 'wpaw-plan-section-desc' }, plan.summary), - wp.element.createElement('div', { className: 'wpaw-plan-actions' }, - wp.element.createElement(Button, { - isPrimary: true, - onClick: executePlanFromCard, - disabled: isLoading, - }, buttonLabel) - ) - ); - } - - if (message.type === 'edit_plan') { - const plan = message.plan || pendingEditPlan; - const isPlanActive = Boolean(pendingEditPlan) && plan === pendingEditPlan; - const actions = normalizePlanActions(plan); - const allBlocks = select('core/block-editor').getBlocks(); - const existingIds = new Set(allBlocks.map((block) => block.clientId)); - const previewActions = actions.filter((action) => { - if (action.action === 'keep') { - return false; - } - if (action.blockId && !existingIds.has(action.blockId)) { - return false; - } - return true; - }); - const actionCount = previewActions.length; - const summary = plan?.summary || `Proposed changes: ${actionCount}`; - const previewItems = previewActions.map((action, actionIndex) => ( - buildPlanPreviewItem(action, actionIndex) - )); - - return wp.element.createElement('div', { - key: `plan-${index}`, - className: 'wpaw-ai-item wpaw-edit-plan', - }, - wp.element.createElement('div', { className: 'wpaw-edit-plan-title' }, 'Proposed Changes'), - wp.element.createElement('div', { className: 'wpaw-edit-plan-summary' }, summary), - previewItems.length > 0 && wp.element.createElement('div', { className: 'wpaw-edit-plan-preview-label' }, 'Apply preview'), - previewItems.length > 0 && wp.element.createElement('ol', { className: 'wpaw-edit-plan-list' }, - previewItems.map((item, itemIndex) => wp.element.createElement('li', { - key: `plan-action-${itemIndex}`, - className: 'wpaw-edit-plan-item', - }, - wp.element.createElement('div', { className: 'wpaw-edit-plan-item-title' }, item.title), - item.target && wp.element.createElement('button', { - type: 'button', - className: 'wpaw-edit-plan-item-target', - disabled: !isPlanActive, - onClick: () => { - if (!isPlanActive || !item.blockId) { - return; - } - dispatch('core/block-editor').selectBlock(item.blockId); - const targetNode = document.querySelector(`[data-block="${item.blockId}"]`); - if (targetNode) { - targetNode.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - }, - }, `${item.targetLabel} ${item.target}`), - item.before && wp.element.createElement('div', { className: 'wpaw-edit-plan-item-before' }, `Before ${item.before}`), - item.after && wp.element.createElement('div', { className: 'wpaw-edit-plan-item-after' }, `Add ${item.after}`) - )) - ), - wp.element.createElement('div', { className: 'wpaw-edit-plan-actions' }, - wp.element.createElement(Button, { - isPrimary: true, - onClick: () => applyEditPlan(plan), - disabled: !plan || !isPlanActive - }, `Apply (${actionCount})`), - wp.element.createElement(Button, { - isSecondary: true, - onClick: cancelEditPlan, - disabled: !isPlanActive - }, 'Cancel') - ) - ); - } - - if (message.type === 'error') { - const handleRetry = () => { - if (message.retryType === 'execute') { - retryLastExecute(); - return; - } - if (message.retryType === 'refine') { - retryLastRefinement(); - return; - } - if (message.retryType === 'chat') { - retryLastChat(); - return; - } - retryLastGeneration(); - }; - - // Support structured error objects { title, detail, actionUrl, actionLabel } - const errContent = message.content; - const isStructured = errContent && typeof errContent === 'object' && errContent.title; - - return wp.element.createElement('div', { - key: `error-${index}`, - className: 'wpaw-ai-item wpaw-message wpaw-message-error', - }, - isStructured - ? wp.element.createElement('div', null, - wp.element.createElement('div', { className: 'wpaw-error-title' }, '⚠ ', errContent.title), - errContent.detail && wp.element.createElement('div', { className: 'wpaw-error-detail' }, errContent.detail), - errContent.actionUrl && wp.element.createElement('a', { - href: errContent.actionUrl, - target: '_blank', - rel: 'noopener', - style: { display: 'inline-block', marginTop: '8px', fontSize: '12px', color: '#fca5a5', textDecoration: 'underline' } - }, errContent.actionLabel || 'Open Settings') - ) - : wp.element.createElement('div', { className: 'wpaw-message-content' }, renderMessageContent(errContent, true)), - message.canRetry && wp.element.createElement(Button, { - isSecondary: true, - onClick: handleRetry, - }, '↻ Retry') - ); - } - - return wp.element.createElement('div', { - key: `response-${index}`, - className: 'wpaw-ai-item wpaw-response', - }, - wp.element.createElement('div', { className: 'wpaw-response-content' }, renderMessageContent(message.content, true)), - isLoading && isLastGroup && isLastItem && wp.element.createElement('div', { - 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, { - isPrimary: true, - onClick: () => { - setExecutionStopped(false); - executePlanFromCard(); - }, - style: { marginRight: '8px' } - }, `Resume Writing (${message.pendingCount} pending)`), - wp.element.createElement(Button, { - isSecondary: true, - onClick: () => { - setExecutionStopped(false); - setAgentMode('planning'); - } - }, 'Switch to Planning') - ) - ); - }) - ); - }); - }; - - // Render Config Tab - // Render Config Tab - Updated for Dark Theme - - const renderConfigTab = () => { - const isConfigDisabled = isLoading || isConfigLoading || isConfigSaving; - - return wp.element.createElement('div', { className: 'wpaw-tab-content wpaw-config-tab dark-theme' }, - // Back Header - wp.element.createElement('div', { className: 'wpaw-tab-header' }, - wp.element.createElement('button', { - className: 'wpaw-back-btn', - onClick: () => setActiveTab('chat') - }, '← Back'), - wp.element.createElement('h3', null, 'CONFIGURATION') - ), - - wp.element.createElement('div', { className: 'wpaw-config-section' }, - wp.element.createElement('label', null, 'DEFAULT MODE'), - wp.element.createElement('select', { - value: postConfig.default_mode === 'writing' ? 'chat' : postConfig.default_mode, - onChange: (e) => { - updatePostConfig('default_mode', e.target.value); - setAgentMode(e.target.value); - }, - disabled: isConfigDisabled, - className: 'wpaw-select' - }, - wp.element.createElement('option', { value: 'planning' }, 'Planning'), - wp.element.createElement('option', { value: 'chat' }, 'Chat') - ), - wp.element.createElement('p', { className: 'description' }, - 'Controls which mode opens by default for this post.' - ) - ), - 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('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, - onChange: (e) => updatePostConfig('experience_level', e.target.value), - disabled: isConfigDisabled, - className: 'wpaw-select' - }, - wp.element.createElement('option', { value: 'general' }, 'General audience'), - wp.element.createElement('option', { value: 'beginner' }, 'Beginner'), - wp.element.createElement('option', { value: 'intermediate' }, 'Intermediate'), - wp.element.createElement('option', { value: 'advanced' }, 'Advanced') - ) - ), - wp.element.createElement('div', { className: 'wpaw-config-section' }, - wp.element.createElement(CheckboxControl, { - label: 'Include image suggestions', - checked: Boolean(postConfig.include_images), - onChange: (value) => updatePostConfig('include_images', value), - disabled: isConfigDisabled, - }), - wp.element.createElement('p', { className: 'description' }, - 'When enabled, the agent will add image placeholders.' - ) - ), - wp.element.createElement('div', { className: 'wpaw-config-section' }, - wp.element.createElement(CheckboxControl, { - label: 'Enable web search for outlines', - checked: Boolean(postConfig.web_search), - onChange: (value) => updatePostConfig('web_search', value), - disabled: isConfigDisabled, - }), - wp.element.createElement('p', { className: 'description' }, - 'Uses web search when planning outlines.' - ) - ), - - // SEO Section - wp.element.createElement('div', { className: 'wpaw-config-divider' }, - wp.element.createElement('span', null, '🔍 SEO OPTIMIZATION') - ), - wp.element.createElement('div', { className: 'wpaw-config-section' }, - wp.element.createElement(CheckboxControl, { - label: 'Enable SEO optimization', - checked: Boolean(postConfig.seo_enabled), - onChange: (value) => updatePostConfig('seo_enabled', value), - disabled: isConfigDisabled, - }), - wp.element.createElement('p', { className: 'description' }, - 'Include SEO guidelines in AI prompts for keyword-optimized content.' - ) - ), - postConfig.seo_enabled && wp.element.createElement('div', { className: 'wpaw-config-section' }, - wp.element.createElement(TextControl, { - label: 'Focus Keyword', - value: postConfig.seo_focus_keyword, - onChange: (value) => updatePostConfig('seo_focus_keyword', value), - disabled: isConfigDisabled, - placeholder: 'e.g., wordpress seo plugin', - }), - wp.element.createElement('p', { className: 'description' }, - 'Primary keyword to optimize content for. Will be included in title, headings, and body.' - ) - ), - postConfig.seo_enabled && wp.element.createElement('div', { className: 'wpaw-config-section' }, - wp.element.createElement(TextControl, { - label: 'Secondary Keywords', - value: postConfig.seo_secondary_keywords, - onChange: (value) => updatePostConfig('seo_secondary_keywords', value), - disabled: isConfigDisabled, - placeholder: 'e.g., content optimization, search ranking', - }), - wp.element.createElement('p', { className: 'description' }, - 'Comma-separated related keywords to sprinkle throughout content.' - ) - ), - postConfig.seo_enabled && wp.element.createElement('div', { className: 'wpaw-config-section' }, - wp.element.createElement(TextareaControl, { - label: 'Meta Description', - value: postConfig.seo_meta_description, - onChange: (value) => updatePostConfig('seo_meta_description', value), - disabled: isConfigDisabled, - placeholder: 'Enter meta description (120-160 chars recommended)', - rows: 3, - }), - wp.element.createElement('div', { className: 'wpaw-meta-info' }, - 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, { - isSecondary: true, - isSmall: true, - onClick: () => generateMetaDescription(), - disabled: isConfigDisabled || isGeneratingMeta, - }, - isGeneratingMeta ? - wp.element.createElement('span', { - style: { display: 'flex', alignItems: 'center', gap: '5px' } - }, - wp.element.createElement('span', { - className: 'wpaw-spinning-icon', - dangerouslySetInnerHTML: { - __html: '' - } - }), - ' Generating...' - ) : - wp.element.createElement('span', { - style: { display: 'flex', alignItems: 'center', gap: '5px' } - }, - wp.element.createElement('span', { - className: 'wpaw-svg-wrapper', - dangerouslySetInnerHTML: { - __html: '' - } - }), - ' Generate' - ) - ) - ) - ), - - // SEO Audit Section - postConfig.seo_enabled && wp.element.createElement('div', { className: 'wpaw-config-section wpaw-seo-audit' }, - wp.element.createElement('div', { className: 'wpaw-seo-audit-header' }, - wp.element.createElement('label', null, 'SEO Audit'), - wp.element.createElement(Button, { - isSecondary: true, - isSmall: true, - onClick: () => runSeoAudit(), - disabled: isConfigDisabled || isSeoAuditing, - }, - isSeoAuditing ? - wp.element.createElement('span', { - 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', { - 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' }, - wp.element.createElement('div', { className: 'wpaw-seo-score ' + (seoAudit.score >= 70 ? 'good' : seoAudit.score >= 40 ? 'warning' : 'poor') }, - wp.element.createElement('span', { className: 'score-value' }, seoAudit.score), - wp.element.createElement('span', { className: 'score-label' }, '/100') - ), - wp.element.createElement('div', { className: 'wpaw-seo-stats' }, - wp.element.createElement('div', { className: 'wpaw-seo-stat' }, - wp.element.createElement('span', { className: 'stat-label' }, 'Words'), - wp.element.createElement('span', { className: 'stat-value' }, seoAudit.word_count || 0) - ), - wp.element.createElement('div', { className: 'wpaw-seo-stat' }, - wp.element.createElement('span', { className: 'stat-label' }, 'Keyword Density'), - wp.element.createElement('span', { className: 'stat-value' }, `${(seoAudit.keyword_density || 0).toFixed(1)}%`) - ) - ), - 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', { - key: idx, - className: 'wpaw-seo-check ' + (isPassed ? 'passed' : 'failed') - }, - wp.element.createElement('span', { className: 'check-icon' }, isPassed ? '✓' : '✗'), - wp.element.createElement('span', { className: 'check-label' }, check.message) - ); - }) - ) - ), - !seoAudit && wp.element.createElement('p', { className: 'description' }, - 'Click "Run Audit" to analyze your content for SEO optimization.' - ) - ), - - (isConfigSaving || configError) && wp.element.createElement('div', { className: 'wpaw-config-section' }, - isConfigSaving && wp.element.createElement('p', { className: 'description' }, 'Saving post configuration...'), - configError && wp.element.createElement('p', { className: 'description' }, configError) - ), - wp.element.createElement('div', { className: 'wpaw-config-section' }, - wp.element.createElement('p', { className: 'description' }, - 'Configure global settings like API keys, models, and clarification quiz options in ', - wp.element.createElement('a', { - href: settings.settings_url || '/wp-admin/options-general.php?page=wp-agentic-writer', - target: '_blank' - }, 'Settings → WP Agentic Writer') - ) - ) - ); - }; - - // Render Chat Tab - const renderChatTab = () => { - // Determine agent status - const getAgentStatus = () => { - if (!isLoading) return 'idle'; - const lastMsg = messages.filter(m => m.type === 'timeline').pop(); - if (lastMsg?.message?.toLowerCase().includes('writing')) return 'writing'; - if (lastMsg?.message?.toLowerCase().includes('generating')) return 'writing'; - return 'thinking'; - }; - const agentStatus = getAgentStatus(); - const statusLabels = { idle: 'Ready', thinking: 'Thinking...', writing: 'Writing...', complete: 'Done', error: 'Error' }; - - return wp.element.createElement('div', { className: 'wpaw-tab-content wpaw-chat-tab dark-theme' }, - renderClarification(), - !inClarification && wp.element.createElement('div', { className: 'wpaw-chat-container' }, - // 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]) - ), - wp.element.createElement('div', { className: 'wpaw-status-actions' }, - !showWelcome && wp.element.createElement('button', { - className: 'wpaw-status-icon-btn', - title: 'Back to Sessions', - onClick: () => setShowWelcome(true), - disabled: isLoading - }, 'Sessions'), - // Undo Button - aiUndoStack.length > 0 && wp.element.createElement('button', { - className: 'wpaw-status-icon-btn wpaw-undo-btn', - title: `Undo: ${aiUndoStack[aiUndoStack.length - 1]?.label || 'Last AI operation'}`, - onClick: undoLastAiOperation, - disabled: isLoading - }, '↩️'), - // Cost Label - // 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', - dangerouslySetInnerHTML: { __html: '' }, - title: 'Configuration', - onClick: () => setActiveTab('config'), - disabled: isLoading - }), - // Cost Icon Button - wp.element.createElement('button', { - className: 'wpaw-status-icon-btn', - dangerouslySetInnerHTML: { __html: '' }, - title: 'Cost Tracking', - onClick: () => setActiveTab('cost'), - disabled: isLoading - }) - ) - ), - // Editor Lock Banner - isEditorLocked && wp.element.createElement('div', { className: 'wpaw-editor-lock-banner' }, - 'Writing in progress — please wait until the article finishes.' - ), - isRefinementLocked && wp.element.createElement('div', { className: 'wpaw-refinement-lock-banner' }, - `Refining in progress — editing is temporarily locked. You can still scroll and review changes live (${refiningBlockIds.length} target block(s)).` - ), - // Health Check Warnings - wpAgenticWriter.health && !wpAgenticWriter.health.ok && wpAgenticWriter.health.issues.map((issue, idx) => - wp.element.createElement('div', { key: `health-${idx}`, className: 'wpaw-health-notice' }, - '⚠️ ', - issue.message, - issue.actionUrl && wp.element.createElement('a', { - href: issue.actionUrl, - target: '_blank', - rel: 'noopener', - style: { marginLeft: '8px' } - }, issue.actionLabel || 'Fix') - ) - ), - // 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(), - // Mode Badge - !showWelcome && !shouldShowWritingEmptyState() && wp.element.createElement('div', { - className: `wpaw-mode-badge mode-${agentMode}` - }, - agentMode === 'chat' ? '💬' : agentMode === 'planning' ? '📝' : '✍️', - agentMode === 'chat' ? 'Chat Mode' : agentMode === 'planning' ? 'Planning Mode' : 'Writing Mode' - ), - // Command Input Area - hide when showing empty state or welcome - !showWelcome && !shouldShowWritingEmptyState() && wp.element.createElement('div', { className: 'wpaw-command-area', style: { position: 'relative' } }, - // Slash command hint when input is empty - !input && !isLoading && wp.element.createElement('div', { className: 'wpaw-input-hint' }, - 'Type ', wp.element.createElement('kbd', null, '/'), ' for commands or ', - wp.element.createElement('kbd', null, '@'), ' to mention a block' - ), - // 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 your article topic...' - : agentMode === 'writing' - ? 'Refine content — use @block to target specific sections...' - : 'Ask anything about your content, or type / for commands...' - }) - ), - showMentionAutocomplete && mentionOptions.length > 0 && wp.element.createElement('div', { - className: 'wpaw-mention-autocomplete', - style: { - position: 'absolute', - bottom: '100%', - left: 0, - right: 0, - maxHeight: '200px', - overflowY: 'auto', - background: '#1e1e1e', - border: '1px solid #3c3c3c', - zIndex: 1000 - } - }, - mentionOptions.map((option, index) => { - const isSelected = index === mentionCursorIndex; - return wp.element.createElement('div', { - key: option.id, - className: 'wpaw-mention-option' + (isSelected ? ' selected' : ''), - onClick: () => insertMention(option), - style: { - padding: '8px 12px', - cursor: 'pointer', - background: isSelected ? '#2c2c2c' : 'transparent', - borderBottom: '1px solid #3c3c3c' - } - }, - wp.element.createElement('strong', { - style: { display: 'block', color: '#fff', fontSize: '13px' } - }, option.label), - wp.element.createElement('span', { - style: { display: 'block', color: '#a7aaad', fontSize: '12px', marginTop: '2px' } - }, option.sublabel) - ); - }) - ), - showSlashAutocomplete && slashOptions.length > 0 && wp.element.createElement('div', { - className: 'wpaw-mention-autocomplete', - style: { - position: 'absolute', - bottom: '100%', - left: 0, - right: 0, - maxHeight: '200px', - overflowY: 'auto', - background: '#1e1e1e', - border: '1px solid #3c3c3c', - zIndex: 1000 - } - }, - slashOptions.map((option, index) => { - const isSelected = index === slashCursorIndex; - return wp.element.createElement('div', { - key: option.id, - className: 'wpaw-mention-option' + (isSelected ? ' selected' : ''), - onClick: () => insertSlashCommand(option), - style: { - padding: '8px 12px', - cursor: 'pointer', - background: isSelected ? '#2c2c2c' : 'transparent', - borderBottom: '1px solid #3c3c3c' - } - }, - wp.element.createElement('strong', { - style: { display: 'block', color: '#fff', fontSize: '13px' } - }, option.label), - wp.element.createElement('span', { - style: { display: 'block', color: '#a7aaad', fontSize: '12px', marginTop: '2px' } - }, option.sublabel) - ); - }) - ), - 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:'), - wp.element.createElement('select', { - className: 'wpaw-command-mode-select', - id: 'agentMode', - value: agentMode === 'writing' ? 'chat' : agentMode, - onChange: (e) => setAgentMode(e.target.value), - disabled: isLoading, - }, - wp.element.createElement('option', { value: 'planning' }, 'Planning'), - wp.element.createElement('option', { value: 'chat' }, 'Chat') - ) - ), - - // Web Search Toggle (next to mode) - (() => { - // 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' }, - !showWelcome && wp.element.createElement('button', { - className: 'wpaw-command-text-btn', - type: 'button', - onClick: () => setShowWelcome(true), - disabled: isLoading, - }, 'Sessions'), - // Clear Context (Bottom Middle-ish) - wp.element.createElement('button', { - className: 'wpaw-command-text-btn', - type: 'button', - onClick: clearChatContext, - disabled: isLoading, - }, 'Clear Context'), - - // Stop Button (appears during execution) - Circle with pause icon - isLoading && wp.element.createElement('button', { - className: 'wpaw-command-circle-btn wpaw-stop-circle-btn', - type: 'button', - onClick: handleStopExecution, - title: 'Stop execution', - dangerouslySetInnerHTML: { - __html: '' - } - }), - - // Send Button (Bottom Right) - Circle with send icon - !isLoading && wp.element.createElement('button', { - className: 'wpaw-command-circle-btn wpaw-send-circle-btn', - type: 'button', - onClick: sendMessage, - disabled: !input.trim(), - title: 'Send message', - dangerouslySetInnerHTML: { - __html: '' - } - }) - ) - ), - 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') - ), - renderRefineAllConfirmModal() - ) - ) - ); - }; - - // Refresh cost data from server - const [costHistory, setCostHistory] = wp.element.useState([]); - - const refreshCostData = async () => { - if (!postId) return; - try { - const response = await fetch(`${wpAgenticWriter.apiUrl}/cost-tracking/${postId}`, { - headers: { 'X-WP-Nonce': wpAgenticWriter.nonce }, - }); - const data = await response.json(); - if (data && typeof data.session === 'number') { - setCost({ - session: data.session, - today: data.today?.total?.cost || 0, - monthlyUsed: data.monthly?.used || 0, - }); - } - if (data?.monthly?.budget) { - setMonthlyBudget(data.monthly.budget); - } - if (data?.history) { - setCostHistory(data.history); - } - } catch (e) { - wpawLog.error('Failed to refresh cost data:', e); - } - }; - - // Render Cost Tab - const renderCostTab = () => { - const budgetPercent = monthlyBudget > 0 ? (cost.monthlyUsed / monthlyBudget) * 100 : 0; - const budgetStatus = budgetPercent > 90 ? 'danger' : budgetPercent > 70 ? 'warning' : 'ok'; - const remaining = Math.max(0, monthlyBudget - cost.monthlyUsed); - - return wp.element.createElement('div', { className: 'wpaw-tab-content wpaw-cost-tab dark-theme' }, - wp.element.createElement('div', { className: 'wpaw-tab-header' }, - wp.element.createElement('button', { - className: 'wpaw-back-btn', - onClick: () => setActiveTab('chat') - }, '← Back'), - wp.element.createElement('h3', null, 'OPENROUTER COST'), - wp.element.createElement('button', { - className: 'wpaw-refresh-btn', - dangerouslySetInnerHTML: { __html: '' }, - onClick: refreshCostData, - title: 'Refresh cost data' - }) - ), - wp.element.createElement('div', { className: 'wpaw-cost-card' }, - wp.element.createElement('div', { className: 'wpaw-cost-stat' }, - wp.element.createElement('label', null, 'This Post'), - wp.element.createElement('div', { className: 'wpaw-cost-value' }, - '$', cost.session.toFixed(4) - ) - ), - wp.element.createElement('div', { className: 'wpaw-cost-stat' }, - wp.element.createElement('label', null, 'Month Used'), - wp.element.createElement('div', { className: 'wpaw-cost-value' }, - '$', cost.monthlyUsed.toFixed(4) - ) - ), - wp.element.createElement('div', { className: 'wpaw-cost-stat wpaw-cost-remaining' }, - wp.element.createElement('label', null, 'Remaining'), - wp.element.createElement('div', { className: 'wpaw-cost-value ' + budgetStatus }, - '$', remaining.toFixed(2) - ) - ) - ), - wp.element.createElement('div', { className: 'wpaw-budget-section' }, - wp.element.createElement('div', { className: 'wpaw-budget-label' }, - wp.element.createElement('span', null, 'Budget: $', monthlyBudget.toFixed(2)), - wp.element.createElement('span', null, budgetPercent.toFixed(1), '%') - ), - wp.element.createElement('div', { className: 'wpaw-budget-bar' }, - wp.element.createElement('div', { - className: 'wpaw-budget-fill ' + budgetStatus, - style: { width: Math.min(budgetPercent, 100) + '%' } - }) - ) - ), - budgetPercent > 80 && wp.element.createElement('div', { - 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, '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, - wp.element.createElement('tr', null, - wp.element.createElement('th', null, 'Time'), - wp.element.createElement('th', null, 'Action'), - wp.element.createElement('th', null, 'Model'), - wp.element.createElement('th', null, 'Tokens'), - wp.element.createElement('th', null, 'Cost(US$)') - ) - ), - wp.element.createElement('tbody', null, - costHistory.map((record, idx) => { - const totalTokens = parseInt(record.input_tokens || 0) + parseInt(record.output_tokens || 0); - const time = new Date(record.created_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); - const modelShort = record.model ? record.model.split('/').pop().substring(0, 20) : 'N/A'; - return wp.element.createElement('tr', { key: idx }, - wp.element.createElement('td', null, time), - wp.element.createElement('td', null, record.action), - wp.element.createElement('td', { title: record.model }, modelShort), - wp.element.createElement('td', null, totalTokens.toLocaleString()), - wp.element.createElement('td', null, '$' + parseFloat(record.cost).toFixed(4)) - ); - }) - ) - ) - ) - ), - wp.element.createElement('div', { className: 'wpaw-cost-footer' }, - wp.element.createElement('a', { - 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' } - }), - ) - ) - ); - }; - - // Main render. - return wp.element.createElement(wp.element.Fragment, null, - wp.element.createElement(PluginSidebarMoreMenuItem, { - target: 'wp-agentic-writer', - icon: pluginIcon, - }, 'WP Agentic Writer'), - 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', - alt: 'WP Agentic Writer', - style: { width: '24px', height: '24px' } - }), - wp.element.createElement('span', null, 'WP Agentic Writer') - ) - }, - wp.element.createElement(Panel, null, - wp.element.createElement('div', { className: 'wpaw-tab-content-wrapper' }, - activeTab === 'chat' && renderChatTab(), - activeTab === 'config' && renderConfigTab(), - activeTab === 'cost' && renderCostTab() - ) - ) - )); - }; - - // HOC to get post ID. - const mapSelectToProps = (select) => ({ - postId: select('core/editor').getCurrentPostId(), - }); - - // Connect sidebar to Redux store. - const ConnectedSidebar = wp.data.withSelect(mapSelectToProps)(AgenticWriterSidebar); - - // Register plugin. - registerPlugin('wp-agentic-writer', { - icon: pluginIcon, - render: ConnectedSidebar, - }); + const { registerPlugin } = wp.plugins; + const { PluginSidebarMoreMenuItem } = wp.editPost; + const { PluginSidebar } = wp.editPost; + const { Panel, TextareaControl, TextControl, CheckboxControl, Button } = + wp.components; + const { dispatch, select } = wp.data; + const { RawHTML } = wp.element; + + // Debug logger - only logs when SCRIPT_DEBUG is enabled + const isDebug = + typeof wpAgenticWriter !== "undefined" && wpAgenticWriter.debug; + const wpawLog = { + log: (...args) => { + if (isDebug) console.log("[WPAW]", ...args); + }, + error: (...args) => console.error("[WPAW]", ...args), // Always log errors + info: (...args) => { + if (isDebug) console.info("[WPAW]", ...args); + }, + warn: (...args) => { + if (isDebug) console.warn("[WPAW]", ...args); + }, + }; + const pluginIcon = wp.element.createElement("img", { + src: wpAgenticWriter.pluginUrl + "/assets/img/icon.svg", + alt: "WP Agentic Writer", + style: { width: "20px", height: "20px" }, + }); + + // Sidebar Component. + const AgenticWriterSidebar = ({ postId }) => { + // Get settings from wpAgenticWriter global. + const settings = + typeof wpAgenticWriter !== "undefined" ? wpAgenticWriter.settings : {}; + const formatAiErrorMessage = ( + error, + fallback = "The AI request failed.", + ) => { + const rawMessage = + typeof error === "string" ? error : error?.message || fallback; + const cleanMessage = String(rawMessage || fallback) + .replace(/^API error:\s*/i, "") + .trim(); + const lowerMessage = cleanMessage.toLowerCase(); + + // Returns structured object { title, detail, actionUrl, actionLabel } + const structured = (title, detail, actionUrl, actionLabel) => { + return { + title, + detail, + actionUrl: actionUrl || "", + actionLabel: actionLabel || "", + }; + }; + + if ( + lowerMessage.includes("no allowed providers are available") || + (lowerMessage.includes("allowed providers") && + lowerMessage.includes("selected model")) + ) { + const routedProvider = + settings?.openrouter_provider_slug && + settings.openrouter_provider_slug !== "auto" + ? ` Pinned: ${settings.openrouter_provider_slug}.` + : ""; + return structured( + "Model unavailable from current provider", + `The pinned provider routing doesn't support this model.${routedProvider} Change provider routing or select a compatible model.`, + settings?.settings_url || "", + "Open Settings", + ); + } + + if (cleanMessage.includes("429") || lowerMessage.includes("rate limit")) { + return structured( + "Rate limit exceeded", + "The AI provider is throttling requests. Wait a moment and try again.", + ); + } + + if ( + cleanMessage.includes("cURL error 28") || + lowerMessage.includes("operation timed out") || + lowerMessage.includes("timed out after") + ) { + return structured( + "Request timed out", + "The model took too long to respond. Try a faster model, reduce content length, or check your provider routing.", + settings?.settings_url || "", + "Open Settings", + ); + } + + if ( + cleanMessage.startsWith("HTTP 401") || + lowerMessage.includes("unauthorized") + ) { + return structured( + "API key rejected", + "The provider rejected your API key. Check your key in settings.", + settings?.settings_url || "", + "Open Settings", + ); + } + + if ( + cleanMessage.startsWith("HTTP 402") || + lowerMessage.includes("insufficient credits") + ) { + return structured( + "Insufficient credits", + "Your provider account has no remaining credits or quota.", + ); + } + + if ( + lowerMessage.includes("api key is not configured") || + lowerMessage.includes("no_api_key") + ) { + return structured( + "API key not configured", + "Add your OpenRouter API key in plugin settings to start using AI features.", + settings?.settings_url || "", + "Configure API Key", + ); + } + + return structured(cleanMessage || fallback, ""); + }; + + // Tab state + const [activeTab, setActiveTab] = React.useState("chat"); + + // Chat state + const [messages, setMessages] = React.useState([]); + const [input, setInput] = React.useState(""); + const [isLoading, setIsLoading] = React.useState(false); + const [currentSessionId, setCurrentSessionId] = React.useState(""); + const [availableSessions, setAvailableSessions] = React.useState([]); + const [isSessionActionLoading, setIsSessionActionLoading] = + React.useState(false); + const [agentMode, setAgentMode] = React.useState("chat"); + + // Config state + const defaultPostConfig = React.useMemo( + () => ({ + article_length: "medium", + language: "auto", + tone: "", + audience: "", + experience_level: "general", + include_images: true, + web_search: Boolean(settings.web_search_enabled), + default_mode: "chat", + // SEO fields + seo_focus_keyword: "", + focus_keyword: "", + seo_secondary_keywords: "", + seo_meta_description: "", + seo_enabled: true, + }), + [settings.web_search_enabled], + ); + const [postConfig, setPostConfig] = React.useState(defaultPostConfig); + const [isConfigLoading, setIsConfigLoading] = React.useState(false); + const [isConfigSaving, setIsConfigSaving] = React.useState(false); + const [configError, setConfigError] = React.useState(""); + const configHydratedRef = React.useRef(false); + const lastSavedConfigRef = React.useRef(""); + const configSaveTimeoutRef = React.useRef(null); + + // Cost state + const [cost, setCost] = React.useState({ + session: 0, + today: 0, + monthlyUsed: 0, + }); + const [monthlyBudget, setMonthlyBudget] = React.useState( + settings.monthly_budget || 600, + ); + + // Provider info state for transparency display + const [providerInfo, setProviderInfo] = React.useState(null); + + // Helper to extract and apply provider metadata from any AI response + const applyProviderMetadata = (data) => { + if (!data) return; + if (data.session_id) { + setCurrentSessionId(data.session_id); + } + + // Support both nested provider_metadata and top-level provider fields + const meta = data.provider_metadata || data; + const provider = meta.provider || meta.selected_provider || meta.provider; + + if (provider) { + setProviderInfo({ + provider: provider, + model: meta.model, + fallbackUsed: meta.fallback_used || meta.fallbackUsed, + warnings: meta.warnings || [], + }); + } + }; + + const [isEditorLocked, setIsEditorLocked] = React.useState(false); + const [isRefinementLocked, setIsRefinementLocked] = React.useState(false); + const [refiningBlockIds, setRefiningBlockIds] = React.useState([]); + const refinementDecoratedIdsRef = React.useRef([]); + const lockedEditableNodesRef = React.useRef([]); + const lockedBlockIdsRef = React.useRef([]); + const REFINEMENT_ALL_CONFIRM_THRESHOLD = 25; + const [refineAllConfirm, setRefineAllConfirm] = React.useState({ + isOpen: false, + blockCount: 0, + dontAskAgain: false, + }); + const refineAllConfirmResolverRef = React.useRef(null); + const skipRefineAllConfirmRef = React.useRef(false); + + // SEO audit state + const [seoAudit, setSeoAudit] = React.useState(null); + const [isSeoAuditing, setIsSeoAuditing] = React.useState(false); + const [isGeneratingMeta, setIsGeneratingMeta] = React.useState(false); + const [activeSeoFixKey, setActiveSeoFixKey] = React.useState(""); + + // Clarification state. + const [inClarification, setInClarification] = React.useState(false); + const [questions, setQuestions] = React.useState([]); + const [currentQuestionIndex, setCurrentQuestionIndex] = React.useState(0); + const [answers, setAnswers] = React.useState([]); + const [detectedLanguage, setDetectedLanguage] = React.useState("auto"); + const [clarificationMode, setClarificationMode] = + React.useState("generation"); + const [pendingRefinement, setPendingRefinement] = React.useState(null); + const [pendingEditPlan, setPendingEditPlan] = React.useState(null); + const lastGenerationRequestRef = React.useRef(null); + const currentPlanRef = React.useRef(null); + const lastExecuteRequestRef = React.useRef(null); + const sectionInsertIndexRef = React.useRef({}); + const activeSectionIdRef = React.useRef(null); + const sectionBlocksRef = React.useRef({}); + const blockSectionRef = React.useRef({}); + const markdownRendererRef = React.useRef(null); + const lastRefineRequestRef = React.useRef(null); + const lastChatRequestRef = React.useRef(null); + const stopExecutionRef = React.useRef(false); + const activeAbortControllerRef = React.useRef(null); + const activeReaderRef = React.useRef(null); + const activeOperationRef = React.useRef({ + type: "idle", + status: "idle", + label: "", + }); + const [executionStopped, setExecutionStopped] = React.useState(false); + const [activeOperation, setActiveOperation] = React.useState({ + type: "idle", + status: "idle", + label: "", + }); + const [writingState, setWritingState] = React.useState({ + status: "idle", + current_section_index: 0, + sections_written: [], + last_updated: null, + plan_id: "", + resume_token: "", + }); + const [isWritingStateLoading, setIsWritingStateLoading] = + React.useState(false); + const [workspaceSnapshot, setWorkspaceSnapshot] = React.useState({ + title: "", + blockCount: 0, + selectedBlockLabel: "None selected", + selectedBlockPreview: "", + }); + const [isWorkspaceCollapsed, setIsWorkspaceCollapsed] = React.useState( + () => { + try { + return ( + window.localStorage.getItem("wpaw_agent_workspace_collapsed") === + "1" + ); + } catch (error) { + return false; + } + }, + ); + const toggleAgentWorkspace = () => { + setIsWorkspaceCollapsed((prev) => { + const next = !prev; + try { + window.localStorage.setItem( + "wpaw_agent_workspace_collapsed", + next ? "1" : "0", + ); + } catch (error) { + // Ignore storage failures; the in-session toggle still works. + } + return next; + }); + }; + + // Mention autocomplete state + const [showMentionAutocomplete, setShowMentionAutocomplete] = + React.useState(false); + const [mentionQuery, setMentionQuery] = React.useState(""); + const [mentionOptions, setMentionOptions] = React.useState([]); + const [mentionCursorIndex, setMentionCursorIndex] = React.useState(0); + const [showSlashAutocomplete, setShowSlashAutocomplete] = + React.useState(false); + const [slashQuery, setSlashQuery] = React.useState(""); + const [slashOptions, setSlashOptions] = React.useState([]); + const [slashCursorIndex, setSlashCursorIndex] = React.useState(0); + const [isTextareaExpanded, setIsTextareaExpanded] = React.useState(false); + const inputRef = React.useRef(null); + const streamTargetRef = React.useRef(null); + + // Focus keyword state + const [focusKeywordSuggestions, setFocusKeywordSuggestions] = + React.useState([]); + const [selectedFocusKeyword, setSelectedFocusKeyword] = React.useState(""); + const [showCustomKeywordInput, setShowCustomKeywordInput] = + React.useState(false); + const [customKeywordInput, setCustomKeywordInput] = React.useState(""); + const messagesSaveTimeoutRef = React.useRef(null); + const lastPersistedMessagesRef = React.useRef(""); + const isHydratingSessionRef = React.useRef(false); + + // Welcome screen state + const [showWelcome, setShowWelcome] = React.useState(true); + const [welcomeKeywordInput, setWelcomeKeywordInput] = React.useState(""); + const [welcomeStartMode, setWelcomeStartMode] = React.useState("chat"); // 'chat' or 'planning' + + // Undo stack for AI operations + const [aiUndoStack, setAiUndoStack] = React.useState([]); + const MAX_UNDO_STACK = 10; + + // MEMANTO memory restore state + const [memantoRestore, setMemantoRestore] = React.useState({ + restored: false, + summary: "", + memories: [], + preferences: [], + systemMessage: "", + }); + const memantoRestoreFetchedRef = React.useRef(false); + React.useEffect(() => { + if (agentMode === "writing" && !isLoading) { + setAgentMode("chat"); + } + }, [agentMode, isLoading]); + + React.useEffect(() => { + if (!postId) { + return; + } + + setIsConfigLoading(true); + fetch(`${wpAgenticWriter.apiUrl}/post-config/${postId}`, { + headers: { + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + }) + .then((response) => + response.ok ? response.json() : Promise.reject(response), + ) + .then((data) => { + const merged = { ...defaultPostConfig, ...data }; + merged.default_mode = "chat"; + setPostConfig(merged); + lastSavedConfigRef.current = JSON.stringify(merged); + configHydratedRef.current = true; + }) + .catch(() => { + configHydratedRef.current = true; + }) + .finally(() => { + setIsConfigLoading(false); + }); + }, [postId, defaultPostConfig]); + + const savePostConfig = React.useCallback( + async (config) => { + if (!postId) { + return; + } + + setIsConfigSaving(true); + setConfigError(""); + try { + const response = await fetch( + `${wpAgenticWriter.apiUrl}/post-config/${postId}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + body: JSON.stringify({ postConfig: config }), + }, + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error( + error.message || "Failed to save post configuration", + ); + } + + const data = await response.json(); + lastSavedConfigRef.current = JSON.stringify(data); + // Don't update state if data matches current - prevents focus loss + setPostConfig((prev) => { + const newConfig = { ...prev, ...data }; + if (JSON.stringify(prev) === JSON.stringify(newConfig)) { + return prev; // Return same reference to prevent re-render + } + return newConfig; + }); + } catch (error) { + setConfigError(error.message || "Failed to save post configuration"); + } finally { + setIsConfigSaving(false); + } + }, + [postId], + ); + + React.useEffect(() => { + if (!configHydratedRef.current || isConfigLoading) { + return; + } + + const serialized = JSON.stringify(postConfig); + if (serialized === lastSavedConfigRef.current) { + return; + } + + if (configSaveTimeoutRef.current) { + clearTimeout(configSaveTimeoutRef.current); + } + + configSaveTimeoutRef.current = setTimeout(() => { + savePostConfig(postConfig); + }, 600); + + return () => { + if (configSaveTimeoutRef.current) { + clearTimeout(configSaveTimeoutRef.current); + } + }; + }, [postConfig, isConfigLoading, savePostConfig]); + + React.useEffect(() => { + if (!settings.cost_tracking_enabled || !postId) { + return; + } + + fetch(`${wpAgenticWriter.apiUrl}/cost-tracking/${postId}`, { + headers: { + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + }) + .then((response) => response.json()) + .then((data) => { + if (data && typeof data.session === "number") { + setCost({ + session: data.session, + today: data.today?.total?.cost || 0, + monthlyUsed: data.monthly?.used || 0, + }); + } + if (data?.monthly?.budget) { + setMonthlyBudget(data.monthly.budget); + } + }) + .catch(() => {}); + }, [postId]); + + const normalizeWritingState = (state = {}) => ({ + status: state.status || "idle", + current_section_index: Number(state.current_section_index || 0), + sections_written: Array.isArray(state.sections_written) + ? state.sections_written + : [], + last_updated: state.last_updated || null, + plan_id: state.plan_id || "", + resume_token: state.resume_token || "", + }); + + const saveWritingState = React.useCallback( + async (statePatch) => { + if (!postId) { + return; + } + + const nextState = normalizeWritingState(statePatch); + try { + const response = await fetch( + `${wpAgenticWriter.apiUrl}/writing-state/${postId}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + body: JSON.stringify(nextState), + }, + ); + if (!response.ok) { + throw new Error("Failed to save writing state"); + } + } catch (error) { + wpawLog.warn("Writing state save failed:", error); + } + }, + [postId], + ); + + const persistWritingStatePatch = React.useCallback( + (patch) => { + setWritingState((prev) => { + const next = normalizeWritingState({ ...prev, ...patch }); + saveWritingState(next); + return next; + }); + }, + [saveWritingState], + ); + + React.useEffect(() => { + if (!postId) { + return; + } + + let cancelled = false; + setIsWritingStateLoading(true); + fetch(`${wpAgenticWriter.apiUrl}/writing-state/${postId}`, { + headers: { + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + }) + .then((response) => + response.ok ? response.json() : Promise.reject(response), + ) + .then((data) => { + if (!cancelled) { + setWritingState(normalizeWritingState(data)); + } + }) + .catch((error) => { + wpawLog.warn("Writing state load failed:", error); + }) + .finally(() => { + if (!cancelled) { + setIsWritingStateLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [postId]); + + // Chat messages container ref for auto-scroll + const messagesEndRef = React.useRef(null); + const messagesContainerRef = React.useRef(null); + + // Auto-scroll to bottom when messages change + React.useEffect(() => { + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [messages]); + + const progressRegex = + /^(I'll|Writing|Now|Creating|Adding|Let me|I'll write|Saya|Saya akan|Sedang menulis|Sedang membuat|Menulis tentang|Membuat tentang|Thinking|Analyzing|Reviewing|Refining|Checking|Updating|Planning|Searching|Querying|Generated|Drafting|Reading|Context|Processing)/i; + const activeTimelineStatuses = new Set([ + "active", + "starting", + "refining", + "checking", + "waiting", + "planning", + "plan_complete", + "writing", + "writing_section", + ]); + const writingTimelineStatuses = new Set(["writing", "writing_section"]); + const findLastActiveTimelineIndex = (items) => { + for (let i = items.length - 1; i >= 0; i--) { + if ( + items[i].type === "timeline" && + activeTimelineStatuses.has(items[i].status) + ) { + return i; + } + } + + return -1; + }; + const deactivateActiveTimelineEntries = (items) => { + return items.map((item) => { + if ( + item.type === "timeline" && + activeTimelineStatuses.has(item.status) + ) { + return { + ...item, + status: "inactive", + }; + } + + return item; + }); + }; + const updateOrCreateTimelineEntry = (message) => { + setMessages((prev) => { + const newMessages = [...prev]; + const timelineIndex = findLastActiveTimelineIndex(newMessages); + + if (timelineIndex === -1) { + newMessages.push({ + role: "system", + type: "timeline", + status: "active", + message: message, + timestamp: new Date(), + }); + } else { + newMessages[timelineIndex] = { + ...newMessages[timelineIndex], + message: message, + }; + } + + return newMessages; + }); + }; + const addActivityTimeline = (status, message, options = {}) => { + const { deactivate = true, extra = {} } = options; + setMessages((prev) => [ + ...(deactivate ? deactivateActiveTimelineEntries(prev) : prev), + { + role: "system", + type: "timeline", + status, + message, + timestamp: new Date(), + ...extra, + }, + ]); + }; + const setActiveOperationState = (nextOperation) => { + const normalized = { + type: nextOperation?.type || "idle", + status: nextOperation?.status || "idle", + label: nextOperation?.label || "", + }; + activeOperationRef.current = normalized; + setActiveOperation(normalized); + }; + const beginAgentOperation = (type, label) => { + stopExecutionRef.current = false; + const controller = new AbortController(); + activeAbortControllerRef.current = controller; + activeReaderRef.current = null; + setExecutionStopped(false); + setActiveOperationState({ type, status: "running", label }); + return controller; + }; + const finishAgentOperation = (type = "") => { + const current = activeOperationRef.current || {}; + if (type && current.type && current.type !== type) { + return; + } + activeAbortControllerRef.current = null; + activeReaderRef.current = null; + setActiveOperationState({ type: "idle", status: "idle", label: "" }); + }; + const markActiveOperationStopping = () => { + const current = activeOperationRef.current || {}; + setActiveOperationState({ + type: current.type || "unknown", + status: "stopping", + label: current.label || "operation", + }); + setMessages((prev) => { + const newMessages = [...prev]; + const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); + if (lastTimelineIndex !== -1) { + newMessages[lastTimelineIndex] = { + ...newMessages[lastTimelineIndex], + status: "stopping", + message: `Stopping ${current.label || "operation"}...`, + timestamp: new Date(), + }; + } + return newMessages; + }); + }; + const isAbortError = (error) => + error?.name === "AbortError" || + /aborted|abort/i.test(String(error?.message || "")); + const registerActiveReader = (reader) => { + activeReaderRef.current = reader; + return reader; + }; + const requestRefineAllConfirmation = React.useCallback((blockCount) => { + if (skipRefineAllConfirmRef.current) { + return Promise.resolve(true); + } + + return new Promise((resolve) => { + refineAllConfirmResolverRef.current = resolve; + setRefineAllConfirm({ + isOpen: true, + blockCount: Number(blockCount) || 0, + dontAskAgain: false, + }); + }); + }, []); + const resolveRefineAllConfirmation = React.useCallback((approved) => { + const resolver = refineAllConfirmResolverRef.current; + refineAllConfirmResolverRef.current = null; + setRefineAllConfirm((prev) => ({ ...prev, isOpen: false })); + if (resolver) { + resolver(Boolean(approved)); + } + }, []); + + // Undo helper functions + const captureEditorSnapshot = (label = "AI Operation") => { + const allBlocks = select("core/block-editor").getBlocks(); + const serializedBlocks = allBlocks + .map((block) => wp.blocks.serialize(block)) + .join("\n"); + return { + label, + timestamp: new Date(), + blocks: serializedBlocks, + }; + }; + + const pushUndoSnapshot = (label = "AI Operation") => { + const snapshot = captureEditorSnapshot(label); + setAiUndoStack((prev) => { + const newStack = [...prev, snapshot]; + if (newStack.length > MAX_UNDO_STACK) { + return newStack.slice(-MAX_UNDO_STACK); + } + return newStack; + }); + }; + + const undoLastAiOperation = () => { + if (aiUndoStack.length === 0) { + return; + } + + const lastSnapshot = aiUndoStack[aiUndoStack.length - 1]; + const { resetBlocks } = dispatch("core/block-editor"); + + try { + const parsedBlocks = wp.blocks.parse(lastSnapshot.blocks); + resetBlocks(parsedBlocks); + + setAiUndoStack((prev) => prev.slice(0, -1)); + + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "timeline", + status: "complete", + message: `Undid: ${lastSnapshot.label}`, + timestamp: new Date(), + }, + ]); + } catch (error) { + wpawLog.error("Failed to undo AI operation:", error); + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: "Failed to undo operation: " + error.message, + }, + ]); + } + }; + + React.useEffect(() => { + const lastTimelineIndex = findLastActiveTimelineIndex(messages); + const lastTimeline = + lastTimelineIndex !== -1 ? messages[lastTimelineIndex] : null; + const isWritingActive = Boolean( + isLoading && + lastTimeline && + writingTimelineStatuses.has(lastTimeline.status), + ); + + if (isWritingActive && !isEditorLocked) { + dispatch("core/editor").lockPostSaving("wpaw-writing"); + document.body.classList.add("wpaw-editor-locked"); + setIsEditorLocked(true); + } else if (!isWritingActive && isEditorLocked) { + dispatch("core/editor").unlockPostSaving("wpaw-writing"); + document.body.classList.remove("wpaw-editor-locked"); + setIsEditorLocked(false); + } + }, [messages, isLoading, isEditorLocked]); + React.useEffect(() => { + if (isRefinementLocked) { + dispatch("core/editor").lockPostSaving("wpaw-refining"); + document.body.classList.add("wpaw-refining-locked"); + } else { + dispatch("core/editor").unlockPostSaving("wpaw-refining"); + document.body.classList.remove("wpaw-refining-locked"); + } + }, [isRefinementLocked]); + React.useEffect(() => { + const blockEditorDispatch = dispatch("core/block-editor"); + if ( + !blockEditorDispatch || + typeof blockEditorDispatch.setBlockEditingMode !== "function" + ) { + return undefined; + } + + if (isRefinementLocked) { + const allBlocks = select("core/block-editor").getBlocks(); + const ids = []; + const collectIds = (blocks) => { + blocks.forEach((block) => { + if (!block?.clientId) { + return; + } + ids.push(block.clientId); + if ( + Array.isArray(block.innerBlocks) && + block.innerBlocks.length > 0 + ) { + collectIds(block.innerBlocks); + } + }); + }; + collectIds(allBlocks); + lockedBlockIdsRef.current = ids; + ids.forEach((id) => + blockEditorDispatch.setBlockEditingMode(id, "disabled"), + ); + } else if (lockedBlockIdsRef.current.length > 0) { + lockedBlockIdsRef.current.forEach((id) => + blockEditorDispatch.setBlockEditingMode(id, "default"), + ); + lockedBlockIdsRef.current = []; + } + + return () => { + if (lockedBlockIdsRef.current.length > 0) { + lockedBlockIdsRef.current.forEach((id) => + blockEditorDispatch.setBlockEditingMode(id, "default"), + ); + lockedBlockIdsRef.current = []; + } + }; + }, [isRefinementLocked, messages]); + + React.useEffect(() => { + const prevIds = refinementDecoratedIdsRef.current || []; + prevIds.forEach((id) => { + const node = document.querySelector(`[data-block="${id}"]`); + if (node) { + node.classList.remove("wpaw-block-refining"); + } + }); + + if (isRefinementLocked && Array.isArray(refiningBlockIds)) { + refiningBlockIds.forEach((id) => { + const node = document.querySelector(`[data-block="${id}"]`); + if (node) { + node.classList.add("wpaw-block-refining"); + } + }); + refinementDecoratedIdsRef.current = [...refiningBlockIds]; + } else { + refinementDecoratedIdsRef.current = []; + } + + return () => { + const cleanupIds = refinementDecoratedIdsRef.current || []; + cleanupIds.forEach((id) => { + const node = document.querySelector(`[data-block="${id}"]`); + if (node) { + node.classList.remove("wpaw-block-refining"); + } + }); + }; + }, [isRefinementLocked, refiningBlockIds, messages]); + + React.useEffect(() => { + if (!isRefinementLocked) { + return undefined; + } + + const shouldBlockEditorInput = (eventTarget) => { + if (!eventTarget || !(eventTarget instanceof Element)) { + return false; + } + if ( + eventTarget.closest( + ".wpaw-sidebar, .wpaw-command-area, .wpaw-messages", + ) + ) { + return false; + } + return Boolean( + eventTarget.closest( + ".interface-interface-skeleton__content, .editor-styles-wrapper, .block-editor-writing-flow", + ), + ); + }; + + const keydownHandler = (event) => { + if (!shouldBlockEditorInput(event.target)) { + return; + } + if (event.metaKey || event.ctrlKey || event.altKey) { + return; + } + const blockedKeys = new Set(["Enter", "Backspace", "Delete", "Tab"]); + if ( + (typeof event.key === "string" && event.key.length === 1) || + blockedKeys.has(event.key) + ) { + event.preventDefault(); + event.stopPropagation(); + } + }; + + const blockMutationEvent = (event) => { + if (!shouldBlockEditorInput(event.target)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + }; + + document.addEventListener("keydown", keydownHandler, true); + document.addEventListener("paste", blockMutationEvent, true); + document.addEventListener("drop", blockMutationEvent, true); + document.addEventListener("cut", blockMutationEvent, true); + + return () => { + document.removeEventListener("keydown", keydownHandler, true); + document.removeEventListener("paste", blockMutationEvent, true); + document.removeEventListener("drop", blockMutationEvent, true); + document.removeEventListener("cut", blockMutationEvent, true); + }; + }, [isRefinementLocked]); + React.useEffect(() => { + if (isRefinementLocked) { + const editableNodes = Array.from( + document.querySelectorAll( + '.editor-styles-wrapper [contenteditable="true"]', + ), + ); + lockedEditableNodesRef.current = editableNodes.map((node) => ({ + node, + prev: node.getAttribute("contenteditable"), + })); + lockedEditableNodesRef.current.forEach(({ node }) => { + node.setAttribute("contenteditable", "false"); + }); + } else { + (lockedEditableNodesRef.current || []).forEach(({ node, prev }) => { + if (!node) return; + if (prev === null) { + node.removeAttribute("contenteditable"); + } else { + node.setAttribute("contenteditable", prev); + } + }); + lockedEditableNodesRef.current = []; + } + + return () => { + (lockedEditableNodesRef.current || []).forEach(({ node, prev }) => { + if (!node) return; + if (prev === null) { + node.removeAttribute("contenteditable"); + } else { + node.setAttribute("contenteditable", prev); + } + }); + lockedEditableNodesRef.current = []; + }; + }, [isRefinementLocked, messages]); + const toTextValue = (value) => { + if (value === null || value === undefined) { + return ""; + } + if (typeof value === "string" || typeof value === "number") { + return String(value); + } + return ""; + }; + const updatePostConfig = (key, value) => { + setPostConfig((prev) => ({ ...prev, [key]: value })); + }; + const buildPostConfigFromAnswers = (answerMap = {}) => { + const merged = { ...postConfig }; + if (answerMap.config_language) { + let languageValue = answerMap.config_language; + if ( + languageValue === "__custom__" && + answerMap.config_language_custom + ) { + languageValue = answerMap.config_language_custom.toLowerCase().trim(); + } + if (languageValue && languageValue !== "__skipped__") { + merged.language = languageValue; + } + } + + if (answerMap.config_all) { + try { + const configData = JSON.parse(answerMap.config_all); + if (configData.web_search !== undefined) { + merged.web_search = configData.web_search; + } + if (configData.seo !== undefined) { + merged.seo_enabled = configData.seo; + } + if (configData.focus_keyword) { + merged.focus_keyword = configData.focus_keyword; + merged.seo_focus_keyword = configData.focus_keyword; + } + if (configData.secondary_keywords) { + merged.seo_secondary_keywords = configData.secondary_keywords; + } + } catch (error) { + wpawLog.error("Failed to merge config answers:", error); + } + } + + return merged; + }; + + // Focus keyword handlers + const handleFocusKeywordChange = (keyword) => { + setSelectedFocusKeyword(keyword); + updatePostConfig("focus_keyword", keyword); + updatePostConfig("seo_focus_keyword", keyword); + setShowCustomKeywordInput(false); + setCustomKeywordInput(""); + }; + + const handleKeywordSelect = (e) => { + const value = e.target.value; + if (value === "__custom__") { + setShowCustomKeywordInput(true); + } else { + handleFocusKeywordChange(value); + } + }; + + // Extract ALL focus keyword suggestions from AI response (returns array) + const extractFocusKeywordSuggestions = (aiResponse) => { + if (!aiResponse || typeof aiResponse !== "string") return []; + + const suggestions = []; + + // Method 1: Bullet list after "Fokus Keyword Suggestion:" or "Focus Keyword Suggestion:" + // Matches: - "Keyword Here" or * "Keyword Here" or - Keyword Here + const bulletListMatch = aiResponse.match( + /(?:fokus|focus)\s+keyword\s+suggestion[s]?\s*:\s*([\s\S]*?)(?=\n\n|Pilih|$)/i, + ); + if (bulletListMatch) { + const listContent = bulletListMatch[1]; + // Extract items from bullet list (- or *) + const bulletItems = listContent.match(/[-*]\s*["']?([^"'\n]+)["']?/g); + if (bulletItems) { + bulletItems.forEach((item) => { + const cleaned = item + .replace(/^[-*]\s*["']?/, "") + .replace(/["']?$/, "") + .trim(); + if (cleaned.length > 2 && cleaned.length < 60) { + suggestions.push(cleaned); + } + }); + } + } + + // Method 2: Single line "Focus Keyword Suggestion: keyword" + if (suggestions.length === 0) { + const singleMatch = aiResponse.match( + /(?:fokus|focus)\s+keyword\s+suggestion[s]?\s*:\s*["']?([^"'\n]+)["']?/i, + ); + if ( + singleMatch && + !singleMatch[1].includes("-") && + !singleMatch[1].includes("*") + ) { + const kw = singleMatch[1].trim(); + if (kw.length > 2 && kw.length < 60) { + suggestions.push(kw); + } + } + } + + return suggestions; + }; + + // Legacy single extraction (for backward compatibility) + const extractFocusKeywordSuggestion = (aiResponse) => { + const suggestions = extractFocusKeywordSuggestions(aiResponse); + return suggestions.length > 0 ? suggestions[0] : null; + }; + + const addFocusKeywordSuggestion = (suggestion) => { + if (!suggestion) return; + setFocusKeywordSuggestions((prev) => { + if (prev.includes(suggestion)) return prev; + const updated = [...prev, suggestion]; + return updated.slice(-5); // Keep max 5 suggestions + }); + // Don't auto-select - let user choose + }; + + // Add multiple suggestions at once + const addFocusKeywordSuggestions = (suggestions) => { + if (!suggestions || !Array.isArray(suggestions)) return; + suggestions.forEach((s) => addFocusKeywordSuggestion(s)); + }; + + // Load focus keyword from postConfig on mount + React.useEffect(() => { + if (postConfig.focus_keyword && !selectedFocusKeyword) { + setSelectedFocusKeyword(postConfig.focus_keyword); + } else if (postConfig.seo_focus_keyword && !selectedFocusKeyword) { + setSelectedFocusKeyword(postConfig.seo_focus_keyword); + } + }, [postConfig.focus_keyword, postConfig.seo_focus_keyword]); + + // Check if should show welcome screen (no messages yet) + React.useEffect(() => { + if (messages.length > 0 || currentPlanRef.current) { + setShowWelcome(false); + } + }, [messages.length]); + + // Welcome screen start handler + const handleWelcomeStart = () => { + // Set focus keyword if provided (but don't add to AI suggestions - it's user input) + if (welcomeKeywordInput.trim()) { + const keyword = welcomeKeywordInput.trim(); + handleFocusKeywordChange(keyword); + // NOT adding to suggestions - user input is NOT AI suggestion + } + // Set mode and hide welcome + setAgentMode(welcomeStartMode); + setShowWelcome(false); + // Focus the input + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, 100); + }; + + // Run SEO Audit + const runSeoAudit = async () => { + if (isSeoAuditing || !postId) return; + const operationController = beginAgentOperation("seo_audit", "SEO audit"); + setIsSeoAuditing(true); + try { + const response = await fetch( + `${wpAgenticWriter.apiUrl}/seo-audit/${postId}`, + { + headers: { + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + signal: operationController.signal, + }, + ); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || "Failed to run SEO audit"); + } + setSeoAudit(data); + setMessages((prev) => { + const newMessages = [...prev]; + const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); + if (lastTimelineIndex !== -1) { + newMessages[lastTimelineIndex] = { + ...newMessages[lastTimelineIndex], + status: "complete", + message: "SEO audit complete.", + completedAt: new Date(), + }; + } + return newMessages; + }); + } catch (error) { + if (isAbortError(error)) { + setMessages((prev) => { + const newMessages = [...prev]; + const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); + if (lastTimelineIndex !== -1) { + newMessages[lastTimelineIndex] = { + ...newMessages[lastTimelineIndex], + status: "stopped", + message: "SEO audit stopped.", + }; + } + return newMessages; + }); + return; + } + wpawLog.error("SEO Audit error:", error); + setMessages((prev) => [ + ...prev, + { + role: "assistant", + content: `SEO Audit error: ${error.message}`, + type: "error", + }, + ]); + setMessages((prev) => { + const newMessages = [...prev]; + const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); + if (lastTimelineIndex !== -1) { + newMessages[lastTimelineIndex] = { + ...newMessages[lastTimelineIndex], + status: "error", + message: "SEO audit failed.", + }; + } + return newMessages; + }); + } finally { + setIsSeoAuditing(false); + finishAgentOperation("seo_audit"); + } + }; + + const buildSeoAuditFixInstruction = (check) => { + const focusKeyword = + selectedFocusKeyword || + postConfig.focus_keyword || + postConfig.seo_focus_keyword || + ""; + const languageHint = + postConfig.language && postConfig.language !== "auto" + ? `Use ${postConfig.language}.` + : "Use the same language as the article."; + const issueName = check?.name || "SEO audit issue"; + const issueMessage = check?.message || ""; + const focusHint = focusKeyword + ? `Focus keyword: "${focusKeyword}".` + : "If a focus keyword is missing, ask the user to set one first."; + const lowerName = String(issueName).toLowerCase(); + + if (lowerName.includes("keyword in intro")) { + return `Fix this SEO audit issue in the article introduction: ${issueMessage}. ${focusHint} Add the focus keyword naturally in the first paragraph without sounding forced. ${languageHint}`; + } + + if (lowerName.includes("keyword density")) { + return `Fix this SEO audit issue across the article: ${issueMessage}. ${focusHint} Improve keyword usage naturally, avoid stuffing, and keep the writing human and useful. ${languageHint}`; + } + + if (lowerName.includes("ai-ish")) { + return `Fix this audit issue across the article: ${issueMessage}. Make the writing more natural, specific, and human. Reduce generic AI-style phrasing while preserving meaning, structure, and facts. ${languageHint}`; + } + + if (lowerName.includes("content length")) { + return `Fix this SEO audit issue: ${issueMessage}. Expand the article with useful, non-fluffy details, examples, and reader guidance. ${focusHint} ${languageHint}`; + } + + if (lowerName.includes("subheadings")) { + return `Fix this readability issue: ${issueMessage}. Improve the article structure with useful H2/H3 subheadings while preserving the article's intent. ${focusHint} ${languageHint}`; + } + + return `Fix this SEO audit issue: ${issueName}: ${issueMessage}. ${focusHint} Keep the result natural, useful, and aligned with the article intent. ${languageHint}`; + }; + const getSeoFixKey = (check) => + `${check?.name || ""}:${check?.message || ""}`; + const getSeoAuditPatternCount = (check) => { + const auditCount = Number(seoAudit?.ai_ish_pattern_count || 0); + if (auditCount > 0) { + return auditCount; + } + const match = String(check?.message || "").match(/(\d+)\s+pattern/i); + return match ? Number(match[1]) : 0; + }; + const formatCountLabel = (count, singular, plural = `${singular}s`) => { + const numeric = Number(count || 0); + return `${numeric} ${numeric === 1 ? singular : plural}`; + }; + const formatAuditPatternLabel = (auditContext) => { + const patternCount = Number(auditContext?.patternCount || 0); + return patternCount > 0 + ? formatCountLabel(patternCount, "pattern occurrence") + : "audit pattern occurrences"; + }; + const buildAuditRefinementContext = ( + check, + targetBlocks, + refineableBlocks, + ) => { + const issueName = check?.name || "SEO audit issue"; + return { + source: "seo_audit", + issueName, + auditMessage: check?.message || "", + patternCount: getSeoAuditPatternCount(check), + candidateBlockCount: Array.isArray(targetBlocks) + ? targetBlocks.length + : 0, + refineableBlockCount: Array.isArray(refineableBlocks) + ? refineableBlocks.length + : 0, + }; + }; + + const handleSeoAuditFix = async (check) => { + if (isLoading || isSeoAuditing || !check) { + return; + } + + const issueName = String(check.name || "").toLowerCase(); + const focusKeyword = + selectedFocusKeyword || + postConfig.focus_keyword || + postConfig.seo_focus_keyword || + ""; + const fixKey = getSeoFixKey(check); + setActiveSeoFixKey(fixKey); + + if (issueName.includes("focus keyword") && !focusKeyword) { + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: "Set a focus keyword first, then run the audit again.", + }, + ]); + setActiveTab("config"); + setActiveSeoFixKey(""); + return; + } + + setActiveTab("chat"); + setShowWelcome(false); + + try { + if (issueName.includes("meta description")) { + setMessages((prev) => [ + ...prev, + { role: "user", content: `Fix SEO audit: ${check.message}` }, + ]); + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "refining", + message: "Generating SEO meta description...", + timestamp: new Date(), + }, + ]); + await generateMetaDescription(); + return; + } + + if (issueName.includes("keyword in title")) { + const titleInstruction = focusKeyword + ? `include the focus keyword "${focusKeyword}" naturally in the title, keep it compelling, and match the article language` + : "make the title more SEO-friendly and aligned with the article"; + await handleTitleRefinement(`@title ${titleInstruction}`, ["@title"]); + return; + } + + const refineableBlocks = getRefineableBlocks(); + if (!refineableBlocks.length) { + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: + "No article blocks found to fix yet. Generate or insert content first, then run the audit again.", + }, + ]); + return; + } + + const instruction = buildSeoAuditFixInstruction(check); + const targetBlocks = issueName.includes("ai-ish") + ? selectLikelyAiSlopBlocks(instruction, refineableBlocks) + : refineableBlocks; + if (issueName.includes("ai-ish") && targetBlocks.length === 0) { + const patternCount = getSeoAuditPatternCount(check); + setMessages((prev) => [ + ...prev, + { + role: "assistant", + content: + patternCount > 0 + ? `Audit found ${formatCountLabel(patternCount, "pattern occurrence")}, but I could not safely map those occurrences to editor blocks. I did not send the whole article to refinement.` + : "I rechecked the editor blocks and did not find AI-ish pattern matches, so I did not send the whole article to refinement.", + }, + ]); + return; + } + + await handleChatRefinement( + instruction, + targetBlocks.map((block) => block.clientId), + { + useDiffPlan: false, + auditContext: issueName.includes("ai-ish") + ? buildAuditRefinementContext( + check, + targetBlocks, + refineableBlocks, + ) + : null, + }, + ); + } finally { + setActiveSeoFixKey(""); + } + }; + + const generateMetaDescription = async () => { + if (isGeneratingMeta) return; + const operationController = beginAgentOperation( + "meta", + "meta description", + ); + setIsGeneratingMeta(true); + try { + const response = await fetch( + `${wpAgenticWriter.apiUrl}/generate-meta`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + body: JSON.stringify({ + postId: postId, + sessionId: currentSessionId, + focusKeyword: postConfig.seo_focus_keyword, + chatHistory: messages.filter((m) => m.role !== "system"), + }), + signal: operationController.signal, + }, + ); + + if (!response.ok) { + const data = await response.json(); + throw new Error( + data.message || "Failed to generate meta description", + ); + } + + const data = await response.json(); + applyProviderMetadata(data); + if (data.meta_description) { + updatePostConfig("seo_meta_description", data.meta_description); + setMessages((prev) => [ + ...prev, + { + role: "assistant", + content: `✅ Meta description generated successfully`, + type: "success", + }, + ]); + setMessages((prev) => { + const newMessages = [...prev]; + const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); + if (lastTimelineIndex !== -1) { + newMessages[lastTimelineIndex] = { + ...newMessages[lastTimelineIndex], + status: "complete", + message: "Meta description generated.", + completedAt: new Date(), + }; + } + return newMessages; + }); + } else { + throw new Error("No meta description returned from API"); + } + } catch (error) { + if (isAbortError(error)) { + setMessages((prev) => { + const newMessages = [...prev]; + const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); + if (lastTimelineIndex !== -1) { + newMessages[lastTimelineIndex] = { + ...newMessages[lastTimelineIndex], + status: "stopped", + message: "Meta description generation stopped.", + }; + } + return newMessages; + }); + return; + } + wpawLog.error("Error generating meta description:", error); + setMessages((prev) => [ + ...prev, + { + role: "system", + content: `❌ Failed to generate meta description: ${error.message}`, + type: "error", + }, + ]); + setMessages((prev) => { + const newMessages = [...prev]; + const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); + if (lastTimelineIndex !== -1) { + newMessages[lastTimelineIndex] = { + ...newMessages[lastTimelineIndex], + status: "error", + message: "Meta description failed.", + }; + } + return newMessages; + }); + } finally { + setIsGeneratingMeta(false); + finishAgentOperation("meta"); + } + }; + + const extractBlockPreview = (block) => { + const direct = toTextValue( + block.attributes?.content || + block.attributes?.value || + block.attributes?.caption || + block.attributes?.title || + "", + ); + + if (direct) { + return direct; + } + + if (wp.blocks && typeof wp.blocks.getBlockContent === "function") { + const html = wp.blocks.getBlockContent(block); + if (html) { + const temp = document.createElement("div"); + temp.innerHTML = html; + return toTextValue(temp.textContent); + } + } + + return ""; + }; + const getBlockPreviewById = (clientId) => { + if (!clientId) { + return ""; + } + const allBlocks = select("core/block-editor").getBlocks(); + const block = allBlocks.find((entry) => entry.clientId === clientId); + if (!block) { + return ""; + } + return extractBlockPreview(block); + }; + + const buildWorkspaceSnapshot = React.useCallback(() => { + const editor = select("core/editor"); + const blockEditor = select("core/block-editor"); + const allBlocks = blockEditor?.getBlocks ? blockEditor.getBlocks() : []; + const selectedBlockId = blockEditor?.getSelectedBlockClientId + ? blockEditor.getSelectedBlockClientId() + : ""; + const selectedBlock = + selectedBlockId && blockEditor?.getBlock + ? blockEditor.getBlock(selectedBlockId) + : null; + const postTitle = editor?.getEditedPostAttribute + ? editor.getEditedPostAttribute("title") || "" + : ""; + const selectedType = selectedBlock?.name + ? selectedBlock.name.replace("core/", "") + : ""; + const selectedPreview = selectedBlock + ? extractBlockPreview(selectedBlock) + : ""; + + return { + title: postTitle || "Untitled draft", + blockCount: allBlocks.filter((block) => { + const preview = extractBlockPreview(block); + return ( + preview || + (Array.isArray(block.innerBlocks) && block.innerBlocks.length > 0) + ); + }).length, + selectedBlockLabel: selectedBlock + ? `${selectedType || "block"} ${selectedBlockId.slice(0, 6)}` + : "None selected", + selectedBlockPreview: selectedPreview + ? selectedPreview.slice(0, 90) + : "", + }; + }, []); + + React.useEffect(() => { + const updateSnapshot = () => { + const next = buildWorkspaceSnapshot(); + setWorkspaceSnapshot((prev) => { + if (JSON.stringify(prev) === JSON.stringify(next)) { + return prev; + } + return next; + }); + }; + + updateSnapshot(); + const unsubscribe = wp.data?.subscribe + ? wp.data.subscribe(updateSnapshot) + : null; + return () => { + if (typeof unsubscribe === "function") { + unsubscribe(); + } + }; + }, [buildWorkspaceSnapshot]); + + // Auto-scroll to bottom when new messages arrive + React.useEffect(() => { + if (messagesContainerRef.current) { + const container = messagesContainerRef.current; + container.scrollTop = container.scrollHeight; + } + }, [messages, isLoading]); + + React.useEffect(() => { + const handleBeforeUnload = (event) => { + if (!isLoading) { + return; + } + event.preventDefault(); + event.returnValue = ""; + return ""; + }; + window.addEventListener("beforeunload", handleBeforeUnload); + return () => + window.removeEventListener("beforeunload", handleBeforeUnload); + }, [isLoading]); + + React.useEffect(() => { + loadSectionBlocks(); + }, [postId]); + + React.useEffect(() => { + if (!postId) { + return; + } + try { + const savedSession = window.localStorage.getItem( + `wpawSessionId_${postId}`, + ); + if (savedSession) { + setCurrentSessionId(savedSession); + } + } catch (error) { + // Ignore storage read errors. + } + }, [postId]); + + React.useEffect(() => { + if (!postId || !currentSessionId) { + return; + } + try { + window.localStorage.setItem( + `wpawSessionId_${postId}`, + currentSessionId, + ); + } catch (error) { + // Ignore storage write errors. + } + }, [postId, currentSessionId]); + + const sanitizeMessagesForStorage = React.useCallback((items) => { + if (!Array.isArray(items)) { + return []; + } + + const MAX_MESSAGES = 300; + const clipped = items.slice(-MAX_MESSAGES); + + return clipped.map((msg) => { + const out = {}; + out.role = typeof msg?.role === "string" ? msg.role : "assistant"; + + if (typeof msg?.content === "string") { + out.content = msg.content; + } + if (typeof msg?.type === "string") { + out.type = msg.type; + } + if (typeof msg?.status === "string") { + out.status = msg.status; + } + if (msg?.timestamp) { + out.timestamp = msg.timestamp; + } + if (Array.isArray(msg?.sections)) { + out.sections = msg.sections; + } + if (msg?.meta && typeof msg.meta === "object") { + out.meta = msg.meta; + } + if (msg?.plan && typeof msg.plan === "object") { + out.plan = msg.plan; + } + + return out; + }); + }, []); + + const hydrateSessionStateFromMessages = React.useCallback( + (sessionMessages) => { + if (!Array.isArray(sessionMessages) || sessionMessages.length === 0) { + currentPlanRef.current = null; + setAgentMode("chat"); + return; + } + + let latestPlan = null; + for (let i = sessionMessages.length - 1; i >= 0; i -= 1) { + if (sessionMessages[i]?.type === "plan" && sessionMessages[i]?.plan) { + latestPlan = ensurePlanTasks(sessionMessages[i].plan); + break; + } + } + + currentPlanRef.current = latestPlan; + if (latestPlan) { + setAgentMode("planning"); + } else { + setAgentMode("chat"); + } + setShowWelcome(false); + }, + [], + ); + + const persistSessionMessages = React.useCallback( + async (sessionId, items) => { + if (!sessionId) { + return; + } + const sanitized = sanitizeMessagesForStorage(items); + const serialized = JSON.stringify(sanitized); + if (serialized === lastPersistedMessagesRef.current) { + return; + } + + try { + const response = await fetch( + `${wpAgenticWriter.apiUrl}/conversations/${sessionId}/messages`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + body: JSON.stringify({ messages: sanitized }), + }, + ); + if (!response.ok) { + throw new Error("Failed to persist session messages"); + } + lastPersistedMessagesRef.current = serialized; + } catch (error) { + // Non-fatal: keep editor responsive, but do not mark this state persisted. + window.console?.warn?.( + "WP Agentic Writer: failed to persist session messages.", + error, + ); + } + }, + [sanitizeMessagesForStorage], + ); + + // Flush pending message persistence on page unload to prevent data loss + React.useEffect(() => { + const flushOnUnload = () => { + if (messagesSaveTimeoutRef.current) { + clearTimeout(messagesSaveTimeoutRef.current); + } + if (!currentSessionId || isHydratingSessionRef.current) { + return; + } + const sanitized = sanitizeMessagesForStorage(messages); + const serialized = JSON.stringify(sanitized); + if (serialized === lastPersistedMessagesRef.current) { + return; + } + // Synchronous XHR as last resort during unload (sendBeacon can't set headers) + try { + const xhr = new XMLHttpRequest(); + xhr.open( + "POST", + `${wpAgenticWriter.apiUrl}/conversations/${currentSessionId}/messages`, + false, + ); // sync + xhr.setRequestHeader("Content-Type", "application/json"); + xhr.setRequestHeader("X-WP-Nonce", wpAgenticWriter.nonce); + xhr.send(JSON.stringify({ messages: sanitized })); + } catch (e) { + // Best effort - ignore errors during unload + } + }; + window.addEventListener("beforeunload", flushOnUnload); + window.addEventListener("pagehide", flushOnUnload); + return () => { + window.removeEventListener("beforeunload", flushOnUnload); + window.removeEventListener("pagehide", flushOnUnload); + }; + }, [currentSessionId, messages, sanitizeMessagesForStorage]); + + React.useEffect(() => { + if (!currentSessionId) { + return; + } + if (isHydratingSessionRef.current) { + return; + } + + if (messagesSaveTimeoutRef.current) { + clearTimeout(messagesSaveTimeoutRef.current); + } + + messagesSaveTimeoutRef.current = setTimeout(() => { + persistSessionMessages(currentSessionId, messages); + }, 700); + + return () => { + if (messagesSaveTimeoutRef.current) { + clearTimeout(messagesSaveTimeoutRef.current); + } + }; + }, [currentSessionId, messages, persistSessionMessages]); + + React.useEffect(() => { + const loadChatHistory = async () => { + // Skip if we already have a session loaded (e.g., from openSessionById) + if (messages.length > 0 || isHydratingSessionRef.current) { + return; + } + + try { + const headers = { + "X-WP-Nonce": wpAgenticWriter.nonce, + }; + let historyMessages = []; + let resolvedSessionId = ""; + + // Primary source: merged sessions list (post sessions + unassigned sessions). + const sessions = await loadPostSessions(); + if (sessions.length > 0) { + if (sessions.length > 0) { + let selected = sessions[0]; + const preferred = (() => { + try { + return ( + window.localStorage.getItem(`wpawSessionId_${postId}`) || "" + ); + } catch (error) { + return ""; + } + })(); + if (preferred) { + const match = sessions.find((s) => s?.session_id === preferred); + if (match) { + selected = match; + } + } + resolvedSessionId = selected?.session_id || ""; + if ( + Array.isArray(selected?.messages) && + selected.messages.length > 0 + ) { + historyMessages = selected.messages; + } + } + } + + // Canonical single-session endpoint fallback. + if (postId && !resolvedSessionId) { + const primary = await fetch( + `${wpAgenticWriter.apiUrl}/conversation/${postId}`, + { + method: "GET", + headers, + }, + ); + if (primary.ok) { + const data = await primary.json(); + if (data?.session_id) { + resolvedSessionId = data.session_id; + } + if ( + data && + Array.isArray(data.messages) && + data.messages.length > 0 + ) { + historyMessages = data.messages; + } + } + } + + // Legacy endpoint fallback - only if no session found at all. + if (postId && historyMessages.length === 0 && !resolvedSessionId) { + const legacy = await fetch( + `${wpAgenticWriter.apiUrl}/chat-history/${postId}`, + { + method: "GET", + headers, + }, + ); + if (legacy.ok) { + const legacyData = await legacy.json(); + if ( + legacyData && + Array.isArray(legacyData.messages) && + legacyData.messages.length > 0 + ) { + historyMessages = legacyData.messages; + } + } + } + + if (historyMessages.length > 0) { + isHydratingSessionRef.current = true; + lastPersistedMessagesRef.current = JSON.stringify( + sanitizeMessagesForStorage(historyMessages), + ); + hydrateSessionStateFromMessages(historyMessages); + setMessages(historyMessages); + setTimeout(() => { + isHydratingSessionRef.current = false; + }, 0); + } + if (resolvedSessionId) { + setCurrentSessionId(resolvedSessionId); + } + } catch (error) { + // Ignore history load failures. + } + }; + loadChatHistory(); + // Only run on mount / postId change — NOT on currentSessionId change + // Session switches are handled by openSessionById directly + }, [postId]); + + // MEMANTO: Restore session from memory when post editor loads. + React.useEffect(() => { + if (!postId || memantoRestoreFetchedRef.current) { + return; + } + memantoRestoreFetchedRef.current = true; + + fetch(`${wpAgenticWriter.apiUrl}/memanto/restore?post_id=${postId}`, { + headers: { "X-WP-Nonce": wpAgenticWriter.nonce }, + }) + .then((r) => (r.ok ? r.json() : Promise.reject(r))) + .then((data) => { + if (data?.restored) { + wpawLog.info("MEMANTO: Session restored", data); + setMemantoRestore({ + restored: true, + summary: data.summary || "", + memories: data.memories || [], + preferences: data.preferences || [], + systemMessage: data.system_message || "", + }); + } + }) + .catch(() => { + // Graceful degradation — ignore restore failures. + }); + }, [postId]); + + // MEMANTO: Apply user preferences to new post config (carry-over). + React.useEffect(() => { + if ( + !postId || + !memantoRestore.restored || + !memantoRestore.preferences?.length || + !configHydratedRef.current + ) { + return; + } + // Only apply preferences that aren't already set by the post config. + setPostConfig((prev) => { + let changed = false; + const updated = { ...prev }; + for (const pref of memantoRestore.preferences) { + // Parse "User preference: tone=X, audience=Y, length=Z, language=W" + const content = pref.content || ""; + const toneMatch = content.match(/tone\s*=\s*([^,\n]+)/i); + const audienceMatch = content.match(/audience\s*=\s*([^,\n]+)/i); + const lengthMatch = content.match( + /(?:article_)?length\s*=\s*([^,\n]+)/i, + ); + const langMatch = content.match(/language\s*=\s*([^,\n]+)/i); + if ( + toneMatch && + !prev.tone && + "default" !== toneMatch[1].trim().toLowerCase() + ) { + updated.tone = toneMatch[1].trim(); + changed = true; + } + if ( + audienceMatch && + !prev.audience && + "general" !== audienceMatch[1].trim().toLowerCase() + ) { + updated.audience = audienceMatch[1].trim(); + changed = true; + } + if ( + lengthMatch && + prev.article_length === "medium" && + "medium" !== lengthMatch[1].trim().toLowerCase() + ) { + updated.article_length = lengthMatch[1].trim(); + changed = true; + } + if ( + langMatch && + prev.language === "auto" && + "auto" !== langMatch[1].trim().toLowerCase() + ) { + updated.language = langMatch[1].trim(); + changed = true; + } + } + return changed ? updated : prev; + }); + }, [postId, memantoRestore.restored, isConfigLoading]); + + const loadPostSessions = async () => { + const headers = { + "X-WP-Nonce": wpAgenticWriter.nonce, + }; + let postSessions = []; + let unassignedSessions = []; + const fetchUserSessionsByStatus = async (status) => { + const response = await fetch( + `${wpAgenticWriter.apiUrl}/conversations?status=${status}&limit=50`, + { + method: "GET", + headers, + }, + ); + if (!response.ok) { + return []; + } + const data = await response.json(); + return Array.isArray(data?.sessions) ? data.sessions : []; + }; + const getContinuableUnassignedSessions = ( + sessions, + includeDraftLinked = false, + ) => + sessions.filter((s) => { + const pid = Number(s?.post_id || 0); + const postStatus = String(s?.post_status || "").toLowerCase(); + return ( + pid === 0 || + postStatus === "auto-draft" || + (includeDraftLinked && postStatus === "") + ); + }); + if (postId) { + const postRes = await fetch( + `${wpAgenticWriter.apiUrl}/conversations/post/${postId}`, + { + method: "GET", + headers, + }, + ); + if (postRes.ok) { + const postData = await postRes.json(); + postSessions = Array.isArray(postData?.sessions) + ? postData.sessions + : []; + } + + // Fallback: if this post has no linked sessions, surface active unassigned sessions + // so users can continue unfinished work from other tabs or prior page loads. + // This covers auto-draft AND draft posts that haven't had a session linked yet. + if (postSessions.length === 0) { + const [activeSessions, completedSessions] = await Promise.all([ + fetchUserSessionsByStatus("active"), + fetchUserSessionsByStatus("completed"), + ]); + // Completed is a legacy conversation status; still treat it as continuable. + unassignedSessions = getContinuableUnassignedSessions( + [...activeSessions, ...completedSessions], + true, + ); + } + } else { + // New post flow: include unassigned/auto-draft sessions for recovery. + const [activeSessions, completedSessions] = await Promise.all([ + fetchUserSessionsByStatus("active"), + fetchUserSessionsByStatus("completed"), + ]); + // Completed is a legacy conversation status; still treat it as continuable. + unassignedSessions = getContinuableUnassignedSessions([ + ...activeSessions, + ...completedSessions, + ]); + } + + const merged = [...postSessions, ...unassignedSessions]; + const deduped = []; + const seen = new Set(); + merged.forEach((session) => { + const sid = session?.session_id || ""; + if (!sid || seen.has(sid)) { + return; + } + const storedMessageCount = Number( + session?.message_count ?? + (Array.isArray(session?.messages) ? session.messages.length : 0), + ); + if (storedMessageCount <= 0) { + return; + } + seen.add(sid); + deduped.push(session); + }); + + setAvailableSessions(deduped); + return deduped; + }; + const openSessionById = async (sessionId) => { + if (!sessionId) { + return; + } + const headers = { + "X-WP-Nonce": wpAgenticWriter.nonce, + }; + setIsSessionActionLoading(true); + try { + const response = await fetch( + `${wpAgenticWriter.apiUrl}/conversations/${sessionId}`, + { + method: "GET", + headers, + }, + ); + if (!response.ok) { + throw new Error("Failed to load session"); + } + const data = await response.json(); + isHydratingSessionRef.current = true; + setCurrentSessionId(sessionId); + const sessionMessages = Array.isArray(data?.messages) + ? data.messages + : []; + + // If session has no messages, try fetching from the conversations/post endpoint + // as a recovery mechanism (messages may be stored under post relationship) + if ( + sessionMessages.length === 0 && + data?.post_id && + Number(data.post_id) > 0 + ) { + wpawLog.warn( + "Session has 0 messages, attempting post-based recovery:", + sessionId, + ); + try { + const postSessionRes = await fetch( + `${wpAgenticWriter.apiUrl}/conversation/${data.post_id}`, + { + method: "GET", + headers, + }, + ); + if (postSessionRes.ok) { + const postSessionData = await postSessionRes.json(); + if ( + Array.isArray(postSessionData?.messages) && + postSessionData.messages.length > 0 + ) { + sessionMessages.push(...postSessionData.messages); + } + } + } catch (e) { + // Non-fatal recovery attempt + } + } + + lastPersistedMessagesRef.current = JSON.stringify( + sanitizeMessagesForStorage(sessionMessages), + ); + hydrateSessionStateFromMessages(sessionMessages); + setMessages(sessionMessages); + setShowWelcome(false); + + // Auto-link unassigned session to current post for continuity + const sessionPostId = Number(data?.post_id || 0); + if (postId && postId > 0 && sessionPostId === 0) { + fetch( + `${wpAgenticWriter.apiUrl}/conversations/${sessionId}/link-post`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + body: JSON.stringify({ postId: postId }), + }, + ).catch(() => {}); // Non-blocking + } + + setTimeout(() => { + isHydratingSessionRef.current = false; + }, 0); + } catch (error) { + isHydratingSessionRef.current = false; + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: "Error: Failed to load selected session.", + }, + ]); + } finally { + setIsSessionActionLoading(false); + } + }; + + const resolveStreamTarget = (content) => { + if (progressRegex.test(content)) { + return "timeline"; + } + + if (content.length >= 6 || /[\s.!?]/.test(content)) { + return "assistant"; + } + + return null; + }; + const normalizeMentionToken = (token) => { + if (!token) { + return ""; + } + + return token + .replace(/[\u2010-\u2015\u2212]/g, "-") + .replace(/[.,;:!?)]*$/g, "") + .toLowerCase(); + }; + const extractMentionsFromText = (text) => { + const tokens = []; + const mentionRegex = /@([^\s]+)/g; + let match; + + while ((match = mentionRegex.exec(text))) { + const normalized = normalizeMentionToken(match[1]); + if (normalized) { + tokens.push("@" + normalized); + } + } + + return tokens; + }; + const stripMentionsFromText = (text) => { + if (!text) { + return ""; + } + + return text + .replace(/@[\w-]+/g, "") + .replace(/\s{2,}/g, " ") + .trim(); + }; + const hasTitleMention = (mentionTokens) => { + return ( + Array.isArray(mentionTokens) && + mentionTokens.some( + (token) => + normalizeMentionToken(String(token).replace("@", "")) === "title", + ) + ); + }; + const handleTitleRefinement = async ( + rawMessage, + mentionTokens, + options = {}, + ) => { + const { skipUserMessage = false } = options; + const instruction = stripMentionsFromText(rawMessage || ""); + + if (!instruction) { + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: + "Please add title instruction after @title. Example: @title tulis ulang, gunakan focus keyword di awal.", + }, + ]); + return false; + } + + if (!skipUserMessage) { + setMessages((prev) => [...prev, { role: "user", content: rawMessage }]); + } + + const operationController = beginAgentOperation( + "title", + "title refinement", + ); + setIsLoading(true); + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "refining", + message: "Refining title...", + timestamp: new Date(), + }, + ]); + + try { + const response = await fetch(`${wpAgenticWriter.apiUrl}/refine-title`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + body: JSON.stringify({ + postId: postId, + sessionId: currentSessionId, + instruction: instruction, + }), + signal: operationController.signal, + }); + + const data = await response.json(); + if (!response.ok) { + throw new Error(data?.message || "Failed to refine title"); + } + + if (data?.title) { + dispatch("core/editor").editPost({ title: data.title }); + } + + if (data?.cost) { + setCost({ ...cost, session: cost.session + Number(data.cost || 0) }); + } + applyProviderMetadata(data); + setMessages((prev) => { + const next = [...prev]; + const timelineIndex = findLastActiveTimelineIndex(next); + if (timelineIndex !== -1) { + next[timelineIndex] = { + ...next[timelineIndex], + status: "complete", + message: "Title refined successfully.", + completedAt: new Date(), + }; + } + next.push({ + role: "assistant", + content: `Updated title: ${data.title || ""}`, + }); + return next; + }); + return true; + } catch (error) { + if (timeout) { + clearTimeout(timeout); + } + if (isAbortError(error)) { + setMessages((prev) => { + const next = [...prev]; + const timelineIndex = findLastActiveTimelineIndex(next); + if (timelineIndex !== -1) { + next[timelineIndex] = { + ...next[timelineIndex], + status: "stopped", + message: "Title refinement stopped.", + }; + } + return next; + }); + return false; + } + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: "Error: " + (error.message || "Failed to refine title"), + }, + ]); + return false; + } finally { + setIsLoading(false); + finishAgentOperation("title"); + } + }; + const parseInsertCommand = (text) => { + const commands = [ + { mode: "add_below", regex: /^\s*(?:\/)?add below\b[:\-]?\s*/i }, + { mode: "add_above", regex: /^\s*(?:\/)?add above\b[:\-]?\s*/i }, + { + mode: "append_code", + regex: /^\s*(?:\/)?append code block\b[:\-]?\s*/i, + }, + { mode: "append_code", regex: /^\s*(?:\/)?append code\b[:\-]?\s*/i }, + { mode: "append_code", regex: /^\s*(?:\/)?add code block\b[:\-]?\s*/i }, + ]; + + for (const command of commands) { + if (command.regex.test(text)) { + return { + mode: command.mode, + message: text.replace(command.regex, "").trim(), + }; + } + } + + return null; + }; + const getSlashOptions = (query) => { + const options = [ + { + id: "add-below", + label: "add below", + sublabel: "Insert a new paragraph below the target block", + insertText: "add below @", + }, + { + id: "add-above", + label: "add above", + sublabel: "Insert a new paragraph above the target block", + insertText: "add above @", + }, + { + id: "append-code-block", + label: "append code block", + sublabel: "Insert a code block below the target block", + insertText: "append code block @", + }, + { + id: "reformat", + label: "reformat", + sublabel: "Convert markdown-like text into blocks", + insertText: "reformat @", + }, + ]; + + if (!query) { + return options; + } + + const queryLower = query.toLowerCase(); + return options.filter((option) => option.label.includes(queryLower)); + }; + const getBlockIndex = (clientId) => { + const blockIndex = select("core/block-editor").getBlockIndex + ? select("core/block-editor").getBlockIndex(clientId) + : -1; + if (blockIndex !== -1) { + return blockIndex; + } + + const allBlocks = select("core/block-editor").getBlocks(); + return allBlocks.findIndex((block) => block.clientId === clientId); + }; + const resolveTargetBlockId = (mentionTokens) => { + if (mentionTokens.length > 0) { + const resolved = resolveBlockMentions(mentionTokens); + if (resolved.length > 0) { + return resolved[0]; + } + } + + const selectedBlockId = + select("core/block-editor").getSelectedBlockClientId(); + if (selectedBlockId) { + return selectedBlockId; + } + + const allBlocks = select("core/block-editor").getBlocks(); + return allBlocks.length > 0 + ? allBlocks[allBlocks.length - 1].clientId + : null; + }; + const insertRefinementBlock = async ( + mode, + message, + mentionTokens, + originalMessage, + ) => { + const initialTargetBlockId = resolveTargetBlockId(mentionTokens); + const initialTargetBlock = initialTargetBlockId + ? select("core/block-editor").getBlock(initialTargetBlockId) + : null; + const listParentId = + initialTargetBlock?.name === "core/list-item" + ? getParentListId(initialTargetBlockId) + : null; + const targetBlockId = listParentId || initialTargetBlockId; + if (!targetBlockId) { + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: + "No target block found. Select a block or mention one with @paragraph-1.", + }, + ]); + setIsLoading(false); + return; + } + + const insertIndexBase = getBlockIndex(targetBlockId); + const insertIndex = + insertIndexBase === -1 + ? undefined + : insertIndexBase + (mode === "add_above" ? 0 : 1); + const { insertBlocks } = dispatch("core/block-editor"); + const blockType = mode === "append_code" ? "core/code" : "core/paragraph"; + const newBlock = wp.blocks.createBlock( + blockType, + mode === "append_code" + ? { content: "", language: "text" } + : { content: "" }, + ); + + insertBlocks(newBlock, insertIndex); + + let refinementMessage = stripMentionsFromText(message); + + if (initialTargetBlock?.name === "core/list-item") { + const listItemText = extractBlockPreview(initialTargetBlock); + if (listItemText) { + refinementMessage = refinementMessage + ? `${refinementMessage}\n\nAdd a short description for: "${listItemText}".` + : `Add a short description for: "${listItemText}".`; + } + } + + const contextSnippets = getContextFromMentions( + mentionTokens, + initialTargetBlockId, + ); + if (!contextSnippets.length) { + const headingContext = getHeadingContextForBlock(targetBlockId); + if (headingContext) { + contextSnippets.push(`Heading: ${headingContext}`); + } + getNearbyParagraphContext(targetBlockId, 2).forEach( + (snippet, index) => { + contextSnippets.push(`Paragraph ${index + 1}: ${snippet}`); + }, + ); + } + + if (contextSnippets.length) { + refinementMessage = `${refinementMessage}\n\nContext snippets:\n${contextSnippets.map((snippet) => `- ${snippet}`).join("\n")}`; + } + + const requestedBlockType = blockType; + refinementMessage = `${refinementMessage}\n\nReturn only JSON: {"content":"...","blockType":"${requestedBlockType}"} with no extra text.`; + + if (mode === "append_code") { + refinementMessage += ' Put the code in "content" only, no backticks.'; + } + + setInput(""); + setMessages((prev) => [ + ...prev, + { role: "user", content: originalMessage }, + ]); + await handleChatRefinement(refinementMessage, [newBlock.clientId], { + skipUserMessage: true, + useDiffPlan: false, + }); + }; + const streamGeneratePlan = async (request, options = {}) => { + const { resume = false, suggestKeywords = agentMode === "planning" } = + options; + const normalizedRequest = { + ...request, + postConfig: postConfig, + chatHistory: buildChatHistoryPayload(), + }; + lastGenerationRequestRef.current = normalizedRequest; + const operationType = + agentMode === "planning" ? "planning" : "generation"; + const operationController = beginAgentOperation( + operationType, + operationType === "planning" + ? "outline generation" + : "article generation", + ); + setIsLoading(true); + + // Capture snapshot before generation (only if not resuming) + if (!resume) { + pushUndoSnapshot("Article Generation"); + } + + let timeout = null; + try { + const response = await fetch( + wpAgenticWriter.apiUrl + "/generate-plan", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + body: JSON.stringify({ ...normalizedRequest, resume: resume }), + signal: operationController.signal, + }, + ); + + if (!response.ok) { + const error = await response.json(); + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: formatAiErrorMessage( + error, + "Failed to generate article", + ), + canRetry: true, + retryType: "generation", + }, + ]); + return; + } + + const reader = registerActiveReader(response.body.getReader()); + const decoder = new TextDecoder(); + timeout = setTimeout(() => { + if (isLoading) { + wpawLog.error("Generation timeout - no response received"); + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: formatAiErrorMessage( + "cURL error 28: Operation timed out after 120000 milliseconds", + "Failed to generate article", + ), + canRetry: true, + retryType: "generation", + }, + ]); + setIsLoading(false); + reader.cancel(); + } + }, 120000); + + while (true) { + if (stopExecutionRef.current || operationController.signal.aborted) { + await reader.cancel().catch(() => {}); + throw new DOMException("Operation stopped by user", "AbortError"); + } + 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({ ...cost, session: cost.session + data.cost }); + if (agentMode === "planning" && data.plan) { + updateOrCreatePlanMessage(data.plan, { suggestKeywords }); + } + } else if (data.type === "title_update") { + dispatch("core/editor").editPost({ title: data.title }); + } 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; + }); + } else if ( + data.type === "conversational" || + data.type === "conversational_stream" + ) { + const cleanContent = (data.content || "") + .replace(/~~~ARTICLE~+/g, "") + .replace(/~~~ARTICLE~~~[\r\n]*/g, "") + .trim(); + + if ( + !cleanContent || + shouldSkipPlanningCompletion(cleanContent) + ) { + continue; + } + + const streamTarget = + streamTargetRef.current || + resolveStreamTarget(cleanContent); + if (!streamTarget) { + continue; + } + + streamTargetRef.current = streamTarget; + + if (streamTarget === "timeline") { + updateOrCreateTimelineEntry(cleanContent); + } else if (data.type === "conversational") { + setMessages((prev) => [ + ...prev, + { role: "assistant", content: cleanContent }, + ]); + } else { + setMessages((prev) => { + const newMessages = [...prev]; + const lastIdx = newMessages.length - 1; + if ( + newMessages[lastIdx] && + newMessages[lastIdx].role === "assistant" + ) { + newMessages[lastIdx] = { + ...newMessages[lastIdx], + content: cleanContent, + }; + } else { + newMessages.push({ + role: "assistant", + content: cleanContent, + }); + } + return newMessages; + }); + } + } else if (data.type === "block") { + const { insertBlocks } = dispatch("core/block-editor"); + let newBlock; + + if (data.block.blockName === "core/paragraph") { + const content = + data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ""; + newBlock = wp.blocks.createBlock("core/paragraph", { + content: content, + }); + } else if (data.block.blockName === "core/heading") { + const level = data.block.attrs?.level || 2; + const content = + data.block.innerHTML?.match( + /(.*?)<\/h[1-6]>/, + )?.[1] || ""; + newBlock = wp.blocks.createBlock("core/heading", { + level: level, + content: content, + }); + } else if (data.block.blockName === "core/list") { + const listItems = (data.block.innerBlocks || []).map( + (item) => { + const content = + item.innerHTML?.match(/

  • (.*?)<\/li>/)?.[1] || ""; + return wp.blocks.createBlock("core/list-item", { + content: content, + }); + }, + ); + newBlock = wp.blocks.createBlock( + "core/list", + { + ...(data.block.attrs || {}), + ordered: data.block.attrs?.ordered || false, + }, + listItems, + ); + } else if (data.block.blockName === "core/quote") { + const content = + data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ""; + newBlock = wp.blocks.createBlock("core/quote", { + value: content, + }); + } else if (data.block.blockName === "core/image") { + newBlock = wp.blocks.createBlock( + "core/image", + data.block.attrs || {}, + ); + } else if (data.block.blockName === "core/code") { + newBlock = wp.blocks.createBlock( + "core/code", + data.block.attrs || {}, + ); + } + + if (newBlock) { + insertBlocks(newBlock); + } + } else if (data.type === "complete") { + applyProviderMetadata(data); + clearTimeout(timeout); + setCost({ ...cost, session: cost.session + data.totalCost }); + + setMessages((prev) => { + const newMessages = [...prev]; + const lastTimelineIndex = + findLastActiveTimelineIndex(newMessages); + if (lastTimelineIndex !== -1) { + newMessages[lastTimelineIndex] = { + ...newMessages[lastTimelineIndex], + status: "complete", + message: + agentMode === "planning" + ? "Outline ready." + : "Article generated successfully!", + completedAt: new Date(), + }; + } + return newMessages; + }); + } else if (data.type === "error") { + clearTimeout(timeout); + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: formatAiErrorMessage( + data.message || + "An error occurred during article generation", + "Failed to generate article", + ), + canRetry: true, + retryType: "generation", + }, + ]); + } + } catch (parseError) { + wpawLog.error( + "Failed to parse streaming data:", + line, + parseError, + ); + } + } + } + } + + if (timeout) { + clearTimeout(timeout); + } + } catch (error) { + if (isAbortError(error)) { + setMessages((prev) => { + const newMessages = [...prev]; + const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); + if (lastTimelineIndex !== -1) { + newMessages[lastTimelineIndex] = { + ...newMessages[lastTimelineIndex], + status: "stopped", + message: "Generation stopped.", + }; + } + return newMessages; + }); + return; + } + wpawLog.error("Article generation error:", error); + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: formatAiErrorMessage(error, "Failed to generate article"), + canRetry: true, + retryType: "generation", + }, + ]); + } finally { + setIsLoading(false); + finishAgentOperation(operationType); + } + }; + const retryLastGeneration = () => { + if (!lastGenerationRequestRef.current) { + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: + "Cannot retry because the original generation request is no longer available. Please send the request again.", + }, + ]); + return; + } + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "starting", + message: "Resuming generation...", + timestamp: new Date(), + }, + ]); + streamGeneratePlan(lastGenerationRequestRef.current, { resume: true }); + }; + const retryLastExecute = () => { + if (!lastExecuteRequestRef.current) { + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: + "Cannot retry because the original writing request is no longer available. Please start writing again.", + }, + ]); + return; + } + executePlanFromCard({ retry: true }); + }; + const retryLastRefinement = () => { + if (!lastRefineRequestRef.current) { + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: + "Cannot retry because the original refinement request is no longer available. Please send the refinement again.", + }, + ]); + return; + } + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "starting", + message: "Retrying refinement...", + timestamp: new Date(), + }, + ]); + handleChatRefinement( + lastRefineRequestRef.current.message, + lastRefineRequestRef.current.blocksOverride, + lastRefineRequestRef.current.options, + ); + }; + const retryLastChat = async () => { + if (!lastChatRequestRef.current) { + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: + "Cannot retry because the original chat request is no longer available. Please send the message again.", + }, + ]); + return; + } + const userMessage = lastChatRequestRef.current.message; + + // Remove the last error message + setMessages((prev) => + prev.filter((m) => !(m.type === "error" && m.retryType === "chat")), + ); + setIsLoading(true); + const retryOperationController = beginAgentOperation( + "chat", + "chat retry", + ); + + try { + const chatHistory = messages + .filter((m) => m.role === "user" || m.role === "assistant") + .map((m) => ({ role: m.role, content: m.content })); + + const response = await fetch(wpAgenticWriter.apiUrl + "/chat", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + body: JSON.stringify({ + messages: [...chatHistory, { role: "user", content: userMessage }], + postId: postId, + sessionId: currentSessionId, + type: "chat", + stream: true, + postConfig: postConfig, + }), + signal: retryOperationController.signal, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || "Failed to chat"); + } + + const reader = registerActiveReader(response.body.getReader()); + const decoder = new TextDecoder(); + let streamBuffer = ""; + let fullContent = ""; + let streamError = null; + let lastDataTime = Date.now(); + let heartbeatShown = false; + + // Heartbeat: show reassurance if no data for 30s + const heartbeatInterval = setInterval(() => { + if (Date.now() - lastDataTime > 30000 && !heartbeatShown) { + heartbeatShown = true; + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "timeline", + status: "active", + message: + "⏳ Still waiting for response — the model is processing...", + timestamp: new Date(), + }, + ]); + } + }, 10000); + + try { + while (true) { + if ( + stopExecutionRef.current || + retryOperationController.signal.aborted + ) { + throw new DOMException("Chat retry stopped", "AbortError"); + } + const { done, value } = await reader.read(); + if (done) break; + lastDataTime = Date.now(); + heartbeatShown = false; + + streamBuffer += decoder.decode(value, { stream: true }); + const lines = streamBuffer.split("\n"); + streamBuffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + try { + const data = JSON.parse(line.slice(6)); + if (data.type === "error") { + streamError = new Error(data.message || "Chat error"); + break; + } + if ( + data.type === "conversational_stream" || + data.type === "conversational" + ) { + fullContent = data.content; + setMessages((prev) => { + const lastMsg = prev[prev.length - 1]; + if ( + lastMsg && + lastMsg.role === "assistant" && + lastMsg.isStreaming + ) { + return [ + ...prev.slice(0, -1), + { ...lastMsg, content: fullContent }, + ]; + } + return [ + ...prev, + { + role: "assistant", + content: fullContent, + isStreaming: true, + }, + ]; + }); + } else if (data.type === "complete") { + // Apply provider metadata from completion. + applyProviderMetadata(data); + + setMessages((prev) => { + const lastMsg = prev[prev.length - 1]; + if (lastMsg && lastMsg.role === "assistant") { + return [ + ...prev.slice(0, -1), + { ...lastMsg, isStreaming: false }, + ]; + } + return prev; + }); + // Extract ALL focus keyword suggestions from completed response. + if (fullContent) { + const suggestions = + extractFocusKeywordSuggestions(fullContent); + if (suggestions.length > 0) { + addFocusKeywordSuggestions(suggestions); + } + } + } else if (data.type === "provider" && data.fallback_used) { + // Show in-chat provider fallback warning + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "timeline", + status: "active", + message: `⚠️ ${data.selectedProvider || "Selected provider"} unavailable — using ${data.provider || "fallback"}`, + timestamp: new Date(), + }, + ]); + } + } catch (e) { + wpawLog.error("Failed to parse retry streaming data:", line, e); + } + } + + if (streamError) { + throw streamError; + } + } + if ( + stopExecutionRef.current || + retryOperationController.signal.aborted + ) { + throw new DOMException("Chat retry stopped", "AbortError"); + } + } finally { + clearInterval(heartbeatInterval); + } + } catch (error) { + if ( + isAbortError(error) || + stopExecutionRef.current || + retryOperationController.signal.aborted + ) { + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "stopped", + message: "Chat retry stopped by user.", + timestamp: new Date(), + }, + ]); + } else { + const errorMsg = formatAiErrorMessage(error, "Failed to chat"); + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: errorMsg, + canRetry: true, + retryType: "chat", + }, + ]); + } + } finally { + setIsLoading(false); + finishAgentOperation("chat"); + } + }; + const createBlockFromPlan = (action) => { + const blockType = action.blockType || "core/paragraph"; + const content = action.content || ""; + + if (blockType === "core/image") { + const match = content.match( + /^!\[(.*?)\]\(([^)\s]+)(?:\s+"[^"]*")?\)\s*$/, + ); + const alt = match ? match[1] : ""; + const url = match ? match[2] : ""; + return wp.blocks.createBlock("core/image", { + id: 0, + url: url, + alt: alt, + caption: "", + sizeSlug: "large", + linkDestination: "none", + }); + } + + if (blockType === "core/heading") { + return wp.blocks.createBlock("core/heading", { + level: action.level || 2, + content: content, + }); + } + + if (blockType === "core/list") { + const items = content + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + const listItems = items.map((item) => + wp.blocks.createBlock("core/list-item", { content: item }), + ); + return wp.blocks.createBlock( + "core/list", + { + ordered: action.ordered || false, + ...(action.start ? { start: parseInt(action.start, 10) } : {}), + }, + listItems, + ); + } + + if (blockType === "core/code") { + return wp.blocks.createBlock("core/code", { + content: content, + language: action.language || "text", + }); + } + + return wp.blocks.createBlock(blockType, { content: content }); + }; + const normalizePlanActions = (plan) => { + if (!plan || !plan.actions) { + return []; + } + if (Array.isArray(plan.actions)) { + return plan.actions; + } + return Object.values(plan.actions); + }; + const buildPlanPreviewItem = (action, index) => { + if (!action || !action.action) { + return { title: "Unknown action" }; + } + + const type = action.blockType + ? ` (${action.blockType.replace("core/", "")})` + : ""; + const content = (action.content || "").replace(/\s+/g, " ").trim(); + const contentPreview = content + ? `"${content.substring(0, 80)}${content.length > 80 ? "..." : ""}"` + : ""; + const before = getBlockPreviewById(action.blockId); + const beforePreview = before + ? `"${before.substring(0, 80)}${before.length > 80 ? "..." : ""}"` + : ""; + const targetLabel = before + ? ` "${before.substring(0, 40)}${before.length > 40 ? "..." : ""}"` + : ""; + const targetPreview = beforePreview || '"Target block not found"'; + const blockId = action.blockId || null; + + switch (action.action) { + case "keep": + return { title: "Keep" }; + case "delete": + return { + title: `Delete${targetLabel}`, + target: targetPreview, + targetLabel: "Target", + blockId, + }; + case "replace": + return { + title: `Replace${targetLabel}${type}`, + before: beforePreview, + after: contentPreview, + blockId, + }; + case "change_type": + return { + title: `Change type${targetLabel}${type}`, + before: beforePreview, + after: contentPreview, + blockId, + }; + case "insert_before": + return { + title: `Insert before${targetLabel}${type}`, + target: targetPreview, + targetLabel: "Target", + after: contentPreview, + blockId, + }; + case "insert_after": + return { + title: `Insert after${targetLabel}${type}`, + target: targetPreview, + targetLabel: "Target", + after: contentPreview, + blockId, + }; + default: + return { + title: `${action.action}${targetLabel}${type}`, + after: contentPreview, + blockId, + }; + } + }; + const normalizePlanSectionTitle = (section) => { + const heading = (section?.heading || section?.title || "").toString(); + return heading + .replace(/<[^>]+>/g, "") + .trim() + .toLowerCase(); + }; + const upsertSectionBlock = (sectionId, blockId) => { + if (!sectionId || !blockId) { + return; + } + + const sectionMap = sectionBlocksRef.current[sectionId] || []; + if (!sectionMap.includes(blockId)) { + sectionBlocksRef.current[sectionId] = [...sectionMap, blockId]; + } + blockSectionRef.current[blockId] = sectionId; + }; + const removeSectionBlock = (sectionId, blockId) => { + if (!sectionId || !blockId) { + return; + } + const sectionMap = sectionBlocksRef.current[sectionId] || []; + sectionBlocksRef.current[sectionId] = sectionMap.filter( + (id) => id !== blockId, + ); + delete blockSectionRef.current[blockId]; + }; + const loadSectionBlocks = async () => { + if (!postId) { + return; + } + try { + const response = await fetch( + `${wpAgenticWriter.apiUrl}/section-blocks/${postId}`, + { + method: "GET", + headers: { + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + }, + ); + + if (!response.ok) { + return; + } + + const data = await response.json(); + if ( + data && + data.sectionBlocks && + typeof data.sectionBlocks === "object" + ) { + sectionBlocksRef.current = data.sectionBlocks; + blockSectionRef.current = {}; + Object.entries(data.sectionBlocks).forEach( + ([sectionId, blockIds]) => { + if (Array.isArray(blockIds)) { + blockIds.forEach((blockId) => { + blockSectionRef.current[blockId] = sectionId; + }); + } + }, + ); + } + } catch (error) { + // Ignore load failures for section mapping. + } + }; + const saveSectionBlocks = async (sectionId) => { + if (!sectionId || !postId) { + return; + } + const blockIds = sectionBlocksRef.current[sectionId] || []; + try { + await fetch(`${wpAgenticWriter.apiUrl}/section-blocks`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + body: JSON.stringify({ + postId: postId, + sessionId: currentSessionId, + sectionId: sectionId, + blockIds: blockIds, + }), + }); + } catch (error) { + // Ignore save failures for section mapping. + } + }; + const ensurePlanTasks = (plan) => { + if (!plan || !Array.isArray(plan.sections)) { + return plan; + } + + const nextSections = plan.sections.map((section, index) => { + const id = section?.id || `section-${index + 1}`; + const status = section?.status || "pending"; + return { ...section, id, status }; + }); + + return { ...plan, sections: nextSections }; + }; + const getTargetedRefinementBlocks = (message) => { + if (!message) { + return null; + } + const codeKeywords = /(kode|coding|code|script|snippet|skrip)/i; + if (!codeKeywords.test(message)) { + return null; + } + const allBlocks = select("core/block-editor").getBlocks(); + const codeBlocks = allBlocks.filter( + (block) => block.name === "core/code", + ); + if (codeBlocks.length === 0) { + return null; + } + const affectedSections = new Set(); + codeBlocks.forEach((block) => { + const sectionId = blockSectionRef.current[block.clientId]; + if (sectionId) { + affectedSections.add(sectionId); + } + }); + if (affectedSections.size === 0) { + return null; + } + const targetIds = []; + affectedSections.forEach((sectionId) => { + const blockIds = sectionBlocksRef.current[sectionId] || []; + blockIds.forEach((blockId) => { + targetIds.push(blockId); + }); + }); + return [...new Set(targetIds)]; + }; + const findBestPlanSectionMatch = (message) => { + const plan = currentPlanRef.current; + if (!plan || !Array.isArray(plan.sections) || !message) { + return null; + } + + const stopwords = new Set([ + "dalam", + "poin", + "bagian", + "yang", + "dan", + "atau", + "untuk", + "dengan", + "ada", + "tidak", + "lebih", + "ini", + "itu", + "seperti", + "agar", + "akan", + "jadi", + "fokus", + "tulis", + "ulang", + "hapus", + "tambahkan", + "pembahasan", + "pada", + "berikan", + "gunakan", + "jelaskan", + "buat", + ]); + const tokens = message + .toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .split(/\s+/) + .filter((token) => token.length > 3 && !stopwords.has(token)); + + if (tokens.length === 0) { + return null; + } + + let best = null; + let bestScore = 0; + + plan.sections.forEach((section) => { + const sectionText = [ + section?.heading, + section?.title, + section?.description, + Array.isArray(section?.content) && section.content.length > 0 + ? section.content[0]?.content + : "", + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + + if (!sectionText) { + return; + } + + let score = 0; + tokens.forEach((token) => { + if (sectionText.includes(token)) { + score += 1; + } + }); + + if (score > bestScore) { + bestScore = score; + best = section; + } + }); + + if (!best || bestScore < 2) { + return null; + } + + return best; + }; + const updatePlanSectionStatus = (sectionId, status) => { + if (!sectionId) { + return; + } + setMessages((prev) => { + const newMessages = [...prev]; + for (let i = newMessages.length - 1; i >= 0; i--) { + if (newMessages[i].type === "plan" && newMessages[i].plan?.sections) { + const sections = newMessages[i].plan.sections.map((section) => { + if (section.id === sectionId) { + return { ...section, status: status }; + } + return section; + }); + const plan = { ...newMessages[i].plan, sections }; + newMessages[i] = { ...newMessages[i], plan }; + currentPlanRef.current = plan; + break; + } + } + return newMessages; + }); + }; + const findSectionInsertIndex = (plan, sectionId) => { + const allBlocks = select("core/block-editor").getBlocks(); + if (!plan || !Array.isArray(plan.sections) || !sectionId) { + return allBlocks.length; + } + + const sections = plan.sections; + const sectionIndex = sections.findIndex( + (section) => section.id === sectionId, + ); + if (sectionIndex === -1) { + return allBlocks.length; + } + + for (let i = sectionIndex + 1; i < sections.length; i++) { + const nextSection = sections[i]; + const nextStatus = nextSection?.status || "pending"; + if (nextStatus !== "done") { + continue; + } + const nextHeading = normalizePlanSectionTitle(nextSection); + if (!nextHeading) { + continue; + } + const anchorIndex = allBlocks.findIndex((block) => { + if (block.name !== "core/heading") { + return false; + } + const content = normalizePlanSectionTitle({ + heading: block.attributes?.content, + }); + return content === nextHeading; + }); + if (anchorIndex !== -1) { + return anchorIndex; + } + } + + return allBlocks.length; + }; + + // Check if Writing mode needs empty state + const shouldShowWritingEmptyState = () => { + if (agentMode !== "writing") return false; + if (currentPlanRef.current) return false; + + // Check if editor has content blocks + const allBlocks = select("core/block-editor").getBlocks(); + const hasContent = allBlocks.length > 0; + + // Only show empty state if no plan AND no content in editor + return !hasContent; + }; + + // Summarize chat history for token optimization + const summarizeChatHistory = async () => { + const chatMessages = messages.filter((m) => m.role !== "system"); + + if (chatMessages.length < 4) { + return { summary: "", useFullHistory: true, cost: 0 }; + } + + try { + const response = await fetch( + wpAgenticWriter.apiUrl + "/summarize-context", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + body: JSON.stringify({ + chatHistory: chatMessages, + postId: postId, + sessionId: currentSessionId, + }), + }, + ); + + if (!response.ok) { + throw new Error("Summarization failed"); + } + + const data = await response.json(); + applyProviderMetadata(data); + + if (data.tokens_saved > 0) { + wpawLog.log( + `Context optimized: ~${data.tokens_saved} tokens saved (~$${(data.tokens_saved * 0.0000002).toFixed(4)})`, + ); + } + + return { + summary: data.summary || "", + useFullHistory: data.use_full_history || false, + cost: data.cost || 0, + tokensSaved: data.tokens_saved || 0, + }; + } catch (error) { + wpawLog.error("Summarization error:", error); + return { summary: "", useFullHistory: true, cost: 0 }; + } + }; + + // Detect user intent for contextual actions + const detectUserIntent = async (lastMessage) => { + if (!lastMessage || lastMessage.trim().length === 0) { + return { intent: "continue_chat", cost: 0 }; + } + + try { + const response = await fetch( + wpAgenticWriter.apiUrl + "/detect-intent", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + body: JSON.stringify({ + lastMessage: lastMessage, + hasPlan: Boolean(currentPlanRef.current), + currentMode: agentMode, + postId: postId, + sessionId: currentSessionId, + }), + }, + ); + + if (!response.ok) { + let message = "Intent detection failed"; + try { + const error = await response.json(); + message = error?.message || message; + } catch (parseError) { + // Keep the fallback message if the error response is not JSON. + } + throw new Error(message); + } + + const data = await response.json(); + applyProviderMetadata(data); + return { + intent: data.intent || "continue_chat", + cost: data.cost || 0, + }; + } catch (error) { + wpawLog.error( + "Intent detection error:", + formatAiErrorMessage(error, "Intent detection failed"), + ); + return { intent: "continue_chat", cost: 0 }; + } + }; + + // Build optimized context (full or summarized) + const buildOptimizedContext = async () => { + const result = await summarizeChatHistory(); + + if (result.useFullHistory) { + return { + type: "full", + messages: messages.filter((m) => m.role !== "system"), + cost: 0, + }; + } + + return { + type: "summary", + summary: result.summary, + cost: result.cost, + tokensSaved: result.tokensSaved, + }; + }; + + // Handle reset/clear command + const handleResetCommand = async () => { + if (!confirm("Clear all conversation history? This cannot be undone.")) { + return; + } + + try { + // Clear frontend state + setMessages([]); + currentPlanRef.current = null; + + // Clear backend chat history + await fetch(wpAgenticWriter.apiUrl + "/clear-context", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + body: JSON.stringify({ postId: postId }), + }); + + setMessages([ + { + role: "system", + type: "info", + content: "✅ Context cleared. Starting fresh conversation.", + }, + ]); + } catch (error) { + wpawLog.error("Reset error:", error); + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: "Failed to clear context. Please try again.", + }, + ]); + } + }; + + const updateOrCreatePlanMessage = (plan, options = {}) => { + const { append = false, suggestKeywords = agentMode === "planning" } = + options; + const normalizedPlan = ensurePlanTasks(plan); + currentPlanRef.current = normalizedPlan; + setMessages((prev) => { + const newMessages = [...prev]; + if (!append) { + for (let i = newMessages.length - 1; i >= 0; i--) { + if (newMessages[i].type === "plan") { + newMessages[i] = { ...newMessages[i], plan: normalizedPlan }; + return newMessages; + } + } + } + newMessages.push({ + role: "assistant", + type: "plan", + plan: normalizedPlan, + }); + return newMessages; + }); + + // Auto-suggest keywords after outline is generated + if (suggestKeywords && normalizedPlan) { + suggestKeywordsFromPlan(normalizedPlan); + } + }; + + const suggestKeywordsFromPlan = async (plan) => { + if (!plan || !plan.title || !plan.sections) { + return; + } + + try { + const response = await fetch( + wpAgenticWriter.apiUrl + "/suggest-keywords", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + body: JSON.stringify({ + postId: postId, + sessionId: currentSessionId, + title: plan.title, + sections: plan.sections, + }), + }, + ); + + if (!response.ok) { + throw new Error("Failed to suggest keywords"); + } + + const data = await response.json(); + + // Update post config with suggested keywords + if (data.focus_keyword) { + updatePostConfig("seo_focus_keyword", data.focus_keyword); + } + if (data.secondary_keywords && Array.isArray(data.secondary_keywords)) { + updatePostConfig( + "seo_secondary_keywords", + data.secondary_keywords.join(", "), + ); + } + + // Track cost and apply provider metadata + if (data.cost) { + setCost({ ...cost, session: cost.session + data.cost }); + } + applyProviderMetadata(data); + + // Add assistant message about keyword suggestions + setMessages((prev) => [ + ...prev, + { + role: "assistant", + content: `🎯 **SEO Keywords Suggested:**\n\n**Focus Keyword:** ${data.focus_keyword}\n\n**Secondary Keywords:** ${data.secondary_keywords.join(", ")}\n\n${data.reasoning || ""}\n\nYou can review and edit these in the Config panel before writing.`, + }, + ]); + } catch (error) { + wpawLog.error("Keyword suggestion error:", error); + // Silently fail - don't interrupt the workflow + } + }; + + const buildChatHistoryPayload = React.useCallback(() => { + return messages + .filter( + (m) => + (m.role === "user" || m.role === "assistant") && + typeof m.content === "string" && + m.content.trim(), + ) + .filter((m) => m.type !== "plan") + .map((m) => ({ + role: m.role, + content: m.content.trim().slice(0, 2000), + })) + .slice(-10); + }, [messages]); + + const getLastUserMessageText = React.useCallback(() => { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const m = messages[i]; + if ( + m?.role === "user" && + typeof m.content === "string" && + m.content.trim() + ) { + return m.content.trim(); + } + } + return ""; + }, [messages]); + + const shouldSkipPlanningCompletion = (content) => { + if (agentMode !== "planning") { + return false; + } + + const text = String(content || "").toLowerCase(); + return ( + text.includes("article generation complete") || + text.includes("content has been added to your editor") || + text.includes("article generated successfully") + ); + }; + const getPlanRuntimeSummary = (plan = currentPlanRef.current) => { + const sections = Array.isArray(plan?.sections) ? plan.sections : []; + const done = sections.filter( + (section) => section.status === "done", + ).length; + const inProgress = sections.filter( + (section) => section.status === "in_progress", + ).length; + const pending = Math.max(0, sections.length - done); + return { + total: sections.length, + done, + inProgress, + pending, + label: + sections.length > 0 + ? `${done}/${sections.length} written` + : "No outline", + }; + }; + const getPlanId = (plan = currentPlanRef.current) => { + return plan?.id || plan?.meta?.id || plan?.title || ""; + }; + const classifyAgentIntent = (message) => { + const text = String(message || "").toLowerCase(); + const outlinePattern = + /\b(?:out?line|plan|structure|kerangka|rencana)(?:\s*[- ]?\s*(?:nya|kan))?\b/i; + if ( + /@[a-z0-9-]/i.test(message) || + hasTitleMention(extractMentionsFromText(message)) + ) { + return "targeted_refinement"; + } + if ( + /\b(meta description|meta title|seo audit|seo score|keyword density|schema|faq)\b/i.test( + text, + ) + ) { + if (/\b(meta description|description)\b/i.test(text)) { + return "generate_meta"; + } + return "seo_audit"; + } + if ( + /\b(continue|resume|start writing|write article|write it|generate article|lanjut|tulis artikel|buat artikel)\b/i.test( + text, + ) + ) { + return "write"; + } + if (outlinePattern.test(text)) { + return "outline"; + } + if ( + /\b(refine|rewrite|improve|polish|fix|ai-ish|aiish|slop|humanize|natural|tone|clarity|rapikan|perbaiki)\b/i.test( + text, + ) + ) { + return "refine"; + } + return "chat"; + }; + const decideAgentAction = (message) => { + const intent = classifyAgentIntent(message); + const planSummary = getPlanRuntimeSummary(); + const refineableBlocks = getRefineableBlocks(); + const hasContent = refineableBlocks.length > 0; + const hasPlan = Boolean(currentPlanRef.current && planSummary.total > 0); + + if (intent === "generate_meta") { + return { + action: "generate_meta", + mode: "seo", + reason: "SEO meta request", + }; + } + if (intent === "seo_audit") { + return { + action: "seo_audit", + mode: "seo", + reason: "SEO analysis request", + }; + } + if (intent === "targeted_refinement") { + return { + action: "targeted_refinement", + mode: "refinement", + reason: "Block mention detected", + }; + } + if (intent === "write" && hasPlan && planSummary.pending > 0) { + return { + action: "execute_plan", + mode: "writing", + reason: "Outline has pending sections", + }; + } + if ((intent === "write" || intent === "outline") && !hasContent) { + return { + action: "create_outline", + mode: "planning", + reason: "Fresh post needs outline first", + }; + } + if (intent === "outline") { + return { + action: hasPlan ? "revise_outline" : "create_outline", + mode: "planning", + reason: hasPlan + ? "Existing outline can be revised" + : "Outline requested", + }; + } + if (intent === "refine" && hasContent) { + return { + action: "article_refinement", + mode: "refinement", + reason: "Content refinement requested", + }; + } + return { action: "chat", mode: "chat", reason: "Conversation" }; + }; + const executePlanFromCard = async (options = {}) => { + if (isLoading) { + return; + } + + // Check if plan exists + if (!currentPlanRef.current) { + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: + "No outline found yet. Ask the agent for an outline first, then it can continue into writing.", + }, + ]); + setIsLoading(false); + return; + } + + const plan = currentPlanRef.current; + + // Confirmation: warn if editor already has content blocks + const existingBlocks = select("core/block-editor").getBlocks(); + const hasExistingContent = existingBlocks.some( + (b) => + b.name !== "core/paragraph" || + (b.attributes?.content && b.attributes.content.trim().length > 0), + ); + if (hasExistingContent && !options.skipConfirm) { + const pendingSections = Array.isArray(plan?.sections) + ? plan.sections.filter((section) => section.status !== "done").length + : 0; + const confirmed = window.confirm( + `This will write ${pendingSections} sections into the editor. Existing content will be preserved below the new content.\n\nContinue?`, + ); + if (!confirmed) { + return; + } + } + + setAgentMode("writing"); + const pendingCount = Array.isArray(plan?.sections) + ? plan.sections.filter((section) => section.status !== "done").length + : null; + if (pendingCount === 0) { + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "complete", + message: "All outline items are already written.", + timestamp: new Date(), + }, + ]); + persistWritingStatePatch({ + status: "completed", + current_section_index: Array.isArray(plan?.sections) + ? plan.sections.length + : 0, + sections_written: Array.isArray(plan?.sections) + ? plan.sections + .map((section) => section.id || section.heading || "") + .filter(Boolean) + : [], + plan_id: getPlanId(plan), + resume_token: "", + }); + setAgentMode("chat"); + return; + } + + const { retry = false } = options; + lastExecuteRequestRef.current = { + postId: postId, + sessionId: currentSessionId, + stream: true, + postConfig: postConfig, + detectedLanguage: detectedLanguage, + chatHistory: messages.filter((m) => m.role !== "system"), + }; + + // Reset stop flag + stopExecutionRef.current = false; + setExecutionStopped(false); + const operationController = beginAgentOperation("writing", "writing"); + + setIsLoading(true); + persistWritingStatePatch({ + status: "in_progress", + current_section_index: 0, + sections_written: retry ? writingState.sections_written : [], + plan_id: getPlanId(plan), + resume_token: "", + }); + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "writing", + message: retry ? "Retrying outline..." : "Writing from outline...", + timestamp: new Date(), + }, + ]); + sectionInsertIndexRef.current = {}; + activeSectionIdRef.current = null; + + try { + const response = await fetch( + wpAgenticWriter.apiUrl + "/execute-article", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + body: JSON.stringify(lastExecuteRequestRef.current), + signal: operationController.signal, + }, + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || "Failed to execute outline"); + } + + const reader = registerActiveReader(response.body.getReader()); + const decoder = new TextDecoder(); + let streamBuffer = ""; + const timeout = setTimeout(() => { + if (isLoading) { + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: + "Request timeout. The AI is taking too long to respond. Please try again.", + }, + ]); + persistWritingStatePatch({ + status: "failed", + plan_id: getPlanId(currentPlanRef.current), + resume_token: activeSectionIdRef.current || "", + }); + setIsLoading(false); + reader.cancel(); + } + }, 120000); + + while (true) { + // Check if execution should stop + if (stopExecutionRef.current || operationController.signal.aborted) { + await reader.cancel().catch(() => {}); + clearTimeout(timeout); + setExecutionStopped(true); + setIsLoading(false); + + // Calculate completed sections + const plan = currentPlanRef.current; + const completedCount = + plan?.sections?.filter((s) => s.status === "done").length || 0; + const totalCount = plan?.sections?.length || 0; + const pendingCount = totalCount - completedCount; + persistWritingStatePatch({ + status: "paused", + current_section_index: completedCount, + sections_written: + plan?.sections + ?.filter((s) => s.status === "done") + .map((s) => s.id || s.heading || "") + .filter(Boolean) || [], + plan_id: getPlanId(plan), + resume_token: activeSectionIdRef.current || "", + }); + + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "stopped", + message: `⏸️ Execution stopped (${completedCount}/${totalCount} sections completed)`, + timestamp: new Date(), + }, + { + role: "assistant", + content: `**Execution Paused**\n\n✅ Completed: ${completedCount} section${completedCount !== 1 ? "s" : ""}\n⏳ Pending: ${pendingCount} section${pendingCount !== 1 ? "s" : ""}\n\nYour generated content has been preserved in the editor.`, + showResumeActions: true, + pendingCount: pendingCount, + }, + ]); + break; + } + + const { done, value } = await reader.read(); + if (done) break; + + streamBuffer += decoder.decode(value, { stream: true }); + const lines = streamBuffer.split("\n"); + streamBuffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) { + continue; + } + + try { + const data = JSON.parse(line.slice(6)); + if (data.type === "title_update") { + dispatch("core/editor").editPost({ title: data.title }); + } else if (data.type === "section_start") { + activeSectionIdRef.current = data.sectionId || null; + const insertIndex = findSectionInsertIndex( + currentPlanRef.current, + data.sectionId, + ); + if (data.sectionId) { + sectionInsertIndexRef.current[data.sectionId] = insertIndex; + sectionBlocksRef.current[data.sectionId] = + sectionBlocksRef.current[data.sectionId] || []; + } + updatePlanSectionStatus(data.sectionId, "in_progress"); + persistWritingStatePatch({ + status: "in_progress", + current_section_index: Number(data.index || 0), + plan_id: getPlanId(currentPlanRef.current), + resume_token: data.sectionId || "", + }); + } 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; + }); + } else if (data.type === "block") { + const { insertBlocks } = dispatch("core/block-editor"); + const newBlock = createBlocksFromSerialized(data.block); + if (newBlock) { + const sectionId = + data.sectionId || activeSectionIdRef.current; + const insertIndex = sectionId + ? sectionInsertIndexRef.current[sectionId] + : undefined; + if (typeof insertIndex === "number") { + insertBlocks(newBlock, insertIndex); + sectionInsertIndexRef.current[sectionId] = insertIndex + 1; + } else { + insertBlocks(newBlock); + } + if (sectionId) { + upsertSectionBlock(sectionId, newBlock.clientId); + } + } + } else if (data.type === "section_complete") { + updatePlanSectionStatus(data.sectionId, "done"); + saveSectionBlocks(data.sectionId); + const writtenSectionIds = Array.isArray( + currentPlanRef.current?.sections, + ) + ? currentPlanRef.current.sections + .filter((section) => section.status === "done") + .map((section) => section.id || section.heading || "") + .filter(Boolean) + : [ + ...new Set( + [ + ...(writingState.sections_written || []), + data.sectionId, + ].filter(Boolean), + ), + ]; + persistWritingStatePatch({ + status: "in_progress", + current_section_index: writtenSectionIds.length, + sections_written: writtenSectionIds, + plan_id: getPlanId(currentPlanRef.current), + resume_token: "", + }); + // Check if execution should stop after section completes + if (stopExecutionRef.current) { + await reader.cancel().catch(() => {}); + clearTimeout(timeout); + setExecutionStopped(true); + setIsLoading(false); + persistWritingStatePatch({ + status: "paused", + current_section_index: writtenSectionIds.length, + sections_written: writtenSectionIds, + plan_id: getPlanId(currentPlanRef.current), + resume_token: data.sectionId || "", + }); + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "stopped", + message: "⏸️ Execution stopped by user", + timestamp: new Date(), + }, + ]); + break; + } + } else if (data.type === "assistant_message") { + // Add assistant message to chat + setMessages((prev) => [ + ...prev, + { role: "assistant", content: data.message }, + ]); + } else if (data.type === "complete") { + clearTimeout(timeout); + if (data.totalCost) { + setCost({ ...cost, session: cost.session + data.totalCost }); + } + applyProviderMetadata(data); + setMessages((prev) => { + const newMessages = [...prev]; + const lastTimelineIndex = + findLastActiveTimelineIndex(newMessages); + if (lastTimelineIndex !== -1) { + newMessages[lastTimelineIndex] = { + ...newMessages[lastTimelineIndex], + status: "complete", + message: "Article generated successfully!", + completedAt: new Date(), + }; + } + return newMessages; + }); + setAgentMode("chat"); + persistWritingStatePatch({ + status: "completed", + current_section_index: Array.isArray( + currentPlanRef.current?.sections, + ) + ? currentPlanRef.current.sections.length + : writingState.current_section_index, + sections_written: Array.isArray( + currentPlanRef.current?.sections, + ) + ? currentPlanRef.current.sections + .map((section) => section.id || section.heading || "") + .filter(Boolean) + : writingState.sections_written, + plan_id: getPlanId(currentPlanRef.current), + resume_token: "", + }); + setIsLoading(false); + } else if (data.type === "error") { + clearTimeout(timeout); + throw new Error(data.message || "Failed to execute outline"); + } + } catch (parseError) { + wpawLog.error( + "Failed to parse streaming data:", + line, + parseError, + ); + } + } + } + clearTimeout(timeout); + // If stream ended without a 'complete' event, deactivate lingering timeline entries + setMessages((prev) => { + const hasActive = prev.some( + (m) => + m.type === "timeline" && + m.status && + !["complete", "inactive", "stopped"].includes(m.status), + ); + if (hasActive) { + return deactivateActiveTimelineEntries(prev); + } + return prev; + }); + } catch (error) { + if (isAbortError(error) || stopExecutionRef.current) { + const plan = currentPlanRef.current; + const completedCount = + plan?.sections?.filter((s) => s.status === "done").length || 0; + const totalCount = plan?.sections?.length || 0; + const pendingCount = totalCount - completedCount; + persistWritingStatePatch({ + status: "paused", + current_section_index: completedCount, + sections_written: + plan?.sections + ?.filter((s) => s.status === "done") + .map((s) => s.id || s.heading || "") + .filter(Boolean) || [], + plan_id: getPlanId(plan), + resume_token: activeSectionIdRef.current || "", + }); + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "stopped", + message: `Execution stopped (${completedCount}/${totalCount} sections completed)`, + timestamp: new Date(), + }, + { + role: "assistant", + content: `**Execution Paused**\n\nCompleted: ${completedCount} section${completedCount !== 1 ? "s" : ""}\nPending: ${pendingCount} section${pendingCount !== 1 ? "s" : ""}\n\nYour generated content has been preserved in the editor.`, + showResumeActions: true, + pendingCount: pendingCount, + }, + ]); + return; + } + setAgentMode(currentPlanRef.current ? "planning" : "chat"); + persistWritingStatePatch({ + status: "failed", + plan_id: getPlanId(currentPlanRef.current), + resume_token: activeSectionIdRef.current || "", + }); + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "error", + content: formatAiErrorMessage(error, "Failed to execute outline"), + canRetry: true, + retryType: "execute", + }, + ]); + } finally { + setIsLoading(false); + finishAgentOperation("writing"); + } + }; + + const handleStopExecution = () => { + if (!isLoading && !isSeoAuditing && !isGeneratingMeta) return; + + stopExecutionRef.current = true; + setExecutionStopped(true); + markActiveOperationStopping(); + + if (activeOperationRef.current?.type === "writing") { + persistWritingStatePatch({ + status: "paused", + plan_id: getPlanId(currentPlanRef.current), + resume_token: activeSectionIdRef.current || "", + }); + } + + if (activeReaderRef.current) { + activeReaderRef.current.cancel().catch(() => {}); + } + if ( + activeAbortControllerRef.current && + !activeAbortControllerRef.current.signal.aborted + ) { + activeAbortControllerRef.current.abort(); + } + }; + + const clearChatContext = async () => { + if (isLoading) { + return; + } + + const confirmMessage = + "Start a new agent session for this post? The current session will stay available in Sessions."; + if (!window.confirm(confirmMessage)) { + return; + } + + try { + setIsSessionActionLoading(true); + const response = await fetch( + wpAgenticWriter.apiUrl + "/conversations", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + body: JSON.stringify({ post_id: postId }), + }, + ); + if (!response.ok) { + throw new Error("Failed to create a new conversation"); + } + const data = await response.json(); + if (data?.session_id) { + setCurrentSessionId(data.session_id); + } + await loadPostSessions(); + setMessages([]); + setInClarification(false); + setQuestions([]); + setCurrentQuestionIndex(0); + setAnswers([]); + setPendingRefinement(null); + setPendingEditPlan(null); + streamTargetRef.current = null; + } catch (error) { + if (isAbortError(error) || stopExecutionRef.current) { + setMessages((prev) => { + const newMessages = [...prev]; + const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); + if (lastTimelineIndex !== -1) { + newMessages[lastTimelineIndex] = { + ...newMessages[lastTimelineIndex], + status: "stopped", + message: "Refinement stopped by user.", + }; + } + return newMessages; + }); + setMessages((prev) => [ + ...prev, + { + role: "assistant", + content: + "Refinement stopped. Already-applied block changes remain in the editor and can be undone from the top bar.", + }, + ]); + return; + } + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: "Error: Failed to start a new conversation.", + }, + ]); + } finally { + setIsSessionActionLoading(false); + } + }; + const createBlocksFromSerialized = (block) => { + if (!block || !block.blockName) { + return null; + } + + const attrs = { ...(block.attrs || {}) }; + + // Handle code blocks + if ( + block.blockName === "core/code" && + !attrs.content && + block.innerHTML + ) { + const match = block.innerHTML.match(/([\s\S]*?)<\/code>/i); + if (match && match[1]) { + attrs.content = match[1] + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .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); + const bodyMatch = block.innerHTML.match(/([\s\S]*?)<\/tbody>/i); + if (headMatch || bodyMatch) { + attrs.head = []; + attrs.body = []; + + // Parse thead rows + if (headMatch) { + const headRows = headMatch[1].match(/([\s\S]*?)<\/tr>/gi) || []; + headRows.forEach((row) => { + const cells = []; + const cellMatches = + row.match(/([\s\S]*?)<\/t[hd]>/gi) || []; + cellMatches.forEach((cell) => { + const content = cell.replace(/<\/?t[hd]>/gi, ""); + cells.push({ content, tag: "th" }); + }); + if (cells.length > 0) attrs.head.push({ cells }); + }); + } + + // Parse tbody rows + if (bodyMatch) { + const bodyRows = bodyMatch[1].match(/([\s\S]*?)<\/tr>/gi) || []; + bodyRows.forEach((row) => { + const cells = []; + const cellMatches = row.match(/([\s\S]*?)<\/td>/gi) || []; + cellMatches.forEach((cell) => { + const content = cell.replace(/<\/?td>/gi, ""); + cells.push({ content, tag: "td" }); + }); + if (cells.length > 0) attrs.body.push({ cells }); + }); + } + } + } + + // Handle button blocks from [CTA:...] syntax + if ( + block.blockName === "core/buttons" || + block.blockName === "core/button" + ) { + if (block.blockName === "core/button") { + return wp.blocks.createBlock("core/buttons", {}, [ + wp.blocks.createBlock("core/button", attrs), + ]); + } + } + + if (block.innerBlocks && block.innerBlocks.length > 0) { + const innerBlocks = block.innerBlocks + .map((innerBlock) => createBlocksFromSerialized(innerBlock)) + .filter(Boolean); + return wp.blocks.createBlock(block.blockName, attrs, innerBlocks); + } + + return wp.blocks.createBlock(block.blockName, attrs); + }; + const reformatBlocks = async (blocksToReformat, originalMessage) => { + if (isLoading) { + return; + } + + if (!blocksToReformat || blocksToReformat.length === 0) { + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: "No blocks found to reformat.", + }, + ]); + return; + } + + setIsLoading(true); + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "refining", + message: `Reformatting ${blocksToReformat.length} block(s)...`, + timestamp: new Date(), + }, + ]); + + try { + const response = await fetch( + wpAgenticWriter.apiUrl + "/reformat-blocks", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + body: JSON.stringify({ + blocks: blocksToReformat, + postId: postId, + sessionId: currentSessionId, + }), + }, + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || "Failed to reformat blocks"); + } + + const data = await response.json(); + applyProviderMetadata(data); + const results = data.results || []; + const { replaceBlocks } = dispatch("core/block-editor"); + const currentTitle = + select("core/editor").getEditedPostAttribute("title") || ""; + + results.forEach((result) => { + const newBlocks = (result.blocks || []) + .map(createBlocksFromSerialized) + .filter(Boolean); + if (newBlocks.length > 0) { + replaceBlocks(result.clientId, newBlocks); + } + }); + + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "timeline", + status: "complete", + message: `Reformatted ${results.length} block(s).`, + timestamp: new Date(), + completedAt: new Date(), + }, + ]); + if (data.recommended_title) { + setMessages((prev) => [ + ...prev, + { + role: "assistant", + content: `Suggested title: ${data.recommended_title}`, + }, + ]); + if (data.title_updated || !currentTitle) { + dispatch("core/editor").editPost({ title: data.recommended_title }); + } + } + } catch (error) { + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: "Error: " + (error.message || "Failed to reformat blocks"), + }, + ]); + } finally { + setIsLoading(false); + } + }; + const revisePlanFromPrompt = async (instruction) => { + if (isLoading) { + return; + } + const existingPlan = currentPlanRef.current; + if (!existingPlan) { + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: "No outline found to revise. Generate an outline first.", + }, + ]); + return; + } + + setIsLoading(true); + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "planning", + message: "Updating outline...", + timestamp: new Date(), + }, + ]); + + try { + const response = await fetch(wpAgenticWriter.apiUrl + "/revise-plan", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + body: JSON.stringify({ + instruction: instruction, + plan: existingPlan, + postId: postId, + sessionId: currentSessionId, + postConfig: postConfig, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || "Failed to revise outline"); + } + + const data = await response.json(); + if (data.plan) { + updateOrCreatePlanMessage(data.plan, { append: true }); + } + + if (data.cost) { + setCost({ ...cost, session: cost.session + data.cost }); + } + applyProviderMetadata(data); + + setMessages((prev) => { + const newMessages = [...prev]; + const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); + if (lastTimelineIndex !== -1) { + newMessages[lastTimelineIndex] = { + ...newMessages[lastTimelineIndex], + status: "complete", + message: "Outline updated.", + completedAt: new Date(), + }; + } + return newMessages; + }); + } catch (error) { + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: "Error: " + (error.message || "Failed to revise outline"), + }, + ]); + } finally { + setIsLoading(false); + } + }; + const applyEditPlan = (plan) => { + const actions = normalizePlanActions(plan); + if (actions.length === 0) { + setPendingEditPlan(null); + return; + } + + // Capture snapshot before applying changes + pushUndoSnapshot("Apply Edit Plan"); + + const { replaceBlocks, insertBlocks, removeBlocks } = + dispatch("core/block-editor"); + const allBlocks = select("core/block-editor").getBlocks(); + const baseIndexById = new Map( + allBlocks.map((block, index) => [block.clientId, index]), + ); + const insertOffsets = {}; + const existingIds = new Set(allBlocks.map((block) => block.clientId)); + + actions.forEach((action) => { + if (action.action === "keep") { + return; + } + if (action.blockId && !existingIds.has(action.blockId)) { + return; + } + + if (action.action === "delete" && action.blockId) { + removeBlocks(action.blockId); + return; + } + + if (action.action === "change_type" && action.blockId) { + const newBlock = createBlockFromPlan(action); + replaceBlocks(action.blockId, newBlock); + return; + } + + if (action.action === "replace" && action.blockId) { + const newBlock = createBlockFromPlan(action); + replaceBlocks(action.blockId, newBlock); + return; + } + + if ( + (action.action === "insert_after" || + action.action === "insert_before") && + action.blockId + ) { + const baseIndex = baseIndexById.get(action.blockId); + const offsets = insertOffsets[action.blockId] || { + before: 0, + after: 0, + }; + let insertIndex; + + if (typeof baseIndex === "number") { + if (action.action === "insert_before") { + insertIndex = baseIndex + offsets.before; + offsets.before += 1; + } else { + insertIndex = baseIndex + offsets.before + 1 + offsets.after; + offsets.after += 1; + } + } + insertOffsets[action.blockId] = offsets; + + const newBlock = createBlockFromPlan(action); + insertBlocks(newBlock, insertIndex); + } + }); + + setPendingEditPlan(null); + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "timeline", + status: "complete", + message: "Changes applied.", + }, + ]); + }; + const cancelEditPlan = () => { + setPendingEditPlan(null); + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "timeline", + status: "inactive", + message: "Changes cancelled.", + }, + ]); + }; + + const formatClarificationContext = (questionsList, answersMap) => { + if (!questionsList || questionsList.length === 0) { + return ""; + } + + const lines = []; + questionsList.forEach((question) => { + const answer = answersMap[question.id]; + if (!answer) { + return; + } + lines.push( + `- ${question.question || question.prompt || "Question"}: ${answer}`, + ); + }); + + if (lines.length === 0) { + return ""; + } + + return `\n\nClarification Answers:\n${lines.join("\n")}`; + }; + + // Auto-select first option when question changes + React.useEffect(() => { + if ( + inClarification && + questions.length > 0 && + questions[currentQuestionIndex] + ) { + const currentQuestion = questions[currentQuestionIndex]; + if ( + currentQuestion.type === "single_choice" && + currentQuestion.options && + currentQuestion.options.length > 0 && + !answers[currentQuestion.id] + ) { + const newAnswers = { ...answers }; + newAnswers[currentQuestion.id] = currentQuestion.options[0].value; + setAnswers(newAnswers); + } + } + }, [currentQuestionIndex, questions, inClarification]); + + /** + * Remove duplicate adjacent heading blocks + */ + const removeDuplicateHeadings = (blocks) => { + if (!blocks || blocks.length === 0) { + return blocks; + } + + const cleanedBlocks = []; + let lastHeadingContent = null; + + for (const block of blocks) { + if (block.name === "core/heading") { + const currentHeading = (block.attributes?.content || "") + .trim() + .toLowerCase(); + + if (currentHeading === lastHeadingContent) { + wpawLog.log( + "WP Agentic Writer: Removed duplicate heading:", + block.attributes.content, + ); + continue; + } + + lastHeadingContent = currentHeading; + } else { + lastHeadingContent = null; + } + + cleanedBlocks.push(block); + } + + return cleanedBlocks; + }; + + // Send message and generate article. + // Resolve block mentions to client IDs + const getRefineableBlocks = (options = {}) => { + const { textOnly = false } = options; + const allBlocks = select("core/block-editor").getBlocks(); + const textBlockTypes = new Set([ + "core/paragraph", + "core/heading", + "core/list", + "core/quote", + "core/pullquote", + "core/code", + "core/preformatted", + "core/table", + ]); + return allBlocks.filter((block) => { + if (!block.name || !block.name.startsWith("core/")) { + return false; + } + if (textOnly && !textBlockTypes.has(block.name)) { + return false; + } + // Filter out empty blocks (e.g., default empty paragraph on new posts) + const content = block.attributes?.content || ""; + const hasInnerBlocks = + block.innerBlocks && block.innerBlocks.length > 0; + // Consider block as refineable only if it has content or inner blocks + return content.trim().length > 0 || hasInnerBlocks; + }); + }; + const getListItemBlocks = () => { + const allBlocks = select("core/block-editor").getBlocks(); + const listItems = []; + let listBlockIndex = 0; + + allBlocks.forEach((block) => { + if (block.name !== "core/list") { + return; + } + + listBlockIndex += 1; + const innerItems = Array.isArray(block.innerBlocks) + ? block.innerBlocks + : []; + innerItems.forEach((itemBlock, itemIndex) => { + if (itemBlock.name !== "core/list-item") { + return; + } + + listItems.push({ + block: itemBlock, + parentId: block.clientId, + listIndex: listBlockIndex, + itemIndex: itemIndex, + }); + }); + }); + + return listItems; + }; + const resolveExplicitListItem = (listIndex, itemIndex) => { + const listItems = getListItemBlocks(); + return listItems.find( + (item) => item.listIndex === listIndex && item.itemIndex === itemIndex, + ); + }; + const getParentListId = (blockId) => { + const getParents = select("core/block-editor").getBlockParents; + if (!getParents) { + return null; + } + + const parentIds = getParents(blockId); + for (const parentId of parentIds) { + const parentBlock = select("core/block-editor").getBlock(parentId); + if (parentBlock?.name === "core/list") { + return parentId; + } + } + + return null; + }; + const getBlockContentForContext = (blockId) => { + const block = blockId + ? select("core/block-editor").getBlock(blockId) + : null; + if (!block) { + return ""; + } + + const content = extractBlockPreview(block); + return content ? content.trim() : ""; + }; + const getHeadingContextForBlock = (blockId) => { + const allBlocks = select("core/block-editor").getBlocks(); + const startIndex = allBlocks.findIndex( + (block) => block.clientId === blockId, + ); + if (startIndex === -1) { + return ""; + } + + for (let i = startIndex - 1; i >= 0; i -= 1) { + if (allBlocks[i].name === "core/heading") { + return extractBlockPreview(allBlocks[i]) || ""; + } + } + + return ""; + }; + const getNearbyParagraphContext = (blockId, limit = 2) => { + const allBlocks = select("core/block-editor").getBlocks(); + const startIndex = allBlocks.findIndex( + (block) => block.clientId === blockId, + ); + if (startIndex === -1) { + return []; + } + + const snippets = []; + for (let i = startIndex - 1; i >= 0 && snippets.length < limit; i -= 1) { + if (allBlocks[i].name === "core/paragraph") { + const preview = extractBlockPreview(allBlocks[i]); + if (preview) { + snippets.push(preview.trim()); + } + } + if (allBlocks[i].name === "core/heading") { + break; + } + } + + return snippets.reverse(); + }; + const getContextFromMentions = (mentionTokens, excludeId) => { + const mentionIds = resolveBlockMentions(mentionTokens).filter( + (id) => id && id !== excludeId, + ); + const uniqueIds = [...new Set(mentionIds)]; + return uniqueIds + .map((id) => getBlockContentForContext(id)) + .filter((content) => content); + }; + const extractQuotedTermsFromMessage = (message) => { + if (!message || typeof message !== "string") { + return []; + } + const terms = []; + const quoteRegex = /"([^"]+)"|'([^']+)'/g; + let match; + while ((match = quoteRegex.exec(message)) !== null) { + const term = (match[1] || match[2] || "").trim().toLowerCase(); + if (term && term.length <= 40) { + terms.push(term); + } + } + return [...new Set(terms)]; + }; + const getAllTextRefineableBlocks = () => + getRefineableBlocks({ textOnly: true }); + const selectLikelySlangBlocks = (message) => { + const textBlocks = getAllTextRefineableBlocks(); + const quotedTerms = extractQuotedTermsFromMessage(message); + if (!quotedTerms.length) { + return textBlocks; + } + const matches = textBlocks.filter((block) => { + const content = (extractBlockPreview(block) || "").toLowerCase(); + return quotedTerms.some((term) => content.includes(term)); + }); + // Fallback to all text blocks when heuristic finds nothing. + return matches.length > 0 ? matches : textBlocks; + }; + const isAiSlopRequest = (message = "") => + /\b(ai-ish|aiish|ai-style|ai style|slop|humanize|natural|robotic|generic|fluffy|formulaic|tone)\b/i.test( + String(message), + ); + const getAiSlopFindingsForBlock = (block) => { + const content = (extractBlockPreview(block) || "").trim(); + if (!content) { + return []; + } + + const findings = []; + const rules = [ + { + label: "formulaic contrast phrase", + pattern: /\b(bukan sekadar|not just)\b/i, + }, + { + label: "template-like conclusion phrase", + pattern: + /\b(pada akhirnya|in conclusion|to summarize|in summary|kesimpulannya)\b/i, + }, + { + label: "instructional/meta leakage", + pattern: + /\b(refined version|key refinements|changes made|rationale|could you please share)\b/i, + }, + { label: "dash-heavy sentence style", pattern: /\s[—–-]\s/u }, + { + label: "generic AI phrase", + pattern: + /\b(delve|furthermore|moreover|crucial|paramount|landscape|testament|unlock|harness|leverage|seamless|robust)\b/i, + }, + { + label: "generic marketing claim", + pattern: + /\b(in today's digital world|plays a vital role|it is important to note|when it comes to)\b/i, + }, + ]; + + rules.forEach((rule) => { + if (rule.pattern.test(content)) { + findings.push(rule.label); + } + }); + + if ( + block.name === "core/heading" && + /\b(introduction|conclusion|overview|benefits|key takeaways|final thoughts)\b/i.test( + content, + ) + ) { + findings.push("weak generic heading"); + } + + return [...new Set(findings)]; + }; + const selectLikelyAiSlopBlocks = ( + message, + candidateBlocks = getAllTextRefineableBlocks(), + ) => { + if (!isAiSlopRequest(message)) { + return candidateBlocks; + } + + const matches = candidateBlocks + .map((block) => ({ + block, + findings: getAiSlopFindingsForBlock(block), + })) + .filter((entry) => entry.findings.length > 0); + + return matches.length > 0 ? matches.map((entry) => entry.block) : []; + }; + const buildContextBlocksForRefinement = ( + targetIds, + normalizedAllBlocks, + ) => { + const targetSet = new Set(targetIds); + return normalizedAllBlocks.filter((block, index) => { + if (targetSet.has(block.clientId)) { + return true; + } + const prev = normalizedAllBlocks[index + 1]; + const next = normalizedAllBlocks[index - 1]; + const neighborsTargeted = + targetSet.has(prev?.clientId) || targetSet.has(next?.clientId); + return neighborsTargeted && block.name === "core/heading"; + }); + }; + const buildRefinementDiagnosis = (message, blocks = [], options = {}) => { + const auditContext = options.auditContext || null; + if (auditContext?.source === "seo_audit") { + const candidateCount = Number( + auditContext.candidateBlockCount || blocks.length || 0, + ); + const patternLabel = formatAuditPatternLabel(auditContext); + const candidateLabel = formatCountLabel( + candidateCount, + "candidate block", + ); + const scopeNote = + Number(auditContext.refineableBlockCount || 0) > candidateCount + ? ` I am not sending the full article; I narrowed the scope from ${formatCountLabel(auditContext.refineableBlockCount, "refineable block")} to ${candidateLabel}.` + : ""; + return `Audit found ${patternLabel}. I mapped that audit signal to ${candidateLabel} in the editor.${scopeNote} I will report changed blocks separately after verification.`; + } + const genericHeadingPattern = + /\b(introduction|conclusion|overview|benefits|key takeaways|final thoughts)\b/i; + let aiishCount = 0; + let weakHeadingCount = 0; + let thinBlockCount = 0; + + blocks.forEach((block) => { + const content = extractBlockPreview({ + name: block.name, + attributes: block.attributes || {}, + innerBlocks: block.innerBlocks || [], + }); + const trimmed = (content || "").trim(); + if (!trimmed) { + return; + } + if ( + block.name === "core/heading" && + (trimmed.length < 18 || genericHeadingPattern.test(trimmed)) + ) { + weakHeadingCount += 1; + } + if ( + block.name === "core/paragraph" && + getAiSlopFindingsForBlock(block).length > 0 + ) { + aiishCount += 1; + } + if (trimmed.split(/\s+/).length < 14 && block.name !== "core/heading") { + thinBlockCount += 1; + } + }); + + const issueParts = []; + if (aiishCount > 0) { + issueParts.push( + `${aiishCount} AI-ish paragraph${aiishCount === 1 ? "" : "s"}`, + ); + } + if (weakHeadingCount > 0) { + issueParts.push( + `${weakHeadingCount} weak heading${weakHeadingCount === 1 ? "" : "s"}`, + ); + } + if (thinBlockCount > 0) { + issueParts.push( + `${thinBlockCount} thin block${thinBlockCount === 1 ? "" : "s"}`, + ); + } + + const targetText = + blocks.length === 1 ? "1 block" : `${blocks.length} blocks`; + const requestLabel = /\b(ai-ish|aiish|slop|humanize|natural)\b/i.test( + message, + ) + ? "tone and AI-slop cleanup" + : "the requested refinement"; + + if (issueParts.length === 0) { + return `I inspected ${targetText}. I did not find obvious slop markers, so I will focus on ${requestLabel} while preserving structure.`; + } + + return `I inspected ${targetText}. I found ${issueParts.join(", ")}. I will focus the refinement on ${requestLabel} and keep the surrounding structure stable.`; + }; + + const resolveBlockMentions = (mentions) => { + const allBlocks = select("core/block-editor").getBlocks(); + const selectedBlockId = + select("core/block-editor").getSelectedBlockClientId(); + const resolved = []; + const listItems = getListItemBlocks(); + + mentions.forEach((mention) => { + const type = normalizeMentionToken(mention.replace("@", "")); + const match = type.match(/^([a-z0-9-]+)-(\d+)$/i); + const listItemMatch = type.match(/^(?:listitem|list-item|li)-(\d+)$/i); + const explicitListItemMatch = type.match( + /^list-(\d+)\.list-item-(\d+)$/i, + ); + + switch (type) { + case "this": + if (selectedBlockId) { + resolved.push(selectedBlockId); + } + break; + + case "previous": + if (selectedBlockId) { + const selectedIndex = allBlocks.findIndex( + (b) => b.clientId === selectedBlockId, + ); + if (selectedIndex > 0) { + resolved.push(allBlocks[selectedIndex - 1].clientId); + } + } + break; + + case "next": + if (selectedBlockId) { + const selectedIndex = allBlocks.findIndex( + (b) => b.clientId === selectedBlockId, + ); + if (selectedIndex < allBlocks.length - 1) { + resolved.push(allBlocks[selectedIndex + 1].clientId); + } + } + break; + + case "all": + // @all intentionally targets text-based content blocks only. + getAllTextRefineableBlocks().forEach((block) => { + resolved.push(block.clientId); + }); + break; + + default: + if (explicitListItemMatch) { + const listIndex = parseInt(explicitListItemMatch[1], 10); + const itemIndex = parseInt(explicitListItemMatch[2], 10); + const item = resolveExplicitListItem(listIndex, itemIndex); + if (item) { + resolved.push(item.block.clientId); + } + break; + } + + if (listItemMatch) { + const rawIndex = parseInt(listItemMatch[1], 10); + const targetIndex = rawIndex <= 0 ? 1 : rawIndex; + const listItem = listItems[targetIndex - 1]; + if (listItem) { + resolved.push(listItem.block.clientId); + } + break; + } + + // Handle "paragraph-1", "heading-2", "list-1" format + if (match) { + const blockType = "core/" + match[1]; + const blockIndex = parseInt(match[2]) - 1; // 1-based to 0-based + + let currentIndex = 0; + allBlocks.forEach((block) => { + if (block.name === blockType) { + if (currentIndex === blockIndex) { + resolved.push(block.clientId); + } + currentIndex++; + } + }); + } + break; + } + }); + + return [...new Set(resolved)]; // Remove duplicates + }; + + // Handle chat-based refinement + const handleChatRefinement = async ( + message, + blocksOverride = null, + options = {}, + ) => { + const { + skipUserMessage = false, + useDiffPlan = true, + auditContext = null, + } = options; + lastRefineRequestRef.current = { message, blocksOverride, options }; + + // Capture snapshot before refinement + pushUndoSnapshot("Block Refinement"); + + // Parse mentions from message + const mentionRegex = /@([a-z0-9-]+(?:-\d+)?|this|previous|next|all)/gi; + const mentionMatches = [...message.matchAll(mentionRegex)]; + const mentions = mentionMatches.map((m) => "@" + m[1]); + + // Resolve to block client IDs + const blocksToRefine = blocksOverride || resolveBlockMentions(mentions); + const hasAllMention = mentions.some( + (token) => normalizeMentionToken(token.replace("@", "")) === "all", + ); + let resolvedIds = blocksToRefine; + if (hasAllMention && !blocksOverride) { + const likelyBlocks = isAiSlopRequest(message) + ? selectLikelyAiSlopBlocks(message) + : selectLikelySlangBlocks(message); + resolvedIds = likelyBlocks.map((block) => block.clientId); + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "timeline", + status: "inactive", + message: `@all scope narrowed to ${resolvedIds.length} likely block(s) based on the request.`, + timestamp: new Date(), + }, + ]); + } + + if (resolvedIds.length === 0) { + // No valid mentions found - alert user + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: + "No valid blocks found to refine. Try @this, @previous, @next, @all, @paragraph-1, @listitem-3, or @list-3.list-item-0.", + }, + ]); + setIsLoading(false); + return; + } + + if ( + hasAllMention && + resolvedIds.length >= REFINEMENT_ALL_CONFIRM_THRESHOLD + ) { + const confirmed = await requestRefineAllConfirmation( + resolvedIds.length, + ); + if (!confirmed) { + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "timeline", + status: "inactive", + message: `Cancelled @all refinement (${resolvedIds.length} target blocks).`, + timestamp: new Date(), + }, + ]); + setIsLoading(false); + return; + } + } + const effectiveUseDiffPlan = hasAllMention ? false : useDiffPlan; + const shouldUseSelectiveRefine = + hasAllMention || (isAiSlopRequest(message) && resolvedIds.length > 1); + + const serializeBlockForApi = (block) => { + if (!block) { + return null; + } + + return { + clientId: block.clientId, + name: block.name, + attributes: block.attributes || {}, + innerBlocks: Array.isArray(block.innerBlocks) + ? block.innerBlocks.map(serializeBlockForApi).filter(Boolean) + : [], + }; + }; + + // Get actual block data snapshot from editor + const allBlocksSnapshot = select("core/block-editor").getBlocks(); + const normalizedAllBlocks = allBlocksSnapshot + .map(serializeBlockForApi) + .filter(Boolean); + const blocksToRefineData = resolvedIds + .map((clientId) => + normalizedAllBlocks.find((block) => block.clientId === clientId), + ) + .filter(Boolean); + const contextBlocksForApi = buildContextBlocksForRefinement( + resolvedIds, + normalizedAllBlocks, + ); + const refinementDiagnosis = buildRefinementDiagnosis( + message, + blocksToRefineData, + { auditContext }, + ); + const isAuditRefinement = auditContext?.source === "seo_audit"; + const targetLabel = isAuditRefinement + ? `${resolvedIds.length} audit candidate block(s)` + : `${resolvedIds.length} block(s)`; + + // Add user message to chat + if (!skipUserMessage) { + setMessages((prev) => [...prev, { role: "user", content: message }]); + } + + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "checking", + message: isAuditRefinement + ? "Reading editor and mapping audit findings to candidate blocks..." + : "Reading editor and inspecting target blocks...", + timestamp: new Date(), + }, + { + role: "assistant", + type: "agent_diagnosis", + content: refinementDiagnosis, + }, + { + role: "system", + type: "timeline", + status: "refining", + message: isAuditRefinement + ? `Processing ${targetLabel}; changed blocks will be verified after streaming...` + : `Refining ${targetLabel}...`, + timestamp: new Date(), + }, + ]); + + setIsRefinementLocked(true); + setRefiningBlockIds(resolvedIds); + const operationController = beginAgentOperation( + "refinement", + "refinement", + ); + setIsLoading(true); + + try { + // Get selected block + const selectedBlockId = + select("core/block-editor").getSelectedBlockClientId(); + + // Call refinement endpoint with actual block data + const response = await fetch( + wpAgenticWriter.apiUrl + "/refine-from-chat", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + body: JSON.stringify({ + topic: message, + context: message, + selectedBlockClientId: selectedBlockId, + blocksToRefine: blocksToRefineData, // Send actual block objects + allBlocks: contextBlocksForApi, + postId: postId, + sessionId: currentSessionId, + stream: true, + diffPlan: effectiveUseDiffPlan, + selectiveRefine: shouldUseSelectiveRefine, + auditContext: isAuditRefinement ? auditContext : null, + postConfig: postConfig, + chatHistory: messages.filter((m) => m.role !== "system"), + }), + signal: operationController.signal, + }, + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || "Refinement failed"); + } + + // Handle streaming response + streamTargetRef.current = null; + const reader = registerActiveReader(response.body.getReader()); + const decoder = new TextDecoder(); + let streamBuffer = ""; + let refinedCount = 0; + const updatedSectionIds = new Set(); + const { replaceBlocks } = dispatch("core/block-editor"); + let refinementFailed = false; + let refinementErrorMessage = ""; + + while (true) { + if (stopExecutionRef.current || operationController.signal.aborted) { + await reader.cancel().catch(() => {}); + throw new DOMException("Operation stopped by user", "AbortError"); + } + const { done, value } = await reader.read(); + if (done) break; + + streamBuffer += decoder.decode(value, { stream: true }); + const lines = streamBuffer.split("\n"); + streamBuffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + try { + const data = JSON.parse(line.slice(6)); + + if (data.type === "error") { + refinementFailed = true; + refinementErrorMessage = data.message || "Refinement failed."; + break; + } else if (data.type === "status") { + setMessages((prev) => { + const newMessages = [...prev]; + const lastTimelineIndex = + findLastActiveTimelineIndex(newMessages); + if (lastTimelineIndex !== -1) { + newMessages[lastTimelineIndex] = { + ...newMessages[lastTimelineIndex], + message: + data.message || + newMessages[lastTimelineIndex].message, + timestamp: new Date(), + }; + } + return newMessages; + }); + } else if (data.type === "edit_plan") { + setPendingEditPlan(data.plan); + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "edit_plan", + plan: data.plan, + }, + ]); + } else if (data.type === "block") { + // Replace block in editor + const blockData = data.block; + + if (blockData.blockName && blockData.attrs) { + let newBlock; + + // Create block using WordPress createBlock API + if ( + blockData.innerBlocks && + blockData.innerBlocks.length > 0 + ) { + // For lists with inner blocks + const innerBlocks = blockData.innerBlocks.map( + (innerB) => { + return wp.blocks.createBlock( + innerB.blockName, + innerB.attrs, + ); + }, + ); + + newBlock = wp.blocks.createBlock( + blockData.blockName, + blockData.attrs, + innerBlocks, + ); + } else { + // For simple blocks (paragraph, heading) + newBlock = wp.blocks.createBlock( + blockData.blockName, + blockData.attrs, + ); + } + + // Replace the target block + if (newBlock && newBlock.name) { + const sectionId = + blockSectionRef.current[blockData.clientId]; + replaceBlocks(blockData.clientId, newBlock); + setRefiningBlockIds((prevIds) => + prevIds.map((id) => + id === blockData.clientId ? newBlock.clientId : id, + ), + ); + if (sectionId) { + removeSectionBlock(sectionId, blockData.clientId); + upsertSectionBlock(sectionId, newBlock.clientId); + updatedSectionIds.add(sectionId); + } + } + } + + refinedCount++; + } else if (data.type === "complete") { + // Apply provider metadata from completion + applyProviderMetadata(data); + + // Update timeline + setMessages((prev) => { + const newMessages = [...prev]; + const lastTimelineIndex = + findLastActiveTimelineIndex(newMessages); + if (lastTimelineIndex !== -1) { + const failedLabel = + Number(data.failed || 0) > 0 + ? `, ${Number(data.failed)} failed` + : ""; + const auditPatternLabel = + formatAuditPatternLabel(auditContext); + const auditCandidateLabel = formatCountLabel( + auditContext?.candidateBlockCount || resolvedIds.length, + "candidate block", + ); + const auditChangedLabel = formatCountLabel( + refinedCount, + "changed block", + ); + newMessages[lastTimelineIndex] = { + ...newMessages[lastTimelineIndex], + status: data.aborted ? "error" : "complete", + message: isAuditRefinement + ? data.aborted + ? `Audit fix stopped early: ${auditPatternLabel} -> ${auditCandidateLabel} inspected -> ${auditChangedLabel}${failedLabel}` + : `Audit fix complete: ${auditPatternLabel} -> ${auditCandidateLabel} inspected -> ${auditChangedLabel}${failedLabel}` + : data.aborted + ? `Refinement stopped early: ${refinedCount} updated${failedLabel}` + : `Refined ${refinedCount} block(s) successfully${failedLabel}`, + timestamp: new Date(), + }; + } + return newMessages; + }); + + // Show completion message + setMessages((prev) => [ + ...prev, + { + role: "assistant", + content: data.aborted + ? isAuditRefinement + ? `Audit fix stopped early after provider errors.\n\n- Audit signal: ${formatAuditPatternLabel(auditContext)}\n- Candidate scope: ${formatCountLabel(auditContext?.candidateBlockCount || resolvedIds.length, "candidate block")} inspected\n- Editor changes: ${formatCountLabel(refinedCount, "block")} changed${Number(data.failed || 0) > 0 ? `\n- Failed attempts: ${Number(data.failed)}` : ""}` + : `⚠️ I stopped early after provider errors. Updated ${refinedCount} block(s)${Number(data.failed || 0) > 0 ? `, ${Number(data.failed)} failed` : ""}.` + : isAuditRefinement + ? `Audit fix complete.\n\n- Audit signal: ${formatAuditPatternLabel(auditContext)}\n- Candidate scope: ${formatCountLabel(auditContext?.candidateBlockCount || resolvedIds.length, "candidate block")} inspected\n- Editor changes: ${formatCountLabel(refinedCount, "block")} changed${Number(data.failed || 0) > 0 ? `\n- Failed attempts: ${Number(data.failed)}` : ""}\n\nVerification: changed blocks were written back to the editor and can be undone from the top bar.` + : `✅ Done! I've refined ${refinedCount} block(s) as requested${Number(data.failed || 0) > 0 ? `, with ${Number(data.failed)} failed attempts` : ""}.\n\nVerification: updated blocks were written back to the editor and can be undone from the top bar.`, + }, + ]); + + // Update cost + if (data.totalCost) { + setCost({ + ...cost, + session: cost.session + data.totalCost, + }); + } + updatedSectionIds.forEach((sectionId) => { + saveSectionBlocks(sectionId); + }); + } + } catch (e) { + wpawLog.error("Failed to parse streaming data:", line, e); + } + } + if (refinementFailed) { + break; + } + } + if (refinementFailed) { + break; + } + } + + if (stopExecutionRef.current || operationController.signal.aborted) { + throw new DOMException("Operation stopped by user", "AbortError"); + } + + if (refinementFailed) { + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: `Refinement stopped: ${refinementErrorMessage}`, + canRetry: true, + retryType: "refine", + }, + ]); + + setMessages((prev) => { + const newMessages = [...prev]; + const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); + if (lastTimelineIndex !== -1) { + newMessages[lastTimelineIndex] = { + ...newMessages[lastTimelineIndex], + status: "error", + message: "Refinement stopped (edit plan failed)", + }; + } + return newMessages; + }); + } + } catch (error) { + if (isAbortError(error) || stopExecutionRef.current) { + setMessages((prev) => { + const newMessages = [...prev]; + const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); + if (lastTimelineIndex !== -1) { + newMessages[lastTimelineIndex] = { + ...newMessages[lastTimelineIndex], + status: "stopped", + message: "Refinement stopped by user.", + }; + } + return newMessages; + }); + setMessages((prev) => [ + ...prev, + { + role: "assistant", + content: + "Refinement stopped. Already-applied block changes remain in the editor and can be undone from the top bar.", + }, + ]); + return; + } + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: "Error: " + error.message, + canRetry: true, + retryType: "refine", + }, + ]); + + // Update timeline to show error + setMessages((prev) => { + const newMessages = [...prev]; + const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); + if (lastTimelineIndex !== -1) { + newMessages[lastTimelineIndex] = { + ...newMessages[lastTimelineIndex], + status: "error", + message: "Refinement failed", + }; + } + return newMessages; + }); + } finally { + setIsRefinementLocked(false); + setRefiningBlockIds([]); + setIsLoading(false); + finishAgentOperation("refinement"); + } + }; + const renderRefineAllConfirmModal = () => { + if (!refineAllConfirm.isOpen) { + return null; + } + + return wp.element.createElement( + "div", + { + className: "wpaw-refine-confirm-overlay", + role: "dialog", + "aria-modal": "true", + "aria-label": "Confirm large @all refinement", + }, + wp.element.createElement( + "div", + { className: "wpaw-refine-confirm-modal" }, + wp.element.createElement( + "div", + { className: "wpaw-refine-confirm-title" }, + "Confirm @all Refinement", + ), + wp.element.createElement( + "div", + { className: "wpaw-refine-confirm-body" }, + `This will refine ${refineAllConfirm.blockCount} text block(s) in batches of 5. ` + + "This may take time and consume API credits.", + ), + wp.element.createElement(CheckboxControl, { + label: "Don’t ask again for this session", + checked: refineAllConfirm.dontAskAgain, + onChange: (checked) => { + setRefineAllConfirm((prev) => ({ + ...prev, + dontAskAgain: Boolean(checked), + })); + }, + }), + wp.element.createElement( + "div", + { className: "wpaw-refine-confirm-actions" }, + wp.element.createElement( + Button, + { + isSecondary: true, + onClick: () => resolveRefineAllConfirmation(false), + }, + "Cancel", + ), + wp.element.createElement( + Button, + { + isPrimary: true, + onClick: () => { + if (refineAllConfirm.dontAskAgain) { + skipRefineAllConfirmRef.current = true; + } + resolveRefineAllConfirmation(true); + }, + }, + "Continue", + ), + ), + ), + ); + }; + + // Get mention options for autocomplete + const getMentionOptions = (query) => { + const allBlocks = select("core/block-editor").getBlocks(); + const selectedBlockId = + select("core/block-editor").getSelectedBlockClientId(); + const options = []; + + // Add special mentions + if (!query || "this".includes(query.toLowerCase())) { + options.push({ + id: "this", + label: "@this", + sublabel: "Currently selected block", + type: "special", + }); + } + if (!query || "previous".includes(query.toLowerCase())) { + options.push({ + id: "previous", + label: "@previous", + sublabel: "Block before current selection", + type: "special", + }); + } + if (!query || "next".includes(query.toLowerCase())) { + options.push({ + id: "next", + label: "@next", + sublabel: "Block after current selection", + type: "special", + }); + } + if (!query || "all".includes(query.toLowerCase())) { + options.push({ + id: "all", + label: "@all", + sublabel: "All content blocks", + type: "special", + }); + } + if (!query || "title".includes(query.toLowerCase())) { + options.push({ + id: "title", + label: "@title", + sublabel: "Refine post title with instruction", + type: "special", + }); + } + + // Add numbered blocks for core blocks + const blockCounters = {}; + const queryLower = query.toLowerCase(); + let listItemIndex = 0; + let listBlockIndex = 0; + + allBlocks.forEach((block) => { + if (!block.name || !block.name.startsWith("core/")) { + return; + } + + const typeName = block.name.replace("core/", ""); + blockCounters[typeName] = (blockCounters[typeName] || 0) + 1; + const blockLabel = `@${typeName}-${blockCounters[typeName]}`; + + const content = extractBlockPreview(block); + const contentLower = content.toLowerCase(); + if ( + !query || + blockLabel.includes(queryLower) || + contentLower.startsWith(queryLower) + ) { + const truncatedContent = + content.length > 40 ? content.substring(0, 40) + "..." : content; + options.push({ + id: blockLabel, + label: String(blockLabel), + sublabel: truncatedContent || String(typeName), + type: "block", + clientId: block.clientId, + }); + } + + if (block.name === "core/list") { + listBlockIndex += 1; + const innerItems = Array.isArray(block.innerBlocks) + ? block.innerBlocks + : []; + innerItems.forEach((itemBlock, itemIndex) => { + if (itemBlock.name !== "core/list-item") { + return; + } + + listItemIndex += 1; + const itemLabel = `@listitem-${listItemIndex}`; + const explicitLabel = `@list-${listBlockIndex}.list-item-${itemIndex}`; + const itemContent = extractBlockPreview(itemBlock); + const itemLower = itemContent.toLowerCase(); + if ( + !query || + itemLabel.includes(queryLower) || + explicitLabel.includes(queryLower) || + itemLower.startsWith(queryLower) + ) { + const truncatedItem = + itemContent.length > 40 + ? itemContent.substring(0, 40) + "..." + : itemContent; + options.push({ + id: itemLabel, + label: String(explicitLabel), + sublabel: truncatedItem + ? `List ${listBlockIndex}: ${truncatedItem}` + : `List ${listBlockIndex} item`, + type: "list-item", + clientId: itemBlock.clientId, + parentClientId: block.clientId, + }); + } + }); + } + }); + + return options; + }; + + React.useEffect(() => { + const handleInsertMention = (event) => { + const token = event?.detail?.token; + if (!token) { + return; + } + + setActiveTab("chat"); + setInput((prev) => { + const prefix = prev && !/\s$/.test(prev) ? prev + " " : prev; + return `${prefix}${token}`; + }); + + setTimeout(() => { + const inputNode = inputRef.current?.textarea || inputRef.current; + if (inputNode) { + inputNode.focus(); + inputNode.selectionStart = inputNode.selectionEnd = + inputNode.value.length; + } + + const mentionOptionsList = getMentionOptions(""); + setMentionOptions(mentionOptionsList); + setShowMentionAutocomplete(mentionOptionsList.length > 0); + }, 0); + }; + + window.addEventListener("wpaw:insert-mention", handleInsertMention); + return () => + window.removeEventListener("wpaw:insert-mention", handleInsertMention); + }, [getMentionOptions]); + + // Handle input change for mention detection + const handleInputChange = (value) => { + setInput(value); + + // Check if user is typing a mention + const inputNode = inputRef.current?.textarea || inputRef.current; + const cursorPosition = + typeof inputNode?.selectionStart === "number" + ? inputNode.selectionStart + : value.length; + const textBeforeCursor = value.substring(0, cursorPosition); + const mentionMatch = textBeforeCursor.match(/@(\w*)$/); + const slashMatch = textBeforeCursor.match(/\/([\w\s]*)$/); + + if (mentionMatch) { + const query = mentionMatch[1]; + setMentionQuery(query); + const options = getMentionOptions(query); + setMentionOptions(options); + setShowMentionAutocomplete(options.length > 0); + setMentionCursorIndex(0); + setShowSlashAutocomplete(false); + setSlashOptions([]); + } else if (slashMatch) { + const query = slashMatch[1]; + setSlashQuery(query); + const options = getSlashOptions(query); + setSlashOptions(options); + setShowSlashAutocomplete(options.length > 0); + setSlashCursorIndex(0); + setShowMentionAutocomplete(false); + setMentionOptions([]); + } else { + setShowMentionAutocomplete(false); + setMentionOptions([]); + setShowSlashAutocomplete(false); + setSlashOptions([]); + } + }; + + // Handle keyboard navigation in autocomplete + const handleKeyDown = (e) => { + if (!showMentionAutocomplete && !showSlashAutocomplete) { + if (e.keyCode === 13 && (e.metaKey || e.ctrlKey)) { + sendMessage(); + } + return; + } + + if (showMentionAutocomplete && e.keyCode === 40) { + // Down arrow + e.preventDefault(); + setMentionCursorIndex((prev) => (prev + 1) % mentionOptions.length); + } else if (showMentionAutocomplete && e.keyCode === 38) { + // Up arrow + e.preventDefault(); + setMentionCursorIndex( + (prev) => (prev - 1 + mentionOptions.length) % mentionOptions.length, + ); + } else if (showMentionAutocomplete && e.keyCode === 13) { + // Enter + e.preventDefault(); + if (mentionOptions[mentionCursorIndex]) { + insertMention(mentionOptions[mentionCursorIndex]); + } + } else if (showSlashAutocomplete && e.keyCode === 40) { + // Down arrow + e.preventDefault(); + setSlashCursorIndex((prev) => (prev + 1) % slashOptions.length); + } else if (showSlashAutocomplete && e.keyCode === 38) { + // Up arrow + e.preventDefault(); + setSlashCursorIndex( + (prev) => (prev - 1 + slashOptions.length) % slashOptions.length, + ); + } else if (showSlashAutocomplete && e.keyCode === 13) { + // Enter + e.preventDefault(); + if (slashOptions[slashCursorIndex]) { + insertSlashCommand(slashOptions[slashCursorIndex]); + } + } else if (e.keyCode === 27) { + // Escape + e.preventDefault(); + setShowMentionAutocomplete(false); + setShowSlashAutocomplete(false); + } + }; + + // Insert selected mention + const insertMention = (option) => { + const value = input; + const inputNode = inputRef.current?.textarea || inputRef.current; + const cursorPosition = + typeof inputNode?.selectionStart === "number" + ? inputNode.selectionStart + : value.length; + const textBeforeCursor = value.substring(0, cursorPosition); + const mentionStart = textBeforeCursor.lastIndexOf("@"); + + const beforeMention = value.substring(0, mentionStart); + const afterMention = value.substring(cursorPosition); + const newValue = beforeMention + option.label + " " + afterMention; + + setInput(newValue); + setShowMentionAutocomplete(false); + setMentionOptions([]); + + // Focus back on input + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, 0); + }; + const insertSlashCommand = (option) => { + const value = input; + const inputNode = inputRef.current?.textarea || inputRef.current; + const cursorPosition = + typeof inputNode?.selectionStart === "number" + ? inputNode.selectionStart + : value.length; + const textBeforeCursor = value.substring(0, cursorPosition); + const slashStart = textBeforeCursor.lastIndexOf("/"); + + const beforeSlash = value.substring(0, slashStart); + const afterSlash = value.substring(cursorPosition); + const newValue = beforeSlash + option.insertText + afterSlash; + + setInput(newValue); + setShowSlashAutocomplete(false); + setSlashOptions([]); + if (option.insertText.endsWith("@")) { + const mentionOptionsList = getMentionOptions(""); + setMentionQuery(""); + setMentionOptions(mentionOptionsList); + setShowMentionAutocomplete(mentionOptionsList.length > 0); + setMentionCursorIndex(0); + } + + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, 0); + }; + + const sendMessage = async () => { + if (!input.trim() || isLoading) { + return; + } + + const userMessage = input.trim(); + // Collapse textarea to give more space for response + setIsTextareaExpanded(false); + + // Check for reset command + if (/^\s*(\/reset|\/clear)\s*$/i.test(userMessage)) { + setInput(""); + await handleResetCommand(); + return; + } + + const agentRoute = decideAgentAction(userMessage); + const effectiveAgentMode = agentRoute.mode || agentMode || "chat"; + if ( + ["chat", "planning", "writing"].includes(effectiveAgentMode) && + effectiveAgentMode !== agentMode + ) { + setAgentMode(effectiveAgentMode); + } + + if (agentRoute.action === "execute_plan") { + setInput(""); + setMessages((prev) => [ + ...prev, + { role: "user", content: userMessage }, + ]); + addActivityTimeline( + "checking", + "Checking outline and editor context...", + ); + await executePlanFromCard({ skipConfirm: true }); + return; + } + + if (agentRoute.action === "generate_meta") { + setInput(""); + setMessages((prev) => [ + ...prev, + { role: "user", content: userMessage }, + ]); + addActivityTimeline( + "checking", + "Reading article and generating meta description...", + ); + await generateMetaDescription(); + return; + } + + if (agentRoute.action === "seo_audit") { + setInput(""); + setMessages((prev) => [ + ...prev, + { role: "user", content: userMessage }, + ]); + addActivityTimeline( + "checking", + "Reading editor and running SEO audit...", + ); + await runSeoAudit(); + return; + } + + const parsedCommand = parseInsertCommand(userMessage); + const commandMessage = parsedCommand + ? parsedCommand.message + : userMessage; + const mentionTokens = extractMentionsFromText(commandMessage); + const hasMentions = mentionTokens.length > 0; + const titleMentioned = hasTitleMention(mentionTokens); + const refineableBlocks = getRefineableBlocks(); + const shouldShowPlan = effectiveAgentMode === "planning"; + const generationLabel = + effectiveAgentMode === "planning" + ? "Creating outline..." + : "Generating article..."; + const reformatCommand = /^\s*(?:\/)?reformat\b/i; + + if (parsedCommand) { + setIsLoading(true); + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "refining", + message: "Preparing insertion...", + timestamp: new Date(), + }, + ]); + await insertRefinementBlock( + parsedCommand.mode, + commandMessage, + mentionTokens, + userMessage, + ); + setIsLoading(false); + return; + } + + if (reformatCommand.test(userMessage)) { + setInput(""); + setMessages((prev) => [ + ...prev, + { role: "user", content: userMessage }, + ]); + const targetIds = hasMentions + ? resolveBlockMentions(mentionTokens) + : getRefineableBlocks().map((block) => block.clientId); + const allBlocks = select("core/block-editor").getBlocks(); + const blocksToReformat = allBlocks.filter((block) => + targetIds.includes(block.clientId), + ); + await reformatBlocks(blocksToReformat, userMessage); + return; + } + + if (titleMentioned) { + setInput(""); + await handleTitleRefinement(userMessage, mentionTokens); + return; + } + + if ( + effectiveAgentMode === "planning" && + !hasMentions && + currentPlanRef.current + ) { + setInput(""); + setMessages((prev) => [ + ...prev, + { role: "user", content: userMessage }, + ]); + await revisePlanFromPrompt(userMessage); + return; + } + + if (effectiveAgentMode === "chat" && !hasMentions) { + setInput(""); + setMessages((prev) => [ + ...prev, + { role: "user", content: userMessage }, + ]); + const operationController = beginAgentOperation( + "chat", + "chat response", + ); + setIsLoading(true); + + // User message is NOT an AI suggestion - don't extract from user input + + // Store for retry + lastChatRequestRef.current = { message: userMessage }; + + try { + const chatHistory = messages + .filter((m) => m.role === "user" || m.role === "assistant") + .map((m) => ({ role: m.role, content: m.content })); + + const response = await fetch(wpAgenticWriter.apiUrl + "/chat", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + body: JSON.stringify({ + messages: [ + ...chatHistory, + { role: "user", content: userMessage }, + ], + postId: postId, + sessionId: currentSessionId, + type: "chat", + stream: true, + postConfig: postConfig, + }), + signal: operationController.signal, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || "Failed to chat"); + } + + const reader = registerActiveReader(response.body.getReader()); + const decoder = new TextDecoder(); + let streamBuffer = ""; + let streamError = null; + streamTargetRef.current = null; + + while (true) { + if ( + stopExecutionRef.current || + operationController.signal.aborted + ) { + await reader.cancel().catch(() => {}); + throw new DOMException("Operation stopped by user", "AbortError"); + } + const { done, value } = await reader.read(); + if (done) break; + + streamBuffer += decoder.decode(value, { stream: true }); + const lines = streamBuffer.split("\n"); + streamBuffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) { + continue; + } + + try { + const data = JSON.parse(line.slice(6)); + if (data.type === "error") { + streamError = new Error(data.message || "Failed to chat"); + break; + } + if ( + data.type === "conversational" || + data.type === "conversational_stream" + ) { + const cleanContent = (data.content || "").trim(); + if (!cleanContent) { + continue; + } + + const streamTarget = + streamTargetRef.current || + resolveStreamTarget(cleanContent); + if (!streamTarget) { + continue; + } + + streamTargetRef.current = streamTarget; + + if (data.type === "conversational") { + setMessages((prev) => { + const newMessages = [...prev]; + const lastIdx = newMessages.length - 1; + const lastMessage = newMessages[lastIdx]; + if ( + lastMessage && + lastMessage.role === "assistant" && + lastMessage.content === cleanContent + ) { + return newMessages; + } + newMessages.push({ + role: "assistant", + content: cleanContent, + }); + return newMessages; + }); + } else { + setMessages((prev) => { + const newMessages = [...prev]; + const lastIdx = newMessages.length - 1; + if ( + newMessages[lastIdx] && + newMessages[lastIdx].role === "assistant" + ) { + newMessages[lastIdx] = { + ...newMessages[lastIdx], + content: cleanContent, + }; + } else { + newMessages.push({ + role: "assistant", + content: cleanContent, + }); + } + return newMessages; + }); + } + } else if (data.type === "complete") { + if (data.totalCost) { + setCost({ + ...cost, + session: cost.session + data.totalCost, + }); + } + applyProviderMetadata(data); + // 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) { + wpawLog.error( + "Failed to parse streaming data:", + line, + parseError, + ); + } + } + + if (streamError) { + throw streamError; + } + } + + // 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]; + const lastIdx = newMessages.length - 1; + if ( + newMessages[lastIdx] && + newMessages[lastIdx].role === "assistant" + ) { + newMessages[lastIdx] = { + ...newMessages[lastIdx], + detectedIntent: intentResult.intent, + }; + } + return newMessages; + }); + } + } catch (intentError) { + wpawLog.error( + "Intent detection failed:", + formatAiErrorMessage(intentError, "Intent detection failed"), + ); + } + } catch (error) { + if (isAbortError(error)) { + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "stopped", + message: "Chat response stopped.", + timestamp: new Date(), + }, + ]); + // Continue to shared cleanup below. + } else { + const errorMsg = formatAiErrorMessage(error, "Failed to chat"); + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: errorMsg, + canRetry: true, + retryType: "chat", + }, + ]); + } + } + + setIsLoading(false); + finishAgentOperation("chat"); + return; + } + + if ( + !hasMentions && + refineableBlocks.length > 0 && + agentRoute.action === "article_refinement" + ) { + // Content exists - run clarity check before full-article refinement + const targetedBlocks = getTargetedRefinementBlocks(userMessage); + const matchedSection = !targetedBlocks + ? findBestPlanSectionMatch(userMessage) + : null; + const matchedSectionBlocks = matchedSection + ? sectionBlocksRef.current[matchedSection.id] || [] + : []; + setInput(""); + setMessages((prev) => [ + ...prev, + { role: "user", content: userMessage }, + ]); + if (matchedSectionBlocks.length > 0) { + setMessages((prev) => [ + ...prev, + { + role: "assistant", + content: `Targeting section: ${matchedSection.heading || matchedSection.title || "Selected section"} (${matchedSectionBlocks.length} block(s)).`, + }, + ]); + } + setIsLoading(true); + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "checking", + message: matchedSection + ? `Analyzing request (targeting: ${matchedSection.heading || matchedSection.title || "section"})...` + : "Analyzing request...", + timestamp: new Date(), + }, + ]); + + const fallbackBlocks = isAiSlopRequest(userMessage) + ? selectLikelyAiSlopBlocks(userMessage, refineableBlocks).map( + (block) => block.clientId, + ) + : refineableBlocks.map((block) => block.clientId); + if ( + isAiSlopRequest(userMessage) && + fallbackBlocks.length === 0 && + !targetedBlocks && + matchedSectionBlocks.length === 0 + ) { + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "assistant", + content: + "I inspected the article and did not find blocks matching the AI-ish/slop detector, so I did not send the whole article to refinement.", + }, + ]); + setIsLoading(false); + return; + } + await handleChatRefinement( + userMessage, + targetedBlocks && targetedBlocks.length > 0 + ? targetedBlocks + : matchedSectionBlocks.length > 0 + ? matchedSectionBlocks + : fallbackBlocks, + { skipUserMessage: true }, + ); + return; + } + + if (!hasMentions) { + // No mentions - check clarity first before article generation + setInput(""); + setMessages((prev) => [ + ...prev, + { role: "user", content: userMessage }, + ]); + const operationType = + effectiveAgentMode === "planning" ? "planning" : "generation"; + const operationController = beginAgentOperation( + operationType, + effectiveAgentMode === "planning" + ? "outline generation" + : "article generation", + ); + setIsLoading(true); + + // Check clarity first + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "checking", + message: "Analyzing request...", + timestamp: new Date(), + }, + ]); + + // First try clarity check + let requestDetectedLanguage = detectedLanguage; + try { + const clarityResponse = await fetch( + wpAgenticWriter.apiUrl + "/check-clarity", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + body: JSON.stringify({ + topic: userMessage, + answers: [], + postId: postId, + sessionId: currentSessionId, + mode: "generation", + postConfig: postConfig, + chatHistory: buildChatHistoryPayload(), + }), + signal: operationController.signal, + }, + ); + + if (clarityResponse.ok) { + const clarityData = await clarityResponse.json(); + const clarityResult = clarityData.result; + + // Store detected language for article generation + if (clarityResult.detected_language) { + requestDetectedLanguage = clarityResult.detected_language; + setDetectedLanguage(clarityResult.detected_language); + } + + if ( + !clarityResult.is_clear && + clarityResult.questions && + clarityResult.questions.length > 0 + ) { + // Need clarification - show quiz + setQuestions(clarityResult.questions); + setInClarification(true); + setCurrentQuestionIndex(0); + setAnswers([]); + setIsLoading(false); + + // Update timeline + setMessages((prev) => { + const newMessages = [...prev]; + const lastTimelineIndex = + findLastActiveTimelineIndex(newMessages); + if (lastTimelineIndex !== -1) { + newMessages[lastTimelineIndex] = { + ...newMessages[lastTimelineIndex], + status: "waiting", + message: "Waiting for clarification...", + }; + } + return newMessages; + }); + finishAgentOperation(operationType); + return; + } + } + // If clarity check fails, proceed with generation anyway + } catch (clarityError) { + if (isAbortError(clarityError)) { + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "stopped", + message: "Generation stopped.", + timestamp: new Date(), + }, + ]); + setIsLoading(false); + finishAgentOperation(operationType); + return; + } + wpawLog.warn( + "Clarity check failed, proceeding with generation:", + clarityError, + ); + // Continue to article generation + } + + // Clear enough - proceed with article generation + // Update timeline + setMessages((prev) => { + const newMessages = [...prev]; + const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); + if (lastTimelineIndex !== -1) { + newMessages[lastTimelineIndex] = { + ...newMessages[lastTimelineIndex], + status: "starting", + message: generationLabel, + }; + } + return newMessages; + }); + + // Now call generate-plan + let timeout = null; + 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: userMessage, + context: "", + postId: postId, + sessionId: currentSessionId, + answers: [], + autoExecute: effectiveAgentMode !== "planning", + stream: true, + articleLength: postConfig.article_length, + detectedLanguage: requestDetectedLanguage, + postConfig: postConfig, + chatHistory: buildChatHistoryPayload(), + }), + signal: operationController.signal, + }, + ); + + if (!response.ok) { + const error = await response.json(); + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: formatAiErrorMessage( + error, + "Failed to generate article", + ), + canRetry: true, + retryType: "generation", + }, + ]); + setIsLoading(false); + return; + } + + // Handle streaming response + streamTargetRef.current = null; + const reader = registerActiveReader(response.body.getReader()); + const decoder = new TextDecoder(); + + // Add timeout to detect hanging responses + timeout = setTimeout(() => { + if (isLoading) { + wpawLog.error("Generation timeout - no response received"); + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: formatAiErrorMessage( + "cURL error 28: Operation timed out after 120000 milliseconds", + "Failed to generate article", + ), + canRetry: true, + retryType: "generation", + }, + ]); + setIsLoading(false); + reader.cancel(); + } + }, 120000); // 2 minute timeout + + while (true) { + if ( + stopExecutionRef.current || + operationController.signal.aborted + ) { + await reader.cancel().catch(() => {}); + throw new DOMException("Operation stopped by user", "AbortError"); + } + 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({ ...cost, session: cost.session + data.cost }); + if (shouldShowPlan && data.plan) { + updateOrCreatePlanMessage(data.plan, { + suggestKeywords: effectiveAgentMode === "planning", + }); + } + } else if (data.type === "title_update") { + dispatch("core/editor").editPost({ title: data.title }); + } else if (data.type === "status") { + if (data.status === "complete") { + continue; + } + + // Update timeline + 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; + }); + } else if ( + data.type === "conversational" || + data.type === "conversational_stream" + ) { + // Remove article marker and clean content + const cleanContent = (data.content || "") + .replace(/~~~ARTICLE~+/g, "") + .replace(/~~~ARTICLE~~~[\r\n]*/g, "") + .trim(); + + // Skip if content is empty after cleaning + if ( + !cleanContent || + shouldSkipPlanningCompletion(cleanContent) + ) { + continue; + } + + const streamTarget = + streamTargetRef.current || + resolveStreamTarget(cleanContent); + + if (!streamTarget) { + continue; + } + + streamTargetRef.current = streamTarget; + + if (streamTarget === "timeline") { + updateOrCreateTimelineEntry(cleanContent); + } else { + // This is actual conversational content - add as chat bubble + if (data.type === "conversational") { + setMessages((prev) => [ + ...prev, + { role: "assistant", content: cleanContent }, + ]); + } else { + setMessages((prev) => { + const newMessages = [...prev]; + const lastIdx = newMessages.length - 1; + if ( + newMessages[lastIdx] && + newMessages[lastIdx].role === "assistant" + ) { + newMessages[lastIdx] = { + ...newMessages[lastIdx], + content: cleanContent, + }; + } else { + newMessages.push({ + role: "assistant", + content: cleanContent, + }); + } + return newMessages; + }); + } + } + } else if (data.type === "block") { + const { insertBlocks } = dispatch("core/block-editor"); + let newBlock; + + if (data.block.blockName === "core/paragraph") { + const content = + data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ""; + newBlock = wp.blocks.createBlock("core/paragraph", { + content: content, + }); + } else if (data.block.blockName === "core/heading") { + const level = data.block.attrs?.level || 2; + const content = + data.block.innerHTML?.match( + /(.*?)<\/h[1-6]>/, + )?.[1] || ""; + newBlock = wp.blocks.createBlock("core/heading", { + level: level, + content: content, + }); + } else if (data.block.blockName === "core/list") { + const listItems = (data.block.innerBlocks || []).map( + (item) => { + const content = + item.innerHTML?.match(/

  • (.*?)<\/li>/)?.[1] || ""; + return wp.blocks.createBlock("core/list-item", { + content: content, + }); + }, + ); + newBlock = wp.blocks.createBlock( + "core/list", + { + ...(data.block.attrs || {}), + ordered: data.block.attrs?.ordered || false, + }, + listItems, + ); + } else if (data.block.blockName === "core/quote") { + const content = + data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ""; + newBlock = wp.blocks.createBlock("core/quote", { + value: content, + }); + } else if (data.block.blockName === "core/image") { + newBlock = wp.blocks.createBlock( + "core/image", + data.block.attrs || {}, + ); + } + + if (newBlock) { + insertBlocks(newBlock); + } + } else if (data.type === "complete") { + applyProviderMetadata(data); + clearTimeout(timeout); + setCost({ + ...cost, + session: cost.session + data.totalCost, + }); + + // Update timeline to complete + setMessages((prev) => { + const newMessages = [...prev]; + const lastTimelineIndex = + findLastActiveTimelineIndex(newMessages); + if (lastTimelineIndex !== -1) { + newMessages[lastTimelineIndex] = { + ...newMessages[lastTimelineIndex], + status: "complete", + message: + effectiveAgentMode === "planning" + ? "Outline ready." + : "Article generated successfully!", + }; + } + return newMessages; + }); + setIsLoading(false); + } else if (data.type === "error") { + clearTimeout(timeout); + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: formatAiErrorMessage( + data.message || + "An error occurred during article generation", + "Failed to generate article", + ), + canRetry: true, + retryType: "generation", + }, + ]); + setIsLoading(false); + } + } catch (parseError) { + wpawLog.error( + "Failed to parse streaming data:", + line, + parseError, + ); + } + } + + if (streamError) { + throw streamError; + } + } + } + // Clear timeout when streaming completes normally + clearTimeout(timeout); + } catch (error) { + if (timeout) { + clearTimeout(timeout); + } + if (isAbortError(error)) { + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "stopped", + message: "Generation stopped.", + timestamp: new Date(), + }, + ]); + } else { + wpawLog.error("Article generation error:", error); + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: formatAiErrorMessage( + error, + "Failed to generate article", + ), + canRetry: true, + retryType: "generation", + }, + ]); + } + setIsLoading(false); + } finally { + finishAgentOperation(operationType); + } + + return; + } + + // Has mentions - check if mentioned blocks exist + let blocksToRefine = []; + if (hasMentions) { + blocksToRefine = resolveBlockMentions(mentionTokens); + } + + if (hasMentions && blocksToRefine.length === 0) { + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: + "No valid blocks found to refine. Select a block and use @this, or target an existing block like @paragraph-1.", + }, + ]); + setIsLoading(false); + return; + } + + if (blocksToRefine.length > 0) { + // Blocks exist - this is a refinement request + setInput(""); + await handleChatRefinement(userMessage); + return; + } + + if (refineableBlocks.length > 0) { + if (userMessage.includes("@")) { + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: + "No valid blocks found to refine. Try @this, @previous, @next, @all, or @paragraph-1.", + }, + ]); + setIsLoading(false); + return; + } + // No valid mentions, but content exists - refine the whole article + setInput(""); + await handleChatRefinement( + userMessage, + refineableBlocks.map((block) => block.clientId), + ); + return; + } + + // Blocks don't exist yet - this is article generation + // User is specifying structure for new article + setInput(""); + setMessages((prev) => [...prev, { role: "user", content: userMessage }]); + const fallbackOperationType = + effectiveAgentMode === "planning" ? "planning" : "generation"; + const fallbackOperationController = beginAgentOperation( + fallbackOperationType, + effectiveAgentMode === "planning" + ? "outline generation" + : "article generation", + ); + setIsLoading(true); + + // Add loading timeline entry + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "starting", + message: "Initializing...", + timestamp: new Date(), + }, + ]); + + 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: userMessage, + context: "", + postId: postId, + sessionId: currentSessionId, + answers: [], + autoExecute: effectiveAgentMode !== "planning", + stream: true, + articleLength: postConfig.article_length, + postConfig: postConfig, + chatHistory: buildChatHistoryPayload(), + }), + signal: fallbackOperationController.signal, + }, + ); + + if (!response.ok) { + const error = await response.json(); + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: formatAiErrorMessage( + error, + "Failed to generate article", + ), + canRetry: true, + retryType: "generation", + }, + ]); + setIsLoading(false); + return; + } + + // Handle streaming response + const reader = registerActiveReader(response.body.getReader()); + const decoder = new TextDecoder(); + + while (true) { + if ( + stopExecutionRef.current || + fallbackOperationController.signal.aborted + ) { + await reader.cancel().catch(() => {}); + throw new DOMException("Operation stopped by user", "AbortError"); + } + 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({ ...cost, session: cost.session + data.cost }); + if (effectiveAgentMode === "planning" && data.plan) { + updateOrCreatePlanMessage(data.plan); + } + } else if (data.type === "title_update") { + dispatch("core/editor").editPost({ title: data.title }); + } else if (data.type === "status") { + if (data.status === "complete") { + continue; + } + + // Update timeline + 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; + }); + } else if ( + data.type === "conversational" || + data.type === "conversational_stream" + ) { + const cleanContent = (data.content || "") + .replace(/~~~ARTICLE~+/g, "") + .replace(/~~~ARTICLE~~~[\r\n]*/g, "") + .trim(); + + if ( + !cleanContent || + shouldSkipPlanningCompletion(cleanContent) + ) { + continue; + } + + const streamTarget = + streamTargetRef.current || + resolveStreamTarget(cleanContent); + + if (!streamTarget) { + continue; + } + + streamTargetRef.current = streamTarget; + + if (streamTarget === "timeline") { + updateOrCreateTimelineEntry(cleanContent); + } else if (data.type === "conversational") { + setMessages((prev) => [ + ...prev, + { role: "assistant", content: cleanContent }, + ]); + } else { + setMessages((prev) => { + const newMessages = [...prev]; + const lastIdx = newMessages.length - 1; + if ( + newMessages[lastIdx] && + newMessages[lastIdx].role === "assistant" + ) { + newMessages[lastIdx] = { + ...newMessages[lastIdx], + content: cleanContent, + }; + } else { + newMessages.push({ + role: "assistant", + content: cleanContent, + }); + } + return newMessages; + }); + } + } else if (data.type === "block") { + const { insertBlocks } = dispatch("core/block-editor"); + let newBlock; + + if (data.block.blockName === "core/paragraph") { + const content = + data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ""; + newBlock = wp.blocks.createBlock("core/paragraph", { + content: content, + }); + } else if (data.block.blockName === "core/heading") { + const level = data.block.attrs?.level || 2; + const content = + data.block.innerHTML?.match( + /(.*?)<\/h[1-6]>/, + )?.[1] || ""; + newBlock = wp.blocks.createBlock("core/heading", { + level: level, + content: content, + }); + } else if (data.block.blockName === "core/list") { + const listItems = (data.block.innerBlocks || []).map( + (item) => { + const content = + item.innerHTML?.match(/

  • (.*?)<\/li>/)?.[1] || ""; + return wp.blocks.createBlock("core/list-item", { + content: content, + }); + }, + ); + newBlock = wp.blocks.createBlock( + "core/list", + { + ...(data.block.attrs || {}), + ordered: data.block.attrs?.ordered || false, + }, + listItems, + ); + } else if (data.block.blockName === "core/quote") { + const content = + data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ""; + newBlock = wp.blocks.createBlock("core/quote", { + value: content, + }); + } else if (data.block.blockName === "core/image") { + newBlock = wp.blocks.createBlock( + "core/image", + data.block.attrs || {}, + ); + } else { + const parsed = wp.blocks.parse(data.block.innerHTML); + newBlock = parsed && parsed.length > 0 ? parsed[0] : null; + } + + if (newBlock) { + insertBlocks(newBlock); + } + } else if (data.type === "complete") { + applyProviderMetadata(data); + setCost({ ...cost, session: cost.session + data.totalCost }); + + // Update timeline to complete + setMessages((prev) => { + const newMessages = [...prev]; + const lastTimelineIndex = + findLastActiveTimelineIndex(newMessages); + if (lastTimelineIndex !== -1) { + newMessages[lastTimelineIndex] = { + ...newMessages[lastTimelineIndex], + status: "complete", + message: + effectiveAgentMode === "planning" + ? "Outline ready." + : "Article generation complete!", + }; + } + return newMessages; + }); + + // Check for image placeholders and open modal if found + if (effectiveAgentMode !== "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, + sessionId: currentSessionId, + imageCount: imagePlaceholders.length, + }, + }), + ); + } + }, 500); + } + } else if (data.type === "error") { + throw new Error(data.message); + } + } catch (parseError) { + wpawLog.error( + "Failed to parse streaming data:", + line, + parseError, + ); + } + } + } + } + + setTimeout(() => { + setIsLoading(false); + }, 1500); + } catch (error) { + if (isAbortError(error)) { + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "stopped", + message: "Generation stopped.", + timestamp: new Date(), + }, + ]); + } else { + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: "Error: " + error.message, + }, + ]); + } + setIsLoading(false); + } finally { + finishAgentOperation(fallbackOperationType); + } + }; + + // Submit answers and continue generation. + const submitAnswers = async () => { + if (isLoading) { + return; + } + + const resolvedPostConfig = buildPostConfigFromAnswers(answers); + + // Process config answers and update post config + // Handle language selection + if (answers.config_language) { + let languageValue = answers.config_language; + // Handle custom language input + if (languageValue === "__custom__" && answers.config_language_custom) { + languageValue = answers.config_language_custom.toLowerCase().trim(); + } + if (languageValue && languageValue !== "__skipped__") { + updatePostConfig("language", languageValue); + } + } + + // Handle other config settings + 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); + } + if (configData.seo !== undefined) { + updatePostConfig("seo_enabled", configData.seo); + } + if (configData.focus_keyword) { + updatePostConfig("focus_keyword", configData.focus_keyword); + updatePostConfig("seo_focus_keyword", configData.focus_keyword); + } + if (configData.secondary_keywords) { + updatePostConfig( + "seo_secondary_keywords", + configData.secondary_keywords, + ); + } + } catch (e) { + wpawLog.error("Failed to parse config answers:", e); + } + } + + if (clarificationMode === "refinement" && pendingRefinement) { + setInClarification(false); + const clarificationContext = formatClarificationContext( + questions, + answers, + ); + const refinedMessage = `${pendingRefinement.message}${clarificationContext}`; + const blocks = pendingRefinement.blocks || []; + setPendingRefinement(null); + setClarificationMode("generation"); + await handleChatRefinement(refinedMessage, blocks, { + skipUserMessage: true, + }); + return; + } + + const submissionMode = agentMode || "chat"; + const submissionOperationType = + submissionMode === "planning" ? "planning" : "generation"; + const submissionOperationController = beginAgentOperation( + submissionOperationType, + submissionMode === "planning" + ? "outline generation" + : "article generation", + ); + setIsLoading(true); + + // Exit quiz mode and return to chat immediately so user can see progress + setInClarification(false); + + // Add timeline entry showing generation is starting + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "starting", + message: + submissionMode === "planning" + ? "Creating outline..." + : "Generating article...", + timestamp: new Date(), + }, + ]); + + let timeout = null; + try { + const topic = + getLastUserMessageText() || + messages + .map((m) => (typeof m.content === "string" ? m.content : "")) + .filter(Boolean) + .join("\n"); + + 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, + context: "", + postId: postId, + sessionId: currentSessionId, + clarificationAnswers: answers, + autoExecute: submissionMode !== "planning", + stream: true, + articleLength: resolvedPostConfig.article_length, + detectedLanguage: detectedLanguage, + postConfig: resolvedPostConfig, + chatHistory: buildChatHistoryPayload(), + }), + signal: submissionOperationController.signal, + }, + ); + + if (!response.ok) { + const error = await response.json(); + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: formatAiErrorMessage(error, "Failed to generate plan"), + canRetry: true, + retryType: "generation", + }, + ]); + setIsLoading(false); + return; + } + + // Handle streaming response (similar to sendMessage) + streamTargetRef.current = null; + const reader = registerActiveReader(response.body.getReader()); + const decoder = new TextDecoder(); + + // Add timeout to detect hanging responses + timeout = setTimeout(() => { + if (isLoading) { + wpawLog.error("Generation timeout - no response received"); + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: formatAiErrorMessage( + "cURL error 28: Operation timed out after 120000 milliseconds", + "Failed to generate plan", + ), + canRetry: true, + retryType: "generation", + }, + ]); + setIsLoading(false); + reader.cancel(); + } + }, 120000); // 2 minute timeout + + while (true) { + if ( + stopExecutionRef.current || + submissionOperationController.signal.aborted + ) { + await reader.cancel().catch(() => {}); + throw new DOMException("Operation stopped by user", "AbortError"); + } + 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({ ...cost, session: cost.session + data.cost }); + if (submissionMode === "planning" && data.plan) { + updateOrCreatePlanMessage(data.plan); + } + } else if (data.type === "title_update") { + dispatch("core/editor").editPost({ title: data.title }); + } else if (data.type === "status") { + if (data.status === "complete") { + continue; + } + + // Update timeline + 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; + }); + } else if ( + data.type === "conversational" || + data.type === "conversational_stream" + ) { + // Remove article marker and clean content + const cleanContent = (data.content || "") + .replace(/~~~ARTICLE~+/g, "") + .replace(/~~~ARTICLE~~~[\r\n]*/g, "") + .trim(); + + // Skip if content is empty after cleaning + if ( + !cleanContent || + shouldSkipPlanningCompletion(cleanContent) + ) { + continue; + } + + const streamTarget = + streamTargetRef.current || + resolveStreamTarget(cleanContent); + + if (!streamTarget) { + continue; + } + + streamTargetRef.current = streamTarget; + + if (streamTarget === "timeline") { + updateOrCreateTimelineEntry(cleanContent); + } else { + // This is actual conversational content - add as chat bubble + if (data.type === "conversational") { + setMessages((prev) => [ + ...prev, + { role: "assistant", content: cleanContent }, + ]); + } else { + setMessages((prev) => { + const newMessages = [...prev]; + const lastIdx = newMessages.length - 1; + if ( + newMessages[lastIdx] && + newMessages[lastIdx].role === "assistant" + ) { + newMessages[lastIdx] = { + ...newMessages[lastIdx], + content: cleanContent, + }; + } else { + newMessages.push({ + role: "assistant", + content: cleanContent, + }); + } + return newMessages; + }); + } + } + } else if (data.type === "block") { + // Insert blocks (same as above) + const { insertBlocks } = dispatch("core/block-editor"); + let newBlock; + + if (data.block.blockName === "core/paragraph") { + const content = + data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ""; + newBlock = wp.blocks.createBlock("core/paragraph", { + content: content, + }); + } else if (data.block.blockName === "core/heading") { + const level = data.block.attrs?.level || 2; + const content = + data.block.innerHTML?.match( + /(.*?)<\/h[1-6]>/, + )?.[1] || ""; + newBlock = wp.blocks.createBlock("core/heading", { + level: level, + content: content, + }); + } else if (data.block.blockName === "core/list") { + const listItems = (data.block.innerBlocks || []).map( + (item) => { + const content = + item.innerHTML?.match(/

  • (.*?)<\/li>/)?.[1] || ""; + return wp.blocks.createBlock("core/list-item", { + content: content, + }); + }, + ); + newBlock = wp.blocks.createBlock( + "core/list", + { + ...(data.block.attrs || {}), + ordered: data.block.attrs?.ordered || false, + }, + listItems, + ); + } else if (data.block.blockName === "core/quote") { + const content = + data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ""; + newBlock = wp.blocks.createBlock("core/quote", { + value: content, + }); + } else if (data.block.blockName === "core/image") { + newBlock = wp.blocks.createBlock( + "core/image", + data.block.attrs || {}, + ); + } + + if (newBlock) { + insertBlocks(newBlock); + } + } else if (data.type === "complete") { + applyProviderMetadata(data); + clearTimeout(timeout); + setCost({ ...cost, session: cost.session + data.totalCost }); + + // Update timeline to complete + setMessages((prev) => { + const newMessages = [...prev]; + const lastTimelineIndex = + findLastActiveTimelineIndex(newMessages); + if (lastTimelineIndex !== -1) { + newMessages[lastTimelineIndex] = { + ...newMessages[lastTimelineIndex], + status: "complete", + message: + submissionMode === "planning" + ? "Outline ready." + : "Article generated successfully!", + }; + } + return newMessages; + }); + setIsLoading(false); + } else if (data.type === "error") { + clearTimeout(timeout); + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: formatAiErrorMessage( + data.message || + "An error occurred during article generation", + "Failed to generate plan", + ), + canRetry: true, + retryType: "generation", + }, + ]); + setIsLoading(false); + } + } catch (parseError) { + wpawLog.error( + "Failed to parse streaming data:", + line, + parseError, + ); + } + } + } + // Clear timeout when streaming completes normally + clearTimeout(timeout); + } + } catch (error) { + if (timeout) { + clearTimeout(timeout); + } + if (isAbortError(error)) { + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "stopped", + message: "Generation stopped.", + timestamp: new Date(), + }, + ]); + } else { + wpawLog.error("Article generation error:", error); + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: formatAiErrorMessage( + error, + "Failed to generate article", + ), + canRetry: true, + retryType: "generation", + }, + ]); + } + setIsLoading(false); + } finally { + finishAgentOperation(submissionOperationType); + } + }; + + // Render clarification quiz UI. + const renderClarification = () => { + if (!inClarification || questions.length === 0) { + return null; + } + + const currentQuestion = questions[currentQuestionIndex]; + const currentAnswer = answers[currentQuestion.id] || ""; + + // Helper to render single choice options + const renderSingleChoice = () => { + const customInputKey = `${currentQuestion.id}_custom`; + const customValue = answers[customInputKey] || ""; + const isCustomSelected = currentAnswer === "__custom__"; + + return wp.element.createElement( + "div", + { className: "wpaw-answer-options" }, + currentQuestion.options.map((option, idx) => { + const isSelected = currentAnswer === option.value; + return wp.element.createElement( + "label", + { key: idx }, + wp.element.createElement("input", { + type: "radio", + name: currentQuestion.id, + checked: isSelected, + onChange: () => { + const newAnswers = { ...answers }; + newAnswers[currentQuestion.id] = option.value; + setAnswers(newAnswers); + }, + }), + wp.element.createElement("span", null, option.value), + ); + }), + // Add custom text input option + wp.element.createElement( + "div", + { className: "wpaw-custom-answer-wrapper", key: "custom" }, + wp.element.createElement( + "label", + null, + wp.element.createElement("input", { + type: "radio", + name: currentQuestion.id, + checked: isCustomSelected, + onChange: () => { + const newAnswers = { ...answers }; + newAnswers[currentQuestion.id] = "__custom__"; + setAnswers(newAnswers); + }, + }), + wp.element.createElement("span", null, "Other (specify):"), + ), + isCustomSelected && + wp.element.createElement("input", { + type: "text", + className: "wpaw-custom-text-input", + placeholder: "Type your answer here...", + value: customValue, + onChange: (e) => { + const newAnswers = { ...answers }; + newAnswers[customInputKey] = e.target.value; + setAnswers(newAnswers); + }, + autoFocus: true, + }), + ), + ); + }; + + // Helper to render multiple choice options + const renderMultipleChoice = () => { + const selectedValues = currentAnswer ? currentAnswer.split(", ") : []; + + return wp.element.createElement( + "div", + { className: "wpaw-answer-options" }, + currentQuestion.options.map((option, idx) => { + const isSelected = selectedValues.includes(option.value); + return wp.element.createElement( + "label", + { key: idx }, + wp.element.createElement("input", { + type: "checkbox", + checked: isSelected, + onChange: () => { + const newAnswers = { ...answers }; + let newSelected = isSelected + ? selectedValues.filter((v) => v !== option.value) + : [...selectedValues, option.value]; + newAnswers[currentQuestion.id] = newSelected.join(", "); + setAnswers(newAnswers); + }, + }), + wp.element.createElement("span", null, option.value), + ); + }), + ); + }; + + // Helper to render open text textarea + const renderOpenText = () => { + return wp.element.createElement( + "div", + { className: "wpaw-answer-options" }, + wp.element.createElement(TextareaControl, { + placeholder: + currentQuestion.placeholder || "Type your answer here...", + value: currentAnswer, + onChange: (value) => { + const newAnswers = { ...answers }; + newAnswers[currentQuestion.id] = value; + setAnswers(newAnswers); + }, + rows: 4, + maxLength: currentQuestion.max_length || 500, + }), + ); + }; + + // Helper to render config form (consolidated config page) + const renderConfigForm = () => { + // Initialize with defaults if no answer exists + let configData = {}; + if (currentAnswer) { + try { + configData = JSON.parse(currentAnswer); + } catch (e) { + configData = {}; + } + } + + // Set defaults from field definitions if not already set + const fields = currentQuestion.fields || []; + fields.forEach((field) => { + if ( + configData[field.id] === undefined && + field.default !== undefined + ) { + configData[field.id] = field.default; + } + }); + + // Initialize answer with defaults on first render + if (!currentAnswer && Object.keys(configData).length > 0) { + const newAnswers = { ...answers }; + newAnswers[currentQuestion.id] = JSON.stringify(configData); + setAnswers(newAnswers); + } + + return wp.element.createElement( + "div", + { className: "wpaw-config-form" }, + fields.map((field, idx) => { + const fieldValue = + configData[field.id] !== undefined + ? configData[field.id] + : field.default; + const isConditional = + field.conditional && !configData[field.conditional]; + + if (isConditional) { + return null; + } + + return wp.element.createElement( + "div", + { key: idx, className: "wpaw-config-field" }, + 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, + ), + field.description && + wp.element.createElement( + "span", + { className: "wpaw-config-description" }, + field.description, + ), + ), + wp.element.createElement( + "label", + { className: "wpaw-config-toggle" }, + wp.element.createElement("input", { + type: "checkbox", + checked: fieldValue || false, + onChange: (e) => { + const newConfig = { ...configData }; + newConfig[field.id] = e.target.checked; + const newAnswers = { ...answers }; + newAnswers[currentQuestion.id] = + JSON.stringify(newConfig); + setAnswers(newAnswers); + }, + }), + 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); + }, + }), + ), + ); + }), + ); + }; + + // Render appropriate input type based on question type + let answerInput; + switch (currentQuestion.type) { + case "single_choice": + answerInput = renderSingleChoice(); + break; + case "multiple_choice": + answerInput = renderMultipleChoice(); + break; + case "open_text": + answerInput = renderOpenText(); + break; + case "config_form": + answerInput = renderConfigForm(); + break; + default: + answerInput = renderSingleChoice(); + } + + return wp.element.createElement( + "div", + { className: "wpaw-clarification-quiz dark-theme" }, + wp.element.createElement( + "div", + { className: "wpaw-quiz-header" }, + wp.element.createElement("h3", null, " Clarification Questions"), + wp.element.createElement( + "div", + { className: "wpaw-progress-bar" }, + wp.element.createElement("div", { + className: "wpaw-progress-fill", + style: { + width: + ((currentQuestionIndex + 1) / questions.length) * 100 + "%", + }, + }), + ), + wp.element.createElement( + "span", + null, + `${currentQuestionIndex + 1} of ${questions.length}`, + ), + ), + wp.element.createElement( + "div", + { className: "wpaw-question-card" }, + 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); + } + }, + 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", + ), + ), + ), + ); + }; + + const startNewConversation = async () => { + if (isLoading || isSessionActionLoading) { + return; + } + try { + setIsSessionActionLoading(true); + const response = await fetch( + wpAgenticWriter.apiUrl + "/conversations", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + body: JSON.stringify({ post_id: postId || 0 }), + }, + ); + if (!response.ok) { + throw new Error("Failed to create a new conversation"); + } + const data = await response.json(); + // Fully reset state for clean slate + isHydratingSessionRef.current = true; + if (data?.session_id) { + setCurrentSessionId(data.session_id); + } + lastPersistedMessagesRef.current = JSON.stringify([]); + setMessages([]); + currentPlanRef.current = null; + setAgentMode("chat"); + setShowWelcome(false); + setFocusKeywordSuggestions([]); + setSelectedFocusKeyword(""); + setProviderInfo(null); + setMemantoRestore({ + restored: false, + summary: "", + memories: [], + preferences: [], + systemMessage: "", + }); + await loadPostSessions(); + setTimeout(() => { + isHydratingSessionRef.current = false; + // Focus input + if (inputRef.current) { + inputRef.current.focus(); + } + }, 50); + } catch (error) { + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: "Error: Failed to start a new conversation.", + }, + ]); + } finally { + setIsSessionActionLoading(false); + } + }; + + const deleteConversationSession = async (sessionId) => { + if (!sessionId || isSessionActionLoading) { + return; + } + if (!window.confirm("Delete this session permanently?")) { + return; + } + try { + setIsSessionActionLoading(true); + const response = await fetch( + `${wpAgenticWriter.apiUrl}/conversations/${sessionId}`, + { + method: "DELETE", + headers: { + "X-WP-Nonce": wpAgenticWriter.nonce, + }, + }, + ); + if (!response.ok) { + throw new Error("Failed to delete session"); + } + const sessions = await loadPostSessions(); + if (currentSessionId === sessionId) { + const replacement = sessions[0]?.session_id || ""; + setCurrentSessionId(replacement); + setMessages( + Array.isArray(sessions[0]?.messages) ? sessions[0].messages : [], + ); + } + } catch (error) { + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: "Error: Failed to delete session.", + }, + ]); + } finally { + setIsSessionActionLoading(false); + } + }; + + const getSessionDisplayTitle = (session, index) => { + if (session?.title && session.title.trim()) { + return session.title.trim(); + } + const firstUser = Array.isArray(session?.messages) + ? session.messages.find( + (m) => + m?.role === "user" && + typeof m?.content === "string" && + m.content.trim(), + ) + : null; + if (firstUser?.content) { + return firstUser.content.trim().slice(0, 56); + } + const updatedRaw = session?.updated_at || session?.last_activity || ""; + if (updatedRaw) { + const d = new Date(updatedRaw); + if (!Number.isNaN(d.getTime())) { + return `Session ${index + 1} - ${d.toLocaleDateString()}`; + } + } + return `Session ${index + 1}`; + }; + const getSessionContinuityLabel = (session) => { + const status = String(session?.status || "active").toLowerCase(); + if (status === "completed") { + return "Continuable"; + } + if (status === "archived") { + return "Archived"; + } + return "Active"; + }; + const getSessionDebugMeta = (session) => { + const id = Number(session?.id || 0); + const sid = String(session?.session_id || "-"); + const pid = Number(session?.post_id || 0); + const sessionStatus = String(session?.status || "active"); + const postStatus = String(session?.post_status || "").toLowerCase(); + const statusLabel = pid === 0 ? "unassigned" : postStatus || "unknown"; + return `id: ${id || "-"} | sid: ${sid} | post_id: ${pid} | post: ${statusLabel} | session: ${sessionStatus}`; + }; + + // Render Welcome Screen (chatty, friendly) + const renderWelcomeScreen = () => { + const recentSession = + availableSessions.length > 0 ? availableSessions[0] : null; + + 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" }, + "Agentic Writer", + ), + wp.element.createElement( + "p", + { className: "wpaw-welcome-subtitle" }, + "What are we writing today?", + ), + // Show single "Continue last conversation" button if available + recentSession && + wp.element.createElement( + "button", + { + className: "wpaw-welcome-pill", + style: { width: "100%", marginBottom: "12px" }, + disabled: isSessionActionLoading, + onClick: () => openSessionById(recentSession.session_id || ""), + }, + `↩ Continue: ${getSessionDisplayTitle(recentSession, 0)}`, + ), + // Show older sessions in collapsible + availableSessions.length > 1 && + wp.element.createElement( + "details", + { + style: { marginBottom: "12px", width: "100%" }, + }, + wp.element.createElement( + "summary", + { + style: { + fontSize: "12px", + color: "#8b95a5", + cursor: "pointer", + marginBottom: "8px", + }, + }, + `${availableSessions.length - 1} more session${availableSessions.length > 2 ? "s" : ""}`, + ), + wp.element.createElement( + "div", + { className: "wpaw-session-list" }, + ...availableSessions.slice(1).map((session, idx) => + wp.element.createElement( + "div", + { + key: session.session_id || idx, + className: "wpaw-welcome-pill", + style: { + width: "100%", + marginBottom: "6px", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "8px", + }, + }, + wp.element.createElement( + "button", + { + type: "button", + disabled: isSessionActionLoading, + className: "wpaw-session-open-btn", + style: { + flex: 1, + background: "transparent", + border: "none", + color: "inherit", + textAlign: "left", + cursor: isSessionActionLoading ? "wait" : "pointer", + }, + onClick: () => + openSessionById(session.session_id || ""), + }, + wp.element.createElement( + "div", + null, + getSessionDisplayTitle(session, idx + 1), + ), + wp.element.createElement( + "div", + { style: { opacity: 0.7, fontSize: "11px" } }, + `${Number(session?.message_count ?? (Array.isArray(session?.messages) ? session.messages.length : 0))} msgs · ${getSessionContinuityLabel(session)}`, + ), + ), + wp.element.createElement( + "button", + { + type: "button", + title: "Delete session", + disabled: isSessionActionLoading, + style: { + background: "transparent", + border: "1px solid rgba(255,255,255,0.25)", + color: "inherit", + borderRadius: "6px", + padding: "2px 6px", + cursor: "pointer", + }, + onClick: () => + deleteConversationSession(session.session_id), + }, + "×", + ), + ), + ), + ), + ), + // Focus keyword input + wp.element.createElement("input", { + type: "text", + className: "wpaw-welcome-input", + placeholder: "Focus keyword (optional)", + value: welcomeKeywordInput, + onChange: (e) => setWelcomeKeywordInput(e.target.value), + onKeyDown: (e) => { + if (e.key === "Enter") { + handleWelcomeStart(); + } + }, + }), + // Mode pills + wp.element.createElement( + "div", + { className: "wpaw-welcome-pills" }, + wp.element.createElement( + "button", + { + className: + "wpaw-welcome-pill" + + (welcomeStartMode === "chat" ? " active" : ""), + onClick: () => setWelcomeStartMode("chat"), + }, + "Explore First", + ), + wp.element.createElement( + "button", + { + className: + "wpaw-welcome-pill" + + (welcomeStartMode === "planning" ? " active" : ""), + onClick: () => setWelcomeStartMode("planning"), + }, + "Start Outline", + ), + ), + // Start button + wp.element.createElement( + Button, + { + isPrimary: true, + onClick: handleWelcomeStart, + className: "wpaw-welcome-start-btn", + }, + "Start Writing", + ), + ), + ); + }; + + // Render Writing mode empty state + 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", { + className: "wpaw-empty-state-icon", + dangerouslySetInnerHTML: { + __html: + '', + }, + }), + wp.element.createElement("h3", null, "Create an Outline First"), + wp.element.createElement( + "p", + null, + "Before writing, the agent needs an outline to structure the article and keep costs predictable. Ask for the article topic and the agent will create one first.", + ), + wp.element.createElement( + Button, + { + isPrimary: true, + onClick: () => setAgentMode("planning"), + className: "wpaw-empty-state-button", + }, + wp.element.createElement( + "div", + { + style: { + display: "inline-flex", + alignItems: "center", + gap: "8px", + }, + }, + wp.element.createElement( + "svg", + { + xmlns: "http://www.w3.org/2000/svg", + width: "18", + height: "18", + viewBox: "0 0 24 24", + }, + wp.element.createElement("path", { + fill: "none", + stroke: "currentColor", + strokeLinecap: "round", + strokeLinejoin: "round", + strokeWidth: "1", + d: "M16 5H3m13 7H3m8 7H3m12-1l2 2l4-4", + }), + ), + "Create Outline", + ), + ), + wp.element.createElement( + "p", + { + className: "wpaw-empty-state-hint", + style: { marginTop: "16px", fontSize: "13px", color: "#a7aaad" }, + }, + "Tip: tell the agent what you want to publish, then approve or adjust the outline before writing.", + ), + ), + ); + }; + + // 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", + }, + "↓", + ), + ), + // 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(); + } + }, + }), + ), + // 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)}`, + ), + providerInfo && + wp.element.createElement( + "span", + { + className: "wpaw-provider-info", + title: + providerInfo.warnings.length > 0 + ? providerInfo.warnings.join("; ") + : "AI provider used", + }, + providerInfo.fallbackUsed + ? " ⚠️ " + (providerInfo.provider || "fallback") + : " 📡 " + (providerInfo.provider || "AI"), + ), + 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)}`, + providerInfo && + wp.element.createElement( + "span", + { + className: "wpaw-provider-badge", + title: + providerInfo.warnings.length > 0 + ? providerInfo.warnings.join("; ") + : "AI provider", + }, + providerInfo.fallbackUsed ? "⚠" : "📡", + ), + ), + wp.element.createElement( + "button", + { + className: "wpaw-fk-expand", + onClick: () => setIsTextareaExpanded(true), + title: "Expand", + }, + wp.element.createElement( + "svg", + { + xmlns: "http://www.w3.org/2000/svg", + width: "16", + height: "16", + viewBox: "0 0 24 24", + }, + wp.element.createElement("path", { + fill: "none", + stroke: "currentColor", + strokeLinecap: "round", + strokeLinejoin: "round", + strokeWidth: "1.5", + d: "m7 15l5 5l5-5M7 9l5-5l5 5", + }), + ), + ), + ); + }; + + const renderAgentWorkspaceCard = () => { + const planSummary = getPlanRuntimeSummary(); + const messageCount = messages.filter( + (m) => m.role === "user" || m.role === "assistant", + ).length; + const providerLabel = providerInfo?.provider + ? `${providerInfo.provider}${providerInfo.model ? ` / ${providerInfo.model}` : ""}` + : "Provider not used yet"; + const activeWorkspaceStatus = + activeOperation.status && activeOperation.status !== "idle" + ? activeOperation.status + : writingState.status || "idle"; + const writingLabel = + activeOperation.status && activeOperation.status !== "idle" + ? `${activeOperation.status === "stopping" ? "Stopping" : "Running"} ${activeOperation.label || activeOperation.type}` + : isWritingStateLoading + ? "Loading..." + : String(writingState.status || "idle").replace(/_/g, " "); + const canResume = + !isLoading && + currentPlanRef.current && + ["in_progress", "paused", "failed"].includes(writingState.status); + const selectedPreview = workspaceSnapshot.selectedBlockPreview + ? `: ${workspaceSnapshot.selectedBlockPreview}` + : ""; + + return wp.element.createElement( + "div", + { + className: `wpaw-agent-workspace-card${isWorkspaceCollapsed ? " is-collapsed" : ""}`, + }, + wp.element.createElement( + "div", + { className: "wpaw-agent-workspace-header" }, + wp.element.createElement( + "div", + { className: "wpaw-agent-workspace-heading" }, + wp.element.createElement( + "div", + { className: "wpaw-agent-workspace-kicker" }, + "Agent Workspace", + ), + wp.element.createElement( + "div", + { className: "wpaw-agent-workspace-title" }, + workspaceSnapshot.title || "Untitled draft", + ), + ), + wp.element.createElement( + "div", + { className: "wpaw-agent-workspace-actions" }, + wp.element.createElement( + "span", + { + className: `wpaw-agent-workspace-status status-${activeWorkspaceStatus}`, + }, + writingLabel, + ), + wp.element.createElement( + "button", + { + type: "button", + className: "wpaw-agent-workspace-toggle", + onClick: toggleAgentWorkspace, + "aria-expanded": !isWorkspaceCollapsed, + title: isWorkspaceCollapsed + ? "Show Agent Workspace" + : "Hide Agent Workspace", + }, + isWorkspaceCollapsed ? "Show" : "Hide", + ), + ), + ), + !isWorkspaceCollapsed && + wp.element.createElement( + "div", + { className: "wpaw-agent-context-grid" }, + wp.element.createElement( + "div", + { className: "wpaw-agent-context-item" }, + wp.element.createElement("span", null, "Post blocks"), + wp.element.createElement( + "strong", + null, + String(workspaceSnapshot.blockCount || 0), + ), + ), + wp.element.createElement( + "div", + { className: "wpaw-agent-context-item" }, + wp.element.createElement("span", null, "Outline"), + wp.element.createElement("strong", null, planSummary.label), + ), + wp.element.createElement( + "div", + { className: "wpaw-agent-context-item" }, + wp.element.createElement("span", null, "Selected"), + wp.element.createElement( + "strong", + null, + `${workspaceSnapshot.selectedBlockLabel}${selectedPreview}`, + ), + ), + wp.element.createElement( + "div", + { className: "wpaw-agent-context-item" }, + wp.element.createElement("span", null, "Focus keyword"), + wp.element.createElement("input", { + type: "text", + className: "wpaw-agent-keyword-input", + placeholder: "Optional", + value: selectedFocusKeyword || "", + onChange: (e) => { + const value = e.target.value; + setSelectedFocusKeyword(value); + if (configSaveTimeoutRef.current) { + clearTimeout(configSaveTimeoutRef.current); + } + configSaveTimeoutRef.current = setTimeout(() => { + handleFocusKeywordChange(value); + }, 500); + }, + onBlur: (e) => { + if (e.target.value !== postConfig.focus_keyword) { + handleFocusKeywordChange(e.target.value); + } + }, + disabled: isLoading, + }), + ), + wp.element.createElement( + "div", + { className: "wpaw-agent-context-item" }, + wp.element.createElement("span", null, "Conversation"), + wp.element.createElement( + "strong", + null, + `${messageCount} message${messageCount === 1 ? "" : "s"}`, + ), + ), + wp.element.createElement( + "div", + { className: "wpaw-agent-context-item" }, + wp.element.createElement("span", null, "Provider"), + wp.element.createElement("strong", null, providerLabel), + ), + ), + !isWorkspaceCollapsed && + canResume && + wp.element.createElement( + "div", + { className: "wpaw-agent-resume-card" }, + wp.element.createElement( + "div", + null, + wp.element.createElement( + "strong", + null, + writingState.status === "failed" + ? "Writing can be retried" + : "Writing can resume", + ), + wp.element.createElement( + "span", + null, + `Last saved section: ${writingState.current_section_index || 0}`, + ), + ), + wp.element.createElement( + Button, + { + isPrimary: true, + isSmall: true, + onClick: () => + executePlanFromCard({ retry: true, skipConfirm: true }), + }, + writingState.status === "failed" ? "Retry" : "Resume", + ), + ), + ); + }; + + // Keep old function name for backward compatibility + const renderContextIndicator = renderAgentWorkspaceCard; + + // Render contextual action card + const renderContextualAction = (intent) => { + if (!intent || intent === "continue_chat") return null; + + const actions = { + create_outline: { + icon: "📝", + title: "Ready to create an outline?", + description: + "I'll generate a structured outline based on our conversation.", + button: "Create Outline Now", + onClick: async () => { + // Switch to planning mode + setAgentMode("planning"); + + // 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 + let requestDetectedLanguage = detectedLanguage; + const contextualOperationController = beginAgentOperation( + "planning", + "outline generation", + ); + try { + wpawLog.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, + sessionId: currentSessionId, + mode: "generation", + postConfig: postConfig, + chatHistory: buildChatHistoryPayload(), + }), + signal: contextualOperationController.signal, + }, + ); + + wpawLog.log( + "[WPAW] Clarity response status:", + clarityResponse.status, + ); + + if (!clarityResponse.ok) { + const errorText = await clarityResponse.text(); + wpawLog.error("[WPAW] Clarity check failed:", errorText); + throw new Error("Clarity check failed: " + errorText); + } + + const clarityData = await clarityResponse.json(); + applyProviderMetadata(clarityData); + const clarityResult = clarityData.result; + wpawLog.log("[WPAW] Clarity result:", clarityResult); + + if (clarityResult.detected_language) { + requestDetectedLanguage = clarityResult.detected_language; + setDetectedLanguage(clarityResult.detected_language); + } + + // MANDATORY: Always show quiz if questions exist + if ( + clarityResult.questions && + clarityResult.questions.length > 0 + ) { + wpawLog.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; + }); + finishAgentOperation("planning"); + return; // Stop here - quiz must be completed first + } else { + wpawLog.warn( + "[WPAW] No questions returned from clarity check!", + ); + } + } catch (clarityError) { + wpawLog.error("[WPAW] Clarity check error:", clarityError); + if ( + isAbortError(clarityError) || + stopExecutionRef.current || + contextualOperationController.signal.aborted + ) { + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "stopped", + message: "Outline generation stopped by user.", + timestamp: new Date(), + }, + ]); + } else { + // 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); + finishAgentOperation("planning"); + 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, + sessionId: currentSessionId, + answers: [], + autoExecute: false, + stream: true, + articleLength: postConfig.article_length, + detectedLanguage: requestDetectedLanguage, + postConfig: postConfig, + chatHistory: buildChatHistoryPayload(), + }), + signal: contextualOperationController.signal, + }, + ); + + if (!response.ok) { + const error = await response.json(); + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: formatAiErrorMessage( + error, + "Failed to generate outline", + ), + canRetry: true, + retryType: "generation", + }, + ]); + setIsLoading(false); + finishAgentOperation("planning"); + return; + } + + // Handle streaming response + streamTargetRef.current = null; + const reader = registerActiveReader(response.body.getReader()); + const decoder = new TextDecoder(); + + while (true) { + if ( + stopExecutionRef.current || + contextualOperationController.signal.aborted + ) { + throw new DOMException( + "Outline generation stopped", + "AbortError", + ); + } + 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, { + suggestKeywords: true, + }); + } + } 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) { + wpawLog.error( + "Failed to parse streaming data:", + parseError, + ); + } + } + } + } + if ( + stopExecutionRef.current || + contextualOperationController.signal.aborted + ) { + throw new DOMException( + "Outline generation stopped", + "AbortError", + ); + } + + setIsLoading(false); + finishAgentOperation("planning"); + } catch (error) { + if ( + isAbortError(error) || + stopExecutionRef.current || + contextualOperationController.signal.aborted + ) { + setMessages((prev) => [ + ...deactivateActiveTimelineEntries(prev), + { + role: "system", + type: "timeline", + status: "stopped", + message: "Outline generation stopped by user.", + timestamp: new Date(), + }, + ]); + } else { + setMessages((prev) => [ + ...prev, + { + role: "system", + type: "error", + content: formatAiErrorMessage( + error, + "Failed to generate outline", + ), + canRetry: true, + retryType: "generation", + }, + ]); + } + setIsLoading(false); + finishAgentOperation("planning"); + } + }, + }, + }; + + 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 = () => { + const normalizeMessageContent = (content) => { + if (content === null || content === undefined) { + return ""; + } + if (typeof content === "string" || typeof content === "number") { + return String(content); + } + return JSON.stringify(content); + }; + const escapeHtml = (value) => { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + }; + const inlineMarkdownToHtml = (text) => { + let html = escapeHtml(text); + html = html.replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + (match, label, url) => + `${label}`, + ); + html = html.replace( + /`([^`]+)`/g, + (match, code) => `${escapeHtml(code)}`, + ); + html = html.replace(/\*\*([^*]+)\*\*/g, "$1"); + html = html.replace(/__([^_]+)__/g, "$1"); + html = html.replace(/\*([^*]+)\*/g, "$1"); + html = html.replace(/_([^_]+)_/g, "$1"); + return html; + }; + const markdownToHtml = (markdown) => { + const raw = normalizeMessageContent(markdown); + if (!raw) { + return ""; + } + + if (window.markdownit && window.DOMPurify) { + if (!markdownRendererRef.current) { + const renderer = window.markdownit({ + html: false, + linkify: true, + breaks: false, + }); + if (window.markdownitTaskLists) { + renderer.use(window.markdownitTaskLists, { + enabled: true, + label: true, + labelAfter: true, + }); + } + const defaultLinkOpen = + renderer.renderer.rules.link_open || + function (tokens, idx, options, env, self) { + return self.renderToken(tokens, idx, options); + }; + renderer.renderer.rules.link_open = function ( + tokens, + idx, + options, + env, + self, + ) { + const token = tokens[idx]; + const targetIndex = token.attrIndex("target"); + if (targetIndex < 0) { + token.attrPush(["target", "_blank"]); + } else { + token.attrs[targetIndex][1] = "_blank"; + } + const relIndex = token.attrIndex("rel"); + if (relIndex < 0) { + token.attrPush(["rel", "noopener noreferrer"]); + } else { + token.attrs[relIndex][1] = "noopener noreferrer"; + } + return defaultLinkOpen(tokens, idx, options, env, self); + }; + markdownRendererRef.current = renderer; + } + + const rendered = markdownRendererRef.current.render(raw); + return window.DOMPurify.sanitize(rendered, { + USE_PROFILES: { html: true }, + ADD_TAGS: ["input", "label"], + ADD_ATTR: ["type", "checked", "disabled", "class"], + }); + } + + const codeBlocks = []; + let text = raw.replace( + /```(\w+)?\n([\s\S]*?)```/g, + (match, lang, code) => { + const safeLang = lang + ? ` class="language-${escapeHtml(lang)}"` + : ""; + const index = codeBlocks.length; + codeBlocks.push( + `

    ${escapeHtml(code)}
    `, + ); + return `@@CODEBLOCK${index}@@`; + }, + ); + + const lines = text.split(/\r?\n/); + let html = ""; + let paragraph = []; + let list = null; + let detailBreak = false; + let lastLineWasListItem = false; + + const flushParagraph = () => { + if (paragraph.length) { + html += `

    ${inlineMarkdownToHtml(paragraph.join(" "))}

    `; + paragraph = []; + } + }; + const flushList = () => { + if (list) { + const items = list.items + .map((item) => { + const details = + item.details && item.details.length > 0 + ? item.details + .map( + (detail) => `

    ${inlineMarkdownToHtml(detail)}

    `, + ) + .join("") + : ""; + const children = + item.children && item.children.length > 0 + ? `` + : ""; + return `
  • ${inlineMarkdownToHtml(item.content)}${details}${children}
  • `; + }) + .join(""); + html += `<${list.type}>${items}`; + list = null; + } + }; + const addListItem = (targetList, value) => { + targetList.items.push({ content: value, children: [], details: [] }); + lastLineWasListItem = true; + }; + const addDetailToLastItem = (targetList, value, newParagraph) => { + const lastItem = targetList.items[targetList.items.length - 1]; + if (!lastItem) { + return; + } + if (newParagraph || lastItem.details.length === 0) { + lastItem.details.push(value); + } else { + lastItem.details[lastItem.details.length - 1] += ` ${value}`; + } + lastLineWasListItem = false; + }; + + const getListType = (value) => { + if (/^\d+\.\s+/.test(value)) { + return "ol"; + } + if (/^[-*+]\s+/.test(value)) { + return "ul"; + } + return null; + }; + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (trimmed === "") { + let nextIndex = i + 1; + while (nextIndex < lines.length && lines[nextIndex].trim() === "") { + nextIndex += 1; + } + const nextLine = + nextIndex < lines.length ? lines[nextIndex].trim() : ""; + const nextType = getListType(nextLine); + if (list && nextType && nextType === list.type) { + continue; + } + if ( + list && + list.type === "ol" && + nextLine && + !nextType && + !nextLine.startsWith("@@CODEBLOCK") && + !/^(#{1,6})\s+/.test(nextLine) + ) { + detailBreak = true; + lastLineWasListItem = false; + continue; + } + flushList(); + flushParagraph(); + lastLineWasListItem = false; + continue; + } + + if (trimmed.startsWith("@@CODEBLOCK")) { + flushList(); + flushParagraph(); + html += trimmed; + lastLineWasListItem = false; + continue; + } + + const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/); + if (headingMatch) { + flushList(); + flushParagraph(); + const level = headingMatch[1].length; + html += `${inlineMarkdownToHtml(headingMatch[2])}`; + lastLineWasListItem = false; + continue; + } + + const unorderedMatch = trimmed.match(/^[-*+]\s+(.*)$/); + const orderedMatch = trimmed.match(/^\d+\.\s+(.*)$/); + if (unorderedMatch || orderedMatch) { + flushParagraph(); + detailBreak = false; + const type = orderedMatch ? "ol" : "ul"; + let value = + (orderedMatch ? orderedMatch[1] : unorderedMatch[1]) || ""; + if (orderedMatch) { + value = value.replace(/^\d+\.\s+/, ""); + } + if ( + !orderedMatch && + list && + list.type === "ol" && + list.items.length > 0 + ) { + list.items[list.items.length - 1].children.push(value); + continue; + } + if (!list || list.type !== type) { + flushList(); + list = { type, items: [] }; + } + addListItem(list, value); + continue; + } + + if ( + list && + list.type === "ol" && + (lastLineWasListItem || detailBreak) + ) { + addDetailToLastItem(list, trimmed, detailBreak); + detailBreak = false; + continue; + } + + if (list) { + flushList(); + } + paragraph.push(trimmed); + lastLineWasListItem = false; + } + + flushList(); + flushParagraph(); + + codeBlocks.forEach((block, index) => { + html = html.replace(`@@CODEBLOCK${index}@@`, block); + }); + + return html; + }; + const renderMessageContent = (content, allowMarkdown) => { + if (!allowMarkdown) { + return normalizeMessageContent(content); + } + return wp.element.createElement(RawHTML, null, markdownToHtml(content)); + }; + + const lastActiveTimelineIndex = findLastActiveTimelineIndex(messages); + const groups = []; + let currentAiGroup = null; + + messages.forEach((message, index) => { + if (message.role === "user") { + groups.push({ type: "user", message, key: `user-${index}` }); + currentAiGroup = null; + return; + } + + if (!currentAiGroup) { + currentAiGroup = { type: "ai", items: [], key: `ai-${index}` }; + groups.push(currentAiGroup); + } + + currentAiGroup.items.push({ message, index }); + }); + + return groups.map((group, groupIndex) => { + if (group.type === "user") { + return wp.element.createElement( + "div", + { + key: group.key, + className: "wpaw-message wpaw-message-user", + }, + wp.element.createElement( + "div", + { className: "wpaw-message-content" }, + renderMessageContent(group.message.content, false), + ), + ); + } + + const isLastGroup = groupIndex === groups.length - 1; + let streamingLabel = "Streaming..."; + for (let i = group.items.length - 1; i >= 0; i--) { + const item = group.items[i].message; + if (item.type === "timeline" && item.status) { + if (item.status === "checking") { + streamingLabel = "Analyzing..."; + } else if ( + item.status === "planning" || + item.status === "plan_complete" + ) { + streamingLabel = "Planning..."; + } else if ( + item.status === "writing" || + item.status === "writing_section" + ) { + streamingLabel = "Writing..."; + } else if (item.status === "refining") { + streamingLabel = "Refining..."; + } else { + streamingLabel = "Streaming..."; + } + break; + } + } + + return wp.element.createElement( + "div", + { + key: group.key, + className: "wpaw-ai-response", + }, + group.items.map((item, itemIndex) => { + const message = item.message; + const index = item.index; + const isLastItem = itemIndex === group.items.length - 1; + + if (message.type === "timeline") { + const statusClass = + message.status === "complete" + ? "complete" + : message.status === "inactive" + ? "inactive" + : "active"; + const showProcessing = isLoading && message.status === "refining"; + const elapsedTime = + message.status === "complete" && + message.timestamp && + message.completedAt + ? ( + (new Date(message.completedAt) - + new Date(message.timestamp)) / + 1000 + ).toFixed(1) + "s" + : null; + return wp.element.createElement( + "div", + { + key: `timeline-${index}`, + className: + "wpaw-ai-item wpaw-timeline-entry " + + statusClass + + (index === lastActiveTimelineIndex ? " is-current" : ""), + }, + wp.element.createElement("div", { + className: "wpaw-timeline-dot", + "aria-hidden": "true", + }), + wp.element.createElement( + "div", + { className: "wpaw-timeline-content" }, + wp.element.createElement( + "div", + { className: "wpaw-timeline-message" }, + normalizeMessageContent(message.message), + ), + message.status === "complete" && + wp.element.createElement( + "div", + { className: "wpaw-timeline-complete" }, + "✓ Complete", + elapsedTime && + wp.element.createElement( + "span", + { className: "wpaw-timeline-elapsed" }, + ` (${elapsedTime})`, + ), + ), + showProcessing && + wp.element.createElement( + "div", + { className: "wpaw-processing-indicator" }, + wp.element.createElement("span", { + className: "wpaw-dots-loader", + }), + wp.element.createElement( + "span", + null, + "Processing updates…", + ), + ), + !showProcessing && + isLoading && + isLastGroup && + isLastItem && + wp.element.createElement( + "div", + { + 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), + ), + ), + ), + ); + } + + if (message.type === "plan") { + const plan = ensurePlanTasks(message.plan); + const sections = Array.isArray(plan?.sections) + ? plan.sections + : []; + const getSectionSummary = (section) => { + if (section.description) { + return section.description; + } + if ( + Array.isArray(section.content) && + section.content.length > 0 + ) { + const firstItem = section.content.find( + (item) => item && item.content, + ); + return firstItem ? firstItem.content : ""; + } + return ""; + }; + const pendingCount = sections.filter( + (section) => section.status !== "done", + ).length; + const buttonLabel = pendingCount + ? `Write ${pendingCount} Pending` + : "Write Article"; + + // Build config summary + const configSummary = []; + 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}`); + } + if (postConfig.web_search) { + configSummary.push("🔍 Web Search: Enabled"); + } + if (postConfig.seo_enabled) { + const seoDetails = []; + if (postConfig.seo_focus_keyword) { + seoDetails.push(`Focus: "${postConfig.seo_focus_keyword}"`); + } + if (postConfig.seo_secondary_keywords) { + seoDetails.push( + `Secondary: "${postConfig.seo_secondary_keywords}"`, + ); + } + configSummary.push( + `📊 SEO: Enabled${seoDetails.length ? " (" + seoDetails.join(", ") + ")" : ""}`, + ); + } + + return wp.element.createElement( + "div", + { + key: `plan-${index}`, + className: "wpaw-ai-item wpaw-plan-card", + }, + wp.element.createElement( + "div", + { className: "wpaw-plan-title" }, + plan?.title || "Proposed Outline", + ), + wp.element.createElement( + "div", + { className: "wpaw-plan-config-summary" }, + configSummary.map((item, idx) => + wp.element.createElement( + "div", + { key: idx, className: "wpaw-config-summary-item" }, + item, + ), + ), + ), + sections.length > 0 && + wp.element.createElement( + "ol", + { className: "wpaw-plan-sections" }, + sections.map((section, sectionIndex) => + wp.element.createElement( + "li", + { + key: `plan-section-${sectionIndex}`, + className: `wpaw-plan-section ${section.status || "pending"}`, + }, + wp.element.createElement( + "div", + { className: "wpaw-plan-section-row" }, + wp.element.createElement("input", { + className: "wpaw-plan-section-check", + type: "checkbox", + checked: section.status === "done", + readOnly: true, + disabled: true, + }), + wp.element.createElement( + "div", + { className: "wpaw-plan-section-body" }, + wp.element.createElement( + "div", + { className: "wpaw-plan-section-title" }, + section.title || + section.heading || + `Section ${sectionIndex + 1}`, + ), + getSectionSummary(section) && + wp.element.createElement( + "div", + { className: "wpaw-plan-section-desc" }, + getSectionSummary(section), + ), + ), + wp.element.createElement( + "div", + { className: "wpaw-plan-section-status" }, + section.status === "done" + ? "Done" + : section.status === "in_progress" + ? "Writing" + : "Pending", + ), + ), + ), + ), + ), + !sections.length && + plan?.summary && + wp.element.createElement( + "div", + { className: "wpaw-plan-section-desc" }, + plan.summary, + ), + wp.element.createElement( + "div", + { className: "wpaw-plan-actions" }, + wp.element.createElement( + Button, + { + isPrimary: true, + onClick: executePlanFromCard, + disabled: isLoading, + }, + buttonLabel, + ), + ), + ); + } + + if (message.type === "edit_plan") { + const plan = message.plan || pendingEditPlan; + const isPlanActive = + Boolean(pendingEditPlan) && plan === pendingEditPlan; + const actions = normalizePlanActions(plan); + const allBlocks = select("core/block-editor").getBlocks(); + const existingIds = new Set( + allBlocks.map((block) => block.clientId), + ); + const previewActions = actions.filter((action) => { + if (action.action === "keep") { + return false; + } + if (action.blockId && !existingIds.has(action.blockId)) { + return false; + } + return true; + }); + const actionCount = previewActions.length; + const summary = + plan?.summary || `Proposed changes: ${actionCount}`; + const previewItems = previewActions.map((action, actionIndex) => + buildPlanPreviewItem(action, actionIndex), + ); + + return wp.element.createElement( + "div", + { + key: `plan-${index}`, + className: "wpaw-ai-item wpaw-edit-plan", + }, + wp.element.createElement( + "div", + { className: "wpaw-edit-plan-title" }, + "Proposed Changes", + ), + wp.element.createElement( + "div", + { className: "wpaw-edit-plan-summary" }, + summary, + ), + previewItems.length > 0 && + wp.element.createElement( + "div", + { className: "wpaw-edit-plan-preview-label" }, + "Apply preview", + ), + previewItems.length > 0 && + wp.element.createElement( + "ol", + { className: "wpaw-edit-plan-list" }, + previewItems.map((item, itemIndex) => + wp.element.createElement( + "li", + { + key: `plan-action-${itemIndex}`, + className: "wpaw-edit-plan-item", + }, + wp.element.createElement( + "div", + { className: "wpaw-edit-plan-item-title" }, + item.title, + ), + item.target && + wp.element.createElement( + "button", + { + type: "button", + className: "wpaw-edit-plan-item-target", + disabled: !isPlanActive, + onClick: () => { + if (!isPlanActive || !item.blockId) { + return; + } + dispatch("core/block-editor").selectBlock( + item.blockId, + ); + const targetNode = document.querySelector( + `[data-block="${item.blockId}"]`, + ); + if (targetNode) { + targetNode.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + }, + }, + `${item.targetLabel} ${item.target}`, + ), + item.before && + wp.element.createElement( + "div", + { className: "wpaw-edit-plan-item-before" }, + `Before ${item.before}`, + ), + item.after && + wp.element.createElement( + "div", + { className: "wpaw-edit-plan-item-after" }, + `Add ${item.after}`, + ), + ), + ), + ), + wp.element.createElement( + "div", + { className: "wpaw-edit-plan-actions" }, + wp.element.createElement( + Button, + { + isPrimary: true, + onClick: () => applyEditPlan(plan), + disabled: !plan || !isPlanActive, + }, + `Apply (${actionCount})`, + ), + wp.element.createElement( + Button, + { + isSecondary: true, + onClick: cancelEditPlan, + disabled: !isPlanActive, + }, + "Cancel", + ), + ), + ); + } + + if (message.type === "error") { + const handleRetry = () => { + if (message.retryType === "execute") { + retryLastExecute(); + return; + } + if (message.retryType === "refine") { + retryLastRefinement(); + return; + } + if (message.retryType === "chat") { + retryLastChat(); + return; + } + retryLastGeneration(); + }; + + // Support structured error objects { title, detail, actionUrl, actionLabel } + const errContent = message.content; + const isStructured = + errContent && + typeof errContent === "object" && + errContent.title; + + return wp.element.createElement( + "div", + { + key: `error-${index}`, + className: "wpaw-ai-item wpaw-message wpaw-message-error", + }, + isStructured + ? wp.element.createElement( + "div", + null, + wp.element.createElement( + "div", + { className: "wpaw-error-title" }, + "⚠ ", + errContent.title, + ), + errContent.detail && + wp.element.createElement( + "div", + { className: "wpaw-error-detail" }, + errContent.detail, + ), + errContent.actionUrl && + wp.element.createElement( + "a", + { + href: errContent.actionUrl, + target: "_blank", + rel: "noopener", + style: { + display: "inline-block", + marginTop: "8px", + fontSize: "12px", + color: "#fca5a5", + textDecoration: "underline", + }, + }, + errContent.actionLabel || "Open Settings", + ), + ) + : wp.element.createElement( + "div", + { className: "wpaw-message-content" }, + renderMessageContent(errContent, true), + ), + message.canRetry && + wp.element.createElement( + Button, + { + isSecondary: true, + onClick: handleRetry, + }, + "↻ Retry", + ), + ); + } + + return wp.element.createElement( + "div", + { + key: `response-${index}`, + className: "wpaw-ai-item wpaw-response", + }, + wp.element.createElement( + "div", + { className: "wpaw-response-content" }, + renderMessageContent(message.content, true), + ), + isLoading && + isLastGroup && + isLastItem && + wp.element.createElement( + "div", + { + 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, + { + isPrimary: true, + onClick: () => { + setExecutionStopped(false); + executePlanFromCard(); + }, + style: { marginRight: "8px" }, + }, + `Resume Writing (${message.pendingCount} pending)`, + ), + wp.element.createElement( + Button, + { + isSecondary: true, + onClick: () => { + setExecutionStopped(false); + setAgentMode("planning"); + }, + }, + "Review Outline", + ), + ), + ); + }), + ); + }); + }; + + // Render Config Tab + // Render Config Tab - Updated for Dark Theme + + const renderConfigTab = () => { + const isConfigDisabled = isLoading || isConfigLoading || isConfigSaving; + + return wp.element.createElement( + "div", + { className: "wpaw-tab-content wpaw-config-tab dark-theme" }, + // Back Header + wp.element.createElement( + "div", + { className: "wpaw-tab-header" }, + wp.element.createElement( + "button", + { + className: "wpaw-back-btn", + onClick: () => setActiveTab("chat"), + }, + "← Back", + ), + wp.element.createElement("h3", null, "CONFIGURATION"), + ), + + wp.element.createElement( + "div", + { className: "wpaw-config-section" }, + wp.element.createElement("label", null, "AGENT WORKSPACE"), + wp.element.createElement( + "p", + { className: "description" }, + "The command box now routes chat, outline, writing, refinement, and SEO requests automatically from the current editor context.", + ), + ), + 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( + "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, + onChange: (e) => + updatePostConfig("experience_level", e.target.value), + disabled: isConfigDisabled, + className: "wpaw-select", + }, + wp.element.createElement( + "option", + { value: "general" }, + "General audience", + ), + wp.element.createElement( + "option", + { value: "beginner" }, + "Beginner", + ), + wp.element.createElement( + "option", + { value: "intermediate" }, + "Intermediate", + ), + wp.element.createElement( + "option", + { value: "advanced" }, + "Advanced", + ), + ), + ), + wp.element.createElement( + "div", + { className: "wpaw-config-section" }, + wp.element.createElement(CheckboxControl, { + label: "Include image suggestions", + checked: Boolean(postConfig.include_images), + onChange: (value) => updatePostConfig("include_images", value), + disabled: isConfigDisabled, + }), + wp.element.createElement( + "p", + { className: "description" }, + "When enabled, the agent will add image placeholders.", + ), + ), + wp.element.createElement( + "div", + { className: "wpaw-config-section" }, + wp.element.createElement(CheckboxControl, { + label: "Enable web search for outlines", + checked: Boolean(postConfig.web_search), + onChange: (value) => updatePostConfig("web_search", value), + disabled: isConfigDisabled, + }), + wp.element.createElement( + "p", + { className: "description" }, + "Uses web search when planning outlines.", + ), + ), + + // SEO Section + wp.element.createElement( + "div", + { className: "wpaw-config-divider" }, + wp.element.createElement("span", null, "🔍 SEO OPTIMIZATION"), + ), + wp.element.createElement( + "div", + { className: "wpaw-config-section" }, + wp.element.createElement(CheckboxControl, { + label: "Enable SEO optimization", + checked: Boolean(postConfig.seo_enabled), + onChange: (value) => updatePostConfig("seo_enabled", value), + disabled: isConfigDisabled, + }), + wp.element.createElement( + "p", + { className: "description" }, + "Include SEO guidelines in AI prompts for keyword-optimized content.", + ), + ), + postConfig.seo_enabled && + wp.element.createElement( + "div", + { className: "wpaw-config-section" }, + wp.element.createElement(TextControl, { + label: "Focus Keyword", + value: postConfig.seo_focus_keyword, + onChange: (value) => updatePostConfig("seo_focus_keyword", value), + disabled: isConfigDisabled, + placeholder: "e.g., wordpress seo plugin", + }), + wp.element.createElement( + "p", + { className: "description" }, + "Primary keyword to optimize content for. Will be included in title, headings, and body.", + ), + ), + postConfig.seo_enabled && + wp.element.createElement( + "div", + { className: "wpaw-config-section" }, + wp.element.createElement(TextControl, { + label: "Secondary Keywords", + value: postConfig.seo_secondary_keywords, + onChange: (value) => + updatePostConfig("seo_secondary_keywords", value), + disabled: isConfigDisabled, + placeholder: "e.g., content optimization, search ranking", + }), + wp.element.createElement( + "p", + { className: "description" }, + "Comma-separated related keywords to sprinkle throughout content.", + ), + ), + postConfig.seo_enabled && + wp.element.createElement( + "div", + { className: "wpaw-config-section" }, + wp.element.createElement(TextareaControl, { + label: "Meta Description", + value: postConfig.seo_meta_description, + onChange: (value) => + updatePostConfig("seo_meta_description", value), + disabled: isConfigDisabled, + placeholder: "Enter meta description (120-160 chars recommended)", + rows: 3, + }), + wp.element.createElement( + "div", + { className: "wpaw-meta-info" }, + 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, + { + isSecondary: true, + isSmall: true, + onClick: () => generateMetaDescription(), + disabled: isConfigDisabled || isGeneratingMeta, + }, + isGeneratingMeta + ? wp.element.createElement( + "span", + { + style: { + display: "flex", + alignItems: "center", + gap: "5px", + }, + }, + wp.element.createElement("span", { + className: "wpaw-spinning-icon", + dangerouslySetInnerHTML: { + __html: + '', + }, + }), + " Generating...", + ) + : wp.element.createElement( + "span", + { + style: { + display: "flex", + alignItems: "center", + gap: "5px", + }, + }, + wp.element.createElement("span", { + className: "wpaw-svg-wrapper", + dangerouslySetInnerHTML: { + __html: + '', + }, + }), + " Generate", + ), + ), + ), + ), + + // SEO Audit Section + postConfig.seo_enabled && + wp.element.createElement( + "div", + { className: "wpaw-config-section wpaw-seo-audit" }, + wp.element.createElement( + "div", + { className: "wpaw-seo-audit-header" }, + wp.element.createElement("label", null, "SEO Audit"), + wp.element.createElement( + Button, + { + isSecondary: true, + isSmall: true, + onClick: () => runSeoAudit(), + disabled: isConfigDisabled || isSeoAuditing, + }, + isSeoAuditing + ? wp.element.createElement( + "span", + { + 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", + { + 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" }, + wp.element.createElement( + "div", + { + className: + "wpaw-seo-score " + + (seoAudit.score >= 70 + ? "good" + : seoAudit.score >= 40 + ? "warning" + : "poor"), + }, + wp.element.createElement( + "span", + { className: "score-value" }, + seoAudit.score, + ), + wp.element.createElement( + "span", + { className: "score-label" }, + "/100", + ), + ), + wp.element.createElement( + "div", + { className: "wpaw-seo-stats" }, + wp.element.createElement( + "div", + { className: "wpaw-seo-stat" }, + wp.element.createElement( + "span", + { className: "stat-label" }, + "Words", + ), + wp.element.createElement( + "span", + { className: "stat-value" }, + seoAudit.word_count || 0, + ), + ), + wp.element.createElement( + "div", + { className: "wpaw-seo-stat" }, + wp.element.createElement( + "span", + { className: "stat-label" }, + "Keyword Density", + ), + wp.element.createElement( + "span", + { className: "stat-value" }, + `${(seoAudit.keyword_density || 0).toFixed(1)}%`, + ), + ), + ), + seoAudit.checks && + wp.element.createElement( + "div", + { className: "wpaw-seo-checks" }, + seoAudit.checks.map((check, idx) => { + const isPassed = + check.status === "good" || check.status === "ok"; + const isFixing = activeSeoFixKey === getSeoFixKey(check); + return wp.element.createElement( + "div", + { + key: idx, + className: + "wpaw-seo-check " + + (isPassed ? "passed" : "failed"), + }, + wp.element.createElement( + "span", + { className: "check-icon" }, + isPassed ? "✓" : "✗", + ), + wp.element.createElement( + "span", + { className: "check-label" }, + check.message, + ), + !isPassed && + wp.element.createElement( + Button, + { + isSmall: true, + isSecondary: true, + className: + "wpaw-seo-fix-button" + + (isFixing ? " is-fixing" : ""), + onClick: () => handleSeoAuditFix(check), + disabled: + isLoading || + isSeoAuditing || + isGeneratingMeta || + Boolean(activeSeoFixKey), + }, + isFixing ? "Fixing..." : "Fix", + ), + ); + }), + ), + ), + !seoAudit && + wp.element.createElement( + "p", + { className: "description" }, + 'Click "Run Audit" to analyze your content for SEO optimization.', + ), + ), + + (isConfigSaving || configError) && + wp.element.createElement( + "div", + { className: "wpaw-config-section" }, + isConfigSaving && + wp.element.createElement( + "p", + { className: "description" }, + "Saving post configuration...", + ), + configError && + wp.element.createElement( + "p", + { className: "description" }, + configError, + ), + ), + wp.element.createElement( + "div", + { className: "wpaw-config-section" }, + wp.element.createElement( + "p", + { className: "description" }, + "Configure global settings like API keys, models, and clarification quiz options in ", + wp.element.createElement( + "a", + { + href: + settings.settings_url || + "/wp-admin/options-general.php?page=wp-agentic-writer", + target: "_blank", + }, + "Settings → WP Agentic Writer", + ), + ), + ), + ); + }; + + // Render Chat Tab + const renderChatTab = () => { + const agentBusy = isLoading || isSeoAuditing || isGeneratingMeta; + const isStoppingOperation = activeOperation.status === "stopping"; + // Determine agent status + const getAgentStatus = () => { + if (isStoppingOperation) return "stopping"; + if (!agentBusy) return "idle"; + const lastMsg = messages.filter((m) => m.type === "timeline").pop(); + if (activeOperation.type === "refinement") return "refining"; + if (activeOperation.type === "seo_audit") return "checking"; + if (lastMsg?.message?.toLowerCase().includes("writing")) + return "writing"; + if (lastMsg?.message?.toLowerCase().includes("generating")) + return "writing"; + return "thinking"; + }; + const agentStatus = getAgentStatus(); + const statusLabels = { + idle: "Ready", + thinking: "Thinking...", + checking: "Checking...", + refining: "Refining...", + writing: "Writing...", + stopping: "Stopping...", + complete: "Done", + error: "Error", + }; + + return wp.element.createElement( + "div", + { className: "wpaw-tab-content wpaw-chat-tab dark-theme" }, + renderClarification(), + !inClarification && + wp.element.createElement( + "div", + { className: "wpaw-chat-container" }, + // 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], + ), + ), + // MEMANTO: Restored from memory badge + memantoRestore.restored && + wp.element.createElement( + "div", + { + className: "wpaw-memanto-badge", + title: + "Restored from memory: " + + (memantoRestore.summary || "prior session context"), + }, + "\uD83E\uDDE0 Restored", + ), + wp.element.createElement( + "div", + { className: "wpaw-status-actions" }, + !showWelcome && + wp.element.createElement( + "button", + { + className: "wpaw-status-icon-btn", + title: "Back to Sessions", + onClick: () => setShowWelcome(true), + disabled: isLoading, + }, + "Sessions", + ), + // Undo Button + aiUndoStack.length > 0 && + wp.element.createElement( + "button", + { + className: "wpaw-status-icon-btn wpaw-undo-btn", + title: `Undo: ${aiUndoStack[aiUndoStack.length - 1]?.label || "Last AI operation"}`, + onClick: undoLastAiOperation, + disabled: isLoading, + }, + "↩️", + ), + // Cost Label + // 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", + dangerouslySetInnerHTML: { + __html: + '', + }, + title: "Configuration", + onClick: () => setActiveTab("config"), + disabled: isLoading, + }), + // Cost Icon Button + wp.element.createElement("button", { + className: "wpaw-status-icon-btn", + dangerouslySetInnerHTML: { + __html: + '', + }, + title: "Cost Tracking", + onClick: () => setActiveTab("cost"), + disabled: isLoading, + }), + ), + ), + // Editor Lock Banner + isEditorLocked && + wp.element.createElement( + "div", + { className: "wpaw-editor-lock-banner" }, + "Writing in progress — please wait until the article finishes.", + ), + isRefinementLocked && + wp.element.createElement( + "div", + { className: "wpaw-refinement-lock-banner" }, + `Refining in progress — editing is temporarily locked. You can still scroll and review changes live (${refiningBlockIds.length} target block(s)).`, + ), + // Health Check Warnings + wpAgenticWriter.health && + !wpAgenticWriter.health.ok && + wpAgenticWriter.health.issues.map((issue, idx) => + wp.element.createElement( + "div", + { key: `health-${idx}`, className: "wpaw-health-notice" }, + "⚠️ ", + issue.message, + issue.actionUrl && + wp.element.createElement( + "a", + { + href: issue.actionUrl, + target: "_blank", + rel: "noopener", + style: { marginLeft: "8px" }, + }, + issue.actionLabel || "Fix", + ), + ), + ), + // Welcome Screen (first time) + showWelcome && !isEditorLocked && renderWelcomeScreen(), + // Writing Mode Empty State + !showWelcome && + shouldShowWritingEmptyState() && + renderWritingEmptyState(), + // Agent Workspace Context + !showWelcome && + !shouldShowWritingEmptyState() && + renderContextIndicator(), + // 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 }), + ), + ), + // Command Input Area - hide when showing empty state or welcome + !showWelcome && + !shouldShowWritingEmptyState() && + wp.element.createElement( + "div", + { + className: "wpaw-command-area", + style: { position: "relative" }, + }, + // Slash command hint when input is empty + !input && + !isLoading && + wp.element.createElement( + "div", + { className: "wpaw-input-hint" }, + "Type ", + wp.element.createElement("kbd", null, "/"), + " for commands or ", + wp.element.createElement("kbd", null, "@"), + " to mention a block", + ), + // 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: + "Ask the agent to write, continue, inspect, refine, or use @ to target blocks...", + }), + ), + showMentionAutocomplete && + mentionOptions.length > 0 && + wp.element.createElement( + "div", + { + className: "wpaw-mention-autocomplete", + style: { + position: "absolute", + bottom: "100%", + left: 0, + right: 0, + maxHeight: "200px", + overflowY: "auto", + background: "#1e1e1e", + border: "1px solid #3c3c3c", + zIndex: 1000, + }, + }, + mentionOptions.map((option, index) => { + const isSelected = index === mentionCursorIndex; + return wp.element.createElement( + "div", + { + key: option.id, + className: + "wpaw-mention-option" + + (isSelected ? " selected" : ""), + onClick: () => insertMention(option), + style: { + padding: "8px 12px", + cursor: "pointer", + background: isSelected ? "#2c2c2c" : "transparent", + borderBottom: "1px solid #3c3c3c", + }, + }, + wp.element.createElement( + "strong", + { + style: { + display: "block", + color: "#fff", + fontSize: "13px", + }, + }, + option.label, + ), + wp.element.createElement( + "span", + { + style: { + display: "block", + color: "#a7aaad", + fontSize: "12px", + marginTop: "2px", + }, + }, + option.sublabel, + ), + ); + }), + ), + showSlashAutocomplete && + slashOptions.length > 0 && + wp.element.createElement( + "div", + { + className: "wpaw-mention-autocomplete", + style: { + position: "absolute", + bottom: "100%", + left: 0, + right: 0, + maxHeight: "200px", + overflowY: "auto", + background: "#1e1e1e", + border: "1px solid #3c3c3c", + zIndex: 1000, + }, + }, + slashOptions.map((option, index) => { + const isSelected = index === slashCursorIndex; + return wp.element.createElement( + "div", + { + key: option.id, + className: + "wpaw-mention-option" + + (isSelected ? " selected" : ""), + onClick: () => insertSlashCommand(option), + style: { + padding: "8px 12px", + cursor: "pointer", + background: isSelected ? "#2c2c2c" : "transparent", + borderBottom: "1px solid #3c3c3c", + }, + }, + wp.element.createElement( + "strong", + { + style: { + display: "block", + color: "#fff", + fontSize: "13px", + }, + }, + option.label, + ), + wp.element.createElement( + "span", + { + style: { + display: "block", + color: "#a7aaad", + fontSize: "12px", + marginTop: "2px", + }, + }, + option.sublabel, + ), + ); + }), + ), + wp.element.createElement( + "div", + { className: "wpaw-command-actions" }, + + wp.element.createElement( + "div", + { className: "wpaw-command-actions-group" }, + (() => { + // 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" }, + !showWelcome && + wp.element.createElement( + "button", + { + className: "wpaw-command-text-btn", + type: "button", + onClick: () => setShowWelcome(true), + disabled: isLoading, + }, + "Sessions", + ), + // New session keeps previous agent sessions continuable. + wp.element.createElement( + "button", + { + className: "wpaw-command-text-btn", + type: "button", + onClick: clearChatContext, + disabled: isLoading, + }, + "New Session", + ), + + // Stop Button (appears during execution) - Circle with pause icon + agentBusy && + wp.element.createElement("button", { + className: + "wpaw-command-circle-btn wpaw-stop-circle-btn" + + (isStoppingOperation ? " is-stopping" : ""), + type: "button", + onClick: handleStopExecution, + disabled: isStoppingOperation, + title: isStoppingOperation + ? "Stopping..." + : "Stop current operation", + dangerouslySetInnerHTML: { + __html: isStoppingOperation + ? '' + : '', + }, + }), + + // Send Button (Bottom Right) - Circle with send icon + !agentBusy && + wp.element.createElement("button", { + className: + "wpaw-command-circle-btn wpaw-send-circle-btn", + type: "button", + onClick: sendMessage, + disabled: !input.trim(), + title: "Send message", + dangerouslySetInnerHTML: { + __html: + '', + }, + }), + ), + ), + 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", + ), + ), + renderRefineAllConfirmModal(), + ), + ), + ); + }; + + // Refresh cost data from server + const [costHistory, setCostHistory] = wp.element.useState([]); + + const refreshCostData = async () => { + if (!postId) return; + try { + const response = await fetch( + `${wpAgenticWriter.apiUrl}/cost-tracking/${postId}`, + { + headers: { "X-WP-Nonce": wpAgenticWriter.nonce }, + }, + ); + const data = await response.json(); + if (data && typeof data.session === "number") { + setCost({ + session: data.session, + today: data.today?.total?.cost || 0, + monthlyUsed: data.monthly?.used || 0, + }); + } + if (data?.monthly?.budget) { + setMonthlyBudget(data.monthly.budget); + } + if (data?.history) { + setCostHistory(data.history); + } + } catch (e) { + wpawLog.error("Failed to refresh cost data:", e); + } + }; + + // Render Cost Tab + const renderCostTab = () => { + const budgetPercent = + monthlyBudget > 0 ? (cost.monthlyUsed / monthlyBudget) * 100 : 0; + const budgetStatus = + budgetPercent > 90 ? "danger" : budgetPercent > 70 ? "warning" : "ok"; + const remaining = Math.max(0, monthlyBudget - cost.monthlyUsed); + + return wp.element.createElement( + "div", + { className: "wpaw-tab-content wpaw-cost-tab dark-theme" }, + wp.element.createElement( + "div", + { className: "wpaw-tab-header" }, + wp.element.createElement( + "button", + { + className: "wpaw-back-btn", + onClick: () => setActiveTab("chat"), + }, + "← Back", + ), + wp.element.createElement("h3", null, "OPENROUTER COST"), + wp.element.createElement("button", { + className: "wpaw-refresh-btn", + dangerouslySetInnerHTML: { + __html: + '', + }, + onClick: refreshCostData, + title: "Refresh cost data", + }), + ), + wp.element.createElement( + "div", + { className: "wpaw-cost-card" }, + wp.element.createElement( + "div", + { className: "wpaw-cost-stat" }, + wp.element.createElement("label", null, "This Post"), + wp.element.createElement( + "div", + { className: "wpaw-cost-value" }, + "$", + cost.session.toFixed(4), + ), + ), + wp.element.createElement( + "div", + { className: "wpaw-cost-stat" }, + wp.element.createElement("label", null, "Month Used"), + wp.element.createElement( + "div", + { className: "wpaw-cost-value" }, + "$", + cost.monthlyUsed.toFixed(4), + ), + ), + wp.element.createElement( + "div", + { className: "wpaw-cost-stat wpaw-cost-remaining" }, + wp.element.createElement("label", null, "Remaining"), + wp.element.createElement( + "div", + { className: "wpaw-cost-value " + budgetStatus }, + "$", + remaining.toFixed(2), + ), + ), + ), + wp.element.createElement( + "div", + { className: "wpaw-budget-section" }, + wp.element.createElement( + "div", + { className: "wpaw-budget-label" }, + wp.element.createElement( + "span", + null, + "Budget: $", + monthlyBudget.toFixed(2), + ), + wp.element.createElement( + "span", + null, + budgetPercent.toFixed(1), + "%", + ), + ), + wp.element.createElement( + "div", + { className: "wpaw-budget-bar" }, + wp.element.createElement("div", { + className: "wpaw-budget-fill " + budgetStatus, + style: { width: Math.min(budgetPercent, 100) + "%" }, + }), + ), + ), + budgetPercent > 80 && + wp.element.createElement( + "div", + { + 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, "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, + wp.element.createElement( + "tr", + null, + wp.element.createElement("th", null, "Time"), + wp.element.createElement("th", null, "Action"), + wp.element.createElement("th", null, "Model"), + wp.element.createElement("th", null, "Tokens"), + wp.element.createElement("th", null, "Cost(US$)"), + ), + ), + wp.element.createElement( + "tbody", + null, + costHistory.map((record, idx) => { + const totalTokens = + parseInt(record.input_tokens || 0) + + parseInt(record.output_tokens || 0); + const time = new Date(record.created_at).toLocaleTimeString( + "en-US", + { hour: "2-digit", minute: "2-digit" }, + ); + const modelShort = record.model + ? record.model.split("/").pop().substring(0, 20) + : "N/A"; + return wp.element.createElement( + "tr", + { key: idx }, + wp.element.createElement("td", null, time), + wp.element.createElement("td", null, record.action), + wp.element.createElement( + "td", + { title: record.model }, + modelShort, + ), + wp.element.createElement( + "td", + null, + totalTokens.toLocaleString(), + ), + wp.element.createElement( + "td", + null, + "$" + parseFloat(record.cost).toFixed(4), + ), + ); + }), + ), + ), + ), + ), + wp.element.createElement( + "div", + { className: "wpaw-cost-footer" }, + wp.element.createElement( + "a", + { + 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', + }, + }), + ), + ), + ); + }; + + // Main render. + return wp.element.createElement( + wp.element.Fragment, + null, + wp.element.createElement( + PluginSidebarMoreMenuItem, + { + target: "wp-agentic-writer", + icon: pluginIcon, + }, + "WP Agentic Writer", + ), + 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", + alt: "WP Agentic Writer", + style: { width: "24px", height: "24px" }, + }), + wp.element.createElement("span", null, "WP Agentic Writer"), + ), + }, + wp.element.createElement( + Panel, + null, + wp.element.createElement( + "div", + { className: "wpaw-tab-content-wrapper" }, + activeTab === "chat" && renderChatTab(), + activeTab === "config" && renderConfigTab(), + activeTab === "cost" && renderCostTab(), + ), + ), + ), + ); + }; + + // HOC to get post ID. + const mapSelectToProps = (select) => ({ + postId: select("core/editor").getCurrentPostId(), + }); + + // Connect sidebar to Redux store. + const ConnectedSidebar = + wp.data.withSelect(mapSelectToProps)(AgenticWriterSidebar); + + // Register plugin. + registerPlugin("wp-agentic-writer", { + icon: pluginIcon, + render: ConnectedSidebar, + }); })(window.wp); diff --git a/docs/implementation/MEMANTO_INTEGRATION_PLAN.md b/docs/implementation/MEMANTO_INTEGRATION_PLAN.md new file mode 100644 index 0000000..8183cb3 --- /dev/null +++ b/docs/implementation/MEMANTO_INTEGRATION_PLAN.md @@ -0,0 +1,435 @@ +# MEMANTO Integration Plan — Optional Context Enhancement + +**Version:** 1.0 +**Date:** 2026-06-07 +**Status:** Planning +**Depends on:** MEMANTO_PRICING_STRATEGY.md + +--- + +## Design Principles + +1. **MEMANTO is optional.** The plugin's built-in Context Builder (`class-context-builder.php`) remains the default and always works without MEMANTO. +2. **MEMANTO enhances, never replaces.** MySQL sessions (`wpaw_conversations`) remain the primary session store. MEMANTO runs parallel. +3. **Zero disruption on failure.** If MEMANTO is unreachable, the plugin falls back to existing behavior with no error shown to the user. +4. **Server-side only.** All MEMANTO API calls happen in PHP. The frontend (sidebar.js) is unaware of MEMANTO — it just sees richer or leaner context in AI responses. +5. **User brings own Moorcheh key.** Plugin stores MEMANTO URL + Moorcheh API key in WordPress settings. Never hardcoded. + +--- + +## Architecture Overview + +``` + ┌────────────────────────────────────────────────┐ + │ WordPress Backend │ + │ │ + User Message ──────► │ Gutenberg Sidebar (handle_chat_request) │ + │ │ │ + │ ▼ │ + │ Context Builder (build_system_message) │ + │ │ │ + │ ├──► MySQL Session (wpaw_conversations) │ + │ │ primary store │ + │ │ │ + │ ├──► Memanto Client ──► MEMANTO API │ + │ │ (if configured) │ + │ │ │ │ + │ │ ├── recall (retrieve) │ + │ │ └── remember (store) │ + │ │ │ + │ ▼ │ + │ Merged Context → AI Provider → Response │ + └────────────────────────────────────────────────┘ +``` + +### Data Flow: Two Paths + +| Path | When | What Happens | +|---|---|---| +| **Default (no MEMANTO)** | MEMANTO URL not configured in settings | Context Builder uses MySQL session only. Identical to current behavior. | +| **MEMANTO active** | MEMANTO URL + Moorcheh key configured and validated | Context Builder queries MEMANTO for relevant memories before building context. After AI response, significant events are stored in MEMANTO. | + +--- + +## Agent Design + +### Agent Naming Convention + +| Agent ID | Scope | Purpose | +|---|---|---| +| `wp-user-{wordpress_user_id}` | Per WordPress user | Cross-post preferences: writing style, tone, audience, language, brand voice | +| `wp-post-{wordpress_post_id}` | Per post | Article-specific: plan decisions, rejections, research, section progress | + +### Memory Types Used + +| MEMANTO Type | When Stored | Example | +|---|---|---| +| `preference` | User sets/changes post config | "User prefers conversational tone, intermediate audience" | +| `instruction` | User sends chat message | "Focus on plugin vulnerabilities only" | +| `decision` | User approves/rejects plan | "Approved 5-section outline for WordPress security" | +| `artifact` | Plan generated, section written | "Plan: 5 sections covering X, Y, Z" | +| `context` | Session ends / summarize | "Article at 60% completion, 3 of 5 sections done" | +| `error` | User corrects AI output | "User rejected generic tips approach, wants specific plugin recommendations" | + +### Tags Convention + +Tags enable targeted recall. Every memory includes: + +| Tag | Example | Purpose | +|---|---|---| +| `post:{id}` | `post:42` | Scope recall to specific post | +| `site:{domain}` | `site:example.com` | Scope to WordPress site | +| `mode:{mode}` | `mode:planning` | What mode was active | +| `model:{model}` | `model:deepseek-chat` | Which model was used | + +--- + +## New Files to Create + +### `includes/class-memanto-client.php` + +PHP client for MEMANTO API v2. Singleton class. + +**Public Methods:** + +| Method | Description | +|---|---| +| `is_configured()` | Returns true if MEMANTO URL + Moorcheh key are set in settings | +| `is_healthy()` | Calls `/health` endpoint, caches result for 5 minutes | +| `ensure_agent( $agent_id )` | Creates agent via `POST /api/v2/agents` if not exists | +| `activate_session( $agent_id )` | `POST /api/v2/agents/{id}/activate`, caches session token in transient | +| `remember( $agent_id, $content, $type, $tags, $title )` | `POST /api/v2/agents/{id}/remember` | +| `batch_remember( $agent_id, $memories )` | `POST /api/v2/agents/{id}/batch-remember` | +| `recall( $agent_id, $query, $type, $limit )` | `POST /api/v2/agents/{id}/recall` | +| `recall_recent( $agent_id, $limit )` | `POST /api/v2/agents/{id}/recall/recent` | +| `deactivate_session( $agent_id )` | `POST /api/v2/agents/{id}/deactivate` | + +**Internal Mechanics:** + +- Session token stored in WP transient: `wpaw_memanto_token_{agent_id}` (6-hour TTL matching MEMANTO JWT) +- Auto-reactivates on expired token (catches 401, re-activates, retries) +- All calls use `wp_remote_post` / `wp_remote_get` with 10-second timeout +- All calls wrapped in try/catch with `wpaw_debug_log` on failure +- Moorcheh API key passed via `X-API-Key` header or configured in MEMANTO instance (depending on MEMANTO's auth model) + +### `includes/class-memanto-context-enhancer.php` + +Orchestrates when and what to remember/recall. Hooks into existing Context Service. + +**Public Methods:** + +| Method | Hook Point | Description | +|---|---|---| +| `on_session_start( $session_id, $post_id, $user_id )` | Session creation | Ensures user + post agents exist; recalls previous session state | +| `on_user_message( $session_id, $content, $post_id )` | After user sends message | Stores instruction-type memory | +| `on_plan_generated( $post_id, $plan )` | After plan creation | Stores artifact-type memory | +| `on_plan_approved( $post_id, $plan )` | User approves plan | Stores decision-type memory | +| `on_plan_rejected( $post_id, $reason )` | User rejects/requests changes | Stores error-type memory with rejection reason | +| `on_section_written( $post_id, $section_id, $summary )` | After section generation | Stores artifact-type memory | +| `on_block_refined( $post_id, $block_id, $instruction )` | After refinement | Stores instruction-type memory | +| `on_config_saved( $post_id, $config )` | Post config updated | Stores preference-type memory to both user and post agents | +| `on_session_end( $session_id, $post_id )` | Session completed/archived | Summarizes session, stores context-type memory, deactivates session | +| `recall_for_context( $post_id, $user_id, $current_message )` | Before building context | Returns recalled memories to enrich prompt | + +**Recall Strategy (`recall_for_context`):** + +1. Recall recent memories from post agent (limit: 10) +2. Recall semantically relevant memories from post agent (query: user's current message, limit: 5) +3. Recall user preferences from user agent (query: "writing preferences tone audience", limit: 5) +4. Deduplicate by content hash +5. Return structured array of recalled items + +--- + +## Files to Modify + +### `includes/class-settings-v2.php` + +Add MEMANTO configuration section: + +``` +MEMANTO Context Keeper +├── Enable MEMANTO integration (checkbox, default: off) +├── MEMANTO Instance URL (text, e.g., https://abc123.context.wpagentic.dev) +├── Moorcheh API Key (password, user's own key) +└── Connection Status (read-only, shows "Connected" / "Not configured" / "Error: ...") +``` + +Add a "Test Connection" button that calls MEMANTO `/health` endpoint. + +### `includes/class-context-builder.php` + +Modify `build_for_task()` method. After line ~52 where `$saved_context` is loaded: + +```php +// Existing: MySQL context +$saved_context = $context_service->get_context( $session_id, $post_id ); + +// NEW: MEMANTO enhancement (if configured) +$memanto_context = array(); +$memanto_client = WP_Agentic_Writer_Memanto_Client::get_instance(); +if ( $memanto_client->is_configured() && $memanto_client->is_healthy() ) { + $enhancer = WP_Agentic_Writer_Memanto_Context_Enhancer::get_instance(); + $memanto_context = $enhancer->recall_for_context( + $post_id, + get_current_user_id(), + $request_params['latestUserMessage'] ?? '' + ); +} +``` + +Modify `build_working_context()` to include a new section: + +```php +// After existing sections, before "Recent saved conversation excerpts" +if ( ! empty( $memanto_context ) ) { + $memory_lines = $this->format_memanto_memories( $memanto_context ); + if ( '' !== $memory_lines ) { + $sections[] = "PERSISTENT MEMORY (recalled from MEMANTO):\n" . $memory_lines; + } +} +``` + +**Key rule:** MEMANTO context is **additive**. It never replaces the existing `BACKEND CONTINUITY CONTEXT` section. It supplements it. + +### `includes/class-context-service.php` + +Add MEMANTO write-through hooks in key methods: + +| Method | Hook Added | +|---|---| +| `save_plan()` | `$enhancer->on_plan_generated( $post_id, $plan )` | +| `update_session_context()` | `$enhancer->on_config_saved()` if config changed | +| `add_message()` | `$enhancer->on_user_message()` for user-role messages | +| `clear_context()` | Optionally clear MEMANTO post agent memories | + +### `includes/class-gutenberg-sidebar.php` + +Add MEMANTO hooks in key handler methods: + +| Handler | Hook Added | +|---|---| +| `handle_chat_request()` | `$enhancer->on_user_message()` after saving to MySQL | +| `handle_generate_plan()` | `$enhancer->on_plan_generated()` after successful plan | +| `handle_execute_article()` | `$enhancer->on_section_written()` per section | +| `handle_refine_block()` | `$enhancer->on_block_refined()` | +| `handle_summarize_context()` | Skip AI call if MEMANTO active — return cached recall instead | +| `handle_detect_intent()` | Skip AI call if MEMANTO active — use regex + MEMANTO context instead | + +Add new REST endpoint: + +``` +POST /wp-agentic-writer/v1/memanto/status +→ Returns: { connected: bool, agent_count: int, memory_count: int, last_recall: string } +``` + +### `includes/class-autoloader.php` + +Register the two new classes: +- `class-memanto-client.php` → `WP_Agentic_Writer_Memanto_Client` +- `class-memanto-context-enhancer.php` → `WP_Agentic_Writer_Memanto_Context_Enhancer` + +### `assets/js/sidebar.js` (minimal change) + +No MEMANTO-specific logic needed. Optional enhancement: +- Show a small "🧠 Memory active" indicator when MEMANTO is connected +- Show "memories recalled: N" in the context audit display + +--- + +## Graceful Degradation Strategy + +``` +MEMANTO call succeeds? +├── YES → Merge MEMANTO context into working context +└── NO + ├── MEMANTO not configured → Use MySQL-only context (default behavior) + ├── MEMANTO timeout (>10s) → Log warning, use MySQL-only context + ├── MEMANTO 401 (token expired) → Re-activate session, retry once, then fallback + └── MEMANTO 5xx (server error) → Log error, use MySQL-only context +``` + +User **never** sees an error from MEMANTO. The worst case is they get the same experience as users without MEMANTO. + +--- + +## Implementation Phases + +### Phase 1: Core Client (Week 1) + +**Goal:** MEMANTO client class + settings UI + connection validation + +| Task | File | Details | +|---|---|---| +| Create Memanto Client | `class-memanto-client.php` | All API methods, session token management, error handling | +| Create Context Enhancer shell | `class-memanto-context-enhancer.php` | Skeleton with `is_configured()` check on every method | +| Add settings section | `class-settings-v2.php` | URL field, API key field, enable checkbox, test button | +| Register in autoloader | `class-autoloader.php` | Add both new classes | +| Add REST status endpoint | `class-gutenberg-sidebar.php` | `/memanto/status` endpoint | + +**Validation:** Admin can configure MEMANTO URL + Moorcheh key, test connection, see "Connected" status. No functional changes to AI features yet. + +### Phase 2: Write-Through Memory (Week 2) + +**Goal:** Store memories on every meaningful action + +| Task | Hook Point | Memory Type | +|---|---|---| +| Store on user message | `handle_chat_request()` | `instruction` | +| Store on plan generated | `handle_generate_plan()` | `artifact` | +| Store on plan approved | After plan save in frontend | `decision` | +| Store on plan rejected | Plan revision flow | `error` | +| Store on section written | `handle_execute_article()` | `artifact` | +| Store on block refined | `handle_refine_block()` | `instruction` | +| Store on config saved | `update_session_context()` | `preference` | +| Store on session end | Session completed/archived | `context` | + +**Validation:** Write an article with MEMANTO enabled. Check MEMANTO API (via recall endpoint) that memories were stored. Verify plugin still works perfectly with MEMANTO disabled. + +### Phase 3: Context Enrichment (Week 3) + +**Goal:** Recall memories to enrich AI prompts + +| Task | File | Details | +|---|---|---| +| Add `recall_for_context()` | `class-memanto-context-enhancer.php` | 3-recall strategy (recent, semantic, preferences) | +| Modify `build_for_task()` | `class-context-builder.php` | Merge recalled memories into working context | +| Add `format_memanto_memories()` | `class-context-builder.php` | Format recalled items as compact prompt text | +| Skip summarize-context when MEMANTO active | `class-gutenberg-sidebar.php` | Return cached recall instead of AI call | +| Skip detect-intent when MEMANTO active | `class-gutenberg-sidebar.php` | Use regex + MEMANTO context instead | + +**Validation:** Write an article. Mid-session, close the browser. Reopen the post. Verify AI "remembers" context from recalled memories. Compare AI response quality with/without MEMANTO. + +### Phase 4: Cross-Session Restore (Week 4) + +**Goal:** Seamless experience when returning to a post after days/weeks + +| Task | Details | +|---|---| +| Session restore on load | When post editor opens, recall recent post memories. Build a "restored session" system message. | +| Frontend indicator | Show "🧠 Restored from memory" badge in sidebar | +| User preference carry-over | On new post creation, recall user agent preferences for default post config | +| Session deactivation | On session end, call MEMANTO deactivate to trigger summary generation | + +**Validation:** Create an article. Complete 50%. Wait 1 day. Open the post again. Verify AI picks up where it left off without user re-explaining context. + +### Phase 5: Polish & Edge Cases (Week 5) + +| Task | Details | +|---|---| +| Memory pruning | On session end, summarize verbose raw messages into compact context memories | +| Connection health UI | Real-time status indicator in plugin sidebar header | +| Moorcheh limit warning | When approaching 10K vectors, show admin notice with upgrade link | +| Error logging | Detailed MEMANTO error logging with `wpaw_debug_log` | +| Settings validation | Validate URL format, API key format, connection test before saving | + +--- + +## Testing Strategy + +### Unit Tests + +| Test | Description | +|---|---| +| `test_memanto_client_not_configured` | Client returns false when settings empty | +| `test_memanto_client_health_check` | Mock `/health` response, verify caching | +| `test_memanto_client_remember` | Mock remember API, verify payload structure | +| `test_memanto_client_recall` | Mock recall API, verify response parsing | +| `test_memanto_client_session_lifecycle` | Activate → remember → recall → deactivate | +| `test_enhancer_graceful_fallback` | MEMANTO returns error, context builder still works | +| `test_context_builder_with_memanto` | Verify MEMANTO context is included in working context | +| `test_context_builder_without_memanto` | Verify no MEMANTO content when not configured | + +### Integration Tests + +| Test | Description | +|---|---| +| Full article with MEMANTO | Chat → Plan → Write → Refine. Verify memories stored at each step. | +| Full article without MEMANTO | Same flow. Verify no MEMANTO calls made. Plugin works identically. | +| MEMANTO goes down mid-session | Start with MEMANTO active. Simulate timeout. Verify graceful fallback. | +| Cross-session restore | Write 50% of article. Simulate new session. Verify AI context restored. | +| Multi-site with same MEMANTO | Use same MEMANTO instance across 2 sites. Verify agent isolation. | + +### Manual Test Checklist + +- [ ] Plugin activates with no MEMANTO settings — works normally +- [ ] MEMANTO URL set but Moorcheh key empty — shows "not configured" +- [ ] MEMANTO URL + invalid key — shows "connection error" +- [ ] MEMANTO URL + valid key — shows "connected" +- [ ] Write article with MEMANTO on — AI responses include recalled memory +- [ ] Write article with MEMANTO off — identical to current behavior +- [ ] Disable MEMANTO mid-session — no errors, fallback to MySQL-only +- [ ] Re-enable MEMANTO — picks up from where it left off +- [ ] Check MEMANTO recall endpoint — memories exist for test post + +--- + +## Performance Considerations + +| Concern | Mitigation | +|---|---| +| MEMANTO recall adds latency to every AI call | Cache recall results in transient (5-min TTL). Only recall when context builder runs. | +| Session token expires mid-request | Auto-reactivate on 401. Single retry. | +| Too many memories stored | Batch-remember to reduce HTTP calls. Summarize on session end. | +| MEMANTO instance overloaded | 10-second timeout on all calls. Graceful fallback. | +| WordPress transient cache bloat | Use specific key patterns. Clean up on session end. | + +--- + +## Settings UI Specification + +### MEMANTO Context Keeper Section + +Located in WP Agentic Writer → Settings → MEMANTO tab. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ MEMANTO Context Keeper │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ☑ Enable MEMANTO integration │ +│ │ +│ MEMANTO Instance URL │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ https://abc123.context.wpagentic.dev │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ Moorcheh API Key │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ •••••••••••••••••••••••• │ │ +│ └──────────────────────────────────────────────────┘ │ +│ ℹ️ Get a free API key at moorcheh.ai (10K vectors/month) │ +│ │ +│ Connection Status: 🟢 Connected │ +│ Last checked: 2 minutes ago │ +│ │ +│ [Test Connection] │ +│ │ +├─────────────────────────────────────────────────────────────────┤ +│ ℹ️ MEMANTO is an optional add-on that provides persistent │ +│ memory for your AI writing assistant. Your AI will remember │ +│ context across sessions and posts. The plugin works │ +│ perfectly without MEMANTO. │ +│ │ +│ Get MEMANTO at: wpagentic.dev/memanto │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Summary + +| Aspect | Decision | +|---|---| +| **Scope** | Optional enhancement, not a dependency | +| **New files** | `class-memanto-client.php`, `class-memanto-context-enhancer.php` | +| **Modified files** | `class-context-builder.php`, `class-context-service.php`, `class-gutenberg-sidebar.php`, `class-settings-v2.php`, `class-autoloader.php` | +| **Frontend changes** | Minimal: status indicator only | +| **Fallback behavior** | Full graceful degradation to MySQL-only context | +| **Implementation time** | 5 weeks (1 week per phase) | +| **Testing priority** | Phase 2 (write-through) and Phase 3 (recall) are critical paths | + +--- + +**Document Date:** June 7, 2026 +**Status:** Draft — Ready for Phase 1 implementation diff --git a/includes/class-context-builder.php b/includes/class-context-builder.php index 9e5ada9..a8cd388 100644 --- a/includes/class-context-builder.php +++ b/includes/class-context-builder.php @@ -7,8 +7,8 @@ * @package WP_Agentic_Writer */ -if ( ! defined( 'ABSPATH' ) ) { - exit; +if (!defined("ABSPATH")) { + exit(); } /** @@ -18,513 +18,727 @@ if ( ! defined( 'ABSPATH' ) ) { * continuity in WordPress by selecting the relevant session/post context for * each request without resending the full browser history. */ -class WP_Agentic_Writer_Context_Builder { - - /** - * Singleton instance. - * - * @var WP_Agentic_Writer_Context_Builder - */ - private static $instance = null; - - /** - * Get singleton instance. - * - * @return WP_Agentic_Writer_Context_Builder - */ - public static function get_instance() { - if ( null === self::$instance ) { - self::$instance = new self(); - } - - return self::$instance; - } - - /** - * Build a context package for a task. - * - * @param string $task Task name. - * @param string $session_id Session ID. - * @param int $post_id Post ID. - * @param array $request_params Request params. - * @return array Context package. - */ - public function build_for_task( $task, $session_id, $post_id, $request_params = array() ) { - $context_service = WP_Agentic_Writer_Context_Service::get_instance(); - $saved_context = ! empty( $session_id ) - ? $context_service->get_context( $session_id, $post_id ) - : array(); - - $session_context = $saved_context['context'] ?? array(); - $messages = $saved_context['messages'] ?? array(); - - if ( empty( $messages ) ) { - $messages = $this->get_request_messages( $request_params ); - } - - $token_policy = $this->get_token_policy( $session_context ); - $recent_messages = $this->prepare_recent_messages( $messages, $token_policy['max_recent_messages'] ); - $recent_messages = $this->remove_active_user_message( $recent_messages, $request_params['latestUserMessage'] ?? '' ); - $post_config = $this->resolve_post_config( $saved_context, $request_params ); - $plan = $this->resolve_plan( $saved_context, $request_params, $post_id ); - - $working_context = $this->build_working_context( - $task, - $session_context, - $recent_messages, - $plan, - $post_config, - $request_params - ); - - return array( - 'system_context' => '', - 'working_context' => $working_context, - 'active_content' => $this->get_active_content( $request_params ), - 'research_context' => $this->build_research_context( $session_context, $request_params, $token_policy['max_research_snippets'] ), - 'audit' => array( - 'included_recent_messages' => count( $recent_messages ), - 'included_research_items' => $this->count_research_items( $session_context, $request_params, $token_policy['max_research_snippets'] ), - 'estimated_input_tokens' => $this->estimate_tokens( $working_context ), - 'used_full_history' => false, - ), - ); - } - - /** - * Build a system message that can be inserted after the primary system prompt. - * - * @param string $task Task name. - * @param string $session_id Session ID. - * @param int $post_id Post ID. - * @param array $request_params Request params. - * @return array Context system message and audit metadata. - */ - public function build_system_message( $task, $session_id, $post_id, $request_params = array() ) { - $package = $this->build_for_task( $task, $session_id, $post_id, $request_params ); - $content = trim( - $package['working_context'] - . "\n" - . $package['active_content'] - . "\n" - . $package['research_context'] - ); - - if ( '' === $content ) { - return array( - 'message' => null, - 'audit' => $package['audit'], - ); - } - - return array( - 'message' => array( - 'role' => 'system', - 'content' => $content, - ), - 'audit' => $package['audit'], - ); - } - - /** - * Get task token policy. - * - * @param array $session_context Session context. - * @return array Token policy. - */ - private function get_token_policy( $session_context ) { - $policy = isset( $session_context['token_policy'] ) && is_array( $session_context['token_policy'] ) - ? $session_context['token_policy'] - : array(); - - return array( - 'max_recent_messages' => max( 2, (int) ( $policy['max_recent_messages'] ?? 6 ) ), - 'max_summary_tokens' => max( 200, (int) ( $policy['max_summary_tokens'] ?? 600 ) ), - 'max_research_snippets' => max( 0, (int) ( $policy['max_research_snippets'] ?? 5 ) ), - ); - } - - /** - * Get messages from request fallback. - * - * @param array $request_params Request params. - * @return array Messages. - */ - private function get_request_messages( $request_params ) { - if ( ! empty( $request_params['messages'] ) && is_array( $request_params['messages'] ) ) { - return $request_params['messages']; - } - - if ( ! empty( $request_params['chatHistory'] ) && is_array( $request_params['chatHistory'] ) ) { - return $request_params['chatHistory']; - } - - return array(); - } - - /** - * Prepare compact recent messages. - * - * @param array $messages Messages. - * @param int $max_messages Max messages. - * @return array Recent messages. - */ - private function prepare_recent_messages( $messages, $max_messages ) { - $prepared = array(); - - foreach ( (array) $messages as $message ) { - $role = isset( $message['role'] ) ? (string) $message['role'] : ''; - if ( ! in_array( $role, array( 'user', 'assistant' ), true ) ) { - continue; - } - - $content = isset( $message['content'] ) ? trim( wp_strip_all_tags( (string) $message['content'] ) ) : ''; - if ( '' === $content ) { - continue; - } - - $prepared[] = array( - 'role' => $role, - 'content' => $this->truncate_text( $content, 900 ), - ); - } - - if ( count( $prepared ) > $max_messages ) { - $prepared = array_slice( $prepared, -1 * $max_messages ); - } - - return $prepared; - } - - /** - * Avoid echoing the active user turn inside the saved-context excerpt. - * - * @param array $messages Recent messages. - * @param string $active_user_message Active user message. - * @return array Messages without duplicate active turn. - */ - private function remove_active_user_message( $messages, $active_user_message ) { - $active_user_message = trim( wp_strip_all_tags( (string) $active_user_message ) ); - if ( '' === $active_user_message || empty( $messages ) ) { - return $messages; - } - - for ( $i = count( $messages ) - 1; $i >= 0; $i-- ) { - if ( 'user' !== ( $messages[ $i ]['role'] ?? '' ) ) { - continue; - } - - $content = trim( (string) ( $messages[ $i ]['content'] ?? '' ) ); - if ( $content === $active_user_message ) { - array_splice( $messages, $i, 1 ); - } - break; - } - - return $messages; - } - - /** - * Resolve post config. - * - * @param array $saved_context Saved context package. - * @param array $request_params Request params. - * @return array Post config. - */ - private function resolve_post_config( $saved_context, $request_params ) { - $config = $saved_context['post_config'] ?? array(); - - if ( ! empty( $request_params['postConfig'] ) && is_array( $request_params['postConfig'] ) ) { - $config = wp_parse_args( $request_params['postConfig'], $config ); - } - - return is_array( $config ) ? $config : array(); - } - - /** - * Resolve current plan. - * - * @param array $saved_context Saved context package. - * @param array $request_params Request params. - * @param int $post_id Post ID. - * @return array|null Plan. - */ - private function resolve_plan( $saved_context, $request_params, $post_id ) { - if ( ! empty( $saved_context['plan'] ) && is_array( $saved_context['plan'] ) ) { - return $saved_context['plan']; - } - - if ( ! empty( $request_params['plan'] ) && is_array( $request_params['plan'] ) ) { - return $request_params['plan']; - } - - if ( $post_id > 0 ) { - $plan = get_post_meta( $post_id, '_wpaw_plan', true ); - if ( is_array( $plan ) ) { - return $plan; - } - } - - return null; - } - - /** - * Build compact working context. - * - * @param string $task Task name. - * @param array $session_context Session context. - * @param array $recent_messages Recent messages. - * @param array $plan Current plan. - * @param array $post_config Post config. - * @param array $request_params Request params. - * @return string Context text. - */ - private function build_working_context( $task, $session_context, $recent_messages, $plan, $post_config, $request_params ) { - $sections = array(); - $sections[] = "BACKEND CONTINUITY CONTEXT\nUse this compact WordPress-saved context as continuity. Do not assume OpenRouter remembers prior turns."; - $sections[] = 'Current task: ' . sanitize_key( $task ); - - $summary = $session_context['working_summary']['text'] ?? ''; - if ( '' !== trim( (string) $summary ) ) { - $sections[] = "Working summary:\n" . $this->truncate_text( (string) $summary, 1600 ); - } - - $config_summary = $this->summarize_post_config( $post_config ); - if ( '' !== $config_summary ) { - $sections[] = "Article configuration:\n" . $config_summary; - } - - $plan_summary = $this->summarize_plan( $plan ); - if ( '' !== $plan_summary ) { - $sections[] = "Current plan:\n" . $plan_summary; - } - - $decision_summary = $this->summarize_context_items( $session_context, 'decisions', 'Decisions' ); - if ( '' !== $decision_summary ) { - $sections[] = $decision_summary; - } - - $rejection_summary = $this->summarize_context_items( $session_context, 'rejections', 'Rejected directions' ); - if ( '' !== $rejection_summary ) { - $sections[] = $rejection_summary; - } - - if ( ! empty( $request_params['context'] ) ) { - $sections[] = "User supplied context:\n" . $this->truncate_text( (string) $request_params['context'], 1600 ); - } - - if ( ! empty( $recent_messages ) ) { - $lines = array(); - foreach ( $recent_messages as $message ) { - $lines[] = ucfirst( $message['role'] ) . ': ' . $message['content']; - } - $sections[] = "Recent saved conversation excerpts:\n" . implode( "\n", $lines ); - } - - return implode( "\n\n", array_filter( $sections ) ); - } - - /** - * Summarize post config. - * - * @param array $post_config Post config. - * @return string Summary. - */ - private function summarize_post_config( $post_config ) { - $lines = array(); - $keys = array( - 'article_length' => 'Article length', - 'language' => 'Language', - 'tone' => 'Tone', - 'audience' => 'Audience', - 'experience_level' => 'Experience level', - 'seo_focus_keyword' => 'SEO focus keyword', - 'seo_secondary_keywords' => 'SEO secondary keywords', - ); - - foreach ( $keys as $key => $label ) { - if ( isset( $post_config[ $key ] ) && '' !== trim( (string) $post_config[ $key ] ) ) { - $lines[] = '- ' . $label . ': ' . trim( (string) $post_config[ $key ] ); - } - } - - if ( isset( $post_config['include_images'] ) ) { - $lines[] = '- Include images: ' . ( $post_config['include_images'] ? 'yes' : 'no' ); - } - - if ( isset( $post_config['web_search'] ) ) { - $lines[] = '- Web search: ' . ( $post_config['web_search'] ? 'yes' : 'no' ); - } - - return implode( "\n", $lines ); - } - - /** - * Summarize current plan. - * - * @param array|null $plan Plan. - * @return string Summary. - */ - private function summarize_plan( $plan ) { - if ( empty( $plan ) || ! is_array( $plan ) ) { - return ''; - } - - $lines = array(); - if ( ! empty( $plan['title'] ) ) { - $lines[] = 'Title: ' . $plan['title']; - } - - if ( ! empty( $plan['sections'] ) && is_array( $plan['sections'] ) ) { - foreach ( $plan['sections'] as $index => $section ) { - $heading = $section['heading'] ?? $section['title'] ?? ''; - if ( '' === trim( (string) $heading ) ) { - continue; - } - $status = $section['status'] ?? 'pending'; - $lines[] = sprintf( '%d. [%s] %s', $index + 1, $status, $heading ); - } - } - - return implode( "\n", $lines ); - } - - /** - * Summarize context array items. - * - * @param array $session_context Session context. - * @param string $key Context key. - * @param string $label Label. - * @return string Summary. - */ - private function summarize_context_items( $session_context, $key, $label ) { - if ( empty( $session_context[ $key ] ) || ! is_array( $session_context[ $key ] ) ) { - return ''; - } - - $lines = array(); - foreach ( array_slice( $session_context[ $key ], -8 ) as $item ) { - $summary = $item['summary'] ?? ''; - if ( '' === trim( (string) $summary ) ) { - continue; - } - $target = ! empty( $item['target'] ) ? '[' . $item['target'] . '] ' : ''; - $lines[] = '- ' . $target . $summary; - } - - return empty( $lines ) ? '' : $label . ":\n" . implode( "\n", $lines ); - } - - /** - * Get active content from request params. - * - * @param array $request_params Request params. - * @return string Active content context. - */ - private function get_active_content( $request_params ) { - $candidates = array( - 'activeContent', - 'blockContent', - 'selectedText', - 'sectionContent', - 'articleContent', - ); - - $lines = array(); - foreach ( $candidates as $key ) { - if ( ! empty( $request_params[ $key ] ) && is_string( $request_params[ $key ] ) ) { - $lines[] = $key . ":\n" . $this->truncate_text( $request_params[ $key ], 2200 ); - } - } - - return empty( $lines ) ? '' : "ACTIVE CONTENT SLICE\n" . implode( "\n\n", $lines ); - } - - /** - * Build research context. - * - * @param array $session_context Session context. - * @param array $request_params Request params. - * @param int $limit Max snippets. - * @return string Research context. - */ - private function build_research_context( $session_context, $request_params, $limit ) { - if ( $limit <= 0 ) { - return ''; - } - - $items = array(); - if ( ! empty( $session_context['research_notes'] ) && is_array( $session_context['research_notes'] ) ) { - $items = array_merge( $items, $session_context['research_notes'] ); - } - if ( ! empty( $request_params['researchNotes'] ) && is_array( $request_params['researchNotes'] ) ) { - $items = array_merge( $items, $request_params['researchNotes'] ); - } - - $items = array_slice( $items, -1 * $limit ); - $lines = array(); - foreach ( $items as $item ) { - if ( ! is_array( $item ) ) { - continue; - } - $title = $item['title'] ?? $item['source'] ?? 'Research note'; - $excerpt = $item['excerpt'] ?? $item['notes'] ?? ''; - if ( '' === trim( (string) $excerpt ) ) { - continue; - } - $lines[] = '- ' . $title . ': ' . $this->truncate_text( (string) $excerpt, 700 ); - } - - return empty( $lines ) ? '' : "RELEVANT RESEARCH\n" . implode( "\n", $lines ); - } - - /** - * Count included research items. - * - * @param array $session_context Session context. - * @param array $request_params Request params. - * @param int $limit Max snippets. - * @return int Count. - */ - private function count_research_items( $session_context, $request_params, $limit ) { - if ( $limit <= 0 ) { - return 0; - } - - $count = 0; - if ( ! empty( $session_context['research_notes'] ) && is_array( $session_context['research_notes'] ) ) { - $count += count( $session_context['research_notes'] ); - } - if ( ! empty( $request_params['researchNotes'] ) && is_array( $request_params['researchNotes'] ) ) { - $count += count( $request_params['researchNotes'] ); - } - - return min( $limit, $count ); - } - - /** - * Truncate text safely. - * - * @param string $text Text. - * @param int $limit Character limit. - * @return string Truncated text. - */ - private function truncate_text( $text, $limit ) { - $text = trim( (string) $text ); - if ( strlen( $text ) <= $limit ) { - return $text; - } - - return substr( $text, 0, $limit ) . '...'; - } - - /** - * Estimate tokens from character length. - * - * @param string $text Text. - * @return int Estimated tokens. - */ - private function estimate_tokens( $text ) { - return (int) ceil( strlen( (string) $text ) / 4 ); - } +class WP_Agentic_Writer_Context_Builder +{ + /** + * Singleton instance. + * + * @var WP_Agentic_Writer_Context_Builder + */ + private static $instance = null; + + /** + * Get singleton instance. + * + * @return WP_Agentic_Writer_Context_Builder + */ + public static function get_instance() + { + if (null === self::$instance) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * Build a context package for a task. + * + * @param string $task Task name. + * @param string $session_id Session ID. + * @param int $post_id Post ID. + * @param array $request_params Request params. + * @return array Context package. + */ + public function build_for_task( + $task, + $session_id, + $post_id, + $request_params = [], + ) { + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $saved_context = !empty($session_id) + ? $context_service->get_context($session_id, $post_id) + : []; + + $session_context = $saved_context["context"] ?? []; + $messages = $saved_context["messages"] ?? []; + + if (empty($messages)) { + $messages = $this->get_request_messages($request_params); + } + + $token_policy = $this->get_token_policy($session_context); + $recent_messages = $this->prepare_recent_messages( + $messages, + $token_policy["max_recent_messages"], + ); + $recent_messages = $this->remove_active_user_message( + $recent_messages, + $request_params["latestUserMessage"] ?? "", + ); + $post_config = $this->resolve_post_config( + $saved_context, + $request_params, + ); + $plan = $this->resolve_plan($saved_context, $request_params, $post_id); + + $working_context = $this->build_working_context( + $task, + $session_context, + $recent_messages, + $plan, + $post_config, + $request_params, + $post_id, + ); + + // Check MEMANTO status for audit (lightweight: just checks is_active, no recall). + $memanto_active = WP_Agentic_Writer_Memanto_Client::get_instance()->is_active(); + + return [ + "system_context" => "", + "working_context" => $working_context, + "active_content" => $this->get_active_content($request_params), + "research_context" => $this->build_research_context( + $session_context, + $request_params, + $token_policy["max_research_snippets"], + ), + "audit" => [ + "included_recent_messages" => count($recent_messages), + "included_research_items" => $this->count_research_items( + $session_context, + $request_params, + $token_policy["max_research_snippets"], + ), + "estimated_input_tokens" => $this->estimate_tokens( + $working_context, + ), + "used_full_history" => false, + "memanto_active" => $memanto_active, + ], + ]; + } + + /** + * Build a system message that can be inserted after the primary system prompt. + * + * @param string $task Task name. + * @param string $session_id Session ID. + * @param int $post_id Post ID. + * @param array $request_params Request params. + * @return array Context system message and audit metadata. + */ + public function build_system_message( + $task, + $session_id, + $post_id, + $request_params = [], + ) { + $package = $this->build_for_task( + $task, + $session_id, + $post_id, + $request_params, + ); + $content = trim( + $package["working_context"] . + "\n" . + $package["active_content"] . + "\n" . + $package["research_context"], + ); + + if ("" === $content) { + return [ + "message" => null, + "audit" => $package["audit"], + ]; + } + + return [ + "message" => [ + "role" => "system", + "content" => $content, + ], + "audit" => $package["audit"], + ]; + } + + /** + * Get task token policy. + * + * @param array $session_context Session context. + * @return array Token policy. + */ + private function get_token_policy($session_context) + { + $policy = + isset($session_context["token_policy"]) && + is_array($session_context["token_policy"]) + ? $session_context["token_policy"] + : []; + + return [ + "max_recent_messages" => max( + 2, + (int) ($policy["max_recent_messages"] ?? 6), + ), + "max_summary_tokens" => max( + 200, + (int) ($policy["max_summary_tokens"] ?? 600), + ), + "max_research_snippets" => max( + 0, + (int) ($policy["max_research_snippets"] ?? 5), + ), + ]; + } + + /** + * Get messages from request fallback. + * + * @param array $request_params Request params. + * @return array Messages. + */ + private function get_request_messages($request_params) + { + if ( + !empty($request_params["messages"]) && + is_array($request_params["messages"]) + ) { + return $request_params["messages"]; + } + + if ( + !empty($request_params["chatHistory"]) && + is_array($request_params["chatHistory"]) + ) { + return $request_params["chatHistory"]; + } + + return []; + } + + /** + * Prepare compact recent messages. + * + * @param array $messages Messages. + * @param int $max_messages Max messages. + * @return array Recent messages. + */ + private function prepare_recent_messages($messages, $max_messages) + { + $prepared = []; + + foreach ((array) $messages as $message) { + $role = isset($message["role"]) ? (string) $message["role"] : ""; + if (!in_array($role, ["user", "assistant"], true)) { + continue; + } + + $content = isset($message["content"]) + ? trim(wp_strip_all_tags((string) $message["content"])) + : ""; + if ("" === $content) { + continue; + } + + $prepared[] = [ + "role" => $role, + "content" => $this->truncate_text($content, 900), + ]; + } + + if (count($prepared) > $max_messages) { + $prepared = array_slice($prepared, -1 * $max_messages); + } + + return $prepared; + } + + /** + * Avoid echoing the active user turn inside the saved-context excerpt. + * + * @param array $messages Recent messages. + * @param string $active_user_message Active user message. + * @return array Messages without duplicate active turn. + */ + private function remove_active_user_message($messages, $active_user_message) + { + $active_user_message = trim( + wp_strip_all_tags((string) $active_user_message), + ); + if ("" === $active_user_message || empty($messages)) { + return $messages; + } + + for ($i = count($messages) - 1; $i >= 0; $i--) { + if ("user" !== ($messages[$i]["role"] ?? "")) { + continue; + } + + $content = trim((string) ($messages[$i]["content"] ?? "")); + if ($content === $active_user_message) { + array_splice($messages, $i, 1); + } + break; + } + + return $messages; + } + + /** + * Resolve post config. + * + * @param array $saved_context Saved context package. + * @param array $request_params Request params. + * @return array Post config. + */ + private function resolve_post_config($saved_context, $request_params) + { + $config = $saved_context["post_config"] ?? []; + + if ( + !empty($request_params["postConfig"]) && + is_array($request_params["postConfig"]) + ) { + $config = wp_parse_args($request_params["postConfig"], $config); + } + + return is_array($config) ? $config : []; + } + + /** + * Resolve current plan. + * + * @param array $saved_context Saved context package. + * @param array $request_params Request params. + * @param int $post_id Post ID. + * @return array|null Plan. + */ + private function resolve_plan($saved_context, $request_params, $post_id) + { + if ( + !empty($saved_context["plan"]) && + is_array($saved_context["plan"]) + ) { + return $saved_context["plan"]; + } + + if ( + !empty($request_params["plan"]) && + is_array($request_params["plan"]) + ) { + return $request_params["plan"]; + } + + if ($post_id > 0) { + $plan = get_post_meta($post_id, "_wpaw_plan", true); + if (is_array($plan)) { + return $plan; + } + } + + return null; + } + + /** + * Build compact working context. + * + * @param string $task Task name. + * @param array $session_context Session context. + * @param array $recent_messages Recent messages. + * @param array $plan Current plan. + * @param array $post_config Post config. + * @param array $request_params Request params. + * @param int $post_id Post ID. + * @return string Context text. + */ + private function build_working_context( + $task, + $session_context, + $recent_messages, + $plan, + $post_config, + $request_params, + $post_id = 0, + ) { + $sections = []; + $sections[] = + "BACKEND CONTINUITY CONTEXT\nUse this compact WordPress-saved context as continuity. Do not assume OpenRouter remembers prior turns."; + $sections[] = "Current task: " . sanitize_key($task); + + // MEMANTO persistent memory injection. + $memanto_context = $this->build_memanto_context( + $post_id, + $request_params["latestUserMessage"] ?? "", + ); + if ("" !== $memanto_context) { + $sections[] = $memanto_context; + } + + $summary = $session_context["working_summary"]["text"] ?? ""; + if ("" !== trim((string) $summary)) { + $sections[] = + "Working summary:\n" . + $this->truncate_text((string) $summary, 1600); + } + + $config_summary = $this->summarize_post_config($post_config); + if ("" !== $config_summary) { + $sections[] = "Article configuration:\n" . $config_summary; + } + + $plan_summary = $this->summarize_plan($plan); + if ("" !== $plan_summary) { + $sections[] = "Current plan:\n" . $plan_summary; + } + + $decision_summary = $this->summarize_context_items( + $session_context, + "decisions", + "Decisions", + ); + if ("" !== $decision_summary) { + $sections[] = $decision_summary; + } + + $rejection_summary = $this->summarize_context_items( + $session_context, + "rejections", + "Rejected directions", + ); + if ("" !== $rejection_summary) { + $sections[] = $rejection_summary; + } + + if (!empty($request_params["context"])) { + $sections[] = + "User supplied context:\n" . + $this->truncate_text((string) $request_params["context"], 1600); + } + + if (!empty($recent_messages)) { + $lines = []; + foreach ($recent_messages as $message) { + $lines[] = + ucfirst($message["role"]) . ": " . $message["content"]; + } + $sections[] = + "Recent saved conversation excerpts:\n" . implode("\n", $lines); + } + + return implode("\n\n", array_filter($sections)); + } + + /** + * Summarize post config. + * + * @param array $post_config Post config. + * @return string Summary. + */ + private function summarize_post_config($post_config) + { + $lines = []; + $keys = [ + "article_length" => "Article length", + "language" => "Language", + "tone" => "Tone", + "audience" => "Audience", + "experience_level" => "Experience level", + "seo_focus_keyword" => "SEO focus keyword", + "seo_secondary_keywords" => "SEO secondary keywords", + ]; + + foreach ($keys as $key => $label) { + if ( + isset($post_config[$key]) && + "" !== trim((string) $post_config[$key]) + ) { + $lines[] = + "- " . $label . ": " . trim((string) $post_config[$key]); + } + } + + if (isset($post_config["include_images"])) { + $lines[] = + "- Include images: " . + ($post_config["include_images"] ? "yes" : "no"); + } + + if (isset($post_config["web_search"])) { + $lines[] = + "- Web search: " . ($post_config["web_search"] ? "yes" : "no"); + } + + return implode("\n", $lines); + } + + /** + * Summarize current plan. + * + * @param array|null $plan Plan. + * @return string Summary. + */ + private function summarize_plan($plan) + { + if (empty($plan) || !is_array($plan)) { + return ""; + } + + $lines = []; + if (!empty($plan["title"])) { + $lines[] = "Title: " . $plan["title"]; + } + + if (!empty($plan["sections"]) && is_array($plan["sections"])) { + foreach ($plan["sections"] as $index => $section) { + $heading = $section["heading"] ?? ($section["title"] ?? ""); + if ("" === trim((string) $heading)) { + continue; + } + $status = $section["status"] ?? "pending"; + $lines[] = sprintf( + "%d. [%s] %s", + $index + 1, + $status, + $heading, + ); + } + } + + return implode("\n", $lines); + } + + /** + * Summarize context array items. + * + * @param array $session_context Session context. + * @param string $key Context key. + * @param string $label Label. + * @return string Summary. + */ + private function summarize_context_items($session_context, $key, $label) + { + if ( + empty($session_context[$key]) || + !is_array($session_context[$key]) + ) { + return ""; + } + + $lines = []; + foreach (array_slice($session_context[$key], -8) as $item) { + $summary = $item["summary"] ?? ""; + if ("" === trim((string) $summary)) { + continue; + } + $target = !empty($item["target"]) + ? "[" . $item["target"] . "] " + : ""; + $lines[] = "- " . $target . $summary; + } + + return empty($lines) ? "" : $label . ":\n" . implode("\n", $lines); + } + + /** + * Get active content from request params. + * + * @param array $request_params Request params. + * @return string Active content context. + */ + private function get_active_content($request_params) + { + $candidates = [ + "activeContent", + "blockContent", + "selectedText", + "sectionContent", + "articleContent", + ]; + + $lines = []; + foreach ($candidates as $key) { + if ( + !empty($request_params[$key]) && + is_string($request_params[$key]) + ) { + $lines[] = + $key . + ":\n" . + $this->truncate_text($request_params[$key], 2200); + } + } + + return empty($lines) + ? "" + : "ACTIVE CONTENT SLICE\n" . implode("\n\n", $lines); + } + + /** + * Build research context. + * + * @param array $session_context Session context. + * @param array $request_params Request params. + * @param int $limit Max snippets. + * @return string Research context. + */ + private function build_research_context( + $session_context, + $request_params, + $limit, + ) { + if ($limit <= 0) { + return ""; + } + + $items = []; + if ( + !empty($session_context["research_notes"]) && + is_array($session_context["research_notes"]) + ) { + $items = array_merge($items, $session_context["research_notes"]); + } + if ( + !empty($request_params["researchNotes"]) && + is_array($request_params["researchNotes"]) + ) { + $items = array_merge($items, $request_params["researchNotes"]); + } + + $items = array_slice($items, -1 * $limit); + $lines = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $title = $item["title"] ?? ($item["source"] ?? "Research note"); + $excerpt = $item["excerpt"] ?? ($item["notes"] ?? ""); + if ("" === trim((string) $excerpt)) { + continue; + } + $lines[] = + "- " . + $title . + ": " . + $this->truncate_text((string) $excerpt, 700); + } + + return empty($lines) + ? "" + : "RELEVANT RESEARCH\n" . implode("\n", $lines); + } + + /** + * Count included research items. + * + * @param array $session_context Session context. + * @param array $request_params Request params. + * @param int $limit Max snippets. + * @return int Count. + */ + private function count_research_items( + $session_context, + $request_params, + $limit, + ) { + if ($limit <= 0) { + return 0; + } + + $count = 0; + if ( + !empty($session_context["research_notes"]) && + is_array($session_context["research_notes"]) + ) { + $count += count($session_context["research_notes"]); + } + if ( + !empty($request_params["researchNotes"]) && + is_array($request_params["researchNotes"]) + ) { + $count += count($request_params["researchNotes"]); + } + + return min($limit, $count); + } + + /** + * Build MEMANTO persistent memory context section. + * + * Calls recall_for_context() via the MEMANTO Context Enhancer + * and formats the returned memories into a prompt-ready section. + * Returns empty string when MEMANTO is inactive or no memories found. + * + * @param int $post_id Post ID. + * @param string $current_message User's current message for semantic search. + * @return string Formatted memory section or empty string. + */ + private function build_memanto_context($post_id, $current_message = "") + { + // Guard: skip entirely if MEMANTO is not active. + $memanto = WP_Agentic_Writer_Memanto_Context_Enhancer::get_instance(); + $memories = $memanto->recall_for_context( + $post_id, + get_current_user_id(), + $current_message, + ); + + if (empty($memories) || !is_array($memories)) { + return ""; + } + + $lines = []; + foreach ($memories as $memory) { + $type = ucfirst($memory["type"] ?? "memory"); + $content = trim((string) ($memory["content"] ?? "")); + if ("" === $content) { + continue; + } + $title = !empty($memory["title"]) + ? " [" . trim($memory["title"]) . "]" + : ""; + $lines[] = "- ({$type}){$title} {$content}"; + } + + if (empty($lines)) { + return ""; + } + + return "PERSISTENT MEMORY (from MEMANTO)\n" . + "The following are memories recalled from prior sessions and interactions. " . + "Use them to maintain continuity, respect user preferences, and avoid repeating past mistakes.\n" . + implode("\n", $lines); + } + + /** + * Truncate text safely. + * + * @param string $text Text. + * @param int $limit Character limit. + * @return string Truncated text. + */ + private function truncate_text($text, $limit) + { + $text = trim((string) $text); + if (strlen($text) <= $limit) { + return $text; + } + + return substr($text, 0, $limit) . "..."; + } + + /** + * Estimate tokens from character length. + * + * @param string $text Text. + * @return int Estimated tokens. + */ + private function estimate_tokens($text) + { + return (int) ceil(strlen((string) $text) / 4); + } } diff --git a/includes/class-conversation-manager.php b/includes/class-conversation-manager.php index d4b67ae..65c9f6c 100644 --- a/includes/class-conversation-manager.php +++ b/includes/class-conversation-manager.php @@ -202,7 +202,7 @@ class WP_Agentic_Writer_Conversation_Manager { $session = $wpdb->get_row( $wpdb->prepare( - "SELECT * FROM {$this->table_name} WHERE post_id = %d AND status = 'active' ORDER BY updated_at DESC LIMIT 1", + "SELECT * FROM {$this->table_name} WHERE post_id = %d AND status != 'archived' ORDER BY updated_at DESC LIMIT 1", $post_id ), ARRAY_A diff --git a/includes/class-gutenberg-sidebar.php b/includes/class-gutenberg-sidebar.php index fc6eb2c..610d88f 100644 --- a/includes/class-gutenberg-sidebar.php +++ b/includes/class-gutenberg-sidebar.php @@ -7,8 +7,8 @@ * @package WP_Agentic_Writer */ -if ( ! defined( 'ABSPATH' ) ) { - exit; +if (!defined("ABSPATH")) { + exit(); } /** @@ -17,15 +17,16 @@ if ( ! defined( 'ABSPATH' ) ) { * @param string $message Log message. * @param mixed $data Optional data to log. */ -function wpaw_debug_log( $message, $data = null ) { - if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) { - $prefix = '[WPAW Debug] '; - if ( null === $data ) { - error_log( $prefix . $message ); - } else { - error_log( $prefix . $message . ' ' . wp_json_encode( $data ) ); - } - } +function wpaw_debug_log($message, $data = null) +{ + if (defined("SCRIPT_DEBUG") && SCRIPT_DEBUG) { + $prefix = "[WPAW Debug] "; + if (null === $data) { + error_log($prefix . $message); + } else { + error_log($prefix . $message . " " . wp_json_encode($data)); + } + } } /** @@ -33,2182 +34,2583 @@ function wpaw_debug_log( $message, $data = null ) { * * @since 0.1.0 */ -class WP_Agentic_Writer_Gutenberg_Sidebar { - - /** - * Get singleton instance. - * - * @since 0.1.0 - * @return WP_Agentic_Writer_Gutenberg_Sidebar - */ - public static function get_instance() { - static $instance = null; - - if ( null === $instance ) { - $instance = new self(); - } - - return $instance; - } - - /** - * Constructor. - * - * @since 0.1.0 - */ - private function __construct() { - add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_assets' ) ); - add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) ); - } - - /** - * Enqueue sidebar assets. - * - * @since 0.1.0 - */ - public function enqueue_assets() { - // Check if Gutenberg is available. - if ( ! function_exists( 'register_block_type' ) ) { - return; - } - - // Check if we're in the block editor. - $current_screen = get_current_screen(); - if ( ! $current_screen || ! $current_screen->is_block_editor ) { - return; - } - - // Build script URL. - $script_url = WP_AGENTIC_WRITER_URL . 'assets/js/sidebar.js'; - $style_url = WP_AGENTIC_WRITER_URL . 'assets/css/sidebar.css'; - $editor_style_url = WP_AGENTIC_WRITER_URL . 'assets/css/editor.css'; - $markdown_it_url = WP_AGENTIC_WRITER_URL . 'assets/js/vendor/markdown-it.min.js'; - $dompurify_url = WP_AGENTIC_WRITER_URL . 'assets/js/vendor/purify.min.js'; - $markdown_task_lists_url = WP_AGENTIC_WRITER_URL . 'assets/js/vendor/markdown-it-task-lists.min.js'; - - // Enqueue markdown renderer and sanitizer. - wp_enqueue_script( - 'wp-agentic-writer-markdown-it', - $markdown_it_url, - array(), - '13.0.2', - true - ); - wp_enqueue_script( - 'wp-agentic-writer-dompurify', - $dompurify_url, - array(), - '3.0.8', - true - ); - wp_enqueue_script( - 'wp-agentic-writer-markdown-task-lists', - $markdown_task_lists_url, - array( 'wp-agentic-writer-markdown-it' ), - '2.1.1', - true - ); - - // Enqueue utility functions (loaded before main sidebar). - $utils_script_path = WP_AGENTIC_WRITER_DIR . 'assets/js/sidebar-utils.js'; - wp_enqueue_script( - 'wp-agentic-writer-sidebar-utils', - WP_AGENTIC_WRITER_URL . 'assets/js/sidebar-utils.js', - array(), - file_exists( $utils_script_path ) ? filemtime( $utils_script_path ) : WP_AGENTIC_WRITER_VERSION, - true - ); - - - // Enqueue sidebar script. - $script_path = WP_AGENTIC_WRITER_DIR . 'assets/js/sidebar.js'; - wp_enqueue_script( - 'wp-agentic-writer-sidebar', - $script_url, - array( - 'wp-plugins', - 'wp-edit-post', - 'wp-element', - 'wp-components', - 'wp-compose', - 'wp-data', - 'wp-i18n', - 'wp-blocks', - 'wp-agentic-writer-markdown-it', - 'wp-agentic-writer-dompurify', - 'wp-agentic-writer-markdown-task-lists', - 'wp-agentic-writer-sidebar-utils', - ), - file_exists( $script_path ) ? filemtime( $script_path ) : WP_AGENTIC_WRITER_VERSION, - true - ); - - $block_toolbar_script_path = WP_AGENTIC_WRITER_DIR . 'assets/js/block-refine.js'; - wp_enqueue_script( - 'wp-agentic-writer-block-chat-mention', - WP_AGENTIC_WRITER_URL . 'assets/js/block-refine.js', - array( - 'wp-block-editor', - 'wp-components', - 'wp-compose', - 'wp-data', - 'wp-element', - 'wp-hooks', - 'wp-i18n', - ), - file_exists( $block_toolbar_script_path ) ? filemtime( $block_toolbar_script_path ) : WP_AGENTIC_WRITER_VERSION, - 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( - 'wp-agentic-writer-sidebar', - $style_url, - array(), - file_exists( $style_path ) ? filemtime( $style_path ) : WP_AGENTIC_WRITER_VERSION - ); - - // Enqueue agentic components styles. - $components_style_path = WP_AGENTIC_WRITER_DIR . 'assets/css/agentic-components.css'; - $components_style_url = WP_AGENTIC_WRITER_URL . 'assets/css/agentic-components.css'; - wp_enqueue_style( - 'wp-agentic-writer-components', - $components_style_url, - array(), - file_exists( $components_style_path ) ? filemtime( $components_style_path ) : WP_AGENTIC_WRITER_VERSION - ); - - // Enqueue workflow styles. - $workflow_style_path = WP_AGENTIC_WRITER_DIR . 'assets/css/agentic-workflow.css'; - $workflow_style_url = WP_AGENTIC_WRITER_URL . 'assets/css/agentic-workflow.css'; - wp_enqueue_style( - 'wp-agentic-writer-workflow', - $workflow_style_url, - array(), - file_exists( $workflow_style_path ) ? filemtime( $workflow_style_path ) : WP_AGENTIC_WRITER_VERSION - ); - - // Enqueue editor styles for image placeholders. - $editor_style_path = WP_AGENTIC_WRITER_DIR . 'assets/css/editor.css'; - wp_enqueue_style( - 'wp-agentic-writer-editor', - $editor_style_url, - array(), - file_exists( $editor_style_path ) ? filemtime( $editor_style_path ) : WP_AGENTIC_WRITER_VERSION - ); - - // Get current post ID. - $post_id = isset( $_GET['post'] ) ? intval( $_GET['post'] ) : 0; - if ( ! $post_id ) { - $post_id = get_the_ID(); - } - if ( ! $post_id ) { - $post_id = 0; - } - - // Get settings for JS. - $settings = $this->get_settings_for_js(); - - // Health check: verify DB table and API key exist - $health = $this->run_health_check(); - - // Localize script with data. - $data = array( - 'apiUrl' => rest_url( 'wp-agentic-writer/v1' ), - 'nonce' => wp_create_nonce( 'wp_rest' ), - 'postId' => $post_id, - 'settings' => $settings, - 'version' => WP_AGENTIC_WRITER_VERSION, - 'debug' => defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG, - 'pluginUrl' => plugin_dir_url( dirname( __FILE__ ) ), - 'health' => $health, - ); - - wp_localize_script( 'wp-agentic-writer-sidebar', 'wpAgenticWriter', $data ); - } - - /** - * Run health check for sidebar initialization. - * - * @since 0.2.4 - * @return array Health status. - */ - private function run_health_check() { - global $wpdb; - $table_name = $wpdb->prefix . 'wpaw_conversations'; - $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ) === $table_name; - - $settings = get_option( 'wp_agentic_writer_settings', array() ); - $has_api_key = ! empty( $settings['openrouter_api_key'] ); - - $issues = array(); - if ( ! $table_exists ) { - $issues[] = array( - 'type' => 'db_table_missing', - 'message' => 'Conversation table not found. Please deactivate and reactivate the plugin.', - ); - } - if ( ! $has_api_key ) { - $issues[] = array( - 'type' => 'no_api_key', - 'message' => 'API key not configured. Add your OpenRouter key in settings.', - 'actionUrl' => admin_url( 'options-general.php?page=wp-agentic-writer-settings' ), - 'actionLabel' => 'Open Settings', - ); - } - - return array( - 'ok' => empty( $issues ), - 'issues' => $issues, - ); - } - - /** - * Get settings for JavaScript. - * - * @since 0.1.0 - * @return array Settings. - */ - private function get_settings_for_js() { - $settings = get_option( 'wp_agentic_writer_settings', array() ); - - // Don't expose API key to frontend. - unset( $settings['openrouter_api_key'] ); - - // Ensure all required keys exist with defaults from model registry. - $defaults = array( - 'chat_model' => WPAW_Model_Registry::get_default_model( 'chat' ), - 'clarity_model' => WPAW_Model_Registry::get_default_model( 'clarity' ), - 'planning_model' => WPAW_Model_Registry::get_default_model( 'planning' ), - 'writing_model' => WPAW_Model_Registry::get_default_model( 'writing' ), - 'refinement_model' => WPAW_Model_Registry::get_default_model( 'refinement' ), - 'image_model' => WPAW_Model_Registry::get_default_model( 'image' ), - 'web_search_enabled' => false, - 'search_engine' => 'auto', - 'search_depth' => 'medium', - 'cost_tracking_enabled' => true, - 'monthly_budget' => 600, - 'settings_url' => admin_url( 'options-general.php?page=wp-agentic-writer-settings' ), - 'preferred_languages' => array( 'auto', 'English', 'Indonesian' ), - 'custom_languages' => array(), - ); - - return wp_parse_args( $settings, $defaults ); - } - - /** - * Register REST API routes. - * - * @since 0.1.0 - */ - public function register_rest_routes() { - // Get models endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/models', - array( - 'methods' => 'GET', - 'callback' => array( $this, 'handle_get_models' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Refresh models endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/models/refresh', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_refresh_models' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Chat endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/chat', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_chat_request' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Clear chat context endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/clear-context', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_clear_context' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - // Chat history endpoint (deprecated - for backward compatibility only). - register_rest_route( - 'wp-agentic-writer/v1', - '/chat-history/(?P\d+)', - array( - 'methods' => 'GET', - 'callback' => array( $this, 'handle_get_chat_history' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Conversation session endpoint (canonical for chat hydration). - register_rest_route( - 'wp-agentic-writer/v1', - '/conversation/(?P\d+)', - array( - 'methods' => 'GET', - 'callback' => array( $this, 'handle_get_conversation_by_post' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Post config endpoints. - register_rest_route( - 'wp-agentic-writer/v1', - '/post-config/(?P\d+)', - array( - 'methods' => 'GET', - 'callback' => array( $this, 'handle_get_post_config' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - register_rest_route( - 'wp-agentic-writer/v1', - '/post-config/(?P\d+)', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_update_post_config' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Generate plan endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/generate-plan', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_generate_plan' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - // Revise plan endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/revise-plan', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_revise_plan' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Execute article endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/execute-article', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_execute_article' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - // Reformat blocks endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/reformat-blocks', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_reformat_blocks' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Regenerate block endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/regenerate-block', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_regenerate_block' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Check clarity endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/check-clarity', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_check_clarity' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Block refine endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/refine-block', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_block_refine' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Chat-based block refinement endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/refine-from-chat', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_refine_from_chat' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Section block mapping endpoints. - register_rest_route( - 'wp-agentic-writer/v1', - '/section-blocks', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_save_section_blocks' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - register_rest_route( - 'wp-agentic-writer/v1', - '/section-blocks/(?P\d+)', - array( - 'methods' => 'GET', - 'callback' => array( $this, 'handle_get_section_blocks' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Get cost tracking data endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/cost-tracking/(?P\d+)', - array( - 'methods' => 'GET', - 'callback' => array( $this, 'handle_get_cost_tracking' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // SEO audit endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/seo-audit/(?P\d+)', - array( - 'methods' => 'GET', - 'callback' => array( $this, 'handle_seo_audit' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Generate meta description endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/generate-meta', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_generate_meta' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Suggest keywords endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/suggest-keywords', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_suggest_keywords' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Summarize context endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/summarize-context', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_summarize_context' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Multi-pass refinement endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/refine-multi-pass', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_refine_multi_pass' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Article-wide refinement endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/refine-article', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_refine_article' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // GEO scoring endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/geo-score/(?P\d+)', - array( - 'methods' => 'GET', - 'callback' => array( $this, 'handle_geo_score' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Proactive suggestions endpoint (idle analysis) - register_rest_route( - 'wp-agentic-writer/v1', - '/suggest-improvements', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_suggest_improvements' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Detect intent endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/detect-intent', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_detect_intent' ), - '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' ), - ) - ); - - // Writing state persistence endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/writing-state/(?P\d+)', - array( - 'methods' => 'GET', - 'callback' => array( $this, 'handle_get_writing_state' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - register_rest_route( - 'wp-agentic-writer/v1', - '/writing-state/(?P\d+)', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_save_writing_state' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Generate title endpoint (uses WP 7.0 AI Client when available). - register_rest_route( - 'wp-agentic-writer/v1', - '/generate-title', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_generate_title' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Refine title endpoint (instruction-driven rewrite from chat mention @title). - register_rest_route( - 'wp-agentic-writer/v1', - '/refine-title', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_refine_title' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Generate excerpt endpoint (uses WP 7.0 AI Client when available). - register_rest_route( - 'wp-agentic-writer/v1', - '/generate-excerpt', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_generate_excerpt' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // AI capabilities status endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/ai-capabilities', - array( - 'methods' => 'GET', - 'callback' => array( $this, 'handle_get_ai_capabilities' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Brave Search endpoint for research. - register_rest_route( - 'wp-agentic-writer/v1', - '/search', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_search' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Fetch web content endpoint for research. - register_rest_route( - 'wp-agentic-writer/v1', - '/fetch-content', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_fetch_content' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Research summary endpoint. - register_rest_route( - 'wp-agentic-writer/v1', - '/research-summary', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_research_summary' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Conversation sessions endpoints. - register_rest_route( - 'wp-agentic-writer/v1', - '/conversations', - array( - 'methods' => 'GET', - 'callback' => array( $this, 'handle_get_conversations' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - register_rest_route( - 'wp-agentic-writer/v1', - '/conversations/post/(?P\d+)', - array( - 'methods' => 'GET', - 'callback' => array( $this, 'handle_get_conversations' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - register_rest_route( - 'wp-agentic-writer/v1', - '/conversations', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_create_conversation' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - register_rest_route( - 'wp-agentic-writer/v1', - '/conversations/(?P[a-zA-Z0-9]+)', - array( - 'methods' => 'GET', - 'callback' => array( $this, 'handle_get_conversation' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - register_rest_route( - 'wp-agentic-writer/v1', - '/conversations/(?P[a-zA-Z0-9]+)', - array( - 'methods' => 'PUT', - 'callback' => array( $this, 'handle_update_conversation' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - register_rest_route( - 'wp-agentic-writer/v1', - '/conversations/(?P[a-zA-Z0-9]+)', - array( - 'methods' => 'DELETE', - 'callback' => array( $this, 'handle_delete_conversation' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - register_rest_route( - 'wp-agentic-writer/v1', - '/conversations/(?P[a-zA-Z0-9]+)/messages', - array( - 'methods' => array( 'POST', 'PUT' ), - 'callback' => array( $this, 'handle_update_conversation_messages' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - register_rest_route( - 'wp-agentic-writer/v1', - '/conversations/(?P[a-zA-Z0-9]+)/link-post', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_link_conversation_to_post' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // Legacy migration endpoint for converting post meta chat history to sessions - register_rest_route( - 'wp-agentic-writer/v1', - '/migrate-chat-history/(?P\d+)', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_migrate_chat_history' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - - // User preferences endpoints (per-user settings) - register_rest_route( - 'wp-agentic-writer/v1', - '/user-preferences', - array( - 'methods' => 'GET', - 'callback' => array( $this, 'handle_get_user_preferences' ), - 'permission_callback' => '__return_true', - ) - ); - register_rest_route( - 'wp-agentic-writer/v1', - '/user-preferences', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'handle_save_user_preferences' ), - 'permission_callback' => array( $this, 'check_permissions' ), - ) - ); - } - - /** - * Handle get writing state request. - * - * @since 0.2.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error - */ - public function handle_get_writing_state( $request ) { - $post_id = isset( $request['post_id'] ) ? (int) $request['post_id'] : 0; - if ( $post_id <= 0 ) { - return new WP_Error( - 'invalid_post', - __( 'Invalid post ID.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - // Authorization: Check if user can edit this specific post. - if ( ! current_user_can( 'edit_post', $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - $state = array( - 'status' => get_post_meta( $post_id, '_wpaw_writing_status', true ) ?: 'idle', - 'current_section_index' => (int) get_post_meta( $post_id, '_wpaw_current_section', true ) ?: 0, - 'sections_written' => get_post_meta( $post_id, '_wpaw_sections_written', true ) ?: array(), - 'last_updated' => get_post_meta( $post_id, '_wpaw_writing_state_updated', true ) ?: null, - 'plan_id' => get_post_meta( $post_id, '_wpaw_plan_id', true ) ?: null, - 'resume_token' => get_post_meta( $post_id, '_wpaw_resume_token', true ) ?: null, - ); - - return new WP_REST_Response( $state, 200 ); - } - - /** - * Handle save writing state request. - * - * @since 0.2.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error - */ - public function handle_save_writing_state( $request ) { - $post_id = isset( $request['post_id'] ) ? (int) $request['post_id'] : 0; - if ( $post_id <= 0 ) { - return new WP_Error( - 'invalid_post', - __( 'Invalid post ID.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - // Authorization: Check if user can edit this specific post. - if ( ! current_user_can( 'edit_post', $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to modify this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - $params = $request->get_json_params(); - - // Validate status against allowed values. - $allowed_statuses = array( 'idle', 'in_progress', 'paused', 'completed', 'failed' ); - $status = sanitize_text_field( $params['status'] ?? 'idle' ); - if ( ! in_array( $status, $allowed_statuses, true ) ) { - $status = 'idle'; - } - - // Save writing status - update_post_meta( $post_id, '_wpaw_writing_status', $status ); - - // Save current section index - $section_index = (int) ( $params['current_section_index'] ?? 0 ); - update_post_meta( $post_id, '_wpaw_current_section', $section_index ); - - // Save sections written array - $sections_written = is_array( $params['sections_written'] ?? null ) - ? array_map( 'sanitize_text_field', $params['sections_written'] ) - : array(); - update_post_meta( $post_id, '_wpaw_sections_written', $sections_written ); - - // Save plan ID - $plan_id = sanitize_text_field( $params['plan_id'] ?? '' ); - update_post_meta( $post_id, '_wpaw_plan_id', $plan_id ); - - // Save resume token - $resume_token = sanitize_text_field( $params['resume_token'] ?? '' ); - update_post_meta( $post_id, '_wpaw_resume_token', $resume_token ); - - // Update timestamp - update_post_meta( $post_id, '_wpaw_writing_state_updated', current_time( 'mysql' ) ); - - $state = array( - 'status' => $status, - 'current_section_index' => $section_index, - 'sections_written' => $sections_written, - 'last_updated' => current_time( 'mysql' ), - 'plan_id' => $plan_id, - ); - - return new WP_REST_Response( $state, 200 ); - } - - /** - * Check permissions. - * - * @since 0.1.0 - * @return bool True if user has permission. - */ - public function check_permissions() { - return current_user_can( 'edit_posts' ); - } - - /** - * Check post-specific edit permissions. - * - * @since 0.1.3 - * @param int $post_id Post ID to check. - * @return bool True if user can edit the post. - */ - public function check_post_permission( $post_id ) { - if ( $post_id <= 0 ) { - return false; - } - return current_user_can( 'edit_post', $post_id ); - } - - /** - * Resolve session ID from request, or auto-create a post-linked session. - * - * @param string $session_id Existing session ID from request. - * @param int $post_id Post ID. - * @return string - */ - private function resolve_or_create_session_id( $session_id, $post_id ) { - $session_id = sanitize_text_field( (string) $session_id ); - if ( '' !== $session_id ) { - return $session_id; - } - - $post_id = (int) $post_id; - $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); - if ( $post_id <= 0 ) { - $created_unassigned = $manager->create_session( - array( - 'post_id' => 0, - 'title' => 'Unassigned Session - ' . current_time( 'Y-m-d H:i' ), - ) - ); - return is_wp_error( $created_unassigned ) ? '' : (string) $created_unassigned; - } - - $existing = $manager->get_session_by_post_id( $post_id ); - if ( $existing && ! empty( $existing['session_id'] ) ) { - return (string) $existing['session_id']; - } - - $created = $manager->create_session( - array( - 'post_id' => $post_id, - 'title' => 'Post ' . $post_id . ' Session', - ) - ); - return is_wp_error( $created ) ? '' : (string) $created; - } - - /** - * Build provider metadata for responses. - * - * @since 0.1.4 - * @param WPAW_Provider_Selection_Result $provider_result Provider selection result. - * @param string $model Model identifier used. - * @return array Provider metadata. - */ - private function build_provider_metadata( $provider_result, $model = '' ) { - $actual_provider = $provider_result->actual_provider ?? 'unknown'; - - return array( - 'provider' => $actual_provider, - 'selected_provider' => $provider_result->selected_provider ?? $actual_provider, - 'fallback_used' => ! empty( $provider_result->fallback_used ), - 'warnings' => $provider_result->warnings ?? array(), - 'model' => $model, - 'byok_managed_by' => 'openrouter' === $actual_provider ? 'openrouter' : '', - ); - } - - /** - * Get a provider model label without assuming provider-specific helpers exist. - * - * @since 0.2.2 - * @param object $provider Provider instance. - * @param string $fallback Fallback model label. - * @return string - */ - private function get_provider_execution_model( $provider, $fallback = 'execution' ) { - if ( is_object( $provider ) && method_exists( $provider, 'get_execution_model' ) ) { - return (string) $provider->get_execution_model(); - } - - return $fallback; - } - - /** - * Track AI cost with full metadata. - * - * This helper ensures all cost tracking includes provider, session, and status - * metadata consistently. Use this instead of raw do_action calls. - * - * @since 0.2.0 - * @param int $post_id Post ID. - * @param string $model Model used. - * @param string $action Action type (chat, planning, execution, etc). - * @param int $input_tokens Input token count. - * @param int $output_tokens Output token count. - * @param float $cost Cost in USD. - * @param mixed $provider_result Provider selection result or provider name string. - * @param string $session_id Session ID (optional). - * @param string $status Status (success, error) (optional, defaults to 'success'). - */ - private function track_ai_cost( $post_id, $model, $action, $input_tokens, $output_tokens, $cost, $provider_result, $session_id = '', $status = 'success' ) { - // Handle both provider result objects and plain strings - if ( is_object( $provider_result ) && isset( $provider_result->actual_provider ) ) { - $actual_provider = $provider_result->actual_provider; - } elseif ( is_string( $provider_result ) ) { - $actual_provider = $provider_result; - } else { - $actual_provider = 'unknown'; - } - - do_action( - 'wp_aw_after_api_request', - $post_id, - $model, - $action, - $input_tokens, - $output_tokens, - $cost, - $actual_provider, - $session_id, - $status - ); - } - - /** - * Handle chat request. - * - * @since 0.1.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_chat_request( $request ) { - $params = $request->get_json_params(); - $messages = $params['messages'] ?? array(); - $post_id = $params['postId'] ?? 0; - $type = $params['type'] ?? 'planning'; - $stream = ! empty( $params['stream'] ); - $session_id = $this->resolve_or_create_session_id( $params['sessionId'] ?? '', $post_id ); - - // Check post permission if post_id is provided. - if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - $post_config = $this->resolve_post_config_from_request( $params, $post_id ); - $post_config_context = $this->build_post_config_context( $post_config ); - - // Detect language from user's last message for real-time response matching - $last_user_message = $this->get_last_user_message( $messages ); - $detected_from_message = $this->detect_language_from_text( $last_user_message ); - $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 = " +class WP_Agentic_Writer_Gutenberg_Sidebar +{ + /** + * Get singleton instance. + * + * @since 0.1.0 + * @return WP_Agentic_Writer_Gutenberg_Sidebar + */ + public static function get_instance() + { + static $instance = null; + + if (null === $instance) { + $instance = new self(); + } + + return $instance; + } + + /** + * Constructor. + * + * @since 0.1.0 + */ + private function __construct() + { + add_action("enqueue_block_editor_assets", [$this, "enqueue_assets"]); + add_action("rest_api_init", [$this, "register_rest_routes"]); + } + + /** + * Enqueue sidebar assets. + * + * @since 0.1.0 + */ + public function enqueue_assets() + { + // Check if Gutenberg is available. + if (!function_exists("register_block_type")) { + return; + } + + // Check if we're in the block editor. + $current_screen = get_current_screen(); + if (!$current_screen || !$current_screen->is_block_editor) { + return; + } + + // Build script URL. + $script_url = WP_AGENTIC_WRITER_URL . "assets/js/sidebar.js"; + $style_url = WP_AGENTIC_WRITER_URL . "assets/css/sidebar.css"; + $editor_style_url = WP_AGENTIC_WRITER_URL . "assets/css/editor.css"; + $markdown_it_url = + WP_AGENTIC_WRITER_URL . "assets/js/vendor/markdown-it.min.js"; + $dompurify_url = + WP_AGENTIC_WRITER_URL . "assets/js/vendor/purify.min.js"; + $markdown_task_lists_url = + WP_AGENTIC_WRITER_URL . + "assets/js/vendor/markdown-it-task-lists.min.js"; + + // Enqueue markdown renderer and sanitizer. + wp_enqueue_script( + "wp-agentic-writer-markdown-it", + $markdown_it_url, + [], + "13.0.2", + true, + ); + wp_enqueue_script( + "wp-agentic-writer-dompurify", + $dompurify_url, + [], + "3.0.8", + true, + ); + wp_enqueue_script( + "wp-agentic-writer-markdown-task-lists", + $markdown_task_lists_url, + ["wp-agentic-writer-markdown-it"], + "2.1.1", + true, + ); + + // Enqueue utility functions (loaded before main sidebar). + $utils_script_path = + WP_AGENTIC_WRITER_DIR . "assets/js/sidebar-utils.js"; + wp_enqueue_script( + "wp-agentic-writer-sidebar-utils", + WP_AGENTIC_WRITER_URL . "assets/js/sidebar-utils.js", + [], + file_exists($utils_script_path) + ? filemtime($utils_script_path) + : WP_AGENTIC_WRITER_VERSION, + true, + ); + + // Enqueue sidebar script. + $script_path = WP_AGENTIC_WRITER_DIR . "assets/js/sidebar.js"; + wp_enqueue_script( + "wp-agentic-writer-sidebar", + $script_url, + [ + "wp-plugins", + "wp-edit-post", + "wp-element", + "wp-components", + "wp-compose", + "wp-data", + "wp-i18n", + "wp-blocks", + "wp-agentic-writer-markdown-it", + "wp-agentic-writer-dompurify", + "wp-agentic-writer-markdown-task-lists", + "wp-agentic-writer-sidebar-utils", + ], + file_exists($script_path) + ? filemtime($script_path) + : WP_AGENTIC_WRITER_VERSION, + true, + ); + + $block_toolbar_script_path = + WP_AGENTIC_WRITER_DIR . "assets/js/block-refine.js"; + wp_enqueue_script( + "wp-agentic-writer-block-chat-mention", + WP_AGENTIC_WRITER_URL . "assets/js/block-refine.js", + [ + "wp-block-editor", + "wp-components", + "wp-compose", + "wp-data", + "wp-element", + "wp-hooks", + "wp-i18n", + ], + file_exists($block_toolbar_script_path) + ? filemtime($block_toolbar_script_path) + : WP_AGENTIC_WRITER_VERSION, + 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", + [ + "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", + ["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( + "wp-agentic-writer-sidebar", + $style_url, + [], + file_exists($style_path) + ? filemtime($style_path) + : WP_AGENTIC_WRITER_VERSION, + ); + + // Enqueue agentic components styles. + $components_style_path = + WP_AGENTIC_WRITER_DIR . "assets/css/agentic-components.css"; + $components_style_url = + WP_AGENTIC_WRITER_URL . "assets/css/agentic-components.css"; + wp_enqueue_style( + "wp-agentic-writer-components", + $components_style_url, + [], + file_exists($components_style_path) + ? filemtime($components_style_path) + : WP_AGENTIC_WRITER_VERSION, + ); + + // Enqueue workflow styles. + $workflow_style_path = + WP_AGENTIC_WRITER_DIR . "assets/css/agentic-workflow.css"; + $workflow_style_url = + WP_AGENTIC_WRITER_URL . "assets/css/agentic-workflow.css"; + wp_enqueue_style( + "wp-agentic-writer-workflow", + $workflow_style_url, + [], + file_exists($workflow_style_path) + ? filemtime($workflow_style_path) + : WP_AGENTIC_WRITER_VERSION, + ); + + // Enqueue editor styles for image placeholders. + $editor_style_path = WP_AGENTIC_WRITER_DIR . "assets/css/editor.css"; + wp_enqueue_style( + "wp-agentic-writer-editor", + $editor_style_url, + [], + file_exists($editor_style_path) + ? filemtime($editor_style_path) + : WP_AGENTIC_WRITER_VERSION, + ); + + // Get current post ID. + $post_id = isset($_GET["post"]) ? intval($_GET["post"]) : 0; + if (!$post_id) { + $post_id = get_the_ID(); + } + if (!$post_id) { + $post_id = 0; + } + + // Get settings for JS. + $settings = $this->get_settings_for_js(); + + // Health check: verify DB table and API key exist + $health = $this->run_health_check(); + + // Localize script with data. + $data = [ + "apiUrl" => rest_url("wp-agentic-writer/v1"), + "nonce" => wp_create_nonce("wp_rest"), + "postId" => $post_id, + "settings" => $settings, + "version" => WP_AGENTIC_WRITER_VERSION, + "debug" => defined("SCRIPT_DEBUG") && SCRIPT_DEBUG, + "pluginUrl" => plugin_dir_url(dirname(__FILE__)), + "health" => $health, + ]; + + wp_localize_script( + "wp-agentic-writer-sidebar", + "wpAgenticWriter", + $data, + ); + } + + /** + * Run health check for sidebar initialization. + * + * @since 0.2.4 + * @return array Health status. + */ + private function run_health_check() + { + global $wpdb; + $table_name = $wpdb->prefix . "wpaw_conversations"; + $table_exists = + $wpdb->get_var( + $wpdb->prepare("SHOW TABLES LIKE %s", $table_name), + ) === $table_name; + + $settings = get_option("wp_agentic_writer_settings", []); + $has_api_key = !empty($settings["openrouter_api_key"]); + + $issues = []; + if (!$table_exists) { + $issues[] = [ + "type" => "db_table_missing", + "message" => + "Conversation table not found. Please deactivate and reactivate the plugin.", + ]; + } + if (!$has_api_key) { + $issues[] = [ + "type" => "no_api_key", + "message" => + "API key not configured. Add your OpenRouter key in settings.", + "actionUrl" => admin_url( + "options-general.php?page=wp-agentic-writer-settings", + ), + "actionLabel" => "Open Settings", + ]; + } + + return [ + "ok" => empty($issues), + "issues" => $issues, + ]; + } + + /** + * Get settings for JavaScript. + * + * @since 0.1.0 + * @return array Settings. + */ + private function get_settings_for_js() + { + $settings = get_option("wp_agentic_writer_settings", []); + + // Don't expose API key to frontend. + unset($settings["openrouter_api_key"]); + + // Ensure all required keys exist with defaults from model registry. + $defaults = [ + "chat_model" => WPAW_Model_Registry::get_default_model("chat"), + "clarity_model" => WPAW_Model_Registry::get_default_model( + "clarity", + ), + "planning_model" => WPAW_Model_Registry::get_default_model( + "planning", + ), + "writing_model" => WPAW_Model_Registry::get_default_model( + "writing", + ), + "refinement_model" => WPAW_Model_Registry::get_default_model( + "refinement", + ), + "image_model" => WPAW_Model_Registry::get_default_model("image"), + "web_search_enabled" => false, + "search_engine" => "auto", + "search_depth" => "medium", + "cost_tracking_enabled" => true, + "monthly_budget" => 600, + "settings_url" => admin_url( + "options-general.php?page=wp-agentic-writer-settings", + ), + "preferred_languages" => ["auto", "English", "Indonesian"], + "custom_languages" => [], + ]; + + return wp_parse_args($settings, $defaults); + } + + /** + * Register REST API routes. + * + * @since 0.1.0 + */ + public function register_rest_routes() + { + // Get models endpoint. + register_rest_route("wp-agentic-writer/v1", "/models", [ + "methods" => "GET", + "callback" => [$this, "handle_get_models"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // Refresh models endpoint. + register_rest_route("wp-agentic-writer/v1", "/models/refresh", [ + "methods" => "POST", + "callback" => [$this, "handle_refresh_models"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // Chat endpoint. + register_rest_route("wp-agentic-writer/v1", "/chat", [ + "methods" => "POST", + "callback" => [$this, "handle_chat_request"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // Clear chat context endpoint. + register_rest_route("wp-agentic-writer/v1", "/clear-context", [ + "methods" => "POST", + "callback" => [$this, "handle_clear_context"], + "permission_callback" => [$this, "check_permissions"], + ]); + // Chat history endpoint (deprecated - for backward compatibility only). + register_rest_route( + "wp-agentic-writer/v1", + "/chat-history/(?P\d+)", + [ + "methods" => "GET", + "callback" => [$this, "handle_get_chat_history"], + "permission_callback" => [$this, "check_permissions"], + ], + ); + + // Conversation session endpoint (canonical for chat hydration). + register_rest_route( + "wp-agentic-writer/v1", + "/conversation/(?P\d+)", + [ + "methods" => "GET", + "callback" => [$this, "handle_get_conversation_by_post"], + "permission_callback" => [$this, "check_permissions"], + ], + ); + + // Post config endpoints. + register_rest_route( + "wp-agentic-writer/v1", + "/post-config/(?P\d+)", + [ + "methods" => "GET", + "callback" => [$this, "handle_get_post_config"], + "permission_callback" => [$this, "check_permissions"], + ], + ); + register_rest_route( + "wp-agentic-writer/v1", + "/post-config/(?P\d+)", + [ + "methods" => "POST", + "callback" => [$this, "handle_update_post_config"], + "permission_callback" => [$this, "check_permissions"], + ], + ); + + // Generate plan endpoint. + register_rest_route("wp-agentic-writer/v1", "/generate-plan", [ + "methods" => "POST", + "callback" => [$this, "handle_generate_plan"], + "permission_callback" => [$this, "check_permissions"], + ]); + // Revise plan endpoint. + register_rest_route("wp-agentic-writer/v1", "/revise-plan", [ + "methods" => "POST", + "callback" => [$this, "handle_revise_plan"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // Execute article endpoint. + register_rest_route("wp-agentic-writer/v1", "/execute-article", [ + "methods" => "POST", + "callback" => [$this, "handle_execute_article"], + "permission_callback" => [$this, "check_permissions"], + ]); + // Reformat blocks endpoint. + register_rest_route("wp-agentic-writer/v1", "/reformat-blocks", [ + "methods" => "POST", + "callback" => [$this, "handle_reformat_blocks"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // Regenerate block endpoint. + register_rest_route("wp-agentic-writer/v1", "/regenerate-block", [ + "methods" => "POST", + "callback" => [$this, "handle_regenerate_block"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // Check clarity endpoint. + register_rest_route("wp-agentic-writer/v1", "/check-clarity", [ + "methods" => "POST", + "callback" => [$this, "handle_check_clarity"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // Block refine endpoint. + register_rest_route("wp-agentic-writer/v1", "/refine-block", [ + "methods" => "POST", + "callback" => [$this, "handle_block_refine"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // Chat-based block refinement endpoint. + register_rest_route("wp-agentic-writer/v1", "/refine-from-chat", [ + "methods" => "POST", + "callback" => [$this, "handle_refine_from_chat"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // Section block mapping endpoints. + register_rest_route("wp-agentic-writer/v1", "/section-blocks", [ + "methods" => "POST", + "callback" => [$this, "handle_save_section_blocks"], + "permission_callback" => [$this, "check_permissions"], + ]); + register_rest_route( + "wp-agentic-writer/v1", + "/section-blocks/(?P\d+)", + [ + "methods" => "GET", + "callback" => [$this, "handle_get_section_blocks"], + "permission_callback" => [$this, "check_permissions"], + ], + ); + + // Get cost tracking data endpoint. + register_rest_route( + "wp-agentic-writer/v1", + "/cost-tracking/(?P\d+)", + [ + "methods" => "GET", + "callback" => [$this, "handle_get_cost_tracking"], + "permission_callback" => [$this, "check_permissions"], + ], + ); + + // SEO audit endpoint. + register_rest_route( + "wp-agentic-writer/v1", + "/seo-audit/(?P\d+)", + [ + "methods" => "GET", + "callback" => [$this, "handle_seo_audit"], + "permission_callback" => [$this, "check_permissions"], + ], + ); + + // Generate meta description endpoint. + register_rest_route("wp-agentic-writer/v1", "/generate-meta", [ + "methods" => "POST", + "callback" => [$this, "handle_generate_meta"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // Suggest keywords endpoint. + register_rest_route("wp-agentic-writer/v1", "/suggest-keywords", [ + "methods" => "POST", + "callback" => [$this, "handle_suggest_keywords"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // Summarize context endpoint. + register_rest_route("wp-agentic-writer/v1", "/summarize-context", [ + "methods" => "POST", + "callback" => [$this, "handle_summarize_context"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // Multi-pass refinement endpoint. + register_rest_route("wp-agentic-writer/v1", "/refine-multi-pass", [ + "methods" => "POST", + "callback" => [$this, "handle_refine_multi_pass"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // Article-wide refinement endpoint. + register_rest_route("wp-agentic-writer/v1", "/refine-article", [ + "methods" => "POST", + "callback" => [$this, "handle_refine_article"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // GEO scoring endpoint. + register_rest_route( + "wp-agentic-writer/v1", + "/geo-score/(?P\d+)", + [ + "methods" => "GET", + "callback" => [$this, "handle_geo_score"], + "permission_callback" => [$this, "check_permissions"], + ], + ); + + // Proactive suggestions endpoint (idle analysis) + register_rest_route("wp-agentic-writer/v1", "/suggest-improvements", [ + "methods" => "POST", + "callback" => [$this, "handle_suggest_improvements"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // Detect intent endpoint. + register_rest_route("wp-agentic-writer/v1", "/detect-intent", [ + "methods" => "POST", + "callback" => [$this, "handle_detect_intent"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // Image generation endpoints. + register_rest_route( + "wp-agentic-writer/v1", + "/image-recommendations/(?P\d+)", + [ + "methods" => "GET", + "callback" => [$this, "handle_get_image_recommendations"], + "permission_callback" => [$this, "check_permissions"], + ], + ); + + register_rest_route("wp-agentic-writer/v1", "/generate-image", [ + "methods" => "POST", + "callback" => [$this, "handle_generate_image"], + "permission_callback" => [$this, "check_permissions"], + ]); + + register_rest_route("wp-agentic-writer/v1", "/commit-image", [ + "methods" => "POST", + "callback" => [$this, "handle_commit_image"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // Writing state persistence endpoint. + register_rest_route( + "wp-agentic-writer/v1", + "/writing-state/(?P\d+)", + [ + "methods" => "GET", + "callback" => [$this, "handle_get_writing_state"], + "permission_callback" => [$this, "check_permissions"], + ], + ); + register_rest_route( + "wp-agentic-writer/v1", + "/writing-state/(?P\d+)", + [ + "methods" => "POST", + "callback" => [$this, "handle_save_writing_state"], + "permission_callback" => [$this, "check_permissions"], + ], + ); + + // Generate title endpoint (uses WP 7.0 AI Client when available). + register_rest_route("wp-agentic-writer/v1", "/generate-title", [ + "methods" => "POST", + "callback" => [$this, "handle_generate_title"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // Refine title endpoint (instruction-driven rewrite from chat mention @title). + register_rest_route("wp-agentic-writer/v1", "/refine-title", [ + "methods" => "POST", + "callback" => [$this, "handle_refine_title"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // Generate excerpt endpoint (uses WP 7.0 AI Client when available). + register_rest_route("wp-agentic-writer/v1", "/generate-excerpt", [ + "methods" => "POST", + "callback" => [$this, "handle_generate_excerpt"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // AI capabilities status endpoint. + register_rest_route("wp-agentic-writer/v1", "/ai-capabilities", [ + "methods" => "GET", + "callback" => [$this, "handle_get_ai_capabilities"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // Brave Search endpoint for research. + register_rest_route("wp-agentic-writer/v1", "/search", [ + "methods" => "POST", + "callback" => [$this, "handle_search"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // Fetch web content endpoint for research. + register_rest_route("wp-agentic-writer/v1", "/fetch-content", [ + "methods" => "POST", + "callback" => [$this, "handle_fetch_content"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // Research summary endpoint. + register_rest_route("wp-agentic-writer/v1", "/research-summary", [ + "methods" => "POST", + "callback" => [$this, "handle_research_summary"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // MEMANTO status endpoint. + register_rest_route("wp-agentic-writer/v1", "/memanto/status", [ + "methods" => "GET", + "callback" => [$this, "handle_memanto_status"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // MEMANTO recall endpoint — recent memories for a post. + register_rest_route("wp-agentic-writer/v1", "/memanto/recall", [ + "methods" => "GET", + "callback" => [$this, "handle_memanto_recall"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // MEMANTO session restore endpoint — for cross-session restore on editor load. + register_rest_route("wp-agentic-writer/v1", "/memanto/restore", [ + "methods" => "GET", + "callback" => [$this, "handle_memanto_restore"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // MEMANTO user preferences endpoint — for new post config carry-over. + register_rest_route("wp-agentic-writer/v1", "/memanto/preferences", [ + "methods" => "GET", + "callback" => [$this, "handle_memanto_preferences"], + "permission_callback" => [$this, "check_permissions"], + ]); + + // Conversation sessions endpoints. + register_rest_route("wp-agentic-writer/v1", "/conversations", [ + "methods" => "GET", + "callback" => [$this, "handle_get_conversations"], + "permission_callback" => [$this, "check_permissions"], + ]); + register_rest_route( + "wp-agentic-writer/v1", + "/conversations/post/(?P\d+)", + [ + "methods" => "GET", + "callback" => [$this, "handle_get_conversations"], + "permission_callback" => [$this, "check_permissions"], + ], + ); + register_rest_route("wp-agentic-writer/v1", "/conversations", [ + "methods" => "POST", + "callback" => [$this, "handle_create_conversation"], + "permission_callback" => [$this, "check_permissions"], + ]); + register_rest_route( + "wp-agentic-writer/v1", + "/conversations/(?P[a-zA-Z0-9]+)", + [ + "methods" => "GET", + "callback" => [$this, "handle_get_conversation"], + "permission_callback" => [$this, "check_permissions"], + ], + ); + register_rest_route( + "wp-agentic-writer/v1", + "/conversations/(?P[a-zA-Z0-9]+)", + [ + "methods" => "PUT", + "callback" => [$this, "handle_update_conversation"], + "permission_callback" => [$this, "check_permissions"], + ], + ); + register_rest_route( + "wp-agentic-writer/v1", + "/conversations/(?P[a-zA-Z0-9]+)", + [ + "methods" => "DELETE", + "callback" => [$this, "handle_delete_conversation"], + "permission_callback" => [$this, "check_permissions"], + ], + ); + register_rest_route( + "wp-agentic-writer/v1", + "/conversations/(?P[a-zA-Z0-9]+)/messages", + [ + "methods" => ["POST", "PUT"], + "callback" => [$this, "handle_update_conversation_messages"], + "permission_callback" => [$this, "check_permissions"], + ], + ); + register_rest_route( + "wp-agentic-writer/v1", + "/conversations/(?P[a-zA-Z0-9]+)/link-post", + [ + "methods" => "POST", + "callback" => [$this, "handle_link_conversation_to_post"], + "permission_callback" => [$this, "check_permissions"], + ], + ); + + // Legacy migration endpoint for converting post meta chat history to sessions + register_rest_route( + "wp-agentic-writer/v1", + "/migrate-chat-history/(?P\d+)", + [ + "methods" => "POST", + "callback" => [$this, "handle_migrate_chat_history"], + "permission_callback" => [$this, "check_permissions"], + ], + ); + + // User preferences endpoints (per-user settings) + register_rest_route("wp-agentic-writer/v1", "/user-preferences", [ + "methods" => "GET", + "callback" => [$this, "handle_get_user_preferences"], + "permission_callback" => "__return_true", + ]); + register_rest_route("wp-agentic-writer/v1", "/user-preferences", [ + "methods" => "POST", + "callback" => [$this, "handle_save_user_preferences"], + "permission_callback" => [$this, "check_permissions"], + ]); + } + + /** + * Handle get writing state request. + * + * @since 0.2.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_get_writing_state($request) + { + $post_id = isset($request["post_id"]) ? (int) $request["post_id"] : 0; + if ($post_id <= 0) { + return new WP_Error( + "invalid_post", + __("Invalid post ID.", "wp-agentic-writer"), + ["status" => 400], + ); + } + + // Authorization: Check if user can edit this specific post. + if (!current_user_can("edit_post", $post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to access this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $state = [ + "status" => + get_post_meta($post_id, "_wpaw_writing_status", true) ?: "idle", + "current_section_index" => + (int) get_post_meta($post_id, "_wpaw_current_section", true) ?: + 0, + "sections_written" => + get_post_meta($post_id, "_wpaw_sections_written", true) ?: [], + "last_updated" => + get_post_meta($post_id, "_wpaw_writing_state_updated", true) ?: + null, + "plan_id" => get_post_meta($post_id, "_wpaw_plan_id", true) ?: null, + "resume_token" => + get_post_meta($post_id, "_wpaw_resume_token", true) ?: null, + ]; + + return new WP_REST_Response($state, 200); + } + + /** + * Handle save writing state request. + * + * @since 0.2.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_save_writing_state($request) + { + $post_id = isset($request["post_id"]) ? (int) $request["post_id"] : 0; + if ($post_id <= 0) { + return new WP_Error( + "invalid_post", + __("Invalid post ID.", "wp-agentic-writer"), + ["status" => 400], + ); + } + + // Authorization: Check if user can edit this specific post. + if (!current_user_can("edit_post", $post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to modify this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $params = $request->get_json_params(); + + // Validate status against allowed values. + $allowed_statuses = [ + "idle", + "in_progress", + "paused", + "completed", + "failed", + ]; + $status = sanitize_text_field($params["status"] ?? "idle"); + if (!in_array($status, $allowed_statuses, true)) { + $status = "idle"; + } + + // Save writing status + update_post_meta($post_id, "_wpaw_writing_status", $status); + + // Save current section index + $section_index = (int) ($params["current_section_index"] ?? 0); + update_post_meta($post_id, "_wpaw_current_section", $section_index); + + // Save sections written array + $sections_written = is_array($params["sections_written"] ?? null) + ? array_map("sanitize_text_field", $params["sections_written"]) + : []; + update_post_meta($post_id, "_wpaw_sections_written", $sections_written); + + // Save plan ID + $plan_id = sanitize_text_field($params["plan_id"] ?? ""); + update_post_meta($post_id, "_wpaw_plan_id", $plan_id); + + // Save resume token + $resume_token = sanitize_text_field($params["resume_token"] ?? ""); + update_post_meta($post_id, "_wpaw_resume_token", $resume_token); + + // Update timestamp + update_post_meta( + $post_id, + "_wpaw_writing_state_updated", + current_time("mysql"), + ); + + $state = [ + "status" => $status, + "current_section_index" => $section_index, + "sections_written" => $sections_written, + "last_updated" => current_time("mysql"), + "plan_id" => $plan_id, + ]; + + return new WP_REST_Response($state, 200); + } + + /** + * Check permissions. + * + * @since 0.1.0 + * @return bool True if user has permission. + */ + public function check_permissions() + { + return current_user_can("edit_posts"); + } + + /** + * Check post-specific edit permissions. + * + * @since 0.1.3 + * @param int $post_id Post ID to check. + * @return bool True if user can edit the post. + */ + public function check_post_permission($post_id) + { + if ($post_id <= 0) { + return false; + } + return current_user_can("edit_post", $post_id); + } + + /** + * Resolve session ID from request, or auto-create a post-linked session. + * + * @param string $session_id Existing session ID from request. + * @param int $post_id Post ID. + * @return string + */ + private function resolve_or_create_session_id($session_id, $post_id) + { + $session_id = sanitize_text_field((string) $session_id); + if ("" !== $session_id) { + return $session_id; + } + + $post_id = (int) $post_id; + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + if ($post_id <= 0) { + $created_unassigned = $manager->create_session([ + "post_id" => 0, + "title" => "Unassigned Session - " . current_time("Y-m-d H:i"), + ]); + return is_wp_error($created_unassigned) + ? "" + : (string) $created_unassigned; + } + + $existing = $manager->get_session_by_post_id($post_id); + if ($existing && !empty($existing["session_id"])) { + return (string) $existing["session_id"]; + } + + $created = $manager->create_session([ + "post_id" => $post_id, + "title" => "Post " . $post_id . " Session", + ]); + return is_wp_error($created) ? "" : (string) $created; + } + + /** + * Build provider metadata for responses. + * + * @since 0.1.4 + * @param WPAW_Provider_Selection_Result $provider_result Provider selection result. + * @param string $model Model identifier used. + * @return array Provider metadata. + */ + private function build_provider_metadata($provider_result, $model = "") + { + $actual_provider = $provider_result->actual_provider ?? "unknown"; + + return [ + "provider" => $actual_provider, + "selected_provider" => + $provider_result->selected_provider ?? $actual_provider, + "fallback_used" => !empty($provider_result->fallback_used), + "warnings" => $provider_result->warnings ?? [], + "model" => $model, + "byok_managed_by" => + "openrouter" === $actual_provider ? "openrouter" : "", + ]; + } + + /** + * Get a provider model label without assuming provider-specific helpers exist. + * + * @since 0.2.2 + * @param object $provider Provider instance. + * @param string $fallback Fallback model label. + * @return string + */ + private function get_provider_execution_model( + $provider, + $fallback = "execution", + ) { + if ( + is_object($provider) && + method_exists($provider, "get_execution_model") + ) { + return (string) $provider->get_execution_model(); + } + + return $fallback; + } + + /** + * Track AI cost with full metadata. + * + * This helper ensures all cost tracking includes provider, session, and status + * metadata consistently. Use this instead of raw do_action calls. + * + * @since 0.2.0 + * @param int $post_id Post ID. + * @param string $model Model used. + * @param string $action Action type (chat, planning, execution, etc). + * @param int $input_tokens Input token count. + * @param int $output_tokens Output token count. + * @param float $cost Cost in USD. + * @param mixed $provider_result Provider selection result or provider name string. + * @param string $session_id Session ID (optional). + * @param string $status Status (success, error) (optional, defaults to 'success'). + */ + private function track_ai_cost( + $post_id, + $model, + $action, + $input_tokens, + $output_tokens, + $cost, + $provider_result, + $session_id = "", + $status = "success", + ) { + // Handle both provider result objects and plain strings + if ( + is_object($provider_result) && + isset($provider_result->actual_provider) + ) { + $actual_provider = $provider_result->actual_provider; + } elseif (is_string($provider_result)) { + $actual_provider = $provider_result; + } else { + $actual_provider = "unknown"; + } + + do_action( + "wp_aw_after_api_request", + $post_id, + $model, + $action, + $input_tokens, + $output_tokens, + $cost, + $actual_provider, + $session_id, + $status, + ); + } + + /** + * Handle chat request. + * + * @since 0.1.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_chat_request($request) + { + $params = $request->get_json_params(); + $messages = $params["messages"] ?? []; + $post_id = $params["postId"] ?? 0; + $type = $params["type"] ?? "planning"; + $stream = !empty($params["stream"]); + $session_id = $this->resolve_or_create_session_id( + $params["sessionId"] ?? "", + $post_id, + ); + + // Check post permission if post_id is provided. + if ($post_id > 0 && !$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to access this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $post_config = $this->resolve_post_config_from_request( + $params, + $post_id, + ); + $post_config_context = $this->build_post_config_context($post_config); + + // Detect language from user's last message for real-time response matching + $last_user_message = $this->get_last_user_message($messages); + $detected_from_message = $this->detect_language_from_text( + $last_user_message, + ); + $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. + $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}"; - $context_builder = WP_Agentic_Writer_Context_Builder::get_instance(); - $context_package = $context_builder->build_system_message( - 'chat', - $session_id, - $post_id, - array_merge( - $params, - array( - 'messages' => $messages, - 'postConfig' => $post_config, - 'latestUserMessage' => $last_user_message, - ) - ) - ); - - // OpenRouter is stateless; send only compact saved context plus the latest turn. - $messages = array(); - if ( '' !== trim( (string) $last_user_message ) ) { - $messages[] = array( - 'role' => 'user', - 'content' => $last_user_message, - ); - } - - $messages = $this->prepend_system_prompt( $messages, $system_prompt ); - if ( ! empty( $context_package['message'] ) ) { - array_splice( $messages, 1, 0, array( $context_package['message'] ) ); - } - - // Get provider for this task type with selection metadata. - $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( $type ); - $provider = $provider_result->provider; - $provider_warnings = $provider_result->warnings; - - if ( $stream ) { - $web_search_options = $this->get_web_search_options( $post_config ); - $this->stream_chat_request( $messages, $post_id, $type, $web_search_options, $session_id ); - exit; - } - - // Send chat request. - $response = $provider->chat( $messages, array(), $type ); - - if ( is_wp_error( $response ) ) { - return new WP_Error( - 'chat_error', - $response->get_error_message(), - array( 'status' => 500 ) - ); - } - - // Track cost with provider and session metadata. - $this->track_ai_cost( - $post_id, - $response['model'] ?? '', - 'chat', - $response['input_tokens'] ?? 0, - $response['output_tokens'] ?? 0, - $response['cost'] ?? 0, - $provider_result, - $session_id, - 'success' - ); - - // Include provider metadata in response (DoD Provider Transparency contract). - $response['provider'] = $provider_result->actual_provider; - $response['selected_provider'] = $provider_result->selected_provider; - $response['fallback_used'] = $provider_result->fallback_used; - $response['warnings'] = $provider_warnings; - $response['session_id'] = $session_id; - $response['context_audit'] = $context_package['audit'] ?? array(); - // Also include nested form for consistency with other AI endpoints - $response['provider_metadata'] = $this->build_provider_metadata( $provider_result, $response['model'] ?? '' ); - - if ( ! empty( $response['content'] ) ) { - // Storage: Persist to session table via Context Service only. - // Legacy _wpaw_chat_history post meta is deprecated and no longer written. - if ( ! empty( $session_id ) ) { - $context_service = WP_Agentic_Writer_Context_Service::get_instance(); - $context_service->add_message( - $session_id, - array( - 'role' => 'user', - 'content' => $last_user_message, - 'timestamp' => current_time( 'c' ), - ) - ); - $context_service->add_message( - $session_id, - array( - 'role' => 'assistant', - 'content' => $response['content'], - 'timestamp' => current_time( 'c' ), - ) - ); - } - } - - return new WP_REST_Response( $response, 200 ); - } - - /** - * Stream chat request response. - * - * @since 0.1.0 - * @param array $messages Chat messages. - * @param int $post_id Post ID. - * @param string $type Chat type. - * @param array $web_search_options Web search options. - * @param string $session_id Session ID for context persistence. - * @return void - */ - private function stream_chat_request( $messages, $post_id, $type, $web_search_options = array(), $session_id = '' ) { - header( 'Content-Type: text/event-stream' ); - header( 'Cache-Control: no-cache' ); - header( 'X-Accel-Buffering: no' ); - - // 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(); - - // Initialize streaming state variables. - $accumulated_content = ''; - $chunks_emitted = 0; - $total_cost = 0; - $last_user_message = $this->get_last_user_message( $messages ); - - // Get provider with selection metadata for transparency. - $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( $type ); - $provider = $provider_result->provider; - $provider_warnings = $provider_result->warnings; - - echo "data: " . wp_json_encode( - array( - 'type' => 'provider', - 'provider' => $provider_result->actual_provider, - 'selectedProvider' => $provider_result->selected_provider, - 'fallback_used' => $provider_result->fallback_used, - 'byok_managed_by' => 'openrouter' === $provider_result->actual_provider ? 'openrouter' : '', - ) - ) . "\n\n"; - flush(); - - $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, &$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( - 'type' => 'error', - 'message' => $response->get_error_message(), - ) - ) . "\n\n"; - flush(); - exit; - } - - $total_cost = $response['cost'] ?? 0; - - // Debug: Log chat cost tracking (only when WP_DEBUG is on) - wpaw_debug_log( 'Tracking chat cost', array( - 'post_id' => $post_id, - 'model' => $response['model'] ?? 'unknown', - 'type' => $type, - 'cost' => $total_cost - ) ); - - // Track cost with provider and session metadata. - $this->track_ai_cost( - $post_id, - $response['model'] ?? '', - 'chat', - $response['input_tokens'] ?? 0, - $response['output_tokens'] ?? 0, - $total_cost, - $provider_result, - $session_id, - 'success' - ); - - if ( ! empty( $accumulated_content ) ) { - echo "data: " . wp_json_encode( - array( - 'type' => 'conversational', - 'content' => $accumulated_content, - ) - ) . "\n\n"; - flush(); - - // Storage: Persist to session table via Context Service only. - // Legacy _wpaw_chat_history post meta is deprecated and no longer written. - if ( ! empty( $session_id ) ) { - $context_service = WP_Agentic_Writer_Context_Service::get_instance(); - $context_service->add_message( - $session_id, - array( - 'role' => 'user', - 'content' => $last_user_message, - 'timestamp' => current_time( 'c' ), - ) - ); - $context_service->add_message( - $session_id, - array( - 'role' => 'assistant', - 'content' => $accumulated_content, - 'timestamp' => current_time( 'c' ), - ) - ); - } - } - - // Send provider transparency metadata in completion event. - echo "data: " . wp_json_encode( - array( - 'type' => 'complete', - 'totalCost' => $total_cost, - 'session_id' => $session_id, - 'provider' => $provider_result->actual_provider, - 'fallback_used' => $provider_result->fallback_used, - 'warnings' => $provider_warnings, - ) - ) . "\n\n"; - flush(); - } - - /** - * Clear chat context for a post. - * - * @since 0.1.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_clear_context( $request ) { - $params = $request->get_json_params(); - $post_id = intval( $params['postId'] ?? 0 ); - $session_id = sanitize_text_field( $params['sessionId'] ?? '' ); - - if ( $post_id <= 0 ) { - return new WP_Error( - 'invalid_post', - __( 'Invalid post ID.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - // Check post permission before clearing context. - if ( ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - // Use the context service to clear the session and post meta consistently. - $this->context_service->clear_context( $session_id, $post_id ); - - return new WP_REST_Response( - array( - 'success' => true, - ), - 200 - ); - } - - /** - * Get chat history for a post (deprecated compatibility endpoint). - * - * @since 0.1.0 - * @deprecated 0.2.0 Use /wp-agentic-writer/v1/conversation/{post_id} instead. - * This endpoint reads from conversation sessions via migration. - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_get_chat_history( $request ) { - $post_id = intval( $request['post_id'] ?? 0 ); - if ( $post_id <= 0 ) { - return new WP_Error( - 'invalid_post', - __( 'Invalid post ID.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - if ( ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - $history = $this->get_post_chat_history( $post_id ); - return new WP_REST_Response( - array( - 'messages' => $history, - 'deprecated' => true, - 'message' => 'This endpoint is deprecated. Use conversation sessions instead.', - ), - 200 - ); - } - - /** - * Handle get conversation by post ID request (canonical endpoint). - * - * @since 0.2.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_get_conversation_by_post( $request ) { - $post_id = intval( $request['post_id'] ?? 0 ); - if ( $post_id <= 0 ) { - return new WP_Error( - 'invalid_post', - __( 'Invalid post ID.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - if ( ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); - $session = $manager->get_session_by_post_id( $post_id ); - - if ( ! $session ) { - // Check for legacy post-meta chat history and migrate if present. - $legacy_history = get_post_meta( $post_id, '_wpaw_chat_history', true ); - if ( ! empty( $legacy_history ) && is_array( $legacy_history ) ) { - $context_service = WP_Agentic_Writer_Context_Service::get_instance(); - $migrated_session_id = $context_service->migrate_legacy_chat_history( $post_id ); - - // Fetch the newly created session after migration. - $session = $manager->get_session_by_post_id( $post_id ); - if ( $session ) { - return new WP_REST_Response( - array( - 'messages' => $session['messages'], - 'has_session' => true, - 'session_id' => $session['session_id'], - 'post_id' => $session['post_id'], - 'migrated' => true, - 'deprecated' => false, - ), - 200 - ); - } - } - - return new WP_REST_Response( - array( - 'messages' => array(), - 'has_session' => false, - ), - 200 - ); - } - - return new WP_REST_Response( - array( - 'messages' => $session['messages'], - 'has_session' => true, - 'session_id' => $session['session_id'], - 'post_id' => $session['post_id'], - 'deprecated' => false, - ), - 200 - ); - } - - /** - * Update per-post chat history. - * - * @since 0.1.0 - * @deprecated 0.1.4 Use conversation sessions instead. This method no longer writes - * to post meta; it exists only for backward compatibility. - * @param int $post_id Post ID. - * @param string $user_message User message. - * @param string $assistant_message Assistant message. - * @return void - */ - private function update_post_chat_history( $post_id, $user_message, $assistant_message ) { - // Deprecated - now only used for migration reads. Do not write. - // New code should use conversation sessions. - return; - } - - /** - * Get per-post chat history. - * - * @since 0.1.0 - * @deprecated 0.1.4 Use conversation sessions instead. - * @param int $post_id Post ID. - * @return array - */ - private function get_post_chat_history( $post_id ) { - if ( $post_id <= 0 ) { - return array(); - } - - $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); - $sessions = $manager->get_sessions_for_post( $post_id ); - - // If we have active sessions, return messages from the most recent one - if ( ! empty( $sessions ) ) { - // Sort by last activity, most recent first - usort( $sessions, function( $a, $b ) { - return strtotime( $b['last_activity'] ?? '' ) - strtotime( $a['last_activity'] ?? '' ); - } ); - - $active_session = $sessions[0]; - $context_service = WP_Agentic_Writer_Context_Service::get_instance(); - $context = $context_service->get_context( $active_session['session_id'], $post_id ); - return $context['messages'] ?? array(); - } - - // No sessions found - check for legacy history and migrate - $history = get_post_meta( $post_id, '_wpaw_chat_history', true ); - if ( ! is_array( $history ) || empty( $history ) ) { - return array(); - } - - // Legacy data exists - trigger migration - $context_service = WP_Agentic_Writer_Context_Service::get_instance(); - $migrated_session_id = $context_service->migrate_legacy_chat_history( $post_id ); - - // Return migrated data using the returned session id - if ( ! empty( $migrated_session_id ) ) { - $context = $context_service->get_context( $migrated_session_id, $post_id ); - return $context['messages'] ?? array(); - } - - return array(); - } - - /** - * Get default per-post configuration values. - * - * @since 0.1.0 - * @return array - */ - private function get_default_post_config() { - $settings = get_option( 'wp_agentic_writer_settings', array() ); - - return array( - 'article_length' => 'medium', - 'language' => 'auto', - 'tone' => '', - 'audience' => '', - 'experience_level'=> 'general', - 'include_images' => true, - 'web_search' => isset( $settings['web_search_enabled'] ) && '1' === $settings['web_search_enabled'], - 'default_mode' => 'writing', - // SEO fields - 'focus_keyword' => '', - 'seo_focus_keyword' => '', - 'seo_secondary_keywords' => '', - 'seo_meta_description' => '', - 'seo_enabled' => true, - ); - } - - /** - * Sanitize post config input. - * - * @since 0.1.0 - * @param array $config Post config. - * @return array - */ - private function sanitize_post_config( $config ) { - $defaults = $this->get_default_post_config(); - $config = is_array( $config ) ? $config : array(); - $sanitized = array(); - - $allowed_lengths = array( 'short', 'medium', 'long' ); - $length = $config['article_length'] ?? $defaults['article_length']; - $sanitized['article_length'] = in_array( $length, $allowed_lengths, true ) ? $length : $defaults['article_length']; - - // Validate language - normalize to lowercase for comparison - $settings = get_option( 'wp_agentic_writer_settings', array() ); - $allowed_languages = array_merge( - $settings['preferred_languages'] ?? array( 'auto', 'English', 'Indonesian' ), - $settings['custom_languages'] ?? array() - ); - // Normalize allowed languages to lowercase - $allowed_languages_lower = array_map( 'strtolower', $allowed_languages ); - $language = strtolower( $config['language'] ?? $defaults['language'] ); - $sanitized['language'] = in_array( $language, $allowed_languages_lower, true ) ? $language : 'auto'; - - $sanitized['tone'] = sanitize_text_field( $config['tone'] ?? $defaults['tone'] ); - $sanitized['audience'] = sanitize_text_field( $config['audience'] ?? $defaults['audience'] ); - $sanitized['experience_level'] = sanitize_text_field( $config['experience_level'] ?? $defaults['experience_level'] ); - - $sanitized['include_images'] = isset( $config['include_images'] ) - ? (bool) $config['include_images'] - : (bool) $defaults['include_images']; - $sanitized['web_search'] = isset( $config['web_search'] ) - ? (bool) $config['web_search'] - : (bool) $defaults['web_search']; - - $allowed_modes = array( 'writing', 'planning', 'chat' ); - $mode = $config['default_mode'] ?? $defaults['default_mode']; - $sanitized['default_mode'] = in_array( $mode, $allowed_modes, true ) ? $mode : $defaults['default_mode']; - - // SEO fields - $sanitized['seo_focus_keyword'] = sanitize_text_field( $config['seo_focus_keyword'] ?? $defaults['seo_focus_keyword'] ); - $sanitized['focus_keyword'] = sanitize_text_field( $config['focus_keyword'] ?? $defaults['focus_keyword'] ); - if ( '' === $sanitized['focus_keyword'] && '' !== $sanitized['seo_focus_keyword'] ) { - $sanitized['focus_keyword'] = $sanitized['seo_focus_keyword']; - } - if ( '' === $sanitized['seo_focus_keyword'] && '' !== $sanitized['focus_keyword'] ) { - $sanitized['seo_focus_keyword'] = $sanitized['focus_keyword']; - } - $sanitized['seo_secondary_keywords'] = sanitize_text_field( $config['seo_secondary_keywords'] ?? $defaults['seo_secondary_keywords'] ); - $sanitized['seo_meta_description'] = sanitize_textarea_field( $config['seo_meta_description'] ?? $defaults['seo_meta_description'] ); - $sanitized['seo_enabled'] = isset( $config['seo_enabled'] ) - ? (bool) $config['seo_enabled'] - : (bool) $defaults['seo_enabled']; - - return $sanitized; - } - - /** - * Get post config (merged with defaults). - * - * @since 0.1.0 - * @param int $post_id Post ID. - * @return array - */ - private function get_post_config( $post_id ) { - $defaults = $this->get_default_post_config(); - if ( $post_id <= 0 ) { - return $defaults; - } - - $stored = get_post_meta( $post_id, '_wpaw_post_config', true ); - $stored = is_array( $stored ) ? $stored : array(); - - return $this->sanitize_post_config( wp_parse_args( $stored, $defaults ) ); - } - - /** - * Resolve post config from request, falling back to stored config. - * - * @since 0.1.0 - * @param array $params Request params. - * @param int $post_id Post ID. - * @return array - */ - private function resolve_post_config_from_request( $params, $post_id ) { - if ( isset( $params['postConfig'] ) && is_array( $params['postConfig'] ) ) { - $merged = wp_parse_args( $params['postConfig'], $this->get_post_config( $post_id ) ); - return $this->sanitize_post_config( $merged ); - } - - return $this->get_post_config( $post_id ); - } - - /** - * Build a short configuration context string for prompts. - * - * @since 0.1.0 - * @param array $post_config Post config. - * @return string - */ - private function build_post_config_context( $post_config ) { - $lines = array(); - if ( ! empty( $post_config['tone'] ) ) { - $lines[] = 'Tone: ' . $post_config['tone']; - } - if ( ! empty( $post_config['audience'] ) ) { - $lines[] = 'Target audience: ' . $post_config['audience']; - } - if ( ! empty( $post_config['experience_level'] ) && 'general' !== $post_config['experience_level'] ) { - $lines[] = 'Expertise level: ' . $post_config['experience_level']; - } - - // Add SEO context if enabled - $seo_context = $this->build_seo_context( $post_config ); - - if ( empty( $lines ) && empty( $seo_context ) ) { - return ''; - } - - $result = ''; - if ( ! empty( $lines ) ) { - $result .= "\nPOST CONFIG:\n- " . implode( "\n- ", $lines ) . "\n"; - } - if ( ! empty( $seo_context ) ) { - $result .= $seo_context; - } - - return $result; - } - - /** - * Build SEO context for prompts. - * - * @since 0.1.0 - * @param array $post_config Post config. - * @return string SEO context string. - */ - private function build_seo_context( $post_config ) { - if ( empty( $post_config['seo_enabled'] ) ) { - return ''; - } - - $seo_lines = array(); - - if ( ! empty( $post_config['seo_focus_keyword'] ) ) { - $seo_lines[] = 'Focus keyword: "' . $post_config['seo_focus_keyword'] . '" - Include this keyword naturally in: title, first paragraph, at least 2-3 subheadings, and throughout the content (aim for 1-2% density)'; - } - - if ( ! empty( $post_config['seo_secondary_keywords'] ) ) { - $seo_lines[] = 'Secondary keywords: ' . $post_config['seo_secondary_keywords'] . ' - Sprinkle these throughout the content naturally'; - } - - if ( empty( $seo_lines ) ) { - return ''; - } - - return "\nSEO OPTIMIZATION:\n- " . implode( "\n- ", $seo_lines ) . "\n- Use descriptive, keyword-rich subheadings (H2, H3)\n- Write compelling meta-description-worthy opening paragraph\n- Include internal linking opportunities where relevant\n"; - } - - /** - * Detect language from text using common word patterns. - * - * @since 0.1.0 - * @param string $text Text to analyze. - * @return string Detected language code. - */ - private function detect_language_from_text( $text ) { - $text = strtolower( $text ); - - // Indonesian indicators - $indonesian_words = array( 'yang', 'dan', 'untuk', 'dengan', 'ini', 'itu', 'dari', 'ke', 'di', 'pada', 'adalah', 'akan', 'sudah', 'bisa', 'harus', 'tidak', 'juga', 'atau', 'saya', 'apa', 'bagaimana', 'mengapa', 'kenapa', 'gimana', 'tolong', 'mohon', 'silakan', 'terima', 'kasih', 'selamat', 'pagi', 'siang', 'malam', 'artikel', 'tentang', 'topik', 'pembahasan', 'cara', 'membuat', 'menulis' ); - $indonesian_count = 0; - foreach ( $indonesian_words as $word ) { - if ( preg_match( '/\b' . preg_quote( $word, '/' ) . '\b/u', $text ) ) { - $indonesian_count++; - } - } - - // Spanish indicators - $spanish_words = array( 'que', 'de', 'no', 'es', 'el', 'la', 'los', 'las', 'un', 'una', 'por', 'con', 'para', 'como', 'pero', 'más', 'este', 'esta', 'todo', 'también', 'puede', 'hacer', 'tiene', 'cuando', 'sobre', 'entre', 'después', 'antes', 'porque', 'cómo', 'qué', 'cuál' ); - $spanish_count = 0; - foreach ( $spanish_words as $word ) { - if ( preg_match( '/\b' . preg_quote( $word, '/' ) . '\b/u', $text ) ) { - $spanish_count++; - } - } - - // French indicators - $french_words = array( 'le', 'la', 'les', 'de', 'du', 'des', 'un', 'une', 'et', 'est', 'que', 'qui', 'dans', 'pour', 'pas', 'sur', 'avec', 'ce', 'cette', 'sont', 'être', 'avoir', 'faire', 'comme', 'mais', 'ou', 'où', 'plus', 'tout', 'bien', 'aussi', 'peut', 'très', 'comment', 'pourquoi', 'quoi' ); - $french_count = 0; - foreach ( $french_words as $word ) { - if ( preg_match( '/\b' . preg_quote( $word, '/' ) . '\b/u', $text ) ) { - $french_count++; - } - } - - // Determine language with threshold - $threshold = 2; - if ( $indonesian_count >= $threshold && $indonesian_count > $spanish_count && $indonesian_count > $french_count ) { - return 'indonesian'; - } - if ( $spanish_count >= $threshold && $spanish_count > $indonesian_count && $spanish_count > $french_count ) { - return 'spanish'; - } - if ( $french_count >= $threshold && $french_count > $indonesian_count && $french_count > $spanish_count ) { - return 'french'; - } - - // Return empty string instead of 'auto' to allow fallback to stored language - return ''; - } - - /** - * Resolve effective language preference. - * - * @since 0.1.0 - * @param array $post_config Post config. - * @param string $fallback Language to fall back to. - * @return string - */ - private function resolve_language_preference( $post_config, $fallback ) { - $language = strtolower( (string) ( $post_config['language'] ?? 'auto' ) ); - if ( 'auto' !== $language && '' !== $language ) { - return $language; - } - - // If fallback is provided and not empty, use it - if ( ! empty( $fallback ) && 'auto' !== strtolower( $fallback ) ) { - return strtolower( $fallback ); - } - - // Default to 'auto' instead of 'english' to let AI detect from context - return 'auto'; - } - - /** - * Build language instruction for prompts. - * - * @since 0.1.0 - * @param string $language Language code. - * @param string $context Context label. - * @return string - */ - private function build_language_instruction( $language, $context = 'content' ) { - $language = trim( (string) $language ); - - // If auto or empty, let AI detect from context - if ( empty( $language ) || 'auto' === strtolower( $language ) ) { - 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 - return "You MUST write the {$context} in {$language}. Use native {$language} vocabulary, grammar, and style."; - } - - /** - * Prepend a system prompt to messages. - * - * @since 0.1.0 - * @param array $messages Messages list. - * @param string $prompt System prompt. - * @return array - */ - private function prepend_system_prompt( $messages, $prompt ) { - if ( empty( $prompt ) ) { - return $messages; - } - - $messages = is_array( $messages ) ? $messages : array(); - array_unshift( - $messages, - array( - 'role' => 'system', - 'content' => $prompt, - ) - ); - - 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; - } - - // Check if Brave API key is configured - $settings = get_option( 'wp_agentic_writer_settings', array() ); - $brave_api_key = $settings['brave_search_api_key'] ?? ''; - - // Determine search strategy: - // 1. If Brave API key is set -> Use Brave (regardless of provider) - // 2. If using OpenRouter without Brave key -> Let OpenRouter's online models handle it - // 3. If using Local Backend without Brave key -> No search available - - if ( empty( $brave_api_key ) && $provider instanceof WP_Agentic_Writer_OpenRouter_Provider ) { - // No Brave API key with OpenRouter - let the model's built-in search handle it - // OpenRouter's online models (e.g., gemini-2.5-flash-online) have search tools built-in - return; - } - - if ( empty( $brave_api_key ) ) { - // Local Backend or other providers without Brave API key - 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. - * - * @since 0.1.0 - * @param array $post_config Post config. - * @return array - */ - private function get_web_search_options( $post_config ) { - $settings = get_option( 'wp_agentic_writer_settings', array() ); - - return array( - 'web_search_enabled' => isset( $post_config['web_search'] ) ? (bool) $post_config['web_search'] : false, - 'search_depth' => $settings['search_depth'] ?? 'medium', - 'search_engine' => $settings['search_engine'] ?? 'auto', - ); - } - - /** - * Handle get post config request. - * - * @since 0.1.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error - */ - public function handle_get_post_config( $request ) { - $post_id = isset( $request['post_id'] ) ? (int) $request['post_id'] : 0; - if ( $post_id <= 0 ) { - return new WP_Error( - 'invalid_post', - __( 'Invalid post ID.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - if ( ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - return new WP_REST_Response( $this->get_post_config( $post_id ), 200 ); - } - - /** - * Handle update post config request. - * - * @since 0.1.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error - */ - public function handle_update_post_config( $request ) { - $post_id = isset( $request['post_id'] ) ? (int) $request['post_id'] : 0; - if ( $post_id <= 0 ) { - return new WP_Error( - 'invalid_post', - __( 'Invalid post ID.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - if ( ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - $params = $request->get_json_params(); - $config = $this->sanitize_post_config( $params['postConfig'] ?? array() ); - update_post_meta( $post_id, '_wpaw_post_config', $config ); - - return new WP_REST_Response( $config, 200 ); - } - - /** - * Get the last user message from a message list. - * - * @since 0.1.0 - * @param array $messages Message list. - * @return string - */ - private function get_last_user_message( $messages ) { - if ( empty( $messages ) || ! is_array( $messages ) ) { - return ''; - } - - for ( $i = count( $messages ) - 1; $i >= 0; $i-- ) { - $message = $messages[ $i ]; - if ( isset( $message['role'] ) && 'user' === $message['role'] && ! empty( $message['content'] ) ) { - return sanitize_text_field( $message['content'] ); - } - } - - return ''; - } - - /** - * Handle generate plan request. - * - * @since 0.1.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_generate_plan( $request ) { - $params = $request->get_json_params(); - $topic = $params['topic'] ?? ''; - $context = $params['context'] ?? ''; - $post_id = $params['postId'] ?? 0; - $session_id = $this->resolve_or_create_session_id( $params['sessionId'] ?? '', $post_id ); - $auto_execute = $params['autoExecute'] ?? false; - $stream = $params['stream'] ?? false; - $chat_history = $params['chatHistory'] ?? array(); - $post_config = $this->resolve_post_config_from_request( $params, $post_id ); - $article_length = $post_config['article_length'] ?? ( $params['articleLength'] ?? 'medium' ); - $clarification_answers = $params['clarificationAnswers'] ?? array(); // Get clarification answers - $detected_language = $params['detectedLanguage'] ?? 'english'; // Get detected language from clarity check - $effective_language = $this->resolve_language_preference( $post_config, $detected_language ); - $post_config_context = $this->build_post_config_context( $post_config ); - $web_search_options = $this->get_web_search_options( $post_config ); - - if ( empty( $topic ) ) { - return new WP_Error( - 'no_topic', - __( 'Topic is required.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - // Check post permission if post_id is provided. - if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - $context_builder = WP_Agentic_Writer_Context_Builder::get_instance(); - $context_package = $context_builder->build_for_task( - 'planning', - $session_id, - $post_id, - array_merge( - $params, - array( - 'chatHistory' => $chat_history, - 'postConfig' => $post_config, - ) - ) - ); - - // If streaming is requested, use streaming response. - if ( $stream ) { - return $this->stream_generate_plan( $topic, $context, $post_id, $auto_execute, $article_length, $clarification_answers, $effective_language, $post_config, $chat_history, $session_id ); - } - - // Get provider for planning task. - $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' ); - $provider = $provider_result->provider; - - // 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 Information Architect and SEO/GEO Strategist. Your task is to outline a high-information-density article based on the user's topic and context. + $context_builder = WP_Agentic_Writer_Context_Builder::get_instance(); + $context_package = $context_builder->build_system_message( + "chat", + $session_id, + $post_id, + array_merge($params, [ + "messages" => $messages, + "postConfig" => $post_config, + "latestUserMessage" => $last_user_message, + ]), + ); + + // OpenRouter is stateless; send only compact saved context plus the latest turn. + $messages = []; + if ("" !== trim((string) $last_user_message)) { + $messages[] = [ + "role" => "user", + "content" => $last_user_message, + ]; + } + + $messages = $this->prepend_system_prompt($messages, $system_prompt); + if (!empty($context_package["message"])) { + array_splice($messages, 1, 0, [$context_package["message"]]); + } + + // Get provider for this task type with selection metadata. + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( + $type, + ); + $provider = $provider_result->provider; + $provider_warnings = $provider_result->warnings; + + if ($stream) { + $web_search_options = $this->get_web_search_options($post_config); + $this->stream_chat_request( + $messages, + $post_id, + $type, + $web_search_options, + $session_id, + ); + exit(); + } + + // Send chat request. + $response = $provider->chat($messages, [], $type); + + if (is_wp_error($response)) { + return new WP_Error("chat_error", $response->get_error_message(), [ + "status" => 500, + ]); + } + + // MEMANTO: Remember user message (fire-and-forget, never blocks). + do_action( + "wpaw_memanto_user_message", + $session_id, + $last_user_message, + $post_id, + ); + + // Track cost with provider and session metadata. + $this->track_ai_cost( + $post_id, + $response["model"] ?? "", + "chat", + $response["input_tokens"] ?? 0, + $response["output_tokens"] ?? 0, + $response["cost"] ?? 0, + $provider_result, + $session_id, + "success", + ); + + // Include provider metadata in response (DoD Provider Transparency contract). + $response["provider"] = $provider_result->actual_provider; + $response["selected_provider"] = $provider_result->selected_provider; + $response["fallback_used"] = $provider_result->fallback_used; + $response["warnings"] = $provider_warnings; + $response["session_id"] = $session_id; + $response["context_audit"] = $context_package["audit"] ?? []; + // Also include nested form for consistency with other AI endpoints + $response["provider_metadata"] = $this->build_provider_metadata( + $provider_result, + $response["model"] ?? "", + ); + + if (!empty($response["content"])) { + // Storage: Persist to session table via Context Service only. + // Legacy _wpaw_chat_history post meta is deprecated and no longer written. + if (!empty($session_id)) { + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $context_service->add_message($session_id, [ + "role" => "user", + "content" => $last_user_message, + "timestamp" => current_time("c"), + ]); + $context_service->add_message($session_id, [ + "role" => "assistant", + "content" => $response["content"], + "timestamp" => current_time("c"), + ]); + } + } + + // MEMANTO: Fire session start on first chat interaction. + do_action( + "wpaw_memanto_session_start", + $session_id, + $post_id, + get_current_user_id(), + ); + + return new WP_REST_Response($response, 200); + } + + /** + * Stream chat request response. + * + * @since 0.1.0 + * @param array $messages Chat messages. + * @param int $post_id Post ID. + * @param string $type Chat type. + * @param array $web_search_options Web search options. + * @param string $session_id Session ID for context persistence. + * @return void + */ + private function stream_chat_request( + $messages, + $post_id, + $type, + $web_search_options = [], + $session_id = "", + ) { + header("Content-Type: text/event-stream"); + header("Cache-Control: no-cache"); + header("X-Accel-Buffering: no"); + + // 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(); + + // Initialize streaming state variables. + $accumulated_content = ""; + $chunks_emitted = 0; + $total_cost = 0; + $last_user_message = $this->get_last_user_message($messages); + + // MEMANTO: Notify session start. + do_action( + "wpaw_memanto_session_start", + $session_id, + $post_id, + get_current_user_id(), + ); + + // Get provider with selection metadata for transparency. + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( + $type, + ); + $provider = $provider_result->provider; + $provider_warnings = $provider_result->warnings; + + echo "data: " . + wp_json_encode([ + "type" => "provider", + "provider" => $provider_result->actual_provider, + "selectedProvider" => $provider_result->selected_provider, + "fallback_used" => $provider_result->fallback_used, + "byok_managed_by" => + "openrouter" === $provider_result->actual_provider + ? "openrouter" + : "", + ]) . + "\n\n"; + flush(); + + $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, + &$chunks_emitted, + ) { + $accumulated_content = $full_content; + if ("" !== $chunk) { + $chunks_emitted++; + echo "data: " . + wp_json_encode([ + "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([ + "type" => "conversational_stream", + "content" => $accumulated_content, + ]) . + "\n\n"; + flush(); + } + + if (is_wp_error($response)) { + echo "data: " . + wp_json_encode([ + "type" => "error", + "message" => $response->get_error_message(), + ]) . + "\n\n"; + flush(); + exit(); + } + + $total_cost = $response["cost"] ?? 0; + + // Debug: Log chat cost tracking (only when WP_DEBUG is on) + wpaw_debug_log("Tracking chat cost", [ + "post_id" => $post_id, + "model" => $response["model"] ?? "unknown", + "type" => $type, + "cost" => $total_cost, + ]); + + // Track cost with provider and session metadata. + $this->track_ai_cost( + $post_id, + $response["model"] ?? "", + "chat", + $response["input_tokens"] ?? 0, + $response["output_tokens"] ?? 0, + $total_cost, + $provider_result, + $session_id, + "success", + ); + + if (!empty($accumulated_content)) { + echo "data: " . + wp_json_encode([ + "type" => "conversational", + "content" => $accumulated_content, + ]) . + "\n\n"; + flush(); + + // Storage: Persist to session table via Context Service only. + // Legacy _wpaw_chat_history post meta is deprecated and no longer written. + if (!empty($session_id)) { + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $context_service->add_message($session_id, [ + "role" => "user", + "content" => $last_user_message, + "timestamp" => current_time("c"), + ]); + $context_service->add_message($session_id, [ + "role" => "assistant", + "content" => $accumulated_content, + "timestamp" => current_time("c"), + ]); + } + + // MEMANTO: Remember user message from chat. + do_action( + "wpaw_memanto_user_message", + $session_id, + $last_user_message, + $post_id, + ); + } + + // Send provider transparency metadata in completion event. + echo "data: " . + wp_json_encode([ + "type" => "complete", + "totalCost" => $total_cost, + "session_id" => $session_id, + "provider" => $provider_result->actual_provider, + "fallback_used" => $provider_result->fallback_used, + "warnings" => $provider_warnings, + ]) . + "\n\n"; + flush(); + } + + /** + * Clear chat context for a post. + * + * @since 0.1.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_clear_context($request) + { + $params = $request->get_json_params(); + $post_id = intval($params["postId"] ?? 0); + $session_id = sanitize_text_field($params["sessionId"] ?? ""); + + if ($post_id <= 0) { + return new WP_Error( + "invalid_post", + __("Invalid post ID.", "wp-agentic-writer"), + ["status" => 400], + ); + } + + // Check post permission before clearing context. + if (!$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to edit this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + // Use the context service to clear the session and post meta consistently. + $this->context_service->clear_context($session_id, $post_id); + + // MEMANTO: Notify session end on context clear. + do_action("wpaw_memanto_session_end", $session_id, $post_id); + + return new WP_REST_Response( + [ + "success" => true, + ], + 200, + ); + } + + /** + * Get chat history for a post (deprecated compatibility endpoint). + * + * @since 0.1.0 + * @deprecated 0.2.0 Use /wp-agentic-writer/v1/conversation/{post_id} instead. + * This endpoint reads from conversation sessions via migration. + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_get_chat_history($request) + { + $post_id = intval($request["post_id"] ?? 0); + if ($post_id <= 0) { + return new WP_Error( + "invalid_post", + __("Invalid post ID.", "wp-agentic-writer"), + ["status" => 400], + ); + } + + if (!$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to access this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $history = $this->get_post_chat_history($post_id); + return new WP_REST_Response( + [ + "messages" => $history, + "deprecated" => true, + "message" => + "This endpoint is deprecated. Use conversation sessions instead.", + ], + 200, + ); + } + + /** + * Handle get conversation by post ID request (canonical endpoint). + * + * @since 0.2.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_get_conversation_by_post($request) + { + $post_id = intval($request["post_id"] ?? 0); + if ($post_id <= 0) { + return new WP_Error( + "invalid_post", + __("Invalid post ID.", "wp-agentic-writer"), + ["status" => 400], + ); + } + + if (!$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to access this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + $session = $manager->get_session_by_post_id($post_id); + + if (!$session) { + // Check for legacy post-meta chat history and migrate if present. + $legacy_history = get_post_meta( + $post_id, + "_wpaw_chat_history", + true, + ); + if (!empty($legacy_history) && is_array($legacy_history)) { + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $migrated_session_id = $context_service->migrate_legacy_chat_history( + $post_id, + ); + + // Fetch the newly created session after migration. + $session = $manager->get_session_by_post_id($post_id); + if ($session) { + return new WP_REST_Response( + [ + "messages" => $session["messages"], + "has_session" => true, + "session_id" => $session["session_id"], + "post_id" => $session["post_id"], + "migrated" => true, + "deprecated" => false, + ], + 200, + ); + } + } + + return new WP_REST_Response( + [ + "messages" => [], + "has_session" => false, + ], + 200, + ); + } + + return new WP_REST_Response( + [ + "messages" => $session["messages"], + "has_session" => true, + "session_id" => $session["session_id"], + "post_id" => $session["post_id"], + "deprecated" => false, + ], + 200, + ); + } + + /** + * Update per-post chat history. + * + * @since 0.1.0 + * @deprecated 0.1.4 Use conversation sessions instead. This method no longer writes + * to post meta; it exists only for backward compatibility. + * @param int $post_id Post ID. + * @param string $user_message User message. + * @param string $assistant_message Assistant message. + * @return void + */ + private function update_post_chat_history( + $post_id, + $user_message, + $assistant_message, + ) { + // Deprecated - now only used for migration reads. Do not write. + // New code should use conversation sessions. + return; + } + + /** + * Get per-post chat history. + * + * @since 0.1.0 + * @deprecated 0.1.4 Use conversation sessions instead. + * @param int $post_id Post ID. + * @return array + */ + private function get_post_chat_history($post_id) + { + if ($post_id <= 0) { + return []; + } + + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + $sessions = $manager->get_sessions_for_post($post_id); + + // If we have active sessions, return messages from the most recent one + if (!empty($sessions)) { + // Sort by last activity, most recent first + usort($sessions, function ($a, $b) { + return strtotime($b["last_activity"] ?? "") - + strtotime($a["last_activity"] ?? ""); + }); + + $active_session = $sessions[0]; + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $context = $context_service->get_context( + $active_session["session_id"], + $post_id, + ); + return $context["messages"] ?? []; + } + + // No sessions found - check for legacy history and migrate + $history = get_post_meta($post_id, "_wpaw_chat_history", true); + if (!is_array($history) || empty($history)) { + return []; + } + + // Legacy data exists - trigger migration + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $migrated_session_id = $context_service->migrate_legacy_chat_history( + $post_id, + ); + + // Return migrated data using the returned session id + if (!empty($migrated_session_id)) { + $context = $context_service->get_context( + $migrated_session_id, + $post_id, + ); + return $context["messages"] ?? []; + } + + return []; + } + + /** + * Get default per-post configuration values. + * + * @since 0.1.0 + * @return array + */ + private function get_default_post_config() + { + $settings = get_option("wp_agentic_writer_settings", []); + + return [ + "article_length" => "medium", + "language" => "auto", + "tone" => "", + "audience" => "", + "experience_level" => "general", + "include_images" => true, + "web_search" => + isset($settings["web_search_enabled"]) && + "1" === $settings["web_search_enabled"], + "default_mode" => "writing", + // SEO fields + "focus_keyword" => "", + "seo_focus_keyword" => "", + "seo_secondary_keywords" => "", + "seo_meta_description" => "", + "seo_enabled" => true, + ]; + } + + /** + * Sanitize post config input. + * + * @since 0.1.0 + * @param array $config Post config. + * @return array + */ + private function sanitize_post_config($config) + { + $defaults = $this->get_default_post_config(); + $config = is_array($config) ? $config : []; + $sanitized = []; + + $allowed_lengths = ["short", "medium", "long"]; + $length = $config["article_length"] ?? $defaults["article_length"]; + $sanitized["article_length"] = in_array($length, $allowed_lengths, true) + ? $length + : $defaults["article_length"]; + + // Validate language - normalize to lowercase for comparison + $settings = get_option("wp_agentic_writer_settings", []); + $allowed_languages = array_merge( + $settings["preferred_languages"] ?? [ + "auto", + "English", + "Indonesian", + ], + $settings["custom_languages"] ?? [], + ); + // Normalize allowed languages to lowercase + $allowed_languages_lower = array_map("strtolower", $allowed_languages); + $language = strtolower($config["language"] ?? $defaults["language"]); + $sanitized["language"] = in_array( + $language, + $allowed_languages_lower, + true, + ) + ? $language + : "auto"; + + $sanitized["tone"] = sanitize_text_field( + $config["tone"] ?? $defaults["tone"], + ); + $sanitized["audience"] = sanitize_text_field( + $config["audience"] ?? $defaults["audience"], + ); + $sanitized["experience_level"] = sanitize_text_field( + $config["experience_level"] ?? $defaults["experience_level"], + ); + + $sanitized["include_images"] = isset($config["include_images"]) + ? (bool) $config["include_images"] + : (bool) $defaults["include_images"]; + $sanitized["web_search"] = isset($config["web_search"]) + ? (bool) $config["web_search"] + : (bool) $defaults["web_search"]; + + $allowed_modes = ["writing", "planning", "chat"]; + $mode = $config["default_mode"] ?? $defaults["default_mode"]; + $sanitized["default_mode"] = in_array($mode, $allowed_modes, true) + ? $mode + : $defaults["default_mode"]; + + // SEO fields + $sanitized["seo_focus_keyword"] = sanitize_text_field( + $config["seo_focus_keyword"] ?? $defaults["seo_focus_keyword"], + ); + $sanitized["focus_keyword"] = sanitize_text_field( + $config["focus_keyword"] ?? $defaults["focus_keyword"], + ); + if ( + "" === $sanitized["focus_keyword"] && + "" !== $sanitized["seo_focus_keyword"] + ) { + $sanitized["focus_keyword"] = $sanitized["seo_focus_keyword"]; + } + if ( + "" === $sanitized["seo_focus_keyword"] && + "" !== $sanitized["focus_keyword"] + ) { + $sanitized["seo_focus_keyword"] = $sanitized["focus_keyword"]; + } + $sanitized["seo_secondary_keywords"] = sanitize_text_field( + $config["seo_secondary_keywords"] ?? + $defaults["seo_secondary_keywords"], + ); + $sanitized["seo_meta_description"] = sanitize_textarea_field( + $config["seo_meta_description"] ?? + $defaults["seo_meta_description"], + ); + $sanitized["seo_enabled"] = isset($config["seo_enabled"]) + ? (bool) $config["seo_enabled"] + : (bool) $defaults["seo_enabled"]; + + return $sanitized; + } + + /** + * Get post config (merged with defaults). + * + * @since 0.1.0 + * @param int $post_id Post ID. + * @return array + */ + private function get_post_config($post_id) + { + $defaults = $this->get_default_post_config(); + if ($post_id <= 0) { + return $defaults; + } + + $stored = get_post_meta($post_id, "_wpaw_post_config", true); + $stored = is_array($stored) ? $stored : []; + + return $this->sanitize_post_config(wp_parse_args($stored, $defaults)); + } + + /** + * Resolve post config from request, falling back to stored config. + * + * @since 0.1.0 + * @param array $params Request params. + * @param int $post_id Post ID. + * @return array + */ + private function resolve_post_config_from_request($params, $post_id) + { + if (isset($params["postConfig"]) && is_array($params["postConfig"])) { + $merged = wp_parse_args( + $params["postConfig"], + $this->get_post_config($post_id), + ); + return $this->sanitize_post_config($merged); + } + + return $this->get_post_config($post_id); + } + + /** + * Build a short configuration context string for prompts. + * + * @since 0.1.0 + * @param array $post_config Post config. + * @return string + */ + private function build_post_config_context($post_config) + { + $lines = []; + if (!empty($post_config["tone"])) { + $lines[] = "Tone: " . $post_config["tone"]; + } + if (!empty($post_config["audience"])) { + $lines[] = "Target audience: " . $post_config["audience"]; + } + if ( + !empty($post_config["experience_level"]) && + "general" !== $post_config["experience_level"] + ) { + $lines[] = "Expertise level: " . $post_config["experience_level"]; + } + + // Add SEO context if enabled + $seo_context = $this->build_seo_context($post_config); + + if (empty($lines) && empty($seo_context)) { + return ""; + } + + $result = ""; + if (!empty($lines)) { + $result .= "\nPOST CONFIG:\n- " . implode("\n- ", $lines) . "\n"; + } + if (!empty($seo_context)) { + $result .= $seo_context; + } + + return $result; + } + + /** + * Build SEO context for prompts. + * + * @since 0.1.0 + * @param array $post_config Post config. + * @return string SEO context string. + */ + private function build_seo_context($post_config) + { + if (empty($post_config["seo_enabled"])) { + return ""; + } + + $seo_lines = []; + + if (!empty($post_config["seo_focus_keyword"])) { + $seo_lines[] = + 'Focus keyword: "' . + $post_config["seo_focus_keyword"] . + '" - Include this keyword naturally in: title, first paragraph, at least 2-3 subheadings, and throughout the content (aim for 1-2% density)'; + } + + if (!empty($post_config["seo_secondary_keywords"])) { + $seo_lines[] = + "Secondary keywords: " . + $post_config["seo_secondary_keywords"] . + " - Sprinkle these throughout the content naturally"; + } + + if (empty($seo_lines)) { + return ""; + } + + return "\nSEO OPTIMIZATION:\n- " . + implode("\n- ", $seo_lines) . + "\n- Use descriptive, keyword-rich subheadings (H2, H3)\n- Write compelling meta-description-worthy opening paragraph\n- Include internal linking opportunities where relevant\n"; + } + + /** + * Detect language from text using common word patterns. + * + * @since 0.1.0 + * @param string $text Text to analyze. + * @return string Detected language code. + */ + private function detect_language_from_text($text) + { + $text = strtolower($text); + + // Indonesian indicators + $indonesian_words = [ + "yang", + "dan", + "untuk", + "dengan", + "ini", + "itu", + "dari", + "ke", + "di", + "pada", + "adalah", + "akan", + "sudah", + "bisa", + "harus", + "tidak", + "juga", + "atau", + "saya", + "apa", + "bagaimana", + "mengapa", + "kenapa", + "gimana", + "tolong", + "mohon", + "silakan", + "terima", + "kasih", + "selamat", + "pagi", + "siang", + "malam", + "artikel", + "tentang", + "topik", + "pembahasan", + "cara", + "membuat", + "menulis", + ]; + $indonesian_count = 0; + foreach ($indonesian_words as $word) { + if (preg_match("/\b" . preg_quote($word, "/") . "\b/u", $text)) { + $indonesian_count++; + } + } + + // Spanish indicators + $spanish_words = [ + "que", + "de", + "no", + "es", + "el", + "la", + "los", + "las", + "un", + "una", + "por", + "con", + "para", + "como", + "pero", + "más", + "este", + "esta", + "todo", + "también", + "puede", + "hacer", + "tiene", + "cuando", + "sobre", + "entre", + "después", + "antes", + "porque", + "cómo", + "qué", + "cuál", + ]; + $spanish_count = 0; + foreach ($spanish_words as $word) { + if (preg_match("/\b" . preg_quote($word, "/") . "\b/u", $text)) { + $spanish_count++; + } + } + + // French indicators + $french_words = [ + "le", + "la", + "les", + "de", + "du", + "des", + "un", + "une", + "et", + "est", + "que", + "qui", + "dans", + "pour", + "pas", + "sur", + "avec", + "ce", + "cette", + "sont", + "être", + "avoir", + "faire", + "comme", + "mais", + "ou", + "où", + "plus", + "tout", + "bien", + "aussi", + "peut", + "très", + "comment", + "pourquoi", + "quoi", + ]; + $french_count = 0; + foreach ($french_words as $word) { + if (preg_match("/\b" . preg_quote($word, "/") . "\b/u", $text)) { + $french_count++; + } + } + + // Determine language with threshold + $threshold = 2; + if ( + $indonesian_count >= $threshold && + $indonesian_count > $spanish_count && + $indonesian_count > $french_count + ) { + return "indonesian"; + } + if ( + $spanish_count >= $threshold && + $spanish_count > $indonesian_count && + $spanish_count > $french_count + ) { + return "spanish"; + } + if ( + $french_count >= $threshold && + $french_count > $indonesian_count && + $french_count > $spanish_count + ) { + return "french"; + } + + // Return empty string instead of 'auto' to allow fallback to stored language + return ""; + } + + /** + * Resolve effective language preference. + * + * @since 0.1.0 + * @param array $post_config Post config. + * @param string $fallback Language to fall back to. + * @return string + */ + private function resolve_language_preference($post_config, $fallback) + { + $language = strtolower((string) ($post_config["language"] ?? "auto")); + if ("auto" !== $language && "" !== $language) { + return $language; + } + + // If fallback is provided and not empty, use it + if (!empty($fallback) && "auto" !== strtolower($fallback)) { + return strtolower($fallback); + } + + // Default to 'auto' instead of 'english' to let AI detect from context + return "auto"; + } + + /** + * Resolve the explicit language answer from the clarity/config quiz. + * + * @since 0.2.3 + * @param array $answers Clarification answers. + * @return string Language answer or empty string. + */ + private function resolve_language_from_clarification_answers($answers) + { + if (empty($answers) || !is_array($answers)) { + return ""; + } + + $language = ""; + if (isset($answers["config_language"])) { + $language = (string) $answers["config_language"]; + } else { + foreach ($answers as $answer) { + if (!is_array($answer)) { + continue; + } + if ( + isset($answer["id"]) && + "config_language" === $answer["id"] + ) { + $language = + (string) ($answer["value"] ?? + ($answer["answer"] ?? "")); + break; + } + } + } + + if ( + "__custom__" === $language && + !empty($answers["config_language_custom"]) + ) { + $language = (string) $answers["config_language_custom"]; + } + + $language = sanitize_text_field($language); + if ( + "" === $language || + "__skipped__" === $language || + "auto" === strtolower($language) + ) { + return ""; + } + + return $language; + } + + /** + * Build language instruction for prompts. + * + * @since 0.1.0 + * @param string $language Language code. + * @param string $context Context label. + * @return string + */ + private function build_language_instruction($language, $context = "content") + { + $language = trim((string) $language); + + // If auto or empty, let AI detect from context + if (empty($language) || "auto" === strtolower($language)) { + 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 + return "You MUST write the {$context} in {$language}. Use native {$language} vocabulary, grammar, and style."; + } + + /** + * Prepend a system prompt to messages. + * + * @since 0.1.0 + * @param array $messages Messages list. + * @param string $prompt System prompt. + * @return array + */ + private function prepend_system_prompt($messages, $prompt) + { + if (empty($prompt)) { + return $messages; + } + + $messages = is_array($messages) ? $messages : []; + array_unshift($messages, [ + "role" => "system", + "content" => $prompt, + ]); + + 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; + } + + // Check if Brave API key is configured + $settings = get_option("wp_agentic_writer_settings", []); + $brave_api_key = $settings["brave_search_api_key"] ?? ""; + + // Determine search strategy: + // 1. If Brave API key is set -> Use Brave (regardless of provider) + // 2. If using OpenRouter without Brave key -> Let OpenRouter's online models handle it + // 3. If using Local Backend without Brave key -> No search available + + if ( + empty($brave_api_key) && + $provider instanceof WP_Agentic_Writer_OpenRouter_Provider + ) { + // No Brave API key with OpenRouter - let the model's built-in search handle it + // OpenRouter's online models (e.g., gemini-2.5-flash-online) have search tools built-in + return; + } + + if (empty($brave_api_key)) { + // Local Backend or other providers without Brave API key + 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 = [ + "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, [$injection_message]); + $injected = true; + break; + } + } + + if (!$injected) { + array_unshift($messages, $injection_message); + } + } + } + + /** + * Build web search option overrides. + * + * @since 0.1.0 + * @param array $post_config Post config. + * @return array + */ + private function get_web_search_options($post_config) + { + $settings = get_option("wp_agentic_writer_settings", []); + + return [ + "web_search_enabled" => isset($post_config["web_search"]) + ? (bool) $post_config["web_search"] + : false, + "search_depth" => $settings["search_depth"] ?? "medium", + "search_engine" => $settings["search_engine"] ?? "auto", + ]; + } + + /** + * Handle get post config request. + * + * @since 0.1.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_get_post_config($request) + { + $post_id = isset($request["post_id"]) ? (int) $request["post_id"] : 0; + if ($post_id <= 0) { + return new WP_Error( + "invalid_post", + __("Invalid post ID.", "wp-agentic-writer"), + ["status" => 400], + ); + } + + if (!$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to access this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + return new WP_REST_Response($this->get_post_config($post_id), 200); + } + + /** + * Handle update post config request. + * + * @since 0.1.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_update_post_config($request) + { + $post_id = isset($request["post_id"]) ? (int) $request["post_id"] : 0; + if ($post_id <= 0) { + return new WP_Error( + "invalid_post", + __("Invalid post ID.", "wp-agentic-writer"), + ["status" => 400], + ); + } + + if (!$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to edit this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $params = $request->get_json_params(); + $config = $this->sanitize_post_config($params["postConfig"] ?? []); + update_post_meta($post_id, "_wpaw_post_config", $config); + + // MEMANTO: Store config preference. + do_action("wpaw_memanto_config_saved", $post_id, $config); + + return new WP_REST_Response($config, 200); + } + + /** + * Get the last user message from a message list. + * + * @since 0.1.0 + * @param array $messages Message list. + * @return string + */ + private function get_last_user_message($messages) + { + if (empty($messages) || !is_array($messages)) { + return ""; + } + + for ($i = count($messages) - 1; $i >= 0; $i--) { + $message = $messages[$i]; + if ( + isset($message["role"]) && + "user" === $message["role"] && + !empty($message["content"]) + ) { + return sanitize_text_field($message["content"]); + } + } + + return ""; + } + + /** + * Handle generate plan request. + * + * @since 0.1.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_generate_plan($request) + { + $params = $request->get_json_params(); + $topic = $params["topic"] ?? ""; + $context = $params["context"] ?? ""; + $post_id = $params["postId"] ?? 0; + $session_id = $this->resolve_or_create_session_id( + $params["sessionId"] ?? "", + $post_id, + ); + $auto_execute = $params["autoExecute"] ?? false; + $stream = $params["stream"] ?? false; + $chat_history = $params["chatHistory"] ?? []; + $post_config = $this->resolve_post_config_from_request( + $params, + $post_id, + ); + $article_length = + $post_config["article_length"] ?? + ($params["articleLength"] ?? "medium"); + $clarification_answers = $params["clarificationAnswers"] ?? []; // Get clarification answers + $detected_language = $params["detectedLanguage"] ?? "auto"; // Get detected language from clarity check + $clarified_language = $this->resolve_language_from_clarification_answers( + $clarification_answers, + ); + $effective_language = $this->resolve_language_preference( + $post_config, + $clarified_language ?: $detected_language, + ); + $post_config_context = $this->build_post_config_context($post_config); + $web_search_options = $this->get_web_search_options($post_config); + + if (empty($topic)) { + return new WP_Error( + "no_topic", + __("Topic is required.", "wp-agentic-writer"), + ["status" => 400], + ); + } + + // Check post permission if post_id is provided. + if ($post_id > 0 && !$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to edit this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $context_builder = WP_Agentic_Writer_Context_Builder::get_instance(); + $context_package = $context_builder->build_for_task( + "planning", + $session_id, + $post_id, + array_merge($params, [ + "chatHistory" => $chat_history, + "postConfig" => $post_config, + ]), + ); + + // If streaming is requested, use streaming response. + if ($stream) { + return $this->stream_generate_plan( + $topic, + $context, + $post_id, + $auto_execute, + $article_length, + $clarification_answers, + $effective_language, + $post_config, + $chat_history, + $session_id, + ); + } + + // Get provider for planning task. + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( + "planning", + ); + $provider = $provider_result->provider; + + // 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 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. @@ -2222,6 +2624,7 @@ GEO/SEO STRATEGY: CRITICAL LANGUAGE REQUIREMENT: {$plan_language_instruction} +Keep JSON keys in English for parsing, but write every user-visible JSON value (title, headings, descriptions, labels) in the required article language. {$post_config_context} Generate a JSON outline with the following structure: @@ -2251,221 +2654,252 @@ Generate a JSON outline with the following structure: Return only valid raw JSON that matches this schema. Do not wrap it in markdown fences and do not add explanatory text. Keep sections focused and actionable. Include H2 headings only. For technical articles, suggest code blocks."; - $messages = array( - array( - 'role' => 'system', - 'content' => $system_prompt, - ), - array( - 'role' => 'user', - 'content' => "Topic: {$topic}\n\nContext:\n{$context_package['working_context']}\n\n{$context_package['research_context']}", - ), - ); + $messages = [ + [ + "role" => "system", + "content" => $system_prompt, + ], + [ + "role" => "user", + "content" => "Topic: {$topic}\n\nContext:\n{$context_package["working_context"]}\n\n{$context_package["research_context"]}", + ], + ]; - // Generate plan. - $this->maybe_inject_brave_search( $messages, $provider, $web_search_options ); - $response = $provider->chat( - $messages, - array_merge( - array( - 'temperature' => 0.7, - 'max_tokens' => 2200, - ), - $web_search_options - ), - 'planning' - ); + // Generate plan. + $this->maybe_inject_brave_search( + $messages, + $provider, + $web_search_options, + ); + $response = $provider->chat( + $messages, + array_merge( + [ + "temperature" => 0.7, + "max_tokens" => 2200, + ], + $web_search_options, + ), + "planning", + ); - // Debug: log the provider type and response - $provider_class = get_class( $provider ); - wpaw_debug_log( 'Plan generation using provider: ' . $provider_class ); + // Debug: log the provider type and response + $provider_class = get_class($provider); + wpaw_debug_log("Plan generation using provider: " . $provider_class); - if ( is_wp_error( $response ) ) { - wpaw_debug_log( 'Plan generation error: ' . $response->get_error_message() ); - return new WP_Error( - 'plan_generation_error', - $response->get_error_message(), - array( 'status' => 500 ) - ); - } + if (is_wp_error($response)) { + wpaw_debug_log( + "Plan generation error: " . $response->get_error_message(), + ); + return new WP_Error( + "plan_generation_error", + $response->get_error_message(), + ["status" => 500], + ); + } - // Extract JSON from response. - $content = $response['content'] ?? ''; - $plan_json = $this->extract_plan_from_response( $content, $topic ); + // Extract JSON from response. + $content = $response["content"] ?? ""; + $plan_json = $this->extract_plan_from_response($content, $topic); - // Debug: log the raw response - wpaw_debug_log( 'Plan generation raw response length: ' . strlen( $content ) ); + // Debug: log the raw response + wpaw_debug_log( + "Plan generation raw response length: " . strlen($content), + ); - if ( empty( trim( (string) $content ) ) ) { - $model_used = $response['model'] ?? 'unknown'; - return new WP_Error( - 'empty_response', - sprintf( - __( 'The AI model (%s) returned an empty response. Try a different planning model or simplify your topic.', 'wp-agentic-writer' ), - $model_used - ), - array( 'status' => 500 ) - ); - } + if (empty(trim((string) $content))) { + $model_used = $response["model"] ?? "unknown"; + return new WP_Error( + "empty_response", + sprintf( + __( + "The AI model (%s) returned an empty response. Try a different planning model or simplify your topic.", + "wp-agentic-writer", + ), + $model_used, + ), + ["status" => 500], + ); + } - if ( null === $plan_json ) { - wpaw_debug_log( 'extract_plan_from_response returned null. Content preview: ' . substr( $content, 0, 500 ) ); - return new WP_Error( - 'invalid_json', - sprintf( - /* translators: %s: model output preview */ - __( 'The AI responded but the outline couldn\'t be parsed as JSON. Try again — this is usually a one-time formatting issue. Preview: %s', 'wp-agentic-writer' ), - $this->build_model_output_preview( $content ) - ), - array( 'status' => 500 ) - ); - } + if (null === $plan_json) { + wpaw_debug_log( + "extract_plan_from_response returned null. Content preview: " . + substr($content, 0, 500), + ); + return new WP_Error( + "invalid_json", + sprintf( + /* translators: %s: model output preview */ + __( + 'The AI responded but the outline couldn\'t be parsed as JSON. Try again — this is usually a one-time formatting issue. Preview: %s', + "wp-agentic-writer", + ), + $this->build_model_output_preview($content), + ), + ["status" => 500], + ); + } - $plan_json = $this->ensure_plan_sections_with_tasks( $plan_json ); + $plan_json = $this->ensure_plan_sections_with_tasks($plan_json); - // Persist planning exchange into session history. - if ( ! empty( $session_id ) ) { - $context_service = WP_Agentic_Writer_Context_Service::get_instance(); - $context_service->update_session_context( - $session_id, - array( - 'working_summary' => array( - 'text' => $this->build_memory_summary_from_plan( $plan_json ), - 'updated_at' => current_time( 'c' ), - 'source_message_count' => 0, - ), - ) - ); - $context_service->add_message( - $session_id, - array( - 'role' => 'user', - 'content' => trim( (string) $topic ), - 'timestamp' => current_time( 'c' ), - ) - ); - $context_service->add_message( - $session_id, - array( - 'role' => 'assistant', - 'type' => 'plan', - 'plan' => $plan_json, - 'content' => $this->build_plan_summary_for_session( $plan_json, $post_config ), - 'timestamp' => current_time( 'c' ), - ) - ); - } + // MEMANTO: Remember plan was generated. + do_action("wpaw_memanto_plan_generated", $post_id, $plan_json); - // Store plan in post meta. - if ( $post_id > 0 ) { - update_post_meta( $post_id, '_wpaw_plan', $plan_json ); - update_post_meta( $post_id, '_wpaw_detected_language', $effective_language ); - $summary = $this->build_memory_summary_from_plan( $plan_json ); - $this->update_post_memory( - $post_id, - array( - 'summary' => $summary, - 'last_prompt' => $topic, - 'last_intent' => 'generate', - ) - ); - } + // Persist planning exchange into session history. + if (!empty($session_id)) { + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $context_service->update_session_context($session_id, [ + "working_summary" => [ + "text" => $this->build_memory_summary_from_plan($plan_json), + "updated_at" => current_time("c"), + "source_message_count" => 0, + ], + ]); + $context_service->add_message($session_id, [ + "role" => "user", + "content" => trim((string) $topic), + "timestamp" => current_time("c"), + ]); + $context_service->add_message($session_id, [ + "role" => "assistant", + "type" => "plan", + "plan" => $plan_json, + "content" => $this->build_plan_summary_for_session( + $plan_json, + $post_config, + ), + "timestamp" => current_time("c"), + ]); + } - // Track cost with provider metadata. - $this->track_ai_cost( - $post_id, - $response['model'] ?? '', - 'planning', - $response['input_tokens'] ?? 0, - $response['output_tokens'] ?? 0, - $response['cost'] ?? 0, - $provider_result, - $session_id, - 'success' - ); + // Store plan in post meta. + if ($post_id > 0) { + update_post_meta($post_id, "_wpaw_plan", $plan_json); + update_post_meta( + $post_id, + "_wpaw_detected_language", + $effective_language, + ); + $summary = $this->build_memory_summary_from_plan($plan_json); + $this->update_post_memory($post_id, [ + "summary" => $summary, + "last_prompt" => $topic, + "last_intent" => "generate", + ]); + } - return new WP_REST_Response( - array( - 'plan' => $plan_json, - 'cost' => $response['cost'] ?? 0, - 'web_search_results' => $response['web_search_results'] ?? array(), - 'context_audit' => $context_package['audit'] ?? array(), - 'provider_metadata' => $this->build_provider_metadata( - $provider_result, - $response['model'] ?? '' - ), - ), - 200 - ); - } + // Track cost with provider metadata. + $this->track_ai_cost( + $post_id, + $response["model"] ?? "", + "planning", + $response["input_tokens"] ?? 0, + $response["output_tokens"] ?? 0, + $response["cost"] ?? 0, + $provider_result, + $session_id, + "success", + ); - /** - * Handle revise plan request. - * - * @since 0.1.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_revise_plan( $request ) { - $params = $request->get_json_params(); - $instruction = $params['instruction'] ?? ''; - $plan = $params['plan'] ?? array(); - $post_id = $params['postId'] ?? 0; - $session_id = $this->resolve_or_create_session_id( $params['sessionId'] ?? '', $post_id ); + return new WP_REST_Response( + [ + "plan" => $plan_json, + "cost" => $response["cost"] ?? 0, + "web_search_results" => $response["web_search_results"] ?? [], + "context_audit" => $context_package["audit"] ?? [], + "provider_metadata" => $this->build_provider_metadata( + $provider_result, + $response["model"] ?? "", + ), + ], + 200, + ); + } - if ( empty( $instruction ) ) { - return new WP_Error( - 'no_instruction', - __( 'Instruction is required.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } + /** + * Handle revise plan request. + * + * @since 0.1.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_revise_plan($request) + { + $params = $request->get_json_params(); + $instruction = $params["instruction"] ?? ""; + $plan = $params["plan"] ?? []; + $post_id = $params["postId"] ?? 0; + $session_id = $this->resolve_or_create_session_id( + $params["sessionId"] ?? "", + $post_id, + ); - if ( empty( $plan ) || ! is_array( $plan ) ) { - return new WP_Error( - 'no_plan', - __( 'Plan is required to revise.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } + if (empty($instruction)) { + return new WP_Error( + "no_instruction", + __("Instruction is required.", "wp-agentic-writer"), + ["status" => 400], + ); + } - // Check post permission BEFORE reading post data. - if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } + if (empty($plan) || !is_array($plan)) { + return new WP_Error( + "no_plan", + __("Plan is required to revise.", "wp-agentic-writer"), + ["status" => 400], + ); + } - // Only read post config/meta after permission check. - $post_config = $this->resolve_post_config_from_request( $params, $post_id ); - $post_config_context = $this->build_post_config_context( $post_config ); - $effective_language = $this->resolve_language_preference( $post_config, get_post_meta( $post_id, '_wpaw_detected_language', true ) ); - $plan_language_instruction = $this->build_language_instruction( $effective_language, 'article plan (title, section headings, descriptions)' ); - $web_search_options = $this->get_web_search_options( $post_config ); + // Check post permission BEFORE reading post data. + if ($post_id > 0 && !$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to edit this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } - $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' ); - $provider = $provider_result->provider; - $memory_context = $this->get_post_memory_context( $post_id ); - $context_builder = WP_Agentic_Writer_Context_Builder::get_instance(); - $context_package = $context_builder->build_for_task( - 'plan_revision', - $session_id, - $post_id, - array_merge( - $params, - array( - 'plan' => $plan, - 'postConfig' => $post_config, - ) - ) - ); + // Only read post config/meta after permission check. + $post_config = $this->resolve_post_config_from_request( + $params, + $post_id, + ); + $post_config_context = $this->build_post_config_context($post_config); + $effective_language = $this->resolve_language_preference( + $post_config, + get_post_meta($post_id, "_wpaw_detected_language", true), + ); + $plan_language_instruction = $this->build_language_instruction( + $effective_language, + "article plan (title, section headings, descriptions)", + ); + $web_search_options = $this->get_web_search_options($post_config); - $system_prompt = "You are an expert content strategist. Revise the provided outline based on the user's instruction. + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( + "planning", + ); + $provider = $provider_result->provider; + $memory_context = $this->get_post_memory_context($post_id); + $context_builder = WP_Agentic_Writer_Context_Builder::get_instance(); + $context_package = $context_builder->build_for_task( + "plan_revision", + $session_id, + $post_id, + array_merge($params, [ + "plan" => $plan, + "postConfig" => $post_config, + ]), + ); + + $system_prompt = "You are an expert content strategist. Revise the provided outline based on the user's instruction. CRITICAL LANGUAGE REQUIREMENT: {$plan_language_instruction} +Keep JSON keys in English for parsing, but write every user-visible JSON value (title, headings, descriptions, labels) in the required article language. {$post_config_context} Return ONLY valid JSON in this structure: @@ -2500,333 +2934,395 @@ Rules: - Keep headings as H2-level topics. - No markdown, no explanation, JSON only."; - $messages = array( - array( - 'role' => 'system', - 'content' => $system_prompt, - ), - array( - 'role' => 'user', - 'content' => "Instruction: {$instruction}\n\nContinuity Context:\n{$context_package['working_context']}\n\nCurrent Outline JSON:\n" . wp_json_encode( $plan ) . $memory_context, - ), - ); + $messages = [ + [ + "role" => "system", + "content" => $system_prompt, + ], + [ + "role" => "user", + "content" => + "Instruction: {$instruction}\n\nContinuity Context:\n{$context_package["working_context"]}\n\nCurrent Outline JSON:\n" . + wp_json_encode($plan) . + $memory_context, + ], + ]; - // 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' ); + // Generate revised plan. + $this->maybe_inject_brave_search( + $messages, + $provider, + $web_search_options, + ); + $response = $provider->chat( + $messages, + array_merge(["temperature" => 0.6], $web_search_options), + "planning", + ); - if ( is_wp_error( $response ) ) { - return new WP_Error( - 'plan_revision_error', - $response->get_error_message(), - array( 'status' => 500 ) - ); - } + if (is_wp_error($response)) { + return new WP_Error( + "plan_revision_error", + $response->get_error_message(), + ["status" => 500], + ); + } - $plan_json = $this->extract_plan_from_response( $response['content'], $instruction, $plan ); - if ( null === $plan_json ) { - return new WP_Error( - 'plan_revision_invalid', - __( 'Failed to generate valid plan JSON.', 'wp-agentic-writer' ), - array( 'status' => 500 ) - ); - } + $plan_json = $this->extract_plan_from_response( + $response["content"], + $instruction, + $plan, + ); + if (null === $plan_json) { + return new WP_Error( + "plan_revision_invalid", + __("Failed to generate valid plan JSON.", "wp-agentic-writer"), + ["status" => 500], + ); + } - $plan_json = $this->ensure_plan_sections_with_tasks( $plan_json, $plan ); + $plan_json = $this->ensure_plan_sections_with_tasks($plan_json, $plan); - if ( $post_id > 0 ) { - update_post_meta( $post_id, '_wpaw_plan', $plan_json ); - if ( ! empty( $effective_language ) ) { - update_post_meta( $post_id, '_wpaw_detected_language', $effective_language ); - } - $summary = $this->build_memory_summary_from_plan( $plan_json ); - $this->update_post_memory( - $post_id, - array( - 'summary' => $summary, - 'last_prompt' => $instruction, - 'last_intent' => 'plan', - ) - ); - } + // MEMANTO: Remember plan revision (implies previous plan was rejected). + do_action("wpaw_memanto_plan_rejected", $post_id, $instruction); - if ( ! empty( $session_id ) ) { - $context_service = WP_Agentic_Writer_Context_Service::get_instance(); - $context_service->append_session_context_item( - $session_id, - 'plan_versions', - array( - 'instruction' => sanitize_text_field( $instruction ), - 'plan' => $plan, - ), - 10 - ); - $context_service->update_session_context( - $session_id, - array( - 'working_summary' => array( - 'text' => $this->build_memory_summary_from_plan( $plan_json ), - 'updated_at' => current_time( 'c' ), - 'source_message_count' => 0, - ), - ) - ); - } + if ($post_id > 0) { + update_post_meta($post_id, "_wpaw_plan", $plan_json); + if (!empty($effective_language)) { + update_post_meta( + $post_id, + "_wpaw_detected_language", + $effective_language, + ); + } + $summary = $this->build_memory_summary_from_plan($plan_json); + $this->update_post_memory($post_id, [ + "summary" => $summary, + "last_prompt" => $instruction, + "last_intent" => "plan", + ]); + } - // Track cost with provider metadata. - $this->track_ai_cost( - $post_id, - $response['model'] ?? '', - 'planning', - $response['input_tokens'] ?? 0, - $response['output_tokens'] ?? 0, - $response['cost'] ?? 0, - $provider_result, - $session_id, - 'success' - ); + if (!empty($session_id)) { + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $context_service->append_session_context_item( + $session_id, + "plan_versions", + [ + "instruction" => sanitize_text_field($instruction), + "plan" => $plan, + ], + 10, + ); + $context_service->update_session_context($session_id, [ + "working_summary" => [ + "text" => $this->build_memory_summary_from_plan($plan_json), + "updated_at" => current_time("c"), + "source_message_count" => 0, + ], + ]); + } - return new WP_REST_Response( - array( - 'plan' => $plan_json, - 'cost' => $response['cost'] ?? 0, - 'context_audit' => $context_package['audit'] ?? array(), - 'provider_metadata' => $this->build_provider_metadata( - $provider_result, - $response['model'] ?? '' - ), - ), - 200 - ); - } + // Track cost with provider metadata. + $this->track_ai_cost( + $post_id, + $response["model"] ?? "", + "planning", + $response["input_tokens"] ?? 0, + $response["output_tokens"] ?? 0, + $response["cost"] ?? 0, + $provider_result, + $session_id, + "success", + ); - /** - * Ensure plan sections have stable ids and task statuses. - * - * @since 0.1.0 - * @param array $plan Plan data. - * @param array $previous_plan Previous plan for matching. - * @return array - */ - private function ensure_plan_sections_with_tasks( $plan, $previous_plan = array() ) { - if ( empty( $plan ) || ! is_array( $plan ) ) { - return $plan; - } + return new WP_REST_Response( + [ + "plan" => $plan_json, + "cost" => $response["cost"] ?? 0, + "context_audit" => $context_package["audit"] ?? [], + "provider_metadata" => $this->build_provider_metadata( + $provider_result, + $response["model"] ?? "", + ), + ], + 200, + ); + } - $sections = $plan['sections'] ?? array(); - if ( ! is_array( $sections ) ) { - $sections = array(); - } + /** + * Ensure plan sections have stable ids and task statuses. + * + * @since 0.1.0 + * @param array $plan Plan data. + * @param array $previous_plan Previous plan for matching. + * @return array + */ + private function ensure_plan_sections_with_tasks($plan, $previous_plan = []) + { + if (empty($plan) || !is_array($plan)) { + return $plan; + } - $previous_sections = array(); - if ( is_array( $previous_plan ) ) { - $previous_sections = $previous_plan['sections'] ?? array(); - if ( ! is_array( $previous_sections ) ) { - $previous_sections = array(); - } - } + $sections = $plan["sections"] ?? []; + if (!is_array($sections)) { + $sections = []; + } - $previous_by_id = array(); - $previous_by_title = array(); - foreach ( $previous_sections as $previous_section ) { - if ( ! is_array( $previous_section ) ) { - continue; - } - $previous_id = $previous_section['id'] ?? ''; - $previous_title = $this->normalize_plan_section_title( $previous_section ); - if ( $previous_id ) { - $previous_by_id[ $previous_id ] = $previous_section; - } - if ( $previous_title ) { - $previous_by_title[ $previous_title ] = $previous_section; - } - } + $previous_sections = []; + if (is_array($previous_plan)) { + $previous_sections = $previous_plan["sections"] ?? []; + if (!is_array($previous_sections)) { + $previous_sections = []; + } + } - $normalized_sections = array(); - foreach ( $sections as $section ) { - if ( ! is_array( $section ) ) { - continue; - } + $previous_by_id = []; + $previous_by_title = []; + foreach ($previous_sections as $previous_section) { + if (!is_array($previous_section)) { + continue; + } + $previous_id = $previous_section["id"] ?? ""; + $previous_title = $this->normalize_plan_section_title( + $previous_section, + ); + if ($previous_id) { + $previous_by_id[$previous_id] = $previous_section; + } + if ($previous_title) { + $previous_by_title[$previous_title] = $previous_section; + } + } - $section_title = $this->normalize_plan_section_title( $section ); - $section_id = $section['id'] ?? ''; - $status = $section['status'] ?? ''; + $normalized_sections = []; + foreach ($sections as $section) { + if (!is_array($section)) { + continue; + } - if ( $section_id && isset( $previous_by_id[ $section_id ] ) ) { - $matched = $previous_by_id[ $section_id ]; - $status = $matched['status'] ?? $status; - } elseif ( $section_title && isset( $previous_by_title[ $section_title ] ) ) { - $matched = $previous_by_title[ $section_title ]; - $section_id = $matched['id'] ?? $section_id; - $status = $matched['status'] ?? $status; - } + $section_title = $this->normalize_plan_section_title($section); + $section_id = $section["id"] ?? ""; + $status = $section["status"] ?? ""; - if ( empty( $section_id ) ) { - $section_id = wp_generate_uuid4(); - } + if ($section_id && isset($previous_by_id[$section_id])) { + $matched = $previous_by_id[$section_id]; + $status = $matched["status"] ?? $status; + } elseif ( + $section_title && + isset($previous_by_title[$section_title]) + ) { + $matched = $previous_by_title[$section_title]; + $section_id = $matched["id"] ?? $section_id; + $status = $matched["status"] ?? $status; + } - if ( empty( $status ) ) { - $status = 'pending'; - } + if (empty($section_id)) { + $section_id = wp_generate_uuid4(); + } - $section['id'] = $section_id; - $section['status'] = $status; - $normalized_sections[] = $section; - } + if (empty($status)) { + $status = "pending"; + } - $plan['sections'] = $normalized_sections; - return $plan; - } + $section["id"] = $section_id; + $section["status"] = $status; + $normalized_sections[] = $section; + } - /** - * Normalize section title for matching. - * - * @since 0.1.0 - * @param array $section Section data. - * @return string - */ - private function normalize_plan_section_title( $section ) { - $title = ''; - if ( is_array( $section ) ) { - $title = $section['heading'] ?? $section['title'] ?? ''; - } - $title = trim( wp_strip_all_tags( (string) $title ) ); - return strtolower( $title ); - } + $plan["sections"] = $normalized_sections; + return $plan; + } - /** - * Stream generate plan with optional auto-execution. - * - * @since 0.1.0 - * @param string $topic Topic. - * @param string $context Context. - * @param int $post_id Post ID. - * @param bool $auto_execute Whether to auto-execute the article. - * @param string $article_length Article length (short, medium, or long). - * @return void Streams response to client. - */ - private function stream_generate_plan( $topic, $context, $post_id, $auto_execute, $article_length = 'medium', $clarification_answers = array(), $detected_language = 'english', $post_config = array(), $chat_history = array(), $session_id = '' ) { - // Set headers for streaming. - header( 'Content-Type: text/event-stream' ); - header( 'Cache-Control: no-cache' ); - header( 'X-Accel-Buffering: no' ); // Disable Nginx buffering. + /** + * Normalize section title for matching. + * + * @since 0.1.0 + * @param array $section Section data. + * @return string + */ + private function normalize_plan_section_title($section) + { + $title = ""; + if (is_array($section)) { + $title = $section["heading"] ?? ($section["title"] ?? ""); + } + $title = trim(wp_strip_all_tags((string) $title)); + return strtolower($title); + } - // Flush output buffer to ensure immediate streaming. - if ( ob_get_level() > 0 ) { - ob_end_flush(); - } - flush(); + /** + * Stream generate plan with optional auto-execution. + * + * @since 0.1.0 + * @param string $topic Topic. + * @param string $context Context. + * @param int $post_id Post ID. + * @param bool $auto_execute Whether to auto-execute the article. + * @param string $article_length Article length (short, medium, or long). + * @return void Streams response to client. + */ + private function stream_generate_plan( + $topic, + $context, + $post_id, + $auto_execute, + $article_length = "medium", + $clarification_answers = [], + $detected_language = "auto", + $post_config = [], + $chat_history = [], + $session_id = "", + ) { + // Set headers for streaming. + header("Content-Type: text/event-stream"); + header("Cache-Control: no-cache"); + header("X-Accel-Buffering: no"); // Disable Nginx buffering. - $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' ); - $provider = $provider_result->provider; - $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 ); + // Flush output buffer to ensure immediate streaming. + if (ob_get_level() > 0) { + ob_end_flush(); + } + flush(); - // 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'] ); - } + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( + "planning", + ); + $provider = $provider_result->provider; + $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); + $clarified_language = $this->resolve_language_from_clarification_answers( + $clarification_answers, + ); + $effective_language = $this->resolve_language_preference( + $post_config, + $clarified_language ?: $detected_language, + ); - // 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 ); - } + // 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"], + ); + } - try { - // Note: Clarity check should be done BEFORE calling this streaming endpoint - // The frontend is responsible for checking clarity first via /check-clarity - // This endpoint only handles the actual streaming generation + // 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); + } - echo "data: " . wp_json_encode( - array( - 'type' => 'provider', - 'provider' => $provider_result->actual_provider, - 'selectedProvider' => $provider_result->selected_provider, - 'fallback_used' => $provider_result->fallback_used, - 'byok_managed_by' => 'openrouter' === $provider_result->actual_provider ? 'openrouter' : '', - ) - ) . "\n\n"; - flush(); + try { + // Note: Clarity check should be done BEFORE calling this streaming endpoint + // The frontend is responsible for checking clarity first via /check-clarity + // This endpoint only handles the actual streaming generation - // Send starting status - $this->send_status( 'starting', 'Connecting to AI...' ); + echo "data: " . + wp_json_encode([ + "type" => "provider", + "provider" => $provider_result->actual_provider, + "selectedProvider" => $provider_result->selected_provider, + "fallback_used" => $provider_result->fallback_used, + "byok_managed_by" => + "openrouter" === $provider_result->actual_provider + ? "openrouter" + : "", + ]) . + "\n\n"; + flush(); - // Step 1: Generate plan. - $this->send_status( 'planning', 'Creating article outline...' ); + // Send starting status + $this->send_status("starting", "Connecting to AI..."); - // Build clarification context if available. - $clarity_context = ''; - if ( ! empty( $clarification_answers ) && is_array( $clarification_answers ) ) { - $clarity_context = "\n\n=== CONTEXT FROM CLARIFICATION QUIZ ===\n"; + // Step 1: Generate plan. + $this->send_status("planning", "Creating article outline..."); - // Group by category. - $grouped = array(); - foreach ( $clarification_answers as $answer ) { - $category = $answer['category'] ?? 'other'; - $value = $answer['value'] ?? $answer['answer'] ?? ''; - $skipped = $answer['skipped'] ?? false; + // Build clarification context if available. + $clarity_context = ""; + if ( + !empty($clarification_answers) && + is_array($clarification_answers) + ) { + $clarity_context = + "\n\n=== CONTEXT FROM CLARIFICATION QUIZ ===\n"; - if ( ! $skipped && ! empty( $value ) ) { - $grouped[ $category ] = $value; - } - } + // Group by category. + $grouped = []; + foreach ($clarification_answers as $answer) { + $category = $answer["category"] ?? "other"; + $value = $answer["value"] ?? ($answer["answer"] ?? ""); + $skipped = $answer["skipped"] ?? false; - // Format for prompt. - $category_labels = array( - 'target_outcome' => 'Primary Goal', - 'target_audience' => 'Target Audience', - 'tone' => 'Tone of Voice', - 'content_depth' => 'Content Depth', - 'expertise_level' => 'Expertise Level', - 'content_type' => 'Content Type', - 'pov' => 'Point of View', - ); + if (!$skipped && !empty($value)) { + $grouped[$category] = $value; + } + } - foreach ( $grouped as $category => $value ) { - $label = $category_labels[ $category ] ?? ucwords( str_replace( '_', ' ', $category ) ); - $clarity_context .= "- {$label}: {$value}\n"; - } + // Format for prompt. + $category_labels = [ + "target_outcome" => "Primary Goal", + "target_audience" => "Target Audience", + "tone" => "Tone of Voice", + "content_depth" => "Content Depth", + "expertise_level" => "Expertise Level", + "content_type" => "Content Type", + "pov" => "Point of View", + ]; - $clarity_context .= "=== END CONTEXT ===\n"; - } + foreach ($grouped as $category => $value) { + $label = + $category_labels[$category] ?? + ucwords(str_replace("_", " ", $category)); + $clarity_context .= "- {$label}: {$value}\n"; + } - $context_builder = WP_Agentic_Writer_Context_Builder::get_instance(); - $context_package = $context_builder->build_for_task( - 'planning', - $session_id, - $post_id, - array( - 'topic' => $topic, - 'context' => $context, - 'chatHistory' => $chat_history, - 'postConfig' => $post_config, - 'clarificationAnswers' => $clarification_answers, - 'detectedLanguage' => $detected_language, - ) - ); - $chat_history_context = "\n\n" . $context_package['working_context'] . "\n\n" . $context_package['research_context']; + $clarity_context .= "=== END CONTEXT ===\n"; + } - // Add section limits based on article length. - $length_section_limits = array( - 'short' => 'Create exactly 2-3 sections maximum.', - 'medium' => 'Create 4-5 sections maximum.', - 'long' => 'Create 6-8 sections maximum.', - ); - $section_limit = $length_section_limits[ $article_length ]; + $context_builder = WP_Agentic_Writer_Context_Builder::get_instance(); + $context_package = $context_builder->build_for_task( + "planning", + $session_id, + $post_id, + [ + "topic" => $topic, + "context" => $context, + "chatHistory" => $chat_history, + "postConfig" => $post_config, + "clarificationAnswers" => $clarification_answers, + "detectedLanguage" => $detected_language, + ], + ); + $chat_history_context = + "\n\n" . + $context_package["working_context"] . + "\n\n" . + $context_package["research_context"]; - // Determine language instruction for plan generation - $plan_language_instruction = $this->build_language_instruction( $effective_language, 'article plan (title, section headings, descriptions)' ); + // Add section limits based on article length. + $length_section_limits = [ + "short" => "Create exactly 2-3 sections maximum.", + "medium" => "Create 4-5 sections maximum.", + "long" => "Create 6-8 sections maximum.", + ]; + $section_limit = $length_section_limits[$article_length]; - // Build focus keyword anchor instruction - $focus_keyword_instruction = ''; - if ( ! empty( $focus_keyword ) ) { - $focus_keyword_instruction = " + // Determine language instruction for plan generation + $plan_language_instruction = $this->build_language_instruction( + $effective_language, + "article plan (title, section headings, descriptions)", + ); + + // 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}\". @@ -2835,12 +3331,13 @@ CRITICAL: This article MUST be about \"{$focus_keyword}\". - 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. + $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} +Keep JSON keys in English for parsing, but write every user-visible JSON value (title, headings, descriptions, labels) in the required article language. IMPORTANT CONSTRAINT: {$section_limit} {$post_config_context} @@ -2872,247 +3369,289 @@ Generate a JSON outline with the following structure: Return only valid raw JSON that matches this schema. Do not wrap it in markdown fences and do not add explanatory text. Keep sections focused and actionable. Include H2 headings only. For technical articles, suggest code blocks."; - $memory_context = $this->get_post_memory_context( $post_id ); - $messages = array( - array( - 'role' => 'system', - 'content' => $system_prompt, - ), - array( - 'role' => 'user', - 'content' => "Topic: {$topic}\n\nContext: {$context}{$chat_history_context}{$clarity_context}{$post_config_context}{$memory_context}", - ), - ); + $memory_context = $this->get_post_memory_context($post_id); + $messages = [ + [ + "role" => "system", + "content" => $system_prompt, + ], + [ + "role" => "user", + "content" => "Topic: {$topic}\n\nContext: {$context}{$chat_history_context}{$clarity_context}{$post_config_context}{$memory_context}", + ], + ]; - // Log the request for debugging (only when WP_DEBUG is on) - wpaw_debug_log( 'Calling OpenRouter API for planning. Topic: ' . substr( $topic, 0, 100 ) ); - wpaw_debug_log( 'Detected language: ' . $detected_language ); + // Log the request for debugging (only when WP_DEBUG is on) + wpaw_debug_log( + "Calling OpenRouter API for planning. Topic: " . + substr($topic, 0, 100), + ); + wpaw_debug_log("Detected language: " . $detected_language); - $this->maybe_inject_brave_search( $messages, $provider, $web_search_options ); - $response = $provider->chat( - $messages, - array_merge( - array( - 'temperature' => 0.7, - 'max_tokens' => 2200, - ), - $web_search_options - ), - 'planning' - ); + $this->maybe_inject_brave_search( + $messages, + $provider, + $web_search_options, + ); + $response = $provider->chat( + $messages, + array_merge( + [ + "temperature" => 0.7, + "max_tokens" => 2200, + ], + $web_search_options, + ), + "planning", + ); - wpaw_debug_log( 'OpenRouter API response received' ); + wpaw_debug_log("OpenRouter API response received"); - if ( is_wp_error( $response ) ) { - echo "data: " . wp_json_encode( - array( - 'type' => 'error', - 'message' => $response->get_error_message(), - ) - ) . "\n\n"; - flush(); - exit; - } + if (is_wp_error($response)) { + echo "data: " . + wp_json_encode([ + "type" => "error", + "message" => $response->get_error_message(), + ]) . + "\n\n"; + flush(); + exit(); + } - $content = $response['content']; - wpaw_debug_log( 'stream_generatePlan content length: ' . strlen( $content ) ); + $content = $response["content"]; + wpaw_debug_log( + "stream_generatePlan content length: " . strlen($content), + ); - // Handle empty response gracefully - if ( empty( trim( (string) $content ) ) ) { - $model_used = $response['model'] ?? 'unknown'; - $input_tokens = $response['input_tokens'] ?? 0; - echo "data: " . wp_json_encode( - array( - 'type' => 'error', - 'message' => sprintf( - 'The AI model (%s) returned an empty response. This usually means the model couldn\'t process the request. Try: 1) Use a different planning model in Settings, 2) Simplify your topic, or 3) Try again. (Tokens sent: %d)', - $model_used, - $input_tokens - ), - ) - ) . "\n\n"; - flush(); - exit; - } + // Handle empty response gracefully + if (empty(trim((string) $content))) { + $model_used = $response["model"] ?? "unknown"; + $input_tokens = $response["input_tokens"] ?? 0; + echo "data: " . + wp_json_encode([ + "type" => "error", + "message" => sprintf( + 'The AI model (%s) returned an empty response. This usually means the model couldn\'t process the request. Try: 1) Use a different planning model in Settings, 2) Simplify your topic, or 3) Try again. (Tokens sent: %d)', + $model_used, + $input_tokens, + ), + ]) . + "\n\n"; + flush(); + exit(); + } - $plan_json = $this->extract_plan_from_response( $content, $topic ); + $plan_json = $this->extract_plan_from_response($content, $topic); - if ( null === $plan_json ) { - wpaw_debug_log( 'extract_plan_from_response failed in streaming. Content preview: ' . substr( $content, 0, 500 ) ); - $preview = $this->build_model_output_preview( $content ); - echo "data: " . wp_json_encode( - array( - 'type' => 'error', - 'message' => 'The AI responded but the outline couldn\'t be parsed as JSON. This sometimes happens when the model adds extra text. Trying again usually fixes this. Preview: ' . $preview, - ) - ) . "\n\n"; - flush(); - exit; - } + if (null === $plan_json) { + wpaw_debug_log( + "extract_plan_from_response failed in streaming. Content preview: " . + substr($content, 0, 500), + ); + $preview = $this->build_model_output_preview($content); + echo "data: " . + wp_json_encode([ + "type" => "error", + "message" => + 'The AI responded but the outline couldn\'t be parsed as JSON. This sometimes happens when the model adds extra text. Trying again usually fixes this. Preview: ' . + $preview, + ]) . + "\n\n"; + flush(); + exit(); + } - $plan_json = $this->ensure_plan_sections_with_tasks( $plan_json ); + $plan_json = $this->ensure_plan_sections_with_tasks($plan_json); - // Persist planning exchange into session history. - if ( ! empty( $session_id ) ) { - $context_service = WP_Agentic_Writer_Context_Service::get_instance(); - $context_service->update_session_context( - $session_id, - array( - 'working_summary' => array( - 'text' => $this->build_memory_summary_from_plan( $plan_json ), - 'updated_at' => current_time( 'c' ), - 'source_message_count' => 0, - ), - ) - ); - $context_service->add_message( - $session_id, - array( - 'role' => 'user', - 'content' => trim( (string) $topic ), - 'timestamp' => current_time( 'c' ), - ) - ); - $context_service->add_message( - $session_id, - array( - 'role' => 'assistant', - 'type' => 'plan', - 'plan' => $plan_json, - 'content' => $this->build_plan_summary_for_session( $plan_json, $post_config ), - 'timestamp' => current_time( 'c' ), - ) - ); - } + // MEMANTO: Remember plan was generated. + do_action("wpaw_memanto_plan_generated", $post_id, $plan_json); - // Store plan in post meta. - if ( $post_id > 0 ) { - update_post_meta( $post_id, '_wpaw_plan', $plan_json ); - update_post_meta( $post_id, '_wpaw_detected_language', $effective_language ); - $summary = $this->build_memory_summary_from_plan( $plan_json ); - $this->update_post_memory( - $post_id, - array( - 'summary' => $summary, - 'last_prompt' => $topic, - 'last_intent' => 'generate', - ) - ); - } + // Persist planning exchange into session history. + if (!empty($session_id)) { + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $context_service->update_session_context($session_id, [ + "working_summary" => [ + "text" => $this->build_memory_summary_from_plan( + $plan_json, + ), + "updated_at" => current_time("c"), + "source_message_count" => 0, + ], + ]); + $context_service->add_message($session_id, [ + "role" => "user", + "content" => trim((string) $topic), + "timestamp" => current_time("c"), + ]); + $context_service->add_message($session_id, [ + "role" => "assistant", + "type" => "plan", + "plan" => $plan_json, + "content" => $this->build_plan_summary_for_session( + $plan_json, + $post_config, + ), + "timestamp" => current_time("c"), + ]); + } - $total_cost += $response['cost']; + // Store plan in post meta. + if ($post_id > 0) { + update_post_meta($post_id, "_wpaw_plan", $plan_json); + update_post_meta( + $post_id, + "_wpaw_detected_language", + $effective_language, + ); + $summary = $this->build_memory_summary_from_plan($plan_json); + $this->update_post_memory($post_id, [ + "summary" => $summary, + "last_prompt" => $topic, + "last_intent" => "generate", + ]); + } - // Track plan cost. - $this->track_ai_cost( - $post_id, - $response['model'], - 'planning', - $response['input_tokens'], - $response['output_tokens'], - $response['cost'], - $provider_result, - $session_id, - 'success' - ); + $total_cost += $response["cost"]; - // Send plan data. - echo "data: " . wp_json_encode( - array( - 'type' => 'plan', - 'plan' => $plan_json, - 'cost' => $response['cost'], - 'web_search_results' => $response['web_search_results'] ?? array(), - 'context_audit' => $context_package['audit'] ?? array(), - ) - ) . "\n\n"; - flush(); + // Track plan cost. + $this->track_ai_cost( + $post_id, + $response["model"], + "planning", + $response["input_tokens"], + $response["output_tokens"], + $response["cost"], + $provider_result, + $session_id, + "success", + ); - // Send plan complete status - if ( $auto_execute ) { - $this->send_status( 'plan_complete', 'Outline created! Starting to write...' ); - } else { - $this->send_status( 'plan_complete', 'Outline ready.' ); - echo "data: " . wp_json_encode( - array_merge( - array( - 'type' => 'complete', - 'totalCost' => $total_cost, - ), - $this->build_provider_metadata( - $provider_result, - $response['model'] ?? '' - ) - ) - ) . "\n\n"; - flush(); - } + // Send plan data. + echo "data: " . + wp_json_encode([ + "type" => "plan", + "plan" => $plan_json, + "cost" => $response["cost"], + "web_search_results" => + $response["web_search_results"] ?? [], + "context_audit" => $context_package["audit"] ?? [], + ]) . + "\n\n"; + flush(); - // Step 2: Auto-execute if requested. - if ( $auto_execute && ! empty( $plan_json['sections'] ) ) { - // Define length constraints with section counts - $length_constraints = array( - 'short' => 'Write exactly 2-3 main sections. Each section should have 3-4 substantial paragraphs (4-6 sentences each). Go deep into each point with examples and explanations. Total: ~400 words.', - 'medium' => 'Write 4-5 main sections. Each section should have 2-3 meaningful paragraphs (3-5 sentences each). Balance breadth with adequate depth. Total: ~750 words.', - 'long' => 'Write 6-8 main sections. Each section should have 2-3 paragraphs (3-4 sentences each) with detailed examples and comprehensive coverage. Total: ~1500 words.', - ); + // Send plan complete status + if ($auto_execute) { + $this->send_status( + "plan_complete", + "Outline created! Starting to write...", + ); + } else { + $this->send_status("plan_complete", "Outline ready."); + echo "data: " . + wp_json_encode( + array_merge( + [ + "type" => "complete", + "totalCost" => $total_cost, + ], + $this->build_provider_metadata( + $provider_result, + $response["model"] ?? "", + ), + ), + ) . + "\n\n"; + flush(); + } - $depth_instruction = array( - 'short' => 'CRITICAL: Fewer sections, more depth per section. Avoid skimming. Each section should feel complete and comprehensive.', - 'medium' => 'Balance: Moderate sections with good paragraph development. Each point should be explained with at least one example.', - 'long' => 'Comprehensive: More sections covering all aspects, but still maintain substance in each paragraph.', - ); + // Step 2: Auto-execute if requested. + if ($auto_execute && !empty($plan_json["sections"])) { + // Define length constraints with section counts + $length_constraints = [ + "short" => + "Write exactly 2-3 main sections. Each section should have 3-4 substantial paragraphs (4-6 sentences each). Go deep into each point with examples and explanations. Total: ~400 words.", + "medium" => + "Write 4-5 main sections. Each section should have 2-3 meaningful paragraphs (3-5 sentences each). Balance breadth with adequate depth. Total: ~750 words.", + "long" => + "Write 6-8 main sections. Each section should have 2-3 paragraphs (3-4 sentences each) with detailed examples and comprehensive coverage. Total: ~1500 words.", + ]; - $length_instruction = $length_constraints[ $article_length ]; + $depth_instruction = [ + "short" => + "CRITICAL: Fewer sections, more depth per section. Avoid skimming. Each section should feel complete and comprehensive.", + "medium" => + "Balance: Moderate sections with good paragraph development. Each point should be explained with at least one example.", + "long" => + "Comprehensive: More sections covering all aspects, but still maintain substance in each paragraph.", + ]; - // Set post title from plan title with validation - if ( $post_id > 0 && ! empty( $plan_json['title'] ) ) { - // Verify post exists and user can edit - $post = get_post( $post_id ); - if ( $post && current_user_can( 'edit_post', $post_id ) ) { - // Disable revisions during this update - add_filter( 'wp_revisions_to_keep', '__return_zero', 999 ); + $length_instruction = $length_constraints[$article_length]; - // Update post title - $update_result = wp_update_post( - array( - 'ID' => $post_id, - 'post_title' => sanitize_text_field( $plan_json['title'] ), - ), - true // Return WP_Error on failure - ); + // Set post title from plan title with validation + if ($post_id > 0 && !empty($plan_json["title"])) { + // Verify post exists and user can edit + $post = get_post($post_id); + if ($post && current_user_can("edit_post", $post_id)) { + // Disable revisions during this update + add_filter( + "wp_revisions_to_keep", + "__return_zero", + 999, + ); - if ( is_wp_error( $update_result ) ) { - wpaw_debug_log( 'Failed to update post title: ' . $update_result->get_error_message() ); - } + // Update post title + $update_result = wp_update_post( + [ + "ID" => $post_id, + "post_title" => sanitize_text_field( + $plan_json["title"], + ), + ], + true, // Return WP_Error on failure + ); - // Restore filters - remove_filter( 'wp_revisions_to_keep', '__return_zero', 999 ); + if (is_wp_error($update_result)) { + wpaw_debug_log( + "Failed to update post title: " . + $update_result->get_error_message(), + ); + } - // Send title update to frontend for immediate sync - echo "data: " . wp_json_encode( - array( - 'type' => 'title_update', - 'title' => $plan_json['title'], - ) - ) . "\n\n"; - flush(); - } - } + // Restore filters + remove_filter( + "wp_revisions_to_keep", + "__return_zero", + 999, + ); - // Determine language instruction based on detected language - $language_instruction = $this->build_language_instruction( $effective_language, 'ENTIRE article (conversational responses and article text)' ); + // Send title update to frontend for immediate sync + echo "data: " . + wp_json_encode([ + "type" => "title_update", + "title" => $plan_json["title"], + ]) . + "\n\n"; + flush(); + } + } - $image_instruction = "IMAGE SUGGESTIONS: + // Determine language instruction based on detected language + $language_instruction = $this->build_language_instruction( + $effective_language, + "ENTIRE article (conversational responses and article text)", + ); + + $image_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) - Maximum 1-2 image suggestions per section"; - if ( empty( $post_config['include_images'] ) ) { - $image_instruction = "IMAGE SUGGESTIONS: + if (empty($post_config["include_images"])) { + $image_instruction = "IMAGE SUGGESTIONS: - Do NOT include any image suggestions or [IMAGE: ...] placeholders."; - } + } - $system_prompt = "You are an expert content writer and technical consultant. Your task is to provide helpful conversational feedback AND write the article content based on the provided plan. + $system_prompt = "You are an expert content writer and technical consultant. Your task is to provide helpful conversational feedback AND write the article content based on the provided plan. CRITICAL LANGUAGE REQUIREMENT: {$language_instruction} @@ -3163,510 +3702,645 @@ Content here... Remember: You MUST include the ~~~ARTICLE~~~ divider to separate your conversational response from the article content."; - $sections_to_write = array(); - foreach ( $plan_json['sections'] as $index => $section ) { - $status = $section['status'] ?? 'pending'; - if ( 'done' === $status ) { - continue; - } - $sections_to_write[ $index ] = $section; - } + $sections_to_write = []; + foreach ($plan_json["sections"] as $index => $section) { + $status = $section["status"] ?? "pending"; + if ("done" === $status) { + continue; + } + $sections_to_write[$index] = $section; + } - $section_index = 0; - $total_sections = count( $sections_to_write ); + $section_index = 0; + $total_sections = count($sections_to_write); - // Send initial writing status - $this->send_status( 'writing', 'Writing content...' ); + // Send initial writing status + $this->send_status("writing", "Writing content..."); - foreach ( $sections_to_write as $section_position => $section ) { - $section_index++; - $is_first_section = $section_index === 1; - $heading = $section['heading'] ?? $section['title'] ?? ''; - $section_id = $section['id'] ?? wp_generate_uuid4(); - $plan_json['sections'][ $section_position ]['id'] = $section_id; - $plan_json['sections'][ $section_position ]['status'] = 'in_progress'; - if ( $post_id > 0 ) { - update_post_meta( $post_id, '_wpaw_plan', $plan_json ); - } + foreach ($sections_to_write as $section_position => $section) { + $section_index++; + $is_first_section = $section_index === 1; + $heading = $section["heading"] ?? ($section["title"] ?? ""); + $section_id = $section["id"] ?? wp_generate_uuid4(); + $plan_json["sections"][$section_position][ + "id" + ] = $section_id; + $plan_json["sections"][$section_position]["status"] = + "in_progress"; + if ($post_id > 0) { + update_post_meta($post_id, "_wpaw_plan", $plan_json); + } - echo "data: " . wp_json_encode( - array( - 'type' => 'section_start', - 'sectionId' => $section_id, - 'heading' => $heading, - 'index' => $section_index, - 'total' => $total_sections, - ) - ) . "\n\n"; - flush(); + echo "data: " . + wp_json_encode([ + "type" => "section_start", + "sectionId" => $section_id, + "heading" => $heading, + "index" => $section_index, + "total" => $total_sections, + ]) . + "\n\n"; + flush(); - // Send section-specific status - $this->send_status( 'writing_section', "Writing section {$section_index} of {$total_sections}: {$heading}" ); + // Send section-specific status + $this->send_status( + "writing_section", + "Writing section {$section_index} of {$total_sections}: {$heading}", + ); - $section_prompt = "Write content for the \"{$heading}\" section.\n\n"; - $section_prompt .= "Content requirements:\n"; + $section_prompt = "Write content for the \"{$heading}\" section.\n\n"; + $section_prompt .= "Content requirements:\n"; - if ( ! empty( $section['content'] ) && is_array( $section['content'] ) ) { - foreach ( $section['content'] as $item ) { - if ( ! empty( $item['content'] ) ) { - $section_prompt .= "- {$item['content']}\n"; - } - } - } - $section_prompt .= "\nIMPORTANT: Start with a brief conversational note, then include ~~~ARTICLE~~~ divider, then write the section content in Markdown.\n"; - if ( $is_first_section ) { - $section_prompt .= "\nNOTE: This is the first section. Start directly with the section heading as an H2 (##), not an H1. The article title is already set separately.\n"; - } + if ( + !empty($section["content"]) && + is_array($section["content"]) + ) { + foreach ($section["content"] as $item) { + if (!empty($item["content"])) { + $section_prompt .= "- {$item["content"]}\n"; + } + } + } + $section_prompt .= + "\nIMPORTANT: Start with a brief conversational note, then include ~~~ARTICLE~~~ divider, then write the section content in Markdown.\n"; + if ($is_first_section) { + $section_prompt .= + "\nNOTE: This is the first section. Start directly with the section heading as an H2 (##), not an H1. The article title is already set separately.\n"; + } - $messages = array( - array( - 'role' => 'system', - 'content' => $system_prompt, - ), - array( - 'role' => 'user', - 'content' => $section_prompt, - ), - ); + $messages = [ + [ + "role" => "system", + "content" => $system_prompt, + ], + [ + "role" => "user", + "content" => $section_prompt, + ], + ]; - // Log before calling streaming API - wpaw_debug_log( 'Starting section generation: ' . $heading ); + // Log before calling streaming API + wpaw_debug_log("Starting section generation: " . $heading); - // Send heading block first (but NOT for first section to avoid duplication with post title) - if ( ! $is_first_section && $heading ) { - echo "data: " . wp_json_encode( - array( - 'type' => 'block', - 'sectionId' => $section_id, - 'block' => array( - 'type' => 'heading', - 'content' => $heading, - 'level' => 2, - ), - ) - ) . "\n\n"; - flush(); - } + // Send heading block first (but NOT for first section to avoid duplication with post title) + if (!$is_first_section && $heading) { + echo "data: " . + wp_json_encode([ + "type" => "block", + "sectionId" => $section_id, + "block" => [ + "type" => "heading", + "content" => $heading, + "level" => 2, + ], + ]) . + "\n\n"; + flush(); + } - // Use streaming for real-time content generation! - $accumulated_content = ''; - $section_cost = 0; - $conversational_sent = false; - $divider_found = false; - $markdown_content = ''; // Store complete markdown for later parsing + // Use streaming for real-time content generation! + $accumulated_content = ""; + $section_cost = 0; + $conversational_sent = false; + $divider_found = false; + $markdown_content = ""; // Store complete markdown for later parsing - wpaw_debug_log( 'Calling OpenRouter streaming API' ); + wpaw_debug_log("Calling OpenRouter streaming API"); - $response = $provider->chat_stream( - $messages, - array( 'temperature' => 0.8 ), - 'execution', - function( $chunk, $is_complete, $full_content ) use ( &$accumulated_content, &$section_cost, &$total_cost, $post_id, $provider, &$conversational_sent, &$divider_found, &$markdown_content ) { - // Accumulate the full content - $accumulated_content = $full_content; + $response = $provider->chat_stream( + $messages, + ["temperature" => 0.8], + "execution", + function ($chunk, $is_complete, $full_content) use ( + &$accumulated_content, + &$section_cost, + &$total_cost, + $post_id, + $provider, + &$conversational_sent, + &$divider_found, + &$markdown_content, + ) { + // Accumulate the full content + $accumulated_content = $full_content; - // Check for divider - if ( ! $divider_found && strpos( $accumulated_content, '~~~ARTICLE~~~' ) !== false ) { - $divider_found = true; + // Check for divider + if ( + !$divider_found && + strpos( + $accumulated_content, + "~~~ARTICLE~~~", + ) !== false + ) { + $divider_found = true; - // Split content on divider - $parts = explode( '~~~ARTICLE~~~', $accumulated_content, 2 ); - $conversational = trim( $parts[0] ); - $markdown_content = isset( $parts[1] ) ? trim( $parts[1] ) : ''; + // Split content on divider + $parts = explode( + "~~~ARTICLE~~~", + $accumulated_content, + 2, + ); + $conversational = trim($parts[0]); + $markdown_content = isset($parts[1]) + ? trim($parts[1]) + : ""; - // CRITICAL: Remove any remaining divider markers from conversational content - $conversational = str_replace( '~~~ARTICLE~~~', '', $conversational ); - $conversational = preg_replace( '/~~~ARTICLE~~~[\r\n]*/', '', $conversational ); - $conversational = trim( $conversational ); + // CRITICAL: Remove any remaining divider markers from conversational content + $conversational = str_replace( + "~~~ARTICLE~~~", + "", + $conversational, + ); + $conversational = preg_replace( + '/~~~ARTICLE~~~[\r\n]*/', + "", + $conversational, + ); + $conversational = trim($conversational); - // Send conversational part as chat message - if ( ! empty( $conversational ) && ! $conversational_sent ) { - echo "data: " . wp_json_encode( - array( - 'type' => 'conversational', - 'content' => $conversational, - ) - ) . "\n\n"; - flush(); - $conversational_sent = true; - } + // Send conversational part as chat message + if ( + !empty($conversational) && + !$conversational_sent + ) { + echo "data: " . + wp_json_encode([ + "type" => "conversational", + "content" => $conversational, + ]) . + "\n\n"; + flush(); + $conversational_sent = true; + } - // Stream raw markdown for display (no parsing yet) - if ( ! empty( $markdown_content ) ) { - echo "data: " . wp_json_encode( - array( - 'type' => 'markdown_stream', - 'content' => $markdown_content, - ) - ) . "\n\n"; - flush(); - } - } elseif ( ! $divider_found ) { - // No divider yet, this is all conversational - // Send conversational updates as they stream - if ( ! $conversational_sent ) { - echo "data: " . wp_json_encode( - array( - 'type' => 'conversational_stream', - 'content' => $accumulated_content, - ) - ) . "\n\n"; - flush(); - } - } else { - // Divider found, stream markdown content as it comes - $parts = explode( '~~~ARTICLE~~~', $accumulated_content, 2 ); - $markdown_content = isset( $parts[1] ) ? trim( $parts[1] ) : ''; + // Stream raw markdown for display (no parsing yet) + if (!empty($markdown_content)) { + echo "data: " . + wp_json_encode([ + "type" => "markdown_stream", + "content" => $markdown_content, + ]) . + "\n\n"; + flush(); + } + } elseif (!$divider_found) { + // No divider yet, this is all conversational + // Send conversational updates as they stream + if (!$conversational_sent) { + echo "data: " . + wp_json_encode([ + "type" => "conversational_stream", + "content" => $accumulated_content, + ]) . + "\n\n"; + flush(); + } + } else { + // Divider found, stream markdown content as it comes + $parts = explode( + "~~~ARTICLE~~~", + $accumulated_content, + 2, + ); + $markdown_content = isset($parts[1]) + ? trim($parts[1]) + : ""; - // Stream raw markdown for display (no parsing yet) - if ( ! empty( $markdown_content ) ) { - echo "data: " . wp_json_encode( - array( - 'type' => 'markdown_stream', - 'content' => $markdown_content, - ) - ) . "\n\n"; - flush(); - } - } - } - ); + // Stream raw markdown for display (no parsing yet) + if (!empty($markdown_content)) { + echo "data: " . + wp_json_encode([ + "type" => "markdown_stream", + "content" => $markdown_content, + ]) . + "\n\n"; + flush(); + } + } + }, + ); - if ( is_wp_error( $response ) ) { - echo "data: " . wp_json_encode( - array( - 'type' => 'error', - 'message' => $response->get_error_message(), - ) - ) . "\n\n"; - flush(); - exit; - } + if (is_wp_error($response)) { + echo "data: " . + wp_json_encode([ + "type" => "error", + "message" => $response->get_error_message(), + ]) . + "\n\n"; + flush(); + exit(); + } - // Handle empty response from model - if ( empty( trim( (string) $accumulated_content ) ) ) { - $model_used = $response['model'] ?? 'unknown'; - wpaw_debug_log( "Section writing got empty response from model: {$model_used}" ); - echo "data: " . wp_json_encode( - array( - 'type' => 'error', - 'message' => sprintf( 'Section "%s" got an empty response from the AI model (%s). Please retry.', $heading, $model_used ), - ) - ) . "\n\n"; - flush(); - exit; - } + // Handle empty response from model + if (empty(trim((string) $accumulated_content))) { + $model_used = $response["model"] ?? "unknown"; + wpaw_debug_log( + "Section writing got empty response from model: {$model_used}", + ); + echo "data: " . + wp_json_encode([ + "type" => "error", + "message" => sprintf( + 'Section "%s" got an empty response from the AI model (%s). Please retry.', + $heading, + $model_used, + ), + ]) . + "\n\n"; + flush(); + exit(); + } - // If divider was never found, treat the entire content as markdown - if ( ! $divider_found ) { - wpaw_debug_log( 'No ~~~ARTICLE~~~ divider found in section response. Using full content as markdown.' ); - $markdown_content = $accumulated_content; - // Strip any leading conversational fluff (first line if it looks like a note) - $lines = explode( "\n", $markdown_content ); - if ( ! empty( $lines[0] ) && ! preg_match( '/^#{1,3}\s/', $lines[0] ) && strlen( $lines[0] ) < 200 ) { - // First line might be a brief conversational note, skip it - $first_line = array_shift( $lines ); - if ( ! empty( $first_line ) ) { - echo "data: " . wp_json_encode( - array( - 'type' => 'conversational', - 'content' => trim( $first_line ), - ) - ) . "\n\n"; - flush(); - } - $markdown_content = implode( "\n", $lines ); - } - } + // If divider was never found, treat the entire content as markdown + if (!$divider_found) { + wpaw_debug_log( + "No ~~~ARTICLE~~~ divider found in section response. Using full content as markdown.", + ); + $markdown_content = $accumulated_content; + // Strip any leading conversational fluff (first line if it looks like a note) + $lines = explode("\n", $markdown_content); + if ( + !empty($lines[0]) && + !preg_match("/^#{1,3}\s/", $lines[0]) && + strlen($lines[0]) < 200 + ) { + // First line might be a brief conversational note, skip it + $first_line = array_shift($lines); + if (!empty($first_line)) { + echo "data: " . + wp_json_encode([ + "type" => "conversational", + "content" => trim($first_line), + ]) . + "\n\n"; + flush(); + } + $markdown_content = implode("\n", $lines); + } + } - $section_cost = $response['cost'] ?? 0; - $total_cost += $section_cost; + $section_cost = $response["cost"] ?? 0; + $total_cost += $section_cost; - // Debug: Log execution cost tracking (only when WP_DEBUG is on) - wpaw_debug_log( 'Tracking execution cost', array( - 'post_id' => $post_id, - 'model' => $response['model'] ?? 'unknown', - 'cost' => $section_cost - ) ); + // Debug: Log execution cost tracking (only when WP_DEBUG is on) + wpaw_debug_log("Tracking execution cost", [ + "post_id" => $post_id, + "model" => $response["model"] ?? "unknown", + "cost" => $section_cost, + ]); - // Track execution cost for this section. - $this->track_ai_cost( - $post_id, - $response['model'] ?? '', - 'execution', - $response['input_tokens'] ?? 0, - $response['output_tokens'] ?? 0, - $section_cost, - $provider_result, - $session_id ?? '', - 'success' - ); + // Track execution cost for this section. + $this->track_ai_cost( + $post_id, + $response["model"] ?? "", + "execution", + $response["input_tokens"] ?? 0, + $response["output_tokens"] ?? 0, + $section_cost, + $provider_result, + $session_id ?? "", + "success", + ); - // NOW parse the complete markdown content and send blocks - if ( ! empty( $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 ); + // NOW parse the complete markdown content and send blocks + if (!empty($markdown_content)) { + // Extract image placeholders and generate IDs + $image_placeholders = []; + if ( + preg_match_all( + "/\[IMAGE:\s*(.+?)\]/i", + $markdown_content, + $matches, + ) + ) { + $image_manager = WP_Agentic_Writer_Image_Manager::get_instance(); - foreach ( $markdown_blocks as $block ) { - echo "data: " . wp_json_encode( - array( - 'type' => 'block', - 'block' => $block, - 'sectionId' => $section_id, - ) - ) . "\n\n"; - flush(); - } - } + foreach ($matches[1] as $index => $description) { + $agent_image_id = + "img_" . + $post_id . + "_" . + time() . + "_" . + ($index + 1); + $image_placeholders[] = [ + "agent_image_id" => $agent_image_id, + "description" => trim($description), + ]; - $plan_json['sections'][ $section_position ]['status'] = 'done'; - if ( $post_id > 0 ) { - update_post_meta( $post_id, '_wpaw_plan', $plan_json ); - } - echo "data: " . wp_json_encode( - array( - 'type' => 'section_complete', - 'sectionId' => $section_id, - ) - ) . "\n\n"; - flush(); - } - } + // Save to database + $image_manager->save_image_recommendation( + $post_id, + $agent_image_id, + "section_" . $section_id, + $heading, + trim($description), + trim($description), + ); + } + } - // Send complete status - $this->send_status( 'complete', 'Article finished!' ); + $markdown_blocks = WP_Agentic_Writer_Markdown_Parser::parse( + $markdown_content, + $image_placeholders, + ); - // Send conversational completion message before complete signal - echo "data: " . wp_json_encode( - array( - 'type' => 'conversational', - 'content' => "✅ Article generation complete! The content has been added to your editor. Feel free to ask for refinements or adjustments to any section.", - ) - ) . "\n\n"; - flush(); + foreach ($markdown_blocks as $block) { + echo "data: " . + wp_json_encode([ + "type" => "block", + "block" => $block, + "sectionId" => $section_id, + ]) . + "\n\n"; + flush(); + } + } - // Send completion message. - echo "data: " . wp_json_encode( - array_merge( - array( - 'type' => 'complete', - 'totalCost' => $total_cost, - ), - $this->build_provider_metadata( - $provider_result, - $response['model'] ?? '' - ) - ) - ) . "\n\n"; - flush(); - } catch ( Exception $e ) { - echo "data: " . wp_json_encode( - array( - 'type' => 'error', - 'message' => $e->getMessage(), - ) - ) . "\n\n"; - flush(); - } + $plan_json["sections"][$section_position]["status"] = + "done"; + if ($post_id > 0) { + update_post_meta($post_id, "_wpaw_plan", $plan_json); + } + echo "data: " . + wp_json_encode([ + "type" => "section_complete", + "sectionId" => $section_id, + ]) . + "\n\n"; + flush(); + } + } - exit; - } + // Send complete status + $this->send_status("complete", "Article finished!"); - /** - * Build a compact, persistent outline summary for session history. - * - * @since 0.2.2 - * @param array $plan_json Plan data. - * @param array $post_config Post config. - * @return string - */ - private function build_plan_summary_for_session( $plan_json, $post_config = array() ) { - $title = trim( (string) ( $plan_json['title'] ?? 'Outline ready' ) ); - $sections = is_array( $plan_json['sections'] ?? null ) ? $plan_json['sections'] : array(); + // Send conversational completion message before complete signal + echo "data: " . + wp_json_encode([ + "type" => "conversational", + "content" => + "✅ Article generation complete! The content has been added to your editor. Feel free to ask for refinements or adjustments to any section.", + ]) . + "\n\n"; + flush(); - $lines = array(); - $lines[] = 'Outline ready.'; - $lines[] = ''; - $lines[] = $title; - $lines[] = ''; + // Send completion message. + echo "data: " . + wp_json_encode( + array_merge( + [ + "type" => "complete", + "totalCost" => $total_cost, + ], + $this->build_provider_metadata( + $provider_result, + $response["model"] ?? "", + ), + ), + ) . + "\n\n"; + flush(); + } catch (Exception $e) { + echo "data: " . + wp_json_encode([ + "type" => "error", + "message" => $e->getMessage(), + ]) . + "\n\n"; + flush(); + } - $focus = trim( (string) ( $post_config['seo_focus_keyword'] ?? '' ) ); - $secondary = trim( (string) ( $post_config['seo_secondary_keywords'] ?? '' ) ); - if ( '' !== $focus || '' !== $secondary ) { - $lines[] = 'SEO Snapshot:'; - if ( '' !== $focus ) { - $lines[] = '- Focus: ' . $focus; - } - if ( '' !== $secondary ) { - $lines[] = '- Secondary: ' . $secondary; - } - $lines[] = ''; - } + exit(); + } - $lines[] = 'Sections:'; - $index = 1; - foreach ( $sections as $section ) { - $heading = trim( (string) ( $section['heading'] ?? $section['title'] ?? '' ) ); - if ( '' === $heading ) { - continue; - } - $lines[] = $index . '. ' . $heading; - $index++; - } + /** + * Build a compact, persistent outline summary for session history. + * + * @since 0.2.2 + * @param array $plan_json Plan data. + * @param array $post_config Post config. + * @return string + */ + private function build_plan_summary_for_session( + $plan_json, + $post_config = [], + ) { + $title = trim((string) ($plan_json["title"] ?? "Outline ready")); + $sections = is_array($plan_json["sections"] ?? null) + ? $plan_json["sections"] + : []; - return implode( "\n", $lines ); - } + $lines = []; + $lines[] = "Outline ready."; + $lines[] = ""; + $lines[] = $title; + $lines[] = ""; - /** - * Handle execute article request. - * - * @since 0.1.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_execute_article( $request ) { - $params = $request->get_json_params(); - $post_id = $params['postId'] ?? 0; - $session_id = $this->resolve_or_create_session_id( $params['sessionId'] ?? '', $post_id ); - $stream = $params['stream'] ?? false; - $recommended_title = ''; - $chat_history = $params['chatHistory'] ?? array(); - $post_config = $this->resolve_post_config_from_request( $params, $post_id ); - $post_config_context = $this->build_post_config_context( $post_config ); - $stored_language = get_post_meta( $post_id, '_wpaw_detected_language', true ); - $detected_language = $params['detectedLanguage'] ?? $stored_language; - $effective_language = $this->resolve_language_preference( $post_config, $detected_language ); + $focus = trim((string) ($post_config["seo_focus_keyword"] ?? "")); + $secondary = trim( + (string) ($post_config["seo_secondary_keywords"] ?? ""), + ); + if ("" !== $focus || "" !== $secondary) { + $lines[] = "SEO Snapshot:"; + if ("" !== $focus) { + $lines[] = "- Focus: " . $focus; + } + if ("" !== $secondary) { + $lines[] = "- Secondary: " . $secondary; + } + $lines[] = ""; + } - // Auto-save post and link conversation if needed (only for post_id = 0) - if ( empty( $post_id ) && ! empty( $session_id ) ) { - $post_id = $this->ensure_conversation_linked_to_post( $session_id, $post_id ); - } + $lines[] = "Sections:"; + $index = 1; + foreach ($sections as $section) { + $heading = trim( + (string) ($section["heading"] ?? ($section["title"] ?? "")), + ); + if ("" === $heading) { + continue; + } + $lines[] = $index . ". " . $heading; + $index++; + } - // Check post permission if post_id is provided. - if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } + return implode("\n", $lines); + } - // Get plan from post meta. - $plan = get_post_meta( $post_id, '_wpaw_plan', true ); + /** + * Handle execute article request. + * + * @since 0.1.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_execute_article($request) + { + $params = $request->get_json_params(); + $post_id = $params["postId"] ?? 0; + $session_id = $this->resolve_or_create_session_id( + $params["sessionId"] ?? "", + $post_id, + ); + $stream = $params["stream"] ?? false; + $recommended_title = ""; + $chat_history = $params["chatHistory"] ?? []; + $post_config = $this->resolve_post_config_from_request( + $params, + $post_id, + ); + $post_config_context = $this->build_post_config_context($post_config); + $stored_language = get_post_meta( + $post_id, + "_wpaw_detected_language", + true, + ); + $detected_language = $params["detectedLanguage"] ?? $stored_language; + $effective_language = $this->resolve_language_preference( + $post_config, + $detected_language, + ); - if ( empty( $plan ) ) { - return new WP_Error( - 'no_plan', - __( 'No plan found. Please generate a plan first.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } + // Auto-save post and link conversation if needed (only for post_id = 0) + if (empty($post_id) && !empty($session_id)) { + $post_id = $this->ensure_conversation_linked_to_post( + $session_id, + $post_id, + ); + } - if ( $stream ) { - // For streaming, link conversation to post BEFORE getting plan from meta - if ( empty( $post_id ) && ! empty( $session_id ) ) { - $post_id = $this->ensure_conversation_linked_to_post( $session_id, $post_id ); - } + // Check post permission if post_id is provided. + if ($post_id > 0 && !$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to edit this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } - // Now get plan after potentially having a valid post_id - $plan = get_post_meta( $post_id, '_wpaw_plan', true ); - if ( empty( $plan ) ) { - echo "data: " . wp_json_encode( - array( - 'type' => 'error', - 'message' => 'No plan found. Please generate a plan first.', - ) - ) . "\n\n"; - flush(); - return; - } + // Get plan from post meta. + $plan = get_post_meta($post_id, "_wpaw_plan", true); - $this->stream_execute_article( $plan, $post_id, $post_config, $effective_language, $session_id ); - exit; - } + if (empty($plan)) { + return new WP_Error( + "no_plan", + __( + "No plan found. Please generate a plan first.", + "wp-agentic-writer", + ), + ["status" => 400], + ); + } - $plan = $this->ensure_plan_sections_with_tasks( $plan ); + if ($stream) { + // For streaming, link conversation to post BEFORE getting plan from meta + if (empty($post_id) && !empty($session_id)) { + $post_id = $this->ensure_conversation_linked_to_post( + $session_id, + $post_id, + ); + } - // Update post title from the plan title when available. - if ( ! empty( $plan['title'] ) ) { - $recommended_title = sanitize_text_field( $plan['title'] ); - if ( $post_id > 0 ) { - $post = get_post( $post_id ); - if ( $post && current_user_can( 'edit_post', $post_id ) ) { - if ( empty( $post->post_title ) ) { - wp_update_post( - array( - 'ID' => $post_id, - 'post_title' => $recommended_title, - ) - ); - } - } - } - } + // Now get plan after potentially having a valid post_id + $plan = get_post_meta($post_id, "_wpaw_plan", true); + if (empty($plan)) { + echo "data: " . + wp_json_encode([ + "type" => "error", + "message" => + "No plan found. Please generate a plan first.", + ]) . + "\n\n"; + flush(); + return; + } - // Get provider for writing task. - $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' ); - $provider = $provider_result->provider; + $this->stream_execute_article( + $plan, + $post_id, + $post_config, + $effective_language, + $session_id, + ); + exit(); + } - $image_instruction = "IMAGE SUGGESTIONS: + $plan = $this->ensure_plan_sections_with_tasks($plan); + + // MEMANTO: Plan execution implies approval. + do_action("wpaw_memanto_plan_approved", $post_id, $plan); + + // Update post title from the plan title when available. + if (!empty($plan["title"])) { + $recommended_title = sanitize_text_field($plan["title"]); + if ($post_id > 0) { + $post = get_post($post_id); + if ($post && current_user_can("edit_post", $post_id)) { + if (empty($post->post_title)) { + wp_update_post([ + "ID" => $post_id, + "post_title" => $recommended_title, + ]); + } + } + } + } + + // Get provider for writing task. + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( + "writing", + ); + $provider = $provider_result->provider; + + $image_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 - Example: [IMAGE: Screenshot of the plugin settings panel showing the API key field]"; - if ( empty( $post_config['include_images'] ) ) { - $image_instruction = "IMAGE SUGGESTIONS: + if (empty($post_config["include_images"])) { + $image_instruction = "IMAGE SUGGESTIONS: - Do NOT include any image suggestions or [IMAGE: ...] placeholders."; - } + } - $language_instruction = $this->build_language_instruction( $effective_language, 'article content' ); + $language_instruction = $this->build_language_instruction( + $effective_language, + "article content", + ); - // Build chat history context for continuity - $chat_history_context = ''; - if ( ! empty( $chat_history ) && is_array( $chat_history ) ) { - $chat_history_context = "\n\n--- CONVERSATION CONTEXT ---\n"; - foreach ( $chat_history as $msg ) { - $role = isset( $msg['role'] ) ? ucfirst( $msg['role'] ) : 'Unknown'; - $content = isset( $msg['content'] ) ? $msg['content'] : ''; - if ( ! empty( $content ) && 'system' !== strtolower( $msg['role'] ?? '' ) ) { - $chat_history_context .= "{$role}: {$content}\n\n"; - } - } - $chat_history_context .= "--- END CONVERSATION CONTEXT ---\n"; - $chat_history_context .= "Use the above conversation to understand the user's intent and preferences for this article."; - } + // Build chat history context for continuity + $chat_history_context = ""; + if (!empty($chat_history) && is_array($chat_history)) { + $chat_history_context = "\n\n--- CONVERSATION CONTEXT ---\n"; + foreach ($chat_history as $msg) { + $role = isset($msg["role"]) ? ucfirst($msg["role"]) : "Unknown"; + $content = isset($msg["content"]) ? $msg["content"] : ""; + if ( + !empty($content) && + "system" !== strtolower($msg["role"] ?? "") + ) { + $chat_history_context .= "{$role}: {$content}\n\n"; + } + } + $chat_history_context .= "--- END CONVERSATION CONTEXT ---\n"; + $chat_history_context .= + "Use the above conversation to understand the user's intent and preferences for this article."; + } - // Build SEO instructions if SEO is enabled - $seo_instruction = ''; - $internal_links_instruction = ''; - if ( ! empty( $post_config['seo_enabled'] ) && ! empty( $post_config['seo_focus_keyword'] ) ) { - $focus_keyword = $post_config['seo_focus_keyword']; - $seo_instruction = "\n\nSEO OPTIMIZATION REQUIREMENTS (CRITICAL - MUST FOLLOW): + // Build SEO instructions if SEO is enabled + $seo_instruction = ""; + $internal_links_instruction = ""; + if ( + !empty($post_config["seo_enabled"]) && + !empty($post_config["seo_focus_keyword"]) + ) { + $focus_keyword = $post_config["seo_focus_keyword"]; + $seo_instruction = "\n\nSEO OPTIMIZATION REQUIREMENTS (CRITICAL - MUST FOLLOW): - Focus Keyword: \"{$focus_keyword}\" - MANDATORY: Include the exact focus keyword \"{$focus_keyword}\" in the article title (preferably at the beginning) - MANDATORY: Use the focus keyword in the FIRST paragraph (within the first 100 words) @@ -3678,19 +4352,25 @@ Remember: You MUST include the ~~~ARTICLE~~~ divider to separate your conversati - When suggesting images, include the focus keyword or related terms in the alt text - Keep the article title under 60 characters"; - // Get internal link suggestions - $internal_links = $this->suggest_internal_links( $post_id, $focus_keyword, 3 ); - if ( ! empty( $internal_links ) ) { - $internal_links_instruction = "\n\nINTERNAL LINKS (optional - use where contextually relevant):\n"; - foreach ( $internal_links as $link ) { - $internal_links_instruction .= "- [{$link['title']}]({$link['url']})\n"; - } - $internal_links_instruction .= "Naturally incorporate 1-2 of these internal links where they add value to the reader. Use descriptive anchor text, not 'click here'."; - } - } + // Get internal link suggestions + $internal_links = $this->suggest_internal_links( + $post_id, + $focus_keyword, + 3, + ); + if (!empty($internal_links)) { + $internal_links_instruction = + "\n\nINTERNAL LINKS (optional - use where contextually relevant):\n"; + foreach ($internal_links as $link) { + $internal_links_instruction .= "- [{$link["title"]}]({$link["url"]})\n"; + } + $internal_links_instruction .= + "Naturally incorporate 1-2 of these internal links where they add value to the reader. Use descriptive anchor text, not 'click here'."; + } + } - // Build system prompt for article generation. - $system_prompt = "You are an industry practitioner sharing insights with a colleague. Write engaging, high-information-density content based on the provided article plan. + // Build system prompt for article generation. + $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. @@ -3723,224 +4403,278 @@ IMAGE SUGGESTIONS: - Maximum 1 image per section {$image_instruction}"; - // Generate content for each section. - $blocks = array(); - $total_cost = 0; + // Generate content for each section. + $blocks = []; + $total_cost = 0; - $sections_to_write = array(); - foreach ( $plan['sections'] as $index => $section ) { - $status = $section['status'] ?? 'pending'; - if ( 'done' === $status ) { - continue; - } - $sections_to_write[ $index ] = $section; - } + $sections_to_write = []; + foreach ($plan["sections"] as $index => $section) { + $status = $section["status"] ?? "pending"; + if ("done" === $status) { + continue; + } + $sections_to_write[$index] = $section; + } - foreach ( $sections_to_write as $section ) { - $heading = $section['heading'] ?? $section['title'] ?? ''; - $section_prompt = $heading ? "Write the \"{$heading}\" section.\n\n" : "Write the next section.\n\n"; - $section_prompt .= "Content requirements:\n"; + foreach ($sections_to_write as $section) { + $heading = $section["heading"] ?? ($section["title"] ?? ""); + $section_prompt = $heading + ? "Write the \"{$heading}\" section.\n\n" + : "Write the next section.\n\n"; + $section_prompt .= "Content requirements:\n"; - if ( ! empty( $section['content'] ) && is_array( $section['content'] ) ) { - foreach ( $section['content'] as $item ) { - if ( ! empty( $item['content'] ) ) { - $section_prompt .= "- {$item['content']}\n"; - } - } - } + if (!empty($section["content"]) && is_array($section["content"])) { + foreach ($section["content"] as $item) { + if (!empty($item["content"])) { + $section_prompt .= "- {$item["content"]}\n"; + } + } + } - $messages = array( - array( - 'role' => 'system', - 'content' => $system_prompt, - ), - array( - 'role' => 'user', - 'content' => $section_prompt, - ), - ); + $messages = [ + [ + "role" => "system", + "content" => $system_prompt, + ], + [ + "role" => "user", + "content" => $section_prompt, + ], + ]; - $response = $provider->chat( $messages, array( 'temperature' => 0.8 ), 'execution' ); + $response = $provider->chat( + $messages, + ["temperature" => 0.8], + "execution", + ); - if ( is_wp_error( $response ) ) { - return new WP_Error( - 'execution_error', - $response->get_error_message(), - array( 'status' => 500 ) - ); - } + if (is_wp_error($response)) { + return new WP_Error( + "execution_error", + $response->get_error_message(), + ["status" => 500], + ); + } - // Add section blocks. - if ( $heading ) { - $blocks[] = array( - 'type' => 'heading', - 'content' => $heading, - 'level' => 2, - ); - } + // Add section blocks. + if ($heading) { + $blocks[] = [ + "type" => "heading", + "content" => $heading, + "level" => 2, + ]; + } - $section_blocks = WP_Agentic_Writer_Markdown_Parser::parse( $response['content'] ); - if ( ! empty( $section_blocks ) ) { - $first_block = $section_blocks[0]; - if ( isset( $first_block['blockName'] ) && 'core/heading' === $first_block['blockName'] ) { - $first_heading = $first_block['attrs']['content'] ?? ''; - if ( $heading && $first_heading && 0 === strcasecmp( trim( $first_heading ), trim( $heading ) ) ) { - array_shift( $section_blocks ); - } - } - foreach ( $section_blocks as $block ) { - $blocks[] = $block; - } - } else { - $blocks[] = array( - 'type' => 'paragraph', - 'content' => $response['content'], - ); - } + $section_blocks = WP_Agentic_Writer_Markdown_Parser::parse( + $response["content"], + ); + if (!empty($section_blocks)) { + $first_block = $section_blocks[0]; + if ( + isset($first_block["blockName"]) && + "core/heading" === $first_block["blockName"] + ) { + $first_heading = $first_block["attrs"]["content"] ?? ""; + if ( + $heading && + $first_heading && + 0 === strcasecmp(trim($first_heading), trim($heading)) + ) { + array_shift($section_blocks); + } + } + foreach ($section_blocks as $block) { + $blocks[] = $block; + } + } else { + $blocks[] = [ + "type" => "paragraph", + "content" => $response["content"], + ]; + } - $total_cost += $response['cost']; - } + $total_cost += $response["cost"]; - if ( ! empty( $sections_to_write ) ) { - foreach ( array_keys( $sections_to_write ) as $section_index ) { - $plan['sections'][ $section_index ]['status'] = 'done'; - } - if ( $post_id > 0 ) { - update_post_meta( $post_id, '_wpaw_plan', $plan ); - } - } + // MEMANTO: Remember section written. + $section_id = $section["id"] ?? sanitize_title($heading); + do_action( + "wpaw_memanto_section_written", + $post_id, + $section_id, + $heading, + ); + } - // Track total cost. - $this->track_ai_cost( - $post_id, - $this->get_provider_execution_model( $provider, 'execution' ), - 'execution', - 0, - 0, - $total_cost, - $provider_result, - '', - 'success' - ); + if (!empty($sections_to_write)) { + foreach (array_keys($sections_to_write) as $section_index) { + $plan["sections"][$section_index]["status"] = "done"; + } + if ($post_id > 0) { + update_post_meta($post_id, "_wpaw_plan", $plan); + } + } - return new WP_REST_Response( - array( - 'blocks' => $blocks, - 'cost' => $total_cost, - 'recommended_title' => $recommended_title, - 'provider_metadata' => $this->build_provider_metadata( - $provider_result, - $this->get_provider_execution_model( $provider, 'execution' ) - ), - ), - 200 - ); - } + // Track total cost. + $this->track_ai_cost( + $post_id, + $this->get_provider_execution_model($provider, "execution"), + "execution", + 0, + 0, + $total_cost, + $provider_result, + "", + "success", + ); - /** - * Stream article execution from a stored plan. - * - * @since 0.1.0 - * @param array $plan Plan data. - * @param int $post_id Post ID. - * @param array $post_config Post configuration. - * @param string $effective_language Effective language. - * @param string $session_id Session ID for conversation linking. - * @return void - */ - private function stream_execute_article( $plan, $post_id, $post_config = array(), $effective_language = 'english', $session_id = '' ) { - header( 'Content-Type: text/event-stream' ); - header( 'Cache-Control: no-cache' ); - header( 'X-Accel-Buffering: no' ); + return new WP_REST_Response( + [ + "blocks" => $blocks, + "cost" => $total_cost, + "recommended_title" => $recommended_title, + "provider_metadata" => $this->build_provider_metadata( + $provider_result, + $this->get_provider_execution_model($provider, "execution"), + ), + ], + 200, + ); + } - // 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(); + /** + * Stream article execution from a stored plan. + * + * @since 0.1.0 + * @param array $plan Plan data. + * @param int $post_id Post ID. + * @param array $post_config Post configuration. + * @param string $effective_language Effective language. + * @param string $session_id Session ID for conversation linking. + * @return void + */ + private function stream_execute_article( + $plan, + $post_id, + $post_config = [], + $effective_language = "english", + $session_id = "", + ) { + header("Content-Type: text/event-stream"); + header("Cache-Control: no-cache"); + header("X-Accel-Buffering: no"); - $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' ); - $provider = $provider_result->provider; - $settings = get_option( 'wp_agentic_writer_settings', array() ); - wpaw_debug_log( 'Using provider', array( - 'class' => get_class( $provider ), - 'configured' => method_exists( $provider, 'is_configured' ) ? $provider->is_configured() : 'unknown', - ) ); - wpaw_debug_log( 'Settings check', array( - 'local_backend_url' => $settings['local_backend_url'] ?? 'NOT SET', - 'task_providers[writing]' => $settings['task_providers']['writing'] ?? 'NOT SET' - ) ); - $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 ); - $language_instruction = $this->build_language_instruction( $effective_language, 'article content' ); - $image_instruction = "IMAGE SUGGESTIONS: + // 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_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( + "writing", + ); + $provider = $provider_result->provider; + $settings = get_option("wp_agentic_writer_settings", []); + wpaw_debug_log("Using provider", [ + "class" => get_class($provider), + "configured" => method_exists($provider, "is_configured") + ? $provider->is_configured() + : "unknown", + ]); + wpaw_debug_log("Settings check", [ + "local_backend_url" => $settings["local_backend_url"] ?? "NOT SET", + "task_providers[writing]" => + $settings["task_providers"]["writing"] ?? "NOT SET", + ]); + $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); + $language_instruction = $this->build_language_instruction( + $effective_language, + "article content", + ); + $image_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 - Example: [IMAGE: Screenshot of the plugin settings panel showing the API key field]"; - if ( empty( $post_config['include_images'] ) ) { - $image_instruction = "IMAGE SUGGESTIONS: + if (empty($post_config["include_images"])) { + $image_instruction = "IMAGE SUGGESTIONS: - Do NOT include any image suggestions or [IMAGE: ...] placeholders."; - } + } - $plan = $this->ensure_plan_sections_with_tasks( $plan ); - $sections = isset( $plan['sections'] ) && is_array( $plan['sections'] ) ? $plan['sections'] : array(); - $sections_to_write = array(); - foreach ( $sections as $index => $section ) { - $status = $section['status'] ?? 'pending'; - if ( 'done' === $status ) { - continue; - } - $sections_to_write[ $index ] = $section; - } - $total_sections = count( $sections_to_write ); - if ( 0 === $total_sections ) { - $this->send_status( 'complete', 'All outline items are already written.' ); - echo "data: " . wp_json_encode( - array( - 'type' => 'complete', - 'totalCost' => $total_cost, - ) - ) . "\n\n"; - flush(); - return; - } + $plan = $this->ensure_plan_sections_with_tasks($plan); + $sections = + isset($plan["sections"]) && is_array($plan["sections"]) + ? $plan["sections"] + : []; + $sections_to_write = []; + foreach ($sections as $index => $section) { + $status = $section["status"] ?? "pending"; + if ("done" === $status) { + continue; + } + $sections_to_write[$index] = $section; + } + $total_sections = count($sections_to_write); + if (0 === $total_sections) { + $this->send_status( + "complete", + "All outline items are already written.", + ); + echo "data: " . + wp_json_encode([ + "type" => "complete", + "totalCost" => $total_cost, + ]) . + "\n\n"; + flush(); + return; + } - $this->send_status( 'writing', 'Writing from outline...' ); + // MEMANTO: Plan execution implies approval. + do_action("wpaw_memanto_plan_approved", $post_id, $plan); - if ( ! empty( $plan['title'] ) ) { - $plan_title = sanitize_text_field( $plan['title'] ); - if ( $post_id > 0 ) { - $post = get_post( $post_id ); - if ( $post && current_user_can( 'edit_post', $post_id ) && empty( $post->post_title ) ) { - wp_update_post( - array( - 'ID' => $post_id, - 'post_title' => $plan_title, - ) - ); - } - } - echo "data: " . wp_json_encode( - array( - 'type' => 'title_update', - 'title' => $plan_title, - ) - ) . "\n\n"; - flush(); - } + $this->send_status("writing", "Writing from outline..."); - // Build SEO instructions if SEO is enabled - $seo_instruction = ''; - $internal_links_instruction = ''; - if ( ! empty( $post_config['seo_enabled'] ) && ! empty( $post_config['seo_focus_keyword'] ) ) { - $focus_keyword = $post_config['seo_focus_keyword']; - $seo_instruction = "\n\nSEO OPTIMIZATION REQUIREMENTS (CRITICAL - MUST FOLLOW): + if (!empty($plan["title"])) { + $plan_title = sanitize_text_field($plan["title"]); + if ($post_id > 0) { + $post = get_post($post_id); + if ( + $post && + current_user_can("edit_post", $post_id) && + empty($post->post_title) + ) { + wp_update_post([ + "ID" => $post_id, + "post_title" => $plan_title, + ]); + } + } + echo "data: " . + wp_json_encode([ + "type" => "title_update", + "title" => $plan_title, + ]) . + "\n\n"; + flush(); + } + + // Build SEO instructions if SEO is enabled + $seo_instruction = ""; + $internal_links_instruction = ""; + if ( + !empty($post_config["seo_enabled"]) && + !empty($post_config["seo_focus_keyword"]) + ) { + $focus_keyword = $post_config["seo_focus_keyword"]; + $seo_instruction = "\n\nSEO OPTIMIZATION REQUIREMENTS (CRITICAL - MUST FOLLOW): - Focus Keyword: \"{$focus_keyword}\" - MANDATORY: Include the exact focus keyword \"{$focus_keyword}\" in the article title (preferably at the beginning) - MANDATORY: Use the focus keyword in the FIRST paragraph (within the first 100 words) @@ -3952,18 +4686,24 @@ IMAGE SUGGESTIONS: - When suggesting images, include the focus keyword or related terms in the alt text - Keep the article title under 60 characters"; - // Get internal link suggestions - $internal_links = $this->suggest_internal_links( $post_id, $focus_keyword, 3 ); - if ( ! empty( $internal_links ) ) { - $internal_links_instruction = "\n\nINTERNAL LINKS (optional - use where contextually relevant):\n"; - foreach ( $internal_links as $link ) { - $internal_links_instruction .= "- [{$link['title']}]({$link['url']})\n"; - } - $internal_links_instruction .= "Naturally incorporate 1-2 of these internal links where they add value to the reader. Use descriptive anchor text, not 'click here'."; - } - } + // Get internal link suggestions + $internal_links = $this->suggest_internal_links( + $post_id, + $focus_keyword, + 3, + ); + if (!empty($internal_links)) { + $internal_links_instruction = + "\n\nINTERNAL LINKS (optional - use where contextually relevant):\n"; + foreach ($internal_links as $link) { + $internal_links_instruction .= "- [{$link["title"]}]({$link["url"]})\n"; + } + $internal_links_instruction .= + "Naturally incorporate 1-2 of these internal links where they add value to the reader. Use descriptive anchor text, not 'click here'."; + } + } - $system_prompt = "You are an industry practitioner sharing insights with a colleague. Write engaging, high-information-density 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. @@ -3995,940 +4735,1097 @@ IMAGE SUGGESTIONS: - Maximum 1 image per section {$image_instruction}"; - $section_index = 0; - - foreach ( $sections_to_write as $section_position => $section ) { - $section_index++; - $heading = $section['heading'] ?? $section['title'] ?? ''; - $status_message = $heading - ? sprintf( 'Writing section %d of %d: %s', $section_index, $total_sections, $heading ) - : sprintf( 'Writing section %d of %d', $section_index, $total_sections ); - - $section_id = $section['id'] ?? wp_generate_uuid4(); - $plan['sections'][ $section_position ]['id'] = $section_id; - $plan['sections'][ $section_position ]['status'] = 'in_progress'; - if ( $post_id > 0 ) { - update_post_meta( $post_id, '_wpaw_plan', $plan ); - } - - echo "data: " . wp_json_encode( - array( - 'type' => 'section_start', - 'sectionId' => $section_id, - 'heading' => $heading, - 'index' => $section_index, - 'total' => $total_sections, - ) - ) . "\n\n"; - flush(); - - $this->send_status( 'writing_section', $status_message ); - - $section_prompt = $heading ? "Write the \"{$heading}\" section.\n\n" : "Write the next section.\n\n"; - $section_prompt .= "Content requirements:\n"; - - if ( ! empty( $section['content'] ) && is_array( $section['content'] ) ) { - foreach ( $section['content'] as $item ) { - if ( ! empty( $item['content'] ) ) { - $section_prompt .= "- {$item['content']}\n"; - } - } - } - - $messages = array( - array( - 'role' => 'system', - 'content' => $system_prompt, - ), - array( - 'role' => 'user', - 'content' => $section_prompt, - ), - ); - - $accumulated_content = ''; - wpaw_debug_log( 'Starting section generation', array( - 'heading' => $heading, - 'section_position' => $section_position - ) ); - $response = $provider->chat_stream( - $messages, - array( 'temperature' => 0.8 ), - 'execution', - function( $chunk, $is_complete, $full_content ) use ( &$accumulated_content ) { - $accumulated_content = $full_content; - } - ); - wpaw_debug_log( 'Section generation complete. accumulated_content length: ' . strlen( $accumulated_content ) ); - - if ( is_wp_error( $response ) ) { - echo "data: " . wp_json_encode( - array( - 'type' => 'error', - 'message' => $response->get_error_message(), - ) - ) . "\n\n"; - flush(); - exit; - } - - $section_cost = $response['cost'] ?? 0; - $total_cost += $section_cost; - - // Track cost for this section. - if ( $section_cost > 0 ) { - $this->track_ai_cost( - $post_id, - $response['model'] ?? 'unknown', - 'execution', - $response['input_tokens'] ?? 0, - $response['output_tokens'] ?? 0, - $section_cost, - $provider_result, - $session_id ?? '', - 'success' - ); - } - - if ( ! empty( $accumulated_content ) ) { - error_log( 'WP Agentic Writer: Parsing and sending blocks for section: ' . $heading ); - $section_blocks = WP_Agentic_Writer_Markdown_Parser::parse( $accumulated_content ); - foreach ( $section_blocks as $block ) { - echo "data: " . wp_json_encode( - array( - 'type' => 'block', - 'block' => $block, - 'sectionId' => $section_id, - ) - ) . "\n\n"; - flush(); - } - } else { - error_log( 'WP Agentic Writer: WARNING - No accumulated content for section: ' . $heading ); - } - - $plan['sections'][ $section_position ]['status'] = 'done'; - if ( $post_id > 0 ) { - update_post_meta( $post_id, '_wpaw_plan', $plan ); - } - echo "data: " . wp_json_encode( - array( - 'type' => 'section_complete', - 'sectionId' => $section_id, - ) - ) . "\n\n"; - flush(); - } - - $this->send_status( 'complete', 'Article finished!' ); - - // Suggest meta description generation if SEO is enabled - if ( ! empty( $post_config['seo_enabled'] ) && $post_id > 0 ) { - echo "data: " . wp_json_encode( - array( - 'type' => 'assistant_message', - 'message' => '✅ Article complete! You can now generate the meta description in config panel.', - ) - ) . "\n\n"; - flush(); - } - echo "data: " . wp_json_encode( - array( - 'type' => 'complete', - 'totalCost' => $total_cost, - 'provider_metadata' => $this->build_provider_metadata( - $provider_result, - $this->get_provider_execution_model( $provider, 'execution' ) - ), - ) - ) . "\n\n"; - flush(); - } - - /** - * Handle reformat blocks request. - * - * @since 0.1.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_reformat_blocks( $request ) { - $params = $request->get_json_params(); - $blocks = $params['blocks'] ?? array(); - $post_id = $params['postId'] ?? 0; - $recommended_title = ''; - $title_updated = false; - - if ( empty( $blocks ) || ! is_array( $blocks ) ) { - return new WP_Error( - 'no_blocks', - __( 'Blocks are required to reformat.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - // Check post permission if post_id is provided. - if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - $results = array(); - - if ( $post_id > 0 ) { - $plan = get_post_meta( $post_id, '_wpaw_plan', true ); - if ( is_array( $plan ) && ! empty( $plan['title'] ) ) { - $recommended_title = sanitize_text_field( $plan['title'] ); - } - } - - foreach ( $blocks as $block ) { - $client_id = $block['clientId'] ?? $block['attrs']['clientId'] ?? ''; - $block_type = $block['name'] ?? $block['blockName'] ?? 'core/paragraph'; - $block_attrs = $block['attributes'] ?? $block['attrs'] ?? array(); - - if ( empty( $client_id ) ) { - continue; - } - - if ( 'core/paragraph' !== $block_type ) { - continue; - } - - $content = $this->extract_block_content_from_attrs( $block_type, $block_attrs ); - if ( '' === trim( (string) $content ) ) { - continue; - } - - $parsed_blocks = WP_Agentic_Writer_Markdown_Parser::parse( $content ); - if ( empty( $parsed_blocks ) ) { - continue; - } - - $results[] = array( - 'clientId' => $client_id, - 'blocks' => $parsed_blocks, - ); - } - - if ( $post_id > 0 && '' !== $recommended_title ) { - $post = get_post( $post_id ); - if ( $post && current_user_can( 'edit_post', $post_id ) ) { - if ( empty( $post->post_title ) ) { - wp_update_post( - array( - 'ID' => $post_id, - 'post_title' => $recommended_title, - ) - ); - $title_updated = true; - } - } - } - - return new WP_REST_Response( - array( - 'results' => $results, - 'recommended_title' => $recommended_title, - 'title_updated' => $title_updated, - ), - 200 - ); - } - - /** - * Handle regenerate block request. - * - * @since 0.1.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_regenerate_block( $request ) { - $params = $request->get_json_params(); - $block_content = $params['blockContent'] ?? ''; - $context = $params['context'] ?? ''; - $post_id = $params['postId'] ?? 0; - - if ( empty( $block_content ) ) { - return new WP_Error( - 'no_content', - __( 'Block content is required.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - // Check post permission if post_id is provided. - if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - // Get provider for writing task. - $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' ); - $provider = $provider_result->provider; - - $messages = array( - array( - 'role' => 'system', - 'content' => 'You are an expert technical writer. Rewrite the provided content to improve it while maintaining the same meaning and key information.', - ), - array( - 'role' => 'user', - 'content' => "Context: {$context}\n\nOriginal content:\n\n{$block_content}\n\nPlease rewrite this content.", - ), - ); - - $response = $provider->chat( $messages, array( 'temperature' => 0.8 ), 'execution' ); - - if ( is_wp_error( $response ) ) { - // Track failed attempt for observability. - $this->track_ai_cost( - $post_id, - WPAW_Model_Registry::get_default_model( 'writing' ), - 'regeneration', - 0, - 0, - 0, - $provider_result, - '', - 'error' - ); - return new WP_Error( - 'regeneration_error', - $response->get_error_message(), - array( 'status' => 500 ) - ); - } - - // Track cost (always track for debugging). - $this->track_ai_cost( - $post_id, - $response['model'] ?? '', - 'regeneration', - $response['input_tokens'] ?? 0, - $response['output_tokens'] ?? 0, - $response['cost'] ?? 0, - $provider_result, - '', - 'success' - ); - - return new WP_REST_Response( - array( - 'content' => $response['content'], - 'cost' => $response['cost'] ?? 0, - 'provider_metadata' => $this->build_provider_metadata( - $provider_result, - $response['model'] ?? '' - ), - ), - 200 - ); - } - - /** - * Handle get cost tracking request. - * - * @since 0.1.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response Response. - */ - public function handle_get_cost_tracking( $request ) { - $post_id = $request->get_param( 'post_id' ); - - // Check post-specific permission if post_id is provided. - if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - $cost_tracker = WP_Agentic_Writer_Cost_Tracker::get_instance(); - $data = $cost_tracker->get_frontend_data( $post_id ); - - return new WP_REST_Response( $data, 200 ); - } - - /** - * Extract JSON from string. - * - * @since 0.1.0 - * @param string $string String containing JSON. - * @return array|null Decoded JSON or null if invalid. - */ - private function extract_json( $string ) { - $string = trim( (string) $string ); - if ( '' === $string ) { - return null; - } - - // Method 1: JSON wrapped in markdown code block. - if ( preg_match_all( '/```(?:json)?\s*([\s\S]*?)```/i', $string, $matches ) ) { - foreach ( $matches[1] as $candidate ) { - $json = json_decode( trim( $candidate ), true ); - if ( json_last_error() === JSON_ERROR_NONE ) { - return $json; - } - } - } - - // Method 2: Decode the whole string. - $json = json_decode( $string, true ); - if ( json_last_error() === JSON_ERROR_NONE ) { - return $json; - } - - // Method 3: Extract balanced JSON object/array candidates. This avoids - // greedy matching across multiple objects or explanatory braces. - $candidates = array_merge( - $this->extract_balanced_json_candidates( $string, '{', '}' ), - $this->extract_balanced_json_candidates( $string, '[', ']' ) - ); - foreach ( $candidates as $candidate ) { - $json = json_decode( $candidate, true ); - if ( json_last_error() === JSON_ERROR_NONE ) { - return $json; - } - } - - return null; - } - - /** - * Extract balanced JSON object candidates from model text. - * - * @since 0.2.2 - * @param string $string Source text. - * @return array - */ - private function extract_balanced_json_candidates( $string, $open_char = '{', $close_char = '}' ) { - $candidates = array(); - $length = strlen( $string ); - $depth = 0; - $start = null; - $in_string = false; - $escaped = false; - - for ( $i = 0; $i < $length; $i++ ) { - $char = $string[ $i ]; - - if ( $in_string ) { - if ( $escaped ) { - $escaped = false; - } elseif ( '\\' === $char ) { - $escaped = true; - } elseif ( '"' === $char ) { - $in_string = false; - } - continue; - } - - if ( '"' === $char ) { - $in_string = true; - continue; - } - - if ( $open_char === $char ) { - if ( 0 === $depth ) { - $start = $i; - } - $depth++; - } elseif ( $close_char === $char && $depth > 0 ) { - $depth--; - if ( 0 === $depth && null !== $start ) { - $candidates[] = substr( $string, $start, $i - $start + 1 ); - $start = null; - } - } - } - - usort( - $candidates, - function( $a, $b ) { - return strlen( $b ) <=> strlen( $a ); - } - ); - - return $candidates; - } - - /** - * Extract an article plan from model output, falling back to markdown outlines. - * - * @since 0.2.2 - * @param string $content Model response. - * @param string $fallback_title Fallback title/topic. - * @param array $previous_plan Previous plan for revisions. - * @return array|null - */ - private function extract_plan_from_response( $content, $fallback_title = '', $previous_plan = array() ) { - $json = $this->extract_json( $content ); - $normalized_json_plan = $this->normalize_extracted_plan_json( $json, $fallback_title ); - if ( ! empty( $normalized_json_plan['sections'] ) ) { - return $normalized_json_plan; - } - - $markdown_plan = $this->build_plan_from_markdown_outline( $content, $fallback_title, $previous_plan ); - if ( ! empty( $markdown_plan['sections'] ) ) { - return $markdown_plan; - } - - return null; - } - - /** - * Build a short, safe preview of unparseable model output. - * - * @since 0.2.3 - * @param string $content Model response. - * @return string - */ - private function build_model_output_preview( $content ) { - $preview = trim( wp_strip_all_tags( (string) $content ) ); - $preview = preg_replace( '/\s+/', ' ', $preview ); - if ( function_exists( 'mb_substr' ) ) { - $preview = mb_substr( $preview, 0, 240 ); - } else { - $preview = substr( $preview, 0, 240 ); - } - - return '' !== $preview ? $preview : '(empty response)'; - } - - /** - * Normalize common model outline JSON variants into the required plan schema. - * - * @since 0.2.3 - * @param mixed $json Decoded model JSON. - * @param string $fallback_title Fallback title/topic. - * @return array|null - */ - private function normalize_extracted_plan_json( $json, $fallback_title = '' ) { - if ( ! is_array( $json ) ) { - return null; - } - - // Some models return the sections array directly. - if ( array_is_list( $json ) ) { - $json = array( - 'title' => $fallback_title, - 'sections' => $json, - ); - } - - // Some models nest the outline under a descriptive top-level key. - foreach ( array( 'plan', 'outline', 'article_plan', 'articlePlan', 'data' ) as $key ) { - if ( empty( $json['sections'] ) && isset( $json[ $key ] ) && is_array( $json[ $key ] ) ) { - $nested = $this->normalize_extracted_plan_json( $json[ $key ], $fallback_title ); - if ( ! empty( $nested['sections'] ) ) { - return $nested; - } - } - } - - $section_keys = array( 'sections', 'outline', 'items', 'chapters', 'headings', 'bagian' ); - $sections = array(); - foreach ( $section_keys as $key ) { - if ( ! empty( $json[ $key ] ) && is_array( $json[ $key ] ) ) { - $sections = $json[ $key ]; - break; - } - } - - if ( empty( $sections ) ) { - return null; - } - - $title = $json['title'] ?? $json['judul'] ?? $json['headline'] ?? $fallback_title; - $title = $this->clean_outline_heading( $title ); - if ( '' === $title ) { - $title = __( 'Article Outline', 'wp-agentic-writer' ); - } - - $normalized_sections = array(); - foreach ( $sections as $index => $section ) { - if ( is_string( $section ) ) { - $section = array( 'heading' => $section ); - } - if ( ! is_array( $section ) ) { - continue; - } - - $heading = $section['heading'] - ?? $section['title'] - ?? $section['judul'] - ?? $section['name'] - ?? $section['h2'] - ?? sprintf( 'Section %d', $index + 1 ); - $heading = $this->clean_outline_heading( $heading ); - if ( '' === $heading ) { - continue; - } - - $content_items = $section['content'] - ?? $section['description'] - ?? $section['summary'] - ?? $section['points'] - ?? $section['bullets'] - ?? array(); - $content = $this->normalize_plan_section_content_items( $content_items ); - if ( empty( $content ) ) { - $content[] = array( - 'type' => 'paragraph', - 'content' => $heading, - ); - } - - $normalized_sections[] = array( - 'id' => sanitize_key( $section['id'] ?? '' ), - 'status' => sanitize_key( $section['status'] ?? 'pending' ), - 'type' => sanitize_key( $section['type'] ?? 'section' ), - 'heading' => $heading, - 'content' => $content, - ); - } - - if ( empty( $normalized_sections ) ) { - return null; - } - - $meta = isset( $json['meta'] ) && is_array( $json['meta'] ) ? $json['meta'] : array(); - return array( - 'title' => $title, - 'meta' => wp_parse_args( - $meta, - array( - 'reading_time' => '5 min', - 'difficulty' => 'intermediate', - 'cost_estimate' => 0.70, - ) - ), - 'sections' => $normalized_sections, - ); - } - - /** - * Normalize varied model section content into plan content items. - * - * @since 0.2.3 - * @param mixed $items Section content candidate. - * @return array - */ - private function normalize_plan_section_content_items( $items ) { - if ( is_string( $items ) ) { - $items = array( $items ); - } - if ( ! is_array( $items ) ) { - return array(); - } - - $normalized = array(); - foreach ( $items as $item ) { - if ( is_string( $item ) ) { - $text = trim( wp_strip_all_tags( $item ) ); - if ( '' !== $text ) { - $normalized[] = array( - 'type' => 'paragraph', - 'content' => $text, - ); - } - continue; - } - - if ( ! is_array( $item ) ) { - continue; - } - - $text = $item['content'] ?? $item['text'] ?? $item['description'] ?? $item['point'] ?? ''; - $text = trim( wp_strip_all_tags( (string) $text ) ); - if ( '' === $text ) { - continue; - } - - $normalized[] = array( - 'type' => sanitize_key( $item['type'] ?? 'paragraph' ), - 'content' => $text, - ); - } - - return $normalized; - } - - /** - * Build a plan schema from markdown/numbered outline output. - * - * @since 0.2.2 - * @param string $content Model response. - * @param string $fallback_title Fallback title/topic. - * @param array $previous_plan Previous plan for revisions. - * @return array|null - */ - private function build_plan_from_markdown_outline( $content, $fallback_title = '', $previous_plan = array() ) { - $lines = preg_split( '/\r\n|\r|\n/', (string) $content ); - if ( ! is_array( $lines ) ) { - return null; - } - - $title = ''; - $sections = array(); - $current = null; - - foreach ( $lines as $raw_line ) { - $line = trim( wp_strip_all_tags( (string) $raw_line ) ); - if ( '' === $line ) { - continue; - } - - $line = preg_replace( '/^\s*(?:[-*]\s*)?\*\*(.*?)\*\*\s*$/', '$1', $line ); - $heading = ''; - - if ( preg_match( '/^#{1,2}\s+(.+)$/', $line, $matches ) ) { - $text = $this->clean_outline_heading( $matches[1] ); - if ( '' === $title ) { - $title = $text; - continue; - } - $heading = $text; - } elseif ( preg_match( '/^\d+[\.)]\s+(.+)$/', $line, $matches ) ) { - $heading = $this->clean_outline_heading( $matches[1] ); - } elseif ( preg_match( '/^(?:section|bagian)\s+\d+\s*[:.-]\s*(.+)$/i', $line, $matches ) ) { - $heading = $this->clean_outline_heading( $matches[1] ); - } elseif ( '' === $title && ! preg_match( '/^(seo snapshot|sections?|outline|meta|focus keyword|secondary keywords?)\b/i', $line ) ) { - $title = $this->clean_outline_heading( $line ); - continue; - } - - if ( '' !== $heading && ! preg_match( '/^(seo snapshot|sections?|outline|meta|focus keyword|secondary keywords?)\b/i', $heading ) ) { - if ( null !== $current ) { - $sections[] = $current; - } - $current = array( - 'id' => wp_generate_uuid4(), - 'status' => 'pending', - 'type' => 'section', - 'heading' => $heading, - 'content' => array(), - ); - continue; - } - - if ( null !== $current ) { - $detail = preg_replace( '/^[-*]\s+/', '', $line ); - if ( '' !== $detail && ! preg_match( '/^(title|judul|meta|reading time|difficulty|cost estimate)\b/i', $detail ) ) { - $current['content'][] = array( - 'type' => 'paragraph', - 'content' => $detail, - ); - } - } - } - - if ( null !== $current ) { - $sections[] = $current; - } - - if ( empty( $sections ) ) { - return null; - } - - if ( '' === $title ) { - $title = $this->clean_outline_heading( $fallback_title ); - } - if ( '' === $title && ! empty( $previous_plan['title'] ) ) { - $title = (string) $previous_plan['title']; - } - if ( '' === $title ) { - $title = __( 'Article Outline', 'wp-agentic-writer' ); - } - - return array( - 'title' => $title, - 'meta' => array( - 'reading_time' => '5 min', - 'difficulty' => 'intermediate', - 'cost_estimate' => 0.70, - ), - 'sections' => $sections, - ); - } - - /** - * Clean markdown decoration from an outline heading. - * - * @since 0.2.2 - * @param string $heading Heading text. - * @return string - */ - private function clean_outline_heading( $heading ) { - $heading = trim( (string) $heading ); - $heading = preg_replace( '/^\s*["\'`]+|["\'`]+\s*$/', '', $heading ); - $heading = preg_replace( '/\*\*(.*?)\*\*/', '$1', $heading ); - $heading = preg_replace( '/\s+/', ' ', $heading ); - return trim( $heading ); - } - - /** - * Handle get models request. - * - * @since 0.1.0 - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_get_models() { - $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance(); - $models = $provider->get_cached_models(); - - if ( is_wp_error( $models ) ) { - return $models; - } - - return new WP_REST_Response( $models, 200 ); - } - - /** - * Handle refresh models request. - * - * @since 0.1.0 - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_refresh_models() { - $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance(); - $models = $provider->fetch_and_cache_models( true ); - - if ( is_wp_error( $models ) ) { - return $models; - } - - return new WP_REST_Response( - array( - 'models' => $models, - 'message' => __( 'Models refreshed successfully.', 'wp-agentic-writer' ), - ), - 200 - ); - } - - /** - * Handle check clarity request. - * - * @since 0.1.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_check_clarity( $request ) { - $params = $request->get_json_params(); - $topic = $params['topic'] ?? ''; - $answers = $params['answers'] ?? array(); - $post_id = $params['postId'] ?? 0; - $mode = $params['mode'] ?? 'generation'; - $chat_history = $params['chatHistory'] ?? array(); - - if ( empty( $topic ) ) { - return new WP_Error( - 'no_topic', - __( 'Topic is required.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - // Check post permission BEFORE reading post data. - if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - // Only read post config after permission check. - $post_config = $this->resolve_post_config_from_request( $params, $post_id ); - $post_config_context = $this->build_post_config_context( $post_config ); - $preferred_language = $this->resolve_language_preference( $post_config, '' ); - $language_hint = ''; - if ( 'auto' !== ( $post_config['language'] ?? 'auto' ) ) { - $language_hint = "\n\nPreferred language: {$preferred_language}. Ask questions in that language."; - } - - $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' ); - $provider = $provider_result->provider; - - // Get settings. - $settings = get_option( 'wp_agentic_writer_settings', array() ); - $enabled = $settings['enable_clarification_quiz'] ?? true; - $threshold = $settings['clarity_confidence_threshold'] ?? '0.6'; - $required_categories = $settings['required_context_categories'] ?? array( - 'target_outcome', - 'target_audience', - 'tone', - 'content_depth', - 'expertise_level', - 'content_type', - 'pov', - ); - - // 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' => $result, - 'cost' => 0, - ), - 200 - ); - } - - // Build context from answers if available. - $context = ''; - if ( ! empty( $answers ) ) { - $context = "\n\nPrevious answers:\n"; - foreach ( $answers as $answer ) { - $context .= "- {$answer['question']}: {$answer['answer']}\n"; - } - } - - // Build chat history context for continuity. - $chat_history_context = ''; - if ( ! empty( $chat_history ) && is_array( $chat_history ) ) { - $chat_history_context = "\n\n--- CONVERSATION HISTORY (IMPORTANT - use this context!) ---\n"; - foreach ( $chat_history as $msg ) { - $role = isset( $msg['role'] ) ? ucfirst( $msg['role'] ) : 'Unknown'; - $content = isset( $msg['content'] ) ? $msg['content'] : ''; - if ( ! empty( $content ) ) { - $chat_history_context .= "{$role}: {$content}\n\n"; - } - } - $chat_history_context .= "--- END CONVERSATION HISTORY ---\n"; - $chat_history_context .= "\nIMPORTANT: The user's current request \"" . $topic . "\" is a CONTINUATION of the above conversation. Extract topic/context from the chat history. If the conversation already discussed a specific topic, the user likely wants to create an outline for THAT topic. Do NOT ask \"what topic?\" if it's already clear from the conversation."; - } - - $memory_context = $this->get_post_memory_context( $post_id ); - $followup_hint = ''; - if ( 'refinement' === $mode && ! empty( $memory_context ) ) { - $followup_hint = "\n\nThis is a follow-up request to an existing article. Use the post memory below to avoid asking generic questions already covered unless the request is ambiguous within that context."; - } - // Also treat chat history as follow-up context. - if ( ! empty( $chat_history_context ) ) { - $followup_hint .= "\n\nThis request continues from a previous chat conversation. Use the conversation history to understand what the user wants."; - } - - $system_prompt = "You are an expert editor who determines if an article request has sufficient context to write effectively. + $section_index = 0; + + foreach ($sections_to_write as $section_position => $section) { + $section_index++; + $heading = $section["heading"] ?? ($section["title"] ?? ""); + $status_message = $heading + ? sprintf( + "Writing section %d of %d: %s", + $section_index, + $total_sections, + $heading, + ) + : sprintf( + "Writing section %d of %d", + $section_index, + $total_sections, + ); + + $section_id = $section["id"] ?? wp_generate_uuid4(); + $plan["sections"][$section_position]["id"] = $section_id; + $plan["sections"][$section_position]["status"] = "in_progress"; + if ($post_id > 0) { + update_post_meta($post_id, "_wpaw_plan", $plan); + } + + echo "data: " . + wp_json_encode([ + "type" => "section_start", + "sectionId" => $section_id, + "heading" => $heading, + "index" => $section_index, + "total" => $total_sections, + ]) . + "\n\n"; + flush(); + + $this->send_status("writing_section", $status_message); + + $section_prompt = $heading + ? "Write the \"{$heading}\" section.\n\n" + : "Write the next section.\n\n"; + $section_prompt .= "Content requirements:\n"; + + if (!empty($section["content"]) && is_array($section["content"])) { + foreach ($section["content"] as $item) { + if (!empty($item["content"])) { + $section_prompt .= "- {$item["content"]}\n"; + } + } + } + + $messages = [ + [ + "role" => "system", + "content" => $system_prompt, + ], + [ + "role" => "user", + "content" => $section_prompt, + ], + ]; + + $accumulated_content = ""; + wpaw_debug_log("Starting section generation", [ + "heading" => $heading, + "section_position" => $section_position, + ]); + $response = $provider->chat_stream( + $messages, + ["temperature" => 0.8], + "execution", + function ($chunk, $is_complete, $full_content) use ( + &$accumulated_content, + ) { + $accumulated_content = $full_content; + }, + ); + wpaw_debug_log( + "Section generation complete. accumulated_content length: " . + strlen($accumulated_content), + ); + + if (is_wp_error($response)) { + echo "data: " . + wp_json_encode([ + "type" => "error", + "message" => $response->get_error_message(), + ]) . + "\n\n"; + flush(); + exit(); + } + + $section_cost = $response["cost"] ?? 0; + $total_cost += $section_cost; + + // Track cost for this section. + if ($section_cost > 0) { + $this->track_ai_cost( + $post_id, + $response["model"] ?? "unknown", + "execution", + $response["input_tokens"] ?? 0, + $response["output_tokens"] ?? 0, + $section_cost, + $provider_result, + $session_id ?? "", + "success", + ); + } + + if (!empty($accumulated_content)) { + error_log( + "WP Agentic Writer: Parsing and sending blocks for section: " . + $heading, + ); + $section_blocks = WP_Agentic_Writer_Markdown_Parser::parse( + $accumulated_content, + ); + foreach ($section_blocks as $block) { + echo "data: " . + wp_json_encode([ + "type" => "block", + "block" => $block, + "sectionId" => $section_id, + ]) . + "\n\n"; + flush(); + } + } else { + error_log( + "WP Agentic Writer: WARNING - No accumulated content for section: " . + $heading, + ); + } + + $plan["sections"][$section_position]["status"] = "done"; + if ($post_id > 0) { + update_post_meta($post_id, "_wpaw_plan", $plan); + } + + // MEMANTO: Remember section was written. + do_action( + "wpaw_memanto_section_written", + $post_id, + $section_id, + $heading, + ); + + echo "data: " . + wp_json_encode([ + "type" => "section_complete", + "sectionId" => $section_id, + ]) . + "\n\n"; + flush(); + } + + $this->send_status("complete", "Article finished!"); + + // Suggest meta description generation if SEO is enabled + if (!empty($post_config["seo_enabled"]) && $post_id > 0) { + echo "data: " . + wp_json_encode([ + "type" => "assistant_message", + "message" => + "✅ Article complete! You can now generate the meta description in config panel.", + ]) . + "\n\n"; + flush(); + } + echo "data: " . + wp_json_encode([ + "type" => "complete", + "totalCost" => $total_cost, + "provider_metadata" => $this->build_provider_metadata( + $provider_result, + $this->get_provider_execution_model($provider, "execution"), + ), + ]) . + "\n\n"; + flush(); + } + + /** + * Handle reformat blocks request. + * + * @since 0.1.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_reformat_blocks($request) + { + $params = $request->get_json_params(); + $blocks = $params["blocks"] ?? []; + $post_id = $params["postId"] ?? 0; + $recommended_title = ""; + $title_updated = false; + + if (empty($blocks) || !is_array($blocks)) { + return new WP_Error( + "no_blocks", + __("Blocks are required to reformat.", "wp-agentic-writer"), + ["status" => 400], + ); + } + + // Check post permission if post_id is provided. + if ($post_id > 0 && !$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to edit this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $results = []; + + if ($post_id > 0) { + $plan = get_post_meta($post_id, "_wpaw_plan", true); + if (is_array($plan) && !empty($plan["title"])) { + $recommended_title = sanitize_text_field($plan["title"]); + } + } + + foreach ($blocks as $block) { + $client_id = + $block["clientId"] ?? ($block["attrs"]["clientId"] ?? ""); + $block_type = + $block["name"] ?? ($block["blockName"] ?? "core/paragraph"); + $block_attrs = $block["attributes"] ?? ($block["attrs"] ?? []); + + if (empty($client_id)) { + continue; + } + + if ("core/paragraph" !== $block_type) { + continue; + } + + $content = $this->extract_block_content_from_attrs( + $block_type, + $block_attrs, + ); + if ("" === trim((string) $content)) { + continue; + } + + $parsed_blocks = WP_Agentic_Writer_Markdown_Parser::parse($content); + if (empty($parsed_blocks)) { + continue; + } + + $results[] = [ + "clientId" => $client_id, + "blocks" => $parsed_blocks, + ]; + } + + if ($post_id > 0 && "" !== $recommended_title) { + $post = get_post($post_id); + if ($post && current_user_can("edit_post", $post_id)) { + if (empty($post->post_title)) { + wp_update_post([ + "ID" => $post_id, + "post_title" => $recommended_title, + ]); + $title_updated = true; + } + } + } + + return new WP_REST_Response( + [ + "results" => $results, + "recommended_title" => $recommended_title, + "title_updated" => $title_updated, + ], + 200, + ); + } + + /** + * Handle regenerate block request. + * + * @since 0.1.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_regenerate_block($request) + { + $params = $request->get_json_params(); + $block_content = $params["blockContent"] ?? ""; + $context = $params["context"] ?? ""; + $post_id = $params["postId"] ?? 0; + + if (empty($block_content)) { + return new WP_Error( + "no_content", + __("Block content is required.", "wp-agentic-writer"), + ["status" => 400], + ); + } + + // Check post permission if post_id is provided. + if ($post_id > 0 && !$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to edit this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + // Get provider for writing task. + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( + "writing", + ); + $provider = $provider_result->provider; + + $messages = [ + [ + "role" => "system", + "content" => + "You are an expert technical writer. Rewrite the provided content to improve it while maintaining the same meaning and key information.", + ], + [ + "role" => "user", + "content" => "Context: {$context}\n\nOriginal content:\n\n{$block_content}\n\nPlease rewrite this content.", + ], + ]; + + $response = $provider->chat( + $messages, + ["temperature" => 0.8], + "execution", + ); + + if (is_wp_error($response)) { + // Track failed attempt for observability. + $this->track_ai_cost( + $post_id, + WPAW_Model_Registry::get_default_model("writing"), + "regeneration", + 0, + 0, + 0, + $provider_result, + "", + "error", + ); + return new WP_Error( + "regeneration_error", + $response->get_error_message(), + ["status" => 500], + ); + } + + // Track cost (always track for debugging). + $this->track_ai_cost( + $post_id, + $response["model"] ?? "", + "regeneration", + $response["input_tokens"] ?? 0, + $response["output_tokens"] ?? 0, + $response["cost"] ?? 0, + $provider_result, + "", + "success", + ); + + return new WP_REST_Response( + [ + "content" => $response["content"], + "cost" => $response["cost"] ?? 0, + "provider_metadata" => $this->build_provider_metadata( + $provider_result, + $response["model"] ?? "", + ), + ], + 200, + ); + } + + /** + * Handle get cost tracking request. + * + * @since 0.1.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response Response. + */ + public function handle_get_cost_tracking($request) + { + $post_id = $request->get_param("post_id"); + + // Check post-specific permission if post_id is provided. + if ($post_id > 0 && !$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to access this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $cost_tracker = WP_Agentic_Writer_Cost_Tracker::get_instance(); + $data = $cost_tracker->get_frontend_data($post_id); + + return new WP_REST_Response($data, 200); + } + + /** + * Extract JSON from string. + * + * @since 0.1.0 + * @param string $string String containing JSON. + * @return array|null Decoded JSON or null if invalid. + */ + private function extract_json($string) + { + $string = trim((string) $string); + if ("" === $string) { + return null; + } + + // Method 1: JSON wrapped in markdown code block. + if ( + preg_match_all("/```(?:json)?\s*([\s\S]*?)```/i", $string, $matches) + ) { + foreach ($matches[1] as $candidate) { + $json = json_decode(trim($candidate), true); + if (json_last_error() === JSON_ERROR_NONE) { + return $json; + } + } + } + + // Method 2: Decode the whole string. + $json = json_decode($string, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $json; + } + + // Method 3: Extract balanced JSON object/array candidates. This avoids + // greedy matching across multiple objects or explanatory braces. + $candidates = array_merge( + $this->extract_balanced_json_candidates($string, "{", "}"), + $this->extract_balanced_json_candidates($string, "[", "]"), + ); + foreach ($candidates as $candidate) { + $json = json_decode($candidate, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $json; + } + } + + return null; + } + + /** + * Extract balanced JSON object candidates from model text. + * + * @since 0.2.2 + * @param string $string Source text. + * @return array + */ + private function extract_balanced_json_candidates( + $string, + $open_char = "{", + $close_char = "}", + ) { + $candidates = []; + $length = strlen($string); + $depth = 0; + $start = null; + $in_string = false; + $escaped = false; + + for ($i = 0; $i < $length; $i++) { + $char = $string[$i]; + + if ($in_string) { + if ($escaped) { + $escaped = false; + } elseif ("\\" === $char) { + $escaped = true; + } elseif ('"' === $char) { + $in_string = false; + } + continue; + } + + if ('"' === $char) { + $in_string = true; + continue; + } + + if ($open_char === $char) { + if (0 === $depth) { + $start = $i; + } + $depth++; + } elseif ($close_char === $char && $depth > 0) { + $depth--; + if (0 === $depth && null !== $start) { + $candidates[] = substr($string, $start, $i - $start + 1); + $start = null; + } + } + } + + usort($candidates, function ($a, $b) { + return strlen($b) <=> strlen($a); + }); + + return $candidates; + } + + /** + * Extract an article plan from model output, falling back to markdown outlines. + * + * @since 0.2.2 + * @param string $content Model response. + * @param string $fallback_title Fallback title/topic. + * @param array $previous_plan Previous plan for revisions. + * @return array|null + */ + private function extract_plan_from_response( + $content, + $fallback_title = "", + $previous_plan = [], + ) { + $json = $this->extract_json($content); + $normalized_json_plan = $this->normalize_extracted_plan_json( + $json, + $fallback_title, + ); + if (!empty($normalized_json_plan["sections"])) { + return $normalized_json_plan; + } + + $markdown_plan = $this->build_plan_from_markdown_outline( + $content, + $fallback_title, + $previous_plan, + ); + if (!empty($markdown_plan["sections"])) { + return $markdown_plan; + } + + return null; + } + + /** + * Build a short, safe preview of unparseable model output. + * + * @since 0.2.3 + * @param string $content Model response. + * @return string + */ + private function build_model_output_preview($content) + { + $preview = trim(wp_strip_all_tags((string) $content)); + $preview = preg_replace("/\s+/", " ", $preview); + if (function_exists("mb_substr")) { + $preview = mb_substr($preview, 0, 240); + } else { + $preview = substr($preview, 0, 240); + } + + return "" !== $preview ? $preview : "(empty response)"; + } + + /** + * Normalize common model outline JSON variants into the required plan schema. + * + * @since 0.2.3 + * @param mixed $json Decoded model JSON. + * @param string $fallback_title Fallback title/topic. + * @return array|null + */ + private function normalize_extracted_plan_json($json, $fallback_title = "") + { + if (!is_array($json)) { + return null; + } + + // Some models return the sections array directly. + if (array_is_list($json)) { + $json = [ + "title" => $fallback_title, + "sections" => $json, + ]; + } + + // Some models nest the outline under a descriptive top-level key. + foreach ( + ["plan", "outline", "article_plan", "articlePlan", "data"] + as $key + ) { + if ( + empty($json["sections"]) && + isset($json[$key]) && + is_array($json[$key]) + ) { + $nested = $this->normalize_extracted_plan_json( + $json[$key], + $fallback_title, + ); + if (!empty($nested["sections"])) { + return $nested; + } + } + } + + $section_keys = [ + "sections", + "outline", + "items", + "chapters", + "headings", + "bagian", + ]; + $sections = []; + foreach ($section_keys as $key) { + if (!empty($json[$key]) && is_array($json[$key])) { + $sections = $json[$key]; + break; + } + } + + if (empty($sections)) { + return null; + } + + $title = + $json["title"] ?? + ($json["judul"] ?? ($json["headline"] ?? $fallback_title)); + $title = $this->clean_outline_heading($title); + if ("" === $title) { + $title = __("Article Outline", "wp-agentic-writer"); + } + + $normalized_sections = []; + foreach ($sections as $index => $section) { + if (is_string($section)) { + $section = ["heading" => $section]; + } + if (!is_array($section)) { + continue; + } + + $heading = + $section["heading"] ?? + ($section["title"] ?? + ($section["judul"] ?? + ($section["name"] ?? + ($section["h2"] ?? + sprintf("Section %d", $index + 1))))); + $heading = $this->clean_outline_heading($heading); + if ("" === $heading) { + continue; + } + + $content_items = + $section["content"] ?? + ($section["description"] ?? + ($section["summary"] ?? + ($section["points"] ?? ($section["bullets"] ?? [])))); + $content = $this->normalize_plan_section_content_items( + $content_items, + ); + if (empty($content)) { + $content[] = [ + "type" => "paragraph", + "content" => $heading, + ]; + } + + $normalized_sections[] = [ + "id" => sanitize_key($section["id"] ?? ""), + "status" => sanitize_key($section["status"] ?? "pending"), + "type" => sanitize_key($section["type"] ?? "section"), + "heading" => $heading, + "content" => $content, + ]; + } + + if (empty($normalized_sections)) { + return null; + } + + $meta = + isset($json["meta"]) && is_array($json["meta"]) + ? $json["meta"] + : []; + return [ + "title" => $title, + "meta" => wp_parse_args($meta, [ + "reading_time" => "5 min", + "difficulty" => "intermediate", + "cost_estimate" => 0.7, + ]), + "sections" => $normalized_sections, + ]; + } + + /** + * Normalize varied model section content into plan content items. + * + * @since 0.2.3 + * @param mixed $items Section content candidate. + * @return array + */ + private function normalize_plan_section_content_items($items) + { + if (is_string($items)) { + $items = [$items]; + } + if (!is_array($items)) { + return []; + } + + $normalized = []; + foreach ($items as $item) { + if (is_string($item)) { + $text = trim(wp_strip_all_tags($item)); + if ("" !== $text) { + $normalized[] = [ + "type" => "paragraph", + "content" => $text, + ]; + } + continue; + } + + if (!is_array($item)) { + continue; + } + + $text = + $item["content"] ?? + ($item["text"] ?? + ($item["description"] ?? ($item["point"] ?? ""))); + $text = trim(wp_strip_all_tags((string) $text)); + if ("" === $text) { + continue; + } + + $normalized[] = [ + "type" => sanitize_key($item["type"] ?? "paragraph"), + "content" => $text, + ]; + } + + return $normalized; + } + + /** + * Build a plan schema from markdown/numbered outline output. + * + * @since 0.2.2 + * @param string $content Model response. + * @param string $fallback_title Fallback title/topic. + * @param array $previous_plan Previous plan for revisions. + * @return array|null + */ + private function build_plan_from_markdown_outline( + $content, + $fallback_title = "", + $previous_plan = [], + ) { + $lines = preg_split('/\r\n|\r|\n/', (string) $content); + if (!is_array($lines)) { + return null; + } + + $title = ""; + $sections = []; + $current = null; + + foreach ($lines as $raw_line) { + $line = trim(wp_strip_all_tags((string) $raw_line)); + if ("" === $line) { + continue; + } + + $line = preg_replace( + '/^\s*(?:[-*]\s*)?\*\*(.*?)\*\*\s*$/', + '$1', + $line, + ); + $heading = ""; + + if (preg_match('/^#{1,2}\s+(.+)$/', $line, $matches)) { + $text = $this->clean_outline_heading($matches[1]); + if ("" === $title) { + $title = $text; + continue; + } + $heading = $text; + } elseif (preg_match('/^\d+[\.)]\s+(.+)$/', $line, $matches)) { + $heading = $this->clean_outline_heading($matches[1]); + } elseif ( + preg_match( + '/^(?:section|bagian)\s+\d+\s*[:.-]\s*(.+)$/i', + $line, + $matches, + ) + ) { + $heading = $this->clean_outline_heading($matches[1]); + } elseif ( + "" === $title && + !preg_match( + "/^(seo snapshot|sections?|outline|meta|focus keyword|secondary keywords?)\b/i", + $line, + ) + ) { + $title = $this->clean_outline_heading($line); + continue; + } + + if ( + "" !== $heading && + !preg_match( + "/^(seo snapshot|sections?|outline|meta|focus keyword|secondary keywords?)\b/i", + $heading, + ) + ) { + if (null !== $current) { + $sections[] = $current; + } + $current = [ + "id" => wp_generate_uuid4(), + "status" => "pending", + "type" => "section", + "heading" => $heading, + "content" => [], + ]; + continue; + } + + if (null !== $current) { + $detail = preg_replace("/^[-*]\s+/", "", $line); + if ( + "" !== $detail && + !preg_match( + "/^(title|judul|meta|reading time|difficulty|cost estimate)\b/i", + $detail, + ) + ) { + $current["content"][] = [ + "type" => "paragraph", + "content" => $detail, + ]; + } + } + } + + if (null !== $current) { + $sections[] = $current; + } + + if (empty($sections)) { + return null; + } + + if ("" === $title) { + $title = $this->clean_outline_heading($fallback_title); + } + if ("" === $title && !empty($previous_plan["title"])) { + $title = (string) $previous_plan["title"]; + } + if ("" === $title) { + $title = __("Article Outline", "wp-agentic-writer"); + } + + return [ + "title" => $title, + "meta" => [ + "reading_time" => "5 min", + "difficulty" => "intermediate", + "cost_estimate" => 0.7, + ], + "sections" => $sections, + ]; + } + + /** + * Clean markdown decoration from an outline heading. + * + * @since 0.2.2 + * @param string $heading Heading text. + * @return string + */ + private function clean_outline_heading($heading) + { + $heading = trim((string) $heading); + $heading = preg_replace('/^\s*["\'`]+|["\'`]+\s*$/', "", $heading); + $heading = preg_replace("/\*\*(.*?)\*\*/", '$1', $heading); + $heading = preg_replace("/\s+/", " ", $heading); + return trim($heading); + } + + /** + * Handle get models request. + * + * @since 0.1.0 + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_get_models() + { + $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance(); + $models = $provider->get_cached_models(); + + if (is_wp_error($models)) { + return $models; + } + + return new WP_REST_Response($models, 200); + } + + /** + * Handle refresh models request. + * + * @since 0.1.0 + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_refresh_models() + { + $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance(); + $models = $provider->fetch_and_cache_models(true); + + if (is_wp_error($models)) { + return $models; + } + + return new WP_REST_Response( + [ + "models" => $models, + "message" => __( + "Models refreshed successfully.", + "wp-agentic-writer", + ), + ], + 200, + ); + } + + /** + * Handle check clarity request. + * + * @since 0.1.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_check_clarity($request) + { + $params = $request->get_json_params(); + $topic = $params["topic"] ?? ""; + $answers = $params["answers"] ?? []; + $post_id = $params["postId"] ?? 0; + $mode = $params["mode"] ?? "generation"; + $chat_history = $params["chatHistory"] ?? []; + + if (empty($topic)) { + return new WP_Error( + "no_topic", + __("Topic is required.", "wp-agentic-writer"), + ["status" => 400], + ); + } + + // Check post permission BEFORE reading post data. + if ($post_id > 0 && !$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to access this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + // Only read post config after permission check. + $post_config = $this->resolve_post_config_from_request( + $params, + $post_id, + ); + $post_config_context = $this->build_post_config_context($post_config); + $preferred_language = $this->resolve_language_preference( + $post_config, + "", + ); + $language_hint = ""; + if ("auto" !== ($post_config["language"] ?? "auto")) { + $language_hint = "\n\nPreferred language: {$preferred_language}. Ask questions in that language."; + } + + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( + "writing", + ); + $provider = $provider_result->provider; + + // Get settings. + $settings = get_option("wp_agentic_writer_settings", []); + $enabled = $settings["enable_clarification_quiz"] ?? true; + $threshold = $settings["clarity_confidence_threshold"] ?? "0.6"; + $required_categories = $settings["required_context_categories"] ?? [ + "target_outcome", + "target_audience", + "tone", + "content_depth", + "expertise_level", + "content_type", + "pov", + ]; + + // If quiz is disabled, skip AI questions but still add MANDATORY config questions + if (!$enabled) { + $result = [ + "is_clear" => true, + "confidence" => 1.0, + "questions" => [], + ]; + // 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( + [ + "result" => $result, + "cost" => 0, + ], + 200, + ); + } + + // Build context from answers if available. + $context = ""; + if (!empty($answers)) { + $context = "\n\nPrevious answers:\n"; + foreach ($answers as $answer) { + $context .= "- {$answer["question"]}: {$answer["answer"]}\n"; + } + } + + // Build chat history context for continuity. + $chat_history_context = ""; + if (!empty($chat_history) && is_array($chat_history)) { + $chat_history_context = + "\n\n--- CONVERSATION HISTORY (IMPORTANT - use this context!) ---\n"; + foreach ($chat_history as $msg) { + $role = isset($msg["role"]) ? ucfirst($msg["role"]) : "Unknown"; + $content = isset($msg["content"]) ? $msg["content"] : ""; + if (!empty($content)) { + $chat_history_context .= "{$role}: {$content}\n\n"; + } + } + $chat_history_context .= "--- END CONVERSATION HISTORY ---\n"; + $chat_history_context .= + "\nIMPORTANT: The user's current request \"" . + $topic . + "\" is a CONTINUATION of the above conversation. Extract topic/context from the chat history. If the conversation already discussed a specific topic, the user likely wants to create an outline for THAT topic. Do NOT ask \"what topic?\" if it's already clear from the conversation."; + } + + $memory_context = $this->get_post_memory_context($post_id); + $followup_hint = ""; + if ("refinement" === $mode && !empty($memory_context)) { + $followup_hint = + "\n\nThis is a follow-up request to an existing article. Use the post memory below to avoid asking generic questions already covered unless the request is ambiguous within that context."; + } + // Also treat chat history as follow-up context. + if (!empty($chat_history_context)) { + $followup_hint .= + "\n\nThis request continues from a previous chat conversation. Use the conversation history to understand what the user wants."; + } + + $system_prompt = "You are an expert editor who determines if an article request has sufficient context to write effectively. IMPORTANT RULES: 1. DETECT LANGUAGE: Identify the user's language (Indonesian, English, etc.) and write ALL questions in that SAME language @@ -5022,320 +5919,385 @@ Return ONLY valid JSON: No markdown, no explanation - just JSON."; - $messages = array( - array( - 'role' => 'system', - 'content' => $system_prompt, - ), - array( - 'role' => 'user', - 'content' => "Topic: {$topic}\n\nRequired Categories: " . implode( ', ', $required_categories ) . "\n\nEvaluate this request and determine which context is missing.{$chat_history_context}{$context}{$post_config_context}{$memory_context}{$followup_hint}{$language_hint}", - ), - ); + $messages = [ + [ + "role" => "system", + "content" => $system_prompt, + ], + [ + "role" => "user", + "content" => + "Topic: {$topic}\n\nRequired Categories: " . + implode(", ", $required_categories) . + "\n\nEvaluate this request and determine which context is missing.{$chat_history_context}{$context}{$post_config_context}{$memory_context}{$followup_hint}{$language_hint}", + ], + ]; - $response = $provider->chat( $messages, array( 'temperature' => 0.7 ), 'planning' ); + $response = $provider->chat( + $messages, + ["temperature" => 0.7], + "planning", + ); - if ( is_wp_error( $response ) ) { - // Track failed attempt for observability. - $this->track_ai_cost( - $post_id, - WPAW_Model_Registry::get_default_model( 'clarity' ), - 'clarity_check', - 0, - 0, - 0, - $provider_result, - '', - 'error' - ); - // 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, - 'cost' => 0, - ), - 200 - ); - } + if (is_wp_error($response)) { + // Track failed attempt for observability. + $this->track_ai_cost( + $post_id, + WPAW_Model_Registry::get_default_model("clarity"), + "clarity_check", + 0, + 0, + 0, + $provider_result, + "", + "error", + ); + // 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"] ?? [], + $post_config, + ); + if (!empty($result["questions"])) { + $result["is_clear"] = false; + } + return new WP_REST_Response( + [ + "result" => $result, + "cost" => 0, + ], + 200, + ); + } - // Extract JSON from response. - $content = $response['content']; - $result = $this->extract_json( $content ); + // Extract JSON from response. + $content = $response["content"]; + $result = $this->extract_json($content); - if ( null === $result ) { - // Track parse failure for observability. - $this->track_ai_cost( - $post_id, - $response['model'] ?? 'unknown', - 'clarity_check', - $response['input_tokens'] ?? 0, - $response['output_tokens'] ?? 0, - $response['cost'] ?? 0, - $provider_result, - '', - 'error' - ); - // 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, - 'cost' => 0, - ), - 200 - ); - } + if (null === $result) { + // Track parse failure for observability. + $this->track_ai_cost( + $post_id, + $response["model"] ?? "unknown", + "clarity_check", + $response["input_tokens"] ?? 0, + $response["output_tokens"] ?? 0, + $response["cost"] ?? 0, + $provider_result, + "", + "error", + ); + // 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"] ?? [], + $post_config, + ); + if (!empty($result["questions"])) { + $result["is_clear"] = false; + } + return new WP_REST_Response( + [ + "result" => $result, + "cost" => 0, + ], + 200, + ); + } - // Track cost (always track for debugging). - $post_id = $params['postId'] ?? 0; - $this->track_ai_cost( - $post_id, - $response['model'] ?? '', - 'clarity_check', - $response['input_tokens'] ?? 0, - $response['output_tokens'] ?? 0, - $response['cost'] ?? 0, - $provider_result, - '', - 'success' - ); + // Track cost (always track for debugging). + $post_id = $params["postId"] ?? 0; + $this->track_ai_cost( + $post_id, + $response["model"] ?? "", + "clarity_check", + $response["input_tokens"] ?? 0, + $response["output_tokens"] ?? 0, + $response["cost"] ?? 0, + $provider_result, + "", + "success", + ); - // 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 ); + // MANDATORY: Always add configuration questions + if (!isset($result["questions"]) || !is_array($result["questions"])) { + $result["questions"] = []; + } + $result["questions"] = $this->append_config_questions( + $result["questions"], + $post_config, + ); - // 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 - } + // 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( - array( - 'result' => $result, - 'cost' => $response['cost'] ?? 0, - 'provider_metadata' => $this->build_provider_metadata( - $provider_result, - $response['model'] ?? '' - ), - ), - 200 - ); - } + return new WP_REST_Response( + [ + "result" => $result, + "cost" => $response["cost"] ?? 0, + "provider_metadata" => $this->build_provider_metadata( + $provider_result, + $response["model"] ?? "", + ), + ], + 200, + ); + } - /** - * Append configuration questions to clarity quiz. - * - * @since 0.1.0 - * @param array $questions Existing questions. - * @param array $post_config Post configuration. - * @return array Updated questions with config prompts. - */ - private function append_config_questions( $questions, $post_config ) { - $detected_language = $post_config['language'] ?? 'auto'; - $is_indonesian = ( 'Indonesian' === $detected_language ); + /** + * Append configuration questions to clarity quiz. + * + * @since 0.1.0 + * @param array $questions Existing questions. + * @param array $post_config Post configuration. + * @return array Updated questions with config prompts. + */ + private function append_config_questions($questions, $post_config) + { + $detected_language = $post_config["language"] ?? "auto"; + $is_indonesian = "Indonesian" === $detected_language; - // Get preferred languages from settings - $settings = get_option( 'wp_agentic_writer_settings', array() ); - $preferred_languages = array_merge( - $settings['preferred_languages'] ?? array( 'auto', 'English', 'Indonesian' ), - $settings['custom_languages'] ?? array() - ); + // Get preferred languages from settings + $settings = get_option("wp_agentic_writer_settings", []); + $preferred_languages = array_merge( + $settings["preferred_languages"] ?? [ + "auto", + "English", + "Indonesian", + ], + $settings["custom_languages"] ?? [], + ); - // Build language options from site preferences - $language_options = array(); - foreach ( $preferred_languages as $lang ) { - $language_options[] = array( - 'value' => $lang, - 'default' => ( 'auto' === $lang ), - ); - } + // Build language options from site preferences + $language_options = []; + foreach ($preferred_languages as $lang) { + $language_options[] = [ + "value" => $lang, + "default" => "auto" === $lang, + ]; + } - // Language selection question (FIRST) - $questions[] = array( - 'id' => 'config_language', - 'category' => 'config', - 'question' => $is_indonesian - ? '🌍 Pilih Bahasa Artikel (Select Article Language)' - : '🌍 Select Article Language', - 'type' => 'single_choice', - 'options' => $language_options, - ); + // Language selection question (FIRST) + $questions[] = [ + "id" => "config_language", + "category" => "config", + "question" => $is_indonesian + ? "🌍 Pilih Bahasa Artikel (Select Article Language)" + : "🌍 Select Article Language", + "type" => "single_choice", + "options" => $language_options, + ]; - // Single consolidated config question with all fields - $questions[] = array( - 'id' => 'config_all', - 'category' => 'config', - 'question' => $is_indonesian - ? '⚙️ Konfigurasi Artikel (Article Configuration)' - : '⚙️ Article Configuration', - 'type' => 'config_form', - 'fields' => array( - array( - 'id' => 'web_search', - 'label' => $is_indonesian - ? '🔍 Pencarian Web (Web Search)' - : '🔍 Web Search', - 'description' => $is_indonesian - ? 'Aktifkan untuk data terkini (~$0.02/pencarian)' - : 'Enable for current data (~$0.02/search)', - 'type' => 'toggle', - 'default' => false, - ), - array( - 'id' => 'seo', - 'label' => $is_indonesian - ? '📊 Optimasi SEO (SEO Optimization)' - : '📊 SEO Optimization', - 'description' => $is_indonesian - ? 'Optimalkan artikel untuk mesin pencari' - : 'Optimize article for search engines', - 'type' => 'toggle', - 'default' => true, - ), - array( - 'id' => 'focus_keyword', - 'label' => $is_indonesian - ? '🎯 Kata Kunci Fokus (Focus Keyword)' - : '🎯 Focus Keyword', - 'placeholder' => $is_indonesian ? 'Contoh: wordpress plugin' : 'Example: wordpress plugin', - 'type' => 'text', - 'max_length' => 100, - 'conditional' => 'seo', - 'default' => $post_config['seo_focus_keyword'] ?? '', - 'description' => ! empty( $post_config['seo_focus_keyword'] ) - ? ( $is_indonesian ? '💡 Disarankan AI - edit jika perlu' : '💡 AI-suggested - edit if needed' ) - : '', - ), - array( - 'id' => 'secondary_keywords', - 'label' => $is_indonesian - ? '🔑 Kata Kunci Sekunder (Secondary Keywords)' - : '🔑 Secondary Keywords', - 'placeholder' => $is_indonesian ? 'Pisahkan dengan koma' : 'Comma-separated', - 'type' => 'text', - 'max_length' => 200, - 'conditional' => 'seo', - 'default' => $post_config['seo_secondary_keywords'] ?? '', - 'description' => ! empty( $post_config['seo_secondary_keywords'] ) - ? ( $is_indonesian ? '💡 Disarankan AI - edit jika perlu' : '💡 AI-suggested - edit if needed' ) - : '', - ), - ), - ); + // Single consolidated config question with all fields + $questions[] = [ + "id" => "config_all", + "category" => "config", + "question" => $is_indonesian + ? "⚙️ Konfigurasi Artikel (Article Configuration)" + : "⚙️ Article Configuration", + "type" => "config_form", + "fields" => [ + [ + "id" => "web_search", + "label" => $is_indonesian + ? "🔍 Pencarian Web (Web Search)" + : "🔍 Web Search", + "description" => $is_indonesian + ? 'Aktifkan untuk data terkini (~$0.02/pencarian)' + : 'Enable for current data (~$0.02/search)', + "type" => "toggle", + "default" => false, + ], + [ + "id" => "seo", + "label" => $is_indonesian + ? "📊 Optimasi SEO (SEO Optimization)" + : "📊 SEO Optimization", + "description" => $is_indonesian + ? "Optimalkan artikel untuk mesin pencari" + : "Optimize article for search engines", + "type" => "toggle", + "default" => true, + ], + [ + "id" => "focus_keyword", + "label" => $is_indonesian + ? "🎯 Kata Kunci Fokus (Focus Keyword)" + : "🎯 Focus Keyword", + "placeholder" => $is_indonesian + ? "Contoh: wordpress plugin" + : "Example: wordpress plugin", + "type" => "text", + "max_length" => 100, + "conditional" => "seo", + "default" => $post_config["seo_focus_keyword"] ?? "", + "description" => !empty($post_config["seo_focus_keyword"]) + ? ($is_indonesian + ? "💡 Disarankan AI - edit jika perlu" + : "💡 AI-suggested - edit if needed") + : "", + ], + [ + "id" => "secondary_keywords", + "label" => $is_indonesian + ? "🔑 Kata Kunci Sekunder (Secondary Keywords)" + : "🔑 Secondary Keywords", + "placeholder" => $is_indonesian + ? "Pisahkan dengan koma" + : "Comma-separated", + "type" => "text", + "max_length" => 200, + "conditional" => "seo", + "default" => $post_config["seo_secondary_keywords"] ?? "", + "description" => !empty( + $post_config["seo_secondary_keywords"] + ) + ? ($is_indonesian + ? "💡 Disarankan AI - edit jika perlu" + : "💡 AI-suggested - edit if needed") + : "", + ], + ], + ]; - return $questions; - } + return $questions; + } - /** - * Handle block refine request. - * - * @since 0.1.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_block_refine( $request ) { - $params = $request->get_json_params(); - $block_id = $params['blockId'] ?? ''; - $block_type = $params['blockType'] ?? ''; - $block_content = $params['blockContent'] ?? ''; - $refinement_request = $params['refinementRequest'] ?? ''; - $article_context = $params['articleContext'] ?? array(); - $post_id = $params['postId'] ?? 0; - $stream = $params['stream'] ?? false; - $chat_history = $params['chatHistory'] ?? array(); + /** + * Handle block refine request. + * + * @since 0.1.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_block_refine($request) + { + $params = $request->get_json_params(); + $block_id = $params["blockId"] ?? ""; + $block_type = $params["blockType"] ?? ""; + $block_content = $params["blockContent"] ?? ""; + $refinement_request = $params["refinementRequest"] ?? ""; + $article_context = $params["articleContext"] ?? []; + $post_id = $params["postId"] ?? 0; + $stream = $params["stream"] ?? false; + $chat_history = $params["chatHistory"] ?? []; - if ( empty( $block_content ) || empty( $refinement_request ) ) { - return new WP_Error( - 'missing_data', - __( 'Block content and refinement request are required.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } + if (empty($block_content) || empty($refinement_request)) { + return new WP_Error( + "missing_data", + __( + "Block content and refinement request are required.", + "wp-agentic-writer", + ), + ["status" => 400], + ); + } - // Check post permission BEFORE reading post data. - if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } + // Check post permission BEFORE reading post data. + if ($post_id > 0 && !$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to edit this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } - // Only read post config after permission check. - $post_config = $this->resolve_post_config_from_request( $params, $post_id ); + // Only read post config after permission check. + $post_config = $this->resolve_post_config_from_request( + $params, + $post_id, + ); - // If streaming is requested, use streaming response. - if ( $stream ) { - return $this->stream_block_refine( $block_id, $block_type, $block_content, $refinement_request, $article_context, $post_id, $post_config ); - } + // If streaming is requested, use streaming response. + if ($stream) { + return $this->stream_block_refine( + $block_id, + $block_type, + $block_content, + $refinement_request, + $article_context, + $post_id, + $post_config, + ); + } - $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'refinement' ); - $provider = $provider_result->provider; + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( + "refinement", + ); + $provider = $provider_result->provider; - // Build context from article structure. - $context_str = "\n\nArticle Context:\n"; - $context_str .= "Title: " . ( $article_context['title'] ?? 'Unknown' ) . "\n"; + // Build context from article structure. + $context_str = "\n\nArticle Context:\n"; + $context_str .= + "Title: " . ($article_context["title"] ?? "Unknown") . "\n"; - if ( ! empty( $article_context['previousBlock'] ) ) { - $context_str .= "Previous section: " . $article_context['previousBlock']['heading'] . "\n"; - } + if (!empty($article_context["previousBlock"])) { + $context_str .= + "Previous section: " . + $article_context["previousBlock"]["heading"] . + "\n"; + } - $context_str .= "Current block type: " . $block_type . "\n"; - $context_str .= "Current content:\n" . $block_content . "\n"; + $context_str .= "Current block type: " . $block_type . "\n"; + $context_str .= "Current content:\n" . $block_content . "\n"; - if ( ! empty( $article_context['nextBlock'] ) ) { - $context_str .= "Next section: " . $article_context['nextBlock']['heading'] . "\n"; - } + if (!empty($article_context["nextBlock"])) { + $context_str .= + "Next section: " . + $article_context["nextBlock"]["heading"] . + "\n"; + } - // Add chat history context if available - $chat_history_context = ''; - if ( ! empty( $chat_history ) && is_array( $chat_history ) ) { - $chat_history_context = "\n\n--- ORIGINAL CONVERSATION ---\n"; - foreach ( $chat_history as $msg ) { - $role = isset( $msg['role'] ) ? ucfirst( $msg['role'] ) : 'Unknown'; - $content = isset( $msg['content'] ) ? $msg['content'] : ''; - if ( ! empty( $content ) && 'system' !== strtolower( $msg['role'] ?? '' ) ) { - $chat_history_context .= "{$role}: {$content}\n\n"; - } - } - $chat_history_context .= "--- END CONVERSATION ---\n"; - $chat_history_context .= "This shows the original discussion that led to this article."; - } + // Add chat history context if available + $chat_history_context = ""; + if (!empty($chat_history) && is_array($chat_history)) { + $chat_history_context = "\n\n--- ORIGINAL CONVERSATION ---\n"; + foreach ($chat_history as $msg) { + $role = isset($msg["role"]) ? ucfirst($msg["role"]) : "Unknown"; + $content = isset($msg["content"]) ? $msg["content"] : ""; + if ( + !empty($content) && + "system" !== strtolower($msg["role"] ?? "") + ) { + $chat_history_context .= "{$role}: {$content}\n\n"; + } + } + $chat_history_context .= "--- END CONVERSATION ---\n"; + $chat_history_context .= + "This shows the original discussion that led to this article."; + } - // Add plan context if available - $plan_context = ''; - $plan = get_post_meta( $post_id, '_wpaw_plan', true ); - if ( ! empty( $plan ) && is_array( $plan ) ) { - $plan_context = "\n\nOriginal Article Outline:\n"; - if ( ! empty( $plan['title'] ) ) { - $plan_context .= "Title: {$plan['title']}\n"; - } - if ( ! empty( $plan['sections'] ) && is_array( $plan['sections'] ) ) { - foreach ( $plan['sections'] as $section ) { - $heading = $section['heading'] ?? $section['title'] ?? ''; - if ( ! empty( $heading ) ) { - $plan_context .= "- {$heading}\n"; - } - } - } - } + // Add plan context if available + $plan_context = ""; + $plan = get_post_meta($post_id, "_wpaw_plan", true); + if (!empty($plan) && is_array($plan)) { + $plan_context = "\n\nOriginal Article Outline:\n"; + if (!empty($plan["title"])) { + $plan_context .= "Title: {$plan["title"]}\n"; + } + if (!empty($plan["sections"]) && is_array($plan["sections"])) { + foreach ($plan["sections"] as $section) { + $heading = $section["heading"] ?? ($section["title"] ?? ""); + if (!empty($heading)) { + $plan_context .= "- {$heading}\n"; + } + } + } + } - $system_prompt = "You are an expert editor helping refine a specific section of an article. + $system_prompt = "You are an expert editor helping refine a specific section of an article. {$context_str} {$plan_context} @@ -5354,106 +6316,149 @@ Refine the current section content considering: Provide the refined content in Markdown format. Keep the same block type (paragraph, heading, list, etc.)."; - $messages = array( - array( - 'role' => 'system', - 'content' => $system_prompt, - ), - array( - 'role' => 'user', - 'content' => "Please refine this content.", - ), - ); + $messages = [ + [ + "role" => "system", + "content" => $system_prompt, + ], + [ + "role" => "user", + "content" => "Please refine this content.", + ], + ]; - $response = $provider->chat( $messages, array( 'temperature' => 0.7 ), 'execution' ); + $response = $provider->chat( + $messages, + ["temperature" => 0.7], + "execution", + ); - if ( is_wp_error( $response ) ) { - return new WP_Error( - 'refinement_error', - $response->get_error_message(), - array( 'status' => 500 ) - ); - } + if (is_wp_error($response)) { + return new WP_Error( + "refinement_error", + $response->get_error_message(), + ["status" => 500], + ); + } - // Parse refined content as Gutenberg blocks. - $blocks = WP_Agentic_Writer_Markdown_Parser::parse( $response['content'] ); + // Parse refined content as Gutenberg blocks. + $blocks = WP_Agentic_Writer_Markdown_Parser::parse( + $response["content"], + ); - // Track cost (always track for debugging). - $this->track_ai_cost( - $post_id, - $response['model'] ?? '', - 'block_refinement', - $response['input_tokens'] ?? 0, - $response['output_tokens'] ?? 0, - $response['cost'] ?? 0, - $provider_result, - $session_id, - 'success' - ); + // MEMANTO: Remember block refinement. + do_action( + "wpaw_memanto_block_refined", + $post_id, + $block_id, + $refinement_request, + ); - return new WP_REST_Response( - array( - 'blocks' => $blocks, - 'blockId' => $block_id, - 'cost' => $response['cost'] ?? 0, - 'provider_metadata' => $this->build_provider_metadata( - $provider_result, - $response['model'] ?? '' - ), - ), - 200 - ); - } + // Track cost (always track for debugging). + $this->track_ai_cost( + $post_id, + $response["model"] ?? "", + "block_refinement", + $response["input_tokens"] ?? 0, + $response["output_tokens"] ?? 0, + $response["cost"] ?? 0, + $provider_result, + $session_id, + "success", + ); - /** - * Stream block refinement response. - * - * @since 0.1.0 - * @param string $block_id Block ID. - * @param string $block_type Block type. - * @param string $block_content Block content. - * @param string $refinement_request Refinement request. - * @param array $article_context Article context. - * @param int $post_id Post ID. - * @return void Streams response to client. - */ - private function stream_block_refine( $block_id, $block_type, $block_content, $refinement_request, $article_context, $post_id, $post_config = array() ) { - // Set headers for streaming. - header( 'Content-Type: text/event-stream' ); - header( 'Cache-Control: no-cache' ); - header( 'X-Accel-Buffering: no' ); // Disable Nginx buffering. + return new WP_REST_Response( + [ + "blocks" => $blocks, + "blockId" => $block_id, + "cost" => $response["cost"] ?? 0, + "provider_metadata" => $this->build_provider_metadata( + $provider_result, + $response["model"] ?? "", + ), + ], + 200, + ); + } - // Flush output buffer to ensure immediate streaming. - if ( ob_get_level() > 0 ) { - ob_end_flush(); - } - flush(); + /** + * Stream block refinement response. + * + * @since 0.1.0 + * @param string $block_id Block ID. + * @param string $block_type Block type. + * @param string $block_content Block content. + * @param string $refinement_request Refinement request. + * @param array $article_context Article context. + * @param int $post_id Post ID. + * @return void Streams response to client. + */ + private function stream_block_refine( + $block_id, + $block_type, + $block_content, + $refinement_request, + $article_context, + $post_id, + $post_config = [], + ) { + // Set headers for streaming. + header("Content-Type: text/event-stream"); + header("Cache-Control: no-cache"); + header("X-Accel-Buffering: no"); // Disable Nginx buffering. - $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'refinement' ); - $provider = $provider_result->provider; - $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 ); - $effective_language = $this->resolve_language_preference( $post_config, $stored_language ); - $language_instruction = $this->build_language_instruction( $effective_language, 'refined content' ); + // Flush output buffer to ensure immediate streaming. + if (ob_get_level() > 0) { + ob_end_flush(); + } + flush(); - try { - // Build context from article structure. - $context_str = "\n\nArticle Context:\n"; - $context_str .= "Title: " . ( $article_context['title'] ?? 'Unknown' ) . "\n"; + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( + "refinement", + ); + $provider = $provider_result->provider; + $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, + ); + $effective_language = $this->resolve_language_preference( + $post_config, + $stored_language, + ); + $language_instruction = $this->build_language_instruction( + $effective_language, + "refined content", + ); - if ( ! empty( $article_context['previousBlock'] ) ) { - $context_str .= "Previous section: " . $article_context['previousBlock']['heading'] . "\n"; - } + try { + // Build context from article structure. + $context_str = "\n\nArticle Context:\n"; + $context_str .= + "Title: " . ($article_context["title"] ?? "Unknown") . "\n"; - $context_str .= "Current block type: " . $block_type . "\n"; - $context_str .= "Current content:\n" . $block_content . "\n"; + if (!empty($article_context["previousBlock"])) { + $context_str .= + "Previous section: " . + $article_context["previousBlock"]["heading"] . + "\n"; + } - if ( ! empty( $article_context['nextBlock'] ) ) { - $context_str .= "Next section: " . $article_context['nextBlock']['heading'] . "\n"; - } + $context_str .= "Current block type: " . $block_type . "\n"; + $context_str .= "Current content:\n" . $block_content . "\n"; - $system_prompt = "You are a precise content editor. Your task is to refine the provided content based strictly on the user's request. + if (!empty($article_context["nextBlock"])) { + $context_str .= + "Next section: " . + $article_context["nextBlock"]["heading"] . + "\n"; + } + + $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. @@ -5484,201 +6489,225 @@ Output format: - No conversational filler - Start directly with the refined content"; - $messages = array( - array( - 'role' => 'system', - 'content' => $system_prompt, - ), - array( - 'role' => 'user', - 'content' => "Refine this content.", - ), - ); + $messages = [ + [ + "role" => "system", + "content" => $system_prompt, + ], + [ + "role" => "user", + "content" => "Refine this content.", + ], + ]; - $response = $provider->chat( $messages, array( 'temperature' => 0.7 ), 'execution' ); + $response = $provider->chat( + $messages, + ["temperature" => 0.7], + "execution", + ); - if ( is_wp_error( $response ) ) { - echo "data: " . wp_json_encode( - array( - 'type' => 'error', - 'message' => $response->get_error_message(), - ) - ) . "\n\n"; - flush(); - exit; - } + if (is_wp_error($response)) { + echo "data: " . + wp_json_encode([ + "type" => "error", + "message" => $response->get_error_message(), + ]) . + "\n\n"; + flush(); + exit(); + } - // Track cost (always track for debugging). - $this->track_ai_cost( - $post_id, - $response['model'] ?? '', - 'block_refinement', - $response['input_tokens'] ?? 0, - $response['output_tokens'] ?? 0, - $response['cost'] ?? 0, - $provider_result, - $session_id ?? '', - 'success' - ); + // Track cost (always track for debugging). + $this->track_ai_cost( + $post_id, + $response["model"] ?? "", + "block_refinement", + $response["input_tokens"] ?? 0, + $response["output_tokens"] ?? 0, + $response["cost"] ?? 0, + $provider_result, + $session_id ?? "", + "success", + ); - $payload = $this->parse_refined_payload( $response['content'] ); - $refined_content = $this->clean_refined_content( $payload['content'] ); - $resolved_block_type = $payload['blockType'] ?? $block_type; + $payload = $this->parse_refined_payload($response["content"]); + $refined_content = $this->clean_refined_content( + $payload["content"], + ); + $resolved_block_type = $payload["blockType"] ?? $block_type; - // Parse as block based on type and create proper Gutenberg block structure - $block_data = array(); - $block_name = 'core/paragraph'; // Default + // Parse as block based on type and create proper Gutenberg block structure + $block_data = []; + $block_name = "core/paragraph"; // Default - if ( $resolved_block_type === 'core/paragraph' ) { - $block_name = 'core/paragraph'; - $block_attrs = array( 'content' => $refined_content ); - // Create proper HTML for paragraph - $block_html = '

    ' . $refined_content . '

    '; - // Create proper block structure - $block_data = array( - 'blockName' => $block_name, - 'attrs' => $block_attrs, - 'innerHTML' => $block_html, - 'clientId' => $block_id, - ); - } elseif ( $resolved_block_type === 'core/heading' ) { - $block_name = 'core/heading'; - // Detect heading level from markdown-style if present - $level = 2; - if ( preg_match( '/^(#{1,6})\s/', $refined_content ) ) { - $count = strspn( $refined_content, '#' ); - $level = min( $count, 6 ); - $refined_content = trim( substr( $refined_content, $count ) ); - } - $block_attrs = array( - 'level' => $level, - 'content' => $refined_content, - ); - $tag = 'h' . $level; - $block_html = "<{$tag}>{$refined_content}"; - $block_data = array( - 'blockName' => $block_name, - 'attrs' => $block_attrs, - 'innerHTML' => $block_html, - 'clientId' => $block_id, - ); - } elseif ( $resolved_block_type === 'core/list' ) { - $block_name = 'core/list'; - $lines = explode( "\n", $refined_content ); - $lines = array_filter( array_map( 'trim', $lines ) ); + if ($resolved_block_type === "core/paragraph") { + $block_name = "core/paragraph"; + $block_attrs = ["content" => $refined_content]; + // Create proper HTML for paragraph + $block_html = "

    " . $refined_content . "

    "; + // Create proper block structure + $block_data = [ + "blockName" => $block_name, + "attrs" => $block_attrs, + "innerHTML" => $block_html, + "clientId" => $block_id, + ]; + } elseif ($resolved_block_type === "core/heading") { + $block_name = "core/heading"; + // Detect heading level from markdown-style if present + $level = 2; + if (preg_match("/^(#{1,6})\s/", $refined_content)) { + $count = strspn($refined_content, "#"); + $level = min($count, 6); + $refined_content = trim(substr($refined_content, $count)); + } + $block_attrs = [ + "level" => $level, + "content" => $refined_content, + ]; + $tag = "h" . $level; + $block_html = "<{$tag}>{$refined_content}"; + $block_data = [ + "blockName" => $block_name, + "attrs" => $block_attrs, + "innerHTML" => $block_html, + "clientId" => $block_id, + ]; + } elseif ($resolved_block_type === "core/list") { + $block_name = "core/list"; + $lines = explode("\n", $refined_content); + $lines = array_filter(array_map("trim", $lines)); - // Create inner blocks for list items - $inner_blocks = array(); - foreach ( $lines as $line ) { - $inner_blocks[] = array( - 'blockName' => 'core/list-item', - 'attrs' => array( 'content' => $line ), - 'innerHTML' => '
  • ' . $line . '
  • ', - ); - } + // Create inner blocks for list items + $inner_blocks = []; + foreach ($lines as $line) { + $inner_blocks[] = [ + "blockName" => "core/list-item", + "attrs" => ["content" => $line], + "innerHTML" => "
  • " . $line . "
  • ", + ]; + } - $block_attrs = array( 'ordered' => false ); - $block_html = '
      ' . implode( '', array_map( function( $item ) { - return $item['innerHTML']; - }, $inner_blocks ) ) . '
    '; + $block_attrs = ["ordered" => false]; + $block_html = + "
      " . + implode( + "", + array_map(function ($item) { + return $item["innerHTML"]; + }, $inner_blocks), + ) . + "
    "; - $block_data = array( - 'blockName' => $block_name, - 'attrs' => $block_attrs, - 'innerBlocks' => $inner_blocks, - 'innerHTML' => $block_html, - 'clientId' => $block_id, - ); - } else { - // Fallback to paragraph for unknown types - $block_name = 'core/paragraph'; - $block_attrs = array( 'content' => $refined_content ); - $block_html = '

    ' . $refined_content . '

    '; - $block_data = array( - 'blockName' => $block_name, - 'attrs' => $block_attrs, - 'innerHTML' => $block_html, - 'clientId' => $block_id, - ); - } + $block_data = [ + "blockName" => $block_name, + "attrs" => $block_attrs, + "innerBlocks" => $inner_blocks, + "innerHTML" => $block_html, + "clientId" => $block_id, + ]; + } else { + // Fallback to paragraph for unknown types + $block_name = "core/paragraph"; + $block_attrs = ["content" => $refined_content]; + $block_html = "

    " . $refined_content . "

    "; + $block_data = [ + "blockName" => $block_name, + "attrs" => $block_attrs, + "innerHTML" => $block_html, + "clientId" => $block_id, + ]; + } - // Send the refined block - echo "data: " . wp_json_encode( - array( - 'type' => 'block', - 'block' => $block_data, - ) - ) . "\n\n"; - flush(); + // Send the refined block + echo "data: " . + wp_json_encode([ + "type" => "block", + "block" => $block_data, + ]) . + "\n\n"; + flush(); - // Small delay for visual effect - usleep( 100000 ); + // Small delay for visual effect + usleep(100000); - // Send completion message with provider metadata. - echo "data: " . wp_json_encode( - array( - 'type' => 'complete', - 'blockId' => $block_id, - 'totalCost' => $response['cost'], - 'provider_metadata' => $this->build_provider_metadata( - $provider_result, - $response['model'] ?? '' - ), - ) - ) . "\n\n"; - flush(); - } catch ( Exception $e ) { - echo "data: " . wp_json_encode( - array( - 'type' => 'error', - 'message' => $e->getMessage(), - ) - ) . "\n\n"; - flush(); - } + // Send completion message with provider metadata. + echo "data: " . + wp_json_encode([ + "type" => "complete", + "blockId" => $block_id, + "totalCost" => $response["cost"], + "provider_metadata" => $this->build_provider_metadata( + $provider_result, + $response["model"] ?? "", + ), + ]) . + "\n\n"; + flush(); - exit; - } + // MEMANTO: Remember block was refined. + do_action( + "wpaw_memanto_block_refined", + $post_id, + $block_id, + $refinement_request, + ); + } catch (Exception $e) { + echo "data: " . + wp_json_encode([ + "type" => "error", + "message" => $e->getMessage(), + ]) . + "\n\n"; + flush(); + } - /** - * Check clarity before article generation. - * - * @since 0.1.0 - * @param string $topic User topic. - * @param array $answers Previous answers. - * @param WP_Agentic_Writer_OpenRouter_Provider $provider OpenRouter provider. - * @return array Clarity check result with is_clear and questions. - */ - private function check_clarity_before_generation( $topic, $answers, $provider ) { - // Get settings. - $settings = get_option( 'wp_agentic_writer_settings', array() ); - $enabled = $settings['enable_clarification_quiz'] ?? true; - $threshold = $settings['clarity_confidence_threshold'] ?? '0.6'; - $required_categories = $settings['required_context_categories'] ?? array( - 'target_outcome', - 'target_audience', - 'tone', - 'content_depth', - 'expertise_level', - 'content_type', - 'pov', - ); + exit(); + } - // If quiz is disabled, always return clear. - if ( ! $enabled ) { - return array( 'is_clear' => true, 'confidence' => 1.0, 'questions' => array() ); - } + /** + * Check clarity before article generation. + * + * @since 0.1.0 + * @param string $topic User topic. + * @param array $answers Previous answers. + * @param WP_Agentic_Writer_OpenRouter_Provider $provider OpenRouter provider. + * @return array Clarity check result with is_clear and questions. + */ + private function check_clarity_before_generation( + $topic, + $answers, + $provider, + ) { + // Get settings. + $settings = get_option("wp_agentic_writer_settings", []); + $enabled = $settings["enable_clarification_quiz"] ?? true; + $threshold = $settings["clarity_confidence_threshold"] ?? "0.6"; + $required_categories = $settings["required_context_categories"] ?? [ + "target_outcome", + "target_audience", + "tone", + "content_depth", + "expertise_level", + "content_type", + "pov", + ]; - // Build context from answers if available. - $context = ''; - if ( ! empty( $answers ) ) { - $context = "\n\nPrevious answers:\n"; - foreach ( $answers as $answer ) { - $context .= "- {$answer['question']}: {$answer['answer']}\n"; - } - } + // If quiz is disabled, always return clear. + if (!$enabled) { + return ["is_clear" => true, "confidence" => 1.0, "questions" => []]; + } - $system_prompt = "You are an expert editor who determines if an article request has sufficient context to write effectively. + // Build context from answers if available. + $context = ""; + if (!empty($answers)) { + $context = "\n\nPrevious answers:\n"; + foreach ($answers as $answer) { + $context .= "- {$answer["question"]}: {$answer["answer"]}\n"; + } + } + + $system_prompt = "You are an expert editor who determines if an article request has sufficient context to write effectively. Evaluate the user's request and determine which context categories are clear: @@ -5724,499 +6753,684 @@ Return ONLY valid JSON with this structure: No markdown, no explanation - just JSON."; - $messages = array( - array( - 'role' => 'system', - 'content' => $system_prompt, - ), - array( - 'role' => 'user', - 'content' => "Topic: {$topic}\n\nRequired Categories: " . implode( ', ', $required_categories ) . "\n\nEvaluate this request and determine which context is missing.{$context}", - ), - ); + $messages = [ + [ + "role" => "system", + "content" => $system_prompt, + ], + [ + "role" => "user", + "content" => + "Topic: {$topic}\n\nRequired Categories: " . + implode(", ", $required_categories) . + "\n\nEvaluate this request and determine which context is missing.{$context}", + ], + ]; - $response = $provider->chat( $messages, array( 'temperature' => 0.7 ), 'planning' ); + $response = $provider->chat( + $messages, + ["temperature" => 0.7], + "planning", + ); - if ( is_wp_error( $response ) ) { - // Log error and use default questions instead of skipping. - error_log( 'WP Agentic Writer: Clarity check API error - ' . $response->get_error_message() ); - return $this->get_default_clarification_questions( $topic ); - } + if (is_wp_error($response)) { + // Log error and use default questions instead of skipping. + error_log( + "WP Agentic Writer: Clarity check API error - " . + $response->get_error_message(), + ); + return $this->get_default_clarification_questions($topic); + } - // Extract JSON from response. - $content = $response['content']; - $result = $this->extract_json( $content ); + // Extract JSON from response. + $content = $response["content"]; + $result = $this->extract_json($content); - if ( null === $result ) { - // Log parse error and use default questions instead of skipping. - error_log( 'WP Agentic Writer: Failed to parse clarity check JSON' ); - return $this->get_default_clarification_questions( $topic ); - } + if (null === $result) { + // Log parse error and use default questions instead of skipping. + error_log("WP Agentic Writer: Failed to parse clarity check JSON"); + return $this->get_default_clarification_questions($topic); + } - return $result; - } + return $result; + } - /** - * Send status update via SSE. - * - * @since 0.1.0 - * @param string $status Status code. - * @param string $message Status message. - */ - private function send_status( $status, $message = '' ) { - $status_icons = array( - 'starting' => '', - 'planning' => '', - 'plan_complete' => '', - 'writing' => '', - 'writing_section' => '', - 'complete' => '', - ); + /** + * Send status update via SSE. + * + * @since 0.1.0 + * @param string $status Status code. + * @param string $message Status message. + */ + private function send_status($status, $message = "") + { + $status_icons = [ + "starting" => "", + "planning" => "", + "plan_complete" => "", + "writing" => "", + "writing_section" => "", + "complete" => "", + ]; - $icon = isset( $status_icons[ $status ] ) ? $status_icons[ $status ] : ''; + $icon = isset($status_icons[$status]) ? $status_icons[$status] : ""; - echo "data: " . wp_json_encode( - array( - 'type' => 'status', - 'status' => $status, - 'message' => $message, - 'icon' => $icon, - ) - ) . "\n\n"; - flush(); - } + echo "data: " . + wp_json_encode([ + "type" => "status", + "status" => $status, + "message" => $message, + "icon" => $icon, + ]) . + "\n\n"; + flush(); + } - /** - * Get default clarification questions when AI fails. - * - * @since 0.1.0 - * @param string $topic User's topic. - * @return array Clarification result with default questions. - */ - private function get_default_clarification_questions( $topic ) { - $settings = get_option( 'wp_agentic_writer_settings', array() ); - $required_categories = $settings['required_context_categories'] ?? array( - 'target_outcome', - 'target_audience', - 'tone', - 'content_depth', - 'expertise_level', - 'content_type', - 'pov', - ); + /** + * Get default clarification questions when AI fails. + * + * @since 0.1.0 + * @param string $topic User's topic. + * @return array Clarification result with default questions. + */ + private function get_default_clarification_questions($topic) + { + $settings = get_option("wp_agentic_writer_settings", []); + $required_categories = $settings["required_context_categories"] ?? [ + "target_outcome", + "target_audience", + "tone", + "content_depth", + "expertise_level", + "content_type", + "pov", + ]; - $questions = array(); - $question_id = 1; + $questions = []; + $question_id = 1; - $question_templates = array( - 'target_outcome' => array( - 'category' => 'target_outcome', - 'question' => 'What is the primary goal of this content?', - 'type' => 'single_choice', - 'options' => array( - array( 'value' => 'Education - Teach something new', 'default' => true ), - array( 'value' => 'Marketing - Promote a product/service', 'default' => false ), - array( 'value' => 'Sales - Drive conversions', 'default' => false ), - array( 'value' => 'Entertainment - Engage readers', 'default' => false ), - array( 'value' => 'Brand Awareness - Build authority', 'default' => false ), - ), - ), - 'target_audience' => array( - 'category' => 'target_audience', - 'question' => 'Who is the primary audience for this content?', - 'type' => 'single_choice', - 'options' => array( - array( 'value' => 'General public / Beginners', 'default' => true ), - array( 'value' => 'Professionals in the field', 'default' => false ), - array( 'value' => 'Potential customers', 'default' => false ), - array( 'value' => 'Existing customers/users', 'default' => false ), - array( 'value' => 'Industry peers / Experts', 'default' => false ), - ), - ), - 'tone' => array( - 'category' => 'tone', - 'question' => 'What tone should this content have?', - 'type' => 'single_choice', - 'options' => array( - array( 'value' => 'Professional & Authoritative', 'default' => true ), - array( 'value' => 'Friendly & Conversational', 'default' => false ), - array( 'value' => 'Technical & Detailed', 'default' => false ), - array( 'value' => 'Casual & Entertaining', 'default' => false ), - array( 'value' => 'Formal & Academic', 'default' => false ), - ), - ), - 'content_depth' => array( - 'category' => 'content_depth', - 'question' => 'How comprehensive should this content be?', - 'type' => 'single_choice', - 'options' => array( - array( 'value' => 'Quick overview (500-800 words)', 'default' => false ), - array( 'value' => 'Standard guide (800-1500 words)', 'default' => true ), - array( 'value' => 'Detailed analysis (1500-2500 words)', 'default' => false ), - array( 'value' => 'Comprehensive deep-dive (2500+ words)', 'default' => false ), - ), - ), - 'expertise_level' => array( - 'category' => 'expertise_level', - 'question' => 'What is the target audience\'s expertise level?', - 'type' => 'single_choice', - 'options' => array( - array( 'value' => 'Beginner - No prior knowledge', 'default' => true ), - array( 'value' => 'Intermediate - Basic understanding', 'default' => false ), - array( 'value' => 'Advanced - Deep technical knowledge', 'default' => false ), - array( 'value' => 'Expert - Industry professional', 'default' => false ), - ), - ), - 'content_type' => array( - 'category' => 'content_type', - 'question' => 'What type of content works best for this topic?', - 'type' => 'single_choice', - 'options' => array( - array( 'value' => 'Tutorial / How-to guide', 'default' => true ), - array( 'value' => 'Opinion / Commentary', 'default' => false ), - array( 'value' => 'Comparison / Review', 'default' => false ), - array( 'value' => 'Listicle / Tips', 'default' => false ), - array( 'value' => 'Case study', 'default' => false ), - array( 'value' => 'News analysis', 'default' => false ), - ), - ), - 'pov' => array( - 'category' => 'pov', - 'question' => 'From what perspective should this be written?', - 'type' => 'single_choice', - 'options' => array( - array( 'value' => 'Third person (objective, "it", "they")', 'default' => true ), - array( 'value' => 'First person (personal, "I", "my")', 'default' => false ), - array( 'value' => 'Expert voice (authoritative, experienced)', 'default' => false ), - array( 'value' => 'Neutral / Unbiased', 'default' => false ), - ), - ), - ); + $question_templates = [ + "target_outcome" => [ + "category" => "target_outcome", + "question" => "What is the primary goal of this content?", + "type" => "single_choice", + "options" => [ + [ + "value" => "Education - Teach something new", + "default" => true, + ], + [ + "value" => "Marketing - Promote a product/service", + "default" => false, + ], + [ + "value" => "Sales - Drive conversions", + "default" => false, + ], + [ + "value" => "Entertainment - Engage readers", + "default" => false, + ], + [ + "value" => "Brand Awareness - Build authority", + "default" => false, + ], + ], + ], + "target_audience" => [ + "category" => "target_audience", + "question" => "Who is the primary audience for this content?", + "type" => "single_choice", + "options" => [ + [ + "value" => "General public / Beginners", + "default" => true, + ], + [ + "value" => "Professionals in the field", + "default" => false, + ], + ["value" => "Potential customers", "default" => false], + ["value" => "Existing customers/users", "default" => false], + ["value" => "Industry peers / Experts", "default" => false], + ], + ], + "tone" => [ + "category" => "tone", + "question" => "What tone should this content have?", + "type" => "single_choice", + "options" => [ + [ + "value" => "Professional & Authoritative", + "default" => true, + ], + [ + "value" => "Friendly & Conversational", + "default" => false, + ], + ["value" => "Technical & Detailed", "default" => false], + ["value" => "Casual & Entertaining", "default" => false], + ["value" => "Formal & Academic", "default" => false], + ], + ], + "content_depth" => [ + "category" => "content_depth", + "question" => "How comprehensive should this content be?", + "type" => "single_choice", + "options" => [ + [ + "value" => "Quick overview (500-800 words)", + "default" => false, + ], + [ + "value" => "Standard guide (800-1500 words)", + "default" => true, + ], + [ + "value" => "Detailed analysis (1500-2500 words)", + "default" => false, + ], + [ + "value" => "Comprehensive deep-dive (2500+ words)", + "default" => false, + ], + ], + ], + "expertise_level" => [ + "category" => "expertise_level", + "question" => 'What is the target audience\'s expertise level?', + "type" => "single_choice", + "options" => [ + [ + "value" => "Beginner - No prior knowledge", + "default" => true, + ], + [ + "value" => "Intermediate - Basic understanding", + "default" => false, + ], + [ + "value" => "Advanced - Deep technical knowledge", + "default" => false, + ], + [ + "value" => "Expert - Industry professional", + "default" => false, + ], + ], + ], + "content_type" => [ + "category" => "content_type", + "question" => "What type of content works best for this topic?", + "type" => "single_choice", + "options" => [ + ["value" => "Tutorial / How-to guide", "default" => true], + ["value" => "Opinion / Commentary", "default" => false], + ["value" => "Comparison / Review", "default" => false], + ["value" => "Listicle / Tips", "default" => false], + ["value" => "Case study", "default" => false], + ["value" => "News analysis", "default" => false], + ], + ], + "pov" => [ + "category" => "pov", + "question" => "From what perspective should this be written?", + "type" => "single_choice", + "options" => [ + [ + "value" => 'Third person (objective, "it", "they")', + "default" => true, + ], + [ + "value" => 'First person (personal, "I", "my")', + "default" => false, + ], + [ + "value" => "Expert voice (authoritative, experienced)", + "default" => false, + ], + ["value" => "Neutral / Unbiased", "default" => false], + ], + ], + ]; - foreach ( $required_categories as $category ) { - if ( isset( $question_templates[ $category ] ) ) { - $q = $question_templates[ $category ]; - $q['id'] = 'q' . $question_id++; - $questions[] = $q; - } - } + foreach ($required_categories as $category) { + if (isset($question_templates[$category])) { + $q = $question_templates[$category]; + $q["id"] = "q" . $question_id++; + $questions[] = $q; + } + } - return array( - 'is_clear' => false, - 'confidence' => 0.0, - 'missing_categories' => $required_categories, - 'questions' => $questions, - ); - } + return [ + "is_clear" => false, + "confidence" => 0.0, + "missing_categories" => $required_categories, + "questions" => $questions, + ]; + } - /** - * Handle chat-based block refinement request. - * - * @since 0.1.0 - * @param WP_REST_Request $request Full request data. - * @return void Streams response to client. - */ - public function handle_refine_from_chat( $request ) { - $params = $request->get_json_params(); - $message = $params['topic'] ?? ''; - $selected_block = $params['selectedBlockClientId'] ?? ''; - $post_id = $params['postId'] ?? 0; - $session_id = $this->resolve_or_create_session_id( $params['sessionId'] ?? '', $post_id ); - $blocks_to_refine = $params['blocksToRefine'] ?? array(); - $all_blocks = $params['allBlocks'] ?? array(); - $diff_plan = ! empty( $params['diffPlan'] ); - $selective_refine = ! empty( $params['selectiveRefine'] ); + /** + * Handle chat-based block refinement request. + * + * @since 0.1.0 + * @param WP_REST_Request $request Full request data. + * @return void Streams response to client. + */ + public function handle_refine_from_chat($request) + { + $params = $request->get_json_params(); + $message = $params["topic"] ?? ""; + $selected_block = $params["selectedBlockClientId"] ?? ""; + $post_id = $params["postId"] ?? 0; + $session_id = $this->resolve_or_create_session_id( + $params["sessionId"] ?? "", + $post_id, + ); + $blocks_to_refine = $params["blocksToRefine"] ?? []; + $all_blocks = $params["allBlocks"] ?? []; + $diff_plan = !empty($params["diffPlan"]); + $selective_refine = !empty($params["selectiveRefine"]); + $audit_context = + isset($params["auditContext"]) && is_array($params["auditContext"]) + ? $params["auditContext"] + : []; - if ( empty( $blocks_to_refine ) || ! is_array( $blocks_to_refine ) ) { - return new WP_Error( - 'no_blocks_mentioned', - __( 'No valid blocks found to refine. Try mentioning blocks like @this, @previous, or specific blocks like @paragraph-1', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } + if (empty($blocks_to_refine) || !is_array($blocks_to_refine)) { + return new WP_Error( + "no_blocks_mentioned", + __( + "No valid blocks found to refine. Try mentioning blocks like @this, @previous, or specific blocks like @paragraph-1", + "wp-agentic-writer", + ), + ["status" => 400], + ); + } - // Check post permission BEFORE reading post data. - if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } + // Check post permission BEFORE reading post data. + if ($post_id > 0 && !$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to edit this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } - // Only read post config after permission check. - $post_config = $this->resolve_post_config_from_request( $params, $post_id ); + // Only read post config after permission check. + $post_config = $this->resolve_post_config_from_request( + $params, + $post_id, + ); - // Stream refinement for each mentioned block - $this->stream_refinement_from_chat( $blocks_to_refine, $message, $selected_block, $post_id, $all_blocks, $diff_plan, $post_config, $session_id, $selective_refine ); + // Stream refinement for each mentioned block + $this->stream_refinement_from_chat( + $blocks_to_refine, + $message, + $selected_block, + $post_id, + $all_blocks, + $diff_plan, + $post_config, + $session_id, + $selective_refine, + $audit_context, + ); - // Return early to avoid REST API trying to send headers after streaming - exit; - } + // Return early to avoid REST API trying to send headers after streaming + exit(); + } - /** - * Save section-to-block mapping. - * - * @since 0.1.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_save_section_blocks( $request ) { - $params = $request->get_json_params(); - $post_id = intval( $params['postId'] ?? 0 ); - $section_id = sanitize_text_field( $params['sectionId'] ?? '' ); - $block_ids = $params['blockIds'] ?? array(); + /** + * Save section-to-block mapping. + * + * @since 0.1.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_save_section_blocks($request) + { + $params = $request->get_json_params(); + $post_id = intval($params["postId"] ?? 0); + $section_id = sanitize_text_field($params["sectionId"] ?? ""); + $block_ids = $params["blockIds"] ?? []; - if ( $post_id <= 0 || empty( $section_id ) || ! is_array( $block_ids ) ) { - return new WP_Error( - 'invalid_section_blocks', - __( 'Invalid section block mapping request.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } + if ($post_id <= 0 || empty($section_id) || !is_array($block_ids)) { + return new WP_Error( + "invalid_section_blocks", + __( + "Invalid section block mapping request.", + "wp-agentic-writer", + ), + ["status" => 400], + ); + } - if ( ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } + if (!$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to edit this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } - $block_ids = array_values( - array_filter( - array_map( 'sanitize_text_field', $block_ids ) - ) - ); + $block_ids = array_values( + array_filter(array_map("sanitize_text_field", $block_ids)), + ); - $mapping = get_post_meta( $post_id, '_wpaw_section_blocks', true ); - if ( ! is_array( $mapping ) ) { - $mapping = array(); - } + $mapping = get_post_meta($post_id, "_wpaw_section_blocks", true); + if (!is_array($mapping)) { + $mapping = []; + } - $mapping[ $section_id ] = $block_ids; - update_post_meta( $post_id, '_wpaw_section_blocks', $mapping ); + $mapping[$section_id] = $block_ids; + update_post_meta($post_id, "_wpaw_section_blocks", $mapping); - return new WP_REST_Response( - array( - 'success' => true, - 'sectionId' => $section_id, - 'blockCount' => count( $block_ids ), - ), - 200 - ); - } + return new WP_REST_Response( + [ + "success" => true, + "sectionId" => $section_id, + "blockCount" => count($block_ids), + ], + 200, + ); + } - /** - * Get section-to-block mapping for a post. - * - * @since 0.1.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_get_section_blocks( $request ) { - $post_id = intval( $request['post_id'] ?? 0 ); - if ( $post_id <= 0 ) { - return new WP_Error( - 'invalid_post', - __( 'Invalid post ID.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } + /** + * Get section-to-block mapping for a post. + * + * @since 0.1.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_get_section_blocks($request) + { + $post_id = intval($request["post_id"] ?? 0); + if ($post_id <= 0) { + return new WP_Error( + "invalid_post", + __("Invalid post ID.", "wp-agentic-writer"), + ["status" => 400], + ); + } - if ( ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } + if (!$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to access this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } - $mapping = get_post_meta( $post_id, '_wpaw_section_blocks', true ); - if ( ! is_array( $mapping ) ) { - $mapping = array(); - } + $mapping = get_post_meta($post_id, "_wpaw_section_blocks", true); + if (!is_array($mapping)) { + $mapping = []; + } - return new WP_REST_Response( - array( - 'sectionBlocks' => $mapping, - ), - 200 - ); - } + return new WP_REST_Response( + [ + "sectionBlocks" => $mapping, + ], + 200, + ); + } - /** - * Stream block refinement from chat to client. - * - * @since 0.1.0 - * @param array $blocks_to_refine Array of block objects to refine (from editor). - * @param string $message User's refinement message. - * @param string $selected_block Currently selected block client ID. - * @param int $post_id Post ID. - * @return void Streams response to client. - */ - private function stream_refinement_from_chat( $blocks_to_refine, $message, $selected_block, $post_id, $all_blocks, $diff_plan, $post_config = array(), $session_id = '', $selective_refine = false ) { - // Set headers for streaming. - header( 'Content-Type: text/event-stream' ); - header( 'Cache-Control: no-cache' ); - header( 'X-Accel-Buffering: no' ); // Disable Nginx buffering. + /** + * Stream block refinement from chat to client. + * + * @since 0.1.0 + * @param array $blocks_to_refine Array of block objects to refine (from editor). + * @param string $message User's refinement message. + * @param string $selected_block Currently selected block client ID. + * @param int $post_id Post ID. + * @return void Streams response to client. + */ + private function stream_refinement_from_chat( + $blocks_to_refine, + $message, + $selected_block, + $post_id, + $all_blocks, + $diff_plan, + $post_config = [], + $session_id = "", + $selective_refine = false, + $audit_context = [], + ) { + // Set headers for streaming. + header("Content-Type: text/event-stream"); + header("Cache-Control: no-cache"); + header("X-Accel-Buffering: no"); // Disable Nginx buffering. - // Flush output buffer to ensure immediate streaming. - if ( ob_get_level() > 0 ) { - ob_end_flush(); - } - flush(); + // Flush output buffer to ensure immediate streaming. + if (ob_get_level() > 0) { + ob_end_flush(); + } + flush(); - try { - if ( $post_id > 0 ) { - $this->update_post_memory( - $post_id, - array( - 'last_prompt' => $message, - 'last_intent' => 'refine', - ) - ); - } + try { + if ($post_id > 0) { + $this->update_post_memory($post_id, [ + "last_prompt" => $message, + "last_intent" => "refine", + ]); + } - $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' ); - $provider = $provider_result->provider; - $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 ); - $effective_language = $this->resolve_language_preference( $post_config, $stored_language ); - $language_instruction = $this->build_language_instruction( $effective_language, 'refined content' ); - $refined_count = 0; - $total_cost = 0.0; - $failed_count = 0; - $consecutive_errors = 0; - $max_consecutive_errors = 3; - $aborted_due_to_provider_errors = false; - $last_model_used = ''; - $total_blocks_to_refine = is_array( $blocks_to_refine ) ? count( $blocks_to_refine ) : 0; - $batch_size = 5; - $batch_total = $total_blocks_to_refine > 0 ? (int) ceil( $total_blocks_to_refine / $batch_size ) : 0; + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( + "writing", + ); + $provider = $provider_result->provider; + $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, + ); + $effective_language = $this->resolve_language_preference( + $post_config, + $stored_language, + ); + $language_instruction = $this->build_language_instruction( + $effective_language, + "refined content", + ); + $refined_count = 0; + $total_cost = 0.0; + $failed_count = 0; + $consecutive_errors = 0; + $max_consecutive_errors = 3; + $aborted_due_to_provider_errors = false; + $last_model_used = ""; + $total_blocks_to_refine = is_array($blocks_to_refine) + ? count($blocks_to_refine) + : 0; + $batch_size = 5; + $batch_total = + $total_blocks_to_refine > 0 + ? (int) ceil($total_blocks_to_refine / $batch_size) + : 0; - // Get post title for context - $post = get_post( $post_id ); - $post_title = $post ? $post->post_title : 'Unknown'; + // Get post title for context + $post = get_post($post_id); + $post_title = $post ? $post->post_title : "Unknown"; - // Normalize blocks for context - $context_blocks = array(); - $block_source = is_array( $all_blocks ) && ! empty( $all_blocks ) ? $all_blocks : $this->select_blocks(); - $allowed_block_ids = array_values( - array_filter( - array_map( - static function ( $block_obj ) { - return sanitize_text_field( $block_obj['clientId'] ?? '' ); - }, - is_array( $blocks_to_refine ) ? $blocks_to_refine : array() - ) - ) - ); - foreach ( $block_source as $block ) { - $client_id = $block['clientId'] ?? $block['attrs']['clientId'] ?? ''; - $block_type = $block['name'] ?? $block['blockName'] ?? 'core/paragraph'; - $block_attrs = $block['attributes'] ?? $block['attrs'] ?? array(); - $content = $this->extract_block_content_from_attrs( $block_type, $block_attrs ); + // Normalize blocks for context + $context_blocks = []; + $block_source = + is_array($all_blocks) && !empty($all_blocks) + ? $all_blocks + : $this->select_blocks(); + $allowed_block_ids = array_values( + array_filter( + array_map( + static function ($block_obj) { + return sanitize_text_field( + $block_obj["clientId"] ?? "", + ); + }, + is_array($blocks_to_refine) ? $blocks_to_refine : [], + ), + ), + ); + foreach ($block_source as $block) { + $client_id = + $block["clientId"] ?? ($block["attrs"]["clientId"] ?? ""); + $block_type = + $block["name"] ?? ($block["blockName"] ?? "core/paragraph"); + $block_attrs = $block["attributes"] ?? ($block["attrs"] ?? []); + $content = $this->extract_block_content_from_attrs( + $block_type, + $block_attrs, + ); - if ( empty( $client_id ) ) { - continue; - } + if (empty($client_id)) { + continue; + } - $context_blocks[] = array( - 'clientId' => $client_id, - 'type' => $block_type, - 'content' => $content, - ); - } + $context_blocks[] = [ + "clientId" => $client_id, + "type" => $block_type, + "content" => $content, + ]; + } - // Optional evaluator pass: classify blocks first, then refine only necessary ones. - if ( $selective_refine && count( $blocks_to_refine ) > 1 ) { - echo "data: " . wp_json_encode( - array( - 'type' => 'status', - 'message' => sprintf( 'Evaluating %d block(s) to select only necessary refinements...', count( $blocks_to_refine ) ), - ) - ) . "\n\n"; - flush(); + // Optional evaluator pass: classify blocks first, then refine only necessary ones. + if ($selective_refine && count($blocks_to_refine) > 1) { + echo "data: " . + wp_json_encode([ + "type" => "status", + "message" => sprintf( + "Evaluating %d block(s) to select only necessary refinements...", + count($blocks_to_refine), + ), + ]) . + "\n\n"; + flush(); - $eval_map = array(); - foreach ( $blocks_to_refine as $block_obj ) { - $cid = sanitize_text_field( $block_obj['clientId'] ?? '' ); - $bname = sanitize_text_field( $block_obj['name'] ?? 'core/paragraph' ); - $battrs = $block_obj['attributes'] ?? array(); - $txt = trim( wp_strip_all_tags( $this->extract_block_content_from_attrs( $bname, $battrs ) ) ); - if ( '' === $cid || '' === $txt ) { - continue; - } - if ( strlen( $txt ) > 220 ) { - $txt = substr( $txt, 0, 220 ) . '...'; - } - $eval_map[] = array( - 'blockId' => $cid, - 'type' => $bname, - 'text' => $txt, - ); - } + $eval_map = []; + foreach ($blocks_to_refine as $block_obj) { + $cid = sanitize_text_field($block_obj["clientId"] ?? ""); + $bname = sanitize_text_field( + $block_obj["name"] ?? "core/paragraph", + ); + $battrs = $block_obj["attributes"] ?? []; + $txt = trim( + wp_strip_all_tags( + $this->extract_block_content_from_attrs( + $bname, + $battrs, + ), + ), + ); + if ("" === $cid || "" === $txt) { + continue; + } + if (strlen($txt) > 220) { + $txt = substr($txt, 0, 220) . "..."; + } + $eval_map[] = [ + "blockId" => $cid, + "type" => $bname, + "text" => $txt, + ]; + } - if ( ! empty( $eval_map ) ) { - $eval_prompt = "You are a strict editor classifier.\n" - . "Task: decide which blocks NEED refinement for this user request.\n" - . "Return ONLY JSON: {\"keep\":[\"id\"],\"needs_refine\":[\"id\"],\"reasons\":{\"id\":\"short reason\"}}\n" - . "Rules: If block already satisfies request, keep it. Do not rewrite. Only classify.\n" - . "User request: {$message}\nBlocks:\n"; - foreach ( $eval_map as $row ) { - $eval_prompt .= "- {$row['blockId']} | {$row['type']} | {$row['text']}\n"; - } + if (!empty($eval_map)) { + $eval_prompt = + "You are a strict editor classifier.\n" . + "Task: decide which blocks NEED refinement for this user request.\n" . + "Return ONLY JSON: {\"keep\":[\"id\"],\"needs_refine\":[\"id\"],\"reasons\":{\"id\":\"short reason\"}}\n" . + "Rules: If block already satisfies request, keep it. Do not rewrite. Only classify.\n" . + "User request: {$message}\nBlocks:\n"; + foreach ($eval_map as $row) { + $eval_prompt .= "- {$row["blockId"]} | {$row["type"]} | {$row["text"]}\n"; + } - $eval_response = $provider->chat( - array( - array( 'role' => 'system', 'content' => $eval_prompt ), - array( 'role' => 'user', 'content' => 'Classify now.' ), - ), - array( 'temperature' => 0.1 ), - 'planning' - ); + $eval_response = $provider->chat( + [ + ["role" => "system", "content" => $eval_prompt], + ["role" => "user", "content" => "Classify now."], + ], + ["temperature" => 0.1], + "planning", + ); - if ( ! is_wp_error( $eval_response ) ) { - $eval_raw = trim( (string) ( $eval_response['content'] ?? '' ) ); - if ( preg_match( '/```(?:json)?\s*\n?(.*?)\n?```/s', $eval_raw, $m ) ) { - $eval_raw = trim( $m[1] ); - } - $eval_json = json_decode( $eval_raw, true ); - if ( is_array( $eval_json ) && isset( $eval_json['needs_refine'] ) && is_array( $eval_json['needs_refine'] ) ) { - $needs_lookup = array_fill_keys( - array_map( 'sanitize_text_field', $eval_json['needs_refine'] ), - true - ); - $filtered = array_values( - array_filter( - $blocks_to_refine, - static function ( $block_obj ) use ( $needs_lookup ) { - $cid = sanitize_text_field( $block_obj['clientId'] ?? '' ); - return isset( $needs_lookup[ $cid ] ); - } - ) - ); - if ( ! empty( $filtered ) ) { - $before_count = count( $blocks_to_refine ); - $blocks_to_refine = $filtered; - echo "data: " . wp_json_encode( - array( - 'type' => 'status', - 'message' => sprintf( 'Selective refinement: %1$d/%2$d block(s) need updates.', count( $blocks_to_refine ), $before_count ), - ) - ) . "\n\n"; - flush(); - } - } - } - } - } + if (!is_wp_error($eval_response)) { + $eval_raw = trim( + (string) ($eval_response["content"] ?? ""), + ); + if ( + preg_match( + '/```(?:json)?\s*\n?(.*?)\n?```/s', + $eval_raw, + $m, + ) + ) { + $eval_raw = trim($m[1]); + } + $eval_json = json_decode($eval_raw, true); + if ( + is_array($eval_json) && + isset($eval_json["needs_refine"]) && + is_array($eval_json["needs_refine"]) + ) { + $needs_lookup = array_fill_keys( + array_map( + "sanitize_text_field", + $eval_json["needs_refine"], + ), + true, + ); + $filtered = array_values( + array_filter( + $blocks_to_refine, + static function ($block_obj) use ( + $needs_lookup, + ) { + $cid = sanitize_text_field( + $block_obj["clientId"] ?? "", + ); + return isset($needs_lookup[$cid]); + }, + ), + ); + if (!empty($filtered)) { + $before_count = count($blocks_to_refine); + $blocks_to_refine = $filtered; + echo "data: " . + wp_json_encode([ + "type" => "status", + "message" => sprintf( + 'Selective refinement: %1$d/%2$d block(s) need updates.', + count($blocks_to_refine), + $before_count, + ), + ]) . + "\n\n"; + flush(); + } + } + } + } + } - if ( $diff_plan && ! empty( $context_blocks ) ) { - $plan_generation_failed = false; - $plan_prompt = "You are an editor planning precise block-level edits. + if ($diff_plan && !empty($context_blocks)) { + $plan_generation_failed = false; + $plan_prompt = + "You are an editor planning precise block-level edits. Return ONLY valid JSON in this format: { @@ -6242,152 +7456,219 @@ Rules: User request: {$message} -Allowed target block IDs (STRICT): " . implode( ', ', $allowed_block_ids ) . " +Allowed target block IDs (STRICT): " . + implode(", ", $allowed_block_ids) . + " Blocks: "; - foreach ( $context_blocks as $index => $block ) { - $plan_prompt .= ($index + 1) . ". {$block['clientId']} | {$block['type']} | " . $block['content'] . "\n"; - } + foreach ($context_blocks as $index => $block) { + $plan_prompt .= + $index + + 1 . + ". {$block["clientId"]} | {$block["type"]} | " . + $block["content"] . + "\n"; + } - $plan_response = $provider->chat( - array( - array( 'role' => 'system', 'content' => $plan_prompt ), - array( 'role' => 'user', 'content' => 'Create the edit plan now.' ), - ), - array( 'temperature' => 0.2 ), - 'planning' - ); + $plan_response = $provider->chat( + [ + ["role" => "system", "content" => $plan_prompt], + [ + "role" => "user", + "content" => "Create the edit plan now.", + ], + ], + ["temperature" => 0.2], + "planning", + ); - if ( ! is_wp_error( $plan_response ) ) { - // Track cost for edit plan generation - $plan_cost = $plan_response['cost'] ?? 0; - if ( $plan_cost > 0 ) { - $this->track_ai_cost( - $post_id, - $plan_response['model'] ?? '', - 'refinement_planning', - $plan_response['input_tokens'] ?? 0, - $plan_response['output_tokens'] ?? 0, - $plan_cost, - $provider_result, - $session_id ?? '', - 'success' - ); - } + if (!is_wp_error($plan_response)) { + // Track cost for edit plan generation + $plan_cost = $plan_response["cost"] ?? 0; + if ($plan_cost > 0) { + $this->track_ai_cost( + $post_id, + $plan_response["model"] ?? "", + "refinement_planning", + $plan_response["input_tokens"] ?? 0, + $plan_response["output_tokens"] ?? 0, + $plan_cost, + $provider_result, + $session_id ?? "", + "success", + ); + } - $raw_content = trim( $plan_response['content'] ); - error_log( 'WP Agentic Writer: Edit plan raw response: ' . substr( $raw_content, 0, 500 ) ); - - // Strip markdown code blocks if present (```json ... ```) - $json_content = $raw_content; - if ( preg_match( '/```(?:json)?\s*\n?(.*?)\n?```/s', $raw_content, $matches ) ) { - $json_content = trim( $matches[1] ); - error_log( 'WP Agentic Writer: Extracted JSON from markdown code block' ); - } - - $plan_json = json_decode( $json_content, true ); - if ( is_array( $plan_json ) && isset( $plan_json['actions'] ) ) { - $plan_json = $this->sanitize_refinement_edit_plan( $plan_json, $allowed_block_ids, $context_blocks ); - if ( empty( $plan_json['actions'] ) ) { - $plan_generation_failed = true; - } else { - echo "data: " . wp_json_encode( - array( - 'type' => 'edit_plan', - 'plan' => $plan_json, - ) - ) . "\n\n"; - flush(); - exit; - } - } else { - error_log( 'WP Agentic Writer: Edit plan JSON decode failed or missing actions. JSON error: ' . json_last_error_msg() ); - error_log( 'WP Agentic Writer: Attempted to parse: ' . substr( $json_content, 0, 200 ) ); - $plan_generation_failed = true; - } - } else { - error_log( 'WP Agentic Writer: Edit plan API error: ' . $plan_response->get_error_message() ); - $plan_generation_failed = true; - } + $raw_content = trim($plan_response["content"]); + error_log( + "WP Agentic Writer: Edit plan raw response: " . + substr($raw_content, 0, 500), + ); - // Fallback path: when edit-plan fails (common on broad @all requests), - // continue with direct per-block refinement instead of hard failing. - if ( $plan_generation_failed ) { - echo "data: " . wp_json_encode( - array( - 'type' => 'status', - 'message' => 'Edit plan failed, switching to direct block refinement.', - ) - ) . "\n\n"; - flush(); - } - } + // Strip markdown code blocks if present (```json ... ```) + $json_content = $raw_content; + if ( + preg_match( + '/```(?:json)?\s*\n?(.*?)\n?```/s', + $raw_content, + $matches, + ) + ) { + $json_content = trim($matches[1]); + error_log( + "WP Agentic Writer: Extracted JSON from markdown code block", + ); + } - foreach ( $blocks_to_refine as $block_index_loop => $block_obj ) { - if ( 0 === ( $block_index_loop % $batch_size ) ) { - $current_batch = (int) floor( $block_index_loop / $batch_size ) + 1; - $batch_start = $block_index_loop + 1; - $batch_end = min( $total_blocks_to_refine, $block_index_loop + $batch_size ); - echo "data: " . wp_json_encode( - array( - 'type' => 'status', - 'message' => sprintf( - 'Processing batch %1$d/%2$d (blocks %3$d-%4$d of %5$d)', - $current_batch, - $batch_total, - $batch_start, - $batch_end, - $total_blocks_to_refine - ), - ) - ) . "\n\n"; - flush(); - } - // Extract block data from the block object sent from frontend - $block_client_id = $block_obj['clientId'] ?? ''; - $block_type = $block_obj['name'] ?? 'core/paragraph'; - $block_attrs = $block_obj['attributes'] ?? array(); - $block_content = $this->extract_block_content_from_attrs( $block_type, $block_attrs ); + $plan_json = json_decode($json_content, true); + if (is_array($plan_json) && isset($plan_json["actions"])) { + $plan_json = $this->sanitize_refinement_edit_plan( + $plan_json, + $allowed_block_ids, + $context_blocks, + ); + if (empty($plan_json["actions"])) { + $plan_generation_failed = true; + } else { + echo "data: " . + wp_json_encode([ + "type" => "edit_plan", + "plan" => $plan_json, + ]) . + "\n\n"; + flush(); + exit(); + } + } else { + error_log( + "WP Agentic Writer: Edit plan JSON decode failed or missing actions. JSON error: " . + json_last_error_msg(), + ); + error_log( + "WP Agentic Writer: Attempted to parse: " . + substr($json_content, 0, 200), + ); + $plan_generation_failed = true; + } + } else { + error_log( + "WP Agentic Writer: Edit plan API error: " . + $plan_response->get_error_message(), + ); + $plan_generation_failed = true; + } - // Find block index in all blocks for context - $block_index = -1; - foreach ( $all_blocks as $i => $block ) { - if ( isset( $block['clientId'] ) && $block['clientId'] === $block_client_id ) { - $block_index = $i; - break; - } - } + // Fallback path: when edit-plan fails (common on broad @all requests), + // continue with direct per-block refinement instead of hard failing. + if ($plan_generation_failed) { + echo "data: " . + wp_json_encode([ + "type" => "status", + "message" => + "Edit plan failed, switching to direct block refinement.", + ]) . + "\n\n"; + flush(); + } + } - // Build article context - $article_context = array( - 'title' => $post_title, - 'previousBlock' => $block_index > 0 ? $this->extract_heading_from_block( $all_blocks[ $block_index - 1 ] ) : null, - 'nextBlock' => $block_index >= 0 && $block_index < count( $all_blocks ) - 1 ? $this->extract_heading_from_block( $all_blocks[ $block_index + 1 ] ) : null, - ); + foreach ($blocks_to_refine as $block_index_loop => $block_obj) { + if (0 === $block_index_loop % $batch_size) { + $current_batch = + (int) floor($block_index_loop / $batch_size) + 1; + $batch_start = $block_index_loop + 1; + $batch_end = min( + $total_blocks_to_refine, + $block_index_loop + $batch_size, + ); + echo "data: " . + wp_json_encode([ + "type" => "status", + "message" => sprintf( + 'Processing batch %1$d/%2$d (blocks %3$d-%4$d of %5$d)', + $current_batch, + $batch_total, + $batch_start, + $batch_end, + $total_blocks_to_refine, + ), + ]) . + "\n\n"; + flush(); + } + // Extract block data from the block object sent from frontend + $block_client_id = $block_obj["clientId"] ?? ""; + $block_type = $block_obj["name"] ?? "core/paragraph"; + $block_attrs = $block_obj["attributes"] ?? []; + $block_content = $this->extract_block_content_from_attrs( + $block_type, + $block_attrs, + ); - // Build refinement prompt - $memory_context = $this->get_post_memory_context( $post_id ); - $context_str = "\n\nArticle Context:\n"; - $context_str .= "Title: " . $post_title . "\n"; + // Find block index in all blocks for context + $block_index = -1; + foreach ($all_blocks as $i => $block) { + if ( + isset($block["clientId"]) && + $block["clientId"] === $block_client_id + ) { + $block_index = $i; + break; + } + } - if ( ! empty( $article_context['previousBlock'] ) ) { - $context_str .= "Previous section: " . $article_context['previousBlock']['heading'] . "\n"; - } + // Build article context + $article_context = [ + "title" => $post_title, + "previousBlock" => + $block_index > 0 + ? $this->extract_heading_from_block( + $all_blocks[$block_index - 1], + ) + : null, + "nextBlock" => + $block_index >= 0 && + $block_index < count($all_blocks) - 1 + ? $this->extract_heading_from_block( + $all_blocks[$block_index + 1], + ) + : null, + ]; - $context_str .= "Current block type: " . $block_type . "\n"; - $context_str .= "Current content:\n" . $block_content . "\n"; - $section_context = $this->build_section_context_for_block( $all_blocks, $block_index, 4 ); - if ( ! empty( $section_context ) ) { - $context_str .= "Section context:\n" . $section_context . "\n"; - } + // Build refinement prompt + $memory_context = $this->get_post_memory_context($post_id); + $context_str = "\n\nArticle Context:\n"; + $context_str .= "Title: " . $post_title . "\n"; - if ( ! empty( $article_context['nextBlock'] ) ) { - $context_str .= "Next section: " . $article_context['nextBlock']['heading'] . "\n"; - } + if (!empty($article_context["previousBlock"])) { + $context_str .= + "Previous section: " . + $article_context["previousBlock"]["heading"] . + "\n"; + } - $system_prompt = "You are a precise content editor. Your task is to refine the provided content based strictly on the user's request. + $context_str .= "Current block type: " . $block_type . "\n"; + $context_str .= "Current content:\n" . $block_content . "\n"; + $section_context = $this->build_section_context_for_block( + $all_blocks, + $block_index, + 4, + ); + if (!empty($section_context)) { + $context_str .= + "Section context:\n" . $section_context . "\n"; + } + + if (!empty($article_context["nextBlock"])) { + $context_str .= + "Next section: " . + $article_context["nextBlock"]["heading"] . + "\n"; + } + + $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. @@ -6416,1595 +7697,1983 @@ Output format: - If list: content should contain one item per line - No markdown wrappers, no chain-of-thought, no \"Refined version\", no \"Key refinements\", no explanations"; - $messages = array( - array( - 'role' => 'system', - 'content' => $system_prompt, - ), - array( - 'role' => 'user', - 'content' => "Refine this content.", - ), - ); - - // Use streaming for real-time feedback - $refined_content = ''; - $stream_result = $provider->chat_stream( - $messages, - array( 'temperature' => 0.2 ), - 'execution', - function( $chunk ) use ( &$refined_content ) { - // Accumulate the streaming content - $refined_content .= $chunk; - } - ); - - if ( is_wp_error( $stream_result ) ) { - $failed_count++; - $consecutive_errors++; - echo "data: " . wp_json_encode( - array( - 'type' => 'error', - 'message' => $stream_result->get_error_message(), - ) - ) . "\n\n"; - flush(); - - if ( $consecutive_errors >= $max_consecutive_errors ) { - $aborted_due_to_provider_errors = true; - echo "data: " . wp_json_encode( - array( - 'type' => 'status', - 'message' => sprintf( - 'Stopped early after %d consecutive provider failures at block %d. Please retry with fewer blocks or check local backend health.', - (int) $consecutive_errors, - (int) $block_index_loop + 1 - ), - ) - ) . "\n\n"; - flush(); - break; - } - - continue; - } - $consecutive_errors = 0; - - // Track cost from streaming result (always track for debugging). - $stream_cost = $stream_result['cost'] ?? 0; - $last_model_used = $stream_result['model'] ?? $last_model_used; - $total_cost += $stream_cost; - $this->track_ai_cost( - $post_id, - $stream_result['model'] ?? '', - 'block_refinement', - $stream_result['input_tokens'] ?? 0, - $stream_result['output_tokens'] ?? 0, - $stream_cost, - $provider_result, - $session_id ?? '', - 'success' - ); - - // Parse and clean the response - $payload = $this->parse_refined_payload( $refined_content ); - $refined_content = $this->clean_refined_content( $payload['content'] ); - $resolved_block_type = $payload['blockType'] ?? $block_type; - if ( $this->is_contaminated_refinement_output( $refined_content, $resolved_block_type ) ) { - $failed_count++; - echo "data: " . wp_json_encode( - array( - 'type' => 'status', - 'message' => sprintf( - 'Skipped contaminated output for block %1$d/%2$d (%3$s).', - (int) $block_index_loop + 1, - (int) $total_blocks_to_refine, - $resolved_block_type - ), - ) - ) . "\n\n"; - flush(); - continue; - } - - // Create proper block structure - $block_structure = $this->create_block_structure( $block_client_id, $resolved_block_type, $refined_content ); - - // Send the refined block - echo "data: " . wp_json_encode( - array( - 'type' => 'block', - 'block' => $block_structure, - ) - ) . "\n\n"; - flush(); - - $refined_count++; - if ( 0 === ( $refined_count % 5 ) || $refined_count === $total_blocks_to_refine ) { - echo "data: " . wp_json_encode( - array( - 'type' => 'status', - 'message' => sprintf( - 'Progress: %1$d/%2$d block(s) updated (%3$d failed)', - $refined_count, - $total_blocks_to_refine, - $failed_count - ), - ) - ) . "\n\n"; - flush(); - } - - // Small delay between blocks - usleep( 100000 ); - } - - // Persist refinement exchange in session history so msg counts remain accurate. - if ( ! empty( $session_id ) && ! empty( $message ) ) { - $context_service = WP_Agentic_Writer_Context_Service::get_instance(); - $context_service->add_message( - $session_id, - array( - 'role' => 'user', - 'content' => sanitize_text_field( $message ), - 'timestamp' => current_time( 'c' ), - ) - ); - $context_service->add_message( - $session_id, - array( - 'role' => 'assistant', - 'content' => sprintf( 'Refined %d block(s) based on your request.', (int) $refined_count ), - 'timestamp' => current_time( 'c' ), - ) - ); - } - - // Send completion message with provider metadata. - echo "data: " . wp_json_encode( - array( - 'type' => 'complete', - 'refined' => $refined_count, - 'failed' => $failed_count, - 'aborted' => $aborted_due_to_provider_errors, - 'totalCost' => $total_cost, - 'provider_metadata' => $this->build_provider_metadata( - $provider_result, - $last_model_used - ), - ) - ) . "\n\n"; - flush(); - } catch ( Exception $e ) { - echo "data: " . wp_json_encode( - array( - 'type' => 'error', - 'message' => $e->getMessage(), - ) - ) . "\n\n"; - flush(); - } - } - - /** - * Restrict model edit-plan actions to explicitly allowed target block IDs. - * - * @since 0.1.0 - * - * @param array $plan_json Parsed model plan response. - * @param array $allowed_block_ids Block IDs allowed to be edited. - * @param array $context_blocks Normalized block context list. - * @return array Sanitized plan response. - */ - private function sanitize_refinement_edit_plan( $plan_json, $allowed_block_ids, $context_blocks ) { - $allowed_actions = array( 'keep', 'replace', 'insert_after', 'insert_before', 'delete', 'change_type' ); - $allowed_lookup = array_fill_keys( $allowed_block_ids, true ); - $context_by_id = array(); - - foreach ( $context_blocks as $block ) { - $context_id = sanitize_text_field( $block['clientId'] ?? '' ); - if ( '' === $context_id ) { - continue; - } - $context_by_id[ $context_id ] = sanitize_text_field( $block['type'] ?? 'core/paragraph' ); - } - - $raw_actions = $plan_json['actions'] ?? array(); - if ( ! is_array( $raw_actions ) ) { - $raw_actions = array(); - } - - $sanitized_actions = array(); - foreach ( $raw_actions as $action ) { - if ( ! is_array( $action ) ) { - continue; - } - - $action_name = sanitize_key( $action['action'] ?? '' ); - $block_id = sanitize_text_field( $action['blockId'] ?? '' ); - if ( '' === $action_name || ! in_array( $action_name, $allowed_actions, true ) ) { - continue; - } - - if ( '' === $block_id || ! isset( $allowed_lookup[ $block_id ] ) ) { - continue; - } - - $clean_action = array( - 'action' => $action_name, - 'blockId' => $block_id, - ); - - if ( in_array( $action_name, array( 'replace', 'insert_after', 'insert_before', 'change_type' ), true ) ) { - $fallback_type = $context_by_id[ $block_id ] ?? 'core/paragraph'; - $clean_action['blockType'] = sanitize_text_field( $action['blockType'] ?? $fallback_type ); - $clean_action['content'] = isset( $action['content'] ) ? wp_kses_post( (string) $action['content'] ) : ''; - } - - $sanitized_actions[] = $clean_action; - } - - return array( - 'summary' => sanitize_text_field( $plan_json['summary'] ?? '' ), - 'actions' => $sanitized_actions, - ); - } - - /** - * Find block by client ID in parsed blocks array. - * - * @since 0.1.0 - * @param array $blocks Parsed blocks array. - * @param string $client_id Block client ID to find. - * @return array|null Block data or null if not found. - */ - private function find_block_by_client_id( $blocks, $client_id ) { - foreach ( $blocks as $block ) { - if ( isset( $block['attrs']['clientId'] ) && $block['attrs']['clientId'] === $client_id ) { - return $block; - } - // Check inner blocks - if ( isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { - $found = $this->find_block_by_client_id( $block['innerBlocks'], $client_id ); - if ( $found ) { - return $found; - } - } - } - return null; - } - - /** - * Find block index in parsed blocks array. - * - * @since 0.1.0 - * @param array $blocks Parsed blocks array. - * @param string $client_id Block client ID to find. - * @return int Block index or -1 if not found. - */ - private function find_block_index( $blocks, $client_id ) { - foreach ( $blocks as $index => $block ) { - if ( isset( $block['attrs']['clientId'] ) && $block['attrs']['clientId'] === $client_id ) { - return $index; - } - } - return -1; - } - - /** - * Extract content from block data. - * - * @since 0.1.0 - * @param array $block Block data. - * @return string Block content. - */ - private function extract_block_content( $block ) { - if ( isset( $block['attrs']['content'] ) ) { - return $block['attrs']['content']; - } - - if ( isset( $block['innerHTML'] ) ) { - // Strip HTML tags for plain content - return wp_strip_all_tags( $block['innerHTML'] ); - } - - return ''; - } - - /** - * Extract heading from block. - * - * @since 0.1.0 - * @param array $block Block data. - * @return array|null Heading data or null if not a heading. - */ - private function extract_heading_from_block( $block ) { - if ( 'core/heading' === $block['blockName'] && isset( $block['attrs']['content'] ) ) { - return array( - 'heading' => $block['attrs']['content'], - ); - } - return null; - } - - /** - * Clean refined content by removing conversational text. - * - * @since 0.1.0 - * @param string $content Content to clean. - * @return string Cleaned content. - */ - private function clean_refined_content( $content ) { - // Remove common conversational prefixes - $conversational_prefixes = array( - 'Certainly! Here\'s', - 'Here\'s', - 'The refined content', - 'Here is the', - 'Below is the', - 'Okay, here', - 'Sure, here', - ); - - foreach ( $conversational_prefixes as $prefix ) { - if ( stripos( $content, $prefix ) === 0 ) { - $content = substr( $content, strlen( $prefix ) ); - $content = ltrim( $content, ":\n\r " ); - } - } - - // Remove markdown code blocks if present - $content = preg_replace( '/^```(?:text|markdown)?\n*/i', '', $content ); - $content = preg_replace( '/```*$/i', '', $content ); - - // Remove common analysis scaffolding that sometimes leaks into block output. - $content = preg_replace( '/^\s*Refined version\s*:\s*/im', '', $content ); - $content = preg_replace( '/^\s*Key refinements\s*:\s*$/im', '', $content ); - $content = preg_replace( '/^\s*(The refinement .*|Changes made .*|Explanation .*|Rationale .*)$/im', '', $content ); - - // If model includes a bullet list of substitutions/explanations, strip that section. - $meta_markers = array( - 'Key refinements:', - 'Changes made:', - 'Explanation:', - 'Rationale:', - ); - foreach ( $meta_markers as $marker ) { - $pos = stripos( $content, $marker ); - if ( false !== $pos ) { - $content = substr( $content, 0, $pos ); - } - } - - // Avoid persisting raw JSON fences or labels. - $content = preg_replace( '/^\s*content\s*:\s*/im', '', $content ); - $content = trim( $content ); - - return $content; - } - - /** - * Parse refined payload that may be wrapped in JSON. - * - * @since 0.1.0 - * @param string $content Raw model content. - * @return array Parsed payload with content and optional blockType. - */ - private function parse_refined_payload( $content ) { - $payload = array( - 'content' => $content, - ); - - if ( ! is_string( $content ) ) { - return $payload; - } - - $trimmed = trim( $content ); - if ( '' === $trimmed ) { - return $payload; - } - - if ( $trimmed[0] !== '{' || substr( $trimmed, -1 ) !== '}' ) { - return $payload; - } - - $decoded = json_decode( $trimmed, true ); - if ( ! is_array( $decoded ) ) { - return $payload; - } - - if ( isset( $decoded['content'] ) && is_string( $decoded['content'] ) ) { - $payload['content'] = $decoded['content']; - } - - $block_type = $decoded['blockType'] ?? $decoded['type'] ?? null; - if ( is_string( $block_type ) && 0 === strpos( $block_type, 'core/' ) ) { - $payload['blockType'] = $block_type; - } - - return $payload; - } - - /** - * Detect assistant/meta chatter that should never be inserted as block content. - * - * @since 0.1.0 - * @param string $content Refined content candidate. - * @param string $block_type Resolved block type. - * @return bool - */ - private function is_contaminated_refinement_output( $content, $block_type = 'core/paragraph' ) { - $text = trim( wp_strip_all_tags( (string) $content ) ); - if ( '' === $text ) { - return true; - } - - $meta_patterns = array( - '/\b(i apologize|could you please|would you like me|please share|no specific content was provided)\b/i', - '/\b(refined version|key refinements|changes made|rationale|note:\s*since)\b/i', - '/\b(i have kept the heading|if you\'d like me to refine this)\b/i', - ); - foreach ( $meta_patterns as $pattern ) { - if ( preg_match( $pattern, $text ) ) { - return true; - } - } - - // Headings should be concise and single-line. - if ( 'core/heading' === $block_type ) { - if ( strlen( $text ) > 180 || substr_count( $text, "\n" ) > 0 ) { - return true; - } - } - - return false; - } - - /** - * Build a compact section-scoped context window around a block. - * - * @since 0.1.0 - * @param array $all_blocks All serialized editor blocks. - * @param int $block_index Current block index. - * @param int $max_snippets Max context snippets. - * @return string - */ - private function build_section_context_for_block( $all_blocks, $block_index, $max_snippets = 4 ) { - if ( ! is_array( $all_blocks ) || $block_index < 0 || ! isset( $all_blocks[ $block_index ] ) ) { - return ''; - } - - $start = $block_index; - for ( $i = $block_index - 1; $i >= 0; $i-- ) { - $name = $all_blocks[ $i ]['name'] ?? $all_blocks[ $i ]['blockName'] ?? ''; - if ( 'core/heading' === $name ) { - $start = $i; - break; - } - $start = $i; - } - - $end = $block_index; - for ( $i = $block_index + 1; $i < count( $all_blocks ); $i++ ) { - $name = $all_blocks[ $i ]['name'] ?? $all_blocks[ $i ]['blockName'] ?? ''; - if ( 'core/heading' === $name ) { - break; - } - $end = $i; - } - - $snippets = array(); - for ( $i = $start; $i <= $end; $i++ ) { - $block = $all_blocks[ $i ]; - $name = $block['name'] ?? $block['blockName'] ?? ''; - if ( ! in_array( $name, array( 'core/heading', 'core/paragraph', 'core/list', 'core/quote' ), true ) ) { - continue; - } - $attrs = $block['attributes'] ?? $block['attrs'] ?? array(); - $text = trim( wp_strip_all_tags( $this->extract_block_content_from_attrs( $name, $attrs ) ) ); - if ( '' === $text ) { - continue; - } - if ( strlen( $text ) > 180 ) { - $text = substr( $text, 0, 180 ) . '...'; - } - $snippets[] = '- ' . $text; - if ( count( $snippets ) >= $max_snippets ) { - break; - } - } - - return implode( "\n", $snippets ); - } - - /** - * Create block structure for refined content. - * - * @since 0.1.0 - * @param string $block_id Block client ID. - * @param string $block_type Block type. - * @param string $content Refined content. - * @return array Block structure. - */ - private function create_block_structure( $block_id, $block_type, $content ) { - if ( preg_match( '/^!\\[(.*?)\\]\\(([^\\s)]+)(?:\\s+\\"[^\\"]*\\")?\\)\\s*$/', trim( $content ), $matches ) ) { - $alt = trim( $matches[1] ); - $url = trim( $matches[2] ); - $escaped_alt = esc_attr( $alt ); - $escaped_url = esc_url( $url ); - - return array( - 'blockName' => 'core/image', - 'attrs' => array( - 'id' => 0, - 'url' => $escaped_url, - 'alt' => $alt, - 'caption' => '', - 'sizeSlug' => 'large', - 'linkDestination' => 'none', - ), - 'innerHTML' => '
    ' . $escaped_alt . '
    ', - 'clientId' => $block_id, - ); - } - - if ( 'core/paragraph' === $block_type ) { - return array( - 'blockName' => 'core/paragraph', - 'attrs' => array( 'content' => $content ), - 'innerHTML' => '

    ' . $content . '

    ', - 'clientId' => $block_id, - ); - } elseif ( 'core/heading' === $block_type ) { - // Detect heading level - $level = 2; - if ( preg_match( '/^(#{1,6})\s/', $content ) ) { - $count = strspn( $content, '#' ); - $level = min( $count, 6 ); - $content = trim( substr( $content, $count ) ); - } - $tag = 'h' . $level; - return array( - 'blockName' => 'core/heading', - 'attrs' => array( - 'level' => $level, - 'content' => $content, - ), - 'innerHTML' => "<{$tag}>{$content}", - 'clientId' => $block_id, - ); - } elseif ( 'core/list' === $block_type ) { - $lines = explode( "\n", $content ); - $lines = array_filter( array_map( 'trim', $lines ) ); - - // Create inner blocks for list items - $inner_blocks = array(); - foreach ( $lines as $line ) { - $inner_blocks[] = array( - 'blockName' => 'core/list-item', - 'attrs' => array( 'content' => $line ), - 'innerHTML' => '
  • ' . $line . '
  • ', - ); - } - - return array( - 'blockName' => 'core/list', - 'attrs' => array( 'ordered' => false ), - 'innerBlocks' => $inner_blocks, - 'clientId' => $block_id, - ); - } elseif ( 'core/code' === $block_type ) { - $language = 'text'; - $code_content = $content; - if ( preg_match( '/^```(\\w+)?\\s*/', $content, $matches ) ) { - if ( ! empty( $matches[1] ) ) { - $language = $matches[1]; - } - $code_content = preg_replace( '/^```\\w*\\s*/', '', $code_content ); - $code_content = preg_replace( '/```\\s*$/', '', $code_content ); - $code_content = trim( $code_content ); - } - - $escaped = htmlspecialchars( $code_content, ENT_NOQUOTES, 'UTF-8' ); - return array( - 'blockName' => 'core/code', - 'attrs' => array( - 'language' => $language, - 'content' => $code_content, - ), - 'innerHTML' => '
    ' . $escaped . '
    ', - 'clientId' => $block_id, - ); - } - - // Fallback to paragraph - return array( - 'blockName' => 'core/paragraph', - 'attrs' => array( 'content' => $content ), - 'innerHTML' => '

    ' . $content . '

    ', - 'clientId' => $block_id, - ); - } - - /** - * Build a short memory summary from the plan JSON. - * - * @since 0.1.0 - * @param array $plan_json Plan data. - * @return string Summary text. - */ - private function build_memory_summary_from_plan( $plan_json ) { - if ( empty( $plan_json ) || ! is_array( $plan_json ) ) { - return ''; - } - - $title = $plan_json['title'] ?? ''; - $headings = array(); - if ( ! empty( $plan_json['sections'] ) && is_array( $plan_json['sections'] ) ) { - foreach ( $plan_json['sections'] as $section ) { - if ( ! empty( $section['heading'] ) ) { - $headings[] = $section['heading']; - } - } - } - - $summary = ''; - if ( $title ) { - $summary .= "Title: {$title}\n"; - } - if ( ! empty( $headings ) ) { - $summary .= 'Sections: ' . implode( ' | ', $headings ); - } - - return trim( $summary ); - } - - /** - * Update per-post memory meta. - * - * @since 0.1.0 - * @param int $post_id Post ID. - * @param array $data Memory fields to update. - * @return void - */ - private function update_post_memory( $post_id, $data ) { - if ( $post_id <= 0 ) { - return; - } - - $memory = get_post_meta( $post_id, '_wpaw_memory', true ); - if ( ! is_array( $memory ) ) { - $memory = array(); - } - - $memory = array_merge( $memory, $data ); - $memory['updated_at'] = current_time( 'timestamp' ); - - update_post_meta( $post_id, '_wpaw_memory', $memory ); - } - - /** - * Build memory context string for prompts. - * - * @since 0.1.0 - * @param int $post_id Post ID. - * @return string Context string. - */ - private function get_post_memory_context( $post_id ) { - if ( $post_id <= 0 ) { - return ''; - } - - $memory = get_post_meta( $post_id, '_wpaw_memory', true ); - if ( empty( $memory ) || ! is_array( $memory ) ) { - return ''; - } - - $lines = array(); - if ( ! empty( $memory['summary'] ) ) { - $lines[] = 'Summary: ' . $memory['summary']; - } - if ( ! empty( $memory['last_prompt'] ) ) { - $lines[] = 'Last prompt: ' . $memory['last_prompt']; - } - if ( ! empty( $memory['last_intent'] ) ) { - $lines[] = 'Last intent: ' . $memory['last_intent']; - } - - if ( empty( $lines ) ) { - return ''; - } - - return "\n\n=== POST MEMORY ===\n" . implode( "\n", $lines ) . "\n=== END POST MEMORY ===\n"; - } - - /** - * Get blocks from the current editor state. - * - * @since 0.1.0 - * @return array Array of block objects from editor. - */ - private function select_blocks() { - // Get blocks from the editor via REST API request - // This is a helper to simulate wp.data.select( 'core/block-editor' ).getBlocks() - global $post; - - if ( ! $post ) { - return array(); - } - - // Parse blocks from post content - $blocks = parse_blocks( $post->post_content ); - - // Filter out empty blocks - return array_filter( $blocks, function( $block ) { - return ! empty( $block['blockName'] ); - } ); - } - - /** - * Serialize block object for consistent handling. - * - * @since 0.1.0 - * @param array $block Block data. - * @return array Serialized block with clientId. - */ - private function serialize_block( $block ) { - // Ensure clientId is set in attrs - if ( ! isset( $block['attrs']['clientId'] ) ) { - $block['attrs']['clientId'] = isset( $block['clientId'] ) ? $block['clientId'] : uniqid(); - } - - return $block; - } - - /** - * Extract content from block attributes. - * - * @since 0.1.0 - * @param string $block_type Block type (e.g., 'core/paragraph'). - * @param array $attrs Block attributes. - * @return string Extracted content. - */ - private function extract_block_content_from_attrs( $block_type, $attrs ) { - switch ( $block_type ) { - case 'core/paragraph': - return isset( $attrs['content'] ) ? $attrs['content'] : ''; - - case 'core/heading': - return isset( $attrs['content'] ) ? $attrs['content'] : ''; - - case 'core/list': - // For lists, return a string representation - if ( isset( $attrs['values'] ) && is_array( $attrs['values'] ) ) { - return implode( "\n", $attrs['values'] ); - } - return ''; - - case 'core/code': - return isset( $attrs['content'] ) ? $attrs['content'] : ''; - - case 'core/image': - if ( isset( $attrs['url'] ) && isset( $attrs['alt'] ) ) { - return '![' . $attrs['alt'] . '](' . $attrs['url'] . ')'; - } - return ''; - - default: - // Try to get content from common attributes - if ( isset( $attrs['content'] ) ) { - return $attrs['content']; - } - if ( isset( $attrs['value'] ) ) { - return $attrs['value']; - } - return ''; - } - } - - /** - * Handle SEO audit request. - * - * @since 0.1.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_seo_audit( $request ) { - $post_id = isset( $request['post_id'] ) ? (int) $request['post_id'] : 0; - if ( $post_id <= 0 ) { - return new WP_Error( - 'invalid_post', - __( 'Invalid post ID.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - // Check post permission before reading post content/config. - if ( ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - $post = get_post( $post_id ); - if ( ! $post ) { - return new WP_Error( - 'post_not_found', - __( 'Post not found.', 'wp-agentic-writer' ), - array( 'status' => 404 ) - ); - } - - $post_config = $this->get_post_config( $post_id ); - $content = wp_strip_all_tags( $post->post_content ); - $title = $post->post_title; - $focus_keyword = $post_config['seo_focus_keyword'] ?? ''; - - $audit = array( - 'score' => 0, - 'checks' => array(), - 'keyword_density' => 0, - 'word_count' => 0, - ); - - // Word count - $word_count = str_word_count( $content ); - $audit['word_count'] = $word_count; - - // Check 1: Content length - if ( $word_count >= 1500 ) { - $audit['checks'][] = array( 'name' => 'Content length', 'status' => 'good', 'message' => "Excellent! {$word_count} words (recommended: 1500+)" ); - $audit['score'] += 15; - } elseif ( $word_count >= 800 ) { - $audit['checks'][] = array( 'name' => 'Content length', 'status' => 'ok', 'message' => "Good: {$word_count} words (recommended: 1500+)" ); - $audit['score'] += 10; - } else { - $audit['checks'][] = array( 'name' => 'Content length', 'status' => 'warning', 'message' => "Short: {$word_count} words (recommended: 800+)" ); - $audit['score'] += 5; - } - - // Check 2: Focus keyword presence - if ( ! empty( $focus_keyword ) ) { - $keyword_count = substr_count( strtolower( $content ), strtolower( $focus_keyword ) ); - $keyword_density = $word_count > 0 ? round( ( $keyword_count / $word_count ) * 100, 2 ) : 0; - $audit['keyword_density'] = $keyword_density; - - // Keyword in title - if ( stripos( $title, $focus_keyword ) !== false ) { - $audit['checks'][] = array( 'name' => 'Keyword in title', 'status' => 'good', 'message' => 'Focus keyword found in title' ); - $audit['score'] += 20; - } else { - $audit['checks'][] = array( 'name' => 'Keyword in title', 'status' => 'warning', 'message' => 'Focus keyword not found in title' ); - } - - // Keyword density - if ( $keyword_density >= 1 && $keyword_density <= 2.5 ) { - $audit['checks'][] = array( 'name' => 'Keyword density', 'status' => 'good', 'message' => "Optimal: {$keyword_density}% (target: 1-2.5%)" ); - $audit['score'] += 20; - } elseif ( $keyword_density > 0 && $keyword_density < 1 ) { - $audit['checks'][] = array( 'name' => 'Keyword density', 'status' => 'ok', 'message' => "Low: {$keyword_density}% (target: 1-2.5%)" ); - $audit['score'] += 10; - } elseif ( $keyword_density > 2.5 ) { - $audit['checks'][] = array( 'name' => 'Keyword density', 'status' => 'warning', 'message' => "High: {$keyword_density}% - may be over-optimized" ); - $audit['score'] += 5; - } else { - $audit['checks'][] = array( 'name' => 'Keyword density', 'status' => 'error', 'message' => 'Focus keyword not found in content' ); - } - - // Keyword in first paragraph - $first_para = substr( $content, 0, 500 ); - if ( stripos( $first_para, $focus_keyword ) !== false ) { - $audit['checks'][] = array( 'name' => 'Keyword in intro', 'status' => 'good', 'message' => 'Focus keyword in first paragraph' ); - $audit['score'] += 15; - } else { - $audit['checks'][] = array( 'name' => 'Keyword in intro', 'status' => 'warning', 'message' => 'Add focus keyword to first paragraph' ); - } - } else { - $audit['checks'][] = array( 'name' => 'Focus keyword', 'status' => 'warning', 'message' => 'No focus keyword set' ); - } - - // Check 3: Headings - $heading_count = preg_match_all( '//', $post->post_content, $matches ); - if ( $heading_count >= 3 ) { - $audit['checks'][] = array( 'name' => 'Subheadings', 'status' => 'good', 'message' => "{$heading_count} subheadings found" ); - $audit['score'] += 15; - } elseif ( $heading_count >= 1 ) { - $audit['checks'][] = array( 'name' => 'Subheadings', 'status' => 'ok', 'message' => "Only {$heading_count} subheading(s) - add more for readability" ); - $audit['score'] += 8; - } else { - $audit['checks'][] = array( 'name' => 'Subheadings', 'status' => 'warning', 'message' => 'No subheadings found - add H2/H3 headings' ); - } - - // Check 4: Images - $image_count = preg_match_all( '//', $post->post_content, $matches ); - if ( $image_count >= 1 ) { - $audit['checks'][] = array( 'name' => 'Images', 'status' => 'good', 'message' => "{$image_count} image(s) found" ); - $audit['score'] += 10; - } else { - $audit['checks'][] = array( 'name' => 'Images', 'status' => 'ok', 'message' => 'No images - consider adding visuals' ); - } - - // Check 5: Meta description - $meta_desc = $post_config['seo_meta_description'] ?? ''; - if ( ! empty( $meta_desc ) ) { - $meta_len = strlen( $meta_desc ); - if ( $meta_len >= 120 && $meta_len <= 160 ) { - $audit['checks'][] = array( 'name' => 'Meta description', 'status' => 'good', 'message' => "Perfect length: {$meta_len} chars (120-160)" ); - $audit['score'] += 5; - } elseif ( $meta_len > 0 ) { - $audit['checks'][] = array( 'name' => 'Meta description', 'status' => 'ok', 'message' => "Length: {$meta_len} chars (optimal: 120-160)" ); - $audit['score'] += 3; - } - } else { - $audit['checks'][] = array( 'name' => 'Meta description', 'status' => 'warning', 'message' => 'No meta description set' ); - } - - // Check 6: AI-ish writing patterns (heuristic scanner). - $ai_pattern_result = $this->scan_ai_ish_patterns( $post->post_content ); - if ( $ai_pattern_result['count'] <= 1 ) { - $audit['checks'][] = array( - 'name' => 'AI-ish pattern risk', - 'status' => 'good', - 'message' => 'Low risk: no significant AI-style pattern detected', - ); - $audit['score'] += 15; - } elseif ( $ai_pattern_result['count'] <= 4 ) { - $audit['checks'][] = array( - 'name' => 'AI-ish pattern risk', - 'status' => 'ok', - 'message' => sprintf( 'Moderate risk: %d pattern(s) detected. Consider selective human polish.', $ai_pattern_result['count'] ), - ); - $audit['score'] += 8; - } else { - $audit['checks'][] = array( - 'name' => 'AI-ish pattern risk', - 'status' => 'warning', - 'message' => sprintf( 'High risk: %d pattern(s) detected. Refine tone for more natural writing.', $ai_pattern_result['count'] ), - ); - $audit['score'] += 3; - } - $audit['ai_ish_pattern_count'] = $ai_pattern_result['count']; - $audit['ai_ish_pattern_examples'] = $ai_pattern_result['examples']; - - // Cap score at 100 - $audit['score'] = min( 100, $audit['score'] ); - - // Convert checks to issues for frontend compatibility - $audit['issues'] = array(); - foreach ( $audit['checks'] as $check ) { - if ( $check['status'] !== 'good' ) { - $audit['issues'][] = array( - 'severity' => $check['status'], - 'message' => $check['name'] . ': ' . $check['message'], - ); - } - } - - return new WP_REST_Response( $audit, 200 ); - } - - /** - * Scan post content for common AI-ish writing patterns. - * - * @param string $raw_content Raw post content. - * @return array{count:int,examples:array} - */ - private function scan_ai_ish_patterns( $raw_content ) { - $normalized = wp_strip_all_tags( (string) $raw_content ); - $normalized = preg_replace( '/\s+/', ' ', $normalized ); - $normalized = trim( (string) $normalized ); - - if ( '' === $normalized ) { - return array( - 'count' => 0, - 'examples' => array(), - ); - } - - $rules = array( - array( - 'id' => 'double_colon', - 'pattern' => '/[^\s]:\s*:[^\s]/u', - 'label' => 'double colon punctuation', - ), - array( - 'id' => 'ai_phrase_not_only_but', - 'pattern' => '/\bbukan sekadar\b|\bnot just\b/i', - 'label' => 'formulaic contrast phrase', - ), - array( - 'id' => 'ai_phrase_in_conclusion', - 'pattern' => '/\b(pada akhirnya|in conclusion|to summarize)\b/i', - 'label' => 'template-like conclusion phrase', - ), - array( - 'id' => 'meta_instruction_leak', - 'pattern' => '/\b(refined version|key refinements|changes made|rationale|could you please share)\b/i', - 'label' => 'instructional/meta leakage', - ), - array( - 'id' => 'dash_overuse', - 'pattern' => '/\s[—–-]\s/u', - 'label' => 'dash-heavy sentence style', - ), - ); - - $matches = array(); - $total = 0; - - foreach ( $rules as $rule ) { - if ( preg_match_all( $rule['pattern'], $normalized, $found, PREG_OFFSET_CAPTURE ) ) { - $total += count( $found[0] ); - if ( count( $matches ) < 5 ) { - foreach ( $found[0] as $entry ) { - if ( count( $matches ) >= 5 ) { - break; - } - $matched_text = (string) ( $entry[0] ?? '' ); - $offset = (int) ( $entry[1] ?? 0 ); - $context_start = max( 0, $offset - 48 ); - $context = function_exists( 'mb_substr' ) - ? mb_substr( $normalized, $context_start, 120 ) - : substr( $normalized, $context_start, 120 ); - $matches[] = array( - 'type' => $rule['label'], - 'match' => trim( $matched_text ), - 'context' => trim( $context ), - ); - } - } - } - } - - return array( - 'count' => (int) $total, - 'examples' => $matches, - ); - } - - /** - * Refine current post title based on user instruction. - * - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error - */ - public function handle_refine_title( $request ) { - $params = $request->get_json_params(); - $post_id = isset( $params['postId'] ) ? (int) $params['postId'] : 0; - $instruction = sanitize_text_field( $params['instruction'] ?? '' ); - $session_id = sanitize_text_field( $params['sessionId'] ?? '' ); - - if ( $post_id <= 0 ) { - return new WP_Error( 'invalid_post', __( 'Invalid post ID.', 'wp-agentic-writer' ), array( 'status' => 400 ) ); - } - if ( ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( 'forbidden', __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), array( 'status' => 403 ) ); - } - if ( '' === $instruction ) { - return new WP_Error( 'missing_instruction', __( 'Title instruction is required.', 'wp-agentic-writer' ), array( 'status' => 400 ) ); - } - - $post = get_post( $post_id ); - if ( ! $post ) { - return new WP_Error( 'post_not_found', __( 'Post not found.', 'wp-agentic-writer' ), array( 'status' => 404 ) ); - } - - $current_title = trim( wp_strip_all_tags( (string) $post->post_title ) ); - $post_config = $this->get_post_config( $post_id ); - $focus_keyword = trim( (string) ( $post_config['seo_focus_keyword'] ?? '' ) ); - - $system_prompt = "You are an expert SEO copy editor for article titles.\n" - . "Rewrite the title based on instruction.\n" - . "Return ONLY the final title text.\n" - . "No quotes. No explanation. No markdown."; - $user_prompt = "Current title: " . ( '' !== $current_title ? $current_title : '(empty)' ) . "\n" - . "Focus keyword: " . ( '' !== $focus_keyword ? $focus_keyword : '(not set)' ) . "\n" - . "Instruction: " . $instruction . "\n" - . "Constraints: keep it concise, natural, and publish-ready."; - - $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'refinement' ); - $provider = $provider_result->provider; - $response = $provider->chat( - array( - array( 'role' => 'system', 'content' => $system_prompt ), - array( 'role' => 'user', 'content' => $user_prompt ), - ), - array( 'post_id' => $post_id ), - 'refinement' - ); - - if ( is_wp_error( $response ) ) { - return $response; - } - - $new_title = trim( wp_strip_all_tags( (string) ( $response['content'] ?? '' ) ) ); - $new_title = preg_replace( '/\s+/', ' ', $new_title ); - - if ( '' === $new_title ) { - return new WP_Error( 'empty_title', __( 'Refined title is empty.', 'wp-agentic-writer' ), array( 'status' => 500 ) ); - } - - wp_update_post( - array( - 'ID' => $post_id, - 'post_title' => $new_title, - ) - ); - - $this->track_ai_cost( - $post_id, - $response['model'] ?? '', - 'title_refinement', - $response['input_tokens'] ?? 0, - $response['output_tokens'] ?? 0, - $response['cost'] ?? 0, - $provider_result, - $session_id, - 'success' - ); - - return new WP_REST_Response( - array( - 'title' => $new_title, - 'cost' => $response['cost'] ?? 0, - 'provider_metadata' => $this->build_provider_metadata( - $provider_result, - $response['model'] ?? '' - ), - ), - 200 - ); - } - - /** - * Suggest relevant internal links based on content similarity. - * - * @since 0.1.0 - * @param int $post_id Current post ID. - * @param string $focus_keyword Focus keyword. - * @param int $limit Maximum number of suggestions. - * @return array Array of suggested posts with title and URL. - */ - private function suggest_internal_links( $post_id, $focus_keyword = '', $limit = 3 ) { - $suggestions = array(); - - // Get all published posts except current - $args = array( - 'post_type' => 'post', - 'post_status' => 'publish', - 'posts_per_page' => 50, - 'post__not_in' => array( $post_id ), - 'orderby' => 'date', - 'order' => 'DESC', - ); - - $posts = get_posts( $args ); - - if ( empty( $posts ) ) { - return $suggestions; - } - - foreach ( $posts as $post ) { - // Skip if this is the current post (safety check) - if ( $post->ID === $post_id ) { - continue; - } - - $score = 0; - - // 1. Same category (weight: 30 points per category) - $current_cats = wp_get_post_categories( $post_id ); - $post_cats = wp_get_post_categories( $post->ID ); - $cat_overlap = count( array_intersect( $current_cats, $post_cats ) ); - $score += $cat_overlap * 30; - - // 2. Same tags (weight: 20 points per tag) - $current_tags = wp_get_post_tags( $post_id, array( 'fields' => 'ids' ) ); - $post_tags = wp_get_post_tags( $post->ID, array( 'fields' => 'ids' ) ); - $tag_overlap = count( array_intersect( $current_tags, $post_tags ) ); - $score += $tag_overlap * 20; - - // 3. Focus keyword in title (weight: 25 points) - if ( ! empty( $focus_keyword ) && stripos( $post->post_title, $focus_keyword ) !== false ) { - $score += 25; - } - - // 4. Focus keyword in content (weight: 15 points) - if ( ! empty( $focus_keyword ) && stripos( $post->post_content, $focus_keyword ) !== false ) { - $score += 15; - } - - // 5. Recency bonus (weight: 10 points for posts < 30 days, 5 points for < 90 days) - $days_old = ( time() - strtotime( $post->post_date ) ) / DAY_IN_SECONDS; - if ( $days_old < 30 ) { - $score += 10; - } elseif ( $days_old < 90 ) { - $score += 5; - } - - if ( $score > 0 ) { - $suggestions[] = array( - 'id' => $post->ID, - 'title' => $post->post_title, - 'url' => get_permalink( $post->ID ), - 'score' => $score, - ); - } - } - - // Sort by score descending - usort( - $suggestions, - function ( $a, $b ) { - return $b['score'] - $a['score']; - } - ); - - return array_slice( $suggestions, 0, $limit ); - } - - /** - * Auto-generate meta description after article execution. - * - * @since 0.1.0 - * @param int $post_id Post ID. - * @param array $post_config Post configuration. - * @param string $effective_language Effective language. - * @return array|WP_Error Result with meta description and cost, or error. - */ - private function auto_generate_meta_description( $post_id, $post_config, $effective_language ) { - $post = get_post( $post_id ); - if ( ! $post ) { - return new WP_Error( 'invalid_post', 'Post not found' ); - } - - $content = wp_strip_all_tags( $post->post_content ); - $title = $post->post_title; - $focus_keyword = $post_config['seo_focus_keyword'] ?? ''; - - if ( empty( $content ) ) { - return new WP_Error( 'no_content', 'No content available' ); - } - - $language_instruction = $this->build_language_instruction( $effective_language, 'meta description' ); - $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' ); - $provider = $provider_result->provider; - - $prompt = "Generate a compelling meta description for SEO. Requirements:\n"; - $prompt .= "- Length: MAXIMUM 155 characters (STRICT - count every character including spaces)\n"; - $prompt .= "- Include a call-to-action or value proposition\n"; - $prompt .= "- Make it enticing for searchers to click\n"; - if ( ! empty( $focus_keyword ) ) { - $prompt .= "- MUST include the focus keyword: \"{$focus_keyword}\"\n"; - } - $prompt .= "\n{$language_instruction}\n"; - $prompt .= "\nTitle: {$title}\n"; - $prompt .= "\nContent summary (first 500 chars):\n" . substr( $content, 0, 500 ); - $prompt .= "\n\nIMPORTANT: Your response must be 155 characters or less. Count carefully.\nRespond with ONLY the meta description text, no quotes, no explanation."; - - $messages = array( - array( - 'role' => 'user', - 'content' => $prompt, - ), - ); - - $response = $provider->chat( $messages, array( 'temperature' => 0.7 ), 'clarity' ); - - if ( is_wp_error( $response ) ) { - return $response; - } - - $meta_description = trim( $response['content'] ?? '' ); - $meta_description = preg_replace( '/^["\']|["\']$/', '', $meta_description ); - - // Enforce 155 character limit - if ( strlen( $meta_description ) > 155 ) { - $meta_description = substr( $meta_description, 0, 152 ) . '...'; - } - - // Save to post meta - update_post_meta( $post_id, '_wpaw_meta_description', $meta_description ); - - // Track cost - $cost = $response['cost'] ?? 0; - if ( $cost > 0 ) { - $this->track_ai_cost( - $post_id, - $response['model'] ?? 'unknown', - 'meta_description', - $response['input_tokens'] ?? 0, - $response['output_tokens'] ?? 0, - $cost, - $provider_result, - $session_id ?? '', - 'success' - ); - } - - return array( - 'meta_description' => $meta_description, - 'length' => strlen( $meta_description ), - 'cost' => $cost, - ); - } - - /** - * Handle generate meta description request. - * - * @since 0.1.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_generate_meta( $request ) { - $params = $request->get_json_params(); - $post_id = $params['postId'] ?? 0; - $content = $params['content'] ?? ''; - $title = $params['title'] ?? ''; - $focus_keyword = $params['focusKeyword'] ?? ''; - $chat_history = $params['chatHistory'] ?? array(); - - // Check post permission BEFORE reading post content. - if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - if ( empty( $content ) && $post_id > 0 ) { - $post = get_post( $post_id ); - if ( $post ) { - $content = wp_strip_all_tags( $post->post_content ); - $title = $post->post_title; - } - } - - if ( empty( $content ) ) { - return new WP_Error( - 'no_content', - __( 'No content available to generate meta description.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - // Get detected language from post meta - $stored_language = get_post_meta( $post_id, '_wpaw_detected_language', true ); - $post_config = $this->get_post_config( $post_id ); - $effective_language = $this->resolve_language_preference( $post_config, $stored_language ); - $language_instruction = $this->build_language_instruction( $effective_language, 'meta description' ); - - // Build chat history context if available - $chat_context = ''; - if ( ! empty( $chat_history ) && is_array( $chat_history ) ) { - $chat_context = "\n\nOriginal discussion context:\n"; - $user_messages = array_filter( $chat_history, function( $msg ) { - return isset( $msg['role'] ) && 'user' === strtolower( $msg['role'] ); - }); - $recent_user = array_slice( $user_messages, -2 ); - foreach ( $recent_user as $msg ) { - $content_text = $msg['content'] ?? ''; - if ( ! empty( $content_text ) ) { - $chat_context .= "- " . substr( $content_text, 0, 100 ) . "\n"; - } - } - } - - $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' ); - $provider = $provider_result->provider; - - $prompt = "Generate a compelling meta description for SEO. Requirements:\n"; - $prompt .= "- Length: MAXIMUM 155 characters (STRICT - count every character including spaces)\n"; - $prompt .= "- Include a call-to-action or value proposition\n"; - $prompt .= "- Make it enticing for searchers to click\n"; - if ( ! empty( $focus_keyword ) ) { - $prompt .= "- MUST include the focus keyword: \"{$focus_keyword}\"\n"; - } - $prompt .= "\n{$language_instruction}\n"; - $prompt .= $chat_context; - $prompt .= "\nTitle: {$title}\n"; - $prompt .= "\nContent summary (first 500 chars):\n" . substr( $content, 0, 500 ); - $prompt .= "\n\nIMPORTANT: Your response must be 155 characters or less. Count carefully.\nRespond with ONLY the meta description text, no quotes, no explanation."; - - $messages = array( - array( - 'role' => 'user', - 'content' => $prompt, - ), - ); - - $response = $provider->chat( $messages, array(), 'clarity' ); - - if ( is_wp_error( $response ) ) { - return $response; - } - - $meta_description = trim( $response['content'] ?? '' ); - $meta_description = preg_replace( '/^["\']|["\']$/', '', $meta_description ); - - // Enforce 155 character limit - if ( strlen( $meta_description ) > 155 ) { - $meta_description = substr( $meta_description, 0, 152 ) . '...'; - } - - // Track cost for meta description generation. - $cost = $response['cost'] ?? 0; - if ( $cost > 0 && $post_id > 0 ) { - $this->track_ai_cost( - $post_id, - $response['model'] ?? 'unknown', - 'meta_description', - $response['input_tokens'] ?? 0, - $response['output_tokens'] ?? 0, - $cost, - $provider_result, - '', - 'success' - ); - } - - return new WP_REST_Response( - array( - 'meta_description' => $meta_description, - 'length' => strlen( $meta_description ), - 'cost' => $cost, - 'provider_metadata' => $this->build_provider_metadata( - $provider_result, - $response['model'] ?? '' - ), - ), - 200 - ); - } - - /** - * Handle suggest keywords request. - * - * @since 0.1.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_suggest_keywords( $request ) { - $params = $request->get_json_params(); - $post_id = $params['postId'] ?? 0; - $session_id = $this->resolve_or_create_session_id( $params['sessionId'] ?? '', $post_id ); - $title = $params['title'] ?? ''; - $sections = $params['sections'] ?? array(); - - if ( empty( $title ) || empty( $sections ) ) { - return new WP_Error( - 'missing_data', - __( 'Title and sections are required for keyword suggestions.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - // Check post permission before reading post data. - if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - // Get detected language from post meta or config - $stored_language = get_post_meta( $post_id, '_wpaw_detected_language', true ); - $post_config = $this->get_post_config( $post_id ); - $effective_language = $this->resolve_language_preference( $post_config, $stored_language ); - - // Use keyword suggester helper - $result = WP_Agentic_Writer_Keyword_Suggester::suggest_keywords( - $title, - $sections, - $effective_language, - $post_id - ); - - if ( is_wp_error( $result ) ) { - return $result; - } - - // Persist SEO keyword suggestion summary to session history for future recall. - if ( ! empty( $session_id ) ) { - $reasoning = trim( (string) ( $result['reasoning'] ?? '' ) ); - $focus_keyword = (string) ( $result['focus_keyword'] ?? '' ); - $secondary_keywords = (array) ( $result['secondary_keywords'] ?? array() ); - $assistant_summary = "SEO Keywords Suggested:\n\n"; - $assistant_summary .= "Focus Keyword: {$focus_keyword}\n\n"; - $assistant_summary .= "Secondary Keywords: " . implode( ', ', $secondary_keywords ); - if ( '' !== $reasoning ) { - $assistant_summary .= "\n\n{$reasoning}"; - } - $assistant_summary .= "\n\nYou can review and edit these in the Config panel before writing."; - - $context_service = WP_Agentic_Writer_Context_Service::get_instance(); - $context_service->add_message( - $session_id, - array( - 'role' => 'assistant', - 'content' => $assistant_summary, - 'timestamp' => current_time( 'c' ), - ) - ); - } - - return new WP_REST_Response( - array( - 'focus_keyword' => $result['focus_keyword'], - 'secondary_keywords' => $result['secondary_keywords'], - 'reasoning' => $result['reasoning'], - 'cost' => $result['cost'], - 'provider_metadata' => $this->build_provider_metadata( - $result['provider_result'] ?? null, - $result['model'] ?? '' - ), - ), - 200 - ); - } - - /** - * Handle context summarization request. - * - * @since 0.1.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_summarize_context( $request ) { - $params = $request->get_json_params(); - $chat_history = $params['chatHistory'] ?? array(); - $post_id = $params['postId'] ?? 0; - $session_id = $this->resolve_or_create_session_id( $params['sessionId'] ?? '', $post_id ); - - // Check post permission before using postId for cost tracking. - if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - if ( ! empty( $session_id ) ) { - $context_service = WP_Agentic_Writer_Context_Service::get_instance(); - $session_context = $context_service->get_context( $session_id, $post_id ); - if ( ! empty( $session_context['messages'] ) && is_array( $session_context['messages'] ) ) { - $chat_history = $session_context['messages']; - } - } - - // Short history doesn't need summarization - if ( empty( $chat_history ) || count( $chat_history ) < 4 ) { - return new WP_REST_Response( - array( - 'summary' => '', - 'use_full_history' => true, - 'cost' => 0, - 'tokens_saved' => 0, - 'session_id' => $session_id, - 'message_count' => is_array( $chat_history ) ? count( $chat_history ) : 0, - 'source_message_count' => is_array( $chat_history ) ? count( $chat_history ) : 0, - ), - 200 - ); - } - - // Build history text - $history_text = ''; - foreach ( $chat_history as $msg ) { - $role = ucfirst( $msg['role'] ?? 'Unknown' ); - $content = $msg['content'] ?? ''; - if ( ! empty( $content ) ) { - $history_text .= "{$role}: {$content}\n\n"; - } - } - - // Build summarization prompt - $prompt = "Summarize this conversation into key points that capture the user's intent and requirements. + $messages = [ + [ + "role" => "system", + "content" => $system_prompt, + ], + [ + "role" => "user", + "content" => "Refine this content.", + ], + ]; + + // Use streaming for real-time feedback + $refined_content = ""; + $stream_result = $provider->chat_stream( + $messages, + ["temperature" => 0.2], + "execution", + function ($chunk) use (&$refined_content) { + // Accumulate the streaming content + $refined_content .= $chunk; + }, + ); + + if (is_wp_error($stream_result)) { + $failed_count++; + $consecutive_errors++; + echo "data: " . + wp_json_encode([ + "type" => "error", + "message" => $stream_result->get_error_message(), + ]) . + "\n\n"; + flush(); + + if ($consecutive_errors >= $max_consecutive_errors) { + $aborted_due_to_provider_errors = true; + echo "data: " . + wp_json_encode([ + "type" => "status", + "message" => sprintf( + "Stopped early after %d consecutive provider failures at block %d. Please retry with fewer blocks or check local backend health.", + (int) $consecutive_errors, + (int) $block_index_loop + 1, + ), + ]) . + "\n\n"; + flush(); + break; + } + + continue; + } + $consecutive_errors = 0; + + // Track cost from streaming result (always track for debugging). + $stream_cost = $stream_result["cost"] ?? 0; + $last_model_used = $stream_result["model"] ?? $last_model_used; + $total_cost += $stream_cost; + $this->track_ai_cost( + $post_id, + $stream_result["model"] ?? "", + "block_refinement", + $stream_result["input_tokens"] ?? 0, + $stream_result["output_tokens"] ?? 0, + $stream_cost, + $provider_result, + $session_id ?? "", + "success", + ); + + // Parse and clean the response + $payload = $this->parse_refined_payload($refined_content); + $refined_content = $this->clean_refined_content( + $payload["content"], + ); + $resolved_block_type = $payload["blockType"] ?? $block_type; + if ( + $this->is_contaminated_refinement_output( + $refined_content, + $resolved_block_type, + ) + ) { + $failed_count++; + echo "data: " . + wp_json_encode([ + "type" => "status", + "message" => sprintf( + 'Skipped contaminated output for block %1$d/%2$d (%3$s).', + (int) $block_index_loop + 1, + (int) $total_blocks_to_refine, + $resolved_block_type, + ), + ]) . + "\n\n"; + flush(); + continue; + } + + // Create proper block structure + $block_structure = $this->create_block_structure( + $block_client_id, + $resolved_block_type, + $refined_content, + ); + + // Send the refined block + echo "data: " . + wp_json_encode([ + "type" => "block", + "block" => $block_structure, + ]) . + "\n\n"; + flush(); + + $refined_count++; + if ( + 0 === $refined_count % 5 || + $refined_count === $total_blocks_to_refine + ) { + echo "data: " . + wp_json_encode([ + "type" => "status", + "message" => sprintf( + 'Progress: %1$d/%2$d block(s) updated (%3$d failed)', + $refined_count, + $total_blocks_to_refine, + $failed_count, + ), + ]) . + "\n\n"; + flush(); + } + + // Small delay between blocks + usleep(100000); + } + + // Persist refinement exchange in session history so msg counts remain accurate. + if (!empty($session_id) && !empty($message)) { + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $context_service->add_message($session_id, [ + "role" => "user", + "content" => sanitize_text_field($message), + "timestamp" => current_time("c"), + ]); + $context_service->add_message($session_id, [ + "role" => "assistant", + "content" => + isset($audit_context["source"]) && + "seo_audit" === + sanitize_text_field( + (string) $audit_context["source"], + ) + ? sprintf( + 'Audit fix complete: %1$s; %2$d candidate block(s) inspected; %3$d block(s) changed.', + !empty($audit_context["patternCount"]) + ? sprintf( + "%d pattern occurrence(s)", + (int) $audit_context["patternCount"], + ) + : "audit pattern occurrence(s)", + isset($audit_context["candidateBlockCount"]) + ? (int) $audit_context[ + "candidateBlockCount" + ] + : count($blocks_to_refine), + (int) $refined_count, + ) + : sprintf( + "Refined %d block(s) based on your request.", + (int) $refined_count, + ), + "timestamp" => current_time("c"), + ]); + } + + // Send completion message with provider metadata. + echo "data: " . + wp_json_encode([ + "type" => "complete", + "refined" => $refined_count, + "failed" => $failed_count, + "aborted" => $aborted_due_to_provider_errors, + "totalCost" => $total_cost, + "provider_metadata" => $this->build_provider_metadata( + $provider_result, + $last_model_used, + ), + ]) . + "\n\n"; + flush(); + } catch (Exception $e) { + echo "data: " . + wp_json_encode([ + "type" => "error", + "message" => $e->getMessage(), + ]) . + "\n\n"; + flush(); + } + } + + /** + * Restrict model edit-plan actions to explicitly allowed target block IDs. + * + * @since 0.1.0 + * + * @param array $plan_json Parsed model plan response. + * @param array $allowed_block_ids Block IDs allowed to be edited. + * @param array $context_blocks Normalized block context list. + * @return array Sanitized plan response. + */ + private function sanitize_refinement_edit_plan( + $plan_json, + $allowed_block_ids, + $context_blocks, + ) { + $allowed_actions = [ + "keep", + "replace", + "insert_after", + "insert_before", + "delete", + "change_type", + ]; + $allowed_lookup = array_fill_keys($allowed_block_ids, true); + $context_by_id = []; + + foreach ($context_blocks as $block) { + $context_id = sanitize_text_field($block["clientId"] ?? ""); + if ("" === $context_id) { + continue; + } + $context_by_id[$context_id] = sanitize_text_field( + $block["type"] ?? "core/paragraph", + ); + } + + $raw_actions = $plan_json["actions"] ?? []; + if (!is_array($raw_actions)) { + $raw_actions = []; + } + + $sanitized_actions = []; + foreach ($raw_actions as $action) { + if (!is_array($action)) { + continue; + } + + $action_name = sanitize_key($action["action"] ?? ""); + $block_id = sanitize_text_field($action["blockId"] ?? ""); + if ( + "" === $action_name || + !in_array($action_name, $allowed_actions, true) + ) { + continue; + } + + if ("" === $block_id || !isset($allowed_lookup[$block_id])) { + continue; + } + + $clean_action = [ + "action" => $action_name, + "blockId" => $block_id, + ]; + + if ( + in_array( + $action_name, + ["replace", "insert_after", "insert_before", "change_type"], + true, + ) + ) { + $fallback_type = $context_by_id[$block_id] ?? "core/paragraph"; + $clean_action["blockType"] = sanitize_text_field( + $action["blockType"] ?? $fallback_type, + ); + $clean_action["content"] = isset($action["content"]) + ? wp_kses_post((string) $action["content"]) + : ""; + } + + $sanitized_actions[] = $clean_action; + } + + return [ + "summary" => sanitize_text_field($plan_json["summary"] ?? ""), + "actions" => $sanitized_actions, + ]; + } + + /** + * Find block by client ID in parsed blocks array. + * + * @since 0.1.0 + * @param array $blocks Parsed blocks array. + * @param string $client_id Block client ID to find. + * @return array|null Block data or null if not found. + */ + private function find_block_by_client_id($blocks, $client_id) + { + foreach ($blocks as $block) { + if ( + isset($block["attrs"]["clientId"]) && + $block["attrs"]["clientId"] === $client_id + ) { + return $block; + } + // Check inner blocks + if ( + isset($block["innerBlocks"]) && + is_array($block["innerBlocks"]) + ) { + $found = $this->find_block_by_client_id( + $block["innerBlocks"], + $client_id, + ); + if ($found) { + return $found; + } + } + } + return null; + } + + /** + * Find block index in parsed blocks array. + * + * @since 0.1.0 + * @param array $blocks Parsed blocks array. + * @param string $client_id Block client ID to find. + * @return int Block index or -1 if not found. + */ + private function find_block_index($blocks, $client_id) + { + foreach ($blocks as $index => $block) { + if ( + isset($block["attrs"]["clientId"]) && + $block["attrs"]["clientId"] === $client_id + ) { + return $index; + } + } + return -1; + } + + /** + * Extract content from block data. + * + * @since 0.1.0 + * @param array $block Block data. + * @return string Block content. + */ + private function extract_block_content($block) + { + if (isset($block["attrs"]["content"])) { + return $block["attrs"]["content"]; + } + + if (isset($block["innerHTML"])) { + // Strip HTML tags for plain content + return wp_strip_all_tags($block["innerHTML"]); + } + + return ""; + } + + /** + * Extract heading from block. + * + * @since 0.1.0 + * @param array $block Block data. + * @return array|null Heading data or null if not a heading. + */ + private function extract_heading_from_block($block) + { + if ( + "core/heading" === $block["blockName"] && + isset($block["attrs"]["content"]) + ) { + return [ + "heading" => $block["attrs"]["content"], + ]; + } + return null; + } + + /** + * Clean refined content by removing conversational text. + * + * @since 0.1.0 + * @param string $content Content to clean. + * @return string Cleaned content. + */ + private function clean_refined_content($content) + { + // Remove common conversational prefixes + $conversational_prefixes = [ + 'Certainly! Here\'s', + 'Here\'s', + "The refined content", + "Here is the", + "Below is the", + "Okay, here", + "Sure, here", + ]; + + foreach ($conversational_prefixes as $prefix) { + if (stripos($content, $prefix) === 0) { + $content = substr($content, strlen($prefix)); + $content = ltrim($content, ":\n\r "); + } + } + + // Remove markdown code blocks if present + $content = preg_replace('/^```(?:text|markdown)?\n*/i', "", $content); + $content = preg_replace('/```*$/i', "", $content); + + // Remove common analysis scaffolding that sometimes leaks into block output. + $content = preg_replace("/^\s*Refined version\s*:\s*/im", "", $content); + $content = preg_replace( + '/^\s*Key refinements\s*:\s*$/im', + "", + $content, + ); + $content = preg_replace( + '/^\s*(The refinement .*|Changes made .*|Explanation .*|Rationale .*)$/im', + "", + $content, + ); + + // If model includes a bullet list of substitutions/explanations, strip that section. + $meta_markers = [ + "Key refinements:", + "Changes made:", + "Explanation:", + "Rationale:", + ]; + foreach ($meta_markers as $marker) { + $pos = stripos($content, $marker); + if (false !== $pos) { + $content = substr($content, 0, $pos); + } + } + + // Avoid persisting raw JSON fences or labels. + $content = preg_replace("/^\s*content\s*:\s*/im", "", $content); + $content = trim($content); + + return $content; + } + + /** + * Parse refined payload that may be wrapped in JSON. + * + * @since 0.1.0 + * @param string $content Raw model content. + * @return array Parsed payload with content and optional blockType. + */ + private function parse_refined_payload($content) + { + $payload = [ + "content" => $content, + ]; + + if (!is_string($content)) { + return $payload; + } + + $trimmed = trim($content); + if ("" === $trimmed) { + return $payload; + } + + if ($trimmed[0] !== "{" || substr($trimmed, -1) !== "}") { + return $payload; + } + + $decoded = json_decode($trimmed, true); + if (!is_array($decoded)) { + return $payload; + } + + if (isset($decoded["content"]) && is_string($decoded["content"])) { + $payload["content"] = $decoded["content"]; + } + + $block_type = $decoded["blockType"] ?? ($decoded["type"] ?? null); + if (is_string($block_type) && 0 === strpos($block_type, "core/")) { + $payload["blockType"] = $block_type; + } + + return $payload; + } + + /** + * Detect assistant/meta chatter that should never be inserted as block content. + * + * @since 0.1.0 + * @param string $content Refined content candidate. + * @param string $block_type Resolved block type. + * @return bool + */ + private function is_contaminated_refinement_output( + $content, + $block_type = "core/paragraph", + ) { + $text = trim(wp_strip_all_tags((string) $content)); + if ("" === $text) { + return true; + } + + $meta_patterns = [ + "/\b(i apologize|could you please|would you like me|please share|no specific content was provided)\b/i", + "/\b(refined version|key refinements|changes made|rationale|note:\s*since)\b/i", + '/\b(i have kept the heading|if you\'d like me to refine this)\b/i', + ]; + foreach ($meta_patterns as $pattern) { + if (preg_match($pattern, $text)) { + return true; + } + } + + // Headings should be concise and single-line. + if ("core/heading" === $block_type) { + if (strlen($text) > 180 || substr_count($text, "\n") > 0) { + return true; + } + } + + return false; + } + + /** + * Build a compact section-scoped context window around a block. + * + * @since 0.1.0 + * @param array $all_blocks All serialized editor blocks. + * @param int $block_index Current block index. + * @param int $max_snippets Max context snippets. + * @return string + */ + private function build_section_context_for_block( + $all_blocks, + $block_index, + $max_snippets = 4, + ) { + if ( + !is_array($all_blocks) || + $block_index < 0 || + !isset($all_blocks[$block_index]) + ) { + return ""; + } + + $start = $block_index; + for ($i = $block_index - 1; $i >= 0; $i--) { + $name = + $all_blocks[$i]["name"] ?? ($all_blocks[$i]["blockName"] ?? ""); + if ("core/heading" === $name) { + $start = $i; + break; + } + $start = $i; + } + + $end = $block_index; + for ($i = $block_index + 1; $i < count($all_blocks); $i++) { + $name = + $all_blocks[$i]["name"] ?? ($all_blocks[$i]["blockName"] ?? ""); + if ("core/heading" === $name) { + break; + } + $end = $i; + } + + $snippets = []; + for ($i = $start; $i <= $end; $i++) { + $block = $all_blocks[$i]; + $name = $block["name"] ?? ($block["blockName"] ?? ""); + if ( + !in_array( + $name, + [ + "core/heading", + "core/paragraph", + "core/list", + "core/quote", + ], + true, + ) + ) { + continue; + } + $attrs = $block["attributes"] ?? ($block["attrs"] ?? []); + $text = trim( + wp_strip_all_tags( + $this->extract_block_content_from_attrs($name, $attrs), + ), + ); + if ("" === $text) { + continue; + } + if (strlen($text) > 180) { + $text = substr($text, 0, 180) . "..."; + } + $snippets[] = "- " . $text; + if (count($snippets) >= $max_snippets) { + break; + } + } + + return implode("\n", $snippets); + } + + /** + * Create block structure for refined content. + * + * @since 0.1.0 + * @param string $block_id Block client ID. + * @param string $block_type Block type. + * @param string $content Refined content. + * @return array Block structure. + */ + private function create_block_structure($block_id, $block_type, $content) + { + if ( + preg_match( + '/^!\\[(.*?)\\]\\(([^\\s)]+)(?:\\s+\\"[^\\"]*\\")?\\)\\s*$/', + trim($content), + $matches, + ) + ) { + $alt = trim($matches[1]); + $url = trim($matches[2]); + $escaped_alt = esc_attr($alt); + $escaped_url = esc_url($url); + + return [ + "blockName" => "core/image", + "attrs" => [ + "id" => 0, + "url" => $escaped_url, + "alt" => $alt, + "caption" => "", + "sizeSlug" => "large", + "linkDestination" => "none", + ], + "innerHTML" => + '
    ' .
+                    $escaped_alt .
+                    '
    ', + "clientId" => $block_id, + ]; + } + + if ("core/paragraph" === $block_type) { + return [ + "blockName" => "core/paragraph", + "attrs" => ["content" => $content], + "innerHTML" => "

    " . $content . "

    ", + "clientId" => $block_id, + ]; + } elseif ("core/heading" === $block_type) { + // Detect heading level + $level = 2; + if (preg_match("/^(#{1,6})\s/", $content)) { + $count = strspn($content, "#"); + $level = min($count, 6); + $content = trim(substr($content, $count)); + } + $tag = "h" . $level; + return [ + "blockName" => "core/heading", + "attrs" => [ + "level" => $level, + "content" => $content, + ], + "innerHTML" => "<{$tag}>{$content}", + "clientId" => $block_id, + ]; + } elseif ("core/list" === $block_type) { + $lines = explode("\n", $content); + $lines = array_filter(array_map("trim", $lines)); + + // Create inner blocks for list items + $inner_blocks = []; + foreach ($lines as $line) { + $inner_blocks[] = [ + "blockName" => "core/list-item", + "attrs" => ["content" => $line], + "innerHTML" => "
  • " . $line . "
  • ", + ]; + } + + return [ + "blockName" => "core/list", + "attrs" => ["ordered" => false], + "innerBlocks" => $inner_blocks, + "clientId" => $block_id, + ]; + } elseif ("core/code" === $block_type) { + $language = "text"; + $code_content = $content; + if (preg_match("/^```(\\w+)?\\s*/", $content, $matches)) { + if (!empty($matches[1])) { + $language = $matches[1]; + } + $code_content = preg_replace( + "/^```\\w*\\s*/", + "", + $code_content, + ); + $code_content = preg_replace('/```\\s*$/', "", $code_content); + $code_content = trim($code_content); + } + + $escaped = htmlspecialchars($code_content, ENT_NOQUOTES, "UTF-8"); + return [ + "blockName" => "core/code", + "attrs" => [ + "language" => $language, + "content" => $code_content, + ], + "innerHTML" => + '
    ' .
    +                    $escaped .
    +                    "
    ", + "clientId" => $block_id, + ]; + } + + // Fallback to paragraph + return [ + "blockName" => "core/paragraph", + "attrs" => ["content" => $content], + "innerHTML" => "

    " . $content . "

    ", + "clientId" => $block_id, + ]; + } + + /** + * Build a short memory summary from the plan JSON. + * + * @since 0.1.0 + * @param array $plan_json Plan data. + * @return string Summary text. + */ + private function build_memory_summary_from_plan($plan_json) + { + if (empty($plan_json) || !is_array($plan_json)) { + return ""; + } + + $title = $plan_json["title"] ?? ""; + $headings = []; + if ( + !empty($plan_json["sections"]) && + is_array($plan_json["sections"]) + ) { + foreach ($plan_json["sections"] as $section) { + if (!empty($section["heading"])) { + $headings[] = $section["heading"]; + } + } + } + + $summary = ""; + if ($title) { + $summary .= "Title: {$title}\n"; + } + if (!empty($headings)) { + $summary .= "Sections: " . implode(" | ", $headings); + } + + return trim($summary); + } + + /** + * Update per-post memory meta. + * + * @since 0.1.0 + * @param int $post_id Post ID. + * @param array $data Memory fields to update. + * @return void + */ + private function update_post_memory($post_id, $data) + { + if ($post_id <= 0) { + return; + } + + $memory = get_post_meta($post_id, "_wpaw_memory", true); + if (!is_array($memory)) { + $memory = []; + } + + $memory = array_merge($memory, $data); + $memory["updated_at"] = current_time("timestamp"); + + update_post_meta($post_id, "_wpaw_memory", $memory); + } + + /** + * Build memory context string for prompts. + * + * @since 0.1.0 + * @param int $post_id Post ID. + * @return string Context string. + */ + private function get_post_memory_context($post_id) + { + if ($post_id <= 0) { + return ""; + } + + $memory = get_post_meta($post_id, "_wpaw_memory", true); + if (empty($memory) || !is_array($memory)) { + return ""; + } + + $lines = []; + if (!empty($memory["summary"])) { + $lines[] = "Summary: " . $memory["summary"]; + } + if (!empty($memory["last_prompt"])) { + $lines[] = "Last prompt: " . $memory["last_prompt"]; + } + if (!empty($memory["last_intent"])) { + $lines[] = "Last intent: " . $memory["last_intent"]; + } + + if (empty($lines)) { + return ""; + } + + return "\n\n=== POST MEMORY ===\n" . + implode("\n", $lines) . + "\n=== END POST MEMORY ===\n"; + } + + /** + * Get blocks from the current editor state. + * + * @since 0.1.0 + * @return array Array of block objects from editor. + */ + private function select_blocks() + { + // Get blocks from the editor via REST API request + // This is a helper to simulate wp.data.select( 'core/block-editor' ).getBlocks() + global $post; + + if (!$post) { + return []; + } + + // Parse blocks from post content + $blocks = parse_blocks($post->post_content); + + // Filter out empty blocks + return array_filter($blocks, function ($block) { + return !empty($block["blockName"]); + }); + } + + /** + * Serialize block object for consistent handling. + * + * @since 0.1.0 + * @param array $block Block data. + * @return array Serialized block with clientId. + */ + private function serialize_block($block) + { + // Ensure clientId is set in attrs + if (!isset($block["attrs"]["clientId"])) { + $block["attrs"]["clientId"] = isset($block["clientId"]) + ? $block["clientId"] + : uniqid(); + } + + return $block; + } + + /** + * Extract content from block attributes. + * + * @since 0.1.0 + * @param string $block_type Block type (e.g., 'core/paragraph'). + * @param array $attrs Block attributes. + * @return string Extracted content. + */ + private function extract_block_content_from_attrs($block_type, $attrs) + { + switch ($block_type) { + case "core/paragraph": + return isset($attrs["content"]) ? $attrs["content"] : ""; + + case "core/heading": + return isset($attrs["content"]) ? $attrs["content"] : ""; + + case "core/list": + // For lists, return a string representation + if (isset($attrs["values"]) && is_array($attrs["values"])) { + return implode("\n", $attrs["values"]); + } + return ""; + + case "core/code": + return isset($attrs["content"]) ? $attrs["content"] : ""; + + case "core/image": + if (isset($attrs["url"]) && isset($attrs["alt"])) { + return "![" . $attrs["alt"] . "](" . $attrs["url"] . ")"; + } + return ""; + + default: + // Try to get content from common attributes + if (isset($attrs["content"])) { + return $attrs["content"]; + } + if (isset($attrs["value"])) { + return $attrs["value"]; + } + return ""; + } + } + + /** + * Handle SEO audit request. + * + * @since 0.1.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_seo_audit($request) + { + $post_id = isset($request["post_id"]) ? (int) $request["post_id"] : 0; + if ($post_id <= 0) { + return new WP_Error( + "invalid_post", + __("Invalid post ID.", "wp-agentic-writer"), + ["status" => 400], + ); + } + + // Check post permission before reading post content/config. + if (!$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to access this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $post = get_post($post_id); + if (!$post) { + return new WP_Error( + "post_not_found", + __("Post not found.", "wp-agentic-writer"), + ["status" => 404], + ); + } + + $post_config = $this->get_post_config($post_id); + $content = wp_strip_all_tags($post->post_content); + $title = $post->post_title; + $focus_keyword = $post_config["seo_focus_keyword"] ?? ""; + + $audit = [ + "score" => 0, + "checks" => [], + "keyword_density" => 0, + "word_count" => 0, + ]; + + // Word count + $word_count = str_word_count($content); + $audit["word_count"] = $word_count; + + // Check 1: Content length + if ($word_count >= 1500) { + $audit["checks"][] = [ + "name" => "Content length", + "status" => "good", + "message" => "Excellent! {$word_count} words (recommended: 1500+)", + ]; + $audit["score"] += 15; + } elseif ($word_count >= 800) { + $audit["checks"][] = [ + "name" => "Content length", + "status" => "ok", + "message" => "Good: {$word_count} words (recommended: 1500+)", + ]; + $audit["score"] += 10; + } else { + $audit["checks"][] = [ + "name" => "Content length", + "status" => "warning", + "message" => "Short: {$word_count} words (recommended: 800+)", + ]; + $audit["score"] += 5; + } + + // Check 2: Focus keyword presence + if (!empty($focus_keyword)) { + $keyword_count = substr_count( + strtolower($content), + strtolower($focus_keyword), + ); + $keyword_density = + $word_count > 0 + ? round(($keyword_count / $word_count) * 100, 2) + : 0; + $audit["keyword_density"] = $keyword_density; + + // Keyword in title + if (stripos($title, $focus_keyword) !== false) { + $audit["checks"][] = [ + "name" => "Keyword in title", + "status" => "good", + "message" => "Focus keyword found in title", + ]; + $audit["score"] += 20; + } else { + $audit["checks"][] = [ + "name" => "Keyword in title", + "status" => "warning", + "message" => "Focus keyword not found in title", + ]; + } + + // Keyword density + if ($keyword_density >= 1 && $keyword_density <= 2.5) { + $audit["checks"][] = [ + "name" => "Keyword density", + "status" => "good", + "message" => "Optimal: {$keyword_density}% (target: 1-2.5%)", + ]; + $audit["score"] += 20; + } elseif ($keyword_density > 0 && $keyword_density < 1) { + $audit["checks"][] = [ + "name" => "Keyword density", + "status" => "ok", + "message" => "Low: {$keyword_density}% (target: 1-2.5%)", + ]; + $audit["score"] += 10; + } elseif ($keyword_density > 2.5) { + $audit["checks"][] = [ + "name" => "Keyword density", + "status" => "warning", + "message" => "High: {$keyword_density}% - may be over-optimized", + ]; + $audit["score"] += 5; + } else { + $audit["checks"][] = [ + "name" => "Keyword density", + "status" => "error", + "message" => "Focus keyword not found in content", + ]; + } + + // Keyword in first paragraph + $first_para = substr($content, 0, 500); + if (stripos($first_para, $focus_keyword) !== false) { + $audit["checks"][] = [ + "name" => "Keyword in intro", + "status" => "good", + "message" => "Focus keyword in first paragraph", + ]; + $audit["score"] += 15; + } else { + $audit["checks"][] = [ + "name" => "Keyword in intro", + "status" => "warning", + "message" => "Add focus keyword to first paragraph", + ]; + } + } else { + $audit["checks"][] = [ + "name" => "Focus keyword", + "status" => "warning", + "message" => "No focus keyword set", + ]; + } + + // Check 3: Headings + $heading_count = preg_match_all( + "//", + $post->post_content, + $matches, + ); + if ($heading_count >= 3) { + $audit["checks"][] = [ + "name" => "Subheadings", + "status" => "good", + "message" => "{$heading_count} subheadings found", + ]; + $audit["score"] += 15; + } elseif ($heading_count >= 1) { + $audit["checks"][] = [ + "name" => "Subheadings", + "status" => "ok", + "message" => "Only {$heading_count} subheading(s) - add more for readability", + ]; + $audit["score"] += 8; + } else { + $audit["checks"][] = [ + "name" => "Subheadings", + "status" => "warning", + "message" => "No subheadings found - add H2/H3 headings", + ]; + } + + // Check 4: Images + $image_count = preg_match_all( + "//", + $post->post_content, + $matches, + ); + if ($image_count >= 1) { + $audit["checks"][] = [ + "name" => "Images", + "status" => "good", + "message" => "{$image_count} image(s) found", + ]; + $audit["score"] += 10; + } else { + $audit["checks"][] = [ + "name" => "Images", + "status" => "ok", + "message" => "No images - consider adding visuals", + ]; + } + + // Check 5: Meta description + $meta_desc = $post_config["seo_meta_description"] ?? ""; + if (!empty($meta_desc)) { + $meta_len = strlen($meta_desc); + if ($meta_len >= 120 && $meta_len <= 160) { + $audit["checks"][] = [ + "name" => "Meta description", + "status" => "good", + "message" => "Perfect length: {$meta_len} chars (120-160)", + ]; + $audit["score"] += 5; + } elseif ($meta_len > 0) { + $audit["checks"][] = [ + "name" => "Meta description", + "status" => "ok", + "message" => "Length: {$meta_len} chars (optimal: 120-160)", + ]; + $audit["score"] += 3; + } + } else { + $audit["checks"][] = [ + "name" => "Meta description", + "status" => "warning", + "message" => "No meta description set", + ]; + } + + // Check 6: AI-ish writing patterns (heuristic scanner). + $ai_pattern_result = $this->scan_ai_ish_patterns($post->post_content); + if ($ai_pattern_result["count"] <= 1) { + $audit["checks"][] = [ + "name" => "AI-ish pattern risk", + "status" => "good", + "message" => + "Low risk: no significant AI-style pattern detected", + ]; + $audit["score"] += 15; + } elseif ($ai_pattern_result["count"] <= 4) { + $audit["checks"][] = [ + "name" => "AI-ish pattern risk", + "status" => "ok", + "message" => sprintf( + "Moderate risk: %d pattern(s) detected. Consider selective human polish.", + $ai_pattern_result["count"], + ), + ]; + $audit["score"] += 8; + } else { + $audit["checks"][] = [ + "name" => "AI-ish pattern risk", + "status" => "warning", + "message" => sprintf( + "High risk: %d pattern(s) detected. Refine tone for more natural writing.", + $ai_pattern_result["count"], + ), + ]; + $audit["score"] += 3; + } + $audit["ai_ish_pattern_count"] = $ai_pattern_result["count"]; + $audit["ai_ish_pattern_examples"] = $ai_pattern_result["examples"]; + + // Cap score at 100 + $audit["score"] = min(100, $audit["score"]); + + // Convert checks to issues for frontend compatibility + $audit["issues"] = []; + foreach ($audit["checks"] as $check) { + if ($check["status"] !== "good") { + $audit["issues"][] = [ + "severity" => $check["status"], + "message" => $check["name"] . ": " . $check["message"], + ]; + } + } + + return new WP_REST_Response($audit, 200); + } + + /** + * Scan post content for common AI-ish writing patterns. + * + * @param string $raw_content Raw post content. + * @return array{count:int,examples:array} + */ + private function scan_ai_ish_patterns($raw_content) + { + $normalized = wp_strip_all_tags((string) $raw_content); + $normalized = preg_replace("/\s+/", " ", $normalized); + $normalized = trim((string) $normalized); + + if ("" === $normalized) { + return [ + "count" => 0, + "examples" => [], + ]; + } + + $rules = [ + [ + "id" => "double_colon", + "pattern" => "/[^\s]:\s*:[^\s]/u", + "label" => "double colon punctuation", + ], + [ + "id" => "ai_phrase_not_only_but", + "pattern" => "/\bbukan sekadar\b|\bnot just\b/i", + "label" => "formulaic contrast phrase", + ], + [ + "id" => "ai_phrase_in_conclusion", + "pattern" => + "/\b(pada akhirnya|in conclusion|to summarize)\b/i", + "label" => "template-like conclusion phrase", + ], + [ + "id" => "meta_instruction_leak", + "pattern" => + "/\b(refined version|key refinements|changes made|rationale|could you please share)\b/i", + "label" => "instructional/meta leakage", + ], + [ + "id" => "dash_overuse", + "pattern" => "/\s[—–-]\s/u", + "label" => "dash-heavy sentence style", + ], + ]; + + $matches = []; + $total = 0; + + foreach ($rules as $rule) { + if ( + preg_match_all( + $rule["pattern"], + $normalized, + $found, + PREG_OFFSET_CAPTURE, + ) + ) { + $total += count($found[0]); + if (count($matches) < 5) { + foreach ($found[0] as $entry) { + if (count($matches) >= 5) { + break; + } + $matched_text = (string) ($entry[0] ?? ""); + $offset = (int) ($entry[1] ?? 0); + $context_start = max(0, $offset - 48); + $context = function_exists("mb_substr") + ? mb_substr($normalized, $context_start, 120) + : substr($normalized, $context_start, 120); + $matches[] = [ + "type" => $rule["label"], + "match" => trim($matched_text), + "context" => trim($context), + ]; + } + } + } + } + + return [ + "count" => (int) $total, + "examples" => $matches, + ]; + } + + /** + * Refine current post title based on user instruction. + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_refine_title($request) + { + $params = $request->get_json_params(); + $post_id = isset($params["postId"]) ? (int) $params["postId"] : 0; + $instruction = sanitize_text_field($params["instruction"] ?? ""); + $session_id = sanitize_text_field($params["sessionId"] ?? ""); + + if ($post_id <= 0) { + return new WP_Error( + "invalid_post", + __("Invalid post ID.", "wp-agentic-writer"), + ["status" => 400], + ); + } + if (!$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to edit this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + if ("" === $instruction) { + return new WP_Error( + "missing_instruction", + __("Title instruction is required.", "wp-agentic-writer"), + ["status" => 400], + ); + } + + $post = get_post($post_id); + if (!$post) { + return new WP_Error( + "post_not_found", + __("Post not found.", "wp-agentic-writer"), + ["status" => 404], + ); + } + + $current_title = trim(wp_strip_all_tags((string) $post->post_title)); + $post_config = $this->get_post_config($post_id); + $focus_keyword = trim( + (string) ($post_config["seo_focus_keyword"] ?? ""), + ); + + $system_prompt = + "You are an expert SEO copy editor for article titles.\n" . + "Rewrite the title based on instruction.\n" . + "Return ONLY the final title text.\n" . + "No quotes. No explanation. No markdown."; + $user_prompt = + "Current title: " . + ("" !== $current_title ? $current_title : "(empty)") . + "\n" . + "Focus keyword: " . + ("" !== $focus_keyword ? $focus_keyword : "(not set)") . + "\n" . + "Instruction: " . + $instruction . + "\n" . + "Constraints: keep it concise, natural, and publish-ready."; + + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( + "refinement", + ); + $provider = $provider_result->provider; + $response = $provider->chat( + [ + ["role" => "system", "content" => $system_prompt], + ["role" => "user", "content" => $user_prompt], + ], + ["post_id" => $post_id], + "refinement", + ); + + if (is_wp_error($response)) { + return $response; + } + + $new_title = trim( + wp_strip_all_tags((string) ($response["content"] ?? "")), + ); + $new_title = preg_replace("/\s+/", " ", $new_title); + + if ("" === $new_title) { + return new WP_Error( + "empty_title", + __("Refined title is empty.", "wp-agentic-writer"), + ["status" => 500], + ); + } + + wp_update_post([ + "ID" => $post_id, + "post_title" => $new_title, + ]); + + $this->track_ai_cost( + $post_id, + $response["model"] ?? "", + "title_refinement", + $response["input_tokens"] ?? 0, + $response["output_tokens"] ?? 0, + $response["cost"] ?? 0, + $provider_result, + $session_id, + "success", + ); + + return new WP_REST_Response( + [ + "title" => $new_title, + "cost" => $response["cost"] ?? 0, + "provider_metadata" => $this->build_provider_metadata( + $provider_result, + $response["model"] ?? "", + ), + ], + 200, + ); + } + + /** + * Suggest relevant internal links based on content similarity. + * + * @since 0.1.0 + * @param int $post_id Current post ID. + * @param string $focus_keyword Focus keyword. + * @param int $limit Maximum number of suggestions. + * @return array Array of suggested posts with title and URL. + */ + private function suggest_internal_links( + $post_id, + $focus_keyword = "", + $limit = 3, + ) { + $suggestions = []; + + // Get all published posts except current + $args = [ + "post_type" => "post", + "post_status" => "publish", + "posts_per_page" => 50, + "post__not_in" => [$post_id], + "orderby" => "date", + "order" => "DESC", + ]; + + $posts = get_posts($args); + + if (empty($posts)) { + return $suggestions; + } + + foreach ($posts as $post) { + // Skip if this is the current post (safety check) + if ($post->ID === $post_id) { + continue; + } + + $score = 0; + + // 1. Same category (weight: 30 points per category) + $current_cats = wp_get_post_categories($post_id); + $post_cats = wp_get_post_categories($post->ID); + $cat_overlap = count(array_intersect($current_cats, $post_cats)); + $score += $cat_overlap * 30; + + // 2. Same tags (weight: 20 points per tag) + $current_tags = wp_get_post_tags($post_id, ["fields" => "ids"]); + $post_tags = wp_get_post_tags($post->ID, ["fields" => "ids"]); + $tag_overlap = count(array_intersect($current_tags, $post_tags)); + $score += $tag_overlap * 20; + + // 3. Focus keyword in title (weight: 25 points) + if ( + !empty($focus_keyword) && + stripos($post->post_title, $focus_keyword) !== false + ) { + $score += 25; + } + + // 4. Focus keyword in content (weight: 15 points) + if ( + !empty($focus_keyword) && + stripos($post->post_content, $focus_keyword) !== false + ) { + $score += 15; + } + + // 5. Recency bonus (weight: 10 points for posts < 30 days, 5 points for < 90 days) + $days_old = (time() - strtotime($post->post_date)) / DAY_IN_SECONDS; + if ($days_old < 30) { + $score += 10; + } elseif ($days_old < 90) { + $score += 5; + } + + if ($score > 0) { + $suggestions[] = [ + "id" => $post->ID, + "title" => $post->post_title, + "url" => get_permalink($post->ID), + "score" => $score, + ]; + } + } + + // Sort by score descending + usort($suggestions, function ($a, $b) { + return $b["score"] - $a["score"]; + }); + + return array_slice($suggestions, 0, $limit); + } + + /** + * Auto-generate meta description after article execution. + * + * @since 0.1.0 + * @param int $post_id Post ID. + * @param array $post_config Post configuration. + * @param string $effective_language Effective language. + * @return array|WP_Error Result with meta description and cost, or error. + */ + private function auto_generate_meta_description( + $post_id, + $post_config, + $effective_language, + ) { + $post = get_post($post_id); + if (!$post) { + return new WP_Error("invalid_post", "Post not found"); + } + + $content = wp_strip_all_tags($post->post_content); + $title = $post->post_title; + $focus_keyword = $post_config["seo_focus_keyword"] ?? ""; + + if (empty($content)) { + return new WP_Error("no_content", "No content available"); + } + + $language_instruction = $this->build_language_instruction( + $effective_language, + "meta description", + ); + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( + "clarity", + ); + $provider = $provider_result->provider; + + $prompt = + "Generate a compelling meta description for SEO. Requirements:\n"; + $prompt .= + "- Length: MAXIMUM 155 characters (STRICT - count every character including spaces)\n"; + $prompt .= "- Include a call-to-action or value proposition\n"; + $prompt .= "- Make it enticing for searchers to click\n"; + if (!empty($focus_keyword)) { + $prompt .= "- MUST include the focus keyword: \"{$focus_keyword}\"\n"; + } + $prompt .= "\n{$language_instruction}\n"; + $prompt .= "\nTitle: {$title}\n"; + $prompt .= + "\nContent summary (first 500 chars):\n" . substr($content, 0, 500); + $prompt .= + "\n\nIMPORTANT: Your response must be 155 characters or less. Count carefully.\nRespond with ONLY the meta description text, no quotes, no explanation."; + + $messages = [ + [ + "role" => "user", + "content" => $prompt, + ], + ]; + + $response = $provider->chat( + $messages, + ["temperature" => 0.7], + "clarity", + ); + + if (is_wp_error($response)) { + return $response; + } + + $meta_description = trim($response["content"] ?? ""); + $meta_description = preg_replace( + '/^["\']|["\']$/', + "", + $meta_description, + ); + + // Enforce 155 character limit + if (strlen($meta_description) > 155) { + $meta_description = substr($meta_description, 0, 152) . "..."; + } + + // Save to post meta + update_post_meta($post_id, "_wpaw_meta_description", $meta_description); + + // Track cost + $cost = $response["cost"] ?? 0; + if ($cost > 0) { + $this->track_ai_cost( + $post_id, + $response["model"] ?? "unknown", + "meta_description", + $response["input_tokens"] ?? 0, + $response["output_tokens"] ?? 0, + $cost, + $provider_result, + $session_id ?? "", + "success", + ); + } + + return [ + "meta_description" => $meta_description, + "length" => strlen($meta_description), + "cost" => $cost, + ]; + } + + /** + * Handle generate meta description request. + * + * @since 0.1.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_generate_meta($request) + { + $params = $request->get_json_params(); + $post_id = $params["postId"] ?? 0; + $content = $params["content"] ?? ""; + $title = $params["title"] ?? ""; + $focus_keyword = $params["focusKeyword"] ?? ""; + $chat_history = $params["chatHistory"] ?? []; + + // Check post permission BEFORE reading post content. + if ($post_id > 0 && !$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to access this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + if (empty($content) && $post_id > 0) { + $post = get_post($post_id); + if ($post) { + $content = wp_strip_all_tags($post->post_content); + $title = $post->post_title; + } + } + + if (empty($content)) { + return new WP_Error( + "no_content", + __( + "No content available to generate meta description.", + "wp-agentic-writer", + ), + ["status" => 400], + ); + } + + // Get detected language from post meta + $stored_language = get_post_meta( + $post_id, + "_wpaw_detected_language", + true, + ); + $post_config = $this->get_post_config($post_id); + $effective_language = $this->resolve_language_preference( + $post_config, + $stored_language, + ); + $language_instruction = $this->build_language_instruction( + $effective_language, + "meta description", + ); + + // Build chat history context if available + $chat_context = ""; + if (!empty($chat_history) && is_array($chat_history)) { + $chat_context = "\n\nOriginal discussion context:\n"; + $user_messages = array_filter($chat_history, function ($msg) { + return isset($msg["role"]) && + "user" === strtolower($msg["role"]); + }); + $recent_user = array_slice($user_messages, -2); + foreach ($recent_user as $msg) { + $content_text = $msg["content"] ?? ""; + if (!empty($content_text)) { + $chat_context .= + "- " . substr($content_text, 0, 100) . "\n"; + } + } + } + + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( + "clarity", + ); + $provider = $provider_result->provider; + + $prompt = + "Generate a compelling meta description for SEO. Requirements:\n"; + $prompt .= + "- Length: MAXIMUM 155 characters (STRICT - count every character including spaces)\n"; + $prompt .= "- Include a call-to-action or value proposition\n"; + $prompt .= "- Make it enticing for searchers to click\n"; + if (!empty($focus_keyword)) { + $prompt .= "- MUST include the focus keyword: \"{$focus_keyword}\"\n"; + } + $prompt .= "\n{$language_instruction}\n"; + $prompt .= $chat_context; + $prompt .= "\nTitle: {$title}\n"; + $prompt .= + "\nContent summary (first 500 chars):\n" . substr($content, 0, 500); + $prompt .= + "\n\nIMPORTANT: Your response must be 155 characters or less. Count carefully.\nRespond with ONLY the meta description text, no quotes, no explanation."; + + $messages = [ + [ + "role" => "user", + "content" => $prompt, + ], + ]; + + $response = $provider->chat($messages, [], "clarity"); + + if (is_wp_error($response)) { + return $response; + } + + $meta_description = trim($response["content"] ?? ""); + $meta_description = preg_replace( + '/^["\']|["\']$/', + "", + $meta_description, + ); + + // Enforce 155 character limit + if (strlen($meta_description) > 155) { + $meta_description = substr($meta_description, 0, 152) . "..."; + } + + // Track cost for meta description generation. + $cost = $response["cost"] ?? 0; + if ($cost > 0 && $post_id > 0) { + $this->track_ai_cost( + $post_id, + $response["model"] ?? "unknown", + "meta_description", + $response["input_tokens"] ?? 0, + $response["output_tokens"] ?? 0, + $cost, + $provider_result, + "", + "success", + ); + } + + return new WP_REST_Response( + [ + "meta_description" => $meta_description, + "length" => strlen($meta_description), + "cost" => $cost, + "provider_metadata" => $this->build_provider_metadata( + $provider_result, + $response["model"] ?? "", + ), + ], + 200, + ); + } + + /** + * Handle suggest keywords request. + * + * @since 0.1.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_suggest_keywords($request) + { + $params = $request->get_json_params(); + $post_id = $params["postId"] ?? 0; + $session_id = $this->resolve_or_create_session_id( + $params["sessionId"] ?? "", + $post_id, + ); + $title = $params["title"] ?? ""; + $sections = $params["sections"] ?? []; + + if (empty($title) || empty($sections)) { + return new WP_Error( + "missing_data", + __( + "Title and sections are required for keyword suggestions.", + "wp-agentic-writer", + ), + ["status" => 400], + ); + } + + // Check post permission before reading post data. + if ($post_id > 0 && !$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to access this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + // Get detected language from post meta or config + $stored_language = get_post_meta( + $post_id, + "_wpaw_detected_language", + true, + ); + $post_config = $this->get_post_config($post_id); + $effective_language = $this->resolve_language_preference( + $post_config, + $stored_language, + ); + + // Use keyword suggester helper + $result = WP_Agentic_Writer_Keyword_Suggester::suggest_keywords( + $title, + $sections, + $effective_language, + $post_id, + ); + + if (is_wp_error($result)) { + return $result; + } + + // Persist SEO keyword suggestion summary to session history for future recall. + if (!empty($session_id)) { + $reasoning = trim((string) ($result["reasoning"] ?? "")); + $focus_keyword = (string) ($result["focus_keyword"] ?? ""); + $secondary_keywords = (array) ($result["secondary_keywords"] ?? []); + $assistant_summary = "SEO Keywords Suggested:\n\n"; + $assistant_summary .= "Focus Keyword: {$focus_keyword}\n\n"; + $assistant_summary .= + "Secondary Keywords: " . implode(", ", $secondary_keywords); + if ("" !== $reasoning) { + $assistant_summary .= "\n\n{$reasoning}"; + } + $assistant_summary .= + "\n\nYou can review and edit these in the Config panel before writing."; + + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $context_service->add_message($session_id, [ + "role" => "assistant", + "content" => $assistant_summary, + "timestamp" => current_time("c"), + ]); + } + + return new WP_REST_Response( + [ + "focus_keyword" => $result["focus_keyword"], + "secondary_keywords" => $result["secondary_keywords"], + "reasoning" => $result["reasoning"], + "cost" => $result["cost"], + "provider_metadata" => $this->build_provider_metadata( + $result["provider_result"] ?? null, + $result["model"] ?? "", + ), + ], + 200, + ); + } + + /** + * Handle context summarization request. + * + * @since 0.1.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_summarize_context($request) + { + $params = $request->get_json_params(); + $chat_history = $params["chatHistory"] ?? []; + $post_id = $params["postId"] ?? 0; + $session_id = $this->resolve_or_create_session_id( + $params["sessionId"] ?? "", + $post_id, + ); + + // Check post permission before using postId for cost tracking. + if ($post_id > 0 && !$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to access this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + if (!empty($session_id)) { + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $session_context = $context_service->get_context( + $session_id, + $post_id, + ); + if ( + !empty($session_context["messages"]) && + is_array($session_context["messages"]) + ) { + $chat_history = $session_context["messages"]; + } + } + + // Short history doesn't need summarization + if (empty($chat_history) || count($chat_history) < 4) { + return new WP_REST_Response( + [ + "summary" => "", + "use_full_history" => true, + "cost" => 0, + "tokens_saved" => 0, + "session_id" => $session_id, + "message_count" => is_array($chat_history) + ? count($chat_history) + : 0, + "source_message_count" => is_array($chat_history) + ? count($chat_history) + : 0, + ], + 200, + ); + } + + // Build history text + $history_text = ""; + foreach ($chat_history as $msg) { + $role = ucfirst($msg["role"] ?? "Unknown"); + $content = $msg["content"] ?? ""; + if (!empty($content)) { + $history_text .= "{$role}: {$content}\n\n"; + } + } + + // Build summarization prompt + $prompt = "Summarize this conversation into key points that capture the user's intent and requirements. Focus on: - Main topic @@ -8024,106 +9693,122 @@ PREFERENCES: [any specific requirements] Conversation: {$history_text}"; - // Call AI with clarity model for language detection - $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' ); - $provider = $provider_result->provider; - $messages = array( - array( - 'role' => 'user', - 'content' => $prompt, - ), - ); + // Call AI with clarity model for language detection + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( + "clarity", + ); + $provider = $provider_result->provider; + $messages = [ + [ + "role" => "user", + "content" => $prompt, + ], + ]; - $response = $provider->chat( $messages, array(), 'summarize' ); + $response = $provider->chat($messages, [], "summarize"); - if ( is_wp_error( $response ) ) { - return $response; - } + if (is_wp_error($response)) { + return $response; + } - // Calculate tokens saved - $original_tokens = count( $chat_history ) * 500; // Rough estimate - $summary_tokens = $response['output_tokens'] ?? 100; - $tokens_saved = $original_tokens - $summary_tokens; - $summary = $response['content'] ?? ''; + // Calculate tokens saved + $original_tokens = count($chat_history) * 500; // Rough estimate + $summary_tokens = $response["output_tokens"] ?? 100; + $tokens_saved = $original_tokens - $summary_tokens; + $summary = $response["content"] ?? ""; - if ( ! empty( $session_id ) && '' !== trim( (string) $summary ) ) { - $context_service = WP_Agentic_Writer_Context_Service::get_instance(); - $context_service->update_session_context( - $session_id, - array( - 'working_summary' => array( - 'text' => $summary, - 'updated_at' => current_time( 'c' ), - 'source_message_count' => count( $chat_history ), - ), - ) - ); - } + if (!empty($session_id) && "" !== trim((string) $summary)) { + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $context_service->update_session_context($session_id, [ + "working_summary" => [ + "text" => $summary, + "updated_at" => current_time("c"), + "source_message_count" => count($chat_history), + ], + ]); + } - // Track cost. - $this->track_ai_cost( - $post_id, - $response['model'] ?? '', - 'summarize_context', - $response['input_tokens'] ?? 0, - $response['output_tokens'] ?? 0, - $response['cost'] ?? 0, - $provider_result, - $session_id, - 'success' - ); + // Track cost. + $this->track_ai_cost( + $post_id, + $response["model"] ?? "", + "summarize_context", + $response["input_tokens"] ?? 0, + $response["output_tokens"] ?? 0, + $response["cost"] ?? 0, + $provider_result, + $session_id, + "success", + ); - return new WP_REST_Response( - array( - 'summary' => $summary, - 'use_full_history' => false, - 'cost' => $response['cost'] ?? 0, - 'tokens_saved' => $tokens_saved, - 'session_id' => $session_id, - 'message_count' => count( $chat_history ), - 'source_message_count' => count( $chat_history ), - 'provider_metadata' => $this->build_provider_metadata( - $provider_result, - $response['model'] ?? '' - ), - ), - 200 - ); - } + return new WP_REST_Response( + [ + "summary" => $summary, + "use_full_history" => false, + "cost" => $response["cost"] ?? 0, + "tokens_saved" => $tokens_saved, + "session_id" => $session_id, + "message_count" => count($chat_history), + "source_message_count" => count($chat_history), + "provider_metadata" => $this->build_provider_metadata( + $provider_result, + $response["model"] ?? "", + ), + ], + 200, + ); + } - /** - * Handle intent detection request. - * - * @since 0.1.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_detect_intent( $request ) { - $params = $request->get_json_params(); - $last_message = $params['lastMessage'] ?? ''; - $has_plan = $params['hasPlan'] ?? false; - $current_mode = $params['currentMode'] ?? 'chat'; - $post_id = $params['postId'] ?? 0; + /** + * Handle intent detection request. + * + * @since 0.1.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_detect_intent($request) + { + $params = $request->get_json_params(); + $last_message = $params["lastMessage"] ?? ""; + $has_plan = $params["hasPlan"] ?? false; + $current_mode = $params["currentMode"] ?? "chat"; + $post_id = $params["postId"] ?? 0; - // Check post permission before using postId for cost tracking. - if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } + // Check post permission before using postId for cost tracking. + if ($post_id > 0 && !$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to access this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } - if ( empty( $last_message ) ) { - return new WP_REST_Response( - array( 'intent' => 'continue_chat' ), - 200 - ); - } + if (empty($last_message)) { + return new WP_REST_Response(["intent" => "continue_chat"], 200); + } - // Build intent detection prompt - $has_plan_str = $has_plan ? 'true' : 'false'; - $prompt = "Based on the user's message, determine their intent. Choose ONE: + $normalized_message = strtolower((string) $last_message); + if ( + preg_match( + "/\b(?:out?line|plan|structure|kerangka|rencana)(?:\s*[- ]?\s*(?:nya|kan))?\b/u", + $normalized_message, + ) + ) { + return new WP_REST_Response( + [ + "intent" => "create_outline", + "cost" => 0, + ], + 200, + ); + } + + // Build intent detection prompt + $has_plan_str = $has_plan ? "true" : "false"; + $prompt = "Based on the user's message, determine their intent. Choose ONE: 1. \"create_outline\" - User wants to create an article outline/structure 2. \"start_writing\" - User wants to write the full article @@ -8141,163 +9826,190 @@ User's message: \"{$last_message}\" Respond with ONLY the intent code (e.g., \"create_outline\"). No explanation."; - // Call AI with clarity model for intent detection - $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' ); - $provider = $provider_result->provider; - $messages = array( - array( - 'role' => 'user', - 'content' => $prompt, - ), - ); + // Call AI with clarity model for intent detection + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( + "clarity", + ); + $provider = $provider_result->provider; + $messages = [ + [ + "role" => "user", + "content" => $prompt, + ], + ]; - $response = $provider->chat( $messages, array(), 'intent_detection' ); + $response = $provider->chat($messages, [], "intent_detection"); - if ( is_wp_error( $response ) ) { - return $response; - } + if (is_wp_error($response)) { + return $response; + } - // Track cost - $this->track_ai_cost( - $post_id, - $response['model'] ?? '', - 'detect_intent', - $response['input_tokens'] ?? 0, - $response['output_tokens'] ?? 0, - $response['cost'] ?? 0, - $provider_result, - $session_id ?? '', - 'success' - ); + // Track cost + $this->track_ai_cost( + $post_id, + $response["model"] ?? "", + "detect_intent", + $response["input_tokens"] ?? 0, + $response["output_tokens"] ?? 0, + $response["cost"] ?? 0, + $provider_result, + $session_id ?? "", + "success", + ); - // Clean up response - $intent = trim( strtolower( $response['content'] ?? 'continue_chat' ) ); - $intent = str_replace( '"', '', $intent ); + // Clean up response + $intent = trim(strtolower($response["content"] ?? "continue_chat")); + $intent = str_replace('"', "", $intent); - // Validate intent - $valid_intents = array( 'create_outline', 'start_writing', 'refine_content', 'add_section', 'continue_chat', 'clarify' ); - if ( ! in_array( $intent, $valid_intents, true ) ) { - $intent = 'continue_chat'; - } + // Validate intent + $valid_intents = [ + "create_outline", + "start_writing", + "refine_content", + "add_section", + "continue_chat", + "clarify", + ]; + if (!in_array($intent, $valid_intents, true)) { + $intent = "continue_chat"; + } - return new WP_REST_Response( - array( - 'intent' => $intent, - 'cost' => $response['cost'] ?? 0, - 'provider_metadata' => $this->build_provider_metadata( - $provider_result, - $response['model'] ?? '' - ), - ), - 200 - ); - } + return new WP_REST_Response( + [ + "intent" => $intent, + "cost" => $response["cost"] ?? 0, + "provider_metadata" => $this->build_provider_metadata( + $provider_result, + $response["model"] ?? "", + ), + ], + 200, + ); + } - /** - * Handle suggest improvements request (proactive AI suggestions). - * - * Analyzes article content and suggests improvements based on - * idle detection trigger. - * - * @since 0.2.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_suggest_improvements( $request ) { - $params = $request->get_json_params(); - $post_id = isset( $params['postId'] ) ? (int) $params['postId'] : 0; - $suggestion_types = $params['types'] ?? array( 'clarity', 'depth', 'structure' ); + /** + * Handle suggest improvements request (proactive AI suggestions). + * + * Analyzes article content and suggests improvements based on + * idle detection trigger. + * + * @since 0.2.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_suggest_improvements($request) + { + $params = $request->get_json_params(); + $post_id = isset($params["postId"]) ? (int) $params["postId"] : 0; + $suggestion_types = $params["types"] ?? [ + "clarity", + "depth", + "structure", + ]; - if ( $post_id <= 0 ) { - return new WP_Error( - 'invalid_post', - __( 'Valid post ID is required.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } + if ($post_id <= 0) { + return new WP_Error( + "invalid_post", + __("Valid post ID is required.", "wp-agentic-writer"), + ["status" => 400], + ); + } - // Check post permission before reading post content. - if ( ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } + // Check post permission before reading post content. + if (!$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to access this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } - // Get post content for analysis - $post = get_post( $post_id ); - if ( ! $post ) { - return new WP_Error( - 'post_not_found', - __( 'Post not found.', 'wp-agentic-writer' ), - array( 'status' => 404 ) - ); - } + // Get post content for analysis + $post = get_post($post_id); + if (!$post) { + return new WP_Error( + "post_not_found", + __("Post not found.", "wp-agentic-writer"), + ["status" => 404], + ); + } - $blocks = parse_blocks( $post->post_content ); - $plain_content = ''; - $block_count = 0; + $blocks = parse_blocks($post->post_content); + $plain_content = ""; + $block_count = 0; - foreach ( $blocks as $block ) { - if ( ! empty( $block['blockName'] ) && 0 === strpos( $block['blockName'], 'core/' ) ) { - $block_content = ''; - if ( 'core/paragraph' === $block['blockName'] || 'core/heading' === $block['blockName'] ) { - $block_content = $block['attrs']['content'] ?? ''; - } elseif ( 'core/list' === $block['blockName'] ) { - $inner_html = $block['innerHTML'] ?? ''; - $block_content = wp_strip_all_tags( $inner_html ); - } else { - $block_content = $block['innerHTML'] ?? ''; - $block_content = wp_strip_all_tags( $block_content ); - } + foreach ($blocks as $block) { + if ( + !empty($block["blockName"]) && + 0 === strpos($block["blockName"], "core/") + ) { + $block_content = ""; + if ( + "core/paragraph" === $block["blockName"] || + "core/heading" === $block["blockName"] + ) { + $block_content = $block["attrs"]["content"] ?? ""; + } elseif ("core/list" === $block["blockName"]) { + $inner_html = $block["innerHTML"] ?? ""; + $block_content = wp_strip_all_tags($inner_html); + } else { + $block_content = $block["innerHTML"] ?? ""; + $block_content = wp_strip_all_tags($block_content); + } - if ( ! empty( $block_content ) ) { - $plain_content .= $block_content . "\n\n"; - $block_count++; - } - } - } + if (!empty($block_content)) { + $plain_content .= $block_content . "\n\n"; + $block_count++; + } + } + } - if ( empty( $plain_content ) || $block_count < 3 ) { - return new WP_REST_Response( - array( - 'suggestions' => array(), - 'message' => 'Not enough content to analyze yet.', - ), - 200 - ); - } + if (empty($plain_content) || $block_count < 3) { + return new WP_REST_Response( + [ + "suggestions" => [], + "message" => "Not enough content to analyze yet.", + ], + 200, + ); + } - // Get post config for context - $post_config = $this->get_post_config( $post_id ); - $focus_keyword = $post_config['seo_focus_keyword'] ?? ''; + // Get post config for context + $post_config = $this->get_post_config($post_id); + $focus_keyword = $post_config["seo_focus_keyword"] ?? ""; - // Build suggestion type instruction - $type_instruction = ''; - foreach ( $suggestion_types as $type ) { - switch ( $type ) { - case 'clarity': - $type_instruction .= "- Identify sentences or paragraphs that are too complex or confusing\n"; - break; - case 'depth': - $type_instruction .= "- Suggest areas where more examples, data, or explanation would improve the content\n"; - break; - case 'structure': - $type_instruction .= "- Identify missing sections or structural improvements needed for the article\n"; - break; - case 'engagement': - $type_instruction .= "- Suggest ways to increase reader engagement (questions, examples, calls to action)\n"; - break; - case 'seo': - if ( ! empty( $focus_keyword ) ) { - $type_instruction .= "- Check keyword '{$focus_keyword}' usage: suggest where to naturally include it\n"; - } - break; - } - } + // Build suggestion type instruction + $type_instruction = ""; + foreach ($suggestion_types as $type) { + switch ($type) { + case "clarity": + $type_instruction .= + "- Identify sentences or paragraphs that are too complex or confusing\n"; + break; + case "depth": + $type_instruction .= + "- Suggest areas where more examples, data, or explanation would improve the content\n"; + break; + case "structure": + $type_instruction .= + "- Identify missing sections or structural improvements needed for the article\n"; + break; + case "engagement": + $type_instruction .= + "- Suggest ways to increase reader engagement (questions, examples, calls to action)\n"; + break; + case "seo": + if (!empty($focus_keyword)) { + $type_instruction .= "- Check keyword '{$focus_keyword}' usage: suggest where to naturally include it\n"; + } + break; + } + } - $system_prompt = "You are an expert content editor providing constructive improvement suggestions. + $system_prompt = "You are an expert content editor providing constructive improvement suggestions. Analyze the provided article content and suggest 1-3 specific improvements. {$type_instruction} @@ -8326,1846 +10038,2366 @@ Return your response as valid JSON in this format: If the content is already excellent and needs no major improvements, return an empty suggestions array with a positive summary. Only suggest changes that would genuinely improve the reader's experience or search engine performance."; - $messages = array( - array( - 'role' => 'system', - 'content' => $system_prompt, - ), - array( - 'role' => 'user', - 'content' => "Please analyze this article and suggest improvements:\n\n{$plain_content}", - ), - ); - - $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' ); - $provider = $provider_result->provider; - $response = $provider->chat( $messages, array(), 'analysis' ); - - if ( is_wp_error( $response ) ) { - return $response; - } - - // Track cost with full nine-argument contract including provider attribution. - $cost = $response['cost'] ?? 0; - if ( $cost > 0 ) { - $actual_provider = 'unknown'; - if ( is_object( $provider_result ) && isset( $provider_result->actual_provider ) ) { - $actual_provider = $provider_result->actual_provider; - } - - // Get session ID for this post if available. - $session_id = ''; - if ( $post_id > 0 ) { - $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); - $session = $manager->get_session_by_post_id( $post_id ); - if ( $session && isset( $session['session_id'] ) ) { - $session_id = $session['session_id']; - } - } - - $this->track_ai_cost( - $post_id, - $response['model'] ?? '', - 'analysis', - $response['input_tokens'] ?? 0, - $response['output_tokens'] ?? 0, - $cost, - $actual_provider, - $session_id, - 'success' - ); - } - - // Parse JSON from response - $content = $response['content'] ?? ''; - $suggestions_json = $this->extract_json( $content ); - - if ( null === $suggestions_json ) { - // If JSON parsing fails, return a generic success with no suggestions - return new WP_REST_Response( - array( - 'suggestions' => array(), - 'message' => 'Analysis complete but suggestions could not be parsed.', - ), - 200 - ); - } - - return new WP_REST_Response( - array( - 'suggestions' => $suggestions_json['suggestions'] ?? array(), - 'summary' => $suggestions_json['summary'] ?? 'Analysis complete.', - 'cost' => $response['cost'] ?? 0, - 'provider_metadata' => $this->build_provider_metadata( - $provider_result, - $response['model'] ?? '' - ), - ), - 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' ); - - if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - $image_manager = WP_Agentic_Writer_Image_Manager::get_instance(); - $images = $image_manager->get_image_recommendations( $post_id ); - - // Block-level sync: ensure each unresolved image block has a stable - // agent id and a corresponding recommendation row. - if ( $post_id > 0 ) { - $post = get_post( $post_id ); - if ( $post instanceof WP_Post && ! empty( $post->post_content ) ) { - $post_config = $this->get_post_config( $post_id ); - if ( ! empty( $post_config['include_images'] ) ) { - $images = $this->sync_image_block_recommendations( $post_id, $post ); - } - } - } - - return new WP_REST_Response( - array( 'images' => $images ), - 200 - ); - } - - /** - * Ensure unresolved image blocks are mapped 1:1 to recommendation rows. - * - * @param int $post_id Post ID. - * @param WP_Post $post Post object. - * @return array - */ - private function sync_image_block_recommendations( $post_id, $post ) { - $image_manager = WP_Agentic_Writer_Image_Manager::get_instance(); - $post_content = (string) $post->post_content; - $blocks = parse_blocks( $post_content ); - $changed = false; - $slots = array(); - $slot_index = 0; - $post_title = trim( wp_strip_all_tags( (string) $post->post_title ) ); - - $walk = function( &$items, $heading_context = '' ) use ( &$walk, &$changed, &$slots, &$slot_index, $post_id, $post_title ) { - foreach ( $items as &$block ) { - $name = $block['blockName'] ?? ''; - $attrs = $block['attrs'] ?? array(); - - if ( 'core/heading' === $name ) { - $heading = ''; - if ( ! empty( $attrs['content'] ) ) { - $heading = trim( wp_strip_all_tags( (string) $attrs['content'] ) ); - } elseif ( ! empty( $block['innerHTML'] ) ) { - $heading = trim( wp_strip_all_tags( (string) $block['innerHTML'] ) ); - } - if ( '' !== $heading ) { - $heading_context = $heading; - } - } - - if ( 'core/image' === $name ) { - $image_id = isset( $attrs['id'] ) ? (int) $attrs['id'] : 0; - if ( $image_id <= 0 ) { - $slot_index++; - $agent_id = isset( $attrs['data-agent-image-id'] ) ? trim( (string) $attrs['data-agent-image-id'] ) : ''; - if ( '' === $agent_id ) { - $agent_id = 'img_' . $post_id . '_blk_' . $slot_index . '_' . substr( wp_hash( microtime( true ) . wp_rand() ), 0, 8 ); - $attrs['data-agent-image-id'] = $agent_id; - $class_name = isset( $attrs['className'] ) ? (string) $attrs['className'] : ''; - if ( false === strpos( $class_name, 'wpaw-agent-img-' ) ) { - $attrs['className'] = trim( $class_name . ' wpaw-agent-img-' . $agent_id ); - } - $block['attrs'] = $attrs; - $changed = true; - } - - $slots[] = array( - 'agent_image_id' => $agent_id, - 'section_title' => '' !== $heading_context ? $heading_context : ( '' !== $post_title ? $post_title : 'Article Section' ), - 'slot_index' => $slot_index, - ); - } - } - - if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { - $walk( $block['innerBlocks'], $heading_context ); - } - } - unset( $block ); - }; - - $walk( $blocks, '' ); - - if ( $changed ) { - $serialized = serialize_blocks( $blocks ); - if ( $serialized !== $post_content ) { - wp_update_post( - array( - 'ID' => $post_id, - 'post_content' => $serialized, - ) - ); - } - } - - $current_images = $image_manager->get_image_recommendations( $post_id ); - $by_agent_id = array(); - $existing_rows = array(); - if ( is_array( $current_images ) ) { - foreach ( $current_images as $row ) { - $key = isset( $row['agent_image_id'] ) ? (string) $row['agent_image_id'] : ''; - if ( '' !== $key ) { - $by_agent_id[ $key ] = true; - } - $existing_rows[] = $row; - } - } - - $slot_agent_ids = array(); - foreach ( $slots as $slot ) { - $slot_agent_ids[ $slot['agent_image_id'] ] = true; - } - - $orphan_rows = array(); - foreach ( $existing_rows as $row ) { - $key = isset( $row['agent_image_id'] ) ? (string) $row['agent_image_id'] : ''; - if ( '' !== $key && ! isset( $slot_agent_ids[ $key ] ) ) { - $orphan_rows[] = $row; - } - } - - $focus_variants = array( - 'establishing scene', - 'close-up detail', - 'human activity and impact', - 'before-and-after comparison', - 'infographic-like composition', - ); - - foreach ( $slots as $slot ) { - $agent_id = $slot['agent_image_id']; - if ( isset( $by_agent_id[ $agent_id ] ) ) { - continue; - } - - $focus = $focus_variants[ ( (int) $slot['slot_index'] - 1 ) % count( $focus_variants ) ]; - $section_title = $slot['section_title']; - $prompt = 'Contextual image for section "' . $section_title . '" with focus on ' . $focus . '. Realistic editorial style, informative composition, natural lighting, high detail.'; - - if ( ! empty( $orphan_rows ) ) { - $orphan = array_shift( $orphan_rows ); - if ( isset( $orphan['id'] ) ) { - global $wpdb; - $table = $wpdb->prefix . 'wpaw_images'; - $wpdb->update( - $table, - array( - 'agent_image_id' => $agent_id, - 'placement' => 'slot_' . (int) $slot['slot_index'], - 'section_title' => $section_title, - 'prompt_initial' => $prompt, - 'alt_text_initial' => 'Gambar untuk bagian: ' . $section_title, - ), - array( - 'id' => (int) $orphan['id'], - 'post_id' => (int) $post_id, - ), - array( '%s', '%s', '%s', '%s', '%s' ), - array( '%d', '%d' ) - ); - $by_agent_id[ $agent_id ] = true; - continue; - } - } - - $image_manager->save_image_recommendation( - $post_id, - $agent_id, - 'slot_' . (int) $slot['slot_index'], - $section_title, - $prompt, - 'Gambar untuk bagian: ' . $section_title - ); - } - - $result = $image_manager->get_image_recommendations( $post_id ); - return is_array( $result ) ? $result : array(); - } - - /** - * Seed deterministic image recommendations from post content. - * - * @param int $post_id Post ID. - * @param string $post_title Post title. - * @param string $post_content Post content. - * @return bool True when at least one recommendation is saved. - */ - private function seed_basic_image_recommendations( $post_id, $post_title, $post_content ) { - $image_manager = WP_Agentic_Writer_Image_Manager::get_instance(); - $existing = $image_manager->get_image_recommendations( $post_id ); - if ( is_array( $existing ) && ! empty( $existing ) ) { - return true; - } - - $max_images = 3; - $title = trim( wp_strip_all_tags( (string) $post_title ) ); - $seeded = 0; - - if ( '' !== $title ) { - $agent_image_id = 'img_' . $post_id . '_' . time() . '_hero'; - $image_manager->save_image_recommendation( - $post_id, - $agent_image_id, - 'hero', - $title, - 'Editorial hero image illustrating: ' . $title . '. Documentary style, natural lighting, high detail.', - 'Ilustrasi utama artikel: ' . $title - ); - $seeded++; - } - - $headings = array(); - if ( preg_match_all( '/]*>(.*?)<\/h[2-4]>/i', $post_content, $matches ) ) { - foreach ( $matches[1] as $heading ) { - $clean = trim( wp_strip_all_tags( $heading ) ); - if ( '' !== $clean ) { - $headings[] = $clean; - } - if ( count( $headings ) >= ( $max_images - 1 ) ) { - break; - } - } - } - - foreach ( $headings as $index => $heading ) { - $agent_image_id = 'img_' . $post_id . '_' . time() . '_sec_' . ( $index + 1 ); - $image_manager->save_image_recommendation( - $post_id, - $agent_image_id, - 'section_' . ( $index + 1 ), - $heading, - 'Contextual supporting image for section "' . $heading . '". Realistic scene, informative composition, editorial quality.', - 'Gambar pendukung untuk bagian: ' . $heading - ); - $seeded++; - } - - return $seeded > 0; - } - - /** - * Ensure recommendations exist for every unresolved image block. - * - * @param int $post_id Post ID. - * @param string $post_content Post content. - * @param string $post_title Post title. - * @return void - */ - private function ensure_recommendations_for_image_blocks( $post_id, $post_content, $post_title ) { - $image_manager = WP_Agentic_Writer_Image_Manager::get_instance(); - $current_images = $image_manager->get_image_recommendations( $post_id ); - $current_count = is_array( $current_images ) ? count( $current_images ) : 0; - - $image_slots = $this->extract_unresolved_image_slots( $post_content ); - $target_count = count( $image_slots ); - if ( $target_count <= $current_count ) { - return; - } - - $fallback_title = trim( wp_strip_all_tags( (string) $post_title ) ); - for ( $i = $current_count; $i < $target_count; $i++ ) { - $slot_title = isset( $image_slots[ $i ]['section_title'] ) ? $image_slots[ $i ]['section_title'] : ''; - $section_title = '' !== $slot_title ? $slot_title : ( '' !== $fallback_title ? $fallback_title : 'Article Section' ); - $agent_image_id = 'img_' . $post_id . '_' . time() . '_slot_' . ( $i + 1 ); - $focus_variants = array( - 'establishing scene', - 'close-up detail', - 'human activity and impact', - 'before-and-after comparison', - 'infographic-like composition', - ); - $focus = $focus_variants[ $i % count( $focus_variants ) ]; - $prompt = 'Contextual image for section "' . $section_title . '" with focus on ' . $focus . '. Realistic editorial style, informative composition, natural lighting, high detail.'; - - $image_manager->save_image_recommendation( - $post_id, - $agent_image_id, - 'slot_' . ( $i + 1 ), - $section_title, - $prompt, - 'Gambar untuk bagian: ' . $section_title - ); - } - } - - /** - * Extract unresolved image slots with nearest heading context. - * - * @param string $post_content Post content. - * @return array - */ - private function extract_unresolved_image_slots( $post_content ) { - $slots = array(); - $blocks = parse_blocks( (string) $post_content ); - - $walk = function( $items, $heading_context = '' ) use ( &$walk, &$slots ) { - foreach ( $items as $block ) { - $name = $block['blockName'] ?? ''; - $attrs = $block['attrs'] ?? array(); - - if ( 'core/heading' === $name ) { - $heading = ''; - if ( ! empty( $attrs['content'] ) ) { - $heading = trim( wp_strip_all_tags( (string) $attrs['content'] ) ); - } elseif ( ! empty( $block['innerHTML'] ) ) { - $heading = trim( wp_strip_all_tags( (string) $block['innerHTML'] ) ); - } - if ( '' !== $heading ) { - $heading_context = $heading; - } - } - - if ( 'core/image' === $name ) { - $image_id = isset( $attrs['id'] ) ? (int) $attrs['id'] : 0; - if ( $image_id <= 0 ) { - $slots[] = array( - 'section_title' => $heading_context, - ); - } - } - - if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { - $walk( $block['innerBlocks'], $heading_context ); - } - } - }; - - $walk( $blocks, '' ); - return $slots; - } - - /** - * 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; - - if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - $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' ); - - if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - $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 ); - } - - /** - * Handle multi-pass refinement request. - * - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_refine_multi_pass( $request ) { - $params = $request->get_json_params(); - $pass = $params['pass'] ?? 'clarity'; - $blocks = $params['blocks'] ?? array(); - $focus_keyword = $params['focusKeyword'] ?? ''; - $post_id = $params['postId'] ?? 0; - - // Check post permission before using postId for cost tracking. - if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - $pass_prompts = array( - 'clarity' => 'Improve the clarity, readability, and flow of this content. Make sentences clearer, remove ambiguity, and ensure smooth transitions between ideas.', - 'seo' => 'Optimize this content for SEO. Naturally incorporate the focus keyword "%s" where appropriate. Ensure good keyword density (1-2.5%), include variations of the keyword, and maintain readability.', - 'quality' => 'Enhance the overall quality of this content. Check for grammar, spelling, and punctuation errors. Improve sentence structure and word choice. Ensure consistent tone throughout.', - ); - - $prompt = $pass_prompts[$pass] ?? $pass_prompts['clarity']; - if ($pass === 'seo' && $focus_keyword) { - $prompt = sprintf($prompt, $focus_keyword); - } - - // Extract text from blocks - $content = ''; - foreach ($blocks as $block) { - $content .= $this->extract_block_content_from_attrs($block['name'] ?? 'core/paragraph', $block['attributes'] ?? array()) . "\n\n"; - } - - if (empty(trim($content))) { - return new WP_Error('empty_content', 'No content to refine', array('status' => 400)); - } - - $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task('refinement'); - $provider = $provider_result->provider; - $messages = array( - array( - 'role' => 'user', - 'content' => $prompt . "\n\nContent to refine:\n\n" . $content, - ), - ); - - $response = $provider->chat($messages, array(), 'refinement'); - - if (is_wp_error($response)) { - // Track failed attempt for observability. - $this->track_ai_cost( - $post_id, - WPAW_Model_Registry::get_default_model( 'refinement' ), - 'refine_multi_pass', - 0, - 0, - 0, - $provider_result, - '', - 'error' - ); - return $response; - } - - // Track cost. - $this->track_ai_cost( - $post_id, - $response['model'] ?? '', - 'refine_multi_pass', - $response['input_tokens'] ?? 0, - $response['output_tokens'] ?? 0, - $response['cost'] ?? 0, - $provider_result, - '', - 'success' - ); - - return new WP_REST_Response( - array( - 'pass' => $pass, - 'refined_content' => $response['content'] ?? '', - 'cost' => $response['cost'] ?? 0, - 'provider_metadata' => $this->build_provider_metadata( - $provider_result, - $response['model'] ?? '' - ), - ), - 200 - ); - } - - /** - * Handle article-wide refinement request. - * - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_refine_article( $request ) { - $params = $request->get_json_params(); - $instructions = $params['instructions'] ?? 'Improve overall quality'; - $blocks = $params['blocks'] ?? array(); - $post_id = $params['postId'] ?? 0; - - // Extract text from blocks - $content = ''; - $block_count = 0; - foreach ($blocks as $block) { - $block_content = $this->extract_block_content_from_attrs($block['name'] ?? 'core/paragraph', $block['attributes'] ?? array()); - if (!empty(trim($block_content))) { - $content .= "[Block " . ($block_count + 1) . "]\n" . $block_content . "\n\n"; - $block_count++; - } - } - - if (empty(trim($content))) { - return new WP_Error('empty_content', 'No content to refine', array('status' => 400)); - } - - // Check post permission if post_id is provided. - if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - $prompt = "Review and improve the following article content based on these instructions: " . $instructions . "\n\n"; - $prompt .= "IMPORTANT: Return the improved content preserving all block structure using this exact format:\n"; - $prompt .= "- Start each block with [Block N] on its own line\n"; - $prompt .= "- Keep the same number of blocks as the original\n"; - $prompt .= "- Preserve any code blocks, lists, or formatting within each block\n\n"; - $prompt .= "Original content:\n\n" . $content; - - $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task('refinement'); - $provider = $provider_result->provider; - $messages = array( - array( - 'role' => 'user', - 'content' => $prompt, - ), - ); - - $response = $provider->chat($messages, array(), 'refinement'); - - if (is_wp_error($response)) { - // Track failed attempt for observability. - $this->track_ai_cost( - $post_id, - WPAW_Model_Registry::get_default_model( 'refinement' ), - 'refine_article', - 0, - 0, - 0, - $provider_result, - '', - 'error' - ); - return $response; - } - - // Parse response back to blocks format - $refined_blocks = $this->parse_refined_blocks($response['content'] ?? '', $block_count); - - // Track cost. - $this->track_ai_cost( - $post_id, - $response['model'] ?? '', - 'refine_article', - $response['input_tokens'] ?? 0, - $response['output_tokens'] ?? 0, - $response['cost'] ?? 0, - $provider_result, - '', - 'success' - ); - - return new WP_REST_Response( - array( - 'blocks' => $refined_blocks, - 'count' => count($refined_blocks), - 'cost' => $response['cost'] ?? 0, - 'provider_metadata' => $this->build_provider_metadata( - $provider_result, - $response['model'] ?? '' - ), - ), - 200 - ); - } - - /** - * Parse refined blocks from AI response. - * - * @param string $content AI response content. - * @param int $expected_count Expected number of blocks. - * @return array Array of block contents. - */ - private function parse_refined_blocks( $content, $expected_count = 0 ) { - $blocks = array(); - - // Split by [Block N] markers - $parts = preg_split('/\[Block\s*\d+\]/i', $content); - - // First part is usually empty or intro text, skip it - array_shift($parts); - - foreach ($parts as $part) { - $block_content = trim($part); - if (!empty($block_content)) { - $blocks[] = $block_content; - } - } - - // If parsing didn't work well, return the whole content as single block - if (empty($blocks) && !empty(trim($content))) { - $blocks[] = trim($content); - } - - return $blocks; - } - - /** - * Handle GEO (Generative Engine Optimization) scoring request. - * - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error Response. - */ - public function handle_geo_score( $request ) { - $post_id = isset( $request['post_id'] ) ? (int) $request['post_id'] : 0; - if ( $post_id <= 0 ) { - return new WP_Error( 'invalid_post', 'Invalid post ID', array( 'status' => 400 ) ); - } - - $post = get_post( $post_id ); - if ( ! $post ) { - return new WP_Error( 'post_not_found', 'Post not found', array( 'status' => 404 ) ); - } - - $post_config = $this->get_post_config( $post_id ); - $content = wp_strip_all_tags( $post->post_content ); - $title = $post->post_title; - - $geo = array( - 'score' => 0, - 'max_score' => 100, - 'rating' => 'poor', - 'checks' => array(), - 'suggestions' => array(), - ); - - $total_checks = 0; - $total_score = 0; - - // Check 1: Directness - Does the content answer questions directly? - $total_checks++; - $directness_indicators = array( - 'this article', 'in this guide', 'in this post', 'here\'s how', 'here\'s what', - 'the best way', 'how to', 'step by step', 'in this tutorial', 'learn how' - ); - $directness_count = 0; - foreach ( $directness_indicators as $indicator ) { - $directness_count += substr_count( strtolower( $content ), $indicator ); - } - - if ( $directness_count >= 2 ) { - $geo['checks'][] = array( - 'name' => 'Directness', - 'status' => 'good', - 'message' => 'Content provides direct answers', - 'score' => 20, - ); - $total_score += 20; - } elseif ( $directness_count >= 1 ) { - $geo['checks'][] = array( - 'name' => 'Directness', - 'status' => 'ok', - 'message' => 'Some direct answers found, consider being more explicit', - 'score' => 12, - ); - $total_score += 12; - } else { - $geo['checks'][] = array( - 'name' => 'Directness', - 'status' => 'warning', - 'message' => 'Content may be too indirect. Add clear intro sentences that directly address the topic.', - 'score' => 5, - ); - $total_score += 5; - $geo['suggestions'][] = 'Start with a clear statement: "This guide explains how to [topic]" or "In this article, you\'ll learn [benefit]"'; - } - - // Check 2: Structure - Is the content well-organized with clear headings? - $total_checks++; - $heading_count = preg_match_all( '/]*>/i', $post->post_content, $matches ); - $paragraph_count = preg_match_all( '/]*>/i', $post->post_content, $matches ); - - if ( $heading_count >= 3 && $paragraph_count >= 5 ) { - $geo['checks'][] = array( - 'name' => 'Structure', - 'status' => 'good', - 'message' => "Excellent structure with {$heading_count} headings and {$paragraph_count} paragraphs", - 'score' => 20, - ); - $total_score += 20; - } elseif ( $heading_count >= 1 ) { - $geo['checks'][] = array( - 'name' => 'Structure', - 'status' => 'ok', - 'message' => 'Basic structure present, consider adding more subheadings', - 'score' => 12, - ); - $total_score += 12; - } else { - $geo['checks'][] = array( - 'name' => 'Structure', - 'status' => 'warning', - 'message' => 'Content lacks structure. Add clear H2/H3 headings to break up content.', - 'score' => 5, - ); - $total_score += 5; - $geo['suggestions'][] = 'Add H2 headings every 200-300 words to organize content into scannable sections'; - } - - // Check 3: Authority - Does the content demonstrate expertise? - $total_checks++; - $authority_indicators = array( - 'experience', 'years', 'research', 'study', 'according to', 'expert', - 'professional', 'certified', 'proven', 'tested', 'verified' - ); - $authority_count = 0; - foreach ( $authority_indicators as $indicator ) { - $authority_count += substr_count( strtolower( $content ), $indicator ); - } - - if ( $authority_count >= 3 ) { - $geo['checks'][] = array( - 'name' => 'Authority', - 'status' => 'good', - 'message' => 'Content demonstrates strong expertise', - 'score' => 20, - ); - $total_score += 20; - } elseif ( $authority_count >= 1 ) { - $geo['checks'][] = array( - 'name' => 'Authority', - 'status' => 'ok', - 'message' => 'Some authority signals present', - 'score' => 12, - ); - $total_score += 12; - } else { - $geo['checks'][] = array( - 'name' => 'Authority', - 'status' => 'warning', - 'message' => 'Content lacks authority signals. Add experience, research, or expert references.', - 'score' => 5, - ); - $total_score += 5; - $geo['suggestions'][] = 'Add phrases like "Based on years of experience", "Research shows", or "Experts recommend"'; - } - - // Check 4: Clarity - Is the content easy to understand? - $total_checks++; - $word_count = str_word_count( $content ); - $sentence_count = preg_match_all( '/[.!?]+/', $content ); - $avg_sentence_length = $sentence_count > 0 ? $word_count / $sentence_count : 0; - - // Count complex words (7+ characters) - $words = preg_split( '/\s+/', $content ); - $complex_words = 0; - foreach ( $words as $word ) { - $clean_word = preg_replace( '/[^a-zA-Z]/', '', $word ); - if ( strlen( $clean_word ) >= 7 ) { - $complex_words++; - } - } - $flesch_score = $word_count > 0 ? 206.835 - (1.015 * ($word_count / max( 1, $sentence_count ))) - (84.6 * ($complex_words / $word_count)) : 0; - $readability = $flesch_score >= 60 ? 'good' : ( $flesch_score >= 40 ? 'ok' : 'complex' ); - - if ( $readability === 'good' ) { - $geo['checks'][] = array( - 'name' => 'Clarity', - 'status' => 'good', - 'message' => sprintf( 'Excellent readability (Flesch: %.0f)', $flesch_score ), - 'score' => 20, - ); - $total_score += 20; - } elseif ( $readability === 'ok' ) { - $geo['checks'][] = array( - 'name' => 'Clarity', - 'status' => 'ok', - 'message' => sprintf( 'Average readability (Flesch: %.0f)', $flesch_score ), - 'score' => 12, - ); - $total_score += 12; - } else { - $geo['checks'][] = array( - 'name' => 'Clarity', - 'status' => 'warning', - 'message' => sprintf( 'Complex text (Flesch: %.0f). Consider shorter sentences.', $flesch_score ), - 'score' => 5, - ); - $total_score += 5; - $geo['suggestions'][] = 'Break long sentences into shorter ones. Aim for 15-20 words per sentence average.'; - } - - // Check 5: Completeness - Does the content cover the topic thoroughly? - $total_checks++; - $focus_keyword = $post_config['seo_focus_keyword'] ?? ''; - - if ( ! empty( $focus_keyword ) ) { - $keyword_in_intro = stripos( substr( $content, 0, 200 ), $focus_keyword ) !== false; - $keyword_in_conclusion = stripos( substr( $content, -200 ), $focus_keyword ) !== false; - $keyword_count = substr_count( strtolower( $content ), strtolower( $focus_keyword ) ); - $keyword_density = $word_count > 0 ? ($keyword_count / $word_count) * 100 : 0; - - if ( $keyword_in_intro && $keyword_in_conclusion && $keyword_density >= 0.5 ) { - $geo['checks'][] = array( - 'name' => 'Completeness', - 'status' => 'good', - 'message' => 'Topic covered comprehensively with keyword in intro and conclusion', - 'score' => 20, - ); - $total_score += 20; - } elseif ( $keyword_density >= 0.5 ) { - $geo['checks'][] = array( - 'name' => 'Completeness', - 'status' => 'ok', - 'message' => 'Topic covered but improve keyword placement', - 'score' => 12, - ); - $total_score += 12; - } else { - $geo['checks'][] = array( - 'name' => 'Completeness', - 'status' => 'warning', - 'message' => 'Topic may not be fully covered. Ensure keyword appears in intro, body, and conclusion.', - 'score' => 5, - ); - $total_score += 5; - $geo['suggestions'][] = 'Include focus keyword in your introduction and conclusion paragraph'; - } - } else { - $geo['checks'][] = array( - 'name' => 'Completeness', - 'status' => 'ok', - 'message' => 'Focus keyword not set - cannot fully assess completeness', - 'score' => 10, - ); - $total_score += 10; - $geo['suggestions'][] = 'Set a focus keyword to enable full GEO analysis'; - } - - // Calculate final score - $geo['score'] = $total_score; - - // Determine rating - if ( $geo['score'] >= 80 ) { - $geo['rating'] = 'excellent'; - } elseif ( $geo['score'] >= 60 ) { - $geo['rating'] = 'good'; - } elseif ( $geo['score'] >= 40 ) { - $geo['rating'] = 'fair'; - } else { - $geo['rating'] = 'poor'; - } - - // Add AI Overview eligibility note - $geo['ai_overview_eligible'] = $geo['score'] >= 80; - - return new WP_REST_Response( $geo, 200 ); - } - - /** - * Handle generate title request. - * - * Uses WordPress 7.0 AI Client when available, falls back to legacy. - * - * @since 0.1.4 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error - */ - public function handle_generate_title( $request ) { - $params = $request->get_json_params(); - $content = sanitize_textarea_field( $params['content'] ?? '' ); - - if ( empty( $content ) ) { - return new WP_Error( - 'missing_content', - __( 'Content is required for title generation.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - $options = array( - 'post_id' => isset( $params['post_id'] ) ? (int) $params['post_id'] : 0, - ); - - $client = WPAW_WP_AI_Client::get_instance(); - $result = $client->generate_title( $content, $options ); - - if ( is_wp_error( $result ) ) { - return $result; - } - - return new WP_REST_Response( - array( - 'title' => $result, - 'source' => $client->get_ai_mode(), - ), - 200 - ); - } - - /** - * Handle generate excerpt request. - * - * Uses WordPress 7.0 AI Client when available, falls back to legacy. - * - * @since 0.1.4 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error - */ - public function handle_generate_excerpt( $request ) { - $params = $request->get_json_params(); - $content = sanitize_textarea_field( $params['content'] ?? '' ); - - if ( empty( $content ) ) { - return new WP_Error( - 'missing_content', - __( 'Content is required for excerpt generation.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - $options = array( - 'post_id' => isset( $params['post_id'] ) ? (int) $params['post_id'] : 0, - ); - - $client = WPAW_WP_AI_Client::get_instance(); - $result = $client->generate_excerpt( $content, $options ); - - if ( is_wp_error( $result ) ) { - return $result; - } - - return new WP_REST_Response( - array( - 'excerpt' => $result, - 'source' => $client->get_ai_mode(), - ), - 200 - ); - } - - /** - * Handle get AI capabilities request. - * - * Returns the current AI capabilities based on available providers. - * - * @since 0.1.4 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response - */ - public function handle_get_ai_capabilities( $request ) { - $client = WPAW_WP_AI_Client::get_instance(); - $capabilities = $client->get_capabilities(); - - return new WP_REST_Response( $capabilities, 200 ); - } - - /** - * Handle search request for research. - * - * Uses Brave Search API for web search results. - * - * @since 0.1.4 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error - */ - public function handle_search( $request ) { - $params = $request->get_json_params(); - $query = sanitize_text_field( $params['query'] ?? '' ); - $count = isset( $params['count'] ) ? absint( $params['count'] ) : 5; - - if ( empty( $query ) ) { - return new WP_Error( - 'missing_query', - __( 'Search query is required.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - $brave = WP_Agentic_Writer_Brave_Search_API::get_instance(); - $results = $brave->search( $query, $count ); - - if ( is_wp_error( $results ) ) { - return $results; - } - - return new WP_REST_Response( - array( - 'query' => $query, - 'results' => $results, - 'count' => count( $results ), - ), - 200 - ); - } - - /** - * Handle fetch content request for research. - * - * Fetches and extracts content from a URL for AI context. - * - * @since 0.1.4 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error - */ - public function handle_fetch_content( $request ) { - $params = $request->get_json_params(); - $url = esc_url_raw( $params['url'] ?? '' ); - - if ( empty( $url ) ) { - return new WP_Error( - 'missing_url', - __( 'URL is required.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - // Validate URL format - if ( ! wp_http_validate_url( $url ) ) { - return new WP_Error( - 'invalid_url', - __( 'Invalid URL provided.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - // Fetch the content - $response = wp_remote_get( - $url, - array( - 'timeout' => 20, - 'user-agent' => 'Mozilla/5.0 (compatible; WP-Agentic-Writer/1.0)', - ) - ); - - if ( is_wp_error( $response ) ) { - return $response; - } - - $http_code = wp_remote_retrieve_response_code( $response ); - if ( $http_code !== 200 ) { - return new WP_Error( - 'fetch_failed', - sprintf( __( 'Failed to fetch URL (HTTP %d).', 'wp-agentic-writer' ), $http_code ), - array( 'status' => $http_code ) - ); - } - - $body = wp_remote_retrieve_body( $response ); - - // Strip HTML tags and get clean text - $content = wp_strip_all_tags( $body ); - - // Truncate to prevent token overflow (max ~4000 chars for context) - if ( strlen( $content ) > 4000 ) { - $content = substr( $content, 0, 4000 ) . '...'; - } - - return new WP_REST_Response( - array( - 'url' => $url, - 'content' => $content, - 'length' => strlen( $content ), - ), - 200 - ); - } - - /** - * Handle research summary request. - * - * Performs multiple searches and generates a research summary. - * - * @since 0.1.4 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error - */ - public function handle_research_summary( $request ) { - $params = $request->get_json_params(); - $topic = sanitize_text_field( $params['topic'] ?? '' ); - $depth = sanitize_text_field( $params['depth'] ?? 'basic' ); - $include_urls = isset( $params['include_urls'] ) ? (bool) $params['include_urls'] : false; - - if ( empty( $topic ) ) { - return new WP_Error( - 'missing_topic', - __( 'Research topic is required.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - // Determine search count based on depth - $search_counts = array( - 'basic' => 3, - 'medium' => 5, - 'deep' => 8, - ); - $count = $search_counts[ $depth ] ?? 3; - - $brave = WP_Agentic_Writer_Brave_Search_API::get_instance(); - - // Perform main search - $main_results = $brave->search( $topic, $count ); - - if ( is_wp_error( $main_results ) ) { - return $main_results; - } - - $research_data = array( - 'topic' => $topic, - 'depth' => $depth, - 'search_results' => $main_results, - 'formatted_context' => $brave->format_results_for_llm( $main_results, $topic ), - ); - - // Optionally fetch content from top URLs - if ( $include_urls && ! empty( $main_results ) ) { - $fetched_content = array(); - $max_urls = min( 2, count( $main_results ) ); // Limit to 2 URLs - - for ( $i = 0; $i < $max_urls; $i++ ) { - $url = $main_results[ $i ]['url'] ?? ''; - if ( empty( $url ) ) { - continue; - } - - $fetch_response = wp_remote_get( - $url, - array( - 'timeout' => 15, - 'user-agent' => 'Mozilla/5.0 (compatible; WP-Agentic-Writer/1.0)', - ) - ); - - if ( ! is_wp_error( $fetch_response ) && 200 === wp_remote_retrieve_response_code( $fetch_response ) ) { - $body = wp_remote_retrieve_body( $fetch_response ); - $content = wp_strip_all_tags( $body ); - - if ( strlen( $content ) > 2000 ) { - $content = substr( $content, 0, 2000 ) . '...'; - } - - $fetched_content[] = array( - 'title' => $main_results[ $i ]['title'], - 'url' => $url, - 'excerpt' => $content, - ); - } - } - - $research_data['fetched_content'] = $fetched_content; - } - - return new WP_REST_Response( $research_data, 200 ); - } - - /** - * Handle get conversations list request. - * - * @since 0.1.4 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error - */ - public function handle_get_conversations( $request ) { - $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); - - $status = sanitize_text_field( $request->get_param( 'status' ) ?: 'active' ); - $limit = (int) $request->get_param( 'limit' ) ?: 20; - $post_id = (int) $request->get_param( 'post_id' ) ?: 0; - - // If post_id is specified, check authorization before returning session. - if ( $post_id > 0 ) { - // Authorization: User must be able to edit this post. - if ( ! current_user_can( 'edit_post', $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - $sessions = $manager->get_sessions_for_post( $post_id ); - return new WP_REST_Response( - array( - 'sessions' => $sessions, - 'count' => count( $sessions ), - ), - 200 - ); - } - - if ( $request->get_param( 'uncompleted' ) ) { - $sessions = $manager->get_uncompleted_sessions( $limit ); - } else { - $sessions = $manager->get_user_sessions( $status, $limit ); - } - - return new WP_REST_Response( - array( - 'sessions' => $sessions, - 'count' => count( $sessions ), - ), - 200 - ); - } - - /** - * Handle create conversation request. - * - * @since 0.1.4 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error - */ - public function handle_create_conversation( $request ) { - $params = $request->get_json_params(); - $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); - - $post_id = isset( $params['post_id'] ) ? (int) $params['post_id'] : 0; - $focus_keyword = isset( $params['focus_keyword'] ) ? sanitize_text_field( $params['focus_keyword'] ) : ''; - $title = isset( $params['title'] ) ? sanitize_text_field( $params['title'] ) : ''; - - // Authorization: If linking to a post, check edit permission. - if ( $post_id > 0 && ! current_user_can( 'edit_post', $post_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to create a session for this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - if ( '' === $title && $post_id > 0 ) { - $post = get_post( $post_id ); - $base_title = $post ? sanitize_text_field( $post->post_title ) : ''; - if ( '' === $base_title ) { - $base_title = 'Conversation'; - } - $title = sprintf( '%s - %s', $base_title, current_time( 'Y-m-d H:i' ) ); - } - - $session_id = $manager->create_session( array( - 'post_id' => $post_id, - 'focus_keyword' => $focus_keyword, - 'title' => $title, - ) ); - - if ( is_wp_error( $session_id ) ) { - return $session_id; - } - - $session = $manager->get_session( $session_id ); - - return new WP_REST_Response( $session, 201 ); - } - - /** - * Handle get single conversation request. - * - * @since 0.1.4 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error - */ - public function handle_get_conversation( $request ) { - $session_id = sanitize_text_field( $request->get_param( 'session_id' ) ); - $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); - - // Check authorization - if ( ! $manager->current_user_can_access( $session_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to access this conversation.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - $session = $manager->get_session( $session_id ); - - if ( ! $session ) { - return new WP_Error( - 'not_found', - __( 'Conversation not found.', 'wp-agentic-writer' ), - array( 'status' => 404 ) - ); - } - - $session = $this->hydrate_session_plan_messages( $session ); - - return new WP_REST_Response( $session, 200 ); - } - - /** - * Restore rich plan UI payloads for sessions that only stored a text summary. - * - * @since 0.2.2 - * @param array $session Conversation session. - * @return array - */ - private function hydrate_session_plan_messages( $session ) { - if ( ! is_array( $session ) ) { - return $session; - } - - $post_id = isset( $session['post_id'] ) ? (int) $session['post_id'] : 0; - if ( $post_id <= 0 || empty( $session['messages'] ) || ! is_array( $session['messages'] ) ) { - return $session; - } - - foreach ( $session['messages'] as $message ) { - if ( isset( $message['type'] ) && 'plan' === $message['type'] && ! empty( $message['plan'] ) ) { - return $session; - } - } - - $plan = get_post_meta( $post_id, '_wpaw_plan', true ); - if ( ! is_array( $plan ) ) { - return $session; - } - - foreach ( $session['messages'] as $index => $message ) { - $content = isset( $message['content'] ) ? (string) $message['content'] : ''; - $role = isset( $message['role'] ) ? (string) $message['role'] : ''; - if ( 'assistant' !== $role || false === strpos( $content, 'Outline ready.' ) ) { - continue; - } - - $session['messages'][ $index ]['type'] = 'plan'; - $session['messages'][ $index ]['plan'] = $plan; - break; - } - - return $session; - } - - /** - * Handle update conversation request. - * - * @since 0.1.4 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error - */ - public function handle_update_conversation( $request ) { - $params = $request->get_json_params(); - $session_id = sanitize_text_field( $request->get_param( 'session_id' ) ); - $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); - - // Check authorization - if ( ! $manager->current_user_can_access( $session_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to modify this conversation.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - $session = $manager->get_session( $session_id ); - - if ( ! $session ) { - return new WP_Error( - 'not_found', - __( 'Conversation not found.', 'wp-agentic-writer' ), - array( 'status' => 404 ) - ); - } - - // Update fields - if ( isset( $params['title'] ) ) { - $manager->update_title( $session_id, $params['title'] ); - } - - if ( isset( $params['focus_keyword'] ) ) { - $manager->update_focus_keyword( $session_id, $params['focus_keyword'] ); - } - - if ( isset( $params['status'] ) ) { - if ( $params['status'] === 'completed' ) { - $manager->mark_completed( $session_id ); - } - } - - $updated_session = $manager->get_session( $session_id ); - - return new WP_REST_Response( $updated_session, 200 ); - } - - /** - * Handle delete conversation request. - * - * @since 0.1.4 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error - */ - public function handle_delete_conversation( $request ) { - $session_id = sanitize_text_field( $request->get_param( 'session_id' ) ); - $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); - - // Check authorization - if ( ! $manager->current_user_can_access( $session_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to delete this conversation.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - $result = $manager->delete_session( $session_id ); - - if ( ! $result ) { - return new WP_Error( - 'delete_failed', - __( 'Failed to delete conversation.', 'wp-agentic-writer' ), - array( 'status' => 500 ) - ); - } - - return new WP_REST_Response( - array( 'deleted' => true ), - 200 - ); - } - - /** - * Handle update conversation messages request. - * - * @since 0.1.4 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error - */ - public function handle_update_conversation_messages( $request ) { - $params = $request->get_json_params(); - $session_id = sanitize_text_field( $request->get_param( 'session_id' ) ); - $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); - - // Check authorization - if ( ! $manager->current_user_can_access( $session_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have permission to modify this conversation.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - $session = $manager->get_session( $session_id ); - - if ( ! $session ) { - return new WP_Error( - 'not_found', - __( 'Conversation not found.', 'wp-agentic-writer' ), - array( 'status' => 404 ) - ); - } - - $messages = isset( $params['messages'] ) ? $params['messages'] : array(); - - if ( ! is_array( $messages ) ) { - return new WP_Error( - 'invalid_messages', - __( 'Messages must be an array.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - $updated = $manager->update_messages( $session_id, $messages ); - if ( ! $updated ) { - return new WP_Error( - 'message_update_failed', - __( 'Failed to update conversation messages.', 'wp-agentic-writer' ), - array( 'status' => 500 ) - ); - } - - return new WP_REST_Response( - array( 'updated' => true, 'message_count' => count( $messages ) ), - 200 - ); - } - - /** - * Handle link conversation to post request. - * - * @since 0.1.4 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error - */ - public function handle_link_conversation_to_post( $request ) { - $params = $request->get_json_params(); - $session_id = sanitize_text_field( $request->get_param( 'session_id' ) ); - $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); - - // First verify user has access to this session (before linking to post). - if ( ! $manager->current_user_can_access( $session_id ) ) { - return new WP_Error( - 'forbidden', - __( 'You do not have access to this conversation.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - $session = $manager->get_session( $session_id ); - - if ( ! $session ) { - return new WP_Error( - 'not_found', - __( 'Conversation not found.', 'wp-agentic-writer' ), - array( 'status' => 404 ) - ); - } - - $post_id = isset( $params['post_id'] ) ? (int) $params['post_id'] : 0; - - if ( $post_id <= 0 ) { - return new WP_Error( - 'invalid_post', - __( 'Valid post ID is required.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - // Verify post exists and user can edit - if ( ! current_user_can( 'edit_post', $post_id ) ) { - return new WP_Error( - 'permission_denied', - __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - $manager->link_to_post( $session_id, $post_id ); - - $updated_session = $manager->get_session( $session_id ); - - return new WP_REST_Response( - array( - 'linked' => true, - 'post_id' => $post_id, - 'session' => $updated_session, - ), - 200 - ); - } - - /** - * Handle migrate chat history request. - * - * Migrates legacy _wpaw_chat_history from post meta to session table. - * - * @since 0.1.4 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error - */ - public function handle_migrate_chat_history( $request ) { - $post_id = (int) $request->get_param( 'post_id' ); - - if ( $post_id <= 0 ) { - return new WP_Error( - 'invalid_post', - __( 'Valid post ID is required.', 'wp-agentic-writer' ), - array( 'status' => 400 ) - ); - } - - // Verify post exists and user can edit - if ( ! current_user_can( 'edit_post', $post_id ) ) { - return new WP_Error( - 'permission_denied', - __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), - array( 'status' => 403 ) - ); - } - - // Use Context Service for migration - $context_service = WP_Agentic_Writer_Context_Service::get_instance(); - $result = $context_service->migrate_legacy_chat_history( $post_id ); - - if ( ! $result ) { - return new WP_Error( - 'migration_failed', - __( 'Failed to migrate chat history.', 'wp-agentic-writer' ), - array( 'status' => 500 ) - ); - } - - // Return migration status - $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); - $sessions = $manager->get_sessions_for_post( $post_id ); - - return new WP_REST_Response( - array( - 'migrated' => true, - 'post_id' => $post_id, - 'sessions_count' => count( $sessions ), - 'message' => 'Legacy chat history has been migrated to session table.', - ), - 200 - ); - } - - /** - * Auto-save post and link conversation when writing execution begins. - * - * @since 0.1.4 - * @param string $session_id Session ID. - * @param int $post_id Current post ID (can be 0). - * @return int New post ID if saved, or original if not needed. - */ - public function ensure_conversation_linked_to_post( $session_id, $post_id = 0 ) { - $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); - - // Already linked - if ( $post_id > 0 ) { - return $post_id; - } - - // Check if editor has content - if ( ! $manager->post_has_content( get_the_ID() ) ) { - // No content yet, keep as post_id = 0 - return 0; - } - - // Get current post (auto-save with placeholder title) - $current_post_id = get_the_ID(); - if ( $current_post_id && $current_post_id > 0 ) { - // Update post with placeholder title if needed - $post = get_post( $current_post_id ); - if ( $post && empty( $post->post_title ) ) { - wp_update_post( array( - 'ID' => $current_post_id, - 'post_title' => 'Draft - ' . date( 'Y-m-d H:i' ), - ) ); - } - - // Link conversation to post - $manager->link_to_post( $session_id, $current_post_id ); - - return $current_post_id; - } - - return 0; - } - - /** - * Get user preferences (per-user settings). - * - * @since 0.2.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error - */ - public function handle_get_user_preferences( $request ) { - $user_id = get_current_user_id(); - - // Return defaults if not logged in - if ( $user_id === 0 ) { - return new WP_REST_Response( - array( - 'proactive_suggestions' => true, - 'command_palette_enabled' => true, - 'outline_panel_enabled' => true, - 'auto_save_interval' => 30, - 'preferred_model' => '', - 'preferred_language' => 'auto', - 'theme' => 'dark', - ), - 200 - ); - } - - $preferences = get_user_meta( $user_id, 'wpaw_user_preferences', true ); - - // Merge with defaults - $defaults = array( - 'proactive_suggestions' => true, - 'command_palette_enabled' => true, - 'outline_panel_enabled' => true, - 'auto_save_interval' => 30, - 'preferred_model' => '', - 'preferred_language' => 'auto', - 'theme' => 'dark', - ); - - $preferences = is_array( $preferences ) ? array_merge( $defaults, $preferences ) : $defaults; - - return new WP_REST_Response( $preferences, 200 ); - } - - /** - * Save user preferences (per-user settings). - * - * @since 0.2.0 - * @param WP_REST_Request $request REST request. - * @return WP_REST_Response|WP_Error - */ - public function handle_save_user_preferences( $request ) { - $user_id = get_current_user_id(); - - if ( $user_id === 0 ) { - return new WP_Error( - 'unauthorized', - __( 'You must be logged in to save preferences.', 'wp-agentic-writer' ), - array( 'status' => 401 ) - ); - } - - $preferences = $request->get_json_params(); - - // Validate and sanitize - $sanitized = array( - 'proactive_suggestions' => ! empty( $preferences['proactive_suggestions'] ), - 'command_palette_enabled' => ! empty( $preferences['command_palette_enabled'] ), - 'outline_panel_enabled' => ! empty( $preferences['outline_panel_enabled'] ), - 'auto_save_interval' => max( 5, min( 300, (int) ( $preferences['auto_save_interval'] ?? 30 ) ) ), - 'preferred_model' => sanitize_text_field( $preferences['preferred_model'] ?? '' ), - 'preferred_language' => sanitize_text_field( $preferences['preferred_language'] ?? 'auto' ), - 'theme' => in_array( $preferences['theme'] ?? 'dark', array( 'dark', 'light' ), true ) ? $preferences['theme'] : 'dark', - ); - - update_user_meta( $user_id, 'wpaw_user_preferences', $sanitized ); - - return new WP_REST_Response( $sanitized, 200 ); - } + $messages = [ + [ + "role" => "system", + "content" => $system_prompt, + ], + [ + "role" => "user", + "content" => "Please analyze this article and suggest improvements:\n\n{$plain_content}", + ], + ]; + + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( + "clarity", + ); + $provider = $provider_result->provider; + $response = $provider->chat($messages, [], "analysis"); + + if (is_wp_error($response)) { + return $response; + } + + // Track cost with full nine-argument contract including provider attribution. + $cost = $response["cost"] ?? 0; + if ($cost > 0) { + $actual_provider = "unknown"; + if ( + is_object($provider_result) && + isset($provider_result->actual_provider) + ) { + $actual_provider = $provider_result->actual_provider; + } + + // Get session ID for this post if available. + $session_id = ""; + if ($post_id > 0) { + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + $session = $manager->get_session_by_post_id($post_id); + if ($session && isset($session["session_id"])) { + $session_id = $session["session_id"]; + } + } + + $this->track_ai_cost( + $post_id, + $response["model"] ?? "", + "analysis", + $response["input_tokens"] ?? 0, + $response["output_tokens"] ?? 0, + $cost, + $actual_provider, + $session_id, + "success", + ); + } + + // Parse JSON from response + $content = $response["content"] ?? ""; + $suggestions_json = $this->extract_json($content); + + if (null === $suggestions_json) { + // If JSON parsing fails, return a generic success with no suggestions + return new WP_REST_Response( + [ + "suggestions" => [], + "message" => + "Analysis complete but suggestions could not be parsed.", + ], + 200, + ); + } + + return new WP_REST_Response( + [ + "suggestions" => $suggestions_json["suggestions"] ?? [], + "summary" => + $suggestions_json["summary"] ?? "Analysis complete.", + "cost" => $response["cost"] ?? 0, + "provider_metadata" => $this->build_provider_metadata( + $provider_result, + $response["model"] ?? "", + ), + ], + 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"); + + if ($post_id > 0 && !$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to access this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $image_manager = WP_Agentic_Writer_Image_Manager::get_instance(); + $images = $image_manager->get_image_recommendations($post_id); + + // Block-level sync: ensure each unresolved image block has a stable + // agent id and a corresponding recommendation row. + if ($post_id > 0) { + $post = get_post($post_id); + if ($post instanceof WP_Post && !empty($post->post_content)) { + $post_config = $this->get_post_config($post_id); + if (!empty($post_config["include_images"])) { + $images = $this->sync_image_block_recommendations( + $post_id, + $post, + ); + } + } + } + + return new WP_REST_Response(["images" => $images], 200); + } + + /** + * Ensure unresolved image blocks are mapped 1:1 to recommendation rows. + * + * @param int $post_id Post ID. + * @param WP_Post $post Post object. + * @return array + */ + private function sync_image_block_recommendations($post_id, $post) + { + $image_manager = WP_Agentic_Writer_Image_Manager::get_instance(); + $post_content = (string) $post->post_content; + $blocks = parse_blocks($post_content); + $changed = false; + $slots = []; + $slot_index = 0; + $post_title = trim(wp_strip_all_tags((string) $post->post_title)); + + $walk = function (&$items, $heading_context = "") use ( + &$walk, + &$changed, + &$slots, + &$slot_index, + $post_id, + $post_title, + ) { + foreach ($items as &$block) { + $name = $block["blockName"] ?? ""; + $attrs = $block["attrs"] ?? []; + + if ("core/heading" === $name) { + $heading = ""; + if (!empty($attrs["content"])) { + $heading = trim( + wp_strip_all_tags((string) $attrs["content"]), + ); + } elseif (!empty($block["innerHTML"])) { + $heading = trim( + wp_strip_all_tags((string) $block["innerHTML"]), + ); + } + if ("" !== $heading) { + $heading_context = $heading; + } + } + + if ("core/image" === $name) { + $image_id = isset($attrs["id"]) ? (int) $attrs["id"] : 0; + if ($image_id <= 0) { + $slot_index++; + $agent_id = isset($attrs["data-agent-image-id"]) + ? trim((string) $attrs["data-agent-image-id"]) + : ""; + if ("" === $agent_id) { + $agent_id = + "img_" . + $post_id . + "_blk_" . + $slot_index . + "_" . + substr( + wp_hash(microtime(true) . wp_rand()), + 0, + 8, + ); + $attrs["data-agent-image-id"] = $agent_id; + $class_name = isset($attrs["className"]) + ? (string) $attrs["className"] + : ""; + if ( + false === strpos($class_name, "wpaw-agent-img-") + ) { + $attrs["className"] = trim( + $class_name . + " wpaw-agent-img-" . + $agent_id, + ); + } + $block["attrs"] = $attrs; + $changed = true; + } + + $slots[] = [ + "agent_image_id" => $agent_id, + "section_title" => + "" !== $heading_context + ? $heading_context + : ("" !== $post_title + ? $post_title + : "Article Section"), + "slot_index" => $slot_index, + ]; + } + } + + if ( + !empty($block["innerBlocks"]) && + is_array($block["innerBlocks"]) + ) { + $walk($block["innerBlocks"], $heading_context); + } + } + unset($block); + }; + + $walk($blocks, ""); + + if ($changed) { + $serialized = serialize_blocks($blocks); + if ($serialized !== $post_content) { + wp_update_post([ + "ID" => $post_id, + "post_content" => $serialized, + ]); + } + } + + $current_images = $image_manager->get_image_recommendations($post_id); + $by_agent_id = []; + $existing_rows = []; + if (is_array($current_images)) { + foreach ($current_images as $row) { + $key = isset($row["agent_image_id"]) + ? (string) $row["agent_image_id"] + : ""; + if ("" !== $key) { + $by_agent_id[$key] = true; + } + $existing_rows[] = $row; + } + } + + $slot_agent_ids = []; + foreach ($slots as $slot) { + $slot_agent_ids[$slot["agent_image_id"]] = true; + } + + $orphan_rows = []; + foreach ($existing_rows as $row) { + $key = isset($row["agent_image_id"]) + ? (string) $row["agent_image_id"] + : ""; + if ("" !== $key && !isset($slot_agent_ids[$key])) { + $orphan_rows[] = $row; + } + } + + $focus_variants = [ + "establishing scene", + "close-up detail", + "human activity and impact", + "before-and-after comparison", + "infographic-like composition", + ]; + + foreach ($slots as $slot) { + $agent_id = $slot["agent_image_id"]; + if (isset($by_agent_id[$agent_id])) { + continue; + } + + $focus = + $focus_variants[ + ((int) $slot["slot_index"] - 1) % count($focus_variants) + ]; + $section_title = $slot["section_title"]; + $prompt = + 'Contextual image for section "' . + $section_title . + '" with focus on ' . + $focus . + ". Realistic editorial style, informative composition, natural lighting, high detail."; + + if (!empty($orphan_rows)) { + $orphan = array_shift($orphan_rows); + if (isset($orphan["id"])) { + global $wpdb; + $table = $wpdb->prefix . "wpaw_images"; + $wpdb->update( + $table, + [ + "agent_image_id" => $agent_id, + "placement" => "slot_" . (int) $slot["slot_index"], + "section_title" => $section_title, + "prompt_initial" => $prompt, + "alt_text_initial" => + "Gambar untuk bagian: " . $section_title, + ], + [ + "id" => (int) $orphan["id"], + "post_id" => (int) $post_id, + ], + ["%s", "%s", "%s", "%s", "%s"], + ["%d", "%d"], + ); + $by_agent_id[$agent_id] = true; + continue; + } + } + + $image_manager->save_image_recommendation( + $post_id, + $agent_id, + "slot_" . (int) $slot["slot_index"], + $section_title, + $prompt, + "Gambar untuk bagian: " . $section_title, + ); + } + + $result = $image_manager->get_image_recommendations($post_id); + return is_array($result) ? $result : []; + } + + /** + * Seed deterministic image recommendations from post content. + * + * @param int $post_id Post ID. + * @param string $post_title Post title. + * @param string $post_content Post content. + * @return bool True when at least one recommendation is saved. + */ + private function seed_basic_image_recommendations( + $post_id, + $post_title, + $post_content, + ) { + $image_manager = WP_Agentic_Writer_Image_Manager::get_instance(); + $existing = $image_manager->get_image_recommendations($post_id); + if (is_array($existing) && !empty($existing)) { + return true; + } + + $max_images = 3; + $title = trim(wp_strip_all_tags((string) $post_title)); + $seeded = 0; + + if ("" !== $title) { + $agent_image_id = "img_" . $post_id . "_" . time() . "_hero"; + $image_manager->save_image_recommendation( + $post_id, + $agent_image_id, + "hero", + $title, + "Editorial hero image illustrating: " . + $title . + ". Documentary style, natural lighting, high detail.", + "Ilustrasi utama artikel: " . $title, + ); + $seeded++; + } + + $headings = []; + if ( + preg_match_all( + "/]*>(.*?)<\/h[2-4]>/i", + $post_content, + $matches, + ) + ) { + foreach ($matches[1] as $heading) { + $clean = trim(wp_strip_all_tags($heading)); + if ("" !== $clean) { + $headings[] = $clean; + } + if (count($headings) >= $max_images - 1) { + break; + } + } + } + + foreach ($headings as $index => $heading) { + $agent_image_id = + "img_" . $post_id . "_" . time() . "_sec_" . ($index + 1); + $image_manager->save_image_recommendation( + $post_id, + $agent_image_id, + "section_" . ($index + 1), + $heading, + 'Contextual supporting image for section "' . + $heading . + '". Realistic scene, informative composition, editorial quality.', + "Gambar pendukung untuk bagian: " . $heading, + ); + $seeded++; + } + + return $seeded > 0; + } + + /** + * Ensure recommendations exist for every unresolved image block. + * + * @param int $post_id Post ID. + * @param string $post_content Post content. + * @param string $post_title Post title. + * @return void + */ + private function ensure_recommendations_for_image_blocks( + $post_id, + $post_content, + $post_title, + ) { + $image_manager = WP_Agentic_Writer_Image_Manager::get_instance(); + $current_images = $image_manager->get_image_recommendations($post_id); + $current_count = is_array($current_images) ? count($current_images) : 0; + + $image_slots = $this->extract_unresolved_image_slots($post_content); + $target_count = count($image_slots); + if ($target_count <= $current_count) { + return; + } + + $fallback_title = trim(wp_strip_all_tags((string) $post_title)); + for ($i = $current_count; $i < $target_count; $i++) { + $slot_title = isset($image_slots[$i]["section_title"]) + ? $image_slots[$i]["section_title"] + : ""; + $section_title = + "" !== $slot_title + ? $slot_title + : ("" !== $fallback_title + ? $fallback_title + : "Article Section"); + $agent_image_id = + "img_" . $post_id . "_" . time() . "_slot_" . ($i + 1); + $focus_variants = [ + "establishing scene", + "close-up detail", + "human activity and impact", + "before-and-after comparison", + "infographic-like composition", + ]; + $focus = $focus_variants[$i % count($focus_variants)]; + $prompt = + 'Contextual image for section "' . + $section_title . + '" with focus on ' . + $focus . + ". Realistic editorial style, informative composition, natural lighting, high detail."; + + $image_manager->save_image_recommendation( + $post_id, + $agent_image_id, + "slot_" . ($i + 1), + $section_title, + $prompt, + "Gambar untuk bagian: " . $section_title, + ); + } + } + + /** + * Extract unresolved image slots with nearest heading context. + * + * @param string $post_content Post content. + * @return array + */ + private function extract_unresolved_image_slots($post_content) + { + $slots = []; + $blocks = parse_blocks((string) $post_content); + + $walk = function ($items, $heading_context = "") use (&$walk, &$slots) { + foreach ($items as $block) { + $name = $block["blockName"] ?? ""; + $attrs = $block["attrs"] ?? []; + + if ("core/heading" === $name) { + $heading = ""; + if (!empty($attrs["content"])) { + $heading = trim( + wp_strip_all_tags((string) $attrs["content"]), + ); + } elseif (!empty($block["innerHTML"])) { + $heading = trim( + wp_strip_all_tags((string) $block["innerHTML"]), + ); + } + if ("" !== $heading) { + $heading_context = $heading; + } + } + + if ("core/image" === $name) { + $image_id = isset($attrs["id"]) ? (int) $attrs["id"] : 0; + if ($image_id <= 0) { + $slots[] = [ + "section_title" => $heading_context, + ]; + } + } + + if ( + !empty($block["innerBlocks"]) && + is_array($block["innerBlocks"]) + ) { + $walk($block["innerBlocks"], $heading_context); + } + } + }; + + $walk($blocks, ""); + return $slots; + } + + /** + * 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; + + if ($post_id > 0 && !$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to edit this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $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(["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"); + + if ($post_id > 0 && !$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to edit this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $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); + } + + /** + * Handle multi-pass refinement request. + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_refine_multi_pass($request) + { + $params = $request->get_json_params(); + $pass = $params["pass"] ?? "clarity"; + $blocks = $params["blocks"] ?? []; + $focus_keyword = $params["focusKeyword"] ?? ""; + $post_id = $params["postId"] ?? 0; + + // Check post permission before using postId for cost tracking. + if ($post_id > 0 && !$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to edit this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $pass_prompts = [ + "clarity" => + "Improve the clarity, readability, and flow of this content. Make sentences clearer, remove ambiguity, and ensure smooth transitions between ideas.", + "seo" => + 'Optimize this content for SEO. Naturally incorporate the focus keyword "%s" where appropriate. Ensure good keyword density (1-2.5%), include variations of the keyword, and maintain readability.', + "quality" => + "Enhance the overall quality of this content. Check for grammar, spelling, and punctuation errors. Improve sentence structure and word choice. Ensure consistent tone throughout.", + ]; + + $prompt = $pass_prompts[$pass] ?? $pass_prompts["clarity"]; + if ($pass === "seo" && $focus_keyword) { + $prompt = sprintf($prompt, $focus_keyword); + } + + // Extract text from blocks + $content = ""; + foreach ($blocks as $block) { + $content .= + $this->extract_block_content_from_attrs( + $block["name"] ?? "core/paragraph", + $block["attributes"] ?? [], + ) . "\n\n"; + } + + if (empty(trim($content))) { + return new WP_Error("empty_content", "No content to refine", [ + "status" => 400, + ]); + } + + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( + "refinement", + ); + $provider = $provider_result->provider; + $messages = [ + [ + "role" => "user", + "content" => $prompt . "\n\nContent to refine:\n\n" . $content, + ], + ]; + + $response = $provider->chat($messages, [], "refinement"); + + if (is_wp_error($response)) { + // Track failed attempt for observability. + $this->track_ai_cost( + $post_id, + WPAW_Model_Registry::get_default_model("refinement"), + "refine_multi_pass", + 0, + 0, + 0, + $provider_result, + "", + "error", + ); + return $response; + } + + // Track cost. + $this->track_ai_cost( + $post_id, + $response["model"] ?? "", + "refine_multi_pass", + $response["input_tokens"] ?? 0, + $response["output_tokens"] ?? 0, + $response["cost"] ?? 0, + $provider_result, + "", + "success", + ); + + return new WP_REST_Response( + [ + "pass" => $pass, + "refined_content" => $response["content"] ?? "", + "cost" => $response["cost"] ?? 0, + "provider_metadata" => $this->build_provider_metadata( + $provider_result, + $response["model"] ?? "", + ), + ], + 200, + ); + } + + /** + * Handle article-wide refinement request. + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_refine_article($request) + { + $params = $request->get_json_params(); + $instructions = $params["instructions"] ?? "Improve overall quality"; + $blocks = $params["blocks"] ?? []; + $post_id = $params["postId"] ?? 0; + + // Extract text from blocks + $content = ""; + $block_count = 0; + foreach ($blocks as $block) { + $block_content = $this->extract_block_content_from_attrs( + $block["name"] ?? "core/paragraph", + $block["attributes"] ?? [], + ); + if (!empty(trim($block_content))) { + $content .= + "[Block " . + ($block_count + 1) . + "]\n" . + $block_content . + "\n\n"; + $block_count++; + } + } + + if (empty(trim($content))) { + return new WP_Error("empty_content", "No content to refine", [ + "status" => 400, + ]); + } + + // Check post permission if post_id is provided. + if ($post_id > 0 && !$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to edit this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $prompt = + "Review and improve the following article content based on these instructions: " . + $instructions . + "\n\n"; + $prompt .= + "IMPORTANT: Return the improved content preserving all block structure using this exact format:\n"; + $prompt .= "- Start each block with [Block N] on its own line\n"; + $prompt .= "- Keep the same number of blocks as the original\n"; + $prompt .= + "- Preserve any code blocks, lists, or formatting within each block\n\n"; + $prompt .= "Original content:\n\n" . $content; + + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( + "refinement", + ); + $provider = $provider_result->provider; + $messages = [ + [ + "role" => "user", + "content" => $prompt, + ], + ]; + + $response = $provider->chat($messages, [], "refinement"); + + if (is_wp_error($response)) { + // Track failed attempt for observability. + $this->track_ai_cost( + $post_id, + WPAW_Model_Registry::get_default_model("refinement"), + "refine_article", + 0, + 0, + 0, + $provider_result, + "", + "error", + ); + return $response; + } + + // Parse response back to blocks format + $refined_blocks = $this->parse_refined_blocks( + $response["content"] ?? "", + $block_count, + ); + + // Track cost. + $this->track_ai_cost( + $post_id, + $response["model"] ?? "", + "refine_article", + $response["input_tokens"] ?? 0, + $response["output_tokens"] ?? 0, + $response["cost"] ?? 0, + $provider_result, + "", + "success", + ); + + return new WP_REST_Response( + [ + "blocks" => $refined_blocks, + "count" => count($refined_blocks), + "cost" => $response["cost"] ?? 0, + "provider_metadata" => $this->build_provider_metadata( + $provider_result, + $response["model"] ?? "", + ), + ], + 200, + ); + } + + /** + * Parse refined blocks from AI response. + * + * @param string $content AI response content. + * @param int $expected_count Expected number of blocks. + * @return array Array of block contents. + */ + private function parse_refined_blocks($content, $expected_count = 0) + { + $blocks = []; + + // Split by [Block N] markers + $parts = preg_split("/\[Block\s*\d+\]/i", $content); + + // First part is usually empty or intro text, skip it + array_shift($parts); + + foreach ($parts as $part) { + $block_content = trim($part); + if (!empty($block_content)) { + $blocks[] = $block_content; + } + } + + // If parsing didn't work well, return the whole content as single block + if (empty($blocks) && !empty(trim($content))) { + $blocks[] = trim($content); + } + + return $blocks; + } + + /** + * Handle GEO (Generative Engine Optimization) scoring request. + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_geo_score($request) + { + $post_id = isset($request["post_id"]) ? (int) $request["post_id"] : 0; + if ($post_id <= 0) { + return new WP_Error("invalid_post", "Invalid post ID", [ + "status" => 400, + ]); + } + + $post = get_post($post_id); + if (!$post) { + return new WP_Error("post_not_found", "Post not found", [ + "status" => 404, + ]); + } + + $post_config = $this->get_post_config($post_id); + $content = wp_strip_all_tags($post->post_content); + $title = $post->post_title; + + $geo = [ + "score" => 0, + "max_score" => 100, + "rating" => "poor", + "checks" => [], + "suggestions" => [], + ]; + + $total_checks = 0; + $total_score = 0; + + // Check 1: Directness - Does the content answer questions directly? + $total_checks++; + $directness_indicators = [ + "this article", + "in this guide", + "in this post", + 'here\'s how', + 'here\'s what', + "the best way", + "how to", + "step by step", + "in this tutorial", + "learn how", + ]; + $directness_count = 0; + foreach ($directness_indicators as $indicator) { + $directness_count += substr_count(strtolower($content), $indicator); + } + + if ($directness_count >= 2) { + $geo["checks"][] = [ + "name" => "Directness", + "status" => "good", + "message" => "Content provides direct answers", + "score" => 20, + ]; + $total_score += 20; + } elseif ($directness_count >= 1) { + $geo["checks"][] = [ + "name" => "Directness", + "status" => "ok", + "message" => + "Some direct answers found, consider being more explicit", + "score" => 12, + ]; + $total_score += 12; + } else { + $geo["checks"][] = [ + "name" => "Directness", + "status" => "warning", + "message" => + "Content may be too indirect. Add clear intro sentences that directly address the topic.", + "score" => 5, + ]; + $total_score += 5; + $geo["suggestions"][] = + 'Start with a clear statement: "This guide explains how to [topic]" or "In this article, you\'ll learn [benefit]"'; + } + + // Check 2: Structure - Is the content well-organized with clear headings? + $total_checks++; + $heading_count = preg_match_all( + "/]*>/i", + $post->post_content, + $matches, + ); + $paragraph_count = preg_match_all( + "/]*>/i", + $post->post_content, + $matches, + ); + + if ($heading_count >= 3 && $paragraph_count >= 5) { + $geo["checks"][] = [ + "name" => "Structure", + "status" => "good", + "message" => "Excellent structure with {$heading_count} headings and {$paragraph_count} paragraphs", + "score" => 20, + ]; + $total_score += 20; + } elseif ($heading_count >= 1) { + $geo["checks"][] = [ + "name" => "Structure", + "status" => "ok", + "message" => + "Basic structure present, consider adding more subheadings", + "score" => 12, + ]; + $total_score += 12; + } else { + $geo["checks"][] = [ + "name" => "Structure", + "status" => "warning", + "message" => + "Content lacks structure. Add clear H2/H3 headings to break up content.", + "score" => 5, + ]; + $total_score += 5; + $geo["suggestions"][] = + "Add H2 headings every 200-300 words to organize content into scannable sections"; + } + + // Check 3: Authority - Does the content demonstrate expertise? + $total_checks++; + $authority_indicators = [ + "experience", + "years", + "research", + "study", + "according to", + "expert", + "professional", + "certified", + "proven", + "tested", + "verified", + ]; + $authority_count = 0; + foreach ($authority_indicators as $indicator) { + $authority_count += substr_count(strtolower($content), $indicator); + } + + if ($authority_count >= 3) { + $geo["checks"][] = [ + "name" => "Authority", + "status" => "good", + "message" => "Content demonstrates strong expertise", + "score" => 20, + ]; + $total_score += 20; + } elseif ($authority_count >= 1) { + $geo["checks"][] = [ + "name" => "Authority", + "status" => "ok", + "message" => "Some authority signals present", + "score" => 12, + ]; + $total_score += 12; + } else { + $geo["checks"][] = [ + "name" => "Authority", + "status" => "warning", + "message" => + "Content lacks authority signals. Add experience, research, or expert references.", + "score" => 5, + ]; + $total_score += 5; + $geo["suggestions"][] = + 'Add phrases like "Based on years of experience", "Research shows", or "Experts recommend"'; + } + + // Check 4: Clarity - Is the content easy to understand? + $total_checks++; + $word_count = str_word_count($content); + $sentence_count = preg_match_all("/[.!?]+/", $content); + $avg_sentence_length = + $sentence_count > 0 ? $word_count / $sentence_count : 0; + + // Count complex words (7+ characters) + $words = preg_split("/\s+/", $content); + $complex_words = 0; + foreach ($words as $word) { + $clean_word = preg_replace("/[^a-zA-Z]/", "", $word); + if (strlen($clean_word) >= 7) { + $complex_words++; + } + } + $flesch_score = + $word_count > 0 + ? 206.835 - + 1.015 * ($word_count / max(1, $sentence_count)) - + 84.6 * ($complex_words / $word_count) + : 0; + $readability = + $flesch_score >= 60 + ? "good" + : ($flesch_score >= 40 + ? "ok" + : "complex"); + + if ($readability === "good") { + $geo["checks"][] = [ + "name" => "Clarity", + "status" => "good", + "message" => sprintf( + "Excellent readability (Flesch: %.0f)", + $flesch_score, + ), + "score" => 20, + ]; + $total_score += 20; + } elseif ($readability === "ok") { + $geo["checks"][] = [ + "name" => "Clarity", + "status" => "ok", + "message" => sprintf( + "Average readability (Flesch: %.0f)", + $flesch_score, + ), + "score" => 12, + ]; + $total_score += 12; + } else { + $geo["checks"][] = [ + "name" => "Clarity", + "status" => "warning", + "message" => sprintf( + "Complex text (Flesch: %.0f). Consider shorter sentences.", + $flesch_score, + ), + "score" => 5, + ]; + $total_score += 5; + $geo["suggestions"][] = + "Break long sentences into shorter ones. Aim for 15-20 words per sentence average."; + } + + // Check 5: Completeness - Does the content cover the topic thoroughly? + $total_checks++; + $focus_keyword = $post_config["seo_focus_keyword"] ?? ""; + + if (!empty($focus_keyword)) { + $keyword_in_intro = + stripos(substr($content, 0, 200), $focus_keyword) !== false; + $keyword_in_conclusion = + stripos(substr($content, -200), $focus_keyword) !== false; + $keyword_count = substr_count( + strtolower($content), + strtolower($focus_keyword), + ); + $keyword_density = + $word_count > 0 ? ($keyword_count / $word_count) * 100 : 0; + + if ( + $keyword_in_intro && + $keyword_in_conclusion && + $keyword_density >= 0.5 + ) { + $geo["checks"][] = [ + "name" => "Completeness", + "status" => "good", + "message" => + "Topic covered comprehensively with keyword in intro and conclusion", + "score" => 20, + ]; + $total_score += 20; + } elseif ($keyword_density >= 0.5) { + $geo["checks"][] = [ + "name" => "Completeness", + "status" => "ok", + "message" => "Topic covered but improve keyword placement", + "score" => 12, + ]; + $total_score += 12; + } else { + $geo["checks"][] = [ + "name" => "Completeness", + "status" => "warning", + "message" => + "Topic may not be fully covered. Ensure keyword appears in intro, body, and conclusion.", + "score" => 5, + ]; + $total_score += 5; + $geo["suggestions"][] = + "Include focus keyword in your introduction and conclusion paragraph"; + } + } else { + $geo["checks"][] = [ + "name" => "Completeness", + "status" => "ok", + "message" => + "Focus keyword not set - cannot fully assess completeness", + "score" => 10, + ]; + $total_score += 10; + $geo["suggestions"][] = + "Set a focus keyword to enable full GEO analysis"; + } + + // Calculate final score + $geo["score"] = $total_score; + + // Determine rating + if ($geo["score"] >= 80) { + $geo["rating"] = "excellent"; + } elseif ($geo["score"] >= 60) { + $geo["rating"] = "good"; + } elseif ($geo["score"] >= 40) { + $geo["rating"] = "fair"; + } else { + $geo["rating"] = "poor"; + } + + // Add AI Overview eligibility note + $geo["ai_overview_eligible"] = $geo["score"] >= 80; + + return new WP_REST_Response($geo, 200); + } + + /** + * Handle generate title request. + * + * Uses WordPress 7.0 AI Client when available, falls back to legacy. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_generate_title($request) + { + $params = $request->get_json_params(); + $content = sanitize_textarea_field($params["content"] ?? ""); + + if (empty($content)) { + return new WP_Error( + "missing_content", + __( + "Content is required for title generation.", + "wp-agentic-writer", + ), + ["status" => 400], + ); + } + + $options = [ + "post_id" => isset($params["post_id"]) + ? (int) $params["post_id"] + : 0, + ]; + + $client = WPAW_WP_AI_Client::get_instance(); + $result = $client->generate_title($content, $options); + + if (is_wp_error($result)) { + return $result; + } + + return new WP_REST_Response( + [ + "title" => $result, + "source" => $client->get_ai_mode(), + ], + 200, + ); + } + + /** + * Handle generate excerpt request. + * + * Uses WordPress 7.0 AI Client when available, falls back to legacy. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_generate_excerpt($request) + { + $params = $request->get_json_params(); + $content = sanitize_textarea_field($params["content"] ?? ""); + + if (empty($content)) { + return new WP_Error( + "missing_content", + __( + "Content is required for excerpt generation.", + "wp-agentic-writer", + ), + ["status" => 400], + ); + } + + $options = [ + "post_id" => isset($params["post_id"]) + ? (int) $params["post_id"] + : 0, + ]; + + $client = WPAW_WP_AI_Client::get_instance(); + $result = $client->generate_excerpt($content, $options); + + if (is_wp_error($result)) { + return $result; + } + + return new WP_REST_Response( + [ + "excerpt" => $result, + "source" => $client->get_ai_mode(), + ], + 200, + ); + } + + /** + * Handle get AI capabilities request. + * + * Returns the current AI capabilities based on available providers. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response + */ + public function handle_get_ai_capabilities($request) + { + $client = WPAW_WP_AI_Client::get_instance(); + $capabilities = $client->get_capabilities(); + + return new WP_REST_Response($capabilities, 200); + } + + /** + * Handle search request for research. + * + * Uses Brave Search API for web search results. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_search($request) + { + $params = $request->get_json_params(); + $query = sanitize_text_field($params["query"] ?? ""); + $count = isset($params["count"]) ? absint($params["count"]) : 5; + + if (empty($query)) { + return new WP_Error( + "missing_query", + __("Search query is required.", "wp-agentic-writer"), + ["status" => 400], + ); + } + + $brave = WP_Agentic_Writer_Brave_Search_API::get_instance(); + $results = $brave->search($query, $count); + + if (is_wp_error($results)) { + return $results; + } + + return new WP_REST_Response( + [ + "query" => $query, + "results" => $results, + "count" => count($results), + ], + 200, + ); + } + + /** + * Handle fetch content request for research. + * + * Fetches and extracts content from a URL for AI context. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_fetch_content($request) + { + $params = $request->get_json_params(); + $url = esc_url_raw($params["url"] ?? ""); + + if (empty($url)) { + return new WP_Error( + "missing_url", + __("URL is required.", "wp-agentic-writer"), + ["status" => 400], + ); + } + + // Validate URL format + if (!wp_http_validate_url($url)) { + return new WP_Error( + "invalid_url", + __("Invalid URL provided.", "wp-agentic-writer"), + ["status" => 400], + ); + } + + // Fetch the content + $response = wp_remote_get($url, [ + "timeout" => 20, + "user-agent" => "Mozilla/5.0 (compatible; WP-Agentic-Writer/1.0)", + ]); + + if (is_wp_error($response)) { + return $response; + } + + $http_code = wp_remote_retrieve_response_code($response); + if ($http_code !== 200) { + return new WP_Error( + "fetch_failed", + sprintf( + __("Failed to fetch URL (HTTP %d).", "wp-agentic-writer"), + $http_code, + ), + ["status" => $http_code], + ); + } + + $body = wp_remote_retrieve_body($response); + + // Strip HTML tags and get clean text + $content = wp_strip_all_tags($body); + + // Truncate to prevent token overflow (max ~4000 chars for context) + if (strlen($content) > 4000) { + $content = substr($content, 0, 4000) . "..."; + } + + return new WP_REST_Response( + [ + "url" => $url, + "content" => $content, + "length" => strlen($content), + ], + 200, + ); + } + + /** + * Handle research summary request. + * + * Performs multiple searches and generates a research summary. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_research_summary($request) + { + $params = $request->get_json_params(); + $topic = sanitize_text_field($params["topic"] ?? ""); + $depth = sanitize_text_field($params["depth"] ?? "basic"); + $include_urls = isset($params["include_urls"]) + ? (bool) $params["include_urls"] + : false; + + if (empty($topic)) { + return new WP_Error( + "missing_topic", + __("Research topic is required.", "wp-agentic-writer"), + ["status" => 400], + ); + } + + // Determine search count based on depth + $search_counts = [ + "basic" => 3, + "medium" => 5, + "deep" => 8, + ]; + $count = $search_counts[$depth] ?? 3; + + $brave = WP_Agentic_Writer_Brave_Search_API::get_instance(); + + // Perform main search + $main_results = $brave->search($topic, $count); + + if (is_wp_error($main_results)) { + return $main_results; + } + + $research_data = [ + "topic" => $topic, + "depth" => $depth, + "search_results" => $main_results, + "formatted_context" => $brave->format_results_for_llm( + $main_results, + $topic, + ), + ]; + + // Optionally fetch content from top URLs + if ($include_urls && !empty($main_results)) { + $fetched_content = []; + $max_urls = min(2, count($main_results)); // Limit to 2 URLs + + for ($i = 0; $i < $max_urls; $i++) { + $url = $main_results[$i]["url"] ?? ""; + if (empty($url)) { + continue; + } + + $fetch_response = wp_remote_get($url, [ + "timeout" => 15, + "user-agent" => + "Mozilla/5.0 (compatible; WP-Agentic-Writer/1.0)", + ]); + + if ( + !is_wp_error($fetch_response) && + 200 === wp_remote_retrieve_response_code($fetch_response) + ) { + $body = wp_remote_retrieve_body($fetch_response); + $content = wp_strip_all_tags($body); + + if (strlen($content) > 2000) { + $content = substr($content, 0, 2000) . "..."; + } + + $fetched_content[] = [ + "title" => $main_results[$i]["title"], + "url" => $url, + "excerpt" => $content, + ]; + } + } + + $research_data["fetched_content"] = $fetched_content; + } + + return new WP_REST_Response($research_data, 200); + } + + /** + * Handle get conversations list request. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_get_conversations($request) + { + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + + $status = sanitize_text_field( + $request->get_param("status") ?: "active", + ); + $limit = (int) $request->get_param("limit") ?: 20; + $post_id = (int) $request->get_param("post_id") ?: 0; + + // If post_id is specified, check authorization before returning session. + if ($post_id > 0) { + // Authorization: User must be able to edit this post. + if (!current_user_can("edit_post", $post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to access this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $sessions = $manager->get_sessions_for_post($post_id); + return new WP_REST_Response( + [ + "sessions" => $sessions, + "count" => count($sessions), + ], + 200, + ); + } + + if ($request->get_param("uncompleted")) { + $sessions = $manager->get_uncompleted_sessions($limit); + } else { + $sessions = $manager->get_user_sessions($status, $limit); + } + + return new WP_REST_Response( + [ + "sessions" => $sessions, + "count" => count($sessions), + ], + 200, + ); + } + + /** + * Handle create conversation request. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_create_conversation($request) + { + $params = $request->get_json_params(); + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + + $post_id = isset($params["post_id"]) ? (int) $params["post_id"] : 0; + $focus_keyword = isset($params["focus_keyword"]) + ? sanitize_text_field($params["focus_keyword"]) + : ""; + $title = isset($params["title"]) + ? sanitize_text_field($params["title"]) + : ""; + + // Authorization: If linking to a post, check edit permission. + if ($post_id > 0 && !current_user_can("edit_post", $post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to create a session for this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + if ("" === $title && $post_id > 0) { + $post = get_post($post_id); + $base_title = $post ? sanitize_text_field($post->post_title) : ""; + if ("" === $base_title) { + $base_title = "Conversation"; + } + $title = sprintf("%s - %s", $base_title, current_time("Y-m-d H:i")); + } + + $session_id = $manager->create_session([ + "post_id" => $post_id, + "focus_keyword" => $focus_keyword, + "title" => $title, + ]); + + if (is_wp_error($session_id)) { + return $session_id; + } + + $session = $manager->get_session($session_id); + + // MEMANTO: New session created. + do_action( + "wpaw_memanto_session_start", + $session_id, + $post_id, + get_current_user_id(), + ); + + return new WP_REST_Response($session, 201); + } + + /** + * Handle get single conversation request. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_get_conversation($request) + { + $session_id = sanitize_text_field($request->get_param("session_id")); + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + + // Check authorization + if (!$manager->current_user_can_access($session_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to access this conversation.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $session = $manager->get_session($session_id); + + if (!$session) { + return new WP_Error( + "not_found", + __("Conversation not found.", "wp-agentic-writer"), + ["status" => 404], + ); + } + + $session = $this->hydrate_session_plan_messages($session); + + return new WP_REST_Response($session, 200); + } + + /** + * Restore rich plan UI payloads for sessions that only stored a text summary. + * + * @since 0.2.2 + * @param array $session Conversation session. + * @return array + */ + private function hydrate_session_plan_messages($session) + { + if (!is_array($session)) { + return $session; + } + + $post_id = isset($session["post_id"]) ? (int) $session["post_id"] : 0; + if ( + $post_id <= 0 || + empty($session["messages"]) || + !is_array($session["messages"]) + ) { + return $session; + } + + foreach ($session["messages"] as $message) { + if ( + isset($message["type"]) && + "plan" === $message["type"] && + !empty($message["plan"]) + ) { + return $session; + } + } + + $plan = get_post_meta($post_id, "_wpaw_plan", true); + if (!is_array($plan)) { + return $session; + } + + foreach ($session["messages"] as $index => $message) { + $content = isset($message["content"]) + ? (string) $message["content"] + : ""; + $role = isset($message["role"]) ? (string) $message["role"] : ""; + if ( + "assistant" !== $role || + false === strpos($content, "Outline ready.") + ) { + continue; + } + + $session["messages"][$index]["type"] = "plan"; + $session["messages"][$index]["plan"] = $plan; + break; + } + + return $session; + } + + /** + * Handle update conversation request. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_update_conversation($request) + { + $params = $request->get_json_params(); + $session_id = sanitize_text_field($request->get_param("session_id")); + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + + // Check authorization + if (!$manager->current_user_can_access($session_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to modify this conversation.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $session = $manager->get_session($session_id); + + if (!$session) { + return new WP_Error( + "not_found", + __("Conversation not found.", "wp-agentic-writer"), + ["status" => 404], + ); + } + + // Update fields + if (isset($params["title"])) { + $manager->update_title($session_id, $params["title"]); + } + + if (isset($params["focus_keyword"])) { + $manager->update_focus_keyword( + $session_id, + $params["focus_keyword"], + ); + } + + if (isset($params["status"])) { + if ($params["status"] === "completed") { + $manager->mark_completed($session_id); + + // MEMANTO: Session completed. + $post_id = $session["post_id"] ?? 0; + do_action( + "wpaw_memanto_session_end", + $session_id, + (int) $post_id, + ); + } + } + + $updated_session = $manager->get_session($session_id); + + return new WP_REST_Response($updated_session, 200); + } + + /** + * Handle delete conversation request. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_delete_conversation($request) + { + $session_id = sanitize_text_field($request->get_param("session_id")); + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + + // Check authorization + if (!$manager->current_user_can_access($session_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to delete this conversation.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $result = $manager->delete_session($session_id); + + if (!$result) { + return new WP_Error( + "delete_failed", + __("Failed to delete conversation.", "wp-agentic-writer"), + ["status" => 500], + ); + } + + // MEMANTO: Session deleted — treat as session end. + $session = $manager->get_session_unchecked($session_id); + $post_id = $session ? $session["post_id"] ?? 0 : 0; + do_action("wpaw_memanto_session_end", $session_id, (int) $post_id); + + return new WP_REST_Response(["deleted" => true], 200); + } + + /** + * Handle update conversation messages request. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_update_conversation_messages($request) + { + $params = $request->get_json_params(); + $session_id = sanitize_text_field($request->get_param("session_id")); + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + + // Check authorization + if (!$manager->current_user_can_access($session_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to modify this conversation.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $session = $manager->get_session($session_id); + + if (!$session) { + return new WP_Error( + "not_found", + __("Conversation not found.", "wp-agentic-writer"), + ["status" => 404], + ); + } + + $messages = isset($params["messages"]) ? $params["messages"] : []; + + if (!is_array($messages)) { + return new WP_Error( + "invalid_messages", + __("Messages must be an array.", "wp-agentic-writer"), + ["status" => 400], + ); + } + + $updated = $manager->update_messages($session_id, $messages); + if (!$updated) { + return new WP_Error( + "message_update_failed", + __( + "Failed to update conversation messages.", + "wp-agentic-writer", + ), + ["status" => 500], + ); + } + + return new WP_REST_Response( + ["updated" => true, "message_count" => count($messages)], + 200, + ); + } + + /** + * Handle link conversation to post request. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_link_conversation_to_post($request) + { + $params = $request->get_json_params(); + $session_id = sanitize_text_field($request->get_param("session_id")); + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + + // First verify user has access to this session (before linking to post). + if (!$manager->current_user_can_access($session_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have access to this conversation.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $session = $manager->get_session($session_id); + + if (!$session) { + return new WP_Error( + "not_found", + __("Conversation not found.", "wp-agentic-writer"), + ["status" => 404], + ); + } + + $post_id = isset($params["post_id"]) ? (int) $params["post_id"] : 0; + + if ($post_id <= 0) { + return new WP_Error( + "invalid_post", + __("Valid post ID is required.", "wp-agentic-writer"), + ["status" => 400], + ); + } + + // Verify post exists and user can edit + if (!current_user_can("edit_post", $post_id)) { + return new WP_Error( + "permission_denied", + __( + "You do not have permission to edit this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $manager->link_to_post($session_id, $post_id); + + $updated_session = $manager->get_session($session_id); + + return new WP_REST_Response( + [ + "linked" => true, + "post_id" => $post_id, + "session" => $updated_session, + ], + 200, + ); + } + + /** + * Handle migrate chat history request. + * + * Migrates legacy _wpaw_chat_history from post meta to session table. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_migrate_chat_history($request) + { + $post_id = (int) $request->get_param("post_id"); + + if ($post_id <= 0) { + return new WP_Error( + "invalid_post", + __("Valid post ID is required.", "wp-agentic-writer"), + ["status" => 400], + ); + } + + // Verify post exists and user can edit + if (!current_user_can("edit_post", $post_id)) { + return new WP_Error( + "permission_denied", + __( + "You do not have permission to edit this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + // Use Context Service for migration + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $result = $context_service->migrate_legacy_chat_history($post_id); + + if (!$result) { + return new WP_Error( + "migration_failed", + __("Failed to migrate chat history.", "wp-agentic-writer"), + ["status" => 500], + ); + } + + // Return migration status + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + $sessions = $manager->get_sessions_for_post($post_id); + + return new WP_REST_Response( + [ + "migrated" => true, + "post_id" => $post_id, + "sessions_count" => count($sessions), + "message" => + "Legacy chat history has been migrated to session table.", + ], + 200, + ); + } + + /** + * Auto-save post and link conversation when writing execution begins. + * + * @since 0.1.4 + * @param string $session_id Session ID. + * @param int $post_id Current post ID (can be 0). + * @return int New post ID if saved, or original if not needed. + */ + public function ensure_conversation_linked_to_post( + $session_id, + $post_id = 0, + ) { + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + + // Already linked + if ($post_id > 0) { + return $post_id; + } + + // Check if editor has content + if (!$manager->post_has_content(get_the_ID())) { + // No content yet, keep as post_id = 0 + return 0; + } + + // Get current post (auto-save with placeholder title) + $current_post_id = get_the_ID(); + if ($current_post_id && $current_post_id > 0) { + // Update post with placeholder title if needed + $post = get_post($current_post_id); + if ($post && empty($post->post_title)) { + wp_update_post([ + "ID" => $current_post_id, + "post_title" => "Draft - " . date("Y-m-d H:i"), + ]); + } + + // Link conversation to post + $manager->link_to_post($session_id, $current_post_id); + + return $current_post_id; + } + + return 0; + } + + /** + * Get user preferences (per-user settings). + * + * @since 0.2.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_get_user_preferences($request) + { + $user_id = get_current_user_id(); + + // Return defaults if not logged in + if ($user_id === 0) { + return new WP_REST_Response( + [ + "proactive_suggestions" => true, + "command_palette_enabled" => true, + "outline_panel_enabled" => true, + "auto_save_interval" => 30, + "preferred_model" => "", + "preferred_language" => "auto", + "theme" => "dark", + ], + 200, + ); + } + + $preferences = get_user_meta($user_id, "wpaw_user_preferences", true); + + // Merge with defaults + $defaults = [ + "proactive_suggestions" => true, + "command_palette_enabled" => true, + "outline_panel_enabled" => true, + "auto_save_interval" => 30, + "preferred_model" => "", + "preferred_language" => "auto", + "theme" => "dark", + ]; + + $preferences = is_array($preferences) + ? array_merge($defaults, $preferences) + : $defaults; + + return new WP_REST_Response($preferences, 200); + } + + /** + * Save user preferences (per-user settings). + * + * @since 0.2.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_save_user_preferences($request) + { + $user_id = get_current_user_id(); + + if ($user_id === 0) { + return new WP_Error( + "unauthorized", + __( + "You must be logged in to save preferences.", + "wp-agentic-writer", + ), + ["status" => 401], + ); + } + + $preferences = $request->get_json_params(); + + // Validate and sanitize + $sanitized = [ + "proactive_suggestions" => !empty( + $preferences["proactive_suggestions"] + ), + "command_palette_enabled" => !empty( + $preferences["command_palette_enabled"] + ), + "outline_panel_enabled" => !empty( + $preferences["outline_panel_enabled"] + ), + "auto_save_interval" => max( + 5, + min(300, (int) ($preferences["auto_save_interval"] ?? 30)), + ), + "preferred_model" => sanitize_text_field( + $preferences["preferred_model"] ?? "", + ), + "preferred_language" => sanitize_text_field( + $preferences["preferred_language"] ?? "auto", + ), + "theme" => in_array( + $preferences["theme"] ?? "dark", + ["dark", "light"], + true, + ) + ? $preferences["theme"] + : "dark", + ]; + + update_user_meta($user_id, "wpaw_user_preferences", $sanitized); + + // MEMANTO: Store user preferences to user agent. + do_action("wpaw_memanto_config_saved", 0, $sanitized); + + return new WP_REST_Response($sanitized, 200); + } + + /** + * Handle MEMANTO status check. + * + * Returns current MEMANTO connection status, health, and configuration + * state for the frontend sidebar indicator. + * + * @since 0.3.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response Status response. + */ + public function handle_memanto_status($request) + { + $client = WP_Agentic_Writer_Memanto_Client::get_instance(); + $settings = get_option("wp_agentic_writer_settings", []); + + $configured = $client->is_configured(); + $enabled = $client->is_enabled(); + $healthy = $configured && $enabled ? $client->is_healthy() : false; + + return new WP_REST_Response( + [ + "configured" => $configured, + "enabled" => $enabled, + "healthy" => $healthy, + "active" => $configured && $enabled && $healthy, + "url_set" => !empty($settings["memanto_url"]), + "key_set" => !empty($settings["memanto_moorcheh_key"]), + ], + 200, + ); + } + + /** + * Handle MEMANTO recall request. + * + * Returns recent memories for a post and user preferences. + * Used by the frontend to show "Restored from memory" indicator + * and to carry preferences across posts. + * + * @since 0.3.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_memanto_recall($request) + { + $post_id = (int) ($request->get_param("post_id") ?? 0); + $user_id = get_current_user_id(); + + if ($post_id > 0 && !$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to access this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $enhancer = WP_Agentic_Writer_Memanto_Context_Enhancer::get_instance(); + $memories = $enhancer->recall_for_context( + $post_id, + $user_id, + "", // No current message for restore — just recent + preferences. + ); + + // Separate preferences from other memories for frontend display. + $preferences = []; + $other = []; + foreach ($memories as $memory) { + if (($memory["type"] ?? "") === "preference") { + $preferences[] = $memory; + } else { + $other[] = $memory; + } + } + + return new WP_REST_Response( + [ + "memories" => $other, + "preferences" => $preferences, + "count" => count($memories), + "post_id" => $post_id, + ], + 200, + ); + } + + /** + * Handle MEMANTO session restore. + * + * Called when the post editor opens to restore prior session state. + * Returns recent memories + preferences + a restore summary. + * + * @since 0.4.0 + * @param WP_REST_Request $request REST request with post_id param. + * @return WP_REST_Response|WP_Error Restore payload. + */ + public function handle_memanto_restore($request) + { + $post_id = (int) ($request->get_param("post_id") ?? 0); + $user_id = get_current_user_id(); + + if ($post_id > 0 && !$this->check_post_permission($post_id)) { + return new WP_Error( + "forbidden", + __( + "You do not have permission to access this post.", + "wp-agentic-writer", + ), + ["status" => 403], + ); + } + + $enhancer = WP_Agentic_Writer_Memanto_Context_Enhancer::get_instance(); + $payload = $enhancer->restore_session($post_id, $user_id); + + // Build the restored system message for AI context. + $system_message = ""; + if (!empty($payload["restored"])) { + $system_message = $enhancer->build_session_restore_message( + $payload, + ); + } + + return new WP_REST_Response( + [ + "restored" => $payload["restored"], + "memories" => $payload["memories"], + "preferences" => $payload["preferences"], + "summary" => $payload["summary"], + "system_message" => $system_message, + "memory_count" => count($payload["memories"]), + "preference_count" => count($payload["preferences"]), + "post_id" => $post_id, + ], + 200, + ); + } + + /** + * Handle MEMANTO user preferences recall. + * + * Returns extracted preference config for new-post config carry-over. + * + * @since 0.4.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response Preference payload. + */ + public function handle_memanto_preferences($request) + { + $user_id = get_current_user_id(); + + $enhancer = WP_Agentic_Writer_Memanto_Context_Enhancer::get_instance(); + $result = $enhancer->get_user_preferences_for_new_post($user_id); + + return new WP_REST_Response( + [ + "restored" => $result["restored"], + "config" => $result["config"], + ], + 200, + ); + } } diff --git a/includes/class-memanto-client.php b/includes/class-memanto-client.php new file mode 100644 index 0000000..67faed4 --- /dev/null +++ b/includes/class-memanto-client.php @@ -0,0 +1,636 @@ +base_url = untrailingslashit($settings["memanto_url"] ?? ""); + } + + // ========================================================================= + // Configuration & Health + // ========================================================================= + + /** + * Whether MEMANTO is configured (URL + Moorcheh key set). + * + * @return bool + */ + public function is_configured() + { + return !empty($this->base_url) && !empty($this->get_moorcheh_key()); + } + + /** + * Whether MEMANTO is enabled in settings. + * + * @return bool + */ + public function is_enabled() + { + $settings = get_option("wp_agentic_writer_settings", []); + return !empty($settings["memanto_enabled"]); + } + + /** + * Whether MEMANTO is enabled, configured, and reachable. + * + * @return bool + */ + public function is_active() + { + return $this->is_enabled() && + $this->is_configured() && + $this->is_healthy(); + } + + /** + * Check MEMANTO health endpoint. Result cached for 5 minutes. + * + * @return bool True if healthy. + */ + public function is_healthy() + { + if (!$this->is_configured()) { + return false; + } + + // Use cached result if fresh (5 minutes). + if (null !== $this->health_cache) { + if (time() - $this->health_cache["checked_at"] < 300) { + return $this->health_cache["healthy"]; + } + } + + // Also check transient for cross-request caching. + $cached = get_transient("wpaw_memanto_health"); + if ( + false !== $cached && + isset($cached["checked_at"]) && + time() - $cached["checked_at"] < 300 + ) { + $this->health_cache = $cached; + return $cached["healthy"]; + } + + $response = $this->get("/health"); + + if (is_wp_error($response)) { + $this->health_cache = ["healthy" => false, "checked_at" => time()]; + set_transient("wpaw_memanto_health", $this->health_cache, 300); + return false; + } + + $healthy = + !empty($response["status"]) && "healthy" === $response["status"]; + $this->health_cache = ["healthy" => $healthy, "checked_at" => time()]; + set_transient("wpaw_memanto_health", $this->health_cache, 300); + return $healthy; + } + + /** + * Force-refresh the health check (used by Test Connection button). + * + * @return array { healthy: bool, details: array|null } + */ + public function check_health_fresh() + { + if (!$this->is_configured()) { + return ["healthy" => false, "details" => null]; + } + + delete_transient("wpaw_memanto_health"); + $this->health_cache = null; + + $response = $this->get("/health"); + + if (is_wp_error($response)) { + return [ + "healthy" => false, + "details" => ["error" => $response->get_error_message()], + ]; + } + + $healthy = + !empty($response["status"]) && "healthy" === $response["status"]; + $this->health_cache = ["healthy" => $healthy, "checked_at" => time()]; + set_transient("wpaw_memanto_health", $this->health_cache, 300); + + return ["healthy" => $healthy, "details" => $response]; + } + + // ========================================================================= + // Agent Management + // ========================================================================= + + /** + * Ensure an agent exists. Creates if not found. + * + * @param string $agent_id Agent identifier (e.g. "wp-user-1" or "wp-post-42"). + * @return bool True on success. + */ + public function ensure_agent($agent_id) + { + if (!$this->is_configured()) { + return false; + } + + // Check if agent already exists. + $agent = $this->get("/api/v2/agents/{$agent_id}"); + if (!is_wp_error($agent) && !empty($agent["agent_id"])) { + return true; + } + + // Create agent. + $result = $this->post("/api/v2/agents", [ + "agent_id" => $agent_id, + "pattern" => "support", + "description" => "WP Agentic Writer agent", + ]); + + return !is_wp_error($result); + } + + // ========================================================================= + // Session Management + // ========================================================================= + + /** + * Activate a session for an agent. Returns cached token if still valid. + * + * @param string $agent_id Agent identifier. + * @return string|false Session token or false on failure. + */ + public function activate_session($agent_id) + { + if (!$this->is_configured()) { + return false; + } + + $transient_key = "wpaw_memanto_token_" . md5($agent_id); + $cached_token = get_transient($transient_key); + + if (!empty($cached_token)) { + return $cached_token; + } + + $response = $this->post( + "/api/v2/agents/{$agent_id}/activate", + [], + $agent_id, + ); + + if (is_wp_error($response) || empty($response["session_token"])) { + wpaw_debug_log("MEMANTO activate_session failed", [ + "agent_id" => $agent_id, + ]); + return false; + } + + $token = $response["session_token"]; + $expires_at = strtotime($response["expires_at"] ?? "+6 hours"); + $ttl = max(60, $expires_at - time() - 300); // Expire 5 min before actual. + + set_transient($transient_key, $token, $ttl); + return $token; + } + + /** + * Deactivate a session for an agent. + * + * Clears the cached token and sends the deactivate request + * without injecting a session token (avoids re-activation loop). + * + * @param string $agent_id Agent identifier. + * @return bool True on success. + */ + public function deactivate_session($agent_id) + { + $transient_key = "wpaw_memanto_token_" . md5($agent_id); + delete_transient($transient_key); + + if (!$this->is_configured()) { + return false; + } + + $url = $this->base_url . "/api/v2/agents/{$agent_id}/deactivate"; + $headers = $this->get_headers(); + + $response = wp_remote_post($url, [ + "headers" => $headers, + "body" => wp_json_encode([]), + "timeout" => 10, + ]); + + if (is_wp_error($response)) { + wpaw_debug_log("MEMANTO deactivate_session failed", [ + "agent_id" => $agent_id, + ]); + return false; + } + + return true; + } + + // ========================================================================= + // Memory Operations + // ========================================================================= + + /** + * Store a memory. + * + * @param string $agent_id Agent identifier. + * @param string $content Memory content (max 10000 chars). + * @param string $type Memory type (fact, preference, goal, decision, artifact, learning, event, instruction, relationship, context, observation, commitment, error). + * @param array $tags Optional tags. + * @param string $title Optional title (max 100 chars). + * @return bool True on success. + */ + public function remember( + $agent_id, + $content, + $type = "context", + $tags = [], + $title = "", + ) { + if (!$this->is_active()) { + return false; + } + + $body = [ + "content" => mb_substr($content, 0, 10000), + "type" => $type, + "tags" => $tags, + "source" => "wp-agentic-writer", + ]; + + if (!empty($title)) { + $body["title"] = mb_substr($title, 0, 100); + } + + $response = $this->post( + "/api/v2/agents/{$agent_id}/remember", + $body, + $agent_id, + ); + + if (is_wp_error($response)) { + wpaw_debug_log("MEMANTO remember failed", [ + "agent_id" => $agent_id, + "type" => $type, + "error" => $response->get_error_message(), + ]); + return false; + } + + return true; + } + + /** + * Store multiple memories in batch (max 100). + * + * @param string $agent_id Agent identifier. + * @param array $memories Array of memory items. Each item: { content, type, tags, title }. + * @return bool True on success. + */ + public function batch_remember($agent_id, $memories) + { + if (!$this->is_active() || empty($memories)) { + return false; + } + + $batch = []; + foreach (array_slice($memories, 0, 100) as $item) { + $entry = [ + "content" => mb_substr($item["content"] ?? "", 0, 10000), + "type" => $item["type"] ?? "context", + "source" => "wp-agentic-writer", + ]; + if (!empty($item["title"])) { + $entry["title"] = mb_substr($item["title"], 0, 100); + } + if (!empty($item["tags"])) { + $entry["tags"] = $item["tags"]; + } + $batch[] = $entry; + } + + $response = $this->post( + "/api/v2/agents/{$agent_id}/batch-remember", + ["memories" => $batch], + $agent_id, + ); + + if (is_wp_error($response)) { + wpaw_debug_log("MEMANTO batch_remember failed", [ + "agent_id" => $agent_id, + "count" => count($batch), + "error" => $response->get_error_message(), + ]); + return false; + } + + return true; + } + + /** + * Semantic search / recall memories. + * + * @param string $agent_id Agent identifier. + * @param string $query Search query. + * @param array $type_filter Optional memory type filter. + * @param int $limit Max results (default 10). + * @param float $min_similarity Minimum similarity score 0-1. + * @return array Recalled memories (empty on failure). + */ + public function recall( + $agent_id, + $query, + $type_filter = [], + $limit = 10, + $min_similarity = 0.3, + ) { + if (!$this->is_active()) { + return []; + } + + $body = [ + "query" => $query, + "limit" => $limit, + "min_similarity" => $min_similarity, + ]; + + if (!empty($type_filter)) { + $body["type"] = $type_filter; + } + + $response = $this->post( + "/api/v2/agents/{$agent_id}/recall", + $body, + $agent_id, + ); + + if (is_wp_error($response)) { + wpaw_debug_log("MEMANTO recall failed", [ + "agent_id" => $agent_id, + "query" => substr($query, 0, 100), + "error" => $response->get_error_message(), + ]); + return []; + } + + return is_array($response) ? $response : []; + } + + /** + * Recall most recent memories. + * + * @param string $agent_id Agent identifier. + * @param int $limit Max results. + * @param array $type_filter Optional memory type filter. + * @return array Recent memories (empty on failure). + */ + public function recall_recent($agent_id, $limit = 10, $type_filter = []) + { + if (!$this->is_active()) { + return []; + } + + $body = ["limit" => $limit]; + if (!empty($type_filter)) { + $body["type"] = $type_filter; + } + + $response = $this->post( + "/api/v2/agents/{$agent_id}/recall/recent", + $body, + $agent_id, + ); + + if (is_wp_error($response)) { + return []; + } + + return is_array($response) ? $response : []; + } + + // ========================================================================= + // Agent ID Builders + // ========================================================================= + + /** + * Build user-level agent ID. + * + * @param int $user_id WordPress user ID. + * @return string Agent ID like "wp-user-1". + */ + public function get_user_agent_id($user_id) + { + return "wp-user-" . absint($user_id); + } + + /** + * Build post-level agent ID. + * + * @param int $post_id WordPress post ID. + * @return string Agent ID like "wp-post-42". + */ + public function get_post_agent_id($post_id) + { + return "wp-post-" . absint($post_id); + } + + // ========================================================================= + // HTTP Helpers (private) + // ========================================================================= + + /** + * Get the Moorcheh API key from settings. + * + * @return string + */ + private function get_moorcheh_key() + { + $settings = get_option("wp_agentic_writer_settings", []); + return $settings["memanto_moorcheh_key"] ?? ""; + } + + /** + * GET request to MEMANTO API. + * + * @param string $path API path (e.g. "/health", "/api/v2/agents/{id}"). + * @return array|WP_Error Decoded response or error. + */ + private function get($path) + { + $url = $this->base_url . $path; + + $response = wp_remote_get($url, [ + "headers" => $this->get_headers(), + "timeout" => 10, + ]); + + return $this->parse_response($response); + } + + /** + * POST request to MEMANTO API. + * + * @param string $path API path. + * @param array $body Request body. + * @param string $agent_id Optional agent ID for session token injection. + * @return array|WP_Error Decoded response or error. + */ + private function post($path, $body = [], $agent_id = "") + { + $url = $this->base_url . $path; + $headers = $this->get_headers(); + + // Inject session token if we have an agent_id. + if (!empty($agent_id)) { + $token = $this->activate_session($agent_id); + if ($token) { + $headers["X-Session-Token"] = $token; + } + } + + $response = wp_remote_post($url, [ + "headers" => $headers, + "body" => wp_json_encode($body), + "timeout" => 10, + ]); + + // Handle expired token (401) — re-activate and retry once. + if (!is_wp_error($response)) { + $code = wp_remote_retrieve_response_code($response); + if (401 === $code && !empty($agent_id)) { + // Clear cached token and retry. + delete_transient("wpaw_memanto_token_" . md5($agent_id)); + $token = $this->activate_session($agent_id); + if ($token) { + $headers["X-Session-Token"] = $token; + $response = wp_remote_post($url, [ + "headers" => $headers, + "body" => wp_json_encode($body), + "timeout" => 10, + ]); + } + } + } + + return $this->parse_response($response); + } + + /** + * Build common headers for MEMANTO API requests. + * + * @return array Headers. + */ + private function get_headers() + { + return [ + "Content-Type" => "application/json", + "Accept" => "application/json", + "X-API-Key" => $this->get_moorcheh_key(), + ]; + } + + /** + * Parse HTTP response from MEMANTO API. + * + * @param array|WP_Error $response wp_remote response. + * @return array|WP_Error Decoded body or error. + */ + private function parse_response($response) + { + if (is_wp_error($response)) { + return new WP_Error( + "memanto_connection_error", + $response->get_error_message(), + ); + } + + $code = wp_remote_retrieve_response_code($response); + $body = wp_remote_retrieve_body($response); + + if (401 === $code) { + // Let caller handle re-auth. + return new WP_Error( + "memanto_unauthorized", + "Session token expired", + ); + } + + if ($code >= 400) { + wpaw_debug_log("MEMANTO API error ({$code})", $body); + return new WP_Error( + "memanto_api_error", + sprintf( + "MEMANTO API error (%d): %s", + $code, + substr($body, 0, 200), + ), + ); + } + + $decoded = json_decode($body, true); + return is_array($decoded) ? $decoded : []; + } +} diff --git a/includes/class-memanto-context-enhancer.php b/includes/class-memanto-context-enhancer.php new file mode 100644 index 0000000..faf5540 --- /dev/null +++ b/includes/class-memanto-context-enhancer.php @@ -0,0 +1,754 @@ +client = WP_Agentic_Writer_Memanto_Client::get_instance(); + + // Session lifecycle. + add_action( + "wpaw_memanto_session_start", + [$this, "on_session_start"], + 10, + 3, + ); + add_action( + "wpaw_memanto_session_end", + [$this, "on_session_end"], + 10, + 2, + ); + + // Write-through: remember on meaningful events. + add_action( + "wpaw_memanto_user_message", + [$this, "on_user_message"], + 10, + 3, + ); + add_action( + "wpaw_memanto_plan_generated", + [$this, "on_plan_generated"], + 10, + 2, + ); + add_action( + "wpaw_memanto_plan_approved", + [$this, "on_plan_approved"], + 10, + 2, + ); + add_action( + "wpaw_memanto_plan_rejected", + [$this, "on_plan_rejected"], + 10, + 2, + ); + add_action( + "wpaw_memanto_section_written", + [$this, "on_section_written"], + 10, + 3, + ); + add_action( + "wpaw_memanto_block_refined", + [$this, "on_block_refined"], + 10, + 3, + ); + add_action( + "wpaw_memanto_config_saved", + [$this, "on_config_saved"], + 10, + 2, + ); + } + + // ========================================================================= + // Session Lifecycle + // ========================================================================= + + /** + * Called when a conversation session starts. + * Ensures agents exist and recalls previous session state. + * + * @param string $session_id Session ID. + * @param int $post_id Post ID. + * @param int $user_id WordPress user ID. + */ + public function on_session_start($session_id, $post_id, $user_id) + { + if (!$this->client->is_active()) { + return; + } + + // Ensure user agent exists. + if ($user_id > 0) { + $this->client->ensure_agent( + $this->client->get_user_agent_id($user_id), + ); + } + + // Ensure post agent exists. + if ($post_id > 0) { + $this->client->ensure_agent( + $this->client->get_post_agent_id($post_id), + ); + } + } + + /** + * Called when a conversation session ends. + * Stores a session summary memory and deactivates the session. + * + * @param string $session_id Session ID. + * @param int $post_id Post ID. + */ + public function on_session_end($session_id, $post_id) + { + if (!$this->client->is_active() || $post_id <= 0) { + return; + } + + $post_agent = $this->client->get_post_agent_id($post_id); + + // Store a session summary. + $this->client->remember( + $post_agent, + "Session ended: " . $session_id, + "context", + ["session:" . $session_id, "post:" . $post_id], + "Session end", + ); + + // Deactivate MEMANTO session to trigger summary generation. + $this->client->deactivate_session($post_agent); + } + + // ========================================================================= + // Write-Through: Remember on Meaningful Events + // ========================================================================= + + /** + * Store a memory when user sends a chat message. + * + * @param string $session_id Session ID. + * @param string $content User message content. + * @param int $post_id Post ID. + */ + public function on_user_message($session_id, $content, $post_id) + { + if (!$this->client->is_active() || $post_id <= 0) { + return; + } + + $this->client->remember( + $this->client->get_post_agent_id($post_id), + "User instruction: " . wp_strip_all_tags($content), + "instruction", + ["post:" . $post_id, "session:" . $session_id], + ); + } + + /** + * Store a memory when a plan is generated. + * + * @param int $post_id Post ID. + * @param array $plan Plan data. + */ + public function on_plan_generated($post_id, $plan) + { + if (!$this->client->is_active() || $post_id <= 0) { + return; + } + + $title = $plan["title"] ?? "Untitled"; + $sections = + isset($plan["sections"]) && is_array($plan["sections"]) + ? count($plan["sections"]) + : 0; + + $this->client->remember( + $this->client->get_post_agent_id($post_id), + sprintf('Plan generated: "%s" with %d sections', $title, $sections), + "artifact", + ["post:" . $post_id, "type:plan"], + "Plan: " . $title, + ); + } + + /** + * Store a memory when user approves a plan. + * + * @param int $post_id Post ID. + * @param array $plan Plan data. + */ + public function on_plan_approved($post_id, $plan) + { + if (!$this->client->is_active() || $post_id <= 0) { + return; + } + + $title = $plan["title"] ?? "Untitled"; + + $this->client->remember( + $this->client->get_post_agent_id($post_id), + sprintf('User approved plan: "%s"', $title), + "decision", + ["post:" . $post_id, "type:plan"], + "Plan approved", + ); + } + + /** + * Store a memory when user rejects or requests plan changes. + * + * @param int $post_id Post ID. + * @param string $reason Rejection/revision reason. + */ + public function on_plan_rejected($post_id, $reason) + { + if (!$this->client->is_active() || $post_id <= 0) { + return; + } + + $this->client->remember( + $this->client->get_post_agent_id($post_id), + "User requested plan revision: " . wp_strip_all_tags($reason), + "error", + ["post:" . $post_id, "type:plan"], + "Plan revision", + ); + } + + /** + * Store a memory when a section is written. + * + * @param int $post_id Post ID. + * @param string $section_id Section identifier. + * @param string $summary Brief section summary. + */ + public function on_section_written($post_id, $section_id, $summary) + { + if (!$this->client->is_active() || $post_id <= 0) { + return; + } + + $this->client->remember( + $this->client->get_post_agent_id($post_id), + "Section written (" . + $section_id . + "): " . + wp_strip_all_tags($summary), + "artifact", + ["post:" . $post_id, "section:" . $section_id], + "Section: " . $section_id, + ); + } + + /** + * Store a memory when a block is refined. + * + * @param int $post_id Post ID. + * @param string $block_id Block identifier. + * @param string $instruction Refinement instruction. + */ + public function on_block_refined($post_id, $block_id, $instruction) + { + if (!$this->client->is_active() || $post_id <= 0) { + return; + } + + $this->client->remember( + $this->client->get_post_agent_id($post_id), + "Block refined (" . + $block_id . + "): " . + wp_strip_all_tags($instruction), + "instruction", + ["post:" . $post_id, "block:" . $block_id], + ); + } + + /** + * Store a memory when post config is saved. + * + * @param int $post_id Post ID. + * @param array $config Post config data. + */ + public function on_config_saved($post_id, $config) + { + if (!$this->client->is_active()) { + return; + } + + $config_summary = sprintf( + "tone=%s, audience=%s, length=%s, language=%s", + $config["tone"] ?? "default", + $config["audience"] ?? "general", + $config["article_length"] ?? "medium", + $config["language"] ?? "auto", + ); + + // Store to post agent. + if ($post_id > 0) { + $this->client->remember( + $this->client->get_post_agent_id($post_id), + "Article config: " . $config_summary, + "preference", + ["post:" . $post_id], + "Post config", + ); + } + + // Store to user agent (cross-post preferences). + $user_id = get_current_user_id(); + if ($user_id > 0) { + $this->client->remember( + $this->client->get_user_agent_id($user_id), + "User preference: " . $config_summary, + "preference", + ["user:" . $user_id], + "Writing preferences", + ); + } + } + + // ========================================================================= + // Recall: Retrieve Memories for Context Enrichment + // ========================================================================= + + /** + * Recall relevant memories to enrich AI prompt context. + * + * @param int $post_id Post ID. + * @param int $user_id WordPress user ID. + * @param string $current_message User's current message (for semantic search). + * @return array Recalled memory items, each with 'type', 'content', 'title'. + */ + public function recall_for_context( + $post_id, + $user_id, + $current_message = "", + ) { + if (!$this->client->is_active()) { + return []; + } + + $memories = []; + $seen = []; + + // 1. Recent post memories. + if ($post_id > 0) { + $post_agent = $this->client->get_post_agent_id($post_id); + $recent = $this->client->recall_recent($post_agent, 10); + + foreach ($this->normalize_memories($recent) as $item) { + $hash = md5($item["content"]); + if (!isset($seen[$hash])) { + $seen[$hash] = true; + $memories[] = $item; + } + } + + // 2. Semantic recall based on current message. + if (!empty($current_message)) { + $semantic = $this->client->recall( + $post_agent, + $current_message, + [], + 5, + ); + foreach ($this->normalize_memories($semantic) as $item) { + $hash = md5($item["content"]); + if (!isset($seen[$hash])) { + $seen[$hash] = true; + $memories[] = $item; + } + } + } + } + + // 3. User preferences (cross-post). + if ($user_id > 0) { + $user_prefs = $this->client->recall( + $this->client->get_user_agent_id($user_id), + "writing preferences tone audience language", + ["preference"], + 5, + ); + + foreach ($this->normalize_memories($user_prefs) as $item) { + $hash = md5($item["content"]); + if (!isset($seen[$hash])) { + $seen[$hash] = true; + $memories[] = $item; + } + } + } + + return $memories; + } + + /** + * Restore session state from MEMANTO when reopening a post. + * + * Returns a structured restore payload that the frontend can use to: + * - Display a "Restored from memory" badge + * - Build a restored-session system message for the AI + * - Pre-fill config from prior preferences + * + * @since 0.4.0 + * @param int $post_id Post ID being reopened. + * @param int $user_id Current user ID. + * @return array { restored: bool, memories: array, preferences: array, summary: string } + */ + public function restore_session($post_id, $user_id) + { + $empty = [ + "restored" => false, + "memories" => [], + "preferences" => [], + "summary" => "", + ]; + + if (!$this->client->is_active() || $post_id <= 0) { + return $empty; + } + + // Recall recent post memories (no current message — restore is about history). + $post_agent = $this->client->get_post_agent_id($post_id); + $recent = $this->client->recall_recent($post_agent, 15); + $memories = $this->normalize_memories($recent); + + // Recall user preferences (for cross-post config carry-over). + $preferences = []; + if ($user_id > 0) { + $user_prefs = $this->client->recall( + $this->client->get_user_agent_id($user_id), + "writing preferences tone audience language length", + ["preference"], + 5, + ); + $preferences = $this->normalize_memories($user_prefs); + } + + if (empty($memories) && empty($preferences)) { + return $empty; + } + + // Build a human-readable summary for the restore badge tooltip. + $summary = $this->build_restore_summary($memories, $preferences); + + return [ + "restored" => true, + "memories" => $memories, + "preferences" => $preferences, + "summary" => $summary, + ]; + } + + /** + * Build a compact summary string of restored memories. + * + * @param array $memories Restored memories. + * @param array $preferences Restored preferences. + * @return string Summary text. + */ + private function build_restore_summary($memories, $preferences) + { + $parts = []; + + if (!empty($memories)) { + $parts[] = sprintf( + /* translators: %d: number of memories */ + _n( + "%d memory", + "%d memories", + count($memories), + "wp-agentic-writer", + ), + count($memories), + ); + } + + if (!empty($preferences)) { + $parts[] = sprintf( + /* translators: %d: number of preferences */ + _n( + "%d preference", + "%d preferences", + count($preferences), + "wp-agentic-writer", + ), + count($preferences), + ); + } + + return implode(", ", $parts); + } + + /** + * Get user's writing preferences recalled from MEMANTO. + * + * Used when creating a new post to pre-fill the post config + * with the user's habitual tone, audience, etc. + * + * @since 0.4.0 + * @param int $user_id WordPress user ID. + * @return array { restored: bool, config: array } + */ + public function get_user_preferences_for_new_post($user_id) + { + $empty = ["restored" => false, "config" => []]; + + if (!$this->client->is_active() || $user_id <= 0) { + return $empty; + } + + $user_prefs = $this->client->recall( + $this->client->get_user_agent_id($user_id), + "writing preferences tone audience language length", + ["preference"], + 3, + ); + + $prefs = $this->normalize_memories($user_prefs); + if (empty($prefs)) { + return $empty; + } + + // Extract config fields from preference content. + // Memory format: "User preference: tone=professional, audience=experts, length=long, language=en" + $config = $this->extract_config_from_preferences($prefs); + + if (empty($config)) { + return $empty; + } + + return ["restored" => true, "config" => $config]; + } + + /** + * Parse preference memory contents and extract config fields. + * + * @param array $prefs Normalized preference memories. + * @return array Extracted config: { tone, audience, article_length, language }. + */ + private function extract_config_from_preferences($prefs) + { + $config = []; + + // Combine all preference content into one blob for parsing. + $blob = ""; + foreach ($prefs as $pref) { + $blob .= " " . ($pref["content"] ?? ""); + } + + // Match key=value patterns. + if (preg_match('/tone\s*=\s*([^,\n]+)/i', $blob, $m)) { + $val = trim($m[1]); + if ("" !== $val && "default" !== strtolower($val)) { + $config["tone"] = $val; + } + } + if (preg_match('/audience\s*=\s*([^,\n]+)/i', $blob, $m)) { + $val = trim($m[1]); + if ("" !== $val && "general" !== strtolower($val)) { + $config["audience"] = $val; + } + } + if (preg_match('/(?:article_)?length\s*=\s*([^,\n]+)/i', $blob, $m)) { + $val = trim($m[1]); + if ("" !== $val && "medium" !== strtolower($val)) { + $config["article_length"] = $val; + } + } + if (preg_match('/language\s*=\s*([^,\n]+)/i', $blob, $m)) { + $val = trim($m[1]); + if ("" !== $val && "auto" !== strtolower($val)) { + $config["language"] = $val; + } + } + + return $config; + } + + /** + * Build a "restored session" system message for the AI. + * + * This message summarizes prior post work so the AI can resume + * mid-article without re-asking the user for context. + * + * @since 0.4.0 + * @param array $restore_payload Result from restore_session(). + * @return string System message content (empty if nothing to restore). + */ + public function build_session_restore_message($restore_payload) + { + if (empty($restore_payload["restored"])) { + return ""; + } + + $memories = $restore_payload["memories"] ?? []; + $preferences = $restore_payload["preferences"] ?? []; + + if (empty($memories) && empty($preferences)) { + return ""; + } + + $lines = ["SESSION RESTORED FROM MEMORY"]; + $lines[] = + "The user has returned to this post. Below is context from prior sessions."; + $lines[] = + "Use this to continue where the conversation left off without re-asking."; + + // Summarize prior post work. + if (!empty($memories)) { + $lines[] = ""; + $lines[] = "## Prior session activity"; + + $grouped = []; + foreach ($memories as $memory) { + $type = $memory["type"] ?? "context"; + if (!isset($grouped[$type])) { + $grouped[$type] = []; + } + $grouped[$type][] = $memory; + } + + // Render in priority order: plan > decision > instruction > artifact > error > context. + $priority = [ + "artifact", + "decision", + "instruction", + "error", + "learning", + "context", + "preference", + ]; + foreach ($priority as $type) { + if (empty($grouped[$type])) { + continue; + } + $type_label = ucfirst($type) . "s"; + $lines[] = "### {$type_label}"; + foreach ($grouped[$type] as $m) { + $content = trim($m["content"] ?? ""); + if ("" === $content) { + continue; + } + $title = !empty($m["title"]) + ? " [" . trim($m["title"]) . "]" + : ""; + $lines[] = "- {$content}{$title}"; + } + } + } + + // Append user preferences (compact). + if (!empty($preferences)) { + $lines[] = ""; + $lines[] = "## User writing preferences"; + foreach ($preferences as $pref) { + $content = trim($pref["content"] ?? ""); + if ("" !== $content) { + $lines[] = "- {$content}"; + } + } + } + + return implode("\n", $lines); + } + + /** + * Normalize raw MEMANTO response into a uniform array. + * + * @param array $raw Raw response from recall/recall_recent. + * @return array Normalized items: { type, content, title }. + */ + private function normalize_memories($raw) + { + if (!is_array($raw)) { + return []; + } + + // Handle different possible response shapes. + $items = $raw; + + // If response has a 'memories' or 'results' key, unwrap. + if (isset($raw["memories"]) && is_array($raw["memories"])) { + $items = $raw["memories"]; + } elseif (isset($raw["results"]) && is_array($raw["results"])) { + $items = $raw["results"]; + } + + $normalized = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $normalized[] = [ + "type" => $item["type"] ?? "context", + "content" => $item["content"] ?? ($item["text"] ?? ""), + "title" => $item["title"] ?? "", + ]; + } + + return $normalized; + } +} diff --git a/includes/class-settings-v2.php b/includes/class-settings-v2.php index 57fc819..2fab01c 100644 --- a/includes/class-settings-v2.php +++ b/includes/class-settings-v2.php @@ -7,8 +7,8 @@ * @package WP_Agentic_Writer */ -if ( ! defined( 'ABSPATH' ) ) { - exit; +if (!defined("ABSPATH")) { + exit(); } /** @@ -16,681 +16,950 @@ if ( ! defined( 'ABSPATH' ) ) { * * @since 0.2.0 */ -class WP_Agentic_Writer_Settings_V2 { - - /** - * Get singleton instance. - * - * @since 0.2.0 - * @return WP_Agentic_Writer_Settings_V2 - */ - public static function get_instance() { - static $instance = null; - - if ( null === $instance ) { - $instance = new self(); - } - - return $instance; - } - - /** - * Constructor. - * - * @since 0.2.0 - */ - private function __construct() { - add_action( 'admin_menu', array( $this, 'add_settings_page' ) ); - add_action( 'admin_init', array( $this, 'register_settings' ) ); - add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) ); - add_action( 'wp_ajax_wpaw_refresh_models', array( $this, 'ajax_refresh_models' ) ); - add_action( 'wp_ajax_wpaw_get_cost_log_data', array( $this, 'ajax_get_cost_log_data' ) ); - add_action( 'wp_ajax_wpaw_get_header_stats', array( $this, 'ajax_get_header_stats' ) ); - add_action( 'wp_ajax_wpaw_test_api_connection', array( $this, 'ajax_test_api_connection' ) ); - 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' ) ); - } - - /** - * Enqueue scripts for settings page. - * - * @since 0.2.0 - * @param string $hook Current admin page hook. - */ - public function enqueue_scripts( $hook ) { - if ( 'settings_page_wp-agentic-writer-settings' !== $hook ) { - return; - } - - // Bootstrap 5.3 - wp_enqueue_style( 'bootstrap', 'https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css', array(), '5.3.3' ); - wp_enqueue_style( 'bootstrap-icons', 'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css', array(), '1.11.1' ); - wp_enqueue_script( 'bootstrap', 'https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js', array(), '5.3.3', true ); - - // Select2 for searchable dropdowns - wp_enqueue_style( 'select2', 'https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css', array(), '4.1.0' ); - wp_enqueue_style( 'select2-bootstrap-5', 'https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css', array( 'select2', 'bootstrap' ), '1.3.0' ); - wp_enqueue_script( 'select2', 'https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js', array( 'jquery' ), '4.1.0', true ); - - // Agentic Vibe CSS - Design System (in order) - wp_enqueue_style( 'wpaw-agentic-variables', WP_AGENTIC_WRITER_URL . 'assets/css/agentic-variables.css', array(), WP_AGENTIC_WRITER_VERSION ); - wp_enqueue_style( 'wpaw-agentic-bootstrap-custom', WP_AGENTIC_WRITER_URL . 'assets/css/agentic-bootstrap-custom.css', array( 'bootstrap', 'wpaw-agentic-variables' ), WP_AGENTIC_WRITER_VERSION ); - wp_enqueue_style( 'wpaw-agentic-components', WP_AGENTIC_WRITER_URL . 'assets/css/agentic-components.css', array( 'wpaw-agentic-variables' ), WP_AGENTIC_WRITER_VERSION ); - wp_enqueue_style( 'wpaw-agentic-workflow', WP_AGENTIC_WRITER_URL . 'assets/css/agentic-workflow.css', array( 'wpaw-agentic-components' ), WP_AGENTIC_WRITER_VERSION ); - - // Legacy plugin styles - $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 ); - - $settings = get_option( 'wp_agentic_writer_settings', array() ); - wp_localize_script( 'wp-agentic-writer-settings-v2', 'wpawSettingsV2', array( - 'ajaxUrl' => admin_url( 'admin-ajax.php' ), - 'nonce' => wp_create_nonce( 'wpaw_settings' ), - 'models' => $this->get_models_for_select(), - 'currentModels' => array( - 'planning' => $settings['planning_model'] ?? WPAW_Model_Registry::get_default_model( 'planning' ), - 'writing' => $settings['writing_model'] ?? ( $settings['execution_model'] ?? WPAW_Model_Registry::get_default_model( 'writing' ) ), - 'execution' => $settings['writing_model'] ?? ( $settings['execution_model'] ?? WPAW_Model_Registry::get_default_model( 'writing' ) ), - 'clarity' => $settings['clarity_model'] ?? WPAW_Model_Registry::get_default_model( 'clarity' ), - 'refinement' => $settings['refinement_model'] ?? WPAW_Model_Registry::get_default_model( 'refinement' ), - 'chat' => $settings['chat_model'] ?? WPAW_Model_Registry::get_default_model( 'chat' ), - 'image' => $settings['image_model'] ?? WPAW_Model_Registry::get_default_model( 'image' ), - ), - 'presets' => $this->get_model_presets(), - 'i18n' => array( - 'refreshing' => __( 'Refreshing...', 'wp-agentic-writer' ), - 'refreshModels' => __( 'Refresh Models', 'wp-agentic-writer' ), - 'saveSuccess' => __( 'Settings saved successfully!', 'wp-agentic-writer' ), - 'saveError' => __( 'Error saving settings.', 'wp-agentic-writer' ), - 'confirmReset' => __( 'Are you sure you want to reset all settings to defaults?', 'wp-agentic-writer' ), - 'loading' => __( 'Loading...', 'wp-agentic-writer' ), - 'noResults' => __( 'No models found', 'wp-agentic-writer' ), - 'searchPlaceholder' => __( 'Search models...', 'wp-agentic-writer' ), - ), - ) ); - } - - /** - * Get curated model presets (centralized source). - * - * These are intentional product decisions for different budget tiers. - * Model IDs may differ from registry defaults to balance cost/quality. - * - * @since 0.2.0 - * @return array Curated model presets. - */ - public function get_model_presets() { - return array( - 'budget' => array( - 'chat' => 'google/gemini-2.5-flash', - 'clarity' => 'google/gemini-2.5-flash', - 'planning' => 'google/gemini-2.5-flash', - 'writing' => 'mistralai/mistral-small-creative', - 'refinement' => 'google/gemini-2.5-flash', - 'image' => 'openai/gpt-4o', - ), - 'balanced' => array( - 'chat' => 'google/gemini-2.5-flash', - 'clarity' => 'google/gemini-2.5-flash', - 'planning' => 'google/gemini-2.5-flash', - 'writing' => 'anthropic/claude-3.5-sonnet', - 'refinement' => 'anthropic/claude-3.5-sonnet', - 'image' => 'openai/gpt-4o', - ), - 'premium' => array( - 'chat' => 'google/gemini-3-flash-preview', - 'clarity' => 'anthropic/claude-sonnet-4', - 'planning' => 'google/gemini-3-flash-preview', - 'writing' => 'openai/gpt-4.1', - 'refinement' => 'openai/gpt-4.1', - 'image' => 'openai/gpt-4o', - ), - ); - } - - /** - * Get models for select dropdowns. - * - * @since 0.2.0 - * @return array Models grouped by category. - */ - public function get_models_for_select() { - $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance(); - $models = $provider->get_cached_models(); - - if ( is_wp_error( $models ) ) { - return $this->get_fallback_models(); - } - - $transformed = $this->transform_models_for_js( $models ); - - // Debug logging - if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { - $custom_models = get_option( 'wp_agentic_writer_custom_models', array() ); - error_log( 'WPAW get_models_for_select: custom_models in DB = ' . wp_json_encode( $custom_models ) ); - error_log( 'WPAW get_models_for_select: image models count = ' . count( $transformed['image']['all'] ?? array() ) ); - } - - return $transformed; - } - - /** - * Format model name from ID. - * - * @since 0.2.0 - * @param string $model_id Model ID. - * @return string Formatted model name. - */ - private function format_model_name( $model_id ) { - // Remove provider prefix - $parts = explode( '/', $model_id ); - $name = end( $parts ); - - // Remove :free suffix - $name = preg_replace( '/:free$/i', '', $name ); - - // Convert hyphens and underscores to spaces - $name = str_replace( array( '-', '_' ), ' ', $name ); - - // Capitalize words - $name = ucwords( $name ); - - // Add provider prefix back - if ( count( $parts ) > 1 ) { - $provider = ucfirst( $parts[0] ); - $name = $provider . ': ' . $name; - } - - return $name; - } - - /** - * Get fallback models when API fails. - * - * @since 0.2.0 - * @return array Fallback model structure. - */ - private function get_fallback_models() { - return array( - 'planning' => array( - 'recommended' => array( - array( 'id' => WPAW_Model_Registry::get_default_model( 'planning' ), 'name' => WPAW_Model_Registry::get_model_display_name( WPAW_Model_Registry::get_default_model( 'planning' ) ) ), - ), - 'all' => array( - array( 'id' => WPAW_Model_Registry::get_default_model( 'planning' ), 'name' => WPAW_Model_Registry::get_model_display_name( WPAW_Model_Registry::get_default_model( 'planning' ) ) ), - ), - ), - 'execution' => array( - 'recommended' => array( - array( 'id' => WPAW_Model_Registry::get_fallback_model( 'execution' ), 'name' => WPAW_Model_Registry::get_model_display_name( WPAW_Model_Registry::get_fallback_model( 'execution' ) ) ), - ), - 'all' => array( - array( 'id' => WPAW_Model_Registry::get_fallback_model( 'execution' ), 'name' => WPAW_Model_Registry::get_model_display_name( WPAW_Model_Registry::get_fallback_model( 'execution' ) ) ), - ), - ), - 'image' => array( - 'recommended' => array( - array( 'id' => WPAW_Model_Registry::get_default_model( 'image' ), 'name' => WPAW_Model_Registry::get_model_display_name( WPAW_Model_Registry::get_default_model( 'image' ) ) ), - ), - 'all' => array( - array( 'id' => WPAW_Model_Registry::get_default_model( 'image' ), 'name' => WPAW_Model_Registry::get_model_display_name( WPAW_Model_Registry::get_default_model( 'image' ) ) ), - ), - ), - ); - } - - /** - * Transform models structure for JavaScript consumption. - * - * @since 0.2.0 - * @param array $models Models from provider. - * @return array Transformed models. - */ - private function transform_models_for_js( $models ) { - // Handle flat model list from OpenRouter - if ( ! empty( $models ) && array_keys( $models ) === range( 0, count( $models ) - 1 ) ) { - $settings = get_option( 'wp_agentic_writer_settings', array() ); - $planning_id = $settings['planning_model'] ?? WPAW_Model_Registry::get_default_model( 'planning' ); - $execution_id = $settings['execution_model'] ?? WPAW_Model_Registry::get_default_model( 'execution' ); - $image_id = $settings['image_model'] ?? WPAW_Model_Registry::get_default_model( 'image' ); - - $text_models = array(); - $image_models = array(); - - // Categorize models using OpenRouter's output_modalities field - foreach ( $models as $model ) { - if ( empty( $model['id'] ) ) { - continue; - } - - $prompt_price = isset( $model['pricing']['prompt'] ) ? (float) $model['pricing']['prompt'] : 0; - $completion_price = isset( $model['pricing']['completion'] ) ? (float) $model['pricing']['completion'] : 0; - $image_price = isset( $model['pricing']['image'] ) ? (float) $model['pricing']['image'] : 0; - - $model_data = array( - 'id' => $model['id'], - 'name' => $model['name'] ?? $model['id'], - 'is_free' => $prompt_price <= 0.0 && $completion_price <= 0.0 && $image_price <= 0.0, - 'pricing' => array( - 'prompt' => $prompt_price, - 'completion' => $completion_price, - 'image' => $image_price, - ), - ); - - // Use OpenRouter's output_modalities to categorize - trust OpenRouter's classification - $output_modalities = $model['architecture']['output_modalities'] ?? array(); - - // Image generation models have 'image' in output_modalities - if ( in_array( 'image', $output_modalities, true ) ) { - $image_models[] = $model_data; - } - - // Text models have 'text' in output_modalities (most models) - if ( in_array( 'text', $output_modalities, true ) ) { - $text_models[] = $model_data; - } - } - - $chat_id = $settings['chat_model'] ?? WPAW_Model_Registry::get_default_model( 'chat' ); - $clarity_id = $settings['clarity_model'] ?? WPAW_Model_Registry::get_default_model( 'clarity' ); - $refinement_id = $settings['refinement_model'] ?? WPAW_Model_Registry::get_default_model( 'refinement' ); - $writing_id = $settings['writing_model'] ?? ( $settings['execution_model'] ?? WPAW_Model_Registry::get_default_model( 'writing' ) ); - - // Add currently selected models to text_models if not already present - $current_model_ids = array( $planning_id, $execution_id, $chat_id, $clarity_id, $refinement_id, $writing_id ); - foreach ( $current_model_ids as $model_id ) { - $found = false; - foreach ( $text_models as $tm ) { - if ( $tm['id'] === $model_id ) { - $found = true; - break; - } - } - if ( ! $found && ! empty( $model_id ) ) { - $text_models[] = array( - 'id' => $model_id, - 'name' => $this->format_model_name( $model_id ), - 'is_free' => false, - 'pricing' => array( - 'prompt' => 0, - 'completion' => 0, - 'image' => 0, - ), - ); - } - } - - // Add currently selected image model to image_models if not already present - if ( ! empty( $image_id ) ) { - $found = false; - foreach ( $image_models as $im ) { - if ( $im['id'] === $image_id ) { - $found = true; - break; - } - } - if ( ! $found ) { - $image_models[] = array( - 'id' => $image_id, - 'name' => $this->format_model_name( $image_id ), - 'is_free' => false, - 'pricing' => array( - 'prompt' => 0, - 'completion' => 0, - 'image' => 0, - ), - ); - } - } - - // Add user's custom models (not listed in API but callable by ID) - $custom_models = get_option( 'wp_agentic_writer_custom_models', array() ); - foreach ( $custom_models as $custom ) { - if ( empty( $custom['id'] ) ) { - continue; - } - $custom_model_data = array( - 'id' => $custom['id'], - 'name' => ! empty( $custom['name'] ) ? $custom['name'] : $this->format_model_name( $custom['id'] ), - 'is_free' => false, - 'is_custom' => true, - 'pricing' => array( - 'prompt' => 0, - 'completion' => 0, - 'image' => 0, - ), - ); - - $type = $custom['type'] ?? 'text'; - if ( 'image' === $type ) { - $image_models[] = $custom_model_data; - } else { - $text_models[] = $custom_model_data; - } - } - - // Now create find_model closure after all models are added - $find_model = function ( $model_id ) use ( $text_models, $image_models ) { - foreach ( array_merge( $text_models, $image_models ) as $model ) { - if ( $model['id'] === $model_id ) { - return $model; - } - } - // If model not found, create a fallback entry - if ( ! empty( $model_id ) ) { - return array( - 'id' => $model_id, - 'name' => $this->format_model_name( $model_id ), - 'is_free' => false, - 'pricing' => array( - 'prompt' => 0, - 'completion' => 0, - 'image' => 0, - ), - ); - } - return null; - }; - - return array( - 'planning' => array( - 'recommended' => array_filter( array( $find_model( $planning_id ) ) ), - 'all' => $text_models, - ), - 'execution' => array( - 'recommended' => array_filter( array( $find_model( $execution_id ) ) ), - 'all' => $text_models, - ), - 'chat' => array( - 'recommended' => array_filter( array( $find_model( $chat_id ) ) ), - 'all' => $text_models, - ), - 'image' => array( - 'recommended' => array_filter( array( $find_model( $image_id ) ) ), - 'all' => $image_models, - ), - ); - } - - $transformed = array(); - - foreach ( $models as $type => $categories ) { - if ( ! isset( $transformed[ $type ] ) ) { - $transformed[ $type ] = array( - 'recommended' => array(), - 'all' => array(), - ); - } - - // Combine free and paid into 'all' array - $all_models = array_merge( - $categories['free'] ?? array(), - $categories['paid'] ?? array() - ); - - // Remove duplicates - $recommended_ids = array(); - foreach ( $categories['recommended'] ?? array() as $model ) { - $transformed[ $type ]['recommended'][] = $model; - $recommended_ids[ $model['id'] ] = true; - } - - // Add all models, avoiding duplicates with recommended - foreach ( $all_models as $model ) { - if ( ! isset( $recommended_ids[ $model['id'] ] ) ) { - $transformed[ $type ]['all'][] = $model; - } - } - } - - return $transformed; - } - - /** - * AJAX handler for refreshing models. - * - * @since 0.2.0 - */ - public function ajax_refresh_models() { - if ( ! check_ajax_referer( 'wpaw_settings', 'nonce', false ) ) { - wp_send_json_error( array( 'message' => 'Invalid nonce' ) ); - return; - } - - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json_error( array( 'message' => 'Permission denied' ) ); - } - - $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance(); - $models = $provider->fetch_and_cache_models( true ); - - if ( is_wp_error( $models ) ) { - wp_send_json_error( array( 'message' => $models->get_error_message() ) ); - } - - $transformed = $this->transform_models_for_js( $models ); - - wp_send_json_success( array( - 'models' => $transformed, - 'message' => __( 'Models refreshed successfully!', 'wp-agentic-writer' ), - ) ); - } - - /** - * AJAX handler for saving a custom model. - * - * @since 0.2.0 - */ - public function ajax_save_custom_model() { - if ( ! check_ajax_referer( 'wpaw_settings', 'nonce', false ) ) { - wp_send_json_error( array( 'message' => 'Invalid nonce' ) ); - return; - } - - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json_error( array( 'message' => 'Permission denied' ) ); - return; - } - - $model_id = isset( $_POST['model_id'] ) ? sanitize_text_field( $_POST['model_id'] ) : ''; - $model_name = isset( $_POST['model_name'] ) ? sanitize_text_field( $_POST['model_name'] ) : ''; - $model_type = isset( $_POST['model_type'] ) ? sanitize_text_field( $_POST['model_type'] ) : 'text'; - - if ( empty( $model_id ) ) { - wp_send_json_error( array( 'message' => 'Model ID is required' ) ); - return; - } - - // Use separate option for custom models - $custom_models = get_option( 'wp_agentic_writer_custom_models', array() ); - - // Check if model already exists, update it - $found = false; - foreach ( $custom_models as $index => $cm ) { - if ( $cm['id'] === $model_id ) { - $custom_models[ $index ] = array( - 'id' => $model_id, - 'name' => $model_name, - 'type' => $model_type, - ); - $found = true; - break; - } - } - - // Add new model if not found - if ( ! $found ) { - $custom_models[] = array( - 'id' => $model_id, - 'name' => $model_name, - 'type' => $model_type, - ); - } - - $saved = update_option( 'wp_agentic_writer_custom_models', array_values( $custom_models ) ); - - // Get fresh combined models for Select2 - $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance(); - $models = $provider->get_cached_models(); - if ( is_wp_error( $models ) ) { - $models = array(); - } - $transformed = $this->transform_models_for_js( $models ); - - wp_send_json_success( array( - 'message' => __( 'Custom model saved!', 'wp-agentic-writer' ), - 'models' => $transformed, - ) ); - } - - /** - * AJAX handler for deleting a custom model. - * - * @since 0.2.0 - */ - public function ajax_delete_custom_model() { - if ( ! check_ajax_referer( 'wpaw_settings', 'nonce', false ) ) { - wp_send_json_error( array( 'message' => 'Invalid nonce' ) ); - return; - } - - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json_error( array( 'message' => 'Permission denied' ) ); - return; - } - - $model_id = isset( $_POST['model_id'] ) ? sanitize_text_field( $_POST['model_id'] ) : ''; - - if ( empty( $model_id ) ) { - wp_send_json_error( array( 'message' => 'Model ID is required' ) ); - return; - } - - // Use separate option for custom models - $custom_models = get_option( 'wp_agentic_writer_custom_models', array() ); - - // Remove the model - $custom_models = array_filter( $custom_models, function ( $cm ) use ( $model_id ) { - return $cm['id'] !== $model_id; - } ); - - update_option( 'wp_agentic_writer_custom_models', array_values( $custom_models ) ); - - // Get fresh combined models for Select2 - $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance(); - $models = $provider->get_cached_models(); - if ( is_wp_error( $models ) ) { - $models = array(); - } - $transformed = $this->transform_models_for_js( $models ); - - wp_send_json_success( array( - 'message' => __( 'Custom model deleted!', 'wp-agentic-writer' ), - 'models' => $transformed, - ) ); - } - - /** - * AJAX handler for getting cost log data (server-side pagination). - * - * @since 0.2.0 - */ - public function ajax_get_cost_log_data() { - if ( ! check_ajax_referer( 'wpaw_settings', 'nonce', false ) ) { - wp_send_json_error( array( 'message' => 'Invalid nonce' ) ); - return; - } - - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json_error( array( 'message' => 'Permission denied' ) ); - return; - } - - global $wpdb; - $table_name = $wpdb->prefix . 'wpaw_cost_tracking'; - - // Check if table exists - $table_exists = $wpdb->get_var( "SHOW TABLES LIKE '{$table_name}'" ) === $table_name; - - if ( ! $table_exists ) { - wp_send_json_success( - array( - 'records' => array(), - 'total_items' => 0, - 'total_pages' => 0, - 'current_page' => 1, - 'per_page' => 25, - 'stats' => array( - 'all_time' => '0.0000', - 'monthly' => '0.0000', - 'today' => '0.0000', - 'avg_per_post' => '0.0000', - 'action_summary' => array(), - ), - 'filters' => array( - 'models' => array(), - 'types' => array(), - ), - ) - ); - return; - } - - // Get parameters - $page = isset( $_POST['page'] ) ? max( 1, intval( $_POST['page'] ) ) : 1; - $per_page = isset( $_POST['per_page'] ) ? min( 100, max( 10, intval( $_POST['per_page'] ) ) ) : 25; - $offset = ( $page - 1 ) * $per_page; - - // Filters - $filter_post = isset( $_POST['filter_post'] ) ? intval( $_POST['filter_post'] ) : 0; - $filter_model = isset( $_POST['filter_model'] ) ? sanitize_text_field( $_POST['filter_model'] ) : ''; - $filter_type = isset( $_POST['filter_type'] ) ? sanitize_text_field( $_POST['filter_type'] ) : ''; - $filter_date_from = isset( $_POST['filter_date_from'] ) ? sanitize_text_field( $_POST['filter_date_from'] ) : ''; - $filter_date_to = isset( $_POST['filter_date_to'] ) ? sanitize_text_field( $_POST['filter_date_to'] ) : ''; - - // Build WHERE clause (OpenRouter-only for this OpenRouter cost log screen). - $where = array( "provider = 'openrouter'" ); - if ( $filter_post > 0 ) { - $where[] = $wpdb->prepare( 'post_id = %d', $filter_post ); - } - if ( ! empty( $filter_model ) ) { - $where[] = $wpdb->prepare( 'model = %s', $filter_model ); - } - if ( ! empty( $filter_type ) ) { - $where[] = $wpdb->prepare( 'action = %s', $filter_type ); - } - if ( ! empty( $filter_date_from ) ) { - $where[] = $wpdb->prepare( 'DATE(created_at) >= %s', $filter_date_from ); - } - if ( ! empty( $filter_date_to ) ) { - $where[] = $wpdb->prepare( 'DATE(created_at) <= %s', $filter_date_to ); - } - $where_clause = implode( ' AND ', $where ); - - // Get total count of distinct posts - $total_items = $wpdb->get_var( "SELECT COUNT(DISTINCT post_id) FROM {$table_name} WHERE {$where_clause}" ); - $total_pages = ceil( $total_items / $per_page ); - - // Optimized: Get grouped records with aggregation in SQL. - // This pushes grouping and ordering to the database instead of PHP. - $grouped_records_sql = $wpdb->get_results( - $wpdb->prepare( - "SELECT +class WP_Agentic_Writer_Settings_V2 +{ + /** + * Get singleton instance. + * + * @since 0.2.0 + * @return WP_Agentic_Writer_Settings_V2 + */ + public static function get_instance() + { + static $instance = null; + + if (null === $instance) { + $instance = new self(); + } + + return $instance; + } + + /** + * Constructor. + * + * @since 0.2.0 + */ + private function __construct() + { + add_action("admin_menu", [$this, "add_settings_page"]); + add_action("admin_init", [$this, "register_settings"]); + add_action("admin_enqueue_scripts", [$this, "enqueue_scripts"]); + add_action("wp_ajax_wpaw_refresh_models", [ + $this, + "ajax_refresh_models", + ]); + add_action("wp_ajax_wpaw_get_cost_log_data", [ + $this, + "ajax_get_cost_log_data", + ]); + add_action("wp_ajax_wpaw_get_header_stats", [ + $this, + "ajax_get_header_stats", + ]); + add_action("wp_ajax_wpaw_test_api_connection", [ + $this, + "ajax_test_api_connection", + ]); + add_action("wp_ajax_wpaw_debug_models", [$this, "ajax_debug_models"]); + add_action("wp_ajax_wpaw_save_custom_model", [ + $this, + "ajax_save_custom_model", + ]); + add_action("wp_ajax_wpaw_delete_custom_model", [ + $this, + "ajax_delete_custom_model", + ]); + add_action("wp_ajax_wpaw_test_local_backend", [ + $this, + "ajax_test_local_backend", + ]); + add_action("wp_ajax_wpaw_test_memanto", [$this, "ajax_test_memanto"]); + } + + /** + * Enqueue scripts for settings page. + * + * @since 0.2.0 + * @param string $hook Current admin page hook. + */ + public function enqueue_scripts($hook) + { + if ("settings_page_wp-agentic-writer-settings" !== $hook) { + return; + } + + // Bootstrap 5.3 + wp_enqueue_style( + "bootstrap", + "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css", + [], + "5.3.3", + ); + wp_enqueue_style( + "bootstrap-icons", + "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css", + [], + "1.11.1", + ); + wp_enqueue_script( + "bootstrap", + "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js", + [], + "5.3.3", + true, + ); + + // Select2 for searchable dropdowns + wp_enqueue_style( + "select2", + "https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css", + [], + "4.1.0", + ); + wp_enqueue_style( + "select2-bootstrap-5", + "https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css", + ["select2", "bootstrap"], + "1.3.0", + ); + wp_enqueue_script( + "select2", + "https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js", + ["jquery"], + "4.1.0", + true, + ); + + // Agentic Vibe CSS - Design System (in order) + wp_enqueue_style( + "wpaw-agentic-variables", + WP_AGENTIC_WRITER_URL . "assets/css/agentic-variables.css", + [], + WP_AGENTIC_WRITER_VERSION, + ); + wp_enqueue_style( + "wpaw-agentic-bootstrap-custom", + WP_AGENTIC_WRITER_URL . "assets/css/agentic-bootstrap-custom.css", + ["bootstrap", "wpaw-agentic-variables"], + WP_AGENTIC_WRITER_VERSION, + ); + wp_enqueue_style( + "wpaw-agentic-components", + WP_AGENTIC_WRITER_URL . "assets/css/agentic-components.css", + ["wpaw-agentic-variables"], + WP_AGENTIC_WRITER_VERSION, + ); + wp_enqueue_style( + "wpaw-agentic-workflow", + WP_AGENTIC_WRITER_URL . "assets/css/agentic-workflow.css", + ["wpaw-agentic-components"], + WP_AGENTIC_WRITER_VERSION, + ); + + // Legacy plugin styles + $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", + ["bootstrap", "select2-bootstrap-5"], + $ver_admin, + ); + wp_enqueue_style( + "wp-agentic-writer-settings-v2", + WP_AGENTIC_WRITER_URL . "assets/css/settings-v2.css", + ["wpaw-agentic-components"], + $ver_settings, + ); + wp_enqueue_style( + "wp-agentic-writer-cost-log-grouped", + WP_AGENTIC_WRITER_URL . "assets/css/cost-log-grouped.css", + ["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", + ["jquery", "bootstrap", "select2"], + WP_AGENTIC_WRITER_VERSION, + true, + ); + + $settings = get_option("wp_agentic_writer_settings", []); + wp_localize_script("wp-agentic-writer-settings-v2", "wpawSettingsV2", [ + "ajaxUrl" => admin_url("admin-ajax.php"), + "nonce" => wp_create_nonce("wpaw_settings"), + "models" => $this->get_models_for_select(), + "currentModels" => [ + "planning" => + $settings["planning_model"] ?? + WPAW_Model_Registry::get_default_model("planning"), + "writing" => + $settings["writing_model"] ?? + ($settings["execution_model"] ?? + WPAW_Model_Registry::get_default_model("writing")), + "execution" => + $settings["writing_model"] ?? + ($settings["execution_model"] ?? + WPAW_Model_Registry::get_default_model("writing")), + "clarity" => + $settings["clarity_model"] ?? + WPAW_Model_Registry::get_default_model("clarity"), + "refinement" => + $settings["refinement_model"] ?? + WPAW_Model_Registry::get_default_model("refinement"), + "chat" => + $settings["chat_model"] ?? + WPAW_Model_Registry::get_default_model("chat"), + "image" => + $settings["image_model"] ?? + WPAW_Model_Registry::get_default_model("image"), + ], + "presets" => $this->get_model_presets(), + "i18n" => [ + "refreshing" => __("Refreshing...", "wp-agentic-writer"), + "refreshModels" => __("Refresh Models", "wp-agentic-writer"), + "saveSuccess" => __( + "Settings saved successfully!", + "wp-agentic-writer", + ), + "saveError" => __( + "Error saving settings.", + "wp-agentic-writer", + ), + "confirmReset" => __( + "Are you sure you want to reset all settings to defaults?", + "wp-agentic-writer", + ), + "loading" => __("Loading...", "wp-agentic-writer"), + "noResults" => __("No models found", "wp-agentic-writer"), + "searchPlaceholder" => __( + "Search models...", + "wp-agentic-writer", + ), + ], + ]); + } + + /** + * Get curated model presets (centralized source). + * + * These are intentional product decisions for different budget tiers. + * Model IDs may differ from registry defaults to balance cost/quality. + * + * @since 0.2.0 + * @return array Curated model presets. + */ + public function get_model_presets() + { + return [ + "budget" => [ + "chat" => "google/gemini-2.5-flash", + "clarity" => "google/gemini-2.5-flash", + "planning" => "google/gemini-2.5-flash", + "writing" => "mistralai/mistral-small-creative", + "refinement" => "google/gemini-2.5-flash", + "image" => "openai/gpt-4o", + ], + "balanced" => [ + "chat" => "google/gemini-2.5-flash", + "clarity" => "google/gemini-2.5-flash", + "planning" => "google/gemini-2.5-flash", + "writing" => "anthropic/claude-3.5-sonnet", + "refinement" => "anthropic/claude-3.5-sonnet", + "image" => "openai/gpt-4o", + ], + "premium" => [ + "chat" => "google/gemini-3-flash-preview", + "clarity" => "anthropic/claude-sonnet-4", + "planning" => "google/gemini-3-flash-preview", + "writing" => "openai/gpt-4.1", + "refinement" => "openai/gpt-4.1", + "image" => "openai/gpt-4o", + ], + ]; + } + + /** + * Get models for select dropdowns. + * + * @since 0.2.0 + * @return array Models grouped by category. + */ + public function get_models_for_select() + { + $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance(); + $models = $provider->get_cached_models(); + + if (is_wp_error($models)) { + return $this->get_fallback_models(); + } + + $transformed = $this->transform_models_for_js($models); + + // Debug logging + if (defined("WP_DEBUG") && WP_DEBUG) { + $custom_models = get_option("wp_agentic_writer_custom_models", []); + error_log( + "WPAW get_models_for_select: custom_models in DB = " . + wp_json_encode($custom_models), + ); + error_log( + "WPAW get_models_for_select: image models count = " . + count($transformed["image"]["all"] ?? []), + ); + } + + return $transformed; + } + + /** + * Format model name from ID. + * + * @since 0.2.0 + * @param string $model_id Model ID. + * @return string Formatted model name. + */ + private function format_model_name($model_id) + { + // Remove provider prefix + $parts = explode("/", $model_id); + $name = end($parts); + + // Remove :free suffix + $name = preg_replace('/:free$/i', "", $name); + + // Convert hyphens and underscores to spaces + $name = str_replace(["-", "_"], " ", $name); + + // Capitalize words + $name = ucwords($name); + + // Add provider prefix back + if (count($parts) > 1) { + $provider = ucfirst($parts[0]); + $name = $provider . ": " . $name; + } + + return $name; + } + + /** + * Get fallback models when API fails. + * + * @since 0.2.0 + * @return array Fallback model structure. + */ + private function get_fallback_models() + { + return [ + "planning" => [ + "recommended" => [ + [ + "id" => WPAW_Model_Registry::get_default_model( + "planning", + ), + "name" => WPAW_Model_Registry::get_model_display_name( + WPAW_Model_Registry::get_default_model("planning"), + ), + ], + ], + "all" => [ + [ + "id" => WPAW_Model_Registry::get_default_model( + "planning", + ), + "name" => WPAW_Model_Registry::get_model_display_name( + WPAW_Model_Registry::get_default_model("planning"), + ), + ], + ], + ], + "execution" => [ + "recommended" => [ + [ + "id" => WPAW_Model_Registry::get_fallback_model( + "execution", + ), + "name" => WPAW_Model_Registry::get_model_display_name( + WPAW_Model_Registry::get_fallback_model( + "execution", + ), + ), + ], + ], + "all" => [ + [ + "id" => WPAW_Model_Registry::get_fallback_model( + "execution", + ), + "name" => WPAW_Model_Registry::get_model_display_name( + WPAW_Model_Registry::get_fallback_model( + "execution", + ), + ), + ], + ], + ], + "image" => [ + "recommended" => [ + [ + "id" => WPAW_Model_Registry::get_default_model("image"), + "name" => WPAW_Model_Registry::get_model_display_name( + WPAW_Model_Registry::get_default_model("image"), + ), + ], + ], + "all" => [ + [ + "id" => WPAW_Model_Registry::get_default_model("image"), + "name" => WPAW_Model_Registry::get_model_display_name( + WPAW_Model_Registry::get_default_model("image"), + ), + ], + ], + ], + ]; + } + + /** + * Transform models structure for JavaScript consumption. + * + * @since 0.2.0 + * @param array $models Models from provider. + * @return array Transformed models. + */ + private function transform_models_for_js($models) + { + // Handle flat model list from OpenRouter + if ( + !empty($models) && + array_keys($models) === range(0, count($models) - 1) + ) { + $settings = get_option("wp_agentic_writer_settings", []); + $planning_id = + $settings["planning_model"] ?? + WPAW_Model_Registry::get_default_model("planning"); + $execution_id = + $settings["execution_model"] ?? + WPAW_Model_Registry::get_default_model("execution"); + $image_id = + $settings["image_model"] ?? + WPAW_Model_Registry::get_default_model("image"); + + $text_models = []; + $image_models = []; + + // Categorize models using OpenRouter's output_modalities field + foreach ($models as $model) { + if (empty($model["id"])) { + continue; + } + + $prompt_price = isset($model["pricing"]["prompt"]) + ? (float) $model["pricing"]["prompt"] + : 0; + $completion_price = isset($model["pricing"]["completion"]) + ? (float) $model["pricing"]["completion"] + : 0; + $image_price = isset($model["pricing"]["image"]) + ? (float) $model["pricing"]["image"] + : 0; + + $model_data = [ + "id" => $model["id"], + "name" => $model["name"] ?? $model["id"], + "is_free" => + $prompt_price <= 0.0 && + $completion_price <= 0.0 && + $image_price <= 0.0, + "pricing" => [ + "prompt" => $prompt_price, + "completion" => $completion_price, + "image" => $image_price, + ], + ]; + + // Use OpenRouter's output_modalities to categorize - trust OpenRouter's classification + $output_modalities = + $model["architecture"]["output_modalities"] ?? []; + + // Image generation models have 'image' in output_modalities + if (in_array("image", $output_modalities, true)) { + $image_models[] = $model_data; + } + + // Text models have 'text' in output_modalities (most models) + if (in_array("text", $output_modalities, true)) { + $text_models[] = $model_data; + } + } + + $chat_id = + $settings["chat_model"] ?? + WPAW_Model_Registry::get_default_model("chat"); + $clarity_id = + $settings["clarity_model"] ?? + WPAW_Model_Registry::get_default_model("clarity"); + $refinement_id = + $settings["refinement_model"] ?? + WPAW_Model_Registry::get_default_model("refinement"); + $writing_id = + $settings["writing_model"] ?? + ($settings["execution_model"] ?? + WPAW_Model_Registry::get_default_model("writing")); + + // Add currently selected models to text_models if not already present + $current_model_ids = [ + $planning_id, + $execution_id, + $chat_id, + $clarity_id, + $refinement_id, + $writing_id, + ]; + foreach ($current_model_ids as $model_id) { + $found = false; + foreach ($text_models as $tm) { + if ($tm["id"] === $model_id) { + $found = true; + break; + } + } + if (!$found && !empty($model_id)) { + $text_models[] = [ + "id" => $model_id, + "name" => $this->format_model_name($model_id), + "is_free" => false, + "pricing" => [ + "prompt" => 0, + "completion" => 0, + "image" => 0, + ], + ]; + } + } + + // Add currently selected image model to image_models if not already present + if (!empty($image_id)) { + $found = false; + foreach ($image_models as $im) { + if ($im["id"] === $image_id) { + $found = true; + break; + } + } + if (!$found) { + $image_models[] = [ + "id" => $image_id, + "name" => $this->format_model_name($image_id), + "is_free" => false, + "pricing" => [ + "prompt" => 0, + "completion" => 0, + "image" => 0, + ], + ]; + } + } + + // Add user's custom models (not listed in API but callable by ID) + $custom_models = get_option("wp_agentic_writer_custom_models", []); + foreach ($custom_models as $custom) { + if (empty($custom["id"])) { + continue; + } + $custom_model_data = [ + "id" => $custom["id"], + "name" => !empty($custom["name"]) + ? $custom["name"] + : $this->format_model_name($custom["id"]), + "is_free" => false, + "is_custom" => true, + "pricing" => [ + "prompt" => 0, + "completion" => 0, + "image" => 0, + ], + ]; + + $type = $custom["type"] ?? "text"; + if ("image" === $type) { + $image_models[] = $custom_model_data; + } else { + $text_models[] = $custom_model_data; + } + } + + // Now create find_model closure after all models are added + $find_model = function ($model_id) use ( + $text_models, + $image_models, + ) { + foreach (array_merge($text_models, $image_models) as $model) { + if ($model["id"] === $model_id) { + return $model; + } + } + // If model not found, create a fallback entry + if (!empty($model_id)) { + return [ + "id" => $model_id, + "name" => $this->format_model_name($model_id), + "is_free" => false, + "pricing" => [ + "prompt" => 0, + "completion" => 0, + "image" => 0, + ], + ]; + } + return null; + }; + + return [ + "planning" => [ + "recommended" => array_filter([$find_model($planning_id)]), + "all" => $text_models, + ], + "execution" => [ + "recommended" => array_filter([$find_model($execution_id)]), + "all" => $text_models, + ], + "chat" => [ + "recommended" => array_filter([$find_model($chat_id)]), + "all" => $text_models, + ], + "image" => [ + "recommended" => array_filter([$find_model($image_id)]), + "all" => $image_models, + ], + ]; + } + + $transformed = []; + + foreach ($models as $type => $categories) { + if (!isset($transformed[$type])) { + $transformed[$type] = [ + "recommended" => [], + "all" => [], + ]; + } + + // Combine free and paid into 'all' array + $all_models = array_merge( + $categories["free"] ?? [], + $categories["paid"] ?? [], + ); + + // Remove duplicates + $recommended_ids = []; + foreach ($categories["recommended"] ?? [] as $model) { + $transformed[$type]["recommended"][] = $model; + $recommended_ids[$model["id"]] = true; + } + + // Add all models, avoiding duplicates with recommended + foreach ($all_models as $model) { + if (!isset($recommended_ids[$model["id"]])) { + $transformed[$type]["all"][] = $model; + } + } + } + + return $transformed; + } + + /** + * AJAX handler for refreshing models. + * + * @since 0.2.0 + */ + public function ajax_refresh_models() + { + if (!check_ajax_referer("wpaw_settings", "nonce", false)) { + wp_send_json_error(["message" => "Invalid nonce"]); + return; + } + + if (!current_user_can("manage_options")) { + wp_send_json_error(["message" => "Permission denied"]); + } + + $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance(); + $models = $provider->fetch_and_cache_models(true); + + if (is_wp_error($models)) { + wp_send_json_error(["message" => $models->get_error_message()]); + } + + $transformed = $this->transform_models_for_js($models); + + wp_send_json_success([ + "models" => $transformed, + "message" => __( + "Models refreshed successfully!", + "wp-agentic-writer", + ), + ]); + } + + /** + * AJAX handler for saving a custom model. + * + * @since 0.2.0 + */ + public function ajax_save_custom_model() + { + if (!check_ajax_referer("wpaw_settings", "nonce", false)) { + wp_send_json_error(["message" => "Invalid nonce"]); + return; + } + + if (!current_user_can("manage_options")) { + wp_send_json_error(["message" => "Permission denied"]); + return; + } + + $model_id = isset($_POST["model_id"]) + ? sanitize_text_field($_POST["model_id"]) + : ""; + $model_name = isset($_POST["model_name"]) + ? sanitize_text_field($_POST["model_name"]) + : ""; + $model_type = isset($_POST["model_type"]) + ? sanitize_text_field($_POST["model_type"]) + : "text"; + + if (empty($model_id)) { + wp_send_json_error(["message" => "Model ID is required"]); + return; + } + + // Use separate option for custom models + $custom_models = get_option("wp_agentic_writer_custom_models", []); + + // Check if model already exists, update it + $found = false; + foreach ($custom_models as $index => $cm) { + if ($cm["id"] === $model_id) { + $custom_models[$index] = [ + "id" => $model_id, + "name" => $model_name, + "type" => $model_type, + ]; + $found = true; + break; + } + } + + // Add new model if not found + if (!$found) { + $custom_models[] = [ + "id" => $model_id, + "name" => $model_name, + "type" => $model_type, + ]; + } + + $saved = update_option( + "wp_agentic_writer_custom_models", + array_values($custom_models), + ); + + // Get fresh combined models for Select2 + $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance(); + $models = $provider->get_cached_models(); + if (is_wp_error($models)) { + $models = []; + } + $transformed = $this->transform_models_for_js($models); + + wp_send_json_success([ + "message" => __("Custom model saved!", "wp-agentic-writer"), + "models" => $transformed, + ]); + } + + /** + * AJAX handler for deleting a custom model. + * + * @since 0.2.0 + */ + public function ajax_delete_custom_model() + { + if (!check_ajax_referer("wpaw_settings", "nonce", false)) { + wp_send_json_error(["message" => "Invalid nonce"]); + return; + } + + if (!current_user_can("manage_options")) { + wp_send_json_error(["message" => "Permission denied"]); + return; + } + + $model_id = isset($_POST["model_id"]) + ? sanitize_text_field($_POST["model_id"]) + : ""; + + if (empty($model_id)) { + wp_send_json_error(["message" => "Model ID is required"]); + return; + } + + // Use separate option for custom models + $custom_models = get_option("wp_agentic_writer_custom_models", []); + + // Remove the model + $custom_models = array_filter($custom_models, function ($cm) use ( + $model_id, + ) { + return $cm["id"] !== $model_id; + }); + + update_option( + "wp_agentic_writer_custom_models", + array_values($custom_models), + ); + + // Get fresh combined models for Select2 + $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance(); + $models = $provider->get_cached_models(); + if (is_wp_error($models)) { + $models = []; + } + $transformed = $this->transform_models_for_js($models); + + wp_send_json_success([ + "message" => __("Custom model deleted!", "wp-agentic-writer"), + "models" => $transformed, + ]); + } + + /** + * AJAX handler for getting cost log data (server-side pagination). + * + * @since 0.2.0 + */ + public function ajax_get_cost_log_data() + { + if (!check_ajax_referer("wpaw_settings", "nonce", false)) { + wp_send_json_error(["message" => "Invalid nonce"]); + return; + } + + if (!current_user_can("manage_options")) { + wp_send_json_error(["message" => "Permission denied"]); + return; + } + + global $wpdb; + $table_name = $wpdb->prefix . "wpaw_cost_tracking"; + + // Check if table exists + $table_exists = + $wpdb->get_var("SHOW TABLES LIKE '{$table_name}'") === $table_name; + + if (!$table_exists) { + wp_send_json_success([ + "records" => [], + "total_items" => 0, + "total_pages" => 0, + "current_page" => 1, + "per_page" => 25, + "stats" => [ + "all_time" => "0.0000", + "monthly" => "0.0000", + "today" => "0.0000", + "avg_per_post" => "0.0000", + "action_summary" => [], + ], + "filters" => [ + "models" => [], + "types" => [], + ], + ]); + return; + } + + // Get parameters + $page = isset($_POST["page"]) ? max(1, intval($_POST["page"])) : 1; + $per_page = isset($_POST["per_page"]) + ? min(100, max(10, intval($_POST["per_page"]))) + : 25; + $offset = ($page - 1) * $per_page; + + // Filters + $filter_post = isset($_POST["filter_post"]) + ? intval($_POST["filter_post"]) + : 0; + $filter_model = isset($_POST["filter_model"]) + ? sanitize_text_field($_POST["filter_model"]) + : ""; + $filter_type = isset($_POST["filter_type"]) + ? sanitize_text_field($_POST["filter_type"]) + : ""; + $filter_date_from = isset($_POST["filter_date_from"]) + ? sanitize_text_field($_POST["filter_date_from"]) + : ""; + $filter_date_to = isset($_POST["filter_date_to"]) + ? sanitize_text_field($_POST["filter_date_to"]) + : ""; + + // Build WHERE clause (OpenRouter-only for this OpenRouter cost log screen). + $where = ["provider = 'openrouter'"]; + if ($filter_post > 0) { + $where[] = $wpdb->prepare("post_id = %d", $filter_post); + } + if (!empty($filter_model)) { + $where[] = $wpdb->prepare("model = %s", $filter_model); + } + if (!empty($filter_type)) { + $where[] = $wpdb->prepare("action = %s", $filter_type); + } + if (!empty($filter_date_from)) { + $where[] = $wpdb->prepare( + "DATE(created_at) >= %s", + $filter_date_from, + ); + } + if (!empty($filter_date_to)) { + $where[] = $wpdb->prepare( + "DATE(created_at) <= %s", + $filter_date_to, + ); + } + $where_clause = implode(" AND ", $where); + + // Get total count of distinct posts + $total_items = $wpdb->get_var( + "SELECT COUNT(DISTINCT post_id) FROM {$table_name} WHERE {$where_clause}", + ); + $total_pages = ceil($total_items / $per_page); + + // Optimized: Get grouped records with aggregation in SQL. + // This pushes grouping and ordering to the database instead of PHP. + $grouped_records_sql = $wpdb->get_results( + $wpdb->prepare( + "SELECT post_id, SUM(cost) as total_cost, COUNT(*) as call_count, @@ -700,663 +969,933 @@ class WP_Agentic_Writer_Settings_V2 { GROUP BY post_id ORDER BY post_id DESC LIMIT %d OFFSET %d", - $per_page, - $offset - ), - ARRAY_A - ); + $per_page, + $offset, + ), + ARRAY_A, + ); - // Build grouped records with post details - $formatted_records = array(); - $post_ids = array(); + // Build grouped records with post details + $formatted_records = []; + $post_ids = []; - foreach ( $grouped_records_sql as $row ) { - $post_id = (int) $row['post_id']; - $post_ids[] = $post_id; + foreach ($grouped_records_sql as $row) { + $post_id = (int) $row["post_id"]; + $post_ids[] = $post_id; - if ( $post_id > 0 ) { - $post_title = get_the_title( $post_id ); - if ( ! $post_title ) { - $post_title = sprintf( __( '[Removed Post #%d]', 'wp-agentic-writer' ), $post_id ); - $post_link = ''; - } else { - $post_link = get_edit_post_link( $post_id, 'raw' ); - } - } else { - $post_title = __( 'System/Other', 'wp-agentic-writer' ); - $post_link = ''; - } + if ($post_id > 0) { + $post_title = get_the_title($post_id); + if (!$post_title) { + $post_title = sprintf( + __("[Removed Post #%d]", "wp-agentic-writer"), + $post_id, + ); + $post_link = ""; + } else { + $post_link = get_edit_post_link($post_id, "raw"); + } + } else { + $post_title = __("System/Other", "wp-agentic-writer"); + $post_link = ""; + } - $formatted_records[] = array( - 'post_id' => $post_id, - 'post_title' => $post_title, - 'post_link' => $post_link, - 'total_cost' => number_format( (float) $row['total_cost'], 4 ), - 'call_count' => (int) $row['call_count'], - 'last_call' => date_i18n( 'Y-m-d H:i:s', strtotime( $row['last_call'] ) ), - 'details' => array(), // Lazy-loaded on expand - ); - } + $formatted_records[] = [ + "post_id" => $post_id, + "post_title" => $post_title, + "post_link" => $post_link, + "total_cost" => number_format((float) $row["total_cost"], 4), + "call_count" => (int) $row["call_count"], + "last_call" => date_i18n( + "Y-m-d H:i:s", + strtotime($row["last_call"]), + ), + "details" => [], // Lazy-loaded on expand + ]; + } - // Load detail rows for visible posts. - // This keeps expand/collapse usable without requiring a second endpoint. - if ( ! empty( $post_ids ) ) { - $placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ); - $details_sql = $wpdb->prepare( - "SELECT post_id, created_at, model, action, input_tokens, output_tokens, cost + // Load detail rows for visible posts. + // This keeps expand/collapse usable without requiring a second endpoint. + if (!empty($post_ids)) { + $placeholders = implode(",", array_fill(0, count($post_ids), "%d")); + $details_sql = $wpdb->prepare( + "SELECT post_id, created_at, model, action, input_tokens, output_tokens, cost FROM {$table_name} WHERE provider = 'openrouter' AND post_id IN ({$placeholders}) ORDER BY created_at DESC", - ...$post_ids - ); - $detail_rows = $wpdb->get_results( $details_sql, ARRAY_A ); - $detail_map = array(); - foreach ( $detail_rows as $detail_row ) { - $pid = (int) ( $detail_row['post_id'] ?? 0 ); - if ( ! isset( $detail_map[ $pid ] ) ) { - $detail_map[ $pid ] = array(); - } - $detail_map[ $pid ][] = array( - 'created_at' => date_i18n( 'Y-m-d H:i:s', strtotime( $detail_row['created_at'] ) ), - 'model' => (string) ( $detail_row['model'] ?? '' ), - 'action' => (string) ( $detail_row['action'] ?? '' ), - 'input_tokens' => (int) ( $detail_row['input_tokens'] ?? 0 ), - 'output_tokens' => (int) ( $detail_row['output_tokens'] ?? 0 ), - 'cost' => number_format( (float) ( $detail_row['cost'] ?? 0 ), 4 ), - ); - } + ...$post_ids, + ); + $detail_rows = $wpdb->get_results($details_sql, ARRAY_A); + $detail_map = []; + foreach ($detail_rows as $detail_row) { + $pid = (int) ($detail_row["post_id"] ?? 0); + if (!isset($detail_map[$pid])) { + $detail_map[$pid] = []; + } + $detail_map[$pid][] = [ + "created_at" => date_i18n( + "Y-m-d H:i:s", + strtotime($detail_row["created_at"]), + ), + "model" => (string) ($detail_row["model"] ?? ""), + "action" => (string) ($detail_row["action"] ?? ""), + "input_tokens" => (int) ($detail_row["input_tokens"] ?? 0), + "output_tokens" => + (int) ($detail_row["output_tokens"] ?? 0), + "cost" => number_format( + (float) ($detail_row["cost"] ?? 0), + 4, + ), + ]; + } - foreach ( $formatted_records as $idx => $formatted_record ) { - $pid = (int) ( $formatted_record['post_id'] ?? 0 ); - $formatted_records[ $idx ]['details'] = $detail_map[ $pid ] ?? array(); - $formatted_records[ $idx ]['details_total'] = count( $formatted_records[ $idx ]['details'] ); - } - } + foreach ($formatted_records as $idx => $formatted_record) { + $pid = (int) ($formatted_record["post_id"] ?? 0); + $formatted_records[$idx]["details"] = $detail_map[$pid] ?? []; + $formatted_records[$idx]["details_total"] = count( + $formatted_records[$idx]["details"], + ); + } + } - // Get summary stats (all-time aggregation in SQL) - $total_all_time = $wpdb->get_var( "SELECT COALESCE(SUM(cost), 0) FROM {$table_name} WHERE provider = 'openrouter'" ); - $month_start = date( 'Y-m-01 00:00:00' ); - $monthly_total = $wpdb->get_var( - $wpdb->prepare( - "SELECT COALESCE(SUM(cost), 0) FROM {$table_name} WHERE provider = 'openrouter' AND created_at >= %s", - $month_start - ) - ); - $today_total = $wpdb->get_var( - $wpdb->prepare( - "SELECT COALESCE(SUM(cost), 0) FROM {$table_name} WHERE provider = 'openrouter' AND DATE(created_at) = %s", - current_time( 'Y-m-d' ) - ) - ); - $total_posts = $wpdb->get_var( "SELECT COUNT(DISTINCT post_id) FROM {$table_name} WHERE provider = 'openrouter' AND post_id > 0" ); - $avg_per_post = $total_posts > 0 ? $total_all_time / $total_posts : 0; + // Get summary stats (all-time aggregation in SQL) + $total_all_time = $wpdb->get_var( + "SELECT COALESCE(SUM(cost), 0) FROM {$table_name} WHERE provider = 'openrouter'", + ); + $month_start = date("Y-m-01 00:00:00"); + $monthly_total = $wpdb->get_var( + $wpdb->prepare( + "SELECT COALESCE(SUM(cost), 0) FROM {$table_name} WHERE provider = 'openrouter' AND created_at >= %s", + $month_start, + ), + ); + $today_total = $wpdb->get_var( + $wpdb->prepare( + "SELECT COALESCE(SUM(cost), 0) FROM {$table_name} WHERE provider = 'openrouter' AND DATE(created_at) = %s", + current_time("Y-m-d"), + ), + ); + $total_posts = $wpdb->get_var( + "SELECT COUNT(DISTINCT post_id) FROM {$table_name} WHERE provider = 'openrouter' AND post_id > 0", + ); + $avg_per_post = $total_posts > 0 ? $total_all_time / $total_posts : 0; - $action_summary_rows = $wpdb->get_results( - "SELECT action, COUNT(*) AS calls, COALESCE(SUM(cost), 0) AS total_cost, COALESCE(AVG(cost), 0) AS avg_cost + $action_summary_rows = $wpdb->get_results( + "SELECT action, COUNT(*) AS calls, COALESCE(SUM(cost), 0) AS total_cost, COALESCE(AVG(cost), 0) AS avg_cost FROM {$table_name} WHERE provider = 'openrouter' GROUP BY action ORDER BY total_cost DESC", - ARRAY_A - ); - $action_summary = array(); - foreach ( $action_summary_rows as $row ) { - $action_summary[] = array( - 'action' => (string) ( $row['action'] ?? '' ), - 'calls' => (int) ( $row['calls'] ?? 0 ), - 'total' => number_format( (float) ( $row['total_cost'] ?? 0 ), 4 ), - 'average' => number_format( (float) ( $row['avg_cost'] ?? 0 ), 4 ), - ); - } + ARRAY_A, + ); + $action_summary = []; + foreach ($action_summary_rows as $row) { + $action_summary[] = [ + "action" => (string) ($row["action"] ?? ""), + "calls" => (int) ($row["calls"] ?? 0), + "total" => number_format((float) ($row["total_cost"] ?? 0), 4), + "average" => number_format((float) ($row["avg_cost"] ?? 0), 4), + ]; + } - // Get filter options (distinct values from DB) - $models = $wpdb->get_col( "SELECT DISTINCT model FROM {$table_name} WHERE provider = 'openrouter' ORDER BY model LIMIT 100" ); - $types = $wpdb->get_col( "SELECT DISTINCT action FROM {$table_name} WHERE provider = 'openrouter' ORDER BY action" ); + // Get filter options (distinct values from DB) + $models = $wpdb->get_col( + "SELECT DISTINCT model FROM {$table_name} WHERE provider = 'openrouter' ORDER BY model LIMIT 100", + ); + $types = $wpdb->get_col( + "SELECT DISTINCT action FROM {$table_name} WHERE provider = 'openrouter' ORDER BY action", + ); - wp_send_json_success( array( - 'records' => $formatted_records, - 'total_items' => intval( $total_items ), - 'total_pages' => intval( $total_pages ), - 'current_page' => $page, - 'per_page' => $per_page, - 'stats' => array( - 'all_time' => number_format( (float) $total_all_time, 4 ), - 'monthly' => number_format( (float) $monthly_total, 4 ), - 'today' => number_format( (float) $today_total, 4 ), - 'avg_per_post' => number_format( (float) $avg_per_post, 4 ), - 'action_summary' => $action_summary, - ), - 'filters' => array( - 'models' => $models, - 'types' => $types, - ), - ) ); - } + wp_send_json_success([ + "records" => $formatted_records, + "total_items" => intval($total_items), + "total_pages" => intval($total_pages), + "current_page" => $page, + "per_page" => $per_page, + "stats" => [ + "all_time" => number_format((float) $total_all_time, 4), + "monthly" => number_format((float) $monthly_total, 4), + "today" => number_format((float) $today_total, 4), + "avg_per_post" => number_format((float) $avg_per_post, 4), + "action_summary" => $action_summary, + ], + "filters" => [ + "models" => $models, + "types" => $types, + ], + ]); + } - /** - * AJAX handler for header statistics. - * - * @since 0.2.0 - */ - public function ajax_get_header_stats() { - if ( ! check_ajax_referer( 'wpaw_settings', 'nonce', false ) ) { - wp_send_json_error( array( 'message' => 'Invalid nonce' ) ); - return; - } + /** + * AJAX handler for header statistics. + * + * @since 0.2.0 + */ + public function ajax_get_header_stats() + { + if (!check_ajax_referer("wpaw_settings", "nonce", false)) { + wp_send_json_error(["message" => "Invalid nonce"]); + return; + } - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json_error( array( 'message' => 'Permission denied' ) ); - return; - } + if (!current_user_can("manage_options")) { + wp_send_json_error(["message" => "Permission denied"]); + return; + } - global $wpdb; - $table_name = $wpdb->prefix . 'wpaw_cost_tracking'; + global $wpdb; + $table_name = $wpdb->prefix . "wpaw_cost_tracking"; - // Check if table exists - $table_exists = $wpdb->get_var( "SHOW TABLES LIKE '{$table_name}'" ) === $table_name; + // Check if table exists + $table_exists = + $wpdb->get_var("SHOW TABLES LIKE '{$table_name}'") === $table_name; - if ( ! $table_exists ) { - wp_send_json_success( - array( - 'articles' => 0, - 'total_cost' => '0.00', - 'api_status' => 'Not configured', - 'api_online' => false, - 'last_updated' => 'Never', - ) - ); - return; - } + if (!$table_exists) { + wp_send_json_success([ + "articles" => 0, + "total_cost" => "0.00", + "api_status" => "Not configured", + "api_online" => false, + "last_updated" => "Never", + ]); + return; + } - // Get total articles - $total_articles = $wpdb->get_var( - "SELECT COUNT(DISTINCT post_id) FROM {$table_name} WHERE post_id > 0" - ); + // Get total articles + $total_articles = $wpdb->get_var( + "SELECT COUNT(DISTINCT post_id) FROM {$table_name} WHERE post_id > 0", + ); - // Get total cost - $total_cost = $wpdb->get_var( "SELECT SUM(cost) FROM {$table_name}" ); + // Get total cost + $total_cost = $wpdb->get_var("SELECT SUM(cost) FROM {$table_name}"); - // Check API status - $settings = get_option( 'wp_agentic_writer_settings', array() ); - $api_key = $settings['openrouter_api_key'] ?? ''; - $api_online = ! empty( $api_key ); + // Check API status + $settings = get_option("wp_agentic_writer_settings", []); + $api_key = $settings["openrouter_api_key"] ?? ""; + $api_online = !empty($api_key); - // Get last activity - $last_activity = $wpdb->get_var( - "SELECT created_at FROM {$table_name} ORDER BY created_at DESC LIMIT 1" - ); - $last_updated = $last_activity ? human_time_diff( strtotime( $last_activity ), current_time( 'timestamp' ) ) . ' ago' : 'Never'; + // Get last activity + $last_activity = $wpdb->get_var( + "SELECT created_at FROM {$table_name} ORDER BY created_at DESC LIMIT 1", + ); + $last_updated = $last_activity + ? human_time_diff( + strtotime($last_activity), + current_time("timestamp"), + ) . " ago" + : "Never"; - wp_send_json_success( - array( - 'articles' => intval( $total_articles ), - 'total_cost' => number_format( (float) $total_cost, 2 ), - 'api_status' => $api_online ? 'Online' : 'Not configured', - 'api_online' => $api_online, - 'last_updated' => $last_updated, - ) - ); - } + wp_send_json_success([ + "articles" => intval($total_articles), + "total_cost" => number_format((float) $total_cost, 2), + "api_status" => $api_online ? "Online" : "Not configured", + "api_online" => $api_online, + "last_updated" => $last_updated, + ]); + } - /** - * AJAX handler for debugging models. - * - * @since 0.2.0 - */ - public function ajax_debug_models() { - if ( ! check_ajax_referer( 'wpaw_settings', 'nonce', false ) ) { - wp_send_json_error( array( 'message' => 'Invalid nonce' ) ); - return; - } + /** + * AJAX handler for debugging models. + * + * @since 0.2.0 + */ + public function ajax_debug_models() + { + if (!check_ajax_referer("wpaw_settings", "nonce", false)) { + wp_send_json_error(["message" => "Invalid nonce"]); + return; + } - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json_error( array( 'message' => 'Permission denied' ) ); - return; - } + if (!current_user_can("manage_options")) { + wp_send_json_error(["message" => "Permission denied"]); + return; + } - $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance(); - $models = $provider->get_cached_models(); + $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance(); + $models = $provider->get_cached_models(); - if ( is_wp_error( $models ) ) { - wp_send_json_error( array( 'message' => $models->get_error_message() ) ); - return; - } + if (is_wp_error($models)) { + wp_send_json_error(["message" => $models->get_error_message()]); + return; + } - // Check for specific models - $check_models = array( 'deepseek/deepseek-chat-v3-0324', 'anthropic/claude-3.5-sonnet' ); - $found_models = array(); - $missing_models = array(); + // Check for specific models + $check_models = [ + "deepseek/deepseek-chat-v3-0324", + "anthropic/claude-3.5-sonnet", + ]; + $found_models = []; + $missing_models = []; - foreach ( $check_models as $check_id ) { - $found = false; - foreach ( $models as $model ) { - if ( isset( $model['id'] ) && $model['id'] === $check_id ) { - $found = true; - $found_models[] = array( - 'id' => $model['id'], - 'name' => $model['name'] ?? 'N/A', - ); - break; - } - } - if ( ! $found ) { - $missing_models[] = $check_id; - } - } + foreach ($check_models as $check_id) { + $found = false; + foreach ($models as $model) { + if (isset($model["id"]) && $model["id"] === $check_id) { + $found = true; + $found_models[] = [ + "id" => $model["id"], + "name" => $model["name"] ?? "N/A", + ]; + break; + } + } + if (!$found) { + $missing_models[] = $check_id; + } + } - wp_send_json_success( array( - 'total_models' => count( $models ), - 'found_models' => $found_models, - 'missing_models' => $missing_models, - 'sample_models' => array_slice( array_map( function( $m ) { - return array( 'id' => $m['id'] ?? 'N/A', 'name' => $m['name'] ?? 'N/A' ); - }, $models ), 0, 10 ), - ) ); - } + wp_send_json_success([ + "total_models" => count($models), + "found_models" => $found_models, + "missing_models" => $missing_models, + "sample_models" => array_slice( + array_map(function ($m) { + return [ + "id" => $m["id"] ?? "N/A", + "name" => $m["name"] ?? "N/A", + ]; + }, $models), + 0, + 10, + ), + ]); + } - /** - * AJAX handler for testing API connection. - * - * @since 0.2.0 - */ - public function ajax_test_api_connection() { - if ( ! check_ajax_referer( 'wpaw_settings', 'nonce', false ) ) { - wp_send_json_error( array( 'message' => 'Invalid nonce' ) ); - return; - } + /** + * AJAX handler for testing API connection. + * + * @since 0.2.0 + */ + public function ajax_test_api_connection() + { + if (!check_ajax_referer("wpaw_settings", "nonce", false)) { + wp_send_json_error(["message" => "Invalid nonce"]); + return; + } - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json_error( array( 'message' => 'Permission denied' ) ); - return; - } + if (!current_user_can("manage_options")) { + wp_send_json_error(["message" => "Permission denied"]); + return; + } - $settings = get_option( 'wp_agentic_writer_settings', array() ); - $api_key = $settings['openrouter_api_key'] ?? ''; + $settings = get_option("wp_agentic_writer_settings", []); + $api_key = $settings["openrouter_api_key"] ?? ""; - if ( empty( $api_key ) ) { - wp_send_json_error( array( 'message' => 'API key is not configured' ) ); - return; - } + if (empty($api_key)) { + wp_send_json_error(["message" => "API key is not configured"]); + return; + } - // Test API connection by making a simple request - $response = wp_remote_get( - 'https://openrouter.ai/api/v1/models?output_modalities=all', - array( - 'headers' => array( - 'Authorization' => 'Bearer ' . $api_key, - 'HTTP-Referer' => home_url(), - ), - 'timeout' => 10, - ) - ); + // Test API connection by making a simple request + $response = wp_remote_get( + "https://openrouter.ai/api/v1/models?output_modalities=all", + [ + "headers" => [ + "Authorization" => "Bearer " . $api_key, + "HTTP-Referer" => home_url(), + ], + "timeout" => 10, + ], + ); - if ( is_wp_error( $response ) ) { - wp_send_json_error( - array( - 'message' => 'Connection failed: ' . $response->get_error_message(), - ) - ); - return; - } + if (is_wp_error($response)) { + wp_send_json_error([ + "message" => + "Connection failed: " . $response->get_error_message(), + ]); + return; + } - $status_code = wp_remote_retrieve_response_code( $response ); - $body = wp_remote_retrieve_body( $response ); + $status_code = wp_remote_retrieve_response_code($response); + $body = wp_remote_retrieve_body($response); - if ( 200 === $status_code ) { - $data = json_decode( $body, true ); - if ( isset( $data['data'] ) && is_array( $data['data'] ) ) { - wp_send_json_success( - array( - 'message' => 'API connection successful!', - 'models_count' => count( $data['data'] ), - ) - ); - return; - } - } + if (200 === $status_code) { + $data = json_decode($body, true); + if (isset($data["data"]) && is_array($data["data"])) { + wp_send_json_success([ + "message" => "API connection successful!", + "models_count" => count($data["data"]), + ]); + return; + } + } - // Handle error responses - if ( 401 === $status_code ) { - wp_send_json_error( array( 'message' => 'Invalid API key' ) ); - return; - } + // Handle error responses + if (401 === $status_code) { + wp_send_json_error(["message" => "Invalid API key"]); + return; + } - if ( 403 === $status_code ) { - wp_send_json_error( array( 'message' => 'Access forbidden - check your API key permissions' ) ); - return; - } + if (403 === $status_code) { + wp_send_json_error([ + "message" => + "Access forbidden - check your API key permissions", + ]); + return; + } - wp_send_json_error( - array( - 'message' => 'API connection failed with status code: ' . $status_code, - ) - ); - } + wp_send_json_error([ + "message" => + "API connection failed with status code: " . $status_code, + ]); + } - /** - * Add settings page to admin menu. - * - * @since 0.2.0 - */ - public function add_settings_page() { - add_options_page( - __( 'WP Agentic Writer', 'wp-agentic-writer' ), - __( 'Agentic Writer', 'wp-agentic-writer' ), - 'manage_options', - 'wp-agentic-writer-settings', - array( $this, 'render_settings_page' ) - ); - } + /** + * Add settings page to admin menu. + * + * @since 0.2.0 + */ + public function add_settings_page() + { + add_options_page( + __("WP Agentic Writer", "wp-agentic-writer"), + __("Agentic Writer", "wp-agentic-writer"), + "manage_options", + "wp-agentic-writer-settings", + [$this, "render_settings_page"], + ); + } - /** - * Register settings. - * - * @since 0.2.0 - */ - public function register_settings() { - register_setting( - 'wp_agentic_writer_settings', - 'wp_agentic_writer_settings', - array( - 'sanitize_callback' => array( $this, 'sanitize_settings' ), - ) - ); - } + /** + * Register settings. + * + * @since 0.2.0 + */ + public function register_settings() + { + register_setting( + "wp_agentic_writer_settings", + "wp_agentic_writer_settings", + [ + "sanitize_callback" => [$this, "sanitize_settings"], + ], + ); + } - /** - * Sanitize settings. - * - * @since 0.2.0 - * @param array $input Settings input. - * @return array Sanitized settings. - */ - public function sanitize_settings( $input ) { - $sanitized = array(); + /** + * Sanitize settings. + * + * @since 0.2.0 + * @param array $input Settings input. + * @return array Sanitized settings. + */ + public function sanitize_settings($input) + { + $sanitized = []; - // 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 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) - using model registry for defaults - $sanitized['chat_model'] = sanitize_text_field( $input['chat_model'] ?? WPAW_Model_Registry::get_default_model( 'chat' ) ); - $sanitized['clarity_model'] = sanitize_text_field( $input['clarity_model'] ?? WPAW_Model_Registry::get_default_model( 'clarity' ) ); - $sanitized['planning_model'] = sanitize_text_field( $input['planning_model'] ?? WPAW_Model_Registry::get_default_model( 'planning' ) ); - $sanitized['writing_model'] = sanitize_text_field( $input['writing_model'] ?? WPAW_Model_Registry::get_default_model( 'writing' ) ); - $sanitized['refinement_model'] = sanitize_text_field( $input['refinement_model'] ?? WPAW_Model_Registry::get_default_model( 'refinement' ) ); - $sanitized['image_model'] = sanitize_text_field( $input['image_model'] ?? WPAW_Model_Registry::get_default_model( 'image' ) ); + // Sanitize model names (6 models) - using model registry for defaults + $sanitized["chat_model"] = sanitize_text_field( + $input["chat_model"] ?? + WPAW_Model_Registry::get_default_model("chat"), + ); + $sanitized["clarity_model"] = sanitize_text_field( + $input["clarity_model"] ?? + WPAW_Model_Registry::get_default_model("clarity"), + ); + $sanitized["planning_model"] = sanitize_text_field( + $input["planning_model"] ?? + WPAW_Model_Registry::get_default_model("planning"), + ); + $sanitized["writing_model"] = sanitize_text_field( + $input["writing_model"] ?? + WPAW_Model_Registry::get_default_model("writing"), + ); + $sanitized["refinement_model"] = sanitize_text_field( + $input["refinement_model"] ?? + WPAW_Model_Registry::get_default_model("refinement"), + ); + $sanitized["image_model"] = sanitize_text_field( + $input["image_model"] ?? + WPAW_Model_Registry::get_default_model("image"), + ); - // Legacy support: map execution_model to writing_model - if ( isset( $input['execution_model'] ) && ! isset( $input['writing_model'] ) ) { - $sanitized['writing_model'] = sanitize_text_field( $input['execution_model'] ); - } + // Legacy support: map execution_model to writing_model + if ( + isset($input["execution_model"]) && + !isset($input["writing_model"]) + ) { + $sanitized["writing_model"] = sanitize_text_field( + $input["execution_model"], + ); + } - // Sanitize boolean values - $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; - $sanitized['allow_openrouter_fallback'] = isset( $input['allow_openrouter_fallback'] ) && '1' === $input['allow_openrouter_fallback']; - $sanitized['openrouter_provider_routing_enabled'] = isset( $input['openrouter_provider_routing_enabled'] ) && '1' === $input['openrouter_provider_routing_enabled']; - $sanitized['openrouter_provider_only'] = isset( $input['openrouter_provider_only'] ) && '1' === $input['openrouter_provider_only']; - $sanitized['openrouter_allow_provider_fallbacks'] = isset( $input['openrouter_allow_provider_fallbacks'] ) && '1' === $input['openrouter_allow_provider_fallbacks']; + // Sanitize boolean values + $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; + $sanitized["allow_openrouter_fallback"] = + isset($input["allow_openrouter_fallback"]) && + "1" === $input["allow_openrouter_fallback"]; + $sanitized["openrouter_provider_routing_enabled"] = + isset($input["openrouter_provider_routing_enabled"]) && + "1" === $input["openrouter_provider_routing_enabled"]; + $sanitized["openrouter_provider_only"] = + isset($input["openrouter_provider_only"]) && + "1" === $input["openrouter_provider_only"]; + $sanitized["openrouter_allow_provider_fallbacks"] = + isset($input["openrouter_allow_provider_fallbacks"]) && + "1" === $input["openrouter_allow_provider_fallbacks"]; - $provider_slug = isset( $input['openrouter_provider_slug'] ) ? sanitize_key( $input['openrouter_provider_slug'] ) : 'auto'; - $sanitized['openrouter_provider_slug'] = '' !== $provider_slug ? $provider_slug : 'auto'; + $provider_slug = isset($input["openrouter_provider_slug"]) + ? sanitize_key($input["openrouter_provider_slug"]) + : "auto"; + $sanitized["openrouter_provider_slug"] = + "" !== $provider_slug ? $provider_slug : "auto"; - // Sanitize search options - $sanitized['search_engine'] = in_array( $input['search_engine'] ?? '', array( 'auto', 'native', 'exa' ), true ) - ? $input['search_engine'] - : 'auto'; - $sanitized['search_depth'] = isset( $input['search_depth'] ) && in_array( $input['search_depth'], array( 'low', 'medium', 'high' ), true ) - ? $input['search_depth'] - : 'medium'; + // Sanitize search options + $sanitized["search_engine"] = in_array( + $input["search_engine"] ?? "", + ["auto", "native", "exa"], + true, + ) + ? $input["search_engine"] + : "auto"; + $sanitized["search_depth"] = + isset($input["search_depth"]) && + in_array($input["search_depth"], ["low", "medium", "high"], true) + ? $input["search_depth"] + : "medium"; - // Sanitize budget - $sanitized['monthly_budget'] = floatval( $input['monthly_budget'] ?? 600 ); + // Sanitize budget + $sanitized["monthly_budget"] = floatval( + $input["monthly_budget"] ?? 600, + ); - // Sanitize chat history limit - $chat_history_limit = isset( $input['chat_history_limit'] ) ? absint( $input['chat_history_limit'] ) : 20; - $sanitized['chat_history_limit'] = min( $chat_history_limit, 200 ); + // Sanitize chat history limit + $chat_history_limit = isset($input["chat_history_limit"]) + ? absint($input["chat_history_limit"]) + : 20; + $sanitized["chat_history_limit"] = min($chat_history_limit, 200); - // Sanitize clarification quiz settings - $sanitized['clarity_confidence_threshold'] = in_array( $input['clarity_confidence_threshold'] ?? '', array( '0.5', '0.6', '0.7', '0.8', '0.9' ), true ) - ? $input['clarity_confidence_threshold'] - : '0.6'; + // Sanitize clarification quiz settings + $sanitized["clarity_confidence_threshold"] = in_array( + $input["clarity_confidence_threshold"] ?? "", + ["0.5", "0.6", "0.7", "0.8", "0.9"], + true, + ) + ? $input["clarity_confidence_threshold"] + : "0.6"; - if ( isset( $input['required_context_categories'] ) && is_array( $input['required_context_categories'] ) ) { - $valid_categories = array( 'target_outcome', 'target_audience', 'tone', 'content_depth', 'expertise_level', 'content_type', 'pov' ); - $sanitized['required_context_categories'] = array_intersect( $input['required_context_categories'], $valid_categories ); - } else { - $sanitized['required_context_categories'] = array( 'target_outcome', 'target_audience', 'tone', 'content_depth', 'expertise_level', 'content_type', 'pov' ); - } + if ( + isset($input["required_context_categories"]) && + is_array($input["required_context_categories"]) + ) { + $valid_categories = [ + "target_outcome", + "target_audience", + "tone", + "content_depth", + "expertise_level", + "content_type", + "pov", + ]; + $sanitized["required_context_categories"] = array_intersect( + $input["required_context_categories"], + $valid_categories, + ); + } else { + $sanitized["required_context_categories"] = [ + "target_outcome", + "target_audience", + "tone", + "content_depth", + "expertise_level", + "content_type", + "pov", + ]; + } - // Sanitize preferred languages - if ( isset( $input['preferred_languages'] ) && is_array( $input['preferred_languages'] ) ) { - $sanitized['preferred_languages'] = array_map( 'sanitize_text_field', $input['preferred_languages'] ); - } else { - $sanitized['preferred_languages'] = array( 'auto', 'English', 'Indonesian' ); - } + // Sanitize preferred languages + if ( + isset($input["preferred_languages"]) && + is_array($input["preferred_languages"]) + ) { + $sanitized["preferred_languages"] = array_map( + "sanitize_text_field", + $input["preferred_languages"], + ); + } else { + $sanitized["preferred_languages"] = [ + "auto", + "English", + "Indonesian", + ]; + } - // Sanitize custom languages - if ( isset( $input['custom_languages'] ) && is_array( $input['custom_languages'] ) ) { - $sanitized['custom_languages'] = array_filter( array_map( 'sanitize_text_field', $input['custom_languages'] ) ); - } else { - $sanitized['custom_languages'] = array(); - } + // Sanitize custom languages + if ( + isset($input["custom_languages"]) && + is_array($input["custom_languages"]) + ) { + $sanitized["custom_languages"] = array_filter( + array_map("sanitize_text_field", $input["custom_languages"]), + ); + } else { + $sanitized["custom_languages"] = []; + } - // 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; - } + // 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"]), + ); + } - return $sanitized; - } + if (isset($input["local_backend_key"])) { + $sanitized["local_backend_key"] = sanitize_text_field( + trim($input["local_backend_key"]), + ); + } - /** - * Render settings page - main entry point. - * - * @since 0.2.0 - */ - public function render_settings_page() { - $settings = get_option( 'wp_agentic_writer_settings', array() ); + if (isset($input["local_backend_model"])) { + $sanitized["local_backend_model"] = sanitize_text_field( + trim($input["local_backend_model"]), + ); + } - // Extract settings for views - $view_data = $this->prepare_view_data( $settings ); + // Sanitize MEMANTO settings. + $sanitized["memanto_enabled"] = + isset($input["memanto_enabled"]) && + "1" === $input["memanto_enabled"]; + $sanitized["memanto_url"] = isset($input["memanto_url"]) + ? esc_url_raw(trim($input["memanto_url"])) + : ""; + $sanitized["memanto_moorcheh_key"] = isset( + $input["memanto_moorcheh_key"], + ) + ? sanitize_text_field(trim($input["memanto_moorcheh_key"])) + : ""; - // Include main layout - include WP_AGENTIC_WRITER_DIR . 'views/settings/layout.php'; - } + // Sanitize Task Providers Routing + if ( + isset($input["task_providers"]) && + is_array($input["task_providers"]) + ) { + $sanitized_providers = []; + $allowed_tasks = [ + "chat", + "clarity", + "planning", + "writing", + "refinement", + "image", + ]; + $allowed_providers_text = ["openrouter", "local_backend", "codex"]; - /** - * Prepare data for view files. - * - * @since 0.2.0 - * @param array $settings Plugin settings. - * @return array View data. - */ - private function prepare_view_data( $settings ) { - // Extract settings (6 models) using model registry for defaults - $api_key = $settings['openrouter_api_key'] ?? ''; - $brave_search_api_key = $settings['brave_search_api_key'] ?? ''; - $chat_model = $settings['chat_model'] ?? WPAW_Model_Registry::get_default_model( 'chat' ); - $clarity_model = $settings['clarity_model'] ?? WPAW_Model_Registry::get_default_model( 'clarity' ); - $planning_model = $settings['planning_model'] ?? WPAW_Model_Registry::get_default_model( 'planning' ); - $writing_model = $settings['writing_model'] ?? ( $settings['execution_model'] ?? WPAW_Model_Registry::get_default_model( 'writing' ) ); - $refinement_model = $settings['refinement_model'] ?? WPAW_Model_Registry::get_default_model( 'refinement' ); - $image_model = $settings['image_model'] ?? WPAW_Model_Registry::get_default_model( 'image' ); - $web_search_enabled = $settings['web_search_enabled'] ?? false; - $search_engine = $settings['search_engine'] ?? 'auto'; - $search_depth = $settings['search_depth'] ?? 'medium'; - $cost_tracking_enabled = $settings['cost_tracking_enabled'] ?? true; - $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', - 'target_audience', - 'tone', - 'content_depth', - 'expertise_level', - 'content_type', - 'pov', - ); - $preferred_languages = $settings['preferred_languages'] ?? array( 'auto', 'English', 'Indonesian' ); - $custom_languages = $settings['custom_languages'] ?? array(); - $custom_models = get_option( 'wp_agentic_writer_custom_models', array() ); + foreach ($input["task_providers"] as $task => $provider) { + $task = sanitize_text_field($task); + $provider = sanitize_text_field($provider); - // 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(); - $allow_openrouter_fallback = ! empty( $settings['allow_openrouter_fallback'] ); - $openrouter_provider_routing_enabled = ! empty( $settings['openrouter_provider_routing_enabled'] ); - $openrouter_provider_slug = $settings['openrouter_provider_slug'] ?? 'auto'; - $openrouter_provider_only = ! empty( $settings['openrouter_provider_only'] ); - $openrouter_allow_provider_fallbacks = ! empty( $settings['openrouter_allow_provider_fallbacks'] ); + 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; + } - // Get cost tracking data - $cost_tracker = WP_Agentic_Writer_Cost_Tracker::get_instance(); - $monthly_used = $cost_tracker->get_monthly_total(); - $budget_percent = $monthly_budget > 0 ? ( $monthly_used / $monthly_budget ) * 100 : 0; - $budget_status = $budget_percent > 90 ? 'danger' : ( $budget_percent > 70 ? 'warning' : 'success' ); + return $sanitized; + } - return compact( - 'api_key', - 'brave_search_api_key', - 'chat_model', - 'clarity_model', - 'planning_model', - 'writing_model', - 'refinement_model', - 'image_model', - 'web_search_enabled', - 'search_engine', - 'search_depth', - 'cost_tracking_enabled', - 'monthly_budget', - 'chat_history_limit', - 'enable_clarification_quiz', - 'enable_faq_schema', - 'clarity_confidence_threshold', - 'required_context_categories', - 'preferred_languages', - 'custom_languages', - 'custom_models', - 'monthly_used', - 'budget_percent', - 'budget_status', - 'local_backend_url', - 'local_backend_key', - 'local_backend_model', - 'task_providers', - 'allow_openrouter_fallback', - 'openrouter_provider_routing_enabled', - 'openrouter_provider_slug', - 'openrouter_provider_only', - 'openrouter_allow_provider_fallbacks', - 'settings' - ); - } + /** + * Render settings page - main entry point. + * + * @since 0.2.0 + */ + public function render_settings_page() + { + $settings = get_option("wp_agentic_writer_settings", []); - /** - * Get available languages list. - * - * @since 0.2.0 - * @return array Available languages. - */ - public function get_available_languages() { - return array( - 'auto' => 'Auto-detect', - 'English' => 'English', - 'Indonesian' => 'Indonesian (Bahasa Indonesia)', - 'Javanese' => 'Javanese (Basa Jawa)', - 'Sundanese' => 'Sundanese (Basa Sunda)', - 'Spanish' => 'Spanish (Español)', - 'French' => 'French (Français)', - 'Arabic' => 'Arabic (العربية)', - 'Chinese' => 'Chinese (中文)', - 'Japanese' => 'Japanese (日本語)', - 'Portuguese' => 'Portuguese (Português)', - 'German' => 'German (Deutsch)', - 'Hindi' => 'Hindi (हिंदी)', - 'Korean' => 'Korean (한국어)', - 'Vietnamese' => 'Vietnamese (Tiếng Việt)', - 'Thai' => 'Thai (ไทย)', - 'Tagalog' => 'Tagalog', - 'Malay' => 'Malay (Bahasa Melayu)', - 'Russian' => 'Russian (Русский)', - 'Italian' => 'Italian (Italiano)', - 'Dutch' => 'Dutch (Nederlands)', - 'Polish' => 'Polish (Polski)', - 'Turkish' => 'Turkish (Türkçe)', - 'Swedish' => 'Swedish (Svenska)', - ); - } + // Extract settings for views + $view_data = $this->prepare_view_data($settings); - /** - * AJAX handler: Test local backend connection - * - * @since 0.2.0 - */ - public function ajax_test_local_backend() { - check_ajax_referer( 'wpaw_test_local_backend', 'nonce' ); + // Include main layout + include WP_AGENTIC_WRITER_DIR . "views/settings/layout.php"; + } - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json_error( array( 'message' => 'Insufficient permissions' ) ); - } + /** + * Prepare data for view files. + * + * @since 0.2.0 + * @param array $settings Plugin settings. + * @return array View data. + */ + private function prepare_view_data($settings) + { + // Extract settings (6 models) using model registry for defaults + $api_key = $settings["openrouter_api_key"] ?? ""; + $brave_search_api_key = $settings["brave_search_api_key"] ?? ""; + $chat_model = + $settings["chat_model"] ?? + WPAW_Model_Registry::get_default_model("chat"); + $clarity_model = + $settings["clarity_model"] ?? + WPAW_Model_Registry::get_default_model("clarity"); + $planning_model = + $settings["planning_model"] ?? + WPAW_Model_Registry::get_default_model("planning"); + $writing_model = + $settings["writing_model"] ?? + ($settings["execution_model"] ?? + WPAW_Model_Registry::get_default_model("writing")); + $refinement_model = + $settings["refinement_model"] ?? + WPAW_Model_Registry::get_default_model("refinement"); + $image_model = + $settings["image_model"] ?? + WPAW_Model_Registry::get_default_model("image"); + $web_search_enabled = $settings["web_search_enabled"] ?? false; + $search_engine = $settings["search_engine"] ?? "auto"; + $search_depth = $settings["search_depth"] ?? "medium"; + $cost_tracking_enabled = $settings["cost_tracking_enabled"] ?? true; + $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" + ] ?? [ + "target_outcome", + "target_audience", + "tone", + "content_depth", + "expertise_level", + "content_type", + "pov", + ]; + $preferred_languages = $settings["preferred_languages"] ?? [ + "auto", + "English", + "Indonesian", + ]; + $custom_languages = $settings["custom_languages"] ?? []; + $custom_models = get_option("wp_agentic_writer_custom_models", []); - $url = sanitize_text_field( wp_unslash( $_POST['url'] ?? '' ) ); + // 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"; - if ( empty( $url ) ) { - wp_send_json_error( array( 'message' => 'URL required' ) ); - } + // MEMANTO settings + $memanto_enabled = $settings["memanto_enabled"] ?? false; + $memanto_url = $settings["memanto_url"] ?? ""; + $memanto_moorcheh_key = $settings["memanto_moorcheh_key"] ?? ""; + $task_providers = $settings["task_providers"] ?? []; + $allow_openrouter_fallback = !empty( + $settings["allow_openrouter_fallback"] + ); + $openrouter_provider_routing_enabled = !empty( + $settings["openrouter_provider_routing_enabled"] + ); + $openrouter_provider_slug = + $settings["openrouter_provider_slug"] ?? "auto"; + $openrouter_provider_only = !empty( + $settings["openrouter_provider_only"] + ); + $openrouter_allow_provider_fallbacks = !empty( + $settings["openrouter_allow_provider_fallbacks"] + ); - // 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 ); + // Get cost tracking data + $cost_tracker = WP_Agentic_Writer_Cost_Tracker::get_instance(); + $monthly_used = $cost_tracker->get_monthly_total(); + $budget_percent = + $monthly_budget > 0 ? ($monthly_used / $monthly_budget) * 100 : 0; + $budget_status = + $budget_percent > 90 + ? "danger" + : ($budget_percent > 70 + ? "warning" + : "success"); - $provider = new WP_Agentic_Writer_Local_Backend_Provider(); - $result = $provider->test_connection(); + return compact( + "api_key", + "brave_search_api_key", + "chat_model", + "clarity_model", + "planning_model", + "writing_model", + "refinement_model", + "image_model", + "web_search_enabled", + "search_engine", + "search_depth", + "cost_tracking_enabled", + "monthly_budget", + "chat_history_limit", + "enable_clarification_quiz", + "enable_faq_schema", + "clarity_confidence_threshold", + "required_context_categories", + "preferred_languages", + "custom_languages", + "custom_models", + "monthly_used", + "budget_percent", + "budget_status", + "local_backend_url", + "local_backend_key", + "local_backend_model", + "task_providers", + "allow_openrouter_fallback", + "openrouter_provider_routing_enabled", + "openrouter_provider_slug", + "openrouter_provider_only", + "openrouter_allow_provider_fallbacks", + "memanto_enabled", + "memanto_url", + "memanto_moorcheh_key", + "settings", + ); + } - if ( is_wp_error( $result ) ) { - wp_send_json_error( array( 'message' => $result->get_error_message() ) ); - } + /** + * Get available languages list. + * + * @since 0.2.0 + * @return array Available languages. + */ + public function get_available_languages() + { + return [ + "auto" => "Auto-detect", + "English" => "English", + "Indonesian" => "Indonesian (Bahasa Indonesia)", + "Javanese" => "Javanese (Basa Jawa)", + "Sundanese" => "Sundanese (Basa Sunda)", + "Spanish" => "Spanish (Español)", + "French" => "French (Français)", + "Arabic" => "Arabic (العربية)", + "Chinese" => "Chinese (中文)", + "Japanese" => "Japanese (日本語)", + "Portuguese" => "Portuguese (Português)", + "German" => "German (Deutsch)", + "Hindi" => "Hindi (हिंदी)", + "Korean" => "Korean (한국어)", + "Vietnamese" => "Vietnamese (Tiếng Việt)", + "Thai" => "Thai (ไทย)", + "Tagalog" => "Tagalog", + "Malay" => "Malay (Bahasa Melayu)", + "Russian" => "Russian (Русский)", + "Italian" => "Italian (Italiano)", + "Dutch" => "Dutch (Nederlands)", + "Polish" => "Polish (Polski)", + "Turkish" => "Turkish (Türkçe)", + "Swedish" => "Swedish (Svenska)", + ]; + } - wp_send_json_success( $result ); - } + /** + * 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(["message" => "Insufficient permissions"]); + } + + $url = sanitize_text_field(wp_unslash($_POST["url"] ?? "")); + + if (empty($url)) { + wp_send_json_error(["message" => "URL required"]); + } + + // Temporarily create provider with this URL + $temp_settings = get_option("wp_agentic_writer_settings", []); + $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(["message" => $result->get_error_message()]); + } + + wp_send_json_success($result); + } + + /** + * AJAX handler: Test MEMANTO connection. + * + * Uses the provided URL and key (from the form) to test connectivity + * without requiring settings to be saved first. + * + * @since 0.3.0 + */ + public function ajax_test_memanto() + { + check_ajax_referer("wpaw_settings", "nonce"); + + if (!current_user_can("manage_options")) { + wp_send_json_error(["message" => "Insufficient permissions"]); + } + + $url = sanitize_text_field(wp_unslash($_POST["url"] ?? "")); + $key = sanitize_text_field(wp_unslash($_POST["key"] ?? "")); + + if (empty($url) || empty($key)) { + wp_send_json_error([ + "message" => "MEMANTO URL and Moorcheh API key are required.", + ]); + } + + // Temporarily override settings so the client uses the form values. + $temp_settings = get_option("wp_agentic_writer_settings", []); + $temp_settings["memanto_url"] = esc_url_raw(trim($url)); + $temp_settings["memanto_moorcheh_key"] = sanitize_text_field( + trim($key), + ); + update_option("wp_agentic_writer_settings", $temp_settings); + + // Clear health cache so the fresh URL/key are used. + delete_transient("wpaw_memanto_health"); + + $client = WP_Agentic_Writer_Memanto_Client::get_instance(); + $result = $client->check_health_fresh(); + + if ($result["healthy"]) { + wp_send_json_success($result); + } else { + $error_msg = "Connection failed."; + if (!empty($result["details"]["error"])) { + $error_msg = $result["details"]["error"]; + } + wp_send_json_error(["message" => $error_msg, "healthy" => false]); + } + } } diff --git a/views/settings/layout.php b/views/settings/layout.php index 05ee6ea..7beee4b 100644 --- a/views/settings/layout.php +++ b/views/settings/layout.php @@ -6,24 +6,24 @@ * @var array $view_data Prepared view data from class-settings-v2.php */ -if ( ! defined( 'ABSPATH' ) ) { - exit; +if (!defined("ABSPATH")) { + exit(); } // Extract view data for easier access -extract( $view_data ); +extract($view_data); ?>
    - +
    - WP Agentic Writer" + alt="WP Agentic Writer" style="width: 24px; height: 24px; filter: invert(1)">

    Agentic Writer

    @@ -39,19 +39,25 @@ extract( $view_data ); + @@ -59,13 +65,13 @@ extract( $view_data ); @@ -75,7 +81,7 @@ extract( $view_data );
    - +
    @@ -140,7 +146,7 @@ extract( $view_data );

    General Settings

    Configure global API keys, budget, and content parameters.

    - +
    @@ -149,7 +155,7 @@ extract( $view_data );

    AI Models

    Select logic engines for different stages of the writing pipeline.

    - +
    @@ -158,16 +164,25 @@ extract( $view_data );

    Local Backend

    Configure connections to local LM Studio or Ollama instances.

    - +
    - + +
    +
    +

    MEMANTO Context Keeper

    +

    Optional persistent memory for your AI writing assistant. The plugin works perfectly without it.

    +
    + +
    + +

    OpenRouter Cost Analytics

    Track API token usage and expenses across all generations.

    - +
    @@ -176,7 +191,7 @@ extract( $view_data );

    Provider Documentation

    Reference materials for selecting the right model constraints.

    - + @@ -185,18 +200,28 @@ extract( $view_data );
    - +
    @@ -210,7 +235,10 @@ extract( $view_data );