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(/ (.*?)<\/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(/ (.*?)<\/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(/ (.*?)<\/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(/ (.*?)<\/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(/ (.*?)<\/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) => ` ${inlineMarkdownToHtml(paragraph.join(' '))} ${inlineMarkdownToHtml(detail)} (.*?)<\/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(
+ / (.*?)<\/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(/ (.*?)<\/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(
+ / (.*?)<\/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(
+ / (.*?)<\/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(
+ / (.*?)<\/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) => ` ${inlineMarkdownToHtml(paragraph.join(" "))} ${inlineMarkdownToHtml(detail)} ' . $refined_content . ' " . $refined_content . " ' . $refined_content . ' " . $refined_content . " ' . $content . ' ' . $content . ' " . $content . " " . $content . " ]*>/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(
+ "/ ]*>/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);
?>
Configure connections to local LM Studio or Ollama instances. Optional persistent memory for your AI writing assistant. The plugin works perfectly without it. Track API token usage and expenses across all generations. Reference materials for selecting the right model constraints.
+ When enabled, the AI writing assistant will store and recall memories across sessions using MEMANTO.
+ The plugin works perfectly without MEMANTO — this is an optional enhancement.
+
+ Don't have MEMANTO? Get MEMANTO Context Keeper
+ ([\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]*?)<\/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(/ ${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(`
`);
- 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 += `${escapeHtml(code)}${item.children.map((child) => `
`
- : '';
- return `([\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]*?)<\/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(/ ${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(
+ `
`,
+ );
+ 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 += `${escapeHtml(code)}${item.children.map((child) => `
`
+ : "";
+ return `' . 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 = '
',
- 'clientId' => $block_id,
- );
- }
-
- // Fallback to paragraph
- return array(
- 'blockName' => 'core/paragraph',
- 'attrs' => array( 'content' => $content ),
- 'innerHTML' => '' . $escaped . '
",
+ "clientId" => $block_id,
+ ];
+ }
+
+ // Fallback to paragraph
+ return [
+ "blockName" => "core/paragraph",
+ "attrs" => ["content" => $content],
+ "innerHTML" => "' .
+ $escaped .
+ "Local Backend
MEMANTO Context Keeper
+ OpenRouter Cost Analytics
Provider Documentation
What MEMANTO does
+
+
+