From 639ce106e165852de17a3cacb72eee63f4290497 Mon Sep 17 00:00:00 2001 From: artpi Date: Thu, 27 Nov 2025 10:36:02 +0100 Subject: [PATCH 1/5] Fix the pinned app --- src-chatbot/app/globals.css | 11 +++++++++++ src-chatbot/app/layout.tsx | 8 +++++--- src-chatbot/components/pwa-register.tsx | 18 ++++++++++++++++++ src-chatbot/public/manifest.json | 9 ++++++--- src-chatbot/public/sw.js | 11 +++++++++++ 5 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 src-chatbot/components/pwa-register.tsx create mode 100644 src-chatbot/public/sw.js diff --git a/src-chatbot/app/globals.css b/src-chatbot/app/globals.css index 3409b98..e3ba0d1 100644 --- a/src-chatbot/app/globals.css +++ b/src-chatbot/app/globals.css @@ -99,8 +99,19 @@ @apply border-border; } + html { + /* PWA safe area handling for iOS notch and home indicator */ + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + } + body { @apply bg-background text-foreground; + /* Ensure content respects safe areas */ + min-height: 100vh; + min-height: 100dvh; } } diff --git a/src-chatbot/app/layout.tsx b/src-chatbot/app/layout.tsx index 1ea4bcb..0b9cd27 100644 --- a/src-chatbot/app/layout.tsx +++ b/src-chatbot/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; import ClientOnly from '@/components/client-only'; +import { PWARegister } from '@/components/pwa-register'; // import { GeistSans } from 'geist/font/sans'; // Removed due to resolution issues import './globals.css'; @@ -13,7 +14,7 @@ export const metadata: Metadata = { applicationName: 'PersonalOS', appleWebApp: { capable: true, - statusBarStyle: 'default', + statusBarStyle: 'black-translucent', title: 'Personal Chat', // startupImage: [ // '/images/apple-touch-startup-image-768x1004.png', @@ -32,7 +33,7 @@ export const metadata: Metadata = { { url: '/wp-content/plugins/personalos/build/chatbot/images/apple-icon.png', sizes: '180x180', type: 'image/png' }, ], }, -// manifest: '/wp-content/plugins/personalos/build/chatbot/manifest.json', + manifest: '/wp-content/plugins/personalos/build/chatbot/manifest.json', }; export const viewport = { @@ -90,7 +91,7 @@ export default async function RootLayout({ > - + @@ -102,6 +103,7 @@ export default async function RootLayout({ /> + {children} diff --git a/src-chatbot/components/pwa-register.tsx b/src-chatbot/components/pwa-register.tsx new file mode 100644 index 0000000..d7d5799 --- /dev/null +++ b/src-chatbot/components/pwa-register.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { useEffect } from 'react'; + +export function PWARegister() { + useEffect(() => { + if ('serviceWorker' in navigator) { + navigator.serviceWorker + .register('/wp-content/plugins/personalos/build/chatbot/sw.js') + .catch((error) => { + console.log('Service worker registration failed:', error); + }); + } + }, []); + + return null; +} + diff --git a/src-chatbot/public/manifest.json b/src-chatbot/public/manifest.json index 1fa8fad..b372b6f 100644 --- a/src-chatbot/public/manifest.json +++ b/src-chatbot/public/manifest.json @@ -2,16 +2,19 @@ "name": "Personal Chat", "short_name": "PersonalOS", "description": "Personal Chat Assistant", - "start_url": "/", + "start_url": "/wp-admin/admin.php?page=personalos-chatbot", + "id": "/wp-admin/admin.php?page=personalos-chatbot", "display": "standalone", + "display_override": ["standalone", "minimal-ui"], + "orientation": "portrait", "background_color": "#ffffff", "theme_color": "#000000", "icons": [ { - "src": "/images/apple-icon.png", + "src": "/wp-content/plugins/personalos/build/chatbot/images/apple-icon.png", "sizes": "180x180", "type": "image/png", "purpose": "any maskable" } ] -} \ No newline at end of file +} diff --git a/src-chatbot/public/sw.js b/src-chatbot/public/sw.js new file mode 100644 index 0000000..e923542 --- /dev/null +++ b/src-chatbot/public/sw.js @@ -0,0 +1,11 @@ +// Service Worker for PersonalOS PWA +// Minimal service worker - just enough to enable PWA installation + +self.addEventListener('install', () => { + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(clients.claim()); +}); + From 85cac057756abe5215038aad1776a1a7603989dc Mon Sep 17 00:00:00 2001 From: artpi Date: Thu, 27 Nov 2025 17:51:19 +0100 Subject: [PATCH 2/5] Multiple fixes to the chat page --- modules/openai/chat-page.php | 37 ++++++++++++++++++++----- src-chatbot/app/globals.css | 25 +++++++++++++---- src-chatbot/components/markdown.tsx | 30 +++++++++----------- src-chatbot/components/pwa-register.tsx | 1 + src-chatbot/public/manifest.json | 2 +- 5 files changed, 64 insertions(+), 31 deletions(-) diff --git a/modules/openai/chat-page.php b/modules/openai/chat-page.php index 50a94e1..eeb6bbc 100644 --- a/modules/openai/chat-page.php +++ b/modules/openai/chat-page.php @@ -22,6 +22,33 @@ function personalos_map_notebook_to_para_item( $notebook ) { ); } +/** + * Get messages from a post and parse them into UIMessage format + * + * @param int $post_id The post ID to retrieve messages from. + * @return array Parsed messages. + */ +/** + * Fix corrupted newlines in message content. + * WordPress's stripslashes corrupts \n in JSON to just 'n'. + * This function restores them by detecting patterns like 'nn' before capitals and 'n-' for list items. + * + * @param string $content The potentially corrupted content. + * @return string Content with newlines restored. + */ +function personalos_fix_corrupted_newlines( $content ) { + // Fix "nn" followed by capital letter (paragraph break before new section) + $content = preg_replace( '/nn(?=[A-Z])/', "\n\n", $content ); + // Fix "n- " (list item marker) + $content = str_replace( 'n- ', "\n- ", $content ); + // Fix "n#" (markdown headers) + $content = preg_replace( '/n(#{1,6}\s)/', "\n$1", $content ); + // Fix "n" followed by digit and period/parenthesis (numbered lists like "1." or "1)") + $content = preg_replace( '/n(\d+[.\)])\s/', "\n$1 ", $content ); + + return $content; +} + /** * Get messages from a post and parse them into UIMessage format * @@ -40,16 +67,12 @@ function personalos_get_messages_from_post( $post_id ) { foreach ( $blocks as $block ) { if ( $block['blockName'] === 'pos/ai-message' ) { $role = $block['attrs']['role'] ?? 'user'; - // Handle different content structures if necessary, for now assume string content in attrs or innerContent - // Gutenberg blocks usually store content in innerContent for HTML, but pos/ai-message might be different. - // Looking at save_backscroll implementation: - // $content_blocks[] = get_comment_delimited_block_content( ... 'content' => $content ... ) - // This usually implies attribute storage or innerHTML if it's a dynamic block saving. - // However, save_backscroll uses get_comment_delimited_block_content which suggests attributes serialization. - $content = $block['attrs']['content'] ?? ''; $id = $block['attrs']['id'] ?? 'generated_' . uniqid(); + // Fix corrupted newlines from WordPress stripslashes + $content = personalos_fix_corrupted_newlines( $content ); + $messages[] = array( 'id' => $id, 'role' => $role, diff --git a/src-chatbot/app/globals.css b/src-chatbot/app/globals.css index e3ba0d1..7487002 100644 --- a/src-chatbot/app/globals.css +++ b/src-chatbot/app/globals.css @@ -99,20 +99,33 @@ @apply border-border; } - html { + html, body { + overflow-x: hidden; + width: 100%; + max-width: 100vw; + } + + body { + @apply bg-background text-foreground; /* PWA safe area handling for iOS notch and home indicator */ padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); - } - - body { - @apply bg-background text-foreground; - /* Ensure content respects safe areas */ min-height: 100vh; min-height: 100dvh; } + + /* Ensure proper text wrapping everywhere */ + p, li, a, span, div { + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + } + + a { + word-break: break-all; + } } .skeleton { diff --git a/src-chatbot/components/markdown.tsx b/src-chatbot/components/markdown.tsx index 6e42887..04c4052 100644 --- a/src-chatbot/components/markdown.tsx +++ b/src-chatbot/components/markdown.tsx @@ -5,25 +5,12 @@ import remarkGfm from 'remark-gfm'; import { CodeBlock } from './code-block'; /** - * Decode HTML entities and normalize text for markdown rendering - * Handles cases where newlines are encoded as "n" or "nn" characters + * Decode HTML entities and fix newline encoding for markdown rendering. + * When text is saved/loaded from database, \n can become literal "n" characters. */ function decodeAndNormalizeText(text: string): string { let decoded = text; - // Remove HTML tags but preserve their content - decoded = decoded.replace(/<[^>]*>/g, ''); - - // Replace encoded newlines: "nn" = double newline, "n" = single newline - // Pattern: "nn" followed by "-" or space, or "n" followed by "-" or space - // Replace "nn" first (double newline) before single "n" to avoid double replacement - decoded = decoded.replace(/nn(?=\s*-)/g, '\n\n'); - decoded = decoded.replace(/n(?=\s*-)/g, '\n'); - - // Also handle cases where "nn" or "n" appears at end of line or before other whitespace - decoded = decoded.replace(/nn(?=\s)/g, '\n\n'); - decoded = decoded.replace(/(? "Nov 28):\n\nElectronics" + decoded = decoded.replace(/nn(?=[A-Z])/g, '\n\n'); + + // Fix "n- " (with space after) = list item marker + // This pattern is safe because "n- " with space is almost always a list marker + // Words like "amazon-prime" don't have a space after the hyphen + decoded = decoded.replace(/n- /g, '\n- '); + + return decoded; } const components: Partial = { @@ -136,7 +132,7 @@ const components: Partial = { const remarkPlugins = [remarkGfm]; const NonMemoizedMarkdown = ({ children }: { children: string }) => { - // Decode HTML entities and normalize the text before rendering + // Decode HTML entities and fix newline encoding before rendering const normalizedText = decodeAndNormalizeText(children); return ( diff --git a/src-chatbot/components/pwa-register.tsx b/src-chatbot/components/pwa-register.tsx index d7d5799..16aaa9d 100644 --- a/src-chatbot/components/pwa-register.tsx +++ b/src-chatbot/components/pwa-register.tsx @@ -16,3 +16,4 @@ export function PWARegister() { return null; } + diff --git a/src-chatbot/public/manifest.json b/src-chatbot/public/manifest.json index b372b6f..9b2312f 100644 --- a/src-chatbot/public/manifest.json +++ b/src-chatbot/public/manifest.json @@ -17,4 +17,4 @@ "purpose": "any maskable" } ] -} +} From db4632c2a62f20ffd935ca0f45139fdc70f423ec Mon Sep 17 00:00:00 2001 From: artpi Date: Thu, 27 Nov 2025 19:01:07 +0100 Subject: [PATCH 3/5] Fixes fixes --- modules/openai/chat-page.php | 24 --- modules/openai/class-openai-module.php | 35 +++- src-chatbot/components/markdown.tsx | 24 +-- src/openai/blocks/message/index.js | 6 +- tests/unit/OpenAIModuleTest.php | 236 +++++++++++++++++++++++++ 5 files changed, 283 insertions(+), 42 deletions(-) diff --git a/modules/openai/chat-page.php b/modules/openai/chat-page.php index eeb6bbc..dedecca 100644 --- a/modules/openai/chat-page.php +++ b/modules/openai/chat-page.php @@ -28,27 +28,6 @@ function personalos_map_notebook_to_para_item( $notebook ) { * @param int $post_id The post ID to retrieve messages from. * @return array Parsed messages. */ -/** - * Fix corrupted newlines in message content. - * WordPress's stripslashes corrupts \n in JSON to just 'n'. - * This function restores them by detecting patterns like 'nn' before capitals and 'n-' for list items. - * - * @param string $content The potentially corrupted content. - * @return string Content with newlines restored. - */ -function personalos_fix_corrupted_newlines( $content ) { - // Fix "nn" followed by capital letter (paragraph break before new section) - $content = preg_replace( '/nn(?=[A-Z])/', "\n\n", $content ); - // Fix "n- " (list item marker) - $content = str_replace( 'n- ', "\n- ", $content ); - // Fix "n#" (markdown headers) - $content = preg_replace( '/n(#{1,6}\s)/', "\n$1", $content ); - // Fix "n" followed by digit and period/parenthesis (numbered lists like "1." or "1)") - $content = preg_replace( '/n(\d+[.\)])\s/', "\n$1 ", $content ); - - return $content; -} - /** * Get messages from a post and parse them into UIMessage format * @@ -70,9 +49,6 @@ function personalos_get_messages_from_post( $post_id ) { $content = $block['attrs']['content'] ?? ''; $id = $block['attrs']['id'] ?? 'generated_' . uniqid(); - // Fix corrupted newlines from WordPress stripslashes - $content = personalos_fix_corrupted_newlines( $content ); - $messages[] = array( 'id' => $id, 'role' => $role, diff --git a/modules/openai/class-openai-module.php b/modules/openai/class-openai-module.php index 785b08c..bf92172 100644 --- a/modules/openai/class-openai-module.php +++ b/modules/openai/class-openai-module.php @@ -47,6 +47,9 @@ public function register() { $this->register_block( 'tool', array( 'render_callback' => array( $this, 'render_tool_block' ) ) ); $this->register_block( 'message', array() ); + // Protect newlines in ai-message blocks from WordPress stripslashes + add_filter( 'wp_insert_post_data', array( $this, 'preserve_ai_message_newlines' ), 10, 2 ); + require_once __DIR__ . '/chat-page.php'; $this->email_responder = new OpenAI_Email_Responder( $this ); @@ -1945,11 +1948,15 @@ public function save_backscroll( array $backscroll, array $search_args, bool $ap } // Create message block + // Pre-escape newlines: convert newline characters to literal \n sequence + // This survives WordPress's stripslashes during save, and JSON decode restores them + $escaped_content = str_replace( "\n", '\\n', $content ); + $escaped_content = str_replace( "\r", '\\r', $escaped_content ); $content_blocks[] = get_comment_delimited_block_content( 'pos/ai-message', array( 'role' => $role, - 'content' => $content, + 'content' => $escaped_content, 'id' => $message_id, ), '' @@ -1992,6 +1999,9 @@ public function save_backscroll( array $backscroll, array $search_args, bool $ap $post_data['post_content'] = implode( "\n\n", $content_blocks ); } + // Note: wp_slash is handled by preserve_ai_message_newlines filter + // to protect escaped newlines from wp_unslash + // Only update if there is content to update or we are not just appending empty content // But here we might want to update just to ensure touch? if ( isset( $post_data['post_content'] ) || isset( $post_data['post_title'] ) || ! $append ) { @@ -2001,6 +2011,7 @@ public function save_backscroll( array $backscroll, array $search_args, bool $ap } } else { // Create new + // Note: wp_slash is handled by preserve_ai_message_newlines filter $post_data['post_content'] = implode( "\n\n", $content_blocks ); // Ensure defaults for new post if ( empty( $post_data['post_title'] ) ) { @@ -2038,6 +2049,28 @@ public function save_backscroll( array $backscroll, array $search_args, bool $ap return $post_id; } + /** + * Preserve newlines in ai-message blocks when posts are saved. + * WordPress runs wp_unslash on post_content which strips backslashes, + * corrupting escaped newlines (\n → n). This filter adds wp_slash to counteract. + * + * @param array $data Post data to be saved. + * @param array $postarr Original post data array. + * @return array Modified post data with preserved newlines. + */ + public function preserve_ai_message_newlines( $data, $postarr ) { + // Only process posts that contain ai-message blocks + if ( empty( $data['post_content'] ) || strpos( $data['post_content'], 'pos/ai-message' ) === false ) { + return $data; + } + + // wp_slash adds backslashes that wp_unslash will later remove, + // preserving our escaped newlines in JSON block attributes + $data['post_content'] = wp_slash( $data['post_content'] ); + + return $data; + } + /** * Generate a title for a conversation using GPT-4o-mini * diff --git a/src-chatbot/components/markdown.tsx b/src-chatbot/components/markdown.tsx index 04c4052..942837d 100644 --- a/src-chatbot/components/markdown.tsx +++ b/src-chatbot/components/markdown.tsx @@ -5,12 +5,15 @@ import remarkGfm from 'remark-gfm'; import { CodeBlock } from './code-block'; /** - * Decode HTML entities and fix newline encoding for markdown rendering. - * When text is saved/loaded from database, \n can become literal "n" characters. + * Decode HTML entities and unescape newlines for markdown rendering. + * Content is stored with escaped \n to survive WordPress stripslashes. */ -function decodeAndNormalizeText(text: string): string { +function decodeAndUnescape(text: string): string { let decoded = text; + // Unescape newlines that were escaped for storage + decoded = decoded.replace(/\\n/g, '\n').replace(/\\r/g, '\r'); + // Decode common HTML entities decoded = decoded .replace(/ /g, ' ') @@ -28,15 +31,6 @@ function decodeAndNormalizeText(text: string): string { decoded = textarea.value; } - // Fix newline encoding: "nn" followed by capital letter = paragraph break - // e.g., "Nov 28):nnElectronics" -> "Nov 28):\n\nElectronics" - decoded = decoded.replace(/nn(?=[A-Z])/g, '\n\n'); - - // Fix "n- " (with space after) = list item marker - // This pattern is safe because "n- " with space is almost always a list marker - // Words like "amazon-prime" don't have a space after the hyphen - decoded = decoded.replace(/n- /g, '\n- '); - return decoded; } @@ -132,12 +126,12 @@ const components: Partial = { const remarkPlugins = [remarkGfm]; const NonMemoizedMarkdown = ({ children }: { children: string }) => { - // Decode HTML entities and fix newline encoding before rendering - const normalizedText = decodeAndNormalizeText(children); + // Decode HTML entities and unescape newlines before rendering + const decodedText = decodeAndUnescape(children); return ( - {normalizedText} + {decodedText} ); }; diff --git a/src/openai/blocks/message/index.js b/src/openai/blocks/message/index.js index 362053f..34c8e24 100644 --- a/src/openai/blocks/message/index.js +++ b/src/openai/blocks/message/index.js @@ -76,11 +76,13 @@ registerBlockType( metadata, { return ''; } try { - return converter.makeHtml( content ); + // Convert escaped newlines back to actual newlines for markdown rendering + const unescapedContent = content.replace( /\\n/g, '\n' ).replace( /\\r/g, '\r' ); + return converter.makeHtml( unescapedContent ); } catch ( error ) { console.warn( 'Markdown parsing error:', error ); // Fallback to plain text with basic formatting - return content.replace( /\n/g, '
' ); + return content.replace( /\\n/g, '
' ).replace( /\n/g, '
' ); } }, [ content ] ); diff --git a/tests/unit/OpenAIModuleTest.php b/tests/unit/OpenAIModuleTest.php index c9fd2d2..78f7dd1 100644 --- a/tests/unit/OpenAIModuleTest.php +++ b/tests/unit/OpenAIModuleTest.php @@ -205,6 +205,242 @@ public function test_save_backscroll_missing_post_name() { $this->assertStringContainsString( 'chat-', $post->post_name ); // Default name format } + /** + * Test that newlines in content are preserved after initial save + */ + public function test_save_backscroll_preserves_newlines_on_initial_save() { + $content_with_newlines = "Hello!\n\nThis is a test.\n\n### Header\n- Item 1\n- Item 2"; + $backscroll = array( + array( + 'role' => 'user', + 'content' => 'Test question', + 'id' => 'user-1', + ), + array( + 'role' => 'assistant', + 'content' => $content_with_newlines, + 'id' => 'assistant-1', + ), + ); + $config = array( 'name' => 'test-newlines-initial' ); + + $reflection = new ReflectionClass( $this->module ); + $method = $reflection->getMethod( 'save_backscroll' ); + $method->setAccessible( true ); + + $post_id = $method->invokeArgs( $this->module, array( $backscroll, $config ) ); + $post = get_post( $post_id ); + + // Parse blocks to get the content back + $blocks = parse_blocks( $post->post_content ); + $assistant_block = null; + foreach ( $blocks as $block ) { + if ( 'pos/ai-message' === $block['blockName'] && 'assistant' === ( $block['attrs']['role'] ?? '' ) ) { + $assistant_block = $block; + break; + } + } + + $this->assertNotNull( $assistant_block, 'Assistant block should exist' ); + $saved_content = $assistant_block['attrs']['content'] ?? ''; + + // The content should have escaped newlines (\n as literal characters) + $this->assertStringContainsString( '\n', $saved_content, 'Newlines should be escaped as \\n' ); + $this->assertStringNotContainsString( 'nn', $saved_content, 'Should not have corrupted nn' ); + } + + /** + * Test that newlines are preserved when appending messages to existing conversation + */ + public function test_save_backscroll_preserves_newlines_on_append() { + $first_content = "First response.\n\nWith newlines."; + $second_content = "Second response.\n\n### More content\n- List item"; + + $reflection = new ReflectionClass( $this->module ); + $method = $reflection->getMethod( 'save_backscroll' ); + $method->setAccessible( true ); + + // First save + $backscroll1 = array( + array( + 'role' => 'user', + 'content' => 'First question', + 'id' => 'user-1', + ), + array( + 'role' => 'assistant', + 'content' => $first_content, + 'id' => 'assistant-1', + ), + ); + $config = array( 'name' => 'test-newlines-append' ); + $post_id = $method->invokeArgs( $this->module, array( $backscroll1, $config ) ); + + // Append second turn + $backscroll2 = array( + array( + 'role' => 'user', + 'content' => 'Second question', + 'id' => 'user-2', + ), + array( + 'role' => 'assistant', + 'content' => $second_content, + 'id' => 'assistant-2', + ), + ); + $post_id2 = $method->invokeArgs( $this->module, array( $backscroll2, $config, true ) ); // append=true + + $this->assertEquals( $post_id, $post_id2, 'Should update same post' ); + + $post = get_post( $post_id ); + $blocks = parse_blocks( $post->post_content ); + + $assistant_blocks = array(); + foreach ( $blocks as $block ) { + if ( 'pos/ai-message' === $block['blockName'] && 'assistant' === ( $block['attrs']['role'] ?? '' ) ) { + $assistant_blocks[] = $block; + } + } + + $this->assertCount( 2, $assistant_blocks, 'Should have 2 assistant messages' ); + + // Check first message still has proper newlines + $first_saved = $assistant_blocks[0]['attrs']['content'] ?? ''; + $this->assertStringContainsString( '\n', $first_saved, 'First message should have escaped newlines' ); + $this->assertStringNotContainsString( 'nn', $first_saved, 'First message should not be corrupted' ); + + // Check second message has proper newlines + $second_saved = $assistant_blocks[1]['attrs']['content'] ?? ''; + $this->assertStringContainsString( '\n', $second_saved, 'Second message should have escaped newlines' ); + $this->assertStringNotContainsString( 'nn', $second_saved, 'Second message should not be corrupted' ); + } + + /** + * Test that multiple rounds of appending don't corrupt newlines + */ + public function test_save_backscroll_preserves_newlines_multiple_appends() { + $reflection = new ReflectionClass( $this->module ); + $method = $reflection->getMethod( 'save_backscroll' ); + $method->setAccessible( true ); + + $config = array( 'name' => 'test-newlines-multi-append' ); + + // Initial save + $backscroll = array( + array( + 'role' => 'user', + 'content' => 'Question 1', + 'id' => 'user-1', + ), + array( + 'role' => 'assistant', + 'content' => "Answer 1.\n\nWith newlines.", + 'id' => 'assistant-1', + ), + ); + $post_id = $method->invokeArgs( $this->module, array( $backscroll, $config ) ); + + // Append 3 more times + for ( $i = 2; $i <= 4; $i++ ) { + $append_backscroll = array( + array( + 'role' => 'user', + 'content' => "Question $i", + 'id' => "user-$i", + ), + array( + 'role' => 'assistant', + 'content' => "Answer $i.\n\n### Section\n- Item", + 'id' => "assistant-$i", + ), + ); + $method->invokeArgs( $this->module, array( $append_backscroll, $config, true ) ); + } + + $post = get_post( $post_id ); + $blocks = parse_blocks( $post->post_content ); + + $assistant_blocks = array(); + foreach ( $blocks as $block ) { + if ( 'pos/ai-message' === $block['blockName'] && 'assistant' === ( $block['attrs']['role'] ?? '' ) ) { + $assistant_blocks[] = $block; + } + } + + $this->assertCount( 4, $assistant_blocks, 'Should have 4 assistant messages after multiple appends' ); + + // Check ALL messages still have proper newlines (not corrupted) + foreach ( $assistant_blocks as $index => $block ) { + $content = $block['attrs']['content'] ?? ''; + $this->assertStringContainsString( '\n', $content, "Message $index should have escaped newlines" ); + $this->assertStringNotContainsString( 'nn', $content, "Message $index should not have corrupted 'nn'" ); + // Also check that we don't have double-escaped newlines + $this->assertStringNotContainsString( '\\\\n', $content, "Message $index should not have double-escaped newlines" ); + } + } + + /** + * Test that newlines survive a simulated Gutenberg editor save (wp_update_post directly) + * This simulates what happens when a user edits a chat in the block editor and saves. + */ + public function test_newlines_survive_gutenberg_editor_save() { + $content_with_newlines = "Response with newlines.\n\n### Header\n- Item"; + + $reflection = new ReflectionClass( $this->module ); + $method = $reflection->getMethod( 'save_backscroll' ); + $method->setAccessible( true ); + + // Initial save via save_backscroll + $backscroll = array( + array( + 'role' => 'user', + 'content' => 'Question', + 'id' => 'user-1', + ), + array( + 'role' => 'assistant', + 'content' => $content_with_newlines, + 'id' => 'assistant-1', + ), + ); + $config = array( 'name' => 'test-gutenberg-save' ); + $post_id = $method->invokeArgs( $this->module, array( $backscroll, $config ) ); + + // Get the current post content + $post = get_post( $post_id ); + $original_content = $post->post_content; + + // Verify it has escaped newlines + $this->assertStringContainsString( '\n', $original_content, 'Should have escaped newlines after initial save' ); + + // Simulate Gutenberg editor save - just re-save the same content via wp_update_post + // This is what happens when user clicks "Update" in the editor + wp_update_post( array( + 'ID' => $post_id, + 'post_content' => $original_content, + ) ); + + // Check if newlines survived + $post_after_edit = get_post( $post_id ); + $blocks = parse_blocks( $post_after_edit->post_content ); + + $assistant_block = null; + foreach ( $blocks as $block ) { + if ( 'pos/ai-message' === $block['blockName'] && 'assistant' === ( $block['attrs']['role'] ?? '' ) ) { + $assistant_block = $block; + break; + } + } + + $this->assertNotNull( $assistant_block, 'Assistant block should exist after editor save' ); + $saved_content = $assistant_block['attrs']['content'] ?? ''; + + // This is the key assertion - does the content survive? + $this->assertStringContainsString( '\n', $saved_content, 'Newlines should survive Gutenberg editor save' ); + $this->assertStringNotContainsString( 'nn', $saved_content, 'Should not have corrupted nn after editor save' ); + } + /** * Test save_backscroll error handling when notes module is not available */ From 883a719ab6a5a58b695bf8a1f8039f841c82a0d2 Mon Sep 17 00:00:00 2001 From: artpi Date: Thu, 27 Nov 2025 19:20:29 +0100 Subject: [PATCH 4/5] Message history much better --- modules/openai/chat-page.php | 11 ++++- modules/openai/class-openai-module.php | 40 +++------------- src-chatbot/components/markdown.tsx | 12 ++--- src/openai/blocks/message/block.json | 2 + src/openai/blocks/message/index.js | 18 ++++---- tests/unit/OpenAIModuleTest.php | 56 ++++++++++++++++------- tests/unit/OpenAIModuleVercelChatTest.php | 4 +- 7 files changed, 71 insertions(+), 72 deletions(-) diff --git a/modules/openai/chat-page.php b/modules/openai/chat-page.php index dedecca..f7f5b28 100644 --- a/modules/openai/chat-page.php +++ b/modules/openai/chat-page.php @@ -44,11 +44,18 @@ function personalos_get_messages_from_post( $post_id ) { $messages = array(); foreach ( $blocks as $block ) { - if ( $block['blockName'] === 'pos/ai-message' ) { + if ( 'pos/ai-message' === $block['blockName'] ) { $role = $block['attrs']['role'] ?? 'user'; - $content = $block['attrs']['content'] ?? ''; $id = $block['attrs']['id'] ?? 'generated_' . uniqid(); + // Content is stored in innerHTML as ... + $content = ''; + if ( ! empty( $block['innerHTML'] ) ) { + if ( preg_match( '/(.*?)<\/span>/s', $block['innerHTML'], $matches ) ) { + $content = html_entity_decode( $matches[1], ENT_QUOTES, 'UTF-8' ); + } + } + $messages[] = array( 'id' => $id, 'role' => $role, diff --git a/modules/openai/class-openai-module.php b/modules/openai/class-openai-module.php index bf92172..032a010 100644 --- a/modules/openai/class-openai-module.php +++ b/modules/openai/class-openai-module.php @@ -47,9 +47,6 @@ public function register() { $this->register_block( 'tool', array( 'render_callback' => array( $this, 'render_tool_block' ) ) ); $this->register_block( 'message', array() ); - // Protect newlines in ai-message blocks from WordPress stripslashes - add_filter( 'wp_insert_post_data', array( $this, 'preserve_ai_message_newlines' ), 10, 2 ); - require_once __DIR__ . '/chat-page.php'; $this->email_responder = new OpenAI_Email_Responder( $this ); @@ -1947,19 +1944,16 @@ public function save_backscroll( array $backscroll, array $search_args, bool $ap } } - // Create message block - // Pre-escape newlines: convert newline characters to literal \n sequence - // This survives WordPress's stripslashes during save, and JSON decode restores them - $escaped_content = str_replace( "\n", '\\n', $content ); - $escaped_content = str_replace( "\r", '\\r', $escaped_content ); + // Create message block with content in innerHTML (not attributes) + // This preserves newlines naturally without escaping hacks + $inner_html = '' . esc_html( $content ) . ''; $content_blocks[] = get_comment_delimited_block_content( 'pos/ai-message', array( - 'role' => $role, - 'content' => $escaped_content, - 'id' => $message_id, + 'role' => $role, + 'id' => $message_id, ), - '' + $inner_html ); } } @@ -2049,28 +2043,6 @@ public function save_backscroll( array $backscroll, array $search_args, bool $ap return $post_id; } - /** - * Preserve newlines in ai-message blocks when posts are saved. - * WordPress runs wp_unslash on post_content which strips backslashes, - * corrupting escaped newlines (\n → n). This filter adds wp_slash to counteract. - * - * @param array $data Post data to be saved. - * @param array $postarr Original post data array. - * @return array Modified post data with preserved newlines. - */ - public function preserve_ai_message_newlines( $data, $postarr ) { - // Only process posts that contain ai-message blocks - if ( empty( $data['post_content'] ) || strpos( $data['post_content'], 'pos/ai-message' ) === false ) { - return $data; - } - - // wp_slash adds backslashes that wp_unslash will later remove, - // preserving our escaped newlines in JSON block attributes - $data['post_content'] = wp_slash( $data['post_content'] ); - - return $data; - } - /** * Generate a title for a conversation using GPT-4o-mini * diff --git a/src-chatbot/components/markdown.tsx b/src-chatbot/components/markdown.tsx index 942837d..d4bb01d 100644 --- a/src-chatbot/components/markdown.tsx +++ b/src-chatbot/components/markdown.tsx @@ -5,15 +5,11 @@ import remarkGfm from 'remark-gfm'; import { CodeBlock } from './code-block'; /** - * Decode HTML entities and unescape newlines for markdown rendering. - * Content is stored with escaped \n to survive WordPress stripslashes. + * Decode HTML entities for markdown rendering. */ -function decodeAndUnescape(text: string): string { +function decodeHtmlEntities(text: string): string { let decoded = text; - // Unescape newlines that were escaped for storage - decoded = decoded.replace(/\\n/g, '\n').replace(/\\r/g, '\r'); - // Decode common HTML entities decoded = decoded .replace(/ /g, ' ') @@ -126,8 +122,8 @@ const components: Partial = { const remarkPlugins = [remarkGfm]; const NonMemoizedMarkdown = ({ children }: { children: string }) => { - // Decode HTML entities and unescape newlines before rendering - const decodedText = decodeAndUnescape(children); + // Decode HTML entities before rendering + const decodedText = decodeHtmlEntities(children); return ( diff --git a/src/openai/blocks/message/block.json b/src/openai/blocks/message/block.json index 272a117..c180d5d 100644 --- a/src/openai/blocks/message/block.json +++ b/src/openai/blocks/message/block.json @@ -11,6 +11,8 @@ "attributes": { "content": { "type": "string", + "source": "text", + "selector": ".ai-message-text", "default": "" }, "role": { diff --git a/src/openai/blocks/message/index.js b/src/openai/blocks/message/index.js index 34c8e24..81e55d0 100644 --- a/src/openai/blocks/message/index.js +++ b/src/openai/blocks/message/index.js @@ -71,22 +71,20 @@ registerBlockType( metadata, { }; // Convert markdown to HTML using showdown + // Content now comes with actual newlines (stored in innerHTML), no unescaping needed const htmlContent = useMemo( () => { if ( ! content ) { return ''; } try { - // Convert escaped newlines back to actual newlines for markdown rendering - const unescapedContent = content.replace( /\\n/g, '\n' ).replace( /\\r/g, '\r' ); - return converter.makeHtml( unescapedContent ); + return converter.makeHtml( content ); } catch ( error ) { console.warn( 'Markdown parsing error:', error ); // Fallback to plain text with basic formatting - return content.replace( /\\n/g, '
' ).replace( /\n/g, '
' ); + return content.replace( /\n/g, '
' ); } }, [ content ] ); - console.log( 'HTML', htmlContent ); return (
@@ -128,8 +126,10 @@ registerBlockType( metadata, {
); }, - save: () => { - // No frontend rendering - editor only - return null; + save: ( { attributes } ) => { + const { content } = attributes; + // Save content as innerHTML - WordPress will preserve newlines naturally + // The span.ai-message-text is the selector defined in block.json + return { content }; } -} ); \ No newline at end of file +} ); diff --git a/tests/unit/OpenAIModuleTest.php b/tests/unit/OpenAIModuleTest.php index 78f7dd1..d601be6 100644 --- a/tests/unit/OpenAIModuleTest.php +++ b/tests/unit/OpenAIModuleTest.php @@ -242,10 +242,16 @@ public function test_save_backscroll_preserves_newlines_on_initial_save() { } $this->assertNotNull( $assistant_block, 'Assistant block should exist' ); - $saved_content = $assistant_block['attrs']['content'] ?? ''; - - // The content should have escaped newlines (\n as literal characters) - $this->assertStringContainsString( '\n', $saved_content, 'Newlines should be escaped as \\n' ); + + // Content is now stored in innerHTML, extract it + $inner_html = $assistant_block['innerHTML'] ?? ''; + $this->assertStringContainsString( 'ai-message-text', $inner_html, 'Should have ai-message-text span' ); + + // Extract content and verify newlines are preserved + preg_match( '/(.*?)<\/span>/s', $inner_html, $matches ); + $saved_content = html_entity_decode( $matches[1] ?? '', ENT_QUOTES, 'UTF-8' ); + + $this->assertStringContainsString( "\n", $saved_content, 'Newlines should be preserved' ); $this->assertStringNotContainsString( 'nn', $saved_content, 'Should not have corrupted nn' ); } @@ -305,14 +311,21 @@ public function test_save_backscroll_preserves_newlines_on_append() { $this->assertCount( 2, $assistant_blocks, 'Should have 2 assistant messages' ); + // Helper to extract content from innerHTML + $extract_content = function( $block ) { + $inner_html = $block['innerHTML'] ?? ''; + preg_match( '/(.*?)<\/span>/s', $inner_html, $matches ); + return html_entity_decode( $matches[1] ?? '', ENT_QUOTES, 'UTF-8' ); + }; + // Check first message still has proper newlines - $first_saved = $assistant_blocks[0]['attrs']['content'] ?? ''; - $this->assertStringContainsString( '\n', $first_saved, 'First message should have escaped newlines' ); + $first_saved = $extract_content( $assistant_blocks[0] ); + $this->assertStringContainsString( "\n", $first_saved, 'First message should have newlines' ); $this->assertStringNotContainsString( 'nn', $first_saved, 'First message should not be corrupted' ); // Check second message has proper newlines - $second_saved = $assistant_blocks[1]['attrs']['content'] ?? ''; - $this->assertStringContainsString( '\n', $second_saved, 'Second message should have escaped newlines' ); + $second_saved = $extract_content( $assistant_blocks[1] ); + $this->assertStringContainsString( "\n", $second_saved, 'Second message should have newlines' ); $this->assertStringNotContainsString( 'nn', $second_saved, 'Second message should not be corrupted' ); } @@ -370,13 +383,18 @@ public function test_save_backscroll_preserves_newlines_multiple_appends() { $this->assertCount( 4, $assistant_blocks, 'Should have 4 assistant messages after multiple appends' ); + // Helper to extract content from innerHTML + $extract_content = function( $block ) { + $inner_html = $block['innerHTML'] ?? ''; + preg_match( '/(.*?)<\/span>/s', $inner_html, $matches ); + return html_entity_decode( $matches[1] ?? '', ENT_QUOTES, 'UTF-8' ); + }; + // Check ALL messages still have proper newlines (not corrupted) foreach ( $assistant_blocks as $index => $block ) { - $content = $block['attrs']['content'] ?? ''; - $this->assertStringContainsString( '\n', $content, "Message $index should have escaped newlines" ); + $content = $extract_content( $block ); + $this->assertStringContainsString( "\n", $content, "Message $index should have newlines" ); $this->assertStringNotContainsString( 'nn', $content, "Message $index should not have corrupted 'nn'" ); - // Also check that we don't have double-escaped newlines - $this->assertStringNotContainsString( '\\\\n', $content, "Message $index should not have double-escaped newlines" ); } } @@ -411,8 +429,8 @@ public function test_newlines_survive_gutenberg_editor_save() { $post = get_post( $post_id ); $original_content = $post->post_content; - // Verify it has escaped newlines - $this->assertStringContainsString( '\n', $original_content, 'Should have escaped newlines after initial save' ); + // Verify it has the innerHTML structure + $this->assertStringContainsString( 'ai-message-text', $original_content, 'Should have ai-message-text span' ); // Simulate Gutenberg editor save - just re-save the same content via wp_update_post // This is what happens when user clicks "Update" in the editor @@ -434,10 +452,14 @@ public function test_newlines_survive_gutenberg_editor_save() { } $this->assertNotNull( $assistant_block, 'Assistant block should exist after editor save' ); - $saved_content = $assistant_block['attrs']['content'] ?? ''; - // This is the key assertion - does the content survive? - $this->assertStringContainsString( '\n', $saved_content, 'Newlines should survive Gutenberg editor save' ); + // Extract content from innerHTML + $inner_html = $assistant_block['innerHTML'] ?? ''; + preg_match( '/(.*?)<\/span>/s', $inner_html, $matches ); + $saved_content = html_entity_decode( $matches[1] ?? '', ENT_QUOTES, 'UTF-8' ); + + // With innerHTML storage, newlines are preserved naturally - no escaping needed + $this->assertStringContainsString( "\n", $saved_content, 'Newlines should survive Gutenberg editor save' ); $this->assertStringNotContainsString( 'nn', $saved_content, 'Should not have corrupted nn after editor save' ); } diff --git a/tests/unit/OpenAIModuleVercelChatTest.php b/tests/unit/OpenAIModuleVercelChatTest.php index 64f2a0a..b8654c8 100644 --- a/tests/unit/OpenAIModuleVercelChatTest.php +++ b/tests/unit/OpenAIModuleVercelChatTest.php @@ -82,8 +82,8 @@ public function test_save_backscroll_append() { $this->assertStringContainsString( 'Hello', $post_updated->post_content ); $this->assertStringContainsString( 'Hi there', $post_updated->post_content ); - // Check structure: should have 2 block delimiters - $this->assertEquals( 2, substr_count( $post_updated->post_content, 'wp:pos/ai-message' ) ); + // Check structure: should have 2 blocks (count opening tags only, not closing) + $this->assertEquals( 2, substr_count( $post_updated->post_content, '