diff --git a/assets/css/settings-v2.css b/assets/css/settings-v2.css index 2ad6700..f817379 100644 --- a/assets/css/settings-v2.css +++ b/assets/css/settings-v2.css @@ -18,6 +18,10 @@ color: var(--wpaw-primary); } +.form-check.mt-3 input[type=checkbox] { + margin-top: .35rem; +} + /* Card enhancements */ .wpaw-settings-v2-wrap .card { background: transparent !important; diff --git a/assets/css/sidebar.css b/assets/css/sidebar.css index 5cecd58..1378b81 100644 --- a/assets/css/sidebar.css +++ b/assets/css/sidebar.css @@ -33,7 +33,6 @@ .interface-complementary-area__fill:has(#wp-agentic-writer\:wp-agentic-writer), #wp-agentic-writer\:wp-agentic-writer svg { margin-bottom: -3px; - width: 18px; } .components-tooltip img { @@ -494,6 +493,96 @@ input.wpaw-plan-section-check:checked::before { margin-bottom: 10px; } +.wpaw-refinement-lock-banner { + background: #dbeafe; + color: #1e3a8a; + padding: 8px 10px; + border: 1px solid #93c5fd; + font-size: 12px; + margin-bottom: 10px; +} + +.wpaw-refine-confirm-overlay { + position: absolute; + inset: 0; + z-index: 1200; + background: rgba(10, 16, 27, 0.72); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.wpaw-refine-confirm-modal { + width: 100%; + max-width: 420px; + background: #111827; + color: #e5e7eb; + border: 1px solid #334155; + border-radius: 8px; + padding: 16px; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45); +} + +.wpaw-refine-confirm-title { + font-size: 15px; + font-weight: 700; + margin-bottom: 8px; +} + +.wpaw-refine-confirm-body { + font-size: 13px; + line-height: 1.5; + color: #cbd5e1; + margin-bottom: 12px; +} + +.wpaw-refine-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 10px; +} + +.wpaw-block-refining { + position: relative; + outline: 2px dashed #3b82f6; + outline-offset: 2px; +} + +.wpaw-block-refining::before { + content: 'REFINING'; + position: absolute; + top: -12px; + right: 8px; + background: #2563eb; + color: #fff; + font-size: 10px; + line-height: 1; + padding: 3px 6px; + border-radius: 4px; + z-index: 20; + letter-spacing: 0; + font-weight: 700; +} + +/* Refinement lock: prevent content editing while still allowing page scroll */ +.wpaw-refining-locked .editor-styles-wrapper [contenteditable="true"], +.wpaw-refining-locked .block-editor-rich-text__editable, +.wpaw-refining-locked .block-editor-writing-flow [role="textbox"] { + pointer-events: none !important; + user-select: none !important; + caret-color: transparent !important; +} + +.wpaw-refining-locked .block-editor-block-toolbar, +.wpaw-refining-locked .block-editor-default-block-appender, +.wpaw-refining-locked .editor-block-list-item__inline-menu, +.wpaw-refining-locked .block-editor-inserter { + pointer-events: none !important; + opacity: 0.5; +} + .wpaw-ai-response pre { background: #f1f5f9; padding: 10px; @@ -1384,6 +1473,7 @@ input.wpaw-plan-section-check:checked::before { border-bottom: 1px solid #3c3c3c; font-size: 11px; text-transform: uppercase; + background-color: #2c2c2c; } .wpaw-cost-table td { @@ -3537,13 +3627,12 @@ input.wpaw-plan-section-check:checked::before { .wpaw-welcome-icon { display: block; - margin-bottom: 1rem; color: #2271b1; } .wpaw-welcome-icon svg { - width: 48px; - height: 48px; + width: 64px; + height: 64px; } .wpaw-welcome-title { @@ -3619,6 +3708,23 @@ input.wpaw-plan-section-check:checked::before { color: #2271b1; } +.wpaw-session-list { + max-height: 35vh; + overflow-y: auto; + padding-right: 2px; + margin-bottom: 8px; +} + +.wpaw-session-open-btn { + display: block; + min-width: 0; + padding: 0; +} + +.wpaw-session-open-btn:disabled { + opacity: 0.65; +} + .wpaw-welcome-start-btn { width: 100%; padding: 12px 24px !important; @@ -4482,4 +4588,4 @@ input.wpaw-plan-section-check:checked::before { .wpaw-provider-badge[title*="Warning"], .wpaw-provider-info:has(.wpaw-fallback) { color: #f59e0b; -} \ No newline at end of file +} diff --git a/assets/js/settings-v2.js b/assets/js/settings-v2.js index 681f5ba..ed13281 100644 --- a/assets/js/settings-v2.js +++ b/assets/js/settings-v2.js @@ -20,6 +20,7 @@ models: {}, currentPage: 1, perPage: 25, + childPerPage: 20, filters: { post: '', model: '', @@ -475,6 +476,7 @@ if (response.success) { renderCostLogTable(response.data); updateCostLogStats(response.data.stats); + renderActionSummary(response.data.stats); updateFilterOptions(response.data.filters); renderPagination(response.data); } else { @@ -507,6 +509,8 @@ let html = ''; records.forEach((group, index) => { const collapseId = `collapse-post-${group.post_id}-${index}`; + const detailsTotal = Number(group.details_total || (group.details || []).length || 0); + const detailsInitialEnd = Math.min(state.childPerPage, detailsTotal); const postCell = group.post_link ? `${escapeHtml(group.post_title)}` : `${escapeHtml(group.post_title)}`; @@ -526,9 +530,13 @@ `; // Collapsible details row + const detailsHint = detailsTotal > 0 + ? `
Showing 1-${detailsInitialEnd} of ${detailsTotal} calls
` + : ''; html += ` - + + ${detailsHint}
@@ -541,7 +549,7 @@ - + `; // Detail rows @@ -562,6 +570,12 @@
+ ${detailsTotal > state.childPerPage ? ` +
+ + Page 1 of ${Math.ceil(detailsTotal / state.childPerPage)} + +
` : ''} `; @@ -577,15 +591,55 @@ const isExpanded = $(target).hasClass('show'); $icon.toggleClass('dashicons-arrow-right-alt2', !isExpanded); $icon.toggleClass('dashicons-arrow-down-alt2', isExpanded); + if (isExpanded) { + renderChildPage($(target), 1); + } }, 10); }); + $(document).off('click.wpawChildPager').on('click.wpawChildPager', '.wpaw-child-prev, .wpaw-child-next', function () { + const $btn = $(this); + const $row = $btn.closest('.wpaw-collapse-row'); + const currentPage = Number($row.find('.wpaw-child-page').text() || 1); + const totalPages = Number($row.find('.wpaw-child-pages').text() || 1); + const nextPage = $btn.hasClass('wpaw-child-prev') + ? Math.max(1, currentPage - 1) + : Math.min(totalPages, currentPage + 1); + renderChildPage($row, nextPage); + }); + + // Ensure initial expanded state also starts at page 1 (20 rows), + // not full unpaginated detail rows. + $('.wpaw-collapse-row').each(function () { + renderChildPage($(this), 1); + }); + // Update records info const start = (data.current_page - 1) * data.per_page + 1; const end = Math.min(data.current_page * data.per_page, data.total_items); $('#wpaw-records-info').text(`Showing ${start}-${end} of ${data.total_items} posts`); } + function renderChildPage($row, page) { + const perPage = state.childPerPage || 20; + const $rows = $row.find('.wpaw-details-body tr'); + const totalRows = $rows.length; + const totalPages = Math.max(1, Math.ceil(totalRows / perPage)); + const safePage = Math.max(1, Math.min(totalPages, page)); + const startIdx = (safePage - 1) * perPage; + const endIdx = Math.min(totalRows, startIdx + perPage); + + $rows.hide(); + $rows.slice(startIdx, endIdx).show(); + + $row.find('.wpaw-child-page').text(safePage); + $row.find('.wpaw-child-pages').text(totalPages); + $row.find('.wpaw-child-range-start').text(totalRows === 0 ? 0 : startIdx + 1); + $row.find('.wpaw-child-range-end').text(endIdx); + $row.find('.wpaw-child-prev').prop('disabled', safePage <= 1); + $row.find('.wpaw-child-next').prop('disabled', safePage >= totalPages); + } + /** * Update cost log stats */ @@ -597,6 +651,34 @@ $('#wpaw-stat-avg').text('$' + stats.avg_per_post); } + function renderActionSummary(stats) { + const $tbody = $('#wpaw-action-summary-tbody'); + if (!$tbody.length) return; + + const rows = Array.isArray(stats?.action_summary) ? stats.action_summary : []; + if (rows.length === 0) { + $tbody.html('No action cost records yet.'); + return; + } + + const formatAction = (action) => String(action || '') + .replace(/_/g, ' ') + .replace(/\b\w/g, c => c.toUpperCase()); + + let html = ''; + rows.forEach((row) => { + html += ` + + ${escapeHtml(formatAction(row.action))} + ${Number(row.calls || 0)} + $${escapeHtml(String(row.total || '0.0000'))} + $${escapeHtml(String(row.average || '0.0000'))} + + `; + }); + $tbody.html(html); + } + /** * Update filter dropdown options */ diff --git a/assets/js/sidebar.js b/assets/js/sidebar.js index 3627d47..275b946 100644 --- a/assets/js/sidebar.js +++ b/assets/js/sidebar.js @@ -30,6 +30,48 @@ 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(); + + 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' + ? ` Current pinned provider: ${settings.openrouter_provider_slug}.` + : ''; + + return 'The selected model is not available from the current OpenRouter provider routing settings.' + + routedProvider + + ' Open Settings -> WP Agentic Writer -> Models -> OpenRouter Provider Routing, then either choose a provider that supports this model, turn off "Only use this provider", enable fallback providers, or select a model from the pinned BYOK provider.'; + } + + if (cleanMessage.includes('429') || lowerMessage.includes('rate limit')) { + return 'Rate limit exceeded. Please wait a moment and try again.'; + } + + if ( + cleanMessage.includes('cURL error 28') + || lowerMessage.includes('operation timed out') + || lowerMessage.includes('timed out after') + ) { + return 'The selected model/provider started the planning request but did not finish before the 2-minute timeout. Try a faster planning model, reduce the outline/article length, or switch OpenRouter Provider Routing back to Auto/fallback-capable routing before retrying.'; + } + + if (cleanMessage.startsWith('HTTP 401') || lowerMessage.includes('unauthorized')) { + return 'The AI provider rejected the API key. Please check the OpenRouter/API key settings and try again.'; + } + + if (cleanMessage.startsWith('HTTP 402') || lowerMessage.includes('insufficient credits')) { + return 'The AI provider says the account has insufficient credits or quota. Please check your provider billing/BYOK setup.'; + } + + return `Error: ${cleanMessage || fallback}`; + }; // Tab state const [activeTab, setActiveTab] = React.useState('chat'); @@ -58,7 +100,7 @@ experience_level: 'general', include_images: true, web_search: Boolean(settings.web_search_enabled), - default_mode: 'writing', + default_mode: 'chat', // SEO fields seo_focus_keyword: '', seo_secondary_keywords: '', @@ -103,6 +145,19 @@ }; 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); @@ -148,6 +203,9 @@ 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); @@ -165,6 +223,12 @@ } }, [agentMode]); + React.useEffect(() => { + if (agentMode === 'writing' && !isLoading) { + setAgentMode(currentPlanRef.current ? 'planning' : 'chat'); + } + }, [agentMode, isLoading]); + React.useEffect(() => { if (!postId) { return; @@ -184,7 +248,7 @@ lastSavedConfigRef.current = JSON.stringify(merged); configHydratedRef.current = true; if (merged.default_mode && !appliedDefaultModeRef.current) { - setAgentMode(merged.default_mode); + setAgentMode(merged.default_mode === 'writing' ? 'chat' : merged.default_mode); appliedDefaultModeRef.current = true; } }) @@ -354,6 +418,28 @@ 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') => { @@ -427,6 +513,166 @@ 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 ''; @@ -715,6 +961,120 @@ } }, [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]); + + 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 () => { try { @@ -781,7 +1141,13 @@ } if (historyMessages.length > 0) { - setMessages((prev) => (prev.length > 0 ? prev : historyMessages)); + isHydratingSessionRef.current = true; + lastPersistedMessagesRef.current = JSON.stringify(sanitizeMessagesForStorage(historyMessages)); + hydrateSessionStateFromMessages(historyMessages); + setMessages(historyMessages); + setTimeout(() => { + isHydratingSessionRef.current = false; + }, 0); } if (resolvedSessionId) { setCurrentSessionId(resolvedSessionId); @@ -791,7 +1157,7 @@ } }; loadChatHistory(); - }, [postId, currentSessionId]); + }, [postId, currentSessionId, sanitizeMessagesForStorage, hydrateSessionStateFromMessages]); const loadPostSessions = async () => { const headers = { @@ -799,6 +1165,9 @@ }; 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}`, { @@ -809,6 +1178,25 @@ const postData = await postRes.json(); postSessions = Array.isArray(postData?.sessions) ? postData.sessions : []; } + + // Auto-draft fallback: + // if this auto-draft has no linked sessions yet, surface active carry-over sessions + // (unassigned or still auto-draft) so users can continue unfinished work. + if (postSessions.length === 0 && currentPostStatus === 'auto-draft') { + 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'; + }); + } + } } else { // New post flow: include unassigned/auto-draft sessions for recovery. const activeRes = await fetch(`${wpAgenticWriter.apiUrl}/conversations?status=active&limit=50`, { @@ -834,6 +1222,12 @@ 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); }); @@ -841,6 +1235,44 @@ 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 : []; + lastPersistedMessagesRef.current = JSON.stringify(sanitizeMessagesForStorage(sessionMessages)); + hydrateSessionStateFromMessages(sessionMessages); + setMessages(sessionMessages); + setShowWelcome(false); + 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)) { @@ -887,6 +1319,92 @@ .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 }, @@ -1035,7 +1553,7 @@ } setInput(''); - setMessages([...messages, { role: 'user', content: originalMessage }]); + setMessages(prev => [...prev, { role: 'user', content: originalMessage }]); await handleChatRefinement( refinementMessage, [newBlock.clientId], @@ -1044,7 +1562,7 @@ }; const streamGeneratePlan = async (request, options = {}) => { const { resume = false } = options; - const normalizedRequest = { ...request, postConfig: postConfig, chatHistory: messages.filter(m => m.role === 'user' || m.role === 'assistant').slice(-10) }; + const normalizedRequest = { ...request, postConfig: postConfig, chatHistory: buildChatHistoryPayload() }; lastGenerationRequestRef.current = normalizedRequest; setIsLoading(true); @@ -1063,28 +1581,30 @@ body: JSON.stringify({ ...normalizedRequest, resume: resume }), }); - if (!response.ok) { - const error = await response.json(); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'Error: ' + (error.message || 'Failed to generate article'), - canRetry: true - }]); - return; - } + 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: 'Request timeout. The AI is taking too long to respond. Please try again.', - canRetry: true - }]); + 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(); } @@ -1209,15 +1729,16 @@ } return newMessages; }); - } else if (data.type === 'error') { - clearTimeout(timeout); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: data.message || 'An error occurred during article generation', - canRetry: true - }]); - } + } 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); } @@ -1228,42 +1749,58 @@ clearTimeout(timeout); } catch (error) { wpawLog.error('Article generation error:', error); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'Error: ' + (error.message || 'Failed to generate article'), - canRetry: true - }]); + 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) { - return; - } - setMessages(prev => [...deactivateActiveTimelineEntries(prev), { - role: 'system', - type: 'timeline', + 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) { - return; - } - executePlanFromCard({ retry: true }); - }; - const retryLastRefinement = () => { - if (!lastRefineRequestRef.current) { - return; - } - setMessages(prev => [...deactivateActiveTimelineEntries(prev), { - role: 'system', - type: 'timeline', + }]); + 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() @@ -1271,14 +1808,19 @@ handleChatRefinement( lastRefineRequestRef.current.message, lastRefineRequestRef.current.blocksOverride, - lastRefineRequestRef.current.options - ); - }; - const retryLastChat = async () => { - if (!lastChatRequestRef.current) { - return; - } - const userMessage = lastChatRequestRef.current.message; + 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'))); @@ -1314,6 +1856,7 @@ const decoder = new TextDecoder(); let streamBuffer = ''; let fullContent = ''; + let streamError = null; while (true) { const { done, value } = await reader.read(); @@ -1327,6 +1870,10 @@ 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 => { @@ -1354,21 +1901,22 @@ addFocusKeywordSuggestions(suggestions); } } - } else if (data.type === 'error') { - throw new Error(data.message || 'Chat error'); } } catch (e) { - if (e.message !== 'Chat error') continue; - throw e; + wpawLog.error('Failed to parse retry streaming data:', line, e); } } + + if (streamError) { + throw streamError; + } } } catch (error) { - const errorMsg = error.message || 'Failed to chat'; + const errorMsg = formatAiErrorMessage(error, 'Failed to chat'); setMessages(prev => [...prev, { role: 'system', type: 'error', - content: 'Error: ' + errorMsg, + content: errorMsg, canRetry: true, retryType: 'chat' }]); @@ -1803,7 +2351,14 @@ }); if (!response.ok) { - throw new Error('Intent detection failed'); + 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(); @@ -1813,7 +2368,7 @@ cost: data.cost || 0, }; } catch (error) { - wpawLog.error('Intent detection error:', error); + wpawLog.error('Intent detection error:', formatAiErrorMessage(error, 'Intent detection failed')); return { intent: 'continue_chat', cost: 0 }; } }; @@ -1949,6 +2504,24 @@ } }; + 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; @@ -1976,17 +2549,19 @@ } const plan = currentPlanRef.current; + 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() - }]); + role: 'system', + type: 'timeline', + status: 'complete', + message: 'All outline items are already written.', + timestamp: new Date() + }]); + setAgentMode('chat'); return; } @@ -2174,7 +2749,7 @@ } return newMessages; }); - setAgentMode('writing'); + setAgentMode('chat'); setIsLoading(false); } else if (data.type === 'error') { clearTimeout(timeout); @@ -2187,6 +2762,7 @@ } clearTimeout(timeout); } catch (error) { + setAgentMode(currentPlanRef.current ? 'planning' : 'chat'); setMessages(prev => [...prev, { role: 'system', type: 'error', @@ -2641,12 +3217,26 @@ // Send message and generate article. // Resolve block mentions to client IDs - const getRefineableBlocks = () => { + 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; @@ -2757,6 +3347,35 @@ .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(); @@ -2796,7 +3415,8 @@ break; case 'all': - getRefineableBlocks().forEach((block) => { + // @all intentionally targets text-based content blocks only. + getAllTextRefineableBlocks().forEach((block) => { resolved.push(block.clientId); }); break; @@ -2859,8 +3479,21 @@ // 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 (blocksToRefine.length === 0) { + if (resolvedIds.length === 0) { // No valid mentions found - alert user setMessages(prev => [...prev, { role: 'system', @@ -2871,6 +3504,22 @@ 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; @@ -2891,13 +3540,13 @@ const normalizedAllBlocks = allBlocksSnapshot .map(serializeBlockForApi) .filter(Boolean); - const blocksToRefineData = blocksToRefine + const blocksToRefineData = resolvedIds .map((clientId) => normalizedAllBlocks.find((block) => block.clientId === clientId)) .filter(Boolean); // Add user message to chat if (!skipUserMessage) { - setMessages([...messages, { role: 'user', content: message }]); + setMessages(prev => [...prev, { role: 'user', content: message }]); } // Add timeline entry @@ -2905,10 +3554,12 @@ role: 'system', type: 'timeline', status: 'refining', - message: `Refining ${blocksToRefine.length} block(s)...`, + message: `Refining ${resolvedIds.length} block(s)...`, timestamp: new Date() }]); + setIsRefinementLocked(true); + setRefiningBlockIds(resolvedIds); setIsLoading(true); try { @@ -2931,7 +3582,8 @@ postId: postId, sessionId: currentSessionId, stream: true, - diffPlan: useDiffPlan, + diffPlan: effectiveUseDiffPlan, + selectiveRefine: hasAllMention, postConfig: postConfig, chatHistory: messages.filter(m => m.role !== 'system'), }), @@ -2970,6 +3622,19 @@ 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, { @@ -3011,6 +3676,7 @@ 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); @@ -3029,10 +3695,15 @@ 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: 'complete', - message: `Refined ${refinedCount} block(s) successfully`, + status: data.aborted ? 'error' : 'complete', + message: data.aborted + ? `Refinement stopped early: ${refinedCount} updated${failedLabel}` + : `Refined ${refinedCount} block(s) successfully${failedLabel}`, timestamp: new Date() }; } @@ -3042,7 +3713,9 @@ // Show completion message setMessages(prev => [...prev, { role: 'assistant', - content: `✅ Done! I've refined ${refinedCount} block(s) as requested.` + 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 @@ -3112,9 +3785,53 @@ 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) => { @@ -3155,6 +3872,14 @@ 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 = {}; @@ -3408,6 +4133,7 @@ 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...'; @@ -3429,7 +4155,7 @@ if (reformatCommand.test(userMessage)) { setInput(''); - setMessages([...messages, { role: 'user', content: userMessage }]); + 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)); @@ -3437,16 +4163,22 @@ return; } + if (titleMentioned) { + setInput(''); + await handleTitleRefinement(userMessage, mentionTokens); + return; + } + if (agentMode === 'planning' && !hasMentions && currentPlanRef.current) { setInput(''); - setMessages([...messages, { role: 'user', content: userMessage }]); + setMessages(prev => [...prev, { role: 'user', content: userMessage }]); await revisePlanFromPrompt(userMessage); return; } if (agentMode === 'chat' && !hasMentions) { setInput(''); - setMessages([...messages, { role: 'user', content: userMessage }]); + setMessages(prev => [...prev, { role: 'user', content: userMessage }]); setIsLoading(true); // User message is NOT an AI suggestion - don't extract from user input @@ -3480,14 +4212,15 @@ throw new Error(error.message || 'Failed to chat'); } - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let streamBuffer = ''; - streamTargetRef.current = null; + 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; + while (true) { + const { done, value } = await reader.read(); + if (done) break; streamBuffer += decoder.decode(value, { stream: true }); const lines = streamBuffer.split('\n'); @@ -3498,15 +4231,16 @@ continue; } - try { - const data = JSON.parse(line.slice(6)); - if (data.type === 'error') { - throw new Error(data.message || 'Failed to chat'); - } - if (data.type === 'conversational' || data.type === 'conversational_stream') { - const cleanContent = (data.content || '').trim(); - if (!cleanContent) { - 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); @@ -3556,13 +4290,17 @@ return prev; }); } - } catch (parseError) { - wpawLog.error('Failed to parse streaming data:', line, parseError); + } catch (parseError) { + wpawLog.error('Failed to parse streaming data:', line, parseError); + } + } + + if (streamError) { + throw streamError; } } - } - // Detect intent after chat completes + // Detect intent after chat completes try { const intentResult = await detectUserIntent(userMessage); @@ -3581,22 +4319,19 @@ return newMessages; }); } - } catch (intentError) { - wpawLog.error('Intent detection failed:', intentError); + } 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' + }]); } - } catch (error) { - const errorMsg = error.message || 'Failed to chat'; - const isRateLimit = errorMsg.includes('429') || errorMsg.toLowerCase().includes('rate limit'); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: isRateLimit - ? 'Rate limit exceeded. Please wait a moment and try again.' - : 'Error: ' + errorMsg, - canRetry: true, - retryType: 'chat' - }]); - } setIsLoading(false); return; @@ -3610,7 +4345,7 @@ ? sectionBlocksRef.current[matchedSection.id] || [] : []; setInput(''); - setMessages([...messages, { role: 'user', content: userMessage }]); + setMessages(prev => [...prev, { role: 'user', content: userMessage }]); if (matchedSectionBlocks.length > 0) { setMessages(prev => [...prev, { role: 'assistant', @@ -3642,7 +4377,7 @@ if (!hasMentions) { // No mentions - check clarity first before article generation setInput(''); - setMessages([...messages, { role: 'user', content: userMessage }]); + setMessages(prev => [...prev, { role: 'user', content: userMessage }]); setIsLoading(true); // Check clarity first @@ -3669,7 +4404,7 @@ sessionId: currentSessionId, mode: 'generation', postConfig: postConfig, - chatHistory: messages.filter(m => m.role === 'user' || m.role === 'assistant').slice(-10), + chatHistory: buildChatHistoryPayload(), }), }); @@ -3746,18 +4481,19 @@ articleLength: postConfig.article_length, detectedLanguage: detectedLanguage, postConfig: postConfig, - chatHistory: messages.filter(m => m.role === 'user' || m.role === 'assistant').slice(-10), + chatHistory: buildChatHistoryPayload(), }), }); if (!response.ok) { const error = await response.json(); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'Error: ' + (error.message || 'Failed to generate article'), - canRetry: true - }]); + setMessages(prev => [...prev, { + role: 'system', + type: 'error', + content: formatAiErrorMessage(error, 'Failed to generate article'), + canRetry: true, + retryType: 'generation' + }]); setIsLoading(false); return; } @@ -3774,8 +4510,9 @@ setMessages(prev => [...prev, { role: 'system', type: 'error', - content: 'Request timeout. The AI is taking too long to respond. Please try again.', - canRetry: true + content: formatAiErrorMessage('cURL error 28: Operation timed out after 120000 milliseconds', 'Failed to generate article'), + canRetry: true, + retryType: 'generation' }]); setIsLoading(false); reader.cancel(); @@ -3910,18 +4647,23 @@ setIsLoading(false); } else if (data.type === 'error') { clearTimeout(timeout); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: data.message || 'An error occurred during article generation', - canRetry: true - }]); + 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 @@ -3932,8 +4674,9 @@ setMessages(prev => [...prev, { role: 'system', type: 'error', - content: 'Error: ' + (error.message || 'Failed to generate article'), - canRetry: true + content: formatAiErrorMessage(error, 'Failed to generate article'), + canRetry: true, + retryType: 'generation' }]); setIsLoading(false); } @@ -3973,7 +4716,7 @@ // Blocks don't exist yet - this is article generation // User is specifying structure for new article setInput(''); - setMessages([...messages, { role: 'user', content: userMessage }]); + setMessages(prev => [...prev, { role: 'user', content: userMessage }]); setIsLoading(true); // Add loading timeline entry @@ -4002,18 +4745,19 @@ stream: true, articleLength: postConfig.article_length, postConfig: postConfig, - chatHistory: messages.filter(m => m.role === 'user' || m.role === 'assistant').slice(-10), + chatHistory: buildChatHistoryPayload(), }), }); if (!response.ok) { const error = await response.json(); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'Error: ' + (error.message || 'Failed to generate article'), - canRetry: true - }]); + setMessages(prev => [...prev, { + role: 'system', + type: 'error', + content: formatAiErrorMessage(error, 'Failed to generate article'), + canRetry: true, + retryType: 'generation' + }]); setIsLoading(false); return; } @@ -4258,7 +5002,7 @@ }]); try { - const topic = messages.map((m) => m.content).join('\n'); + 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', @@ -4277,19 +5021,19 @@ articleLength: postConfig.article_length, detectedLanguage: detectedLanguage, postConfig: postConfig, - chatHistory: messages.filter(m => m.role === 'user' || m.role === 'assistant').slice(-10), + chatHistory: buildChatHistoryPayload(), }), }); - if (!response.ok) { - const error = await response.json(); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'Error: ' + (error.message || 'Failed to generate plan'), - canRetry: true, - retryType: 'generation' - }]); + 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; } @@ -4303,13 +5047,13 @@ const timeout = setTimeout(() => { if (isLoading) { wpawLog.error('Generation timeout - no response received'); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'Request timeout. The AI is taking too long to respond. Please try again.', - canRetry: true, - retryType: 'generation' - }]); + 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(); } @@ -4442,15 +5186,15 @@ return newMessages; }); setIsLoading(false); - } else if (data.type === 'error') { - clearTimeout(timeout); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: data.message || 'An error occurred during article generation', - canRetry: true, - retryType: 'generation' - }]); + } 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) { @@ -4464,13 +5208,13 @@ } catch (error) { clearTimeout(timeout); wpawLog.error('Article generation error:', error); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'Error: ' + (error.message || 'Failed to generate article'), - canRetry: true, - retryType: 'generation' - }]); + setMessages(prev => [...prev, { + role: 'system', + type: 'error', + content: formatAiErrorMessage(error, 'Failed to generate article'), + canRetry: true, + retryType: 'generation' + }]); setIsLoading(false); } }; @@ -4758,6 +5502,7 @@ setCurrentSessionId(data.session_id); } await loadPostSessions(); + lastPersistedMessagesRef.current = JSON.stringify([]); setMessages([]); setShowWelcome(false); } catch (error) { @@ -4825,6 +5570,16 @@ } 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 = () => { @@ -4843,54 +5598,59 @@ wp.element.createElement('div', { style: { fontSize: '12px', opacity: 0.8, marginBottom: '8px' } }, 'Continue a previous conversation'), - ...availableSessions.slice(0, 5).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', + wp.element.createElement('div', { className: 'wpaw-session-list' }, + ...availableSessions.map((session, idx) => + wp.element.createElement('div', { + key: session.session_id || idx, + className: 'wpaw-welcome-pill', style: { - flex: 1, - background: 'transparent', - border: 'none', - color: 'inherit', - textAlign: 'left', - cursor: 'pointer' - }, - onClick: () => { - setCurrentSessionId(session.session_id || ''); - setMessages(Array.isArray(session.messages) ? session.messages : []); - setShowWelcome(false); + width: '100%', + marginBottom: '6px', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '8px' } }, - wp.element.createElement('div', null, getSessionDisplayTitle(session, idx)), - wp.element.createElement('div', { style: { opacity: 0.7, fontSize: '11px' } }, - `${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' + 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 || ''); + } }, - onClick: () => deleteConversationSession(session.session_id) - }, '×') + wp.element.createElement('div', null, getSessionDisplayTitle(session, idx)), + // wp.element.createElement('div', { style: { opacity: 0.55, fontSize: '10px', marginTop: '2px' } }, + // getSessionDebugMeta(session) + // ), + 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) + }, '×') + ) ) ), wp.element.createElement('button', { @@ -5165,7 +5925,7 @@ sessionId: currentSessionId, mode: 'generation', postConfig: postConfig, - chatHistory: messages.filter(m => m.role === 'user' || m.role === 'assistant').slice(-10), + chatHistory: buildChatHistoryPayload(), }), }); @@ -5256,18 +6016,19 @@ articleLength: postConfig.article_length, detectedLanguage: detectedLanguage, postConfig: postConfig, - chatHistory: messages.filter(m => m.role === 'user' || m.role === 'assistant').slice(-10), + chatHistory: buildChatHistoryPayload(), }), }); if (!response.ok) { const error = await response.json(); - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'Error: ' + (error.message || 'Failed to generate outline'), - canRetry: true - }]); + setMessages(prev => [...prev, { + role: 'system', + type: 'error', + content: formatAiErrorMessage(error, 'Failed to generate outline'), + canRetry: true, + retryType: 'generation' + }]); setIsLoading(false); return; } @@ -5320,29 +6081,17 @@ } setIsLoading(false); - } catch (error) { - const errorMsg = error.message || 'Failed to generate outline'; - setMessages(prev => [...prev, { - role: 'system', - type: 'error', - content: 'Error: ' + errorMsg, - canRetry: true - }]); + } catch (error) { + setMessages(prev => [...prev, { + role: 'system', + type: 'error', + content: formatAiErrorMessage(error, 'Failed to generate outline'), + canRetry: true, + retryType: 'generation' + }]); setIsLoading(false); } } - }, - start_writing: { - icon: '✍️', - title: 'Ready to start writing?', - description: 'Let\'s turn your outline into a full article.', - button: 'Start Writing', - onClick: async () => { - setAgentMode('writing'); - if (currentPlanRef.current) { - await executePlanFromCard(); - } - } } }; @@ -5937,7 +6686,7 @@ wp.element.createElement('div', { className: 'wpaw-config-section' }, wp.element.createElement('label', null, 'DEFAULT MODE'), wp.element.createElement('select', { - value: postConfig.default_mode, + value: postConfig.default_mode === 'writing' ? 'chat' : postConfig.default_mode, onChange: (e) => { updatePostConfig('default_mode', e.target.value); setAgentMode(e.target.value); @@ -5945,7 +6694,6 @@ disabled: isConfigDisabled, className: 'wpaw-select' }, - wp.element.createElement('option', { value: 'writing' }, 'Writing'), wp.element.createElement('option', { value: 'planning' }, 'Planning'), wp.element.createElement('option', { value: 'chat' }, 'Chat') ), @@ -6273,6 +7021,9 @@ isEditorLocked && wp.element.createElement('div', { className: 'wpaw-editor-lock-banner' }, 'Writing in progress — please wait until the article finishes.' ), + isRefinementLocked && wp.element.createElement('div', { className: 'wpaw-refinement-lock-banner' }, + `Refining in progress — editing is temporarily locked. You can still scroll and review changes live (${refiningBlockIds.length} target block(s)).` + ), // Welcome Screen (first time) showWelcome && !isEditorLocked && renderWelcomeScreen(), // Writing Mode Empty State @@ -6301,9 +7052,7 @@ rows: isTextareaExpanded ? 20 : 3, placeholder: agentMode === 'planning' ? 'Describe what you want to write about...' - : agentMode === 'chat' - ? 'Ask me anything about your content...' - : 'Tell me what to write. Use @block to refine.' + : 'Ask me anything about your content...' }) ), showMentionAutocomplete && mentionOptions.length > 0 && wp.element.createElement('div', { @@ -6388,11 +7137,10 @@ wp.element.createElement('select', { className: 'wpaw-command-mode-select', id: 'agentMode', - value: agentMode, + value: agentMode === 'writing' ? 'chat' : agentMode, onChange: (e) => setAgentMode(e.target.value), disabled: isLoading, }, - wp.element.createElement('option', { value: 'writing' }, 'Writing'), wp.element.createElement('option', { value: 'planning' }, 'Planning'), wp.element.createElement('option', { value: 'chat' }, 'Chat') ) @@ -6482,10 +7230,11 @@ wp.element.createElement('span', { className: 'wpaw-kbd' }, wp.element.createElement('kbd', null, /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl'), '+', wp.element.createElement('kbd', null, '↵'), ' Send'), wp.element.createElement('span', { className: 'wpaw-kbd' }, wp.element.createElement('kbd', null, '@'), ' Blocks'), wp.element.createElement('span', { className: 'wpaw-kbd' }, wp.element.createElement('kbd', null, '/'), ' Commands') - ) - ) + ), + renderRefineAllConfirmModal() ) - ); + ) + ); }; // Refresh cost data from server diff --git a/docs/architecture/OPENROUTER_BYOK_CONTEXT_STREAMING_SPEC.md b/docs/architecture/OPENROUTER_BYOK_CONTEXT_STREAMING_SPEC.md new file mode 100644 index 0000000..5ffe134 --- /dev/null +++ b/docs/architecture/OPENROUTER_BYOK_CONTEXT_STREAMING_SPEC.md @@ -0,0 +1,421 @@ +# OpenRouter BYOK Context and Streaming Spec + +**Date:** 2026-06-05 +**Status:** Proposed implementation direction +**Goal:** Replace local bash/proxy-first text generation with an OpenRouter BYOK-first API path while preserving article continuity and improving streamed editor UX. + +## Decision + +Use OpenRouter as the primary text transport for `chat`, `clarity`, `planning`, `writing`, and `refinement`, with the user's OpenRouter workspace configured for BYOK provider keys. + +The plugin should continue to store conversation and article memory in WordPress. OpenRouter should be treated as a stateless model gateway: it streams model output, returns usage metadata, applies provider routing, and can cache identical responses. It does not own article continuity. + +Local Backend should become optional or legacy. It is useful for experiments, but it should not be the recommended default because it asks users to run local scripts/proxy tooling and creates trust friction. + +## Current Implementation Snapshot + +The current plugin already has most of the foundation: + +- `includes/interface-ai-provider.php` defines `chat()`, `chat_stream()`, `generate_image()`, `is_configured()`, `test_connection()`, and `supports_task_type()`. +- `includes/class-provider-manager.php` routes each task through configured providers and already prevents silent OpenRouter spend when fallback is disabled. +- `includes/class-openrouter-provider.php` supports non-streaming and streaming chat completions through OpenRouter. +- `includes/class-local-backend-provider.php` supports a local proxy at `/v1/messages`, including a cURL streaming parser and plain JSON fallback. +- `includes/class-conversation-manager.php` stores sessions in `{$wpdb->prefix}wpaw_conversations` with `messages` and `context` JSON fields. +- `includes/class-context-service.php` is already documented as the single source of truth for messages, `_wpaw_plan`, `_wpaw_post_config`, and legacy chat migration. +- `includes/class-gutenberg-sidebar.php` exposes the main REST routes: `/chat`, `/generate-plan`, `/revise-plan`, `/execute-article`, `/refine-block`, `/refine-from-chat`, `/summarize-context`, `/detect-intent`, `/writing-state/{post_id}`, and conversation routes. +- Cost tracking already records `post_id`, `session_id`, `model`, `provider`, `action`, input tokens, output tokens, cost, and status. + +The main gap is not lack of streaming. The gap is that several routes still accept full `chatHistory` from the browser and inject it into prompts. That makes continuity depend on the browser payload and can re-send too much context. + +## Product Positioning + +Recommended provider settings: + +```php +'task_providers' => array( + 'chat' => 'openrouter', + 'clarity' => 'openrouter', + 'planning' => 'openrouter', + 'writing' => 'openrouter', + 'refinement' => 'openrouter', + 'image' => 'openrouter', +), +'allow_openrouter_fallback' => false, +``` + +The UI copy should present this as: + +- Connect OpenRouter API key. +- Configure BYOK provider keys inside OpenRouter. +- Stream directly into WordPress. +- Keep all article memory in WordPress. +- Local Backend is advanced or legacy. + +OpenRouter BYOK details to reflect in docs: + +- BYOK lets users route requests through their own provider keys while still using OpenRouter's API surface. +- BYOK provider keys are encrypted and used for requests routed through the selected provider. +- OpenRouter's BYOK fee is documented as 5 percent of the normal OpenRouter model/provider cost, waived for the first 1M BYOK requests per month. +- Users can prevent fallback to OpenRouter shared endpoints by enabling the provider key's "Always use for this provider" behavior in OpenRouter. +- OpenRouter usage data is returned in normal responses and in the last SSE message for streamed responses. + +Sources: + +- https://openrouter.ai/docs/guides/overview/auth/byok +- https://openrouter.ai/docs/cookbook/administration/usage-accounting +- https://openrouter.ai/docs/guides/features/response-caching/ + +## Continuity Ownership + +Continuity is owned by WordPress, not OpenRouter. + +Persisted state: + +| State | Current storage | Keep or change | +| --- | --- | --- | +| Conversation messages | `wpaw_conversations.messages` | Keep | +| Session context | `wpaw_conversations.context` | Extend | +| Article plan | `_wpaw_plan` post meta | Keep | +| Post config | `_wpaw_post_config` post meta | Keep | +| Writing state | `_wpaw_writing_status`, `_wpaw_current_section`, `_wpaw_sections_written`, `_wpaw_resume_token` | Keep | +| Section to block mapping | `_wpaw_section_blocks` | Keep | +| Lightweight post memory | `_wpaw_memory` | Extend or migrate into `context` | +| Cost and token usage | `wpaw_cost_tracking` | Extend | + +Recommended new session context shape: + +```json +{ + "working_summary": { + "text": "The article is about ...", + "updated_at": "2026-06-05T10:30:00+07:00", + "source_message_count": 14 + }, + "decisions": [ + { + "type": "accept", + "target": "outline.section.2", + "summary": "Keep the practical checklist framing.", + "created_at": "2026-06-05T10:31:00+07:00" + } + ], + "rejections": [ + { + "target": "outline.section.4", + "summary": "Too generic; needs concrete WordPress examples.", + "created_at": "2026-06-05T10:32:00+07:00" + } + ], + "research_notes": [ + { + "source": "manual", + "title": "User supplied constraint", + "excerpt": "Avoid local bash instructions in the default UX.", + "tags": ["trust", "onboarding"] + } + ], + "token_policy": { + "max_recent_messages": 6, + "max_summary_tokens": 600, + "max_research_snippets": 5 + } +} +``` + +Store this in `wpaw_conversations.context` first. Avoid adding a new custom table until `context` becomes too large or needs relational querying. + +## Context Builder + +Add a dedicated builder instead of assembling continuity inside each REST handler. + +New file: + +```text +includes/class-context-builder.php +``` + +Primary API: + +```php +class WP_Agentic_Writer_Context_Builder { + public function build_for_task( $task, $session_id, $post_id, $request_params = array() ) { + // Returns normalized prompt parts for chat, planning, writing, refinement, SEO. + } +} +``` + +Return shape: + +```php +array( + 'system_context' => 'Stable task and policy instructions.', + 'working_context' => 'Compact summary, decisions, plan, selected post config.', + 'active_content' => 'The exact section/block/article slice being edited.', + 'research_context' => 'Only relevant excerpts.', + 'audit' => array( + 'included_recent_messages' => 6, + 'included_research_items' => 3, + 'estimated_input_tokens' => 2200, + 'used_full_history' => false, + ), +) +``` + +Context assembly rules: + +- Always include the task system prompt and language instruction. +- Always include post config summary: audience, tone, language, article length, SEO fields, web search preference. +- Include `_wpaw_plan` for planning, writing, and outline refinement. +- Include only the active block or section for block refinement. +- Include recent raw messages only up to `max_recent_messages`. +- Include `working_summary` when message history is long. +- Include decisions and rejections as compact bullet points. +- Include post content only when the task requires whole-article awareness, such as final polish or article-wide refinement. +- Never trust browser-provided `chatHistory` as authoritative if `sessionId` is available. + +## Endpoint Changes + +### `/chat` + +Current behavior: + +- Receives `messages` from the browser. +- Prepends a system prompt. +- Streams or returns a chat response. +- Persists user and assistant messages. + +Required change: + +- Use browser `messages` only to identify the latest user message. +- Load authoritative session context from `WP_Agentic_Writer_Context_Service`. +- Build final messages through `WP_Agentic_Writer_Context_Builder`. +- Persist the raw user message and assistant response after completion. + +### `/generate-plan` + +Current behavior: + +- Accepts `topic`, `context`, `chatHistory`, and other config. +- Serializes full `chatHistory` into the planning prompt. +- Stores `_wpaw_plan` and `_wpaw_memory`. + +Required change: + +- Keep `topic`, `context`, `clarificationAnswers`, and `post_config`. +- Replace full `chatHistory` injection with a context package from the builder. +- Save generated plan to `_wpaw_plan`. +- Update `wpaw_conversations.context.working_summary` after plan generation. + +### `/revise-plan` + +Required behavior: + +- Include current `_wpaw_plan`. +- Include latest user instruction. +- Include accepted/rejected outline decisions. +- Ask for raw JSON plan only. +- Save previous plan as a version entry inside `wpaw_conversations.context.plan_versions` before overwriting `_wpaw_plan`. + +### `/execute-article` + +Current behavior: + +- Writes sections from the plan. +- Streams section content and block events. +- Updates `_wpaw_plan` section statuses. + +Required change: + +- For each section, send the section brief, global article summary, relevant decisions, and relevant research. +- Do not send the full conversation for every section. +- After each section completes, update writing state and append a section summary to session context. + +### `/refine-block` and `/refine-from-chat` + +Required behavior: + +- Send active block content, neighboring heading/section context, relevant plan entry, and latest instruction. +- Include compact working summary and decisions. +- Do not include the full draft unless the requested operation is article-wide. + +### `/summarize-context` + +Current behavior: + +- Summarizes browser-provided `chatHistory`. +- Returns summary but does not appear to be the authoritative persistence mechanism. + +Required change: + +- Accept `sessionId`. +- Load authoritative session messages. +- Save the resulting summary into `wpaw_conversations.context.working_summary`. +- Return `summary`, `message_count`, `source_message_count`, `tokens_saved`, and provider metadata. + +## Streaming Transport + +OpenRouter streaming is already implemented in `WP_Agentic_Writer_OpenRouter_Provider::chat_stream()`. + +Keep this transport shape: + +```php +$body = array( + 'model' => $model, + 'messages' => $messages, + 'stream' => true, +); +``` + +Modernize usage handling: + +- OpenRouter now returns full usage metadata automatically. +- `usage: { include: true }` and `stream_options: { include_usage: true }` are documented as deprecated and no longer required. +- Keep parsing the final `usage` object from streamed chunks. +- Extend cost tracking to store cache metadata when available. + +Recommended emitted SSE events: + +```json +{"type":"provider","provider":"openrouter","model":"openai/gpt-4o-mini","byok_expected":true} +{"type":"conversational_stream","content":"partial accumulated text"} +{"type":"usage","input_tokens":1200,"output_tokens":360,"cached_tokens":0,"cost":0.0012} +{"type":"complete","session_id":"abc123","totalCost":0.0012} +``` + +Use the existing browser parsing path in `assets/js/sidebar.js` and add support for the optional `provider` and `usage` event types. + +## Response Caching Policy + +OpenRouter response caching should be used for deterministic, duplicate-safe operations only. It is not article memory. + +Recommended use: + +- `detect_intent` +- `summarize_context` retry +- connection test +- repeated model capability lookups if routed through completion calls + +Avoid by default: + +- article draft generation +- outline revision +- refinement requests +- image prompt generation + +Provider implementation change: + +```php +if ( ! empty( $options['openrouter_response_cache'] ) ) { + $headers[] = 'X-OpenRouter-Cache: true'; + $headers[] = 'X-OpenRouter-Cache-TTL: ' . (int) ( $options['openrouter_cache_ttl'] ?? 300 ); +} +``` + +Important limitations: + +- Cache hits only happen for identical requests. +- Streaming and non-streaming requests are cached separately. +- Cache hit usage counters are zeroed. +- Response caching is beta and requires OpenRouter to store response data temporarily. + +## Usage and Budget Tracking + +Extend `wpaw_cost_tracking` with optional cache and upstream fields: + +```sql +ALTER TABLE {$wpdb->prefix}wpaw_cost_tracking + ADD COLUMN cached_tokens int(11) DEFAULT 0 AFTER output_tokens, + ADD COLUMN cache_write_tokens int(11) DEFAULT 0 AFTER cached_tokens, + ADD COLUMN upstream_inference_cost decimal(10,6) DEFAULT NULL AFTER cost, + ADD COLUMN generation_id varchar(64) DEFAULT '' AFTER status; +``` + +Implementation notes: + +- Put this behind a schema version bump, not plugin version alone. +- Keep existing `maybe_upgrade_table()` pattern in `WP_Agentic_Writer_Cost_Tracker`. +- Parse `usage.prompt_tokens_details.cached_tokens`. +- Parse `usage.prompt_tokens_details.cache_write_tokens`. +- Parse `usage.cost_details.upstream_inference_cost` for BYOK requests. +- Include a monthly token budget view alongside the existing cost view. + +Budget metric examples: + +```php +billable_input_tokens = max( 0, input_tokens - cached_tokens ); +total_monthly_tokens = sum( input_tokens + output_tokens ); +byok_free_request_counter = count( provider = 'openrouter' and status = 'success' ); +``` + +Note: OpenRouter documents the BYOK waiver as first 1M BYOK requests per month, not first 1M tokens. Keep UI wording precise. + +## Settings UI Changes + +Update Settings V2: + +- Rename default cloud path to `OpenRouter BYOK / API`. +- Keep API key storage in `wp_agentic_writer_settings.openrouter_api_key`. +- Add a help panel explaining that provider BYOK keys are configured in OpenRouter, not in WordPress. +- Add a "Prevent shared fallback" checklist item that links users to OpenRouter BYOK provider settings. +- Move Local Backend to an `Advanced` or `Legacy Local Backend` section. +- Make provider routing default all text tasks to `openrouter`. +- Keep image task on `openrouter`. +- Show a trust note: WordPress streams directly to OpenRouter; no local shell or CLI process is required. + +Do not collect provider keys directly in WordPress unless there is a deliberate product decision to bypass OpenRouter BYOK management. The safer default is only storing the OpenRouter API key. + +## Migration Plan + +### Phase 1: Documentation and defaults + +- Add this spec. +- Update user-facing Local Backend docs to say local backend is optional/advanced. +- Default new installs to OpenRouter for all tasks. +- Keep existing installs unchanged unless the user opts in. + +### Phase 2: Context builder + +- Add `includes/class-context-builder.php`. +- Load it from `wp-agentic-writer.php`. +- Move repeated context assembly out of `class-gutenberg-sidebar.php`. +- Make `/chat`, `/generate-plan`, `/revise-plan`, and refinement endpoints use the builder. + +### Phase 3: Authoritative summaries + +- Extend `WP_Agentic_Writer_Context_Service` with: + - `get_session_context( $session_id )` + - `update_session_context( $session_id, $patch )` + - `summarize_session_if_needed( $session_id, $post_id )` +- Make `/summarize-context` persist summaries to `wpaw_conversations.context`. +- Store plan versions and section summaries in context. + +### Phase 4: Streaming and usage polish + +- Remove deprecated OpenRouter usage request parameters. +- Emit optional `provider` and `usage` SSE events. +- Extend cost tracking schema for cached tokens and BYOK upstream cost. +- Add UI display for monthly token usage. + +### Phase 5: Local backend repositioning + +- Move local backend downloads and setup UI to advanced/legacy. +- Keep `WP_Agentic_Writer_Local_Backend_Provider` for existing users. +- Disable automatic local backend recommendation in onboarding. + +## Acceptance Criteria + +- A new article can be planned and written through OpenRouter streaming without any local bash/proxy setup. +- Existing conversation history persists through `wpaw_conversations`. +- Plan generation no longer sends full browser `chatHistory` when `sessionId` is available. +- Refining a block includes active block, relevant plan, compact decisions, and recent messages, not full raw history. +- Streaming responses show partial text in the editor and finish with usage metadata. +- Cost tracking records provider, model, action, session, tokens, and cost as it does today. +- New cache fields are recorded when OpenRouter returns them. +- Local Backend still works for users who already configured it, but it is no longer the default recommendation. + +## Implementation Risks + +- Some existing frontend flows rely on `messages` as the full source of truth. Those flows need to pass `sessionId` reliably before backend context can become authoritative. +- `wpaw_conversations.context` is `LONGTEXT`, so it can hold rich JSON, but large contexts should still be summarized to keep admin queries fast. +- OpenRouter response caching is beta and should not be presented as durable memory. +- BYOK provider fallback behavior is configured in OpenRouter, so the WordPress UI can guide and detect symptoms but cannot fully enforce provider-key policy from this plugin alone. diff --git a/docs/user-facing/downloads/.gitignore b/docs/user-facing/downloads/.gitignore deleted file mode 100644 index 7ffb7c3..0000000 --- a/docs/user-facing/downloads/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Ignore node_modules in local backend package -agentic-writer-local-backend/node_modules/ -agentic-writer-local-backend/proxy.log -agentic-writer-local-backend/proxy.pid - -# Keep the distributable ZIP -!agentic-writer-local-backend.zip diff --git a/docs/user-facing/downloads/agentic-writer-local-backend.zip b/docs/user-facing/downloads/agentic-writer-local-backend.zip deleted file mode 100644 index 62f40a3..0000000 Binary files a/docs/user-facing/downloads/agentic-writer-local-backend.zip and /dev/null differ diff --git a/docs/user-facing/downloads/agentic-writer-local-backend/README.md b/docs/user-facing/downloads/agentic-writer-local-backend/README.md deleted file mode 100644 index 67f122d..0000000 --- a/docs/user-facing/downloads/agentic-writer-local-backend/README.md +++ /dev/null @@ -1,170 +0,0 @@ -# Agentic Writer Local Backend - -Run unlimited AI content generation on your own machine using your Claude CLI + Z.ai/Anthropic account. - -## Prerequisites - -Before starting, ensure you have: - -- ✅ **Claude CLI** installed and configured - - Get it: [https://claude.ai/code](https://claude.ai/code) or [https://z.ai](https://z.ai) - - Verify: `claude --version` or `which claude` -- ✅ **Node.js 18+** installed - - Download: [https://nodejs.org](https://nodejs.org) - - Verify: `node --version` -- ✅ **Z.ai Coding Plan** or **Anthropic API key** configured in Claude CLI - -## Quick Start - -### 1. Extract Package - -```bash -unzip agentic-writer-local-backend.zip -cd agentic-writer-local-backend -``` - -### 2. Start the Proxy - -```bash -chmod +x *.sh -./start-proxy.sh -``` - -You'll see: - -``` -═══════════════════════════════════════════════════ -✅ Local Backend Running! -═══════════════════════════════════════════════════ - -Your Configuration: - Base URL: http://192.168.1.105:8080 - API Key: dummy - Model: claude-local -``` - -### 3. Configure WordPress Plugin - -1. Open **WP Admin** → **Agentic Writer** → **Settings** → **Local Backend** -2. Paste the **Base URL** shown above -3. API Key: `dummy` -4. Click **Test Connection** → should show ✅ -5. Start generating content! - -## Commands - -```bash -./start-proxy.sh # Start proxy (runs in background) -./stop-proxy.sh # Stop proxy -./test-connection.sh # Test if proxy responds -./get-local-ip.sh # Find your local IP address -tail -f proxy.log # View real-time logs -``` - -## Firewall Setup - -The proxy needs to accept connections from your WordPress site. - -### macOS - -1. **System Settings** → **Network** → **Firewall** -2. Click **Options** → **Add** → Select `node` -3. Set to **Allow incoming connections** - -### Linux (ufw) - -```bash -sudo ufw allow 8080/tcp -sudo ufw reload -``` - -### Windows - -1. **Windows Defender Firewall** → **Advanced Settings** -2. **Inbound Rules** → **New Rule** -3. **Port** → TCP **8080** → **Allow** - -## How It Works - -``` -WordPress Plugin → HTTP POST → Local Proxy (port 8080) - ↓ - Spawns Claude CLI - ↓ - Returns AI Response -``` - -**Benefits:** -- 🆓 **Free**: Uses your existing Z.ai/Anthropic subscription -- 🔒 **Private**: Content never leaves your network -- ⚡ **Fast**: LAN latency (~50-200ms) -- 🚀 **Unlimited**: No rate limits, no token counting - -## Troubleshooting - -See [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for detailed solutions. - -### Quick Fixes - -**"Connection failed" in plugin:** -```bash -# Check proxy is running -ps aux | grep claude-proxy - -# Restart if needed -./stop-proxy.sh && ./start-proxy.sh -``` - -**"Claude CLI not found":** -```bash -# Verify Claude is installed -which claude -claude --version - -# Test Claude works -echo "Hello" | claude -``` - -**"Wrong IP address":** -```bash -# Find your correct IP -./get-local-ip.sh - -# Or manually: -# macOS: ipconfig getifaddr en0 -# Linux: ip route get 1 | awk '{print $7}' -``` - -**Port 8080 already in use:** -```bash -# Find what's using it -lsof -i :8080 - -# Change port (edit claude-proxy.js) -PORT=9000 node claude-proxy.js -# Update plugin Base URL to: http://your-ip:9000 -``` - -## Security Notes - -- Proxy binds to `0.0.0.0` (all network interfaces) for LAN access -- No authentication by design (LAN trust model) -- All request prompts are logged to `proxy.log` -- For internet exposure, use ngrok/reverse proxy with authentication - -## Environment Variables - -```bash -PORT=9000 ./start-proxy.sh # Use different port -NODE_ENV=production # Production mode -``` - -## Support - -- **Documentation**: [Plugin Docs](https://github.com/your/plugin) -- **Issues**: [GitHub Issues](https://github.com/your/plugin/issues) -- **Community**: [Discord](https://discord.gg/your-server) - -## License - -GPL-2.0+ - Same as WP Agentic Writer plugin diff --git a/docs/user-facing/downloads/agentic-writer-local-backend/TROUBLESHOOTING.md b/docs/user-facing/downloads/agentic-writer-local-backend/TROUBLESHOOTING.md deleted file mode 100644 index 41e822d..0000000 --- a/docs/user-facing/downloads/agentic-writer-local-backend/TROUBLESHOOTING.md +++ /dev/null @@ -1,339 +0,0 @@ -# Troubleshooting Guide - -Common issues and solutions for Agentic Writer Local Backend. - -## Connection Issues - -### "Connection timeout" in Plugin - -**Symptoms:** -- Plugin shows "Connection timeout" error -- Test connection fails - -**Solutions:** - -1. **Check proxy is running:** - ```bash - ps aux | grep claude-proxy - ``` - -2. **Restart proxy:** - ```bash - ./stop-proxy.sh - ./start-proxy.sh - ``` - -3. **Check logs:** - ```bash - tail -f proxy.log - ``` - -4. **Verify IP address:** - ```bash - ./get-local-ip.sh - ``` - -### "Connection refused" - -**Cause:** Proxy not running or wrong IP - -**Solutions:** - -1. **Start proxy:** - ```bash - ./start-proxy.sh - ``` - -2. **Check firewall:** - - macOS: System Settings → Network → Firewall → Allow Node.js - - Linux: `sudo ufw allow 8080/tcp` - - Windows: Defender Firewall → Allow port 8080 - -3. **Test locally first:** - ```bash - curl http://localhost:8080/ping - # Should return: pong - ``` - -## Claude CLI Issues - -### "Claude CLI not found" - -**Verify installation:** -```bash -which claude -# macOS: /opt/homebrew/bin/claude or /usr/local/bin/claude -# Linux: ~/.local/bin/claude or /usr/bin/claude -``` - -**Fix PATH:** -```bash -# Add to ~/.zshrc or ~/.bashrc -export PATH="/opt/homebrew/bin:$PATH" -source ~/.zshrc -``` - -**Reinstall Claude CLI:** -- Visit: [https://claude.ai/code](https://claude.ai/code) -- Follow installation instructions - -### "No response from Claude" - -**Test Claude manually:** -```bash -echo "Hello, reply with: Test successful" | claude -``` - -**Check authentication:** -```bash -claude --version -# Should show version and auth status -``` - -**Reconfigure Claude:** -- Check Z.ai account: [https://z.ai](https://z.ai) -- Or Anthropic API key setup - -## Network Issues - -### Wrong IP Address Detected - -**Find correct IP:** -```bash -# macOS -ipconfig getifaddr en0 # WiFi -ipconfig getifaddr en1 # Ethernet - -# Linux -ip route get 1 | awk '{print $7}' -hostname -I - -# Windows -ipconfig -# Look for "IPv4 Address" under active adapter -``` - -**Update plugin settings:** -- Use the correct IP in Base URL: `http://CORRECT-IP:8080` - -### Port 8080 Already in Use - -**Find what's using it:** -```bash -lsof -i :8080 -# or -netstat -anp | grep 8080 -``` - -**Change port:** - -1. Edit `claude-proxy.js`: - ```javascript - const PORT = process.env.PORT || 9000; // Change 8080 to 9000 - ``` - -2. Restart proxy: - ```bash - ./stop-proxy.sh - PORT=9000 ./start-proxy.sh - ``` - -3. Update plugin Base URL: `http://your-ip:9000` - -## Performance Issues - -### Slow Response Times - -**Normal latency:** -- Local network: 50-200ms -- Claude CLI processing: 2-30 seconds depending on prompt - -**If consistently slow:** - -1. **Check network:** - ```bash - ping 192.168.1.105 # Your proxy IP - ``` - -2. **Monitor logs:** - ```bash - tail -f proxy.log - ``` - -3. **Check machine resources:** - - CPU usage: Claude CLI is CPU-intensive - - Memory: Ensure sufficient RAM available - -### Proxy Crashes - -**Check logs:** -```bash -cat proxy.log | tail -50 -``` - -**Common causes:** -- Out of memory: Close other applications -- Claude CLI timeout: Increase timeout in `claude-proxy.js` -- Malformed requests: Check plugin version compatibility - -**Restart with clean state:** -```bash -./stop-proxy.sh -rm proxy.log -./start-proxy.sh -``` - -## Plugin Integration Issues - -### "Invalid response format" - -**Cause:** Claude response doesn't match expected JSON format - -**Debug:** -1. Check `proxy.log` for actual Claude output -2. Test manually: - ```bash - curl -X POST http://localhost:8080/v1/messages \ - -H "Content-Type: application/json" \ - -d '{"messages":[{"role":"user","content":"Hello"}]}' - ``` - -3. Update Claude CLI if outdated: - ```bash - claude --version - # Upgrade if needed - ``` - -### Cost Tracking Shows $0 - -**Expected behavior:** Local backend is free, plugin should show `$0.00 (Local)` - -**If concerned:** -- This is correct - local backend has no API costs -- Dashboard should show "X requests local (free)" - -## Advanced Troubleshooting - -### Enable Debug Logging - -Edit `claude-proxy.js`: -```javascript -const DEBUG = true; // Add at top of file - -// In /v1/messages handler: -if (DEBUG) { - console.log('Full request:', JSON.stringify(req.body, null, 2)); - console.log('Full response:', output); -} -``` - -### Test with curl - -**Ping:** -```bash -curl http://localhost:8080/ping -# Expected: pong -``` - -**Inference:** -```bash -curl -X POST http://localhost:8080/v1/messages \ - -H "Content-Type: application/json" \ - -d '{ - "messages": [ - {"role": "user", "content": "Reply with: Test successful"} - ] - }' -``` - -**Expected response:** -```json -{ - "id": "local-1234567890", - "object": "chat.completion", - "model": "claude-local", - "choices": [{ - "message": { - "content": "Test successful" - } - }] -} -``` - -### Permissions Issues (macOS) - -**Make scripts executable:** -```bash -chmod +x start-proxy.sh stop-proxy.sh test-connection.sh get-local-ip.sh -``` - -**If "permission denied":** -```bash -# Check file permissions -ls -la *.sh - -# Reset if needed -chmod 755 *.sh -``` - -## Still Having Issues? - -1. **Check system requirements:** - - Node.js 18+: `node --version` - - Claude CLI installed: `which claude` - - Sufficient disk space: `df -h` - -2. **Collect diagnostic info:** - ```bash - echo "Node version:" $(node --version) - echo "Claude path:" $(which claude) - echo "Local IP:" $(./get-local-ip.sh) - echo "Proxy status:" $(ps aux | grep claude-proxy) - tail -20 proxy.log - ``` - -3. **Reset everything:** - ```bash - ./stop-proxy.sh - rm -rf node_modules proxy.log proxy.pid - npm install - ./start-proxy.sh - ``` - -4. **Get help:** - - GitHub Issues: [Report Bug](https://github.com/your/plugin/issues) - - Discord Community: [Join Chat](https://discord.gg/your-server) - - Include: OS, Node version, Claude CLI version, error logs - -## Environment-Specific Notes - -### macOS - -- Default Claude path: `/opt/homebrew/bin/claude` -- Firewall: System Settings → Network → Firewall -- IP detection: `ipconfig getifaddr en0` - -### Linux - -- Default Claude path: `~/.local/bin/claude` -- Firewall: `sudo ufw allow 8080/tcp` -- IP detection: `ip route get 1 | awk '{print $7}'` - -### Windows - -- Claude path varies, check `where claude` -- Firewall: Windows Defender → Allow port 8080 -- IP detection: `ipconfig` (look for IPv4) -- Scripts: Use Git Bash or WSL to run `.sh` scripts - -## Security Best Practices - -1. **LAN only:** Don't expose proxy to internet without authentication -2. **Firewall:** Restrict to specific IPs if on shared network -3. **Logs:** `proxy.log` contains all prompts - review periodically -4. **Updates:** Keep Node.js and Claude CLI updated - ---- - -**Last Updated:** 2025-02-27 -**Version:** 1.0.0 diff --git a/docs/user-facing/downloads/agentic-writer-local-backend/claude-proxy.js b/docs/user-facing/downloads/agentic-writer-local-backend/claude-proxy.js deleted file mode 100644 index 0c7ff37..0000000 --- a/docs/user-facing/downloads/agentic-writer-local-backend/claude-proxy.js +++ /dev/null @@ -1,122 +0,0 @@ -const express = require('express'); -const { spawn } = require('child_process'); -const app = express(); - -app.use(express.json()); - -// Health check endpoint -app.get('/ping', (req, res) => { - res.send('pong'); -}); - -// Main inference endpoint (OpenAI-compatible format) -app.post('/v1/messages', async (req, res) => { - const { messages } = req.body; - - if (!messages || !Array.isArray(messages) || messages.length === 0) { - return res.status(400).json({ - error: { - message: 'Invalid request: messages array required' - } - }); - } - - // Extract the last user message as the prompt - const lastMessage = messages[messages.length - 1]; - const prompt = lastMessage.content; - - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log('Request from:', req.ip); - console.log('Prompt length:', prompt.length, 'chars'); - console.log('Prompt preview:', prompt.substring(0, 150) + '...'); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - - // Spawn Claude CLI process - const claude = spawn('claude', [], { - stdio: ['pipe', 'pipe', 'pipe'] - }); - - let output = ''; - let errorOutput = ''; - - claude.stdout.on('data', (data) => { - output += data.toString(); - process.stdout.write('.'); - }); - - claude.stderr.on('data', (data) => { - errorOutput += data.toString(); - console.error('Claude stderr:', data.toString()); - }); - - claude.on('close', (code) => { - console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log('Claude exit code:', code); - console.log('Response length:', output.length, 'chars'); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); - - if (code !== 0 || !output.trim()) { - return res.status(500).json({ - error: { - message: 'Claude CLI error', - details: errorOutput || 'No response from Claude' - } - }); - } - - // Return OpenAI-compatible response format - res.json({ - id: 'local-' + Date.now(), - object: 'chat.completion', - created: Math.floor(Date.now() / 1000), - model: 'claude-local', - choices: [{ - index: 0, - message: { - role: 'assistant', - content: output.trim() - }, - finish_reason: 'stop' - }], - usage: { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0 - } - }); - }); - - claude.on('error', (err) => { - console.error('Failed to spawn Claude CLI:', err); - res.status(500).json({ - error: { - message: 'Failed to spawn Claude CLI', - details: err.message - } - }); - }); - - // Send prompt to Claude after brief pause - setTimeout(() => { - claude.stdin.write(prompt + '\n'); - claude.stdin.end(); - }, 100); -}); - -const PORT = process.env.PORT || 8080; -app.listen(PORT, '0.0.0.0', () => { - console.log('═══════════════════════════════════════════════════'); - console.log('🚀 Agentic Writer Local Backend Started!'); - console.log('═══════════════════════════════════════════════════'); - console.log(`Local: http://localhost:${PORT}`); - console.log(`Network: http://YOUR-IP:${PORT}`); - console.log(''); - console.log('Plugin Configuration:'); - console.log(` Base URL: http://YOUR-IP:${PORT}`); - console.log(` API Key: dummy`); - console.log(` Model: claude-local`); - console.log(''); - console.log('Health check: GET /ping'); - console.log('Inference: POST /v1/messages'); - console.log('═══════════════════════════════════════════════════'); -}); diff --git a/docs/user-facing/downloads/agentic-writer-local-backend/get-local-ip.sh b/docs/user-facing/downloads/agentic-writer-local-backend/get-local-ip.sh deleted file mode 100644 index 62b3c08..0000000 --- a/docs/user-facing/downloads/agentic-writer-local-backend/get-local-ip.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash - -echo "Detecting your local IP address..." -echo "" - -# Detect local IP based on OS -if [[ "$OSTYPE" == "darwin"* ]]; then - # macOS - try en0 (WiFi) then en1 (Ethernet) - IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "") - INTERFACE=$(ifconfig en0 &>/dev/null && echo "en0 (WiFi)" || echo "en1 (Ethernet)") -elif [[ "$OSTYPE" == "linux-gnu"* ]]; then - # Linux - IP=$(ip route get 1 | awk '{print $7;exit}' 2>/dev/null || hostname -I | awk '{print $1}') - INTERFACE="default" -else - # Windows or unknown - IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "") - INTERFACE="unknown" -fi - -if [ -z "$IP" ]; then - echo "❌ Could not detect IP address automatically" - echo "" - echo "Manual detection:" - echo " macOS: ipconfig getifaddr en0" - echo " Linux: ip route get 1 | awk '{print \$7}'" - echo " Windows: ipconfig (look for IPv4 Address)" - exit 1 -fi - -echo "✅ Your local IP: $IP ($INTERFACE)" -echo "" -echo "Use this in your plugin settings:" -echo " Base URL: http://$IP:8080" diff --git a/docs/user-facing/downloads/agentic-writer-local-backend/package.json b/docs/user-facing/downloads/agentic-writer-local-backend/package.json deleted file mode 100644 index af80dad..0000000 --- a/docs/user-facing/downloads/agentic-writer-local-backend/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "agentic-writer-local-backend", - "version": "1.0.0", - "description": "Local backend proxy for WP Agentic Writer using Claude CLI", - "main": "claude-proxy.js", - "scripts": { - "start": "node claude-proxy.js", - "test": "curl http://localhost:8080/ping" - }, - "keywords": [ - "wordpress", - "ai", - "claude", - "proxy" - ], - "author": "WP Agentic Writer", - "license": "GPL-2.0+", - "dependencies": { - "express": "^4.18.2" - } -} diff --git a/docs/user-facing/downloads/agentic-writer-local-backend/start-proxy.sh b/docs/user-facing/downloads/agentic-writer-local-backend/start-proxy.sh deleted file mode 100644 index b4fc7e0..0000000 --- a/docs/user-facing/downloads/agentic-writer-local-backend/start-proxy.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/bin/bash - -echo "🚀 Starting Agentic Writer Local Backend..." -echo "" - -# Check dependencies -if ! command -v node &> /dev/null; then - echo "❌ Node.js not found. Install from https://nodejs.org/" - exit 1 -fi - -if ! command -v claude &> /dev/null; then - echo "❌ Claude CLI not found. Install and configure first." - echo " Check: https://claude.ai/code or https://z.ai" - exit 1 -fi - -# Auto-install express if needed -if [ ! -d "node_modules" ]; then - echo "📦 Installing dependencies..." - npm install -fi - -# Detect local IP -if [[ "$OSTYPE" == "darwin"* ]]; then - # macOS - LOCAL_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "127.0.0.1") -elif [[ "$OSTYPE" == "linux-gnu"* ]]; then - # Linux - LOCAL_IP=$(ip route get 1 | awk '{print $7;exit}' 2>/dev/null || echo "127.0.0.1") -else - # Windows/other - LOCAL_IP="127.0.0.1" -fi - -echo "✅ Dependencies OK" -echo "✅ Claude CLI found: $(which claude)" -echo "" -echo "Starting proxy server..." -echo "" - -# Start server in background -nohup node claude-proxy.js > proxy.log 2>&1 & -PID=$! -echo $PID > proxy.pid - -# Wait for server to boot -sleep 2 - -# Test if running -if kill -0 $PID 2>/dev/null; then - echo "═══════════════════════════════════════════════════" - echo "✅ Local Backend Running!" - echo "═══════════════════════════════════════════════════" - echo "" - echo "Your Configuration:" - echo " Base URL: http://$LOCAL_IP:8080" - echo " API Key: dummy" - echo " Model: claude-local" - echo "" - echo "Next Steps:" - echo " 1. Open your WordPress Admin" - echo " 2. Go to Agentic Writer → Settings → Local Backend" - echo " 3. Paste the Base URL above" - echo " 4. Click 'Test Connection'" - echo "" - echo "Commands:" - echo " Logs: tail -f proxy.log" - echo " Stop: ./stop-proxy.sh" - echo " Test: ./test-connection.sh" - echo "═══════════════════════════════════════════════════" -else - echo "❌ Failed to start. Check proxy.log for errors." - cat proxy.log - rm -f proxy.pid - exit 1 -fi diff --git a/docs/user-facing/downloads/agentic-writer-local-backend/stop-proxy.sh b/docs/user-facing/downloads/agentic-writer-local-backend/stop-proxy.sh deleted file mode 100644 index 2327056..0000000 --- a/docs/user-facing/downloads/agentic-writer-local-backend/stop-proxy.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash - -if [ -f proxy.pid ]; then - PID=$(cat proxy.pid) - if kill -0 $PID 2>/dev/null; then - kill $PID - rm proxy.pid - echo "🛑 Local Backend stopped (PID: $PID)" - else - echo "⚠️ No process found with PID: $PID" - rm proxy.pid - fi -else - # Fallback: kill by process name - pkill -f claude-proxy.js - if [ $? -eq 0 ]; then - echo "🛑 Stopped all claude-proxy processes" - else - echo "ℹ️ No claude-proxy processes running" - fi -fi diff --git a/docs/user-facing/downloads/agentic-writer-local-backend/test-connection.sh b/docs/user-facing/downloads/agentic-writer-local-backend/test-connection.sh deleted file mode 100644 index cf51782..0000000 --- a/docs/user-facing/downloads/agentic-writer-local-backend/test-connection.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/bash - -echo "Testing local backend connection..." -echo "" - -# Test /ping endpoint -echo "1. Testing health check..." -PING_RESPONSE=$(curl -s http://localhost:8080/ping 2>&1) - -if [ "$PING_RESPONSE" = "pong" ]; then - echo " ✅ Health check passed" -else - echo " ❌ Health check failed" - echo " Response: $PING_RESPONSE" - echo "" - echo "Is the proxy running? Check: ps aux | grep claude-proxy" - exit 1 -fi - -# Test /v1/messages endpoint -echo "2. Testing inference..." -RESPONSE=$(curl -s -X POST http://localhost:8080/v1/messages \ - -H "Content-Type: application/json" \ - -d '{"messages":[{"role":"user","content":"Reply with exactly: Test successful"}]}' 2>&1) - -echo " Response: $RESPONSE" - -if echo "$RESPONSE" | grep -q "choices"; then - echo " ✅ Inference endpoint working" - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "✅ Local Backend is working correctly!" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -else - echo " ❌ Inference test failed" - echo "" - echo "Troubleshooting:" - echo " 1. Check Claude CLI: echo 'test' | claude" - echo " 2. Check logs: tail -f proxy.log" - echo " 3. Restart proxy: ./stop-proxy.sh && ./start-proxy.sh" - exit 1 -fi diff --git a/downloads/README.md b/downloads/README.md deleted file mode 100644 index 137a0e9..0000000 --- a/downloads/README.md +++ /dev/null @@ -1,217 +0,0 @@ -# Agentic Writer Local Backend - -Run unlimited AI content generation on your own machine using your Claude CLI + Z.ai/Anthropic account. - -## Prerequisites - -Before starting, ensure you have: - -- ✅ **Claude CLI** installed and configured - - Get it: [https://claude.ai/code](https://claude.ai/code) or [https://z.ai](https://z.ai) - - Verify: `claude --version` or `which claude` -- ✅ **Node.js 18+** installed - - Download: [https://nodejs.org](https://nodejs.org) - - Verify: `node --version` -- ✅ **Z.ai Coding Plan** or **Anthropic API key** configured in Claude CLI - -## Quick Start - -### 1. Extract Package - -```bash -unzip agentic-writer-local-backend.zip -cd agentic-writer-local-backend -``` - -### 2. Start the Proxy - -```bash -chmod +x *.sh -./start-proxy.sh -``` - -You'll see: - -``` -═══════════════════════════════════════════════════ -✅ Local Backend Running! -═══════════════════════════════════════════════════ - -Your Configuration: - Base URL: http://192.168.1.105:8080 - API Key: dummy - Model: claude-local -``` - -### 3. Configure WordPress Plugin - -1. Open **WP Admin** → **Agentic Writer** → **Settings** → **Local Backend** -2. Paste the **Base URL** shown above -3. API Key: `dummy` -4. Click **Test Connection** → should show ✅ -5. Start generating content! - -## Commands - -```bash -./start-proxy.sh # Start proxy (runs in background) -./stop-proxy.sh # Stop proxy -./test-connection.sh # Test if proxy responds -./get-local-ip.sh # Find your local IP address -tail -f proxy.log # View real-time logs -``` - -## Firewall Setup - -The proxy needs to accept connections from your WordPress site. - -### macOS - -1. **System Settings** → **Network** → **Firewall** -2. Click **Options** → **Add** → Select `node` -3. Set to **Allow incoming connections** - -### Linux (ufw) - -```bash -sudo ufw allow 8080/tcp -sudo ufw reload -``` - -### Windows - -1. **Windows Defender Firewall** → **Advanced Settings** -2. **Inbound Rules** → **New Rule** -3. **Port** → TCP **8080** → **Allow** - -## How It Works - -``` -WordPress Plugin → HTTP POST → Local Proxy (port 8080) - ↓ - Spawns Claude CLI - ↓ - Returns AI Response -``` - -**Benefits:** -- 🆓 **Free**: Uses your existing Z.ai/Anthropic subscription -- 🔒 **Private**: Content never leaves your network -- ⚡ **Fast**: LAN latency (~50-200ms) -- 🚀 **Unlimited**: No rate limits, no token counting - -## Troubleshooting - -See [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for detailed solutions. - -### Quick Fixes - -**"Connection failed" in plugin:** -```bash -# Check proxy is running -ps aux | grep claude-proxy - -# Restart if needed -./stop-proxy.sh && ./start-proxy.sh -``` - -**"Claude CLI not found":** -```bash -# Verify Claude is installed -which claude -claude --version - -# Test Claude works -echo "Hello" | claude -``` - -**"Wrong IP address":** -```bash -# Find your correct IP -./get-local-ip.sh - -# Or manually: -# macOS: ipconfig getifaddr en0 -# Linux: ip route get 1 | awk '{print $7}' -``` - -**Port 8080 already in use:** -```bash -# Find what's using it -lsof -i :8080 - -# Change port (edit claude-proxy.js) -PORT=9000 node claude-proxy.js -# Update plugin Base URL to: http://your-ip:9000 -``` - -## Security Notes - -- Proxy binds to `0.0.0.0` (all network interfaces) for LAN access -- No authentication by design (LAN trust model) -- All request prompts are logged to `proxy.log` -- For internet exposure, use ngrok/reverse proxy with authentication - -## Environment Variables - -```bash -# Use different port (default: 8080) -PORT=9000 ./start-proxy.sh - -# Production mode -NODE_ENV=production - -# Brave Search API (for web search capability) -export BRAVE_SEARCH_API_KEY="your-brave-api-key" -``` - -### Enabling Web Search (Brave Search) - -To enable web search in your AI responses: - -1. **Get a Brave Search API key** from [https://brave.com/search/api/](https://brave.com/search/api/) - -2. **Configure it in one of these ways:** - -**Option 1: Add to `.env` file (recommended for this proxy)** -```bash -echo 'BRAVE_SEARCH_API_KEY="BSA03Yj-your-key-here"' > .env -``` - -**Option 2: Add to Claude Code settings** -Add to `~/.claude/settings.json`: -```json -{ - "env": { - "BRAVE_SEARCH_API_KEY": "your-key-here" - } -} -``` - -**Option 3: Add to shell profile** -```bash -export BRAVE_SEARCH_API_KEY="your-key-here" -``` - -3. **Restart the proxy**: -```bash -./stop-proxy.sh && ./start-proxy.sh -``` - -When the proxy starts, you should see: -``` -Brave Search: - API Key: CONFIGURED -``` - -**Note:** Web search must also be enabled in the WordPress plugin settings (Agentic Writer → Settings → General → Search → Enable). The plugin will automatically use search results when planning or researching topics. - -## Support - -- **Documentation**: [Plugin Docs](https://github.com/your/plugin) -- **Issues**: [GitHub Issues](https://github.com/your/plugin/issues) -- **Community**: [Discord](https://discord.gg/your-server) - -## License - -GPL-2.0+ - Same as WP Agentic Writer plugin diff --git a/downloads/TROUBLESHOOTING.md b/downloads/TROUBLESHOOTING.md deleted file mode 100644 index 41e822d..0000000 --- a/downloads/TROUBLESHOOTING.md +++ /dev/null @@ -1,339 +0,0 @@ -# Troubleshooting Guide - -Common issues and solutions for Agentic Writer Local Backend. - -## Connection Issues - -### "Connection timeout" in Plugin - -**Symptoms:** -- Plugin shows "Connection timeout" error -- Test connection fails - -**Solutions:** - -1. **Check proxy is running:** - ```bash - ps aux | grep claude-proxy - ``` - -2. **Restart proxy:** - ```bash - ./stop-proxy.sh - ./start-proxy.sh - ``` - -3. **Check logs:** - ```bash - tail -f proxy.log - ``` - -4. **Verify IP address:** - ```bash - ./get-local-ip.sh - ``` - -### "Connection refused" - -**Cause:** Proxy not running or wrong IP - -**Solutions:** - -1. **Start proxy:** - ```bash - ./start-proxy.sh - ``` - -2. **Check firewall:** - - macOS: System Settings → Network → Firewall → Allow Node.js - - Linux: `sudo ufw allow 8080/tcp` - - Windows: Defender Firewall → Allow port 8080 - -3. **Test locally first:** - ```bash - curl http://localhost:8080/ping - # Should return: pong - ``` - -## Claude CLI Issues - -### "Claude CLI not found" - -**Verify installation:** -```bash -which claude -# macOS: /opt/homebrew/bin/claude or /usr/local/bin/claude -# Linux: ~/.local/bin/claude or /usr/bin/claude -``` - -**Fix PATH:** -```bash -# Add to ~/.zshrc or ~/.bashrc -export PATH="/opt/homebrew/bin:$PATH" -source ~/.zshrc -``` - -**Reinstall Claude CLI:** -- Visit: [https://claude.ai/code](https://claude.ai/code) -- Follow installation instructions - -### "No response from Claude" - -**Test Claude manually:** -```bash -echo "Hello, reply with: Test successful" | claude -``` - -**Check authentication:** -```bash -claude --version -# Should show version and auth status -``` - -**Reconfigure Claude:** -- Check Z.ai account: [https://z.ai](https://z.ai) -- Or Anthropic API key setup - -## Network Issues - -### Wrong IP Address Detected - -**Find correct IP:** -```bash -# macOS -ipconfig getifaddr en0 # WiFi -ipconfig getifaddr en1 # Ethernet - -# Linux -ip route get 1 | awk '{print $7}' -hostname -I - -# Windows -ipconfig -# Look for "IPv4 Address" under active adapter -``` - -**Update plugin settings:** -- Use the correct IP in Base URL: `http://CORRECT-IP:8080` - -### Port 8080 Already in Use - -**Find what's using it:** -```bash -lsof -i :8080 -# or -netstat -anp | grep 8080 -``` - -**Change port:** - -1. Edit `claude-proxy.js`: - ```javascript - const PORT = process.env.PORT || 9000; // Change 8080 to 9000 - ``` - -2. Restart proxy: - ```bash - ./stop-proxy.sh - PORT=9000 ./start-proxy.sh - ``` - -3. Update plugin Base URL: `http://your-ip:9000` - -## Performance Issues - -### Slow Response Times - -**Normal latency:** -- Local network: 50-200ms -- Claude CLI processing: 2-30 seconds depending on prompt - -**If consistently slow:** - -1. **Check network:** - ```bash - ping 192.168.1.105 # Your proxy IP - ``` - -2. **Monitor logs:** - ```bash - tail -f proxy.log - ``` - -3. **Check machine resources:** - - CPU usage: Claude CLI is CPU-intensive - - Memory: Ensure sufficient RAM available - -### Proxy Crashes - -**Check logs:** -```bash -cat proxy.log | tail -50 -``` - -**Common causes:** -- Out of memory: Close other applications -- Claude CLI timeout: Increase timeout in `claude-proxy.js` -- Malformed requests: Check plugin version compatibility - -**Restart with clean state:** -```bash -./stop-proxy.sh -rm proxy.log -./start-proxy.sh -``` - -## Plugin Integration Issues - -### "Invalid response format" - -**Cause:** Claude response doesn't match expected JSON format - -**Debug:** -1. Check `proxy.log` for actual Claude output -2. Test manually: - ```bash - curl -X POST http://localhost:8080/v1/messages \ - -H "Content-Type: application/json" \ - -d '{"messages":[{"role":"user","content":"Hello"}]}' - ``` - -3. Update Claude CLI if outdated: - ```bash - claude --version - # Upgrade if needed - ``` - -### Cost Tracking Shows $0 - -**Expected behavior:** Local backend is free, plugin should show `$0.00 (Local)` - -**If concerned:** -- This is correct - local backend has no API costs -- Dashboard should show "X requests local (free)" - -## Advanced Troubleshooting - -### Enable Debug Logging - -Edit `claude-proxy.js`: -```javascript -const DEBUG = true; // Add at top of file - -// In /v1/messages handler: -if (DEBUG) { - console.log('Full request:', JSON.stringify(req.body, null, 2)); - console.log('Full response:', output); -} -``` - -### Test with curl - -**Ping:** -```bash -curl http://localhost:8080/ping -# Expected: pong -``` - -**Inference:** -```bash -curl -X POST http://localhost:8080/v1/messages \ - -H "Content-Type: application/json" \ - -d '{ - "messages": [ - {"role": "user", "content": "Reply with: Test successful"} - ] - }' -``` - -**Expected response:** -```json -{ - "id": "local-1234567890", - "object": "chat.completion", - "model": "claude-local", - "choices": [{ - "message": { - "content": "Test successful" - } - }] -} -``` - -### Permissions Issues (macOS) - -**Make scripts executable:** -```bash -chmod +x start-proxy.sh stop-proxy.sh test-connection.sh get-local-ip.sh -``` - -**If "permission denied":** -```bash -# Check file permissions -ls -la *.sh - -# Reset if needed -chmod 755 *.sh -``` - -## Still Having Issues? - -1. **Check system requirements:** - - Node.js 18+: `node --version` - - Claude CLI installed: `which claude` - - Sufficient disk space: `df -h` - -2. **Collect diagnostic info:** - ```bash - echo "Node version:" $(node --version) - echo "Claude path:" $(which claude) - echo "Local IP:" $(./get-local-ip.sh) - echo "Proxy status:" $(ps aux | grep claude-proxy) - tail -20 proxy.log - ``` - -3. **Reset everything:** - ```bash - ./stop-proxy.sh - rm -rf node_modules proxy.log proxy.pid - npm install - ./start-proxy.sh - ``` - -4. **Get help:** - - GitHub Issues: [Report Bug](https://github.com/your/plugin/issues) - - Discord Community: [Join Chat](https://discord.gg/your-server) - - Include: OS, Node version, Claude CLI version, error logs - -## Environment-Specific Notes - -### macOS - -- Default Claude path: `/opt/homebrew/bin/claude` -- Firewall: System Settings → Network → Firewall -- IP detection: `ipconfig getifaddr en0` - -### Linux - -- Default Claude path: `~/.local/bin/claude` -- Firewall: `sudo ufw allow 8080/tcp` -- IP detection: `ip route get 1 | awk '{print $7}'` - -### Windows - -- Claude path varies, check `where claude` -- Firewall: Windows Defender → Allow port 8080 -- IP detection: `ipconfig` (look for IPv4) -- Scripts: Use Git Bash or WSL to run `.sh` scripts - -## Security Best Practices - -1. **LAN only:** Don't expose proxy to internet without authentication -2. **Firewall:** Restrict to specific IPs if on shared network -3. **Logs:** `proxy.log` contains all prompts - review periodically -4. **Updates:** Keep Node.js and Claude CLI updated - ---- - -**Last Updated:** 2025-02-27 -**Version:** 1.0.0 diff --git a/downloads/agentic-writer-local-backend.zip b/downloads/agentic-writer-local-backend.zip deleted file mode 100644 index 2a62caa..0000000 Binary files a/downloads/agentic-writer-local-backend.zip and /dev/null differ diff --git a/downloads/claude-proxy.js b/downloads/claude-proxy.js deleted file mode 100644 index 965261a..0000000 --- a/downloads/claude-proxy.js +++ /dev/null @@ -1,279 +0,0 @@ -const express = require('express'); -const { spawn } = require('child_process'); -const https = require('https'); -const http = require('http'); -const fs = require('fs'); -const path = require('path'); - -const app = express(); -app.use(express.json()); - -// Try multiple sources for Brave API Key (in order of priority): -// 1. Environment variable -// 2. .env file in proxy directory -// 3. ~/.claude/settings.json (Claude Code config) -function getBraveApiKey() { - // 1. Check environment variable first - if (process.env.BRAVE_SEARCH_API_KEY) { - return process.env.BRAVE_SEARCH_API_KEY; - } - - // 2. Check .env file in proxy directory - const envPath = path.join(__dirname, '.env'); - if (fs.existsSync(envPath)) { - const envContent = fs.readFileSync(envPath, 'utf8'); - const match = envContent.match(/BRAVE_SEARCH_API_KEY\s*=\s*(.+)/m); - if (match) { - return match[1].trim(); - } - } - - // 3. Check Claude Code settings.json - const claudeSettingsPath = path.join(process.env.HOME || '/root', '.claude', 'settings.json'); - if (fs.existsSync(claudeSettingsPath)) { - try { - const settings = JSON.parse(fs.readFileSync(claudeSettingsPath, 'utf8')); - if (settings.env?.BRAVE_SEARCH_API_KEY) { - return settings.env.BRAVE_SEARCH_API_KEY; - } - } catch (e) { - // Ignore JSON parse errors - } - } - - return ''; -} - -// Health check endpoint -app.get('/ping', (req, res) => { - const status = { - status: 'pong', - braveSearchConfigured: !!BRAVE_API_KEY, - timestamp: new Date().toISOString() - }; - res.json(status); -}); - -// Main inference endpoint (OpenAI-compatible format) -app.post('/v1/messages', async (req, res) => { - const { messages, stream } = req.body; - - if (!messages || !Array.isArray(messages) || messages.length === 0) { - return res.status(400).json({ - error: { - message: 'Invalid request: messages array required' - } - }); - } - - // Check if web search is requested (via X-Search-Enabled header) - const webSearchEnabled = req.headers['x-search-enabled'] === 'true'; - const searchQuery = req.headers['x-search-query'] || ''; - - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log('Request from:', req.ip); - console.log('Web Search:', webSearchEnabled ? 'ENABLED' : 'disabled'); - if (searchQuery) { - console.log('Search Query:', searchQuery.substring(0, 100) + '...'); - } - const braveApiKey = getBraveApiKey(); - console.log('Brave API Key:', braveApiKey ? 'CONFIGURED' : 'NOT SET'); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - - // If web search is enabled and we have a query, fetch search results first - let searchContext = ''; - if (webSearchEnabled && searchQuery && braveApiKey) { - console.log('Fetching web search results...'); - try { - searchContext = await fetchBraveSearchResults(searchQuery, braveApiKey); - console.log('Search results fetched:', searchContext.length, 'chars'); - } catch (err) { - console.error('Search error:', err.message); - } - } - - // Build conversation context from messages array - // Include previous messages for context continuity - let conversationPrompt = ''; - for (const msg of messages) { - const role = msg.role === 'assistant' ? 'Assistant' : 'User'; - conversationPrompt += `${role}: ${msg.content}\n\n`; - } - - let prompt = conversationPrompt.trim(); - - // Prepend search context if available - if (searchContext) { - prompt = `WEB SEARCH RESULTS:\n${searchContext}\n\n---\n\nUSER QUERY:\n${prompt}\n\nPlease answer based on the search results above when relevant.`; - } - - console.log('Prompt length:', prompt.length, 'chars'); - console.log('Prompt preview:', prompt.substring(0, 150) + '...'); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - - // Spawn Claude CLI process - const claude = spawn('claude', [], { - stdio: ['pipe', 'pipe', 'pipe'] - }); - - let output = ''; - let errorOutput = ''; - - claude.stdout.on('data', (data) => { - output += data.toString(); - process.stdout.write('.'); - }); - - claude.stderr.on('data', (data) => { - errorOutput += data.toString(); - console.error('Claude stderr:', data.toString()); - }); - - claude.on('close', (code) => { - console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log('Claude exit code:', code); - console.log('Response length:', output.length, 'chars'); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); - - if (code !== 0 || !output.trim()) { - return res.status(500).json({ - error: { - message: 'Claude CLI error', - details: errorOutput || 'No response from Claude' - } - }); - } - - // Return OpenAI-compatible response format - res.json({ - id: 'local-' + Date.now(), - object: 'chat.completion', - created: Math.floor(Date.now() / 1000), - model: 'claude-local', - choices: [{ - index: 0, - message: { - role: 'assistant', - content: output.trim() - }, - finish_reason: 'stop' - }], - usage: { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0 - } - }); - }); - - claude.on('error', (err) => { - console.error('Failed to spawn Claude CLI:', err); - res.status(500).json({ - error: { - message: 'Failed to spawn Claude CLI', - details: err.message - } - }); - }); - - // Send prompt to Claude after brief pause - setTimeout(() => { - claude.stdin.write(prompt + '\n'); - claude.stdin.end(); - }, 100); -}); - -/** - * Fetch search results from Brave Search API - */ -async function fetchBraveSearchResults(query, apiKey, count = 5) { - return new Promise((resolve, reject) => { - const encodedQuery = encodeURIComponent(query); - const url = `https://api.search.brave.com/res/v1/web/search?q=${encodedQuery}&count=${count}`; - - const options = { - headers: { - 'Accept': 'application/json', - 'X-Subscription-Token': apiKey - } - }; - - const protocol = url.startsWith('https') ? https : http; - const request = protocol.get(url, options, (response) => { - let data = ''; - - response.on('data', (chunk) => { - data += chunk; - }); - - response.on('end', () => { - if (response.statusCode !== 200) { - return reject(new Error(`Brave API error: ${response.statusCode}`)); - } - - try { - const json = JSON.parse(data); - const results = json.web?.results || []; - - if (results.length === 0) { - return resolve('No search results found.'); - } - - // Format results for LLM consumption - let formatted = 'Search Results:\n\n'; - - results.forEach((result, i) => { - formatted += `${i + 1}. **${result.title}**\n`; - formatted += ` URL: ${result.url}\n`; - if (result.description) { - formatted += ` Summary: ${result.description}\n`; - } - formatted += '\n'; - }); - - resolve(formatted); - } catch (err) { - reject(new Error('Failed to parse Brave response')); - } - }); - }); - - request.on('error', (err) => { - reject(err); - }); - - request.setTimeout(10000, () => { - request.destroy(); - reject(new Error('Brave search timeout')); - }); - }); -} - -const PORT = process.env.PORT || 8080; -app.listen(PORT, '0.0.0.0', () => { - const braveApiKey = getBraveApiKey(); - console.log('═══════════════════════════════════════════════════'); - console.log('🚀 Agentic Writer Local Backend v1.1.0'); - console.log('═══════════════════════════════════════════════════'); - console.log(`Local: http://localhost:${PORT}`); - console.log(`Network: http://YOUR-IP:${PORT}`); - console.log(''); - console.log('Plugin Configuration:'); - console.log(` Base URL: http://YOUR-IP:${PORT}`); - console.log(` API Key: dummy`); - console.log(` Model: claude-local`); - console.log(''); - console.log('Brave Search:'); - console.log(` API Key: ${braveApiKey ? 'CONFIGURED' : 'NOT SET'}`); - console.log(''); - console.log('Web search works when Brave API key is found from:'); - console.log(' 1. Environment: export BRAVE_SEARCH_API_KEY="key"'); - console.log(' 2. .env file: BRAVE_SEARCH_API_KEY=key'); - console.log(' 3. ~/.claude/settings.json env.BRAVE_SEARCH_API_KEY'); - console.log(''); - console.log('Restart proxy after adding key: ./stop-proxy.sh && ./start-proxy.sh'); - console.log(''); - console.log('Health check: GET /ping'); - console.log('Inference: POST /v1/messages'); - console.log('═══════════════════════════════════════════════════'); -}); \ No newline at end of file diff --git a/downloads/get-local-ip.sh b/downloads/get-local-ip.sh deleted file mode 100755 index 62b3c08..0000000 --- a/downloads/get-local-ip.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash - -echo "Detecting your local IP address..." -echo "" - -# Detect local IP based on OS -if [[ "$OSTYPE" == "darwin"* ]]; then - # macOS - try en0 (WiFi) then en1 (Ethernet) - IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "") - INTERFACE=$(ifconfig en0 &>/dev/null && echo "en0 (WiFi)" || echo "en1 (Ethernet)") -elif [[ "$OSTYPE" == "linux-gnu"* ]]; then - # Linux - IP=$(ip route get 1 | awk '{print $7;exit}' 2>/dev/null || hostname -I | awk '{print $1}') - INTERFACE="default" -else - # Windows or unknown - IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "") - INTERFACE="unknown" -fi - -if [ -z "$IP" ]; then - echo "❌ Could not detect IP address automatically" - echo "" - echo "Manual detection:" - echo " macOS: ipconfig getifaddr en0" - echo " Linux: ip route get 1 | awk '{print \$7}'" - echo " Windows: ipconfig (look for IPv4 Address)" - exit 1 -fi - -echo "✅ Your local IP: $IP ($INTERFACE)" -echo "" -echo "Use this in your plugin settings:" -echo " Base URL: http://$IP:8080" diff --git a/downloads/package-lock.json b/downloads/package-lock.json deleted file mode 100644 index 2670744..0000000 --- a/downloads/package-lock.json +++ /dev/null @@ -1,828 +0,0 @@ -{ - "name": "agentic-writer-local-backend", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "agentic-writer-local-backend", - "version": "1.0.0", - "license": "GPL-2.0+", - "dependencies": { - "express": "^4.18.2" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.15.1", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - } - } -} diff --git a/downloads/package.json b/downloads/package.json deleted file mode 100644 index af80dad..0000000 --- a/downloads/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "agentic-writer-local-backend", - "version": "1.0.0", - "description": "Local backend proxy for WP Agentic Writer using Claude CLI", - "main": "claude-proxy.js", - "scripts": { - "start": "node claude-proxy.js", - "test": "curl http://localhost:8080/ping" - }, - "keywords": [ - "wordpress", - "ai", - "claude", - "proxy" - ], - "author": "WP Agentic Writer", - "license": "GPL-2.0+", - "dependencies": { - "express": "^4.18.2" - } -} diff --git a/downloads/start-proxy.sh b/downloads/start-proxy.sh deleted file mode 100755 index cb56ca5..0000000 --- a/downloads/start-proxy.sh +++ /dev/null @@ -1,92 +0,0 @@ -#!/bin/bash - -echo "🚀 Starting Agentic Writer Local Backend..." -echo "" - -# Check dependencies -if ! command -v node &> /dev/null; then - echo "❌ Node.js not found. Install from https://nodejs.org/" - exit 1 -fi - -if ! command -v claude &> /dev/null; then - echo "❌ Claude CLI not found. Install and configure first." - echo " Check: https://claude.ai/code or https://z.ai" - exit 1 -fi - -# Auto-install express if needed -if [ ! -d "node_modules" ]; then - echo "📦 Installing dependencies..." - npm install -fi - -# Load environment variables from .env file if it exists -if [ -f .env ]; then - echo "📋 Loading environment from .env file..." - set -a # automatically export all variables created - source .env - set +a # stop auto-export -fi - -# Detect local IP -if [[ "$OSTYPE" == "darwin"* ]]; then - # macOS - LOCAL_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "127.0.0.1") -elif [[ "$OSTYPE" == "linux-gnu"* ]]; then - # Linux - LOCAL_IP=$(ip route get 1 | awk '{print $7;exit}' 2>/dev/null || echo "127.0.0.1") -else - # Windows/other - LOCAL_IP="127.0.0.1" -fi - -echo "✅ Dependencies OK" -echo "✅ Claude CLI found: $(which claude)" -echo "" -echo "Starting proxy server..." -echo "" - -# Start server in background -nohup node claude-proxy.js > proxy.log 2>&1 & -PID=$! -echo $PID > proxy.pid - -# Wait for server to boot -sleep 2 - -# Test if running -if kill -0 $PID 2>/dev/null; then - echo "═══════════════════════════════════════════════════" - echo "✅ Local Backend Running!" - echo "═══════════════════════════════════════════════════" - echo "" - echo "Your Configuration:" - echo " Base URL: http://$LOCAL_IP:8080" - echo " API Key: dummy" - echo " Model: claude-local" - echo "" - if [ -n "$BRAVE_SEARCH_API_KEY" ]; then - echo "Brave Search: ✅ CONFIGURED" - else - echo "Brave Search: ⚠️ NOT SET (web search disabled)" - echo " To enable: Add BRAVE_SEARCH_API_KEY to .env file" - fi - echo "" - echo "Next Steps:" - echo " 1. Open your WordPress Admin" - echo " 2. Go to Agentic Writer → Settings → Local Backend" - echo " 3. Paste the Base URL above" - echo " 4. Click 'Test Connection'" - echo "" - echo "Commands:" - echo " Logs: tail -f proxy.log" - echo " Stop: ./stop-proxy.sh" - echo " Test: ./test-connection.sh" - echo "═══════════════════════════════════════════════════" -else - echo "❌ Failed to start. Check proxy.log for errors." - cat proxy.log - rm -f proxy.pid - exit 1 -fi diff --git a/downloads/stop-proxy.sh b/downloads/stop-proxy.sh deleted file mode 100755 index 2327056..0000000 --- a/downloads/stop-proxy.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash - -if [ -f proxy.pid ]; then - PID=$(cat proxy.pid) - if kill -0 $PID 2>/dev/null; then - kill $PID - rm proxy.pid - echo "🛑 Local Backend stopped (PID: $PID)" - else - echo "⚠️ No process found with PID: $PID" - rm proxy.pid - fi -else - # Fallback: kill by process name - pkill -f claude-proxy.js - if [ $? -eq 0 ]; then - echo "🛑 Stopped all claude-proxy processes" - else - echo "ℹ️ No claude-proxy processes running" - fi -fi diff --git a/downloads/test-connection.sh b/downloads/test-connection.sh deleted file mode 100755 index cf51782..0000000 --- a/downloads/test-connection.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/bash - -echo "Testing local backend connection..." -echo "" - -# Test /ping endpoint -echo "1. Testing health check..." -PING_RESPONSE=$(curl -s http://localhost:8080/ping 2>&1) - -if [ "$PING_RESPONSE" = "pong" ]; then - echo " ✅ Health check passed" -else - echo " ❌ Health check failed" - echo " Response: $PING_RESPONSE" - echo "" - echo "Is the proxy running? Check: ps aux | grep claude-proxy" - exit 1 -fi - -# Test /v1/messages endpoint -echo "2. Testing inference..." -RESPONSE=$(curl -s -X POST http://localhost:8080/v1/messages \ - -H "Content-Type: application/json" \ - -d '{"messages":[{"role":"user","content":"Reply with exactly: Test successful"}]}' 2>&1) - -echo " Response: $RESPONSE" - -if echo "$RESPONSE" | grep -q "choices"; then - echo " ✅ Inference endpoint working" - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "✅ Local Backend is working correctly!" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -else - echo " ❌ Inference test failed" - echo "" - echo "Troubleshooting:" - echo " 1. Check Claude CLI: echo 'test' | claude" - echo " 2. Check logs: tail -f proxy.log" - echo " 3. Restart proxy: ./stop-proxy.sh && ./start-proxy.sh" - exit 1 -fi diff --git a/includes/class-context-builder.php b/includes/class-context-builder.php new file mode 100644 index 0000000..9e5ada9 --- /dev/null +++ b/includes/class-context-builder.php @@ -0,0 +1,530 @@ +get_context( $session_id, $post_id ) + : array(); + + $session_context = $saved_context['context'] ?? array(); + $messages = $saved_context['messages'] ?? array(); + + if ( empty( $messages ) ) { + $messages = $this->get_request_messages( $request_params ); + } + + $token_policy = $this->get_token_policy( $session_context ); + $recent_messages = $this->prepare_recent_messages( $messages, $token_policy['max_recent_messages'] ); + $recent_messages = $this->remove_active_user_message( $recent_messages, $request_params['latestUserMessage'] ?? '' ); + $post_config = $this->resolve_post_config( $saved_context, $request_params ); + $plan = $this->resolve_plan( $saved_context, $request_params, $post_id ); + + $working_context = $this->build_working_context( + $task, + $session_context, + $recent_messages, + $plan, + $post_config, + $request_params + ); + + return array( + 'system_context' => '', + 'working_context' => $working_context, + 'active_content' => $this->get_active_content( $request_params ), + 'research_context' => $this->build_research_context( $session_context, $request_params, $token_policy['max_research_snippets'] ), + 'audit' => array( + 'included_recent_messages' => count( $recent_messages ), + 'included_research_items' => $this->count_research_items( $session_context, $request_params, $token_policy['max_research_snippets'] ), + 'estimated_input_tokens' => $this->estimate_tokens( $working_context ), + 'used_full_history' => false, + ), + ); + } + + /** + * Build a system message that can be inserted after the primary system prompt. + * + * @param string $task Task name. + * @param string $session_id Session ID. + * @param int $post_id Post ID. + * @param array $request_params Request params. + * @return array Context system message and audit metadata. + */ + public function build_system_message( $task, $session_id, $post_id, $request_params = array() ) { + $package = $this->build_for_task( $task, $session_id, $post_id, $request_params ); + $content = trim( + $package['working_context'] + . "\n" + . $package['active_content'] + . "\n" + . $package['research_context'] + ); + + if ( '' === $content ) { + return array( + 'message' => null, + 'audit' => $package['audit'], + ); + } + + return array( + 'message' => array( + 'role' => 'system', + 'content' => $content, + ), + 'audit' => $package['audit'], + ); + } + + /** + * Get task token policy. + * + * @param array $session_context Session context. + * @return array Token policy. + */ + private function get_token_policy( $session_context ) { + $policy = isset( $session_context['token_policy'] ) && is_array( $session_context['token_policy'] ) + ? $session_context['token_policy'] + : array(); + + return array( + 'max_recent_messages' => max( 2, (int) ( $policy['max_recent_messages'] ?? 6 ) ), + 'max_summary_tokens' => max( 200, (int) ( $policy['max_summary_tokens'] ?? 600 ) ), + 'max_research_snippets' => max( 0, (int) ( $policy['max_research_snippets'] ?? 5 ) ), + ); + } + + /** + * Get messages from request fallback. + * + * @param array $request_params Request params. + * @return array Messages. + */ + private function get_request_messages( $request_params ) { + if ( ! empty( $request_params['messages'] ) && is_array( $request_params['messages'] ) ) { + return $request_params['messages']; + } + + if ( ! empty( $request_params['chatHistory'] ) && is_array( $request_params['chatHistory'] ) ) { + return $request_params['chatHistory']; + } + + return array(); + } + + /** + * Prepare compact recent messages. + * + * @param array $messages Messages. + * @param int $max_messages Max messages. + * @return array Recent messages. + */ + private function prepare_recent_messages( $messages, $max_messages ) { + $prepared = array(); + + foreach ( (array) $messages as $message ) { + $role = isset( $message['role'] ) ? (string) $message['role'] : ''; + if ( ! in_array( $role, array( 'user', 'assistant' ), true ) ) { + continue; + } + + $content = isset( $message['content'] ) ? trim( wp_strip_all_tags( (string) $message['content'] ) ) : ''; + if ( '' === $content ) { + continue; + } + + $prepared[] = array( + 'role' => $role, + 'content' => $this->truncate_text( $content, 900 ), + ); + } + + if ( count( $prepared ) > $max_messages ) { + $prepared = array_slice( $prepared, -1 * $max_messages ); + } + + return $prepared; + } + + /** + * Avoid echoing the active user turn inside the saved-context excerpt. + * + * @param array $messages Recent messages. + * @param string $active_user_message Active user message. + * @return array Messages without duplicate active turn. + */ + private function remove_active_user_message( $messages, $active_user_message ) { + $active_user_message = trim( wp_strip_all_tags( (string) $active_user_message ) ); + if ( '' === $active_user_message || empty( $messages ) ) { + return $messages; + } + + for ( $i = count( $messages ) - 1; $i >= 0; $i-- ) { + if ( 'user' !== ( $messages[ $i ]['role'] ?? '' ) ) { + continue; + } + + $content = trim( (string) ( $messages[ $i ]['content'] ?? '' ) ); + if ( $content === $active_user_message ) { + array_splice( $messages, $i, 1 ); + } + break; + } + + return $messages; + } + + /** + * Resolve post config. + * + * @param array $saved_context Saved context package. + * @param array $request_params Request params. + * @return array Post config. + */ + private function resolve_post_config( $saved_context, $request_params ) { + $config = $saved_context['post_config'] ?? array(); + + if ( ! empty( $request_params['postConfig'] ) && is_array( $request_params['postConfig'] ) ) { + $config = wp_parse_args( $request_params['postConfig'], $config ); + } + + return is_array( $config ) ? $config : array(); + } + + /** + * Resolve current plan. + * + * @param array $saved_context Saved context package. + * @param array $request_params Request params. + * @param int $post_id Post ID. + * @return array|null Plan. + */ + private function resolve_plan( $saved_context, $request_params, $post_id ) { + if ( ! empty( $saved_context['plan'] ) && is_array( $saved_context['plan'] ) ) { + return $saved_context['plan']; + } + + if ( ! empty( $request_params['plan'] ) && is_array( $request_params['plan'] ) ) { + return $request_params['plan']; + } + + if ( $post_id > 0 ) { + $plan = get_post_meta( $post_id, '_wpaw_plan', true ); + if ( is_array( $plan ) ) { + return $plan; + } + } + + return null; + } + + /** + * Build compact working context. + * + * @param string $task Task name. + * @param array $session_context Session context. + * @param array $recent_messages Recent messages. + * @param array $plan Current plan. + * @param array $post_config Post config. + * @param array $request_params Request params. + * @return string Context text. + */ + private function build_working_context( $task, $session_context, $recent_messages, $plan, $post_config, $request_params ) { + $sections = array(); + $sections[] = "BACKEND CONTINUITY CONTEXT\nUse this compact WordPress-saved context as continuity. Do not assume OpenRouter remembers prior turns."; + $sections[] = 'Current task: ' . sanitize_key( $task ); + + $summary = $session_context['working_summary']['text'] ?? ''; + if ( '' !== trim( (string) $summary ) ) { + $sections[] = "Working summary:\n" . $this->truncate_text( (string) $summary, 1600 ); + } + + $config_summary = $this->summarize_post_config( $post_config ); + if ( '' !== $config_summary ) { + $sections[] = "Article configuration:\n" . $config_summary; + } + + $plan_summary = $this->summarize_plan( $plan ); + if ( '' !== $plan_summary ) { + $sections[] = "Current plan:\n" . $plan_summary; + } + + $decision_summary = $this->summarize_context_items( $session_context, 'decisions', 'Decisions' ); + if ( '' !== $decision_summary ) { + $sections[] = $decision_summary; + } + + $rejection_summary = $this->summarize_context_items( $session_context, 'rejections', 'Rejected directions' ); + if ( '' !== $rejection_summary ) { + $sections[] = $rejection_summary; + } + + if ( ! empty( $request_params['context'] ) ) { + $sections[] = "User supplied context:\n" . $this->truncate_text( (string) $request_params['context'], 1600 ); + } + + if ( ! empty( $recent_messages ) ) { + $lines = array(); + foreach ( $recent_messages as $message ) { + $lines[] = ucfirst( $message['role'] ) . ': ' . $message['content']; + } + $sections[] = "Recent saved conversation excerpts:\n" . implode( "\n", $lines ); + } + + return implode( "\n\n", array_filter( $sections ) ); + } + + /** + * Summarize post config. + * + * @param array $post_config Post config. + * @return string Summary. + */ + private function summarize_post_config( $post_config ) { + $lines = array(); + $keys = array( + 'article_length' => 'Article length', + 'language' => 'Language', + 'tone' => 'Tone', + 'audience' => 'Audience', + 'experience_level' => 'Experience level', + 'seo_focus_keyword' => 'SEO focus keyword', + 'seo_secondary_keywords' => 'SEO secondary keywords', + ); + + foreach ( $keys as $key => $label ) { + if ( isset( $post_config[ $key ] ) && '' !== trim( (string) $post_config[ $key ] ) ) { + $lines[] = '- ' . $label . ': ' . trim( (string) $post_config[ $key ] ); + } + } + + if ( isset( $post_config['include_images'] ) ) { + $lines[] = '- Include images: ' . ( $post_config['include_images'] ? 'yes' : 'no' ); + } + + if ( isset( $post_config['web_search'] ) ) { + $lines[] = '- Web search: ' . ( $post_config['web_search'] ? 'yes' : 'no' ); + } + + return implode( "\n", $lines ); + } + + /** + * Summarize current plan. + * + * @param array|null $plan Plan. + * @return string Summary. + */ + private function summarize_plan( $plan ) { + if ( empty( $plan ) || ! is_array( $plan ) ) { + return ''; + } + + $lines = array(); + if ( ! empty( $plan['title'] ) ) { + $lines[] = 'Title: ' . $plan['title']; + } + + if ( ! empty( $plan['sections'] ) && is_array( $plan['sections'] ) ) { + foreach ( $plan['sections'] as $index => $section ) { + $heading = $section['heading'] ?? $section['title'] ?? ''; + if ( '' === trim( (string) $heading ) ) { + continue; + } + $status = $section['status'] ?? 'pending'; + $lines[] = sprintf( '%d. [%s] %s', $index + 1, $status, $heading ); + } + } + + return implode( "\n", $lines ); + } + + /** + * Summarize context array items. + * + * @param array $session_context Session context. + * @param string $key Context key. + * @param string $label Label. + * @return string Summary. + */ + private function summarize_context_items( $session_context, $key, $label ) { + if ( empty( $session_context[ $key ] ) || ! is_array( $session_context[ $key ] ) ) { + return ''; + } + + $lines = array(); + foreach ( array_slice( $session_context[ $key ], -8 ) as $item ) { + $summary = $item['summary'] ?? ''; + if ( '' === trim( (string) $summary ) ) { + continue; + } + $target = ! empty( $item['target'] ) ? '[' . $item['target'] . '] ' : ''; + $lines[] = '- ' . $target . $summary; + } + + return empty( $lines ) ? '' : $label . ":\n" . implode( "\n", $lines ); + } + + /** + * Get active content from request params. + * + * @param array $request_params Request params. + * @return string Active content context. + */ + private function get_active_content( $request_params ) { + $candidates = array( + 'activeContent', + 'blockContent', + 'selectedText', + 'sectionContent', + 'articleContent', + ); + + $lines = array(); + foreach ( $candidates as $key ) { + if ( ! empty( $request_params[ $key ] ) && is_string( $request_params[ $key ] ) ) { + $lines[] = $key . ":\n" . $this->truncate_text( $request_params[ $key ], 2200 ); + } + } + + return empty( $lines ) ? '' : "ACTIVE CONTENT SLICE\n" . implode( "\n\n", $lines ); + } + + /** + * Build research context. + * + * @param array $session_context Session context. + * @param array $request_params Request params. + * @param int $limit Max snippets. + * @return string Research context. + */ + private function build_research_context( $session_context, $request_params, $limit ) { + if ( $limit <= 0 ) { + return ''; + } + + $items = array(); + if ( ! empty( $session_context['research_notes'] ) && is_array( $session_context['research_notes'] ) ) { + $items = array_merge( $items, $session_context['research_notes'] ); + } + if ( ! empty( $request_params['researchNotes'] ) && is_array( $request_params['researchNotes'] ) ) { + $items = array_merge( $items, $request_params['researchNotes'] ); + } + + $items = array_slice( $items, -1 * $limit ); + $lines = array(); + foreach ( $items as $item ) { + if ( ! is_array( $item ) ) { + continue; + } + $title = $item['title'] ?? $item['source'] ?? 'Research note'; + $excerpt = $item['excerpt'] ?? $item['notes'] ?? ''; + if ( '' === trim( (string) $excerpt ) ) { + continue; + } + $lines[] = '- ' . $title . ': ' . $this->truncate_text( (string) $excerpt, 700 ); + } + + return empty( $lines ) ? '' : "RELEVANT RESEARCH\n" . implode( "\n", $lines ); + } + + /** + * Count included research items. + * + * @param array $session_context Session context. + * @param array $request_params Request params. + * @param int $limit Max snippets. + * @return int Count. + */ + private function count_research_items( $session_context, $request_params, $limit ) { + if ( $limit <= 0 ) { + return 0; + } + + $count = 0; + if ( ! empty( $session_context['research_notes'] ) && is_array( $session_context['research_notes'] ) ) { + $count += count( $session_context['research_notes'] ); + } + if ( ! empty( $request_params['researchNotes'] ) && is_array( $request_params['researchNotes'] ) ) { + $count += count( $request_params['researchNotes'] ); + } + + return min( $limit, $count ); + } + + /** + * Truncate text safely. + * + * @param string $text Text. + * @param int $limit Character limit. + * @return string Truncated text. + */ + private function truncate_text( $text, $limit ) { + $text = trim( (string) $text ); + if ( strlen( $text ) <= $limit ) { + return $text; + } + + return substr( $text, 0, $limit ) . '...'; + } + + /** + * Estimate tokens from character length. + * + * @param string $text Text. + * @return int Estimated tokens. + */ + private function estimate_tokens( $text ) { + return (int) ceil( strlen( (string) $text ) / 4 ); + } +} diff --git a/includes/class-context-service.php b/includes/class-context-service.php index 35dd311..6986fb2 100644 --- a/includes/class-context-service.php +++ b/includes/class-context-service.php @@ -177,6 +177,78 @@ class WP_Agentic_Writer_Context_Service { return $manager->update_messages( $session_id, $messages ); } + /** + * Get structured session context JSON. + * + * @since 0.2.3 + * @param string $session_id Session ID. + * @return array Session context. + */ + public function get_session_context( $session_id ) { + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + $session = $manager->get_session( $session_id ); + + if ( ! $session || empty( $session['context'] ) || ! is_array( $session['context'] ) ) { + return array(); + } + + return $session['context']; + } + + /** + * Merge a context patch into the stored session context. + * + * @since 0.2.3 + * @param string $session_id Session ID. + * @param array $patch Context fields to merge. + * @return bool Success. + */ + public function update_session_context( $session_id, $patch ) { + if ( empty( $session_id ) || ! is_array( $patch ) ) { + return false; + } + + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + $context = $this->get_session_context( $session_id ); + $context = $this->merge_context_recursive( $context, $patch ); + $context['updated_at'] = current_time( 'c' ); + + return $manager->update_context( $session_id, $context ); + } + + /** + * Append an item to an array inside session context. + * + * @since 0.2.3 + * @param string $session_id Session ID. + * @param string $key Context key. + * @param array $item Item to append. + * @param int $limit Maximum retained items. + * @return bool Success. + */ + public function append_session_context_item( $session_id, $key, $item, $limit = 25 ) { + if ( empty( $session_id ) || empty( $key ) || ! is_array( $item ) ) { + return false; + } + + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + $context = $this->get_session_context( $session_id ); + if ( empty( $context[ $key ] ) || ! is_array( $context[ $key ] ) ) { + $context[ $key ] = array(); + } + + $item['created_at'] = $item['created_at'] ?? current_time( 'c' ); + $context[ $key ][] = $item; + + if ( $limit > 0 && count( $context[ $key ] ) > $limit ) { + $context[ $key ] = array_slice( $context[ $key ], -1 * $limit ); + } + + $context['updated_at'] = current_time( 'c' ); + + return $manager->update_context( $session_id, $context ); + } + /** * Save plan to post meta. * @@ -254,6 +326,7 @@ class WP_Agentic_Writer_Context_Service { 'include_images' => true, 'web_search' => false, 'default_mode' => 'writing', + 'focus_keyword' => '', 'seo_focus_keyword' => '', 'seo_secondary_keywords' => '', 'seo_meta_description' => '', @@ -336,6 +409,26 @@ class WP_Agentic_Writer_Context_Service { return md5( $role . ':' . $content ); } + /** + * Merge context arrays while preserving nested JSON objects. + * + * @since 0.2.3 + * @param array $base Existing context. + * @param array $patch Context patch. + * @return array Merged context. + */ + private function merge_context_recursive( $base, $patch ) { + foreach ( $patch as $key => $value ) { + if ( is_array( $value ) && isset( $base[ $key ] ) && is_array( $base[ $key ] ) && ! wp_is_numeric_array( $value ) ) { + $base[ $key ] = $this->merge_context_recursive( $base[ $key ], $value ); + } else { + $base[ $key ] = $value; + } + } + + return $base; + } + /** * Clear context for a session and post. * @@ -422,4 +515,4 @@ class WP_Agentic_Writer_Context_Service { return array_merge( array( $context_summary ), $messages ); } -} \ No newline at end of file +} diff --git a/includes/class-conversation-manager.php b/includes/class-conversation-manager.php index 4b08076..d4b67ae 100644 --- a/includes/class-conversation-manager.php +++ b/includes/class-conversation-manager.php @@ -539,7 +539,7 @@ class WP_Agentic_Writer_Conversation_Manager { $sessions = $wpdb->get_results( $wpdb->prepare( - "SELECT * FROM {$this->table_name} WHERE post_id = %d ORDER BY updated_at DESC", + "SELECT *, JSON_LENGTH(messages) as message_count FROM {$this->table_name} WHERE post_id = %d ORDER BY updated_at DESC", $post_id ), ARRAY_A diff --git a/includes/class-gutenberg-sidebar.php b/includes/class-gutenberg-sidebar.php index 25d60a8..2abfa90 100644 --- a/includes/class-gutenberg-sidebar.php +++ b/includes/class-gutenberg-sidebar.php @@ -12,13 +12,13 @@ if ( ! defined( 'ABSPATH' ) ) { } /** - * Debug logging helper - only logs if WP_DEBUG is enabled. + * Debug logging helper - logs only when SCRIPT_DEBUG is enabled. * * @param string $message Log message. * @param mixed $data Optional data to log. */ function wpaw_debug_log( $message, $data = null ) { - if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) { $prefix = '[WPAW Debug] '; if ( null === $data ) { error_log( $prefix . $message ); @@ -86,10 +86,6 @@ class WP_Agentic_Writer_Gutenberg_Sidebar { $dompurify_url = WP_AGENTIC_WRITER_URL . 'assets/js/vendor/purify.min.js'; $markdown_task_lists_url = WP_AGENTIC_WRITER_URL . 'assets/js/vendor/markdown-it-task-lists.min.js'; - // Debug: Log the script URL (only when WP_DEBUG is on). - wpaw_debug_log( 'Script URL: ' . $script_url ); - wpaw_debug_log( 'File exists: ' . ( file_exists( WP_AGENTIC_WRITER_DIR . 'assets/js/sidebar.js' ) ? 'YES' : 'NO' ) ); - // Enqueue markdown renderer and sanitizer. wp_enqueue_script( 'wp-agentic-writer-markdown-it', @@ -663,6 +659,17 @@ class WP_Agentic_Writer_Gutenberg_Sidebar { ) ); + // Refine title endpoint (instruction-driven rewrite from chat mention @title). + register_rest_route( + 'wp-agentic-writer/v1', + '/refine-title', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'handle_refine_title' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + // Generate excerpt endpoint (uses WP 7.0 AI Client when available). register_rest_route( 'wp-agentic-writer/v1', @@ -777,7 +784,7 @@ class WP_Agentic_Writer_Gutenberg_Sidebar { 'wp-agentic-writer/v1', '/conversations/(?P[a-zA-Z0-9]+)/messages', array( - 'methods' => 'POST', + 'methods' => array( 'POST', 'PUT' ), 'callback' => array( $this, 'handle_update_conversation_messages' ), 'permission_callback' => array( $this, 'check_permissions' ), ) @@ -1004,15 +1011,34 @@ class WP_Agentic_Writer_Gutenberg_Sidebar { * @return array Provider metadata. */ private function build_provider_metadata( $provider_result, $model = '' ) { + $actual_provider = $provider_result->actual_provider ?? 'unknown'; + return array( - 'provider' => $provider_result->actual_provider ?? 'unknown', - 'selected_provider' => $provider_result->selected_provider ?? $provider_result->actual_provider ?? 'unknown', + 'provider' => $actual_provider, + 'selected_provider' => $provider_result->selected_provider ?? $actual_provider, 'fallback_used' => ! empty( $provider_result->fallback_used ), 'warnings' => $provider_result->warnings ?? array(), 'model' => $model, + 'byok_managed_by' => 'openrouter' === $actual_provider ? 'openrouter' : '', ); } + /** + * Get a provider model label without assuming provider-specific helpers exist. + * + * @since 0.2.2 + * @param object $provider Provider instance. + * @param string $fallback Fallback model label. + * @return string + */ + private function get_provider_execution_model( $provider, $fallback = 'execution' ) { + if ( is_object( $provider ) && method_exists( $provider, 'get_execution_model' ) ) { + return (string) $provider->get_execution_model(); + } + + return $fallback; + } + /** * Track AI cost with full metadata. * @@ -1116,7 +1142,34 @@ CRITICAL LANGUAGE REQUIREMENT: {$language_instruction} {$post_config_context}"; + $context_builder = WP_Agentic_Writer_Context_Builder::get_instance(); + $context_package = $context_builder->build_system_message( + 'chat', + $session_id, + $post_id, + array_merge( + $params, + array( + 'messages' => $messages, + 'postConfig' => $post_config, + 'latestUserMessage' => $last_user_message, + ) + ) + ); + + // OpenRouter is stateless; send only compact saved context plus the latest turn. + $messages = array(); + if ( '' !== trim( (string) $last_user_message ) ) { + $messages[] = array( + 'role' => 'user', + 'content' => $last_user_message, + ); + } + $messages = $this->prepend_system_prompt( $messages, $system_prompt ); + if ( ! empty( $context_package['message'] ) ) { + array_splice( $messages, 1, 0, array( $context_package['message'] ) ); + } // Get provider for this task type with selection metadata. $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( $type ); @@ -1159,6 +1212,7 @@ CRITICAL LANGUAGE REQUIREMENT: $response['fallback_used'] = $provider_result->fallback_used; $response['warnings'] = $provider_warnings; $response['session_id'] = $session_id; + $response['context_audit'] = $context_package['audit'] ?? array(); // Also include nested form for consistency with other AI endpoints $response['provider_metadata'] = $this->build_provider_metadata( $provider_result, $response['model'] ?? '' ); @@ -1224,6 +1278,17 @@ CRITICAL LANGUAGE REQUIREMENT: $provider = $provider_result->provider; $provider_warnings = $provider_result->warnings; + echo "data: " . wp_json_encode( + array( + 'type' => 'provider', + 'provider' => $provider_result->actual_provider, + 'selectedProvider' => $provider_result->selected_provider, + 'fallback_used' => $provider_result->fallback_used, + 'byok_managed_by' => 'openrouter' === $provider_result->actual_provider ? 'openrouter' : '', + ) + ) . "\n\n"; + flush(); + $this->maybe_inject_brave_search( $messages, $provider, $web_search_options ); $response = $provider->chat_stream( @@ -1575,6 +1640,7 @@ CRITICAL LANGUAGE REQUIREMENT: 'web_search' => isset( $settings['web_search_enabled'] ) && '1' === $settings['web_search_enabled'], 'default_mode' => 'writing', // SEO fields + 'focus_keyword' => '', 'seo_focus_keyword' => '', 'seo_secondary_keywords' => '', 'seo_meta_description' => '', @@ -1626,6 +1692,13 @@ CRITICAL LANGUAGE REQUIREMENT: // SEO fields $sanitized['seo_focus_keyword'] = sanitize_text_field( $config['seo_focus_keyword'] ?? $defaults['seo_focus_keyword'] ); + $sanitized['focus_keyword'] = sanitize_text_field( $config['focus_keyword'] ?? $defaults['focus_keyword'] ); + if ( '' === $sanitized['focus_keyword'] && '' !== $sanitized['seo_focus_keyword'] ) { + $sanitized['focus_keyword'] = $sanitized['seo_focus_keyword']; + } + if ( '' === $sanitized['seo_focus_keyword'] && '' !== $sanitized['focus_keyword'] ) { + $sanitized['seo_focus_keyword'] = $sanitized['focus_keyword']; + } $sanitized['seo_secondary_keywords'] = sanitize_text_field( $config['seo_secondary_keywords'] ?? $defaults['seo_secondary_keywords'] ); $sanitized['seo_meta_description'] = sanitize_textarea_field( $config['seo_meta_description'] ?? $defaults['seo_meta_description'] ); $sanitized['seo_enabled'] = isset( $config['seo_enabled'] ) @@ -2041,6 +2114,7 @@ CRITICAL LANGUAGE REQUIREMENT: $topic = $params['topic'] ?? ''; $context = $params['context'] ?? ''; $post_id = $params['postId'] ?? 0; + $session_id = $this->resolve_or_create_session_id( $params['sessionId'] ?? '', $post_id ); $auto_execute = $params['autoExecute'] ?? false; $stream = $params['stream'] ?? false; $chat_history = $params['chatHistory'] ?? array(); @@ -2069,24 +2143,23 @@ CRITICAL LANGUAGE REQUIREMENT: ); } - // Build chat history context for continuity. - $chat_history_context = ''; - if ( ! empty( $chat_history ) && is_array( $chat_history ) ) { - $chat_history_context = "\n\n--- CONVERSATION HISTORY ---\n"; - foreach ( $chat_history as $msg ) { - $role = isset( $msg['role'] ) ? ucfirst( $msg['role'] ) : 'Unknown'; - $content = isset( $msg['content'] ) ? $msg['content'] : ''; - if ( ! empty( $content ) ) { - $chat_history_context .= "{$role}: {$content}\n\n"; - } - } - $chat_history_context .= "--- END CONVERSATION HISTORY ---\n"; - $chat_history_context .= "\nUse the above conversation to understand what the user wants for this article."; - } + $context_builder = WP_Agentic_Writer_Context_Builder::get_instance(); + $context_package = $context_builder->build_for_task( + 'planning', + $session_id, + $post_id, + array_merge( + $params, + array( + 'chatHistory' => $chat_history, + 'postConfig' => $post_config, + ) + ) + ); // If streaming is requested, use streaming response. if ( $stream ) { - return $this->stream_generate_plan( $topic, $context, $post_id, $auto_execute, $article_length, $clarification_answers, $effective_language, $post_config, $chat_history ); + return $this->stream_generate_plan( $topic, $context, $post_id, $auto_execute, $article_length, $clarification_answers, $effective_language, $post_config, $chat_history, $session_id ); } // Get provider for planning task. @@ -2135,9 +2208,9 @@ Generate a JSON outline with the following structure: ] } +Return only valid raw JSON that matches this schema. Do not wrap it in markdown fences and do not add explanatory text. Keep sections focused and actionable. Include H2 headings only. For technical articles, suggest code blocks."; - $memory_context = $this->get_post_memory_context( $post_id ); $messages = array( array( 'role' => 'system', @@ -2145,13 +2218,23 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar ), array( 'role' => 'user', - 'content' => "Topic: {$topic}\n\nContext: {$context}{$post_config_context}{$memory_context}", + 'content' => "Topic: {$topic}\n\nContext:\n{$context_package['working_context']}\n\n{$context_package['research_context']}", ), ); // Generate plan. $this->maybe_inject_brave_search( $messages, $provider, $web_search_options ); - $response = $provider->chat( $messages, array_merge( array( 'temperature' => 0.7 ), $web_search_options ), 'planning' ); + $response = $provider->chat( + $messages, + array_merge( + array( + 'temperature' => 0.7, + 'max_tokens' => 2200, + ), + $web_search_options + ), + 'planning' + ); // Debug: log the provider type and response $provider_class = get_class( $provider ); @@ -2168,22 +2251,59 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar // Extract JSON from response. $content = $response['content'] ?? ''; - $plan_json = $this->extract_json( $content ); + $plan_json = $this->extract_plan_from_response( $content, $topic ); // Debug: log the raw response wpaw_debug_log( 'Plan generation raw response length: ' . strlen( $content ) ); - if ( null === $plan_json ) { - wpaw_debug_log( 'extract_json returned null. Content length: ' . strlen( $content ) ); - return new WP_Error( - 'invalid_json', - __( 'Failed to generate valid plan JSON.', 'wp-agentic-writer' ), - array( 'status' => 500 ) - ); - } + if ( null === $plan_json ) { + wpaw_debug_log( 'extract_plan_from_response returned null. Content preview: ' . substr( $content, 0, 500 ) ); + return new WP_Error( + 'invalid_json', + sprintf( + /* translators: %s: model output preview */ + __( 'The model responded, but the outline format could not be parsed. Preview: %s', 'wp-agentic-writer' ), + $this->build_model_output_preview( $content ) + ), + array( 'status' => 500 ) + ); + } $plan_json = $this->ensure_plan_sections_with_tasks( $plan_json ); + // Persist planning exchange into session history. + if ( ! empty( $session_id ) ) { + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $context_service->update_session_context( + $session_id, + array( + 'working_summary' => array( + 'text' => $this->build_memory_summary_from_plan( $plan_json ), + 'updated_at' => current_time( 'c' ), + 'source_message_count' => 0, + ), + ) + ); + $context_service->add_message( + $session_id, + array( + 'role' => 'user', + 'content' => trim( (string) $topic ), + 'timestamp' => current_time( 'c' ), + ) + ); + $context_service->add_message( + $session_id, + array( + 'role' => 'assistant', + 'type' => 'plan', + 'plan' => $plan_json, + 'content' => $this->build_plan_summary_for_session( $plan_json, $post_config ), + 'timestamp' => current_time( 'c' ), + ) + ); + } + // Store plan in post meta. if ( $post_id > 0 ) { update_post_meta( $post_id, '_wpaw_plan', $plan_json ); @@ -2208,7 +2328,7 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar $response['output_tokens'] ?? 0, $response['cost'] ?? 0, $provider_result, - '', + $session_id, 'success' ); @@ -2217,6 +2337,7 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar 'plan' => $plan_json, 'cost' => $response['cost'] ?? 0, 'web_search_results' => $response['web_search_results'] ?? array(), + 'context_audit' => $context_package['audit'] ?? array(), 'provider_metadata' => $this->build_provider_metadata( $provider_result, $response['model'] ?? '' @@ -2238,6 +2359,7 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar $instruction = $params['instruction'] ?? ''; $plan = $params['plan'] ?? array(); $post_id = $params['postId'] ?? 0; + $session_id = $this->resolve_or_create_session_id( $params['sessionId'] ?? '', $post_id ); if ( empty( $instruction ) ) { return new WP_Error( @@ -2274,6 +2396,19 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' ); $provider = $provider_result->provider; $memory_context = $this->get_post_memory_context( $post_id ); + $context_builder = WP_Agentic_Writer_Context_Builder::get_instance(); + $context_package = $context_builder->build_for_task( + 'plan_revision', + $session_id, + $post_id, + array_merge( + $params, + array( + 'plan' => $plan, + 'postConfig' => $post_config, + ) + ) + ); $system_prompt = "You are an expert content strategist. Revise the provided outline based on the user's instruction. @@ -2320,7 +2455,7 @@ Rules: ), array( 'role' => 'user', - 'content' => "Instruction: {$instruction}\n\nCurrent Outline JSON:\n" . wp_json_encode( $plan ) . $memory_context, + 'content' => "Instruction: {$instruction}\n\nContinuity Context:\n{$context_package['working_context']}\n\nCurrent Outline JSON:\n" . wp_json_encode( $plan ) . $memory_context, ), ); @@ -2336,7 +2471,7 @@ Rules: ); } - $plan_json = $this->extract_json( $response['content'] ); + $plan_json = $this->extract_plan_from_response( $response['content'], $instruction, $plan ); if ( null === $plan_json ) { return new WP_Error( 'plan_revision_invalid', @@ -2363,6 +2498,29 @@ Rules: ); } + if ( ! empty( $session_id ) ) { + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $context_service->append_session_context_item( + $session_id, + 'plan_versions', + array( + 'instruction' => sanitize_text_field( $instruction ), + 'plan' => $plan, + ), + 10 + ); + $context_service->update_session_context( + $session_id, + array( + 'working_summary' => array( + 'text' => $this->build_memory_summary_from_plan( $plan_json ), + 'updated_at' => current_time( 'c' ), + 'source_message_count' => 0, + ), + ) + ); + } + // Track cost with provider metadata. $this->track_ai_cost( $post_id, @@ -2372,7 +2530,7 @@ Rules: $response['output_tokens'] ?? 0, $response['cost'] ?? 0, $provider_result, - '', + $session_id, 'success' ); @@ -2380,6 +2538,7 @@ Rules: array( 'plan' => $plan_json, 'cost' => $response['cost'] ?? 0, + 'context_audit' => $context_package['audit'] ?? array(), 'provider_metadata' => $this->build_provider_metadata( $provider_result, $response['model'] ?? '' @@ -2494,7 +2653,7 @@ Rules: * @param string $article_length Article length (short, medium, or long). * @return void Streams response to client. */ - private function stream_generate_plan( $topic, $context, $post_id, $auto_execute, $article_length = 'medium', $clarification_answers = array(), $detected_language = 'english', $post_config = array(), $chat_history = array() ) { + private function stream_generate_plan( $topic, $context, $post_id, $auto_execute, $article_length = 'medium', $clarification_answers = array(), $detected_language = 'english', $post_config = array(), $chat_history = array(), $session_id = '' ) { // Set headers for streaming. header( 'Content-Type: text/event-stream' ); header( 'Cache-Control: no-cache' ); @@ -2506,7 +2665,7 @@ Rules: } flush(); - $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' ); + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' ); $provider = $provider_result->provider; $total_cost = 0; $post_config = $this->sanitize_post_config( wp_parse_args( $post_config, $this->get_default_post_config() ) ); @@ -2532,6 +2691,17 @@ Rules: // The frontend is responsible for checking clarity first via /check-clarity // This endpoint only handles the actual streaming generation + echo "data: " . wp_json_encode( + array( + 'type' => 'provider', + 'provider' => $provider_result->actual_provider, + 'selectedProvider' => $provider_result->selected_provider, + 'fallback_used' => $provider_result->fallback_used, + 'byok_managed_by' => 'openrouter' === $provider_result->actual_provider ? 'openrouter' : '', + ) + ) . "\n\n"; + flush(); + // Send starting status $this->send_status( 'starting', 'Connecting to AI...' ); @@ -2574,20 +2744,21 @@ Rules: $clarity_context .= "=== END CONTEXT ===\n"; } - // Build chat history context for continuity. - $chat_history_context = ''; - if ( ! empty( $chat_history ) && is_array( $chat_history ) ) { - $chat_history_context = "\n\n--- CONVERSATION HISTORY ---\n"; - foreach ( $chat_history as $msg ) { - $role = isset( $msg['role'] ) ? ucfirst( $msg['role'] ) : 'Unknown'; - $content = isset( $msg['content'] ) ? $msg['content'] : ''; - if ( ! empty( $content ) ) { - $chat_history_context .= "{$role}: {$content}\n\n"; - } - } - $chat_history_context .= "--- END CONVERSATION HISTORY ---\n"; - $chat_history_context .= "\nUse the above conversation to understand what the user wants for this article."; - } + $context_builder = WP_Agentic_Writer_Context_Builder::get_instance(); + $context_package = $context_builder->build_for_task( + 'planning', + $session_id, + $post_id, + array( + 'topic' => $topic, + 'context' => $context, + 'chatHistory' => $chat_history, + 'postConfig' => $post_config, + 'clarificationAnswers' => $clarification_answers, + 'detectedLanguage' => $detected_language, + ) + ); + $chat_history_context = "\n\n" . $context_package['working_context'] . "\n\n" . $context_package['research_context']; // Add section limits based on article length. $length_section_limits = array( @@ -2646,6 +2817,7 @@ Generate a JSON outline with the following structure: ] } +Return only valid raw JSON that matches this schema. Do not wrap it in markdown fences and do not add explanatory text. Keep sections focused and actionable. Include H2 headings only. For technical articles, suggest code blocks."; $memory_context = $this->get_post_memory_context( $post_id ); @@ -2665,7 +2837,17 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar wpaw_debug_log( 'Detected language: ' . $detected_language ); $this->maybe_inject_brave_search( $messages, $provider, $web_search_options ); - $response = $provider->chat( $messages, array_merge( array( 'temperature' => 0.7 ), $web_search_options ), 'planning' ); + $response = $provider->chat( + $messages, + array_merge( + array( + 'temperature' => 0.7, + 'max_tokens' => 2200, + ), + $web_search_options + ), + 'planning' + ); wpaw_debug_log( 'OpenRouter API response received' ); @@ -2682,14 +2864,15 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar $content = $response['content']; wpaw_debug_log( 'stream_generatePlan content length: ' . strlen( $content ) ); - $plan_json = $this->extract_json( $content ); + $plan_json = $this->extract_plan_from_response( $content, $topic ); if ( null === $plan_json ) { - wpaw_debug_log( 'extract_json failed in streaming. Content length: ' . strlen( $content ) ); + wpaw_debug_log( 'extract_plan_from_response failed in streaming. Content preview: ' . substr( $content, 0, 500 ) ); + $preview = $this->build_model_output_preview( $content ); echo "data: " . wp_json_encode( array( 'type' => 'error', - 'message' => 'Failed to generate valid plan JSON.', + 'message' => 'The model responded, but the outline format could not be parsed. Preview: ' . $preview, ) ) . "\n\n"; flush(); @@ -2698,6 +2881,39 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar $plan_json = $this->ensure_plan_sections_with_tasks( $plan_json ); + // Persist planning exchange into session history. + if ( ! empty( $session_id ) ) { + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $context_service->update_session_context( + $session_id, + array( + 'working_summary' => array( + 'text' => $this->build_memory_summary_from_plan( $plan_json ), + 'updated_at' => current_time( 'c' ), + 'source_message_count' => 0, + ), + ) + ); + $context_service->add_message( + $session_id, + array( + 'role' => 'user', + 'content' => trim( (string) $topic ), + 'timestamp' => current_time( 'c' ), + ) + ); + $context_service->add_message( + $session_id, + array( + 'role' => 'assistant', + 'type' => 'plan', + 'plan' => $plan_json, + 'content' => $this->build_plan_summary_for_session( $plan_json, $post_config ), + 'timestamp' => current_time( 'c' ), + ) + ); + } + // Store plan in post meta. if ( $post_id > 0 ) { update_post_meta( $post_id, '_wpaw_plan', $plan_json ); @@ -2724,7 +2940,7 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar $response['output_tokens'], $response['cost'], $provider_result, - '', + $session_id, 'success' ); @@ -2735,6 +2951,7 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar 'plan' => $plan_json, 'cost' => $response['cost'], 'web_search_results' => $response['web_search_results'] ?? array(), + 'context_audit' => $context_package['audit'] ?? array(), ) ) . "\n\n"; flush(); @@ -3171,6 +3388,51 @@ Remember: You MUST include the ~~~ARTICLE~~~ divider to separate your conversati exit; } + /** + * Build a compact, persistent outline summary for session history. + * + * @since 0.2.2 + * @param array $plan_json Plan data. + * @param array $post_config Post config. + * @return string + */ + private function build_plan_summary_for_session( $plan_json, $post_config = array() ) { + $title = trim( (string) ( $plan_json['title'] ?? 'Outline ready' ) ); + $sections = is_array( $plan_json['sections'] ?? null ) ? $plan_json['sections'] : array(); + + $lines = array(); + $lines[] = 'Outline ready.'; + $lines[] = ''; + $lines[] = $title; + $lines[] = ''; + + $focus = trim( (string) ( $post_config['seo_focus_keyword'] ?? '' ) ); + $secondary = trim( (string) ( $post_config['seo_secondary_keywords'] ?? '' ) ); + if ( '' !== $focus || '' !== $secondary ) { + $lines[] = 'SEO Snapshot:'; + if ( '' !== $focus ) { + $lines[] = '- Focus: ' . $focus; + } + if ( '' !== $secondary ) { + $lines[] = '- Secondary: ' . $secondary; + } + $lines[] = ''; + } + + $lines[] = 'Sections:'; + $index = 1; + foreach ( $sections as $section ) { + $heading = trim( (string) ( $section['heading'] ?? $section['title'] ?? '' ) ); + if ( '' === $heading ) { + continue; + } + $lines[] = $index . '. ' . $heading; + $index++; + } + + return implode( "\n", $lines ); + } + /** * Handle execute article request. * @@ -3444,7 +3706,7 @@ IMAGE SUGGESTIONS: // Track total cost. $this->track_ai_cost( $post_id, - $provider->get_execution_model(), + $this->get_provider_execution_model( $provider, 'execution' ), 'execution', 0, 0, @@ -3461,7 +3723,7 @@ IMAGE SUGGESTIONS: 'recommended_title' => $recommended_title, 'provider_metadata' => $this->build_provider_metadata( $provider_result, - $provider->get_execution_model() + $this->get_provider_execution_model( $provider, 'execution' ) ), ), 200 @@ -3497,7 +3759,7 @@ IMAGE SUGGESTIONS: $settings = get_option( 'wp_agentic_writer_settings', array() ); wpaw_debug_log( 'Using provider', array( 'class' => get_class( $provider ), - 'base_url' => property_exists( $provider, 'base_url' ) ? $provider->base_url : 'N/A' + 'configured' => method_exists( $provider, 'is_configured' ) ? $provider->is_configured() : 'unknown', ) ); wpaw_debug_log( 'Settings check', array( 'local_backend_url' => $settings['local_backend_url'] ?? 'NOT SET', @@ -3769,7 +4031,7 @@ IMAGE SUGGESTIONS: 'totalCost' => $total_cost, 'provider_metadata' => $this->build_provider_metadata( $provider_result, - $provider->get_execution_model() + $this->get_provider_execution_model( $provider, 'execution' ) ), ) ) . "\n\n"; @@ -3996,33 +4258,416 @@ IMAGE SUGGESTIONS: * @return array|null Decoded JSON or null if invalid. */ private function extract_json( $string ) { - // Try to find JSON in the string. + $string = trim( (string) $string ); + if ( '' === $string ) { + return null; + } - // Method 1: Standard JSON object - if ( preg_match( '/\{.*\}/s', $string, $matches ) ) { - $json = json_decode( $matches[0], true ); - if ( json_last_error() === JSON_ERROR_NONE ) { - return $json; + // Method 1: JSON wrapped in markdown code block. + if ( preg_match_all( '/```(?:json)?\s*([\s\S]*?)```/i', $string, $matches ) ) { + foreach ( $matches[1] as $candidate ) { + $json = json_decode( trim( $candidate ), true ); + if ( json_last_error() === JSON_ERROR_NONE ) { + return $json; + } } } - // Method 2: JSON wrapped in markdown code block - if ( preg_match( '/```(?:json)?\s*\n?(.*?)\n?```/s', $string, $matches ) ) { - $json = json_decode( $matches[1], true ); - if ( json_last_error() === JSON_ERROR_NONE ) { - return $json; - } - } - - // Method 3: Just try to decode the whole string + // Method 2: Decode the whole string. $json = json_decode( $string, true ); if ( json_last_error() === JSON_ERROR_NONE ) { return $json; } + // Method 3: Extract balanced JSON object/array candidates. This avoids + // greedy matching across multiple objects or explanatory braces. + $candidates = array_merge( + $this->extract_balanced_json_candidates( $string, '{', '}' ), + $this->extract_balanced_json_candidates( $string, '[', ']' ) + ); + foreach ( $candidates as $candidate ) { + $json = json_decode( $candidate, true ); + if ( json_last_error() === JSON_ERROR_NONE ) { + return $json; + } + } + return null; } + /** + * Extract balanced JSON object candidates from model text. + * + * @since 0.2.2 + * @param string $string Source text. + * @return array + */ + private function extract_balanced_json_candidates( $string, $open_char = '{', $close_char = '}' ) { + $candidates = array(); + $length = strlen( $string ); + $depth = 0; + $start = null; + $in_string = false; + $escaped = false; + + for ( $i = 0; $i < $length; $i++ ) { + $char = $string[ $i ]; + + if ( $in_string ) { + if ( $escaped ) { + $escaped = false; + } elseif ( '\\' === $char ) { + $escaped = true; + } elseif ( '"' === $char ) { + $in_string = false; + } + continue; + } + + if ( '"' === $char ) { + $in_string = true; + continue; + } + + if ( $open_char === $char ) { + if ( 0 === $depth ) { + $start = $i; + } + $depth++; + } elseif ( $close_char === $char && $depth > 0 ) { + $depth--; + if ( 0 === $depth && null !== $start ) { + $candidates[] = substr( $string, $start, $i - $start + 1 ); + $start = null; + } + } + } + + usort( + $candidates, + function( $a, $b ) { + return strlen( $b ) <=> strlen( $a ); + } + ); + + return $candidates; + } + + /** + * Extract an article plan from model output, falling back to markdown outlines. + * + * @since 0.2.2 + * @param string $content Model response. + * @param string $fallback_title Fallback title/topic. + * @param array $previous_plan Previous plan for revisions. + * @return array|null + */ + private function extract_plan_from_response( $content, $fallback_title = '', $previous_plan = array() ) { + $json = $this->extract_json( $content ); + $normalized_json_plan = $this->normalize_extracted_plan_json( $json, $fallback_title ); + if ( ! empty( $normalized_json_plan['sections'] ) ) { + return $normalized_json_plan; + } + + $markdown_plan = $this->build_plan_from_markdown_outline( $content, $fallback_title, $previous_plan ); + if ( ! empty( $markdown_plan['sections'] ) ) { + return $markdown_plan; + } + + return null; + } + + /** + * Build a short, safe preview of unparseable model output. + * + * @since 0.2.3 + * @param string $content Model response. + * @return string + */ + private function build_model_output_preview( $content ) { + $preview = trim( wp_strip_all_tags( (string) $content ) ); + $preview = preg_replace( '/\s+/', ' ', $preview ); + if ( function_exists( 'mb_substr' ) ) { + $preview = mb_substr( $preview, 0, 240 ); + } else { + $preview = substr( $preview, 0, 240 ); + } + + return '' !== $preview ? $preview : '(empty response)'; + } + + /** + * Normalize common model outline JSON variants into the required plan schema. + * + * @since 0.2.3 + * @param mixed $json Decoded model JSON. + * @param string $fallback_title Fallback title/topic. + * @return array|null + */ + private function normalize_extracted_plan_json( $json, $fallback_title = '' ) { + if ( ! is_array( $json ) ) { + return null; + } + + // Some models return the sections array directly. + if ( array_is_list( $json ) ) { + $json = array( + 'title' => $fallback_title, + 'sections' => $json, + ); + } + + // Some models nest the outline under a descriptive top-level key. + foreach ( array( 'plan', 'outline', 'article_plan', 'articlePlan', 'data' ) as $key ) { + if ( empty( $json['sections'] ) && isset( $json[ $key ] ) && is_array( $json[ $key ] ) ) { + $nested = $this->normalize_extracted_plan_json( $json[ $key ], $fallback_title ); + if ( ! empty( $nested['sections'] ) ) { + return $nested; + } + } + } + + $section_keys = array( 'sections', 'outline', 'items', 'chapters', 'headings', 'bagian' ); + $sections = array(); + foreach ( $section_keys as $key ) { + if ( ! empty( $json[ $key ] ) && is_array( $json[ $key ] ) ) { + $sections = $json[ $key ]; + break; + } + } + + if ( empty( $sections ) ) { + return null; + } + + $title = $json['title'] ?? $json['judul'] ?? $json['headline'] ?? $fallback_title; + $title = $this->clean_outline_heading( $title ); + if ( '' === $title ) { + $title = __( 'Article Outline', 'wp-agentic-writer' ); + } + + $normalized_sections = array(); + foreach ( $sections as $index => $section ) { + if ( is_string( $section ) ) { + $section = array( 'heading' => $section ); + } + if ( ! is_array( $section ) ) { + continue; + } + + $heading = $section['heading'] + ?? $section['title'] + ?? $section['judul'] + ?? $section['name'] + ?? $section['h2'] + ?? sprintf( 'Section %d', $index + 1 ); + $heading = $this->clean_outline_heading( $heading ); + if ( '' === $heading ) { + continue; + } + + $content_items = $section['content'] + ?? $section['description'] + ?? $section['summary'] + ?? $section['points'] + ?? $section['bullets'] + ?? array(); + $content = $this->normalize_plan_section_content_items( $content_items ); + if ( empty( $content ) ) { + $content[] = array( + 'type' => 'paragraph', + 'content' => $heading, + ); + } + + $normalized_sections[] = array( + 'id' => sanitize_key( $section['id'] ?? '' ), + 'status' => sanitize_key( $section['status'] ?? 'pending' ), + 'type' => sanitize_key( $section['type'] ?? 'section' ), + 'heading' => $heading, + 'content' => $content, + ); + } + + if ( empty( $normalized_sections ) ) { + return null; + } + + $meta = isset( $json['meta'] ) && is_array( $json['meta'] ) ? $json['meta'] : array(); + return array( + 'title' => $title, + 'meta' => wp_parse_args( + $meta, + array( + 'reading_time' => '5 min', + 'difficulty' => 'intermediate', + 'cost_estimate' => 0.70, + ) + ), + 'sections' => $normalized_sections, + ); + } + + /** + * Normalize varied model section content into plan content items. + * + * @since 0.2.3 + * @param mixed $items Section content candidate. + * @return array + */ + private function normalize_plan_section_content_items( $items ) { + if ( is_string( $items ) ) { + $items = array( $items ); + } + if ( ! is_array( $items ) ) { + return array(); + } + + $normalized = array(); + foreach ( $items as $item ) { + if ( is_string( $item ) ) { + $text = trim( wp_strip_all_tags( $item ) ); + if ( '' !== $text ) { + $normalized[] = array( + 'type' => 'paragraph', + 'content' => $text, + ); + } + continue; + } + + if ( ! is_array( $item ) ) { + continue; + } + + $text = $item['content'] ?? $item['text'] ?? $item['description'] ?? $item['point'] ?? ''; + $text = trim( wp_strip_all_tags( (string) $text ) ); + if ( '' === $text ) { + continue; + } + + $normalized[] = array( + 'type' => sanitize_key( $item['type'] ?? 'paragraph' ), + 'content' => $text, + ); + } + + return $normalized; + } + + /** + * Build a plan schema from markdown/numbered outline output. + * + * @since 0.2.2 + * @param string $content Model response. + * @param string $fallback_title Fallback title/topic. + * @param array $previous_plan Previous plan for revisions. + * @return array|null + */ + private function build_plan_from_markdown_outline( $content, $fallback_title = '', $previous_plan = array() ) { + $lines = preg_split( '/\r\n|\r|\n/', (string) $content ); + if ( ! is_array( $lines ) ) { + return null; + } + + $title = ''; + $sections = array(); + $current = null; + + foreach ( $lines as $raw_line ) { + $line = trim( wp_strip_all_tags( (string) $raw_line ) ); + if ( '' === $line ) { + continue; + } + + $line = preg_replace( '/^\s*(?:[-*]\s*)?\*\*(.*?)\*\*\s*$/', '$1', $line ); + $heading = ''; + + if ( preg_match( '/^#{1,2}\s+(.+)$/', $line, $matches ) ) { + $text = $this->clean_outline_heading( $matches[1] ); + if ( '' === $title ) { + $title = $text; + continue; + } + $heading = $text; + } elseif ( preg_match( '/^\d+[\.)]\s+(.+)$/', $line, $matches ) ) { + $heading = $this->clean_outline_heading( $matches[1] ); + } elseif ( preg_match( '/^(?:section|bagian)\s+\d+\s*[:.-]\s*(.+)$/i', $line, $matches ) ) { + $heading = $this->clean_outline_heading( $matches[1] ); + } elseif ( '' === $title && ! preg_match( '/^(seo snapshot|sections?|outline|meta|focus keyword|secondary keywords?)\b/i', $line ) ) { + $title = $this->clean_outline_heading( $line ); + continue; + } + + if ( '' !== $heading && ! preg_match( '/^(seo snapshot|sections?|outline|meta|focus keyword|secondary keywords?)\b/i', $heading ) ) { + if ( null !== $current ) { + $sections[] = $current; + } + $current = array( + 'id' => wp_generate_uuid4(), + 'status' => 'pending', + 'type' => 'section', + 'heading' => $heading, + 'content' => array(), + ); + continue; + } + + if ( null !== $current ) { + $detail = preg_replace( '/^[-*]\s+/', '', $line ); + if ( '' !== $detail && ! preg_match( '/^(title|judul|meta|reading time|difficulty|cost estimate)\b/i', $detail ) ) { + $current['content'][] = array( + 'type' => 'paragraph', + 'content' => $detail, + ); + } + } + } + + if ( null !== $current ) { + $sections[] = $current; + } + + if ( empty( $sections ) ) { + return null; + } + + if ( '' === $title ) { + $title = $this->clean_outline_heading( $fallback_title ); + } + if ( '' === $title && ! empty( $previous_plan['title'] ) ) { + $title = (string) $previous_plan['title']; + } + if ( '' === $title ) { + $title = __( 'Article Outline', 'wp-agentic-writer' ); + } + + return array( + 'title' => $title, + 'meta' => array( + 'reading_time' => '5 min', + 'difficulty' => 'intermediate', + 'cost_estimate' => 0.70, + ), + 'sections' => $sections, + ); + } + + /** + * Clean markdown decoration from an outline heading. + * + * @since 0.2.2 + * @param string $heading Heading text. + * @return string + */ + private function clean_outline_heading( $heading ) { + $heading = trim( (string) $heading ); + $heading = preg_replace( '/^\s*["\'`]+|["\'`]+\s*$/', '', $heading ); + $heading = preg_replace( '/\*\*(.*?)\*\*/', '$1', $heading ); + $heading = preg_replace( '/\s+/', ' ', $heading ); + return trim( $heading ); + } + /** * Handle get models request. * @@ -5173,6 +5818,7 @@ No markdown, no explanation - just JSON."; $blocks_to_refine = $params['blocksToRefine'] ?? array(); $all_blocks = $params['allBlocks'] ?? array(); $diff_plan = ! empty( $params['diffPlan'] ); + $selective_refine = ! empty( $params['selectiveRefine'] ); if ( empty( $blocks_to_refine ) || ! is_array( $blocks_to_refine ) ) { return new WP_Error( @@ -5195,7 +5841,7 @@ No markdown, no explanation - just JSON."; $post_config = $this->resolve_post_config_from_request( $params, $post_id ); // Stream refinement for each mentioned block - $this->stream_refinement_from_chat( $blocks_to_refine, $message, $selected_block, $post_id, $all_blocks, $diff_plan, $post_config, $session_id ); + $this->stream_refinement_from_chat( $blocks_to_refine, $message, $selected_block, $post_id, $all_blocks, $diff_plan, $post_config, $session_id, $selective_refine ); // Return early to avoid REST API trying to send headers after streaming exit; @@ -5302,7 +5948,7 @@ No markdown, no explanation - just JSON."; * @param int $post_id Post ID. * @return void Streams response to client. */ - private function stream_refinement_from_chat( $blocks_to_refine, $message, $selected_block, $post_id, $all_blocks, $diff_plan, $post_config = array(), $session_id = '' ) { + private function stream_refinement_from_chat( $blocks_to_refine, $message, $selected_block, $post_id, $all_blocks, $diff_plan, $post_config = array(), $session_id = '', $selective_refine = false ) { // Set headers for streaming. header( 'Content-Type: text/event-stream' ); header( 'Cache-Control: no-cache' ); @@ -5334,6 +5980,14 @@ No markdown, no explanation - just JSON."; $language_instruction = $this->build_language_instruction( $effective_language, 'refined content' ); $refined_count = 0; $total_cost = 0.0; + $failed_count = 0; + $consecutive_errors = 0; + $max_consecutive_errors = 3; + $aborted_due_to_provider_errors = false; + $last_model_used = ''; + $total_blocks_to_refine = is_array( $blocks_to_refine ) ? count( $blocks_to_refine ) : 0; + $batch_size = 5; + $batch_total = $total_blocks_to_refine > 0 ? (int) ceil( $total_blocks_to_refine / $batch_size ) : 0; // Get post title for context $post = get_post( $post_id ); @@ -5342,6 +5996,16 @@ No markdown, no explanation - just JSON."; // Normalize blocks for context $context_blocks = array(); $block_source = is_array( $all_blocks ) && ! empty( $all_blocks ) ? $all_blocks : $this->select_blocks(); + $allowed_block_ids = array_values( + array_filter( + array_map( + static function ( $block_obj ) { + return sanitize_text_field( $block_obj['clientId'] ?? '' ); + }, + is_array( $blocks_to_refine ) ? $blocks_to_refine : array() + ) + ) + ); foreach ( $block_source as $block ) { $client_id = $block['clientId'] ?? $block['attrs']['clientId'] ?? ''; $block_type = $block['name'] ?? $block['blockName'] ?? 'core/paragraph'; @@ -5359,7 +6023,92 @@ No markdown, no explanation - just JSON."; ); } + // Optional evaluator pass: classify blocks first, then refine only necessary ones. + if ( $selective_refine && count( $blocks_to_refine ) > 1 ) { + echo "data: " . wp_json_encode( + array( + 'type' => 'status', + 'message' => sprintf( 'Evaluating %d block(s) to select only necessary refinements...', count( $blocks_to_refine ) ), + ) + ) . "\n\n"; + flush(); + + $eval_map = array(); + foreach ( $blocks_to_refine as $block_obj ) { + $cid = sanitize_text_field( $block_obj['clientId'] ?? '' ); + $bname = sanitize_text_field( $block_obj['name'] ?? 'core/paragraph' ); + $battrs = $block_obj['attributes'] ?? array(); + $txt = trim( wp_strip_all_tags( $this->extract_block_content_from_attrs( $bname, $battrs ) ) ); + if ( '' === $cid || '' === $txt ) { + continue; + } + if ( strlen( $txt ) > 220 ) { + $txt = substr( $txt, 0, 220 ) . '...'; + } + $eval_map[] = array( + 'blockId' => $cid, + 'type' => $bname, + 'text' => $txt, + ); + } + + if ( ! empty( $eval_map ) ) { + $eval_prompt = "You are a strict editor classifier.\n" + . "Task: decide which blocks NEED refinement for this user request.\n" + . "Return ONLY JSON: {\"keep\":[\"id\"],\"needs_refine\":[\"id\"],\"reasons\":{\"id\":\"short reason\"}}\n" + . "Rules: If block already satisfies request, keep it. Do not rewrite. Only classify.\n" + . "User request: {$message}\nBlocks:\n"; + foreach ( $eval_map as $row ) { + $eval_prompt .= "- {$row['blockId']} | {$row['type']} | {$row['text']}\n"; + } + + $eval_response = $provider->chat( + array( + array( 'role' => 'system', 'content' => $eval_prompt ), + array( 'role' => 'user', 'content' => 'Classify now.' ), + ), + array( 'temperature' => 0.1 ), + 'planning' + ); + + if ( ! is_wp_error( $eval_response ) ) { + $eval_raw = trim( (string) ( $eval_response['content'] ?? '' ) ); + if ( preg_match( '/```(?:json)?\s*\n?(.*?)\n?```/s', $eval_raw, $m ) ) { + $eval_raw = trim( $m[1] ); + } + $eval_json = json_decode( $eval_raw, true ); + if ( is_array( $eval_json ) && isset( $eval_json['needs_refine'] ) && is_array( $eval_json['needs_refine'] ) ) { + $needs_lookup = array_fill_keys( + array_map( 'sanitize_text_field', $eval_json['needs_refine'] ), + true + ); + $filtered = array_values( + array_filter( + $blocks_to_refine, + static function ( $block_obj ) use ( $needs_lookup ) { + $cid = sanitize_text_field( $block_obj['clientId'] ?? '' ); + return isset( $needs_lookup[ $cid ] ); + } + ) + ); + if ( ! empty( $filtered ) ) { + $before_count = count( $blocks_to_refine ); + $blocks_to_refine = $filtered; + echo "data: " . wp_json_encode( + array( + 'type' => 'status', + 'message' => sprintf( 'Selective refinement: %1$d/%2$d block(s) need updates.', count( $blocks_to_refine ), $before_count ), + ) + ) . "\n\n"; + flush(); + } + } + } + } + } + if ( $diff_plan && ! empty( $context_blocks ) ) { + $plan_generation_failed = false; $plan_prompt = "You are an editor planning precise block-level edits. Return ONLY valid JSON in this format: @@ -5386,6 +6135,8 @@ Rules: User request: {$message} +Allowed target block IDs (STRICT): " . implode( ', ', $allowed_block_ids ) . " + Blocks: "; @@ -5431,33 +6182,62 @@ Blocks: $plan_json = json_decode( $json_content, true ); if ( is_array( $plan_json ) && isset( $plan_json['actions'] ) ) { - echo "data: " . wp_json_encode( - array( - 'type' => 'edit_plan', - 'plan' => $plan_json, - ) - ) . "\n\n"; - flush(); - exit; + $plan_json = $this->sanitize_refinement_edit_plan( $plan_json, $allowed_block_ids, $context_blocks ); + if ( empty( $plan_json['actions'] ) ) { + $plan_generation_failed = true; + } else { + echo "data: " . wp_json_encode( + array( + 'type' => 'edit_plan', + 'plan' => $plan_json, + ) + ) . "\n\n"; + flush(); + exit; + } } else { error_log( 'WP Agentic Writer: Edit plan JSON decode failed or missing actions. JSON error: ' . json_last_error_msg() ); error_log( 'WP Agentic Writer: Attempted to parse: ' . substr( $json_content, 0, 200 ) ); + $plan_generation_failed = true; } } else { error_log( 'WP Agentic Writer: Edit plan API error: ' . $plan_response->get_error_message() ); + $plan_generation_failed = true; } - echo "data: " . wp_json_encode( - array( - 'type' => 'error', - 'message' => 'Failed to build an edit plan. The AI response was invalid. Please try a simpler instruction or use add below/above commands.', - ) - ) . "\n\n"; - flush(); - exit; + // Fallback path: when edit-plan fails (common on broad @all requests), + // continue with direct per-block refinement instead of hard failing. + if ( $plan_generation_failed ) { + echo "data: " . wp_json_encode( + array( + 'type' => 'status', + 'message' => 'Edit plan failed, switching to direct block refinement.', + ) + ) . "\n\n"; + flush(); + } } - foreach ( $blocks_to_refine as $block_obj ) { + foreach ( $blocks_to_refine as $block_index_loop => $block_obj ) { + if ( 0 === ( $block_index_loop % $batch_size ) ) { + $current_batch = (int) floor( $block_index_loop / $batch_size ) + 1; + $batch_start = $block_index_loop + 1; + $batch_end = min( $total_blocks_to_refine, $block_index_loop + $batch_size ); + echo "data: " . wp_json_encode( + array( + 'type' => 'status', + 'message' => sprintf( + 'Processing batch %1$d/%2$d (blocks %3$d-%4$d of %5$d)', + $current_batch, + $batch_total, + $batch_start, + $batch_end, + $total_blocks_to_refine + ), + ) + ) . "\n\n"; + flush(); + } // Extract block data from the block object sent from frontend $block_client_id = $block_obj['clientId'] ?? ''; $block_type = $block_obj['name'] ?? 'core/paragraph'; @@ -5491,6 +6271,10 @@ Blocks: $context_str .= "Current block type: " . $block_type . "\n"; $context_str .= "Current content:\n" . $block_content . "\n"; + $section_context = $this->build_section_context_for_block( $all_blocks, $block_index, 4 ); + if ( ! empty( $section_context ) ) { + $context_str .= "Section context:\n" . $section_context . "\n"; + } if ( ! empty( $article_context['nextBlock'] ) ) { $context_str .= "Next section: " . $article_context['nextBlock']['heading'] . "\n"; @@ -5517,15 +6301,13 @@ IMPORTANT RULES: 2. Maintain the core meaning and key information 3. Ensure it flows well with surrounding sections 4. Match the article's overall tone and style -5. Return ONLY the refined content, no explanations or conversational text +5. Return ONLY the refined content payload, no explanations or conversational text Output format: -- If paragraph: Return the refined text only -- If heading: Return the refined heading text only -- If list: Return the list items, one per line -- No markdown formatting like ```text``` wrappers -- No conversational filler -- Start directly with the refined content"; +- Return STRICT JSON ONLY: {\"content\":\"...\",\"blockType\":\"{$block_type}\"} +- Use content value only for the final refined block text +- If list: content should contain one item per line +- No markdown wrappers, no chain-of-thought, no \"Refined version\", no \"Key refinements\", no explanations"; $messages = array( array( @@ -5542,7 +6324,7 @@ Output format: $refined_content = ''; $stream_result = $provider->chat_stream( $messages, - array( 'temperature' => 0.7 ), + array( 'temperature' => 0.2 ), 'execution', function( $chunk ) use ( &$refined_content ) { // Accumulate the streaming content @@ -5551,6 +6333,8 @@ Output format: ); if ( is_wp_error( $stream_result ) ) { + $failed_count++; + $consecutive_errors++; echo "data: " . wp_json_encode( array( 'type' => 'error', @@ -5558,11 +6342,30 @@ Output format: ) ) . "\n\n"; flush(); + + if ( $consecutive_errors >= $max_consecutive_errors ) { + $aborted_due_to_provider_errors = true; + echo "data: " . wp_json_encode( + array( + 'type' => 'status', + 'message' => sprintf( + 'Stopped early after %d consecutive provider failures at block %d. Please retry with fewer blocks or check local backend health.', + (int) $consecutive_errors, + (int) $block_index_loop + 1 + ), + ) + ) . "\n\n"; + flush(); + break; + } + continue; } + $consecutive_errors = 0; // Track cost from streaming result (always track for debugging). $stream_cost = $stream_result['cost'] ?? 0; + $last_model_used = $stream_result['model'] ?? $last_model_used; $total_cost += $stream_cost; $this->track_ai_cost( $post_id, @@ -5580,6 +6383,22 @@ Output format: $payload = $this->parse_refined_payload( $refined_content ); $refined_content = $this->clean_refined_content( $payload['content'] ); $resolved_block_type = $payload['blockType'] ?? $block_type; + if ( $this->is_contaminated_refinement_output( $refined_content, $resolved_block_type ) ) { + $failed_count++; + echo "data: " . wp_json_encode( + array( + 'type' => 'status', + 'message' => sprintf( + 'Skipped contaminated output for block %1$d/%2$d (%3$s).', + (int) $block_index_loop + 1, + (int) $total_blocks_to_refine, + $resolved_block_type + ), + ) + ) . "\n\n"; + flush(); + continue; + } // Create proper block structure $block_structure = $this->create_block_structure( $block_client_id, $resolved_block_type, $refined_content ); @@ -5594,6 +6413,20 @@ Output format: flush(); $refined_count++; + if ( 0 === ( $refined_count % 5 ) || $refined_count === $total_blocks_to_refine ) { + echo "data: " . wp_json_encode( + array( + 'type' => 'status', + 'message' => sprintf( + 'Progress: %1$d/%2$d block(s) updated (%3$d failed)', + $refined_count, + $total_blocks_to_refine, + $failed_count + ), + ) + ) . "\n\n"; + flush(); + } // Small delay between blocks usleep( 100000 ); @@ -5625,10 +6458,12 @@ Output format: array( 'type' => 'complete', 'refined' => $refined_count, + 'failed' => $failed_count, + 'aborted' => $aborted_due_to_provider_errors, 'totalCost' => $total_cost, 'provider_metadata' => $this->build_provider_metadata( $provider_result, - $stream_result['model'] ?? '' + $last_model_used ), ) ) . "\n\n"; @@ -5644,6 +6479,70 @@ Output format: } } + /** + * Restrict model edit-plan actions to explicitly allowed target block IDs. + * + * @since 0.1.0 + * + * @param array $plan_json Parsed model plan response. + * @param array $allowed_block_ids Block IDs allowed to be edited. + * @param array $context_blocks Normalized block context list. + * @return array Sanitized plan response. + */ + private function sanitize_refinement_edit_plan( $plan_json, $allowed_block_ids, $context_blocks ) { + $allowed_actions = array( 'keep', 'replace', 'insert_after', 'insert_before', 'delete', 'change_type' ); + $allowed_lookup = array_fill_keys( $allowed_block_ids, true ); + $context_by_id = array(); + + foreach ( $context_blocks as $block ) { + $context_id = sanitize_text_field( $block['clientId'] ?? '' ); + if ( '' === $context_id ) { + continue; + } + $context_by_id[ $context_id ] = sanitize_text_field( $block['type'] ?? 'core/paragraph' ); + } + + $raw_actions = $plan_json['actions'] ?? array(); + if ( ! is_array( $raw_actions ) ) { + $raw_actions = array(); + } + + $sanitized_actions = array(); + foreach ( $raw_actions as $action ) { + if ( ! is_array( $action ) ) { + continue; + } + + $action_name = sanitize_key( $action['action'] ?? '' ); + $block_id = sanitize_text_field( $action['blockId'] ?? '' ); + if ( '' === $action_name || ! in_array( $action_name, $allowed_actions, true ) ) { + continue; + } + + if ( '' === $block_id || ! isset( $allowed_lookup[ $block_id ] ) ) { + continue; + } + + $clean_action = array( + 'action' => $action_name, + 'blockId' => $block_id, + ); + + if ( in_array( $action_name, array( 'replace', 'insert_after', 'insert_before', 'change_type' ), true ) ) { + $fallback_type = $context_by_id[ $block_id ] ?? 'core/paragraph'; + $clean_action['blockType'] = sanitize_text_field( $action['blockType'] ?? $fallback_type ); + $clean_action['content'] = isset( $action['content'] ) ? wp_kses_post( (string) $action['content'] ) : ''; + } + + $sanitized_actions[] = $clean_action; + } + + return array( + 'summary' => sanitize_text_field( $plan_json['summary'] ?? '' ), + 'actions' => $sanitized_actions, + ); + } + /** * Find block by client ID in parsed blocks array. * @@ -5750,6 +6649,28 @@ Output format: // Remove markdown code blocks if present $content = preg_replace( '/^```(?:text|markdown)?\n*/i', '', $content ); $content = preg_replace( '/```*$/i', '', $content ); + + // Remove common analysis scaffolding that sometimes leaks into block output. + $content = preg_replace( '/^\s*Refined version\s*:\s*/im', '', $content ); + $content = preg_replace( '/^\s*Key refinements\s*:\s*$/im', '', $content ); + $content = preg_replace( '/^\s*(The refinement .*|Changes made .*|Explanation .*|Rationale .*)$/im', '', $content ); + + // If model includes a bullet list of substitutions/explanations, strip that section. + $meta_markers = array( + 'Key refinements:', + 'Changes made:', + 'Explanation:', + 'Rationale:', + ); + foreach ( $meta_markers as $marker ) { + $pos = stripos( $content, $marker ); + if ( false !== $pos ) { + $content = substr( $content, 0, $pos ); + } + } + + // Avoid persisting raw JSON fences or labels. + $content = preg_replace( '/^\s*content\s*:\s*/im', '', $content ); $content = trim( $content ); return $content; @@ -5797,6 +6718,98 @@ Output format: return $payload; } + /** + * Detect assistant/meta chatter that should never be inserted as block content. + * + * @since 0.1.0 + * @param string $content Refined content candidate. + * @param string $block_type Resolved block type. + * @return bool + */ + private function is_contaminated_refinement_output( $content, $block_type = 'core/paragraph' ) { + $text = trim( wp_strip_all_tags( (string) $content ) ); + if ( '' === $text ) { + return true; + } + + $meta_patterns = array( + '/\b(i apologize|could you please|would you like me|please share|no specific content was provided)\b/i', + '/\b(refined version|key refinements|changes made|rationale|note:\s*since)\b/i', + '/\b(i have kept the heading|if you\'d like me to refine this)\b/i', + ); + foreach ( $meta_patterns as $pattern ) { + if ( preg_match( $pattern, $text ) ) { + return true; + } + } + + // Headings should be concise and single-line. + if ( 'core/heading' === $block_type ) { + if ( strlen( $text ) > 180 || substr_count( $text, "\n" ) > 0 ) { + return true; + } + } + + return false; + } + + /** + * Build a compact section-scoped context window around a block. + * + * @since 0.1.0 + * @param array $all_blocks All serialized editor blocks. + * @param int $block_index Current block index. + * @param int $max_snippets Max context snippets. + * @return string + */ + private function build_section_context_for_block( $all_blocks, $block_index, $max_snippets = 4 ) { + if ( ! is_array( $all_blocks ) || $block_index < 0 || ! isset( $all_blocks[ $block_index ] ) ) { + return ''; + } + + $start = $block_index; + for ( $i = $block_index - 1; $i >= 0; $i-- ) { + $name = $all_blocks[ $i ]['name'] ?? $all_blocks[ $i ]['blockName'] ?? ''; + if ( 'core/heading' === $name ) { + $start = $i; + break; + } + $start = $i; + } + + $end = $block_index; + for ( $i = $block_index + 1; $i < count( $all_blocks ); $i++ ) { + $name = $all_blocks[ $i ]['name'] ?? $all_blocks[ $i ]['blockName'] ?? ''; + if ( 'core/heading' === $name ) { + break; + } + $end = $i; + } + + $snippets = array(); + for ( $i = $start; $i <= $end; $i++ ) { + $block = $all_blocks[ $i ]; + $name = $block['name'] ?? $block['blockName'] ?? ''; + if ( ! in_array( $name, array( 'core/heading', 'core/paragraph', 'core/list', 'core/quote' ), true ) ) { + continue; + } + $attrs = $block['attributes'] ?? $block['attrs'] ?? array(); + $text = trim( wp_strip_all_tags( $this->extract_block_content_from_attrs( $name, $attrs ) ) ); + if ( '' === $text ) { + continue; + } + if ( strlen( $text ) > 180 ) { + $text = substr( $text, 0, 180 ) . '...'; + } + $snippets[] = '- ' . $text; + if ( count( $snippets ) >= $max_snippets ) { + break; + } + } + + return implode( "\n", $snippets ); + } + /** * Create block structure for refined content. * @@ -6221,6 +7234,33 @@ Output format: $audit['checks'][] = array( 'name' => 'Meta description', 'status' => 'warning', 'message' => 'No meta description set' ); } + // Check 6: AI-ish writing patterns (heuristic scanner). + $ai_pattern_result = $this->scan_ai_ish_patterns( $post->post_content ); + if ( $ai_pattern_result['count'] <= 1 ) { + $audit['checks'][] = array( + 'name' => 'AI-ish pattern risk', + 'status' => 'good', + 'message' => 'Low risk: no significant AI-style pattern detected', + ); + $audit['score'] += 15; + } elseif ( $ai_pattern_result['count'] <= 4 ) { + $audit['checks'][] = array( + 'name' => 'AI-ish pattern risk', + 'status' => 'ok', + 'message' => sprintf( 'Moderate risk: %d pattern(s) detected. Consider selective human polish.', $ai_pattern_result['count'] ), + ); + $audit['score'] += 8; + } else { + $audit['checks'][] = array( + 'name' => 'AI-ish pattern risk', + 'status' => 'warning', + 'message' => sprintf( 'High risk: %d pattern(s) detected. Refine tone for more natural writing.', $ai_pattern_result['count'] ), + ); + $audit['score'] += 3; + } + $audit['ai_ish_pattern_count'] = $ai_pattern_result['count']; + $audit['ai_ish_pattern_examples'] = $ai_pattern_result['examples']; + // Cap score at 100 $audit['score'] = min( 100, $audit['score'] ); @@ -6238,6 +7278,179 @@ Output format: return new WP_REST_Response( $audit, 200 ); } + /** + * Scan post content for common AI-ish writing patterns. + * + * @param string $raw_content Raw post content. + * @return array{count:int,examples:array} + */ + private function scan_ai_ish_patterns( $raw_content ) { + $normalized = wp_strip_all_tags( (string) $raw_content ); + $normalized = preg_replace( '/\s+/', ' ', $normalized ); + $normalized = trim( (string) $normalized ); + + if ( '' === $normalized ) { + return array( + 'count' => 0, + 'examples' => array(), + ); + } + + $rules = array( + array( + 'id' => 'double_colon', + 'pattern' => '/[^\s]:\s*:[^\s]/u', + 'label' => 'double colon punctuation', + ), + array( + 'id' => 'ai_phrase_not_only_but', + 'pattern' => '/\bbukan sekadar\b|\bnot just\b/i', + 'label' => 'formulaic contrast phrase', + ), + array( + 'id' => 'ai_phrase_in_conclusion', + 'pattern' => '/\b(pada akhirnya|in conclusion|to summarize)\b/i', + 'label' => 'template-like conclusion phrase', + ), + array( + 'id' => 'meta_instruction_leak', + 'pattern' => '/\b(refined version|key refinements|changes made|rationale|could you please share)\b/i', + 'label' => 'instructional/meta leakage', + ), + array( + 'id' => 'dash_overuse', + 'pattern' => '/\s[—–-]\s/u', + 'label' => 'dash-heavy sentence style', + ), + ); + + $matches = array(); + $total = 0; + + foreach ( $rules as $rule ) { + if ( preg_match_all( $rule['pattern'], $normalized, $found, PREG_OFFSET_CAPTURE ) ) { + $total += count( $found[0] ); + if ( count( $matches ) < 5 ) { + foreach ( $found[0] as $entry ) { + if ( count( $matches ) >= 5 ) { + break; + } + $matched_text = (string) ( $entry[0] ?? '' ); + $offset = (int) ( $entry[1] ?? 0 ); + $context_start = max( 0, $offset - 48 ); + $context = function_exists( 'mb_substr' ) + ? mb_substr( $normalized, $context_start, 120 ) + : substr( $normalized, $context_start, 120 ); + $matches[] = array( + 'type' => $rule['label'], + 'match' => trim( $matched_text ), + 'context' => trim( $context ), + ); + } + } + } + } + + return array( + 'count' => (int) $total, + 'examples' => $matches, + ); + } + + /** + * Refine current post title based on user instruction. + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_refine_title( $request ) { + $params = $request->get_json_params(); + $post_id = isset( $params['postId'] ) ? (int) $params['postId'] : 0; + $instruction = sanitize_text_field( $params['instruction'] ?? '' ); + $session_id = sanitize_text_field( $params['sessionId'] ?? '' ); + + if ( $post_id <= 0 ) { + return new WP_Error( 'invalid_post', __( 'Invalid post ID.', 'wp-agentic-writer' ), array( 'status' => 400 ) ); + } + if ( ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( 'forbidden', __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), array( 'status' => 403 ) ); + } + if ( '' === $instruction ) { + return new WP_Error( 'missing_instruction', __( 'Title instruction is required.', 'wp-agentic-writer' ), array( 'status' => 400 ) ); + } + + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( 'post_not_found', __( 'Post not found.', 'wp-agentic-writer' ), array( 'status' => 404 ) ); + } + + $current_title = trim( wp_strip_all_tags( (string) $post->post_title ) ); + $post_config = $this->get_post_config( $post_id ); + $focus_keyword = trim( (string) ( $post_config['seo_focus_keyword'] ?? '' ) ); + + $system_prompt = "You are an expert SEO copy editor for article titles.\n" + . "Rewrite the title based on instruction.\n" + . "Return ONLY the final title text.\n" + . "No quotes. No explanation. No markdown."; + $user_prompt = "Current title: " . ( '' !== $current_title ? $current_title : '(empty)' ) . "\n" + . "Focus keyword: " . ( '' !== $focus_keyword ? $focus_keyword : '(not set)' ) . "\n" + . "Instruction: " . $instruction . "\n" + . "Constraints: keep it concise, natural, and publish-ready."; + + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'refinement' ); + $provider = $provider_result->provider; + $response = $provider->chat( + array( + array( 'role' => 'system', 'content' => $system_prompt ), + array( 'role' => 'user', 'content' => $user_prompt ), + ), + array( 'post_id' => $post_id ), + 'refinement' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $new_title = trim( wp_strip_all_tags( (string) ( $response['content'] ?? '' ) ) ); + $new_title = preg_replace( '/\s+/', ' ', $new_title ); + + if ( '' === $new_title ) { + return new WP_Error( 'empty_title', __( 'Refined title is empty.', 'wp-agentic-writer' ), array( 'status' => 500 ) ); + } + + wp_update_post( + array( + 'ID' => $post_id, + 'post_title' => $new_title, + ) + ); + + $this->track_ai_cost( + $post_id, + $response['model'] ?? '', + 'title_refinement', + $response['input_tokens'] ?? 0, + $response['output_tokens'] ?? 0, + $response['cost'] ?? 0, + $provider_result, + $session_id, + 'success' + ); + + return new WP_REST_Response( + array( + 'title' => $new_title, + 'cost' => $response['cost'] ?? 0, + 'provider_metadata' => $this->build_provider_metadata( + $provider_result, + $response['model'] ?? '' + ), + ), + 200 + ); + } + /** * Suggest relevant internal links based on content similarity. * @@ -6550,6 +7763,7 @@ Output format: public function handle_suggest_keywords( $request ) { $params = $request->get_json_params(); $post_id = $params['postId'] ?? 0; + $session_id = $this->resolve_or_create_session_id( $params['sessionId'] ?? '', $post_id ); $title = $params['title'] ?? ''; $sections = $params['sections'] ?? array(); @@ -6587,6 +7801,30 @@ Output format: return $result; } + // Persist SEO keyword suggestion summary to session history for future recall. + if ( ! empty( $session_id ) ) { + $reasoning = trim( (string) ( $result['reasoning'] ?? '' ) ); + $focus_keyword = (string) ( $result['focus_keyword'] ?? '' ); + $secondary_keywords = (array) ( $result['secondary_keywords'] ?? array() ); + $assistant_summary = "SEO Keywords Suggested:\n\n"; + $assistant_summary .= "Focus Keyword: {$focus_keyword}\n\n"; + $assistant_summary .= "Secondary Keywords: " . implode( ', ', $secondary_keywords ); + if ( '' !== $reasoning ) { + $assistant_summary .= "\n\n{$reasoning}"; + } + $assistant_summary .= "\n\nYou can review and edit these in the Config panel before writing."; + + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $context_service->add_message( + $session_id, + array( + 'role' => 'assistant', + 'content' => $assistant_summary, + 'timestamp' => current_time( 'c' ), + ) + ); + } + return new WP_REST_Response( array( 'focus_keyword' => $result['focus_keyword'], @@ -6613,6 +7851,7 @@ Output format: $params = $request->get_json_params(); $chat_history = $params['chatHistory'] ?? array(); $post_id = $params['postId'] ?? 0; + $session_id = $this->resolve_or_create_session_id( $params['sessionId'] ?? '', $post_id ); // Check post permission before using postId for cost tracking. if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { @@ -6623,6 +7862,14 @@ Output format: ); } + if ( ! empty( $session_id ) ) { + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $session_context = $context_service->get_context( $session_id, $post_id ); + if ( ! empty( $session_context['messages'] ) && is_array( $session_context['messages'] ) ) { + $chat_history = $session_context['messages']; + } + } + // Short history doesn't need summarization if ( empty( $chat_history ) || count( $chat_history ) < 4 ) { return new WP_REST_Response( @@ -6631,6 +7878,9 @@ Output format: 'use_full_history' => true, 'cost' => 0, 'tokens_saved' => 0, + 'session_id' => $session_id, + 'message_count' => is_array( $chat_history ) ? count( $chat_history ) : 0, + 'source_message_count' => is_array( $chat_history ) ? count( $chat_history ) : 0, ), 200 ); @@ -6687,6 +7937,21 @@ Conversation: $original_tokens = count( $chat_history ) * 500; // Rough estimate $summary_tokens = $response['output_tokens'] ?? 100; $tokens_saved = $original_tokens - $summary_tokens; + $summary = $response['content'] ?? ''; + + if ( ! empty( $session_id ) && '' !== trim( (string) $summary ) ) { + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $context_service->update_session_context( + $session_id, + array( + 'working_summary' => array( + 'text' => $summary, + 'updated_at' => current_time( 'c' ), + 'source_message_count' => count( $chat_history ), + ), + ) + ); + } // Track cost. $this->track_ai_cost( @@ -6697,16 +7962,19 @@ Conversation: $response['output_tokens'] ?? 0, $response['cost'] ?? 0, $provider_result, - '', + $session_id, 'success' ); return new WP_REST_Response( array( - 'summary' => $response['content'] ?? '', + 'summary' => $summary, 'use_full_history' => false, 'cost' => $response['cost'] ?? 0, 'tokens_saved' => $tokens_saved, + 'session_id' => $session_id, + 'message_count' => count( $chat_history ), + 'source_message_count' => count( $chat_history ), 'provider_metadata' => $this->build_provider_metadata( $provider_result, $response['model'] ?? '' @@ -8360,9 +9628,54 @@ Only suggest changes that would genuinely improve the reader's experience or sea ); } + $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. * @@ -8491,7 +9804,14 @@ Only suggest changes that would genuinely improve the reader's experience or sea ); } - $manager->update_messages( $session_id, $messages ); + $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 ) ), diff --git a/includes/class-local-backend-provider.php b/includes/class-local-backend-provider.php index e520557..13af5e7 100644 --- a/includes/class-local-backend-provider.php +++ b/includes/class-local-backend-provider.php @@ -342,32 +342,44 @@ class WP_Agentic_Writer_Local_Backend_Provider implements WP_Agentic_Writer_AI_P ); } - // Test /ping endpoint - $ping_response = wp_remote_get( - $this->base_url . '/ping', - array( - 'timeout' => 5, - 'sslverify' => false, - ) - ); - - if ( is_wp_error( $ping_response ) ) { - return new WP_Error( - 'ping_failed', - sprintf( - /* translators: %s: error message */ - __( 'Cannot reach proxy: %s. Is it running?', 'wp-agentic-writer' ), - $ping_response->get_error_message() + // Best-effort reachability checks. Do not hard-fail here; inference test below is authoritative. + $reachable = false; + $health_endpoints = array( '/ping', '/health', '/' ); + foreach ( $health_endpoints as $endpoint ) { + $health_response = wp_remote_get( + $this->base_url . $endpoint, + array( + 'timeout' => 5, + 'sslverify' => false, ) ); - } - $ping_body = wp_remote_retrieve_body( $ping_response ); - if ( 'pong' !== $ping_body ) { - return new WP_Error( - 'invalid_ping', - __( 'Proxy responded but with unexpected format', 'wp-agentic-writer' ) - ); + if ( is_wp_error( $health_response ) ) { + continue; + } + + $health_body = trim( (string) wp_remote_retrieve_body( $health_response ) ); + $health_code = (int) wp_remote_retrieve_response_code( $health_response ); + $health_json = json_decode( $health_body, true ); + + // Any 2xx indicates proxy process is reachable. + if ( $health_code >= 200 && $health_code < 300 ) { + $reachable = true; + } + + // Stronger signal for known proxy responses. + if ( strcasecmp( $health_body, 'pong' ) === 0 ) { + $reachable = true; + break; + } + if ( is_array( $health_json ) ) { + $ok_flag = $health_json['ok'] ?? $health_json['success'] ?? null; + $status = strtolower( (string) ( $health_json['status'] ?? '' ) ); + if ( true === $ok_flag || in_array( $status, array( 'ok', 'healthy', 'pong' ), true ) ) { + $reachable = true; + break; + } + } } // Test actual inference with simple prompt @@ -393,6 +405,17 @@ class WP_Agentic_Writer_Local_Backend_Provider implements WP_Agentic_Writer_AI_P ); if ( is_wp_error( $test_response ) ) { + // If both health and inference are unreachable, report connection issue. + if ( ! $reachable ) { + return new WP_Error( + 'ping_failed', + sprintf( + /* translators: %s: error message */ + __( 'Cannot reach proxy: %s. Is it running and reachable from this server?', 'wp-agentic-writer' ), + $test_response->get_error_message() + ) + ); + } return new WP_Error( 'inference_failed', sprintf( diff --git a/includes/class-openrouter-provider.php b/includes/class-openrouter-provider.php index 6c47107..f1682aa 100644 --- a/includes/class-openrouter-provider.php +++ b/includes/class-openrouter-provider.php @@ -514,6 +514,49 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov return __( 'Please go to Settings → Models and select a different model that is available on OpenRouter.', 'wp-agentic-writer' ); } + /** + * Build optional request-level OpenRouter provider routing preferences. + * + * This is intentionally settings-driven. BYOK users may pin a provider and + * disable fallbacks, but the plugin should not assume every OpenRouter model + * should use OpenAI, Anthropic, Azure, or any other provider. + * + * @since 0.2.3 + * @param array $options Request options. + * @return array Provider routing preferences. + */ + private function get_provider_routing_preferences( $options = array() ) { + if ( isset( $options['provider'] ) && is_array( $options['provider'] ) ) { + return $options['provider']; + } + + if ( array_key_exists( 'openrouter_provider_routing', $options ) && false === (bool) $options['openrouter_provider_routing'] ) { + return array(); + } + + $settings = get_option( 'wp_agentic_writer_settings', array() ); + $enabled = ! empty( $settings['openrouter_provider_routing_enabled'] ); + $provider_slug = isset( $settings['openrouter_provider_slug'] ) ? sanitize_key( $settings['openrouter_provider_slug'] ) : ''; + + if ( ! $enabled || '' === $provider_slug || 'auto' === $provider_slug ) { + return array(); + } + + $routing = array( + 'order' => array( $provider_slug ), + ); + + if ( ! empty( $settings['openrouter_provider_only'] ) ) { + $routing['only'] = array( $provider_slug ); + } + + if ( isset( $settings['openrouter_allow_provider_fallbacks'] ) ) { + $routing['allow_fallbacks'] = (bool) $settings['openrouter_allow_provider_fallbacks']; + } + + return $routing; + } + /** * Get singleton instance. * @@ -605,6 +648,10 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov 'include' => true, ), ); + $provider_routing = $this->get_provider_routing_preferences( $options ); + if ( ! empty( $provider_routing ) ) { + $body['provider'] = $provider_routing; + } // Add optional parameters. if ( isset( $options['max_tokens'] ) ) { @@ -752,6 +799,10 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov 'include' => true, ), ); + $provider_routing = $this->get_provider_routing_preferences( $options ); + if ( ! empty( $provider_routing ) ) { + $body['provider'] = $provider_routing; + } // Add optional parameters. if ( isset( $options['max_tokens'] ) ) { diff --git a/includes/class-provider-manager.php b/includes/class-provider-manager.php index fcc5e5a..758490f 100644 --- a/includes/class-provider-manager.php +++ b/includes/class-provider-manager.php @@ -43,6 +43,7 @@ class WP_Agentic_Writer_Provider_Manager { public static function get_provider_for_task( $type ) { $settings = get_option( 'wp_agentic_writer_settings', array() ); $task_providers = $settings['task_providers'] ?? array(); + $allow_openrouter_fallback = ! empty( $settings['allow_openrouter_fallback'] ); // Determine which provider to use for this task $requested_provider = $task_providers[ $type ] ?? 'openrouter'; @@ -58,11 +59,26 @@ class WP_Agentic_Writer_Provider_Manager { // Get provider instance with fallback logic $provider = self::get_provider_instance( $requested_provider, $type ); - // If provider not configured or unavailable, fallback to OpenRouter + $can_fallback_to_openrouter = ( 'openrouter' === $requested_provider ) || $allow_openrouter_fallback; + + // If provider not configured or unavailable. if ( ! $provider || ! $provider->is_configured() ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { - error_log( "Provider '{$requested_provider}' not available for task '{$type}', using OpenRouter fallback" ); + error_log( "Provider '{$requested_provider}' not available for task '{$type}'" ); } + + // Never silently spend OpenRouter credits when user selected another provider. + if ( ! $can_fallback_to_openrouter ) { + $warnings[] = "Provider '{$requested_provider}' unavailable. No automatic fallback was applied."; + return new WPAW_Provider_Selection_Result( + $provider, + $requested_provider, + $requested_provider, + false, + $warnings + ); + } + $warnings[] = "Provider '{$requested_provider}' unavailable, fell back to OpenRouter"; $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance(); $actual_provider = 'openrouter'; @@ -74,12 +90,16 @@ class WP_Agentic_Writer_Provider_Manager { $test_result = $provider->test_connection(); if ( is_wp_error( $test_result ) ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { - error_log( "Local Backend not reachable for task '{$type}', using OpenRouter fallback. Error: " . $test_result->get_error_message() ); + error_log( "Local Backend not reachable for task '{$type}'. Error: " . $test_result->get_error_message() ); + } + if ( $can_fallback_to_openrouter ) { + $warnings[] = "Local Backend not reachable, fell back to OpenRouter."; + $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance(); + $actual_provider = 'openrouter'; + $fallback_used = true; + } else { + $warnings[] = "Local Backend not reachable. No automatic fallback was applied."; } - $warnings[] = "Local Backend not reachable, fell back to OpenRouter"; - $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance(); - $actual_provider = 'openrouter'; - $fallback_used = true; } } diff --git a/includes/class-settings-v2.php b/includes/class-settings-v2.php index 6341c47..57fc819 100644 --- a/includes/class-settings-v2.php +++ b/includes/class-settings-v2.php @@ -640,6 +640,7 @@ class WP_Agentic_Writer_Settings_V2 { 'monthly' => '0.0000', 'today' => '0.0000', 'avg_per_post' => '0.0000', + 'action_summary' => array(), ), 'filters' => array( 'models' => array(), @@ -662,8 +663,8 @@ class WP_Agentic_Writer_Settings_V2 { $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 - $where = array( '1=1' ); + // 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 ); } @@ -697,7 +698,7 @@ class WP_Agentic_Writer_Settings_V2 { FROM {$table_name} WHERE {$where_clause} GROUP BY post_id - ORDER BY total_cost DESC + ORDER BY post_id DESC LIMIT %d OFFSET %d", $per_page, $offset @@ -737,25 +738,80 @@ class WP_Agentic_Writer_Settings_V2 { ); } - // Get details for visible posts only (on-demand loading). - // For better performance, we skip details here and let frontend request them. - // The details can be loaded via a separate endpoint when user expands a row. + // 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 ), + ); + } + + 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'] ); + } + } // Get summary stats (all-time aggregation in SQL) - $total_all_time = $wpdb->get_var( "SELECT COALESCE(SUM(cost), 0) FROM {$table_name}" ); - $monthly_total = $cost_tracker->get_monthly_total(); + $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 DATE(created_at) = %s", + "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 post_id > 0" ); + $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 + 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 ), + ); + } + // Get filter options (distinct values from DB) - $models = $wpdb->get_col( "SELECT DISTINCT model FROM {$table_name} ORDER BY model LIMIT 100" ); - $types = $wpdb->get_col( "SELECT DISTINCT action FROM {$table_name} ORDER BY action" ); + $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, @@ -768,6 +824,7 @@ class WP_Agentic_Writer_Settings_V2 { '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, @@ -1042,6 +1099,13 @@ class WP_Agentic_Writer_Settings_V2 { $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'; // Sanitize search options $sanitized['search_engine'] = in_array( $input['search_engine'] ?? '', array( 'auto', 'native', 'exa' ), true ) @@ -1178,6 +1242,11 @@ class WP_Agentic_Writer_Settings_V2 { $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'] ); // Get cost tracking data $cost_tracker = WP_Agentic_Writer_Cost_Tracker::get_instance(); @@ -1214,6 +1283,11 @@ class WP_Agentic_Writer_Settings_V2 { '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' ); } diff --git a/scripts/build-local-backend-zip.sh b/scripts/build-local-backend-zip.sh new file mode 100755 index 0000000..37b1656 --- /dev/null +++ b/scripts/build-local-backend-zip.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build a clean distributable ZIP for Local Backend package. +# +# Usage: +# ./scripts/build-local-backend-zip.sh +# ./scripts/build-local-backend-zip.sh /path/to/source /path/to/output.zip + +SOURCE_DIR="${1:-/Users/dwindown/Documents/agentic-writer-local-backend}" +OUTPUT_ZIP="${2:-/private/tmp/agentic-writer-local-backend.zip}" + +if [[ ! -d "$SOURCE_DIR" ]]; then + echo "Source directory not found: $SOURCE_DIR" >&2 + exit 1 +fi + +if ! command -v zip >/dev/null 2>&1; then + echo "'zip' command is required but not found." >&2 + exit 1 +fi + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +PKG_DIR="$TMP_DIR/agentic-writer-local-backend" +mkdir -p "$PKG_DIR" + +# Copy only distributable files/directories (exclude runtime/build/noise files). +rsync -a \ + --exclude '.git/' \ + --exclude '.github/' \ + --exclude '.claude/' \ + --exclude '.sixth/' \ + --exclude 'node_modules/' \ + --exclude '.env' \ + --exclude '.env.*' \ + --exclude '*.log' \ + --exclude 'logs/' \ + --exclude '.DS_Store' \ + --exclude '__MACOSX/' \ + --exclude '*.zip' \ + "$SOURCE_DIR/" "$PKG_DIR/" + +mkdir -p "$(dirname "$OUTPUT_ZIP")" +rm -f "$OUTPUT_ZIP" + +( + cd "$TMP_DIR" + zip -r "$OUTPUT_ZIP" "agentic-writer-local-backend" >/dev/null +) + +echo "Built package:" +echo " Source: $SOURCE_DIR" +echo " Output: $OUTPUT_ZIP" diff --git a/views/settings/tab-cost-log.php b/views/settings/tab-cost-log.php index e79a22c..3a44c99 100644 --- a/views/settings/tab-cost-log.php +++ b/views/settings/tab-cost-log.php @@ -48,10 +48,33 @@ if ( ! defined( 'ABSPATH' ) ) {
$0.0000
-
+
+ + +
+ +
+
+ + + + + + + + + + + + + + +
+
+
diff --git a/views/settings/tab-local-backend.php b/views/settings/tab-local-backend.php index 7f2ae9d..03c9aea 100644 --- a/views/settings/tab-local-backend.php +++ b/views/settings/tab-local-backend.php @@ -218,6 +218,18 @@ if ( ! defined( 'ABSPATH' ) ) { + +
+ /> + +
+ +
+
diff --git a/views/settings/tab-models.php b/views/settings/tab-models.php index a6de103..8ac7203 100644 --- a/views/settings/tab-models.php +++ b/views/settings/tab-models.php @@ -119,6 +119,66 @@ if ( ! function_exists( 'wpaw_get_provider_badge' ) ) {
+
+
+
+
+

+
+
+ > + +
+
+ +
+
+ + +
+
+
+
+ > + +
+
+
+
+
+ > + +
+
+
+
+
+
diff --git a/wp-agentic-writer.php b/wp-agentic-writer.php index 42776d8..c05c448 100644 --- a/wp-agentic-writer.php +++ b/wp-agentic-writer.php @@ -108,11 +108,6 @@ function wp_agentic_writer_init() { WP_Agentic_Writer_Admin_Columns::get_instance(); } - // Debug: Log plugin URL (only when SCRIPT_DEBUG is enabled). - if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) { - error_log( 'WP Agentic Writer URL: ' . WP_AGENTIC_WRITER_URL ); - error_log( 'WP Agentic Writer DIR: ' . WP_AGENTIC_WRITER_DIR ); - } } add_action( 'plugins_loaded', 'wp_agentic_writer_init' );