From 7709d0d5fc4d0df12c45cdc6f88624e88c291928 Mon Sep 17 00:00:00 2001 From: artpi Date: Fri, 30 May 2025 20:57:21 +0200 Subject: [PATCH 1/8] new block representing the messages --- modules/openai/class-openai-module.php | 79 ++++++++- src/openai/blocks/message/block.json | 33 ++++ src/openai/blocks/message/index.css | 235 +++++++++++++++++++++++++ src/openai/blocks/message/index.js | 78 ++++++++ 4 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 src/openai/blocks/message/block.json create mode 100644 src/openai/blocks/message/index.css create mode 100644 src/openai/blocks/message/index.js diff --git a/modules/openai/class-openai-module.php b/modules/openai/class-openai-module.php index 0b4e66f..09790b0 100644 --- a/modules/openai/class-openai-module.php +++ b/modules/openai/class-openai-module.php @@ -35,7 +35,10 @@ public function register() { add_action( 'admin_menu', array( $this, 'admin_menu' ) ); add_filter( 'pos_openai_tools', array( $this, 'register_openai_tools' ) ); $this->register_cli_command( 'tool', 'cli_openai_tool' ); + $this->register_block( 'tool', array( 'render_callback' => array( $this, 'render_tool_block' ) ) ); + $this->register_block( 'message', array() ); + require_once __DIR__ . '/chat-page.php'; } @@ -901,6 +904,79 @@ public function api_call( $url, $data ) { return json_decode( $body ); } + private function save_backscroll( array $backscroll, string $id ) { + $notes_module = POS::get_module_by_id( 'notes' ); + if ( ! $notes_module ) { + return; + } + + // Create content from backscroll messages + $content_blocks = array(); + foreach ( $backscroll as $message ) { + if ( is_object( $message ) ) { + $message = (array) $message; + } + + if ( ! isset( $message['role'] ) ) { + continue; + } + + $role = $message['role']; + $content = $message['content'] ?? ''; + + if ( in_array( $role, array( 'user', 'assistant' ), true ) ) { + // Create message block + $content_blocks[] = get_comment_delimited_block_content( + 'pos/ai-message', + array( + 'role' => $role, + 'content' => $content, + 'id' => $message['id'] ?? '', + ), + '' + ); + } + } + + // Prepare post data + $post_data = array( + 'post_title' => 'Chat ' . gmdate( 'Y-m-d H:i:s' ), + 'post_type' => $notes_module->id, + 'post_name' => $id, + 'post_content' => implode( "\n\n", $content_blocks ), + 'post_status' => 'private', + ); + + $existing_posts = get_posts( + array( + 'post_type' => $notes_module->id, + 'posts_per_page' => 1, + 'name' => $id, + ) + ); + error_log( 'Existing posts: ' . print_r( $existing_posts, true ) ); + // Create or update post + if ( ! empty( $existing_posts ) ) { + $post_data['ID'] = $existing_posts[0]->ID; + wp_update_post( $post_data ); + } else { + $post_id = wp_insert_post( $post_data ); + + // Add to OpenAI notebook + $openai_notebook = get_term_by( 'slug', 'openai-chats', 'notebook' ); + if ( ! $openai_notebook ) { + $term_result = wp_insert_term( 'OpenAI Chats', 'notebook', array( 'slug' => 'openai-chats' ) ); + if ( ! is_wp_error( $term_result ) ) { + $openai_notebook = get_term( $term_result['term_id'], 'notebook' ); + } + } + + if ( $openai_notebook ) { + wp_set_object_terms( $post_id, array( $openai_notebook->term_id ), 'notebook' ); + } + } + } + public function vercel_chat( WP_REST_Request $request ) { $params = $request->get_json_params(); @@ -967,7 +1043,8 @@ public function vercel_chat( WP_REST_Request $request ) { $vercel_sdk->sendToolCall( $data->id, $data->function->name, json_decode( $data->function->arguments, true ) ); } } ); - set_transient( 'vercel_chat_' . $params['id'], $openai_messages, 60 * 60 ); + set_transient( 'vercel_chat_' . $params['id'], $response, 60 * 60 ); + $this->save_backscroll( $response, $params['id'] ); // $vercel_sdk->sendText( $response->choices[0]->message->content ); $vercel_sdk->finishStep( 'stop', array( 'promptTokens' => 0, 'completionTokens' => 0 ), false ); diff --git a/src/openai/blocks/message/block.json b/src/openai/blocks/message/block.json new file mode 100644 index 0000000..272a117 --- /dev/null +++ b/src/openai/blocks/message/block.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "pos/ai-message", + "version": "1.0.0", + "title": "AI Message", + "category": "personalos", + "description": "AI message representing a step in the AI chat conversation.", + "keywords": [], + "textdomain": "personalos", + "attributes": { + "content": { + "type": "string", + "default": "" + }, + "role": { + "type": "string", + "default": "user", + "enum": [ "user", "assistant", "system" ] + }, + "id": { + "type": "string", + "default": "" + } + }, + "supports": { + "html": false, + "customClassName": false, + "className": true + }, + "editorScript": "file:./index.js", + "editorStyle": "file:./index.css" +} diff --git a/src/openai/blocks/message/index.css b/src/openai/blocks/message/index.css new file mode 100644 index 0000000..1de767a --- /dev/null +++ b/src/openai/blocks/message/index.css @@ -0,0 +1,235 @@ +/** + * Chat-style editor styles for the AI Message block + */ + +.wp-block-pos-ai-message { + margin: 16px 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + max-width: none; +} + +.wp-block-pos-ai-message .openai-message-block { + display: flex; + flex-direction: column; + gap: 8px; + padding: 0; + background: transparent; + border: none; + border-radius: 0; +} + +.wp-block-pos-ai-message .message-header { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + color: #6b7280; + margin-bottom: 4px; +} + +.wp-block-pos-ai-message .message-role-icon { + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + flex-shrink: 0; +} + +.wp-block-pos-ai-message .message-role-label { + text-transform: capitalize; + font-weight: 600; +} + +.wp-block-pos-ai-message .message-id { + font-size: 11px; + color: #9ca3af; + background: #f3f4f6; + padding: 2px 6px; + border-radius: 10px; + font-family: monospace; + margin-left: auto; +} + +.wp-block-pos-ai-message .message-content { + background: #f1f5f9; + border-radius: 18px; + padding: 12px 16px; + margin: 0; + border: 1px solid #e2e8f0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + line-height: 1.5; + font-size: 14px; + color: #1f2937; + min-height: 20px; + transition: all 0.2s ease; + position: relative; +} + +.wp-block-pos-ai-message .message-content:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1), 0 1px 3px rgba(0, 0, 0, 0.1); + background: #ffffff; +} + +.wp-block-pos-ai-message .message-content:empty::before { + content: "Type your message..."; + color: #9ca3af; + font-style: italic; +} + +/* User message styling */ +.wp-block-pos-ai-message.message-role-user .message-header { + color: #3b82f6; +} + +.wp-block-pos-ai-message.message-role-user .message-role-icon { + background: #3b82f6; + color: white; +} + +.wp-block-pos-ai-message.message-role-user .message-content { + background: #3b82f6; + color: white; + border-color: #2563eb; + margin-left: 32px; +} + +.wp-block-pos-ai-message.message-role-user .message-content:focus { + background: #2563eb; + border-color: #1d4ed8; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2), 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.wp-block-pos-ai-message.message-role-user .message-content:empty::before { + color: rgba(255, 255, 255, 0.7); +} + +/* Assistant message styling */ +.wp-block-pos-ai-message.message-role-assistant .message-header { + color: #10b981; +} + +.wp-block-pos-ai-message.message-role-assistant .message-role-icon { + background: #10b981; + color: white; +} + +.wp-block-pos-ai-message.message-role-assistant .message-content { + background: #f0f9ff; + border-color: #bae6fd; + color: #0c4a6e; +} + +.wp-block-pos-ai-message.message-role-assistant .message-content:focus { + background: #e0f2fe; + border-color: #0ea5e9; + box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.1), 0 1px 3px rgba(0, 0, 0, 0.1); +} + +/* System message styling */ +.wp-block-pos-ai-message.message-role-system .message-header { + color: #f59e0b; +} + +.wp-block-pos-ai-message.message-role-system .message-role-icon { + background: #f59e0b; + color: white; +} + +.wp-block-pos-ai-message.message-role-system .message-content { + background: #fffbeb; + border-color: #fde68a; + color: #92400e; + border-left: 3px solid #f59e0b; +} + +.wp-block-pos-ai-message.message-role-system .message-content:focus { + background: #fef3c7; + border-color: #f59e0b; + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.1), 0 1px 3px rgba(0, 0, 0, 0.1); +} + +/* Hover effects */ +.wp-block-pos-ai-message:hover .message-content { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.wp-block-pos-ai-message.message-role-user:hover .message-content { + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); +} + +.wp-block-pos-ai-message.message-role-assistant:hover .message-content { + box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3); +} + +.wp-block-pos-ai-message.message-role-system:hover .message-content { + box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3); +} + +/* Selected state */ +.wp-block-pos-ai-message.is-selected .message-content { + transform: translateY(-1px); +} + +.wp-block-pos-ai-message.is-selected.message-role-user .message-content { + box-shadow: 0 0 0 2px #3b82f6, 0 4px 12px rgba(59, 130, 246, 0.4); +} + +.wp-block-pos-ai-message.is-selected.message-role-assistant .message-content { + box-shadow: 0 0 0 2px #10b981, 0 4px 12px rgba(16, 185, 129, 0.4); +} + +.wp-block-pos-ai-message.is-selected.message-role-system .message-content { + box-shadow: 0 0 0 2px #f59e0b, 0 4px 12px rgba(245, 158, 11, 0.4); +} + +/* Rich text formatting */ +.wp-block-pos-ai-message .message-content strong { + font-weight: 600; +} + +.wp-block-pos-ai-message .message-content em { + font-style: italic; +} + +.wp-block-pos-ai-message .message-content code { + background: rgba(0, 0, 0, 0.1); + padding: 2px 4px; + border-radius: 4px; + font-family: 'Monaco', 'Courier New', monospace; + font-size: 13px; +} + +.wp-block-pos-ai-message.message-role-user .message-content code { + background: rgba(255, 255, 255, 0.2); +} + +.wp-block-pos-ai-message.message-role-assistant .message-content code { + background: rgba(14, 165, 233, 0.1); +} + +.wp-block-pos-ai-message.message-role-system .message-content code { + background: rgba(245, 158, 11, 0.1); +} + +/* Mobile responsiveness */ +@media (max-width: 768px) { + .wp-block-pos-ai-message { + margin: 12px 0; + } + + .wp-block-pos-ai-message .message-content { + padding: 10px 14px; + font-size: 13px; + } + + .wp-block-pos-ai-message.message-role-user .message-content { + margin-left: 24px; + } +} \ No newline at end of file diff --git a/src/openai/blocks/message/index.js b/src/openai/blocks/message/index.js new file mode 100644 index 0000000..4418eb2 --- /dev/null +++ b/src/openai/blocks/message/index.js @@ -0,0 +1,78 @@ +import './index.css'; +import { registerBlockType } from '@wordpress/blocks'; +import { + InspectorControls, + useBlockProps, + RichText +} from '@wordpress/block-editor'; +import { + PanelBody, + SelectControl, + TextControl +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import metadata from './block.json'; + +// Register the message block +registerBlockType( metadata, { + edit: ( { attributes, setAttributes } ) => { + const { content, role, id } = attributes; + const blockProps = useBlockProps( { + className: `message-role-${ role }` + } ); + + const roleOptions = [ + { label: __( 'User', 'personalos' ), value: 'user' }, + { label: __( 'Assistant', 'personalos' ), value: 'assistant' }, + { label: __( 'System', 'personalos' ), value: 'system' } + ]; + + const roleIcons = { + user: '👤', + assistant: '🤖', + system: '⚙️' + }; + + return ( +
+ + + setAttributes( { role: newRole } ) } + /> + setAttributes( { id: newId } ) } + help={ __( 'Optional unique identifier for this message', 'personalos' ) } + /> + + + +
+
+ { roleIcons[ role ] } + { role } + { id && ID: { id } } +
+
+ setAttributes( { content: newContent } ) } + placeholder={ __( 'Enter message content...', 'personalos' ) } + allowedFormats={ [ 'core/bold', 'core/italic', 'core/code' ] } + /> +
+
+
+ ); + }, + save: () => { + // No frontend rendering - editor only + return null; + } +} ); \ No newline at end of file From 9f7e3640a6d9b6b826992af778e3225e0172661d Mon Sep 17 00:00:00 2001 From: artpi Date: Fri, 30 May 2025 20:57:42 +0200 Subject: [PATCH 2/8] represent in app --- src-chatbot/app/(chat)/page.tsx | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src-chatbot/app/(chat)/page.tsx b/src-chatbot/app/(chat)/page.tsx index 2ae21a2..b73bb46 100644 --- a/src-chatbot/app/(chat)/page.tsx +++ b/src-chatbot/app/(chat)/page.tsx @@ -1,9 +1,12 @@ // import { cookies } from 'next/headers'; // Removed for static export: caused "used headers" error +'use client'; + import { Chat } from '@/components/chat'; import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models'; import { generateUUID } from '@/lib/utils'; import { DataStreamHandler } from '@/components/data-stream-handler'; +import { useEffect, useState } from 'react'; // import { auth } from '../(auth)/auth'; // auth() call disabled for static export // import { redirect } from 'next/navigation'; // Redirect disabled for static export // import type { Session } from 'next-auth'; // Removed as next-auth is uninstalled @@ -26,7 +29,15 @@ interface MockSession { expires: string; } -export default async function Page() { +export default function Page() { + // Generate unique ID on client-side to avoid static export issue + const [id, setId] = useState(''); + + useEffect(() => { + // Generate a unique ID after component mounts on client-side + setId(generateUUID()); + }, []); + // const sessionFromAuth = await auth(); // auth() call disabled for static export console.warn('auth() call in app/(chat)/page.tsx disabled. Using mock session.'); @@ -47,13 +58,22 @@ export default async function Page() { // redirect('/api/auth/guest'); // } - const id = generateUUID(); + console.log( 'id: ' + id ); // For static export, cookie reading is disabled. Always use default model. // const cookieStore = await cookies(); // Call to cookies() disabled // const modelIdFromCookie = cookieStore.get('chat-model'); console.warn('Cookie reading in app/(chat)/page.tsx disabled for static export. Using default chat model.'); + // Show loading state until ID is generated + if (!id) { + return ( +
+
Loading...
+
+ ); + } + return ( <> Date: Sat, 31 May 2025 09:38:33 +0200 Subject: [PATCH 3/8] Stuff now works with message history --- modules/openai/class-ollama.php | 82 ++++++- src-chatbot/components/sidebar-history.tsx | 263 ++++++++++++--------- 2 files changed, 227 insertions(+), 118 deletions(-) diff --git a/modules/openai/class-ollama.php b/modules/openai/class-ollama.php index 512cd4b..560a6ae 100644 --- a/modules/openai/class-ollama.php +++ b/modules/openai/class-ollama.php @@ -45,10 +45,10 @@ class POS_Ollama_Server { public function __construct( $module ) { $this->module = $module; $token = $this->module->get_setting( 'ollama_auth_token' ); - $this->module->settings[ 'ollama_auth_token' ] = array( + $this->module->settings['ollama_auth_token'] = array( 'type' => 'text', 'name' => 'Token for authorizing OLLAMA mock API.', - 'label' => strlen( $token ) < 3 ? 'Set a token to enable Ollama-compatible API for external clients' : 'OLLAMA Api accessible at here', + 'label' => strlen( $token ) < 3 ? 'Set a token to enable Ollama-compatible API for external clients' : 'OLLAMA Api accessible at here', 'default' => '0', ); if ( strlen( $token ) >= 3 ) { @@ -312,6 +312,24 @@ public function get_version( WP_REST_Request $request ): WP_REST_Response { ); } + private function calculate_rolling_hash( $messages ) { + $hash = ''; + $last_assistant_index = -1; + foreach ( $messages as $index => $message ) { + $message = (array) $message; + if ( in_array( $message['role'], array( 'assistant', 'system' ), true ) ) { + $last_assistant_index = $index; + } + } + foreach ( $messages as $index => $message ) { + $message = (array) $message; + if ( ( $index <= $last_assistant_index || $last_assistant_index === -1 ) && in_array( $message['role'], array( 'user', 'assistant' ), true ) ) { + $hash .= "\n\n" . trim( $message['content'] ); + } + } + return hash( 'sha256', trim( $hash ) ); + } + /** * POST /api/chat - Chat endpoint. * @@ -345,7 +363,67 @@ function( $message ) { return $message['role'] !== 'system'; } ); + + $hash = $this->calculate_rolling_hash( $messages ); + + // Get post with ollama-hash meta equal to $hash, or create one if it doesn't exist + $existing_posts = get_posts( + array( + 'post_type' => 'notes', + 'post_status' => 'private', + 'numberposts' => 1, + 'meta_query' => array( + array( + 'key' => 'ollama-hash', + 'value' => $hash, + ), + ), + ) + ); + error_log( 'existing_posts: ' . print_r( $existing_posts, true ) . ' hash: [' . $hash . ']' ); + + if ( ! empty( $existing_posts ) ) { + $post_id = $existing_posts[0]->ID; + } else { + // Create new post with ollama-hash meta + $post_id = wp_insert_post( + array( + 'post_title' => 'Ollama Chat ' . gmdate( 'Y-m-d H:i:s' ), + 'post_status' => 'private', + 'post_type' => 'notes', + ) + ); + } + $result = $this->module->complete_backscroll( $non_system_messages ); + + $content_blocks = array(); + foreach ( $result as $message ) { + $message = (array) $message; + if ( in_array( $message['role'], array( 'user', 'assistant' ), true ) ) { + // Create message block + $content_blocks[] = get_comment_delimited_block_content( + 'pos/ai-message', + array( + 'role' => $message['role'], + 'content' => $message['content'], + 'id' => $message['id'] ?? '', + ), + '' + ); + } + } + + wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => implode( "\n\n", $content_blocks ), + 'meta_input' => array( + 'ollama-hash' => $this->calculate_rolling_hash( $result ), + ), + ) + ); + $last_message = (array) end( $result ); $answer = $last_message['content'] ?? 'Hello from PersonalOS Mock Ollama!'; // $answer = 'Echo: ' . json_encode( $data ); //$content; diff --git a/src-chatbot/components/sidebar-history.tsx b/src-chatbot/components/sidebar-history.tsx index e08f0c3..54f3887 100644 --- a/src-chatbot/components/sidebar-history.tsx +++ b/src-chatbot/components/sidebar-history.tsx @@ -1,21 +1,6 @@ 'use client'; -// import { isToday, isYesterday, subMonths, subWeeks } from 'date-fns'; // Not used in active code -// import { useParams, useRouter } from 'next/navigation'; // useRouter not used in active code, useParams not used if history items not rendered -// import type { User } from 'next-auth'; // Removed -// import { useState } from 'react'; // Not used if delete dialog logic is removed -// import { toast } from 'sonner'; // Not used if delete logic is removed -// import { motion } from 'framer-motion'; // Not used if history items not rendered -// import { -// AlertDialog, -// AlertDialogAction, -// AlertDialogCancel, -// AlertDialogContent, -// AlertDialogDescription, -// AlertDialogFooter, -// AlertDialogHeader, -// AlertDialogTitle, -// } from '@/components/ui/alert-dialog'; // Not used if delete dialog logic is removed +import { useState, useEffect } from 'react'; import { SidebarGroup, SidebarGroupContent, @@ -28,29 +13,111 @@ import { // import useSWRInfinite from 'swr/infinite'; // Removed // import { LoaderIcon } from './icons'; // Not used if loading state is removed import type { MockSessionUser } from './sidebar-user-nav'; // Import MockSessionUser +import { getConfig } from '@/lib/constants'; // Types GroupedChats, ChatHistory and functions groupChatsByDate, getChatHistoryPaginationKey are unused due to SWR logic removal +// Note type definition +interface Note { + id: number; + title: { + rendered: string; + }; + date: string; + modified: string; + slug: string; + status: string; +} + + export function SidebarHistory({ user }: { user: MockSessionUser | undefined }) { // const { setOpenMobile } = useSidebar(); // Not used in active logic // const { id } = useParams(); // Not used in active logic - // All SWR and delete logic removed as API calls are non-functional - // const { - // data: paginatedChatHistories, - // setSize, - // isValidating, - // isLoading, - // mutate, - // } = useSWRInfinite(getChatHistoryPaginationKey, fetcher, { - // fallbackData: [], - // }); - // const router = useRouter(); - // const [deleteId, setDeleteId] = useState(null); - // const [showDeleteDialog, setShowDeleteDialog] = useState(false); - // const hasReachedEnd = false; // Default for static view - // const hasEmptyChatHistory = true; // Default for static view - // const handleDelete = async () => {}; + const [notes, setNotes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!user) { + setLoading(false); + return; + } + + const fetchNotes = async () => { + try { + const currentConfig = getConfig(); + if (!currentConfig) { + throw new Error('Configuration not available'); + } + + const response = await fetch(currentConfig.rest_api_url + 'pos/v1/notes?status[]=private&status[]=publish¬ebook=132', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': currentConfig.nonce, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + + // Defensive programming: ensure data is valid + if (!data) { + setNotes([]); + return; + } + + // Handle both array and object responses + let notesArray: any[] = []; + if (Array.isArray(data)) { + notesArray = data; + } else if (data.data && Array.isArray(data.data)) { + notesArray = data.data; + } else if (typeof data === 'object' && data !== null) { + // If it's a single note object, wrap it in an array + notesArray = [data]; + } + + // Validate each note has required fields and matches WordPress post structure + const validNotes = notesArray.filter((note: any) => + note && + typeof note === 'object' && + note.id && + (typeof note.id === 'number' || typeof note.id === 'string') && + note.title && + typeof note.title === 'object' && + note.title.rendered + ).map((note: any) => ({ + id: Number(note.id), + title: { + rendered: String(note.title.rendered || 'Untitled Note') + }, + content: note.content ? { + rendered: String(note.content.rendered || ''), + protected: Boolean(note.content.protected) + } : undefined, + date: String(note.date || new Date().toISOString()), + modified: String(note.modified || note.date || new Date().toISOString()), + slug: String(note.slug || ''), + status: String(note.status || 'publish'), + })); + + setNotes(validNotes); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch notes'); + console.error('Error fetching notes:', err); + } finally { + setLoading(false); + } + }; + + fetchNotes(); + }, [user]); if (!user) { return ( @@ -64,11 +131,11 @@ export function SidebarHistory({ user }: { user: MockSessionUser | undefined }) ); } - if (false) { + if (loading) { return (
- Today + Notes
@@ -93,101 +160,65 @@ export function SidebarHistory({ user }: { user: MockSessionUser | undefined }) ); } - if (true) { + if (error) { return ( -
- Your conversations will appear here once you start chatting! +
+ Error loading notes: {error}
); } - // Original return with rendering logic is removed as it depends on non-functional API calls - /* - return ( - <> + if (notes.length === 0) { + return ( - - {paginatedChatHistories && - (() => { - const chatsFromHistory = paginatedChatHistories.flatMap( - (paginatedChatHistory) => paginatedChatHistory.chats, - ); - - const groupedChats = groupChatsByDate(chatsFromHistory); - - return ( -
- {groupedChats.today.length > 0 && ( -
-
- Today -
- {groupedChats.today.map((chat) => ( - { - setDeleteId(chatId); - setShowDeleteDialog(true); - }} - setOpenMobile={setOpenMobile} - /> - ))} -
- )} - // ... other groups ... -
- ); - })()} -
- - { - if (!isValidating && !hasReachedEnd) { - setSize((size) => size + 1); - } - }} - /> - - {hasReachedEnd ? ( -
- You have reached the end of your chat history. -
- ) : ( -
-
- -
-
Loading Chats...
-
- )} +
+ Your notes will appear here once you create some! +
+ ); + } - - - - Are you absolutely sure? - - This action cannot be undone. This will permanently delete your - chat and remove it from our servers. - - - - Cancel - - Continue - - - - - + return ( + +
+ Notes ({notes.length}) +
+ +
+ {notes.map((note) => { + // Additional safety check + if (!note || typeof note !== 'object' || !note.id) { + return null; + } + + return ( +
+
+ {note.title.rendered || 'Untitled Note'} +
+
+ {(() => { + try { + return new Date(note.modified).toLocaleDateString(); + } catch { + return 'Invalid date'; + } + })()} +
+
+ ); + })} +
+
+
); - */ } From 4857394b892e69102c3c72cd554a241b50feb184 Mon Sep 17 00:00:00 2001 From: artpi Date: Sun, 1 Jun 2025 17:58:13 +0200 Subject: [PATCH 4/8] Tests test tests --- modules/openai/class-openai-module.php | 70 +++-- .../OpenAIModuleIntegrationTest.php | 249 ++++++++++++++++ tests/unit/OpenAIModuleTest.php | 278 ++++++++++++++++++ 3 files changed, 572 insertions(+), 25 deletions(-) create mode 100644 tests/integration/OpenAIModuleIntegrationTest.php create mode 100644 tests/unit/OpenAIModuleTest.php diff --git a/modules/openai/class-openai-module.php b/modules/openai/class-openai-module.php index 09790b0..b5a6589 100644 --- a/modules/openai/class-openai-module.php +++ b/modules/openai/class-openai-module.php @@ -904,10 +904,20 @@ public function api_call( $url, $data ) { return json_decode( $body ); } - private function save_backscroll( array $backscroll, string $id ) { + /** + * Save conversation backscroll as a note + * + * @param array $backscroll Array of conversation messages + * @param array $config Configuration array for finding/creating the post + * - 'name': The post slug/name to search for + * - 'post_title': Title for new posts (optional) + * - 'notebook': Notebook slug to assign (optional, defaults to 'openai-chats') + * @return int|WP_Error Post ID on success, WP_Error on failure + */ + private function save_backscroll( array $backscroll, array $config ) { $notes_module = POS::get_module_by_id( 'notes' ); if ( ! $notes_module ) { - return; + return new WP_Error( 'notes_module_not_found', 'Notes module not available' ); } // Create content from backscroll messages @@ -929,52 +939,57 @@ private function save_backscroll( array $backscroll, string $id ) { $content_blocks[] = get_comment_delimited_block_content( 'pos/ai-message', array( - 'role' => $role, + 'role' => $role, 'content' => $content, - 'id' => $message['id'] ?? '', + 'id' => $message['id'] ?? '', ), '' ); } } + // Use notes module's list method to find existing posts + $existing_posts = $notes_module->list( $config, 'ai-chats' ); + // Prepare post data $post_data = array( - 'post_title' => 'Chat ' . gmdate( 'Y-m-d H:i:s' ), + // TODO: generate title with AI. + 'post_title' => $config['post_title'] ?? 'Chat ' . gmdate( 'Y-m-d H:i:s' ), 'post_type' => $notes_module->id, - 'post_name' => $id, - 'post_content' => implode( "\n\n", $content_blocks ), + 'post_name' => $config['name'] ?? 'chat-' . gmdate( 'Y-m-d-H-i-s' ), 'post_status' => 'private', ); - $existing_posts = get_posts( - array( - 'post_type' => $notes_module->id, - 'posts_per_page' => 1, - 'name' => $id, - ) - ); - error_log( 'Existing posts: ' . print_r( $existing_posts, true ) ); // Create or update post if ( ! empty( $existing_posts ) ) { - $post_data['ID'] = $existing_posts[0]->ID; - wp_update_post( $post_data ); + $post_id = wp_update_post( + array( + 'ID' => $existing_posts[0]->ID, + 'post_content' => implode( "\n\n", $content_blocks ), + ) + ); } else { + $post_data['post_content'] = implode( "\n\n", $content_blocks ); $post_id = wp_insert_post( $post_data ); - // Add to OpenAI notebook - $openai_notebook = get_term_by( 'slug', 'openai-chats', 'notebook' ); - if ( ! $openai_notebook ) { - $term_result = wp_insert_term( 'OpenAI Chats', 'notebook', array( 'slug' => 'openai-chats' ) ); + // Add to specified notebook or default to OpenAI chats + $notebook_slug = $config['notebook'] ?? 'ai-chats'; + $notebook = get_term_by( 'slug', $notebook_slug, 'notebook' ); + + if ( ! $notebook ) { + $notebook_name = 'ai-chats' === $notebook_slug ? 'AI Chats' : ucwords( str_replace( '-', ' ', $notebook_slug ) ); + $term_result = wp_insert_term( $notebook_name, 'notebook', array( 'slug' => $notebook_slug ) ); if ( ! is_wp_error( $term_result ) ) { - $openai_notebook = get_term( $term_result['term_id'], 'notebook' ); + $notebook = get_term( $term_result['term_id'], 'notebook' ); } } - if ( $openai_notebook ) { - wp_set_object_terms( $post_id, array( $openai_notebook->term_id ), 'notebook' ); + if ( $notebook ) { + wp_set_object_terms( $post_id, array( $notebook->term_id ), 'notebook' ); } } + + return $post_id; } public function vercel_chat( WP_REST_Request $request ) { @@ -1044,7 +1059,12 @@ public function vercel_chat( WP_REST_Request $request ) { } } ); set_transient( 'vercel_chat_' . $params['id'], $response, 60 * 60 ); - $this->save_backscroll( $response, $params['id'] ); + $this->save_backscroll( + $response, + array( + 'post_name' => $params['id'], + ) + ); // $vercel_sdk->sendText( $response->choices[0]->message->content ); $vercel_sdk->finishStep( 'stop', array( 'promptTokens' => 0, 'completionTokens' => 0 ), false ); diff --git a/tests/integration/OpenAIModuleIntegrationTest.php b/tests/integration/OpenAIModuleIntegrationTest.php new file mode 100644 index 0000000..f5353bc --- /dev/null +++ b/tests/integration/OpenAIModuleIntegrationTest.php @@ -0,0 +1,249 @@ +module = \POS::get_module_by_id( 'openai' ); + $this->notes_module = \POS::get_module_by_id( 'notes' ); + wp_set_current_user( 1 ); + + // Create default notebook term if it doesn't exist + if ( ! term_exists( 'openai-chats', 'notebook' ) ) { + wp_insert_term( 'OpenAI Chats', 'notebook', array( 'slug' => 'openai-chats' ) ); + } + } + + /** + * Helper method to create a mock request for vercel_chat + * + * @param array $params The parameters for the request + * @return WP_REST_Request Mock request object + */ + private function create_mock_request( array $params ) { + $request = new WP_REST_Request( 'POST', '/pos/v1/openai/vercel/chat' ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + return $request; + } + + /** + * Test vercel_chat integration with save_backscroll + * + * @group integration + */ + public function test_vercel_chat_saves_conversation() { + // Skip if OpenAI is not configured (no API key) + if ( ! $this->module->is_configured() ) { + $this->markTestSkipped( 'OpenAI module not configured with API key' ); + } + + $chat_id = 'test-integration-chat-' . time(); + $params = array( + 'id' => $chat_id, + 'message' => array( + 'content' => 'Hello, this is a test message for integration testing.', + ), + ); + + $request = $this->create_mock_request( $params ); + + // Mock the complete_backscroll method to avoid actual API calls + $mock_response = array( + array( + 'role' => 'user', + 'content' => 'Hello, this is a test message for integration testing.', + ), + array( + 'role' => 'assistant', + 'content' => 'Hello! I understand this is a test message for integration testing. How can I help you today?', + ), + ); + + // Use a different approach - we'll capture the output and check for saved posts + // Since vercel_chat calls die(), we need to handle this carefully + + // Set up transient to simulate existing conversation + set_transient( 'vercel_chat_' . $chat_id, array(), 60 * 60 ); + + // Check if a post gets created with the chat ID + $posts_before = $this->notes_module->list( array( 'name' => $chat_id ) ); + $this->assertEmpty( $posts_before, 'No posts should exist before the test' ); + + // Since vercel_chat ends with die(), we need to test save_backscroll directly + // but in the context of how vercel_chat would call it + $config = array( + 'post_name' => $chat_id, + ); + + // Use reflection to access the private method + $reflection = new ReflectionClass( $this->module ); + $method = $reflection->getMethod( 'save_backscroll' ); + $method->setAccessible( true ); + + $post_id = $method->invokeArgs( $this->module, array( $mock_response, $config ) ); + + // Verify the post was created + $this->assertIsInt( $post_id ); + $this->assertGreaterThan( 0, $post_id ); + + // Verify the post has the correct properties + $post = get_post( $post_id ); + $this->assertEquals( 'notes', $post->post_type ); + $this->assertEquals( 'private', $post->post_status ); + $this->assertEquals( $chat_id, $post->post_name ); + + // Verify content structure + $this->assertStringContainsString( 'wp:pos/ai-message', $post->post_content ); + $this->assertStringContainsString( 'Hello, this is a test message for integration testing.', $post->post_content ); + $this->assertStringContainsString( 'Hello! I understand this is a test message', $post->post_content ); + + // Verify notebook assignment + $notebooks = wp_get_object_terms( $post_id, 'notebook' ); + $this->assertCount( 1, $notebooks ); + $this->assertEquals( 'openai-chats', $notebooks[0]->slug ); + + // Test updating the same conversation + $updated_response = array_merge( $mock_response, array( + array( + 'role' => 'user', + 'content' => 'Can you help me with something else?', + ), + array( + 'role' => 'assistant', + 'content' => 'Of course! I would be happy to help you with something else.', + ), + ) ); + + $updated_post_id = $method->invokeArgs( $this->module, array( $updated_response, $config ) ); + + // Should be the same post ID + $this->assertEquals( $post_id, $updated_post_id ); + + // Verify updated content + $updated_post = get_post( $updated_post_id ); + $this->assertStringContainsString( 'Can you help me with something else?', $updated_post->post_content ); + $this->assertStringContainsString( 'Of course! I would be happy to help you', $updated_post->post_content ); + } + + /** + * Test vercel_chat with missing content + * + * @group integration + */ + public function test_vercel_chat_missing_content() { + $params = array( + 'id' => 'test-missing-content', + // Missing message content + ); + + $request = $this->create_mock_request( $params ); + + // Mock the check_permission method to return true + $result = $this->module->vercel_chat( $request ); + + $this->assertInstanceOf( 'WP_Error', $result ); + $this->assertEquals( 'missing_message_content', $result->get_error_code() ); + } + + /** + * Test vercel_chat with messages array format + * + * @group integration + */ + public function test_vercel_chat_with_messages_array() { + $chat_id = 'test-messages-array-' . time(); + $params = array( + 'id' => $chat_id, + 'messages' => array( + array( + 'role' => 'user', + 'content' => 'Hello from messages array', + ), + array( + 'role' => 'assistant', + 'content' => 'Previous response', + ), + array( + 'role' => 'user', + 'content' => 'This is the latest message', + ), + ), + ); + + // Set up transient to simulate conversation state + $existing_messages = array( + array( + 'role' => 'user', + 'content' => 'Hello from messages array', + ), + ); + set_transient( 'vercel_chat_' . $chat_id, $existing_messages, 60 * 60 ); + + // Test that the latest user message is extracted correctly + // We'll test this by checking what would be added to the conversation + $expected_content = 'This is the latest message'; + + // Since we can't easily test the full vercel_chat method due to die(), + // we'll verify the message extraction logic by testing a similar pattern + $user_message_content = null; + if ( isset( $params['message']['content'] ) ) { + $user_message_content = $params['message']['content']; + } elseif ( isset( $params['messages'] ) && is_array( $params['messages'] ) ) { + $last_message = end( $params['messages'] ); + if ( $last_message && isset( $last_message['content'] ) && 'user' === $last_message['role'] ) { + $user_message_content = $last_message['content']; + } + } + + $this->assertEquals( $expected_content, $user_message_content ); + } + + /** + * Test transient handling in vercel_chat + * + * @group integration + */ + public function test_vercel_chat_transient_handling() { + $chat_id = 'test-transient-' . time(); + + // Test with no existing transient + $transient = get_transient( 'vercel_chat_' . $chat_id ); + $this->assertFalse( $transient ); + + // Simulate what vercel_chat does with transients + $openai_messages = get_transient( 'vercel_chat_' . $chat_id ); + if ( ! $openai_messages ) { + $openai_messages = array(); + } + $openai_messages[] = array( + 'role' => 'user', + 'content' => 'Test message', + ); + + // This simulates the pattern in vercel_chat + $this->assertCount( 1, $openai_messages ); + $this->assertEquals( 'user', $openai_messages[0]['role'] ); + $this->assertEquals( 'Test message', $openai_messages[0]['content'] ); + + // Test setting the transient (what would happen at the end of vercel_chat) + $response = array( + array( + 'role' => 'user', + 'content' => 'Test message', + ), + array( + 'role' => 'assistant', + 'content' => 'Test response', + ), + ); + + set_transient( 'vercel_chat_' . $chat_id, $response, 60 * 60 ); + + // Verify transient was set + $stored_transient = get_transient( 'vercel_chat_' . $chat_id ); + $this->assertEquals( $response, $stored_transient ); + } +} \ No newline at end of file diff --git a/tests/unit/OpenAIModuleTest.php b/tests/unit/OpenAIModuleTest.php new file mode 100644 index 0000000..dbc33d2 --- /dev/null +++ b/tests/unit/OpenAIModuleTest.php @@ -0,0 +1,278 @@ +module = \POS::get_module_by_id( 'openai' ); + $this->notes_module = \POS::get_module_by_id( 'notes' ); + wp_set_current_user( 1 ); + + // Create default notebook term if it doesn't exist + if ( ! term_exists( 'openai-chats', 'notebook' ) ) { + wp_insert_term( 'OpenAI Chats', 'notebook', array( 'slug' => 'openai-chats' ) ); + } + } + + /** + * Helper method to create a sample backscroll for testing + * + * @return array Sample backscroll data + */ + private function get_sample_backscroll() { + return array( + array( + 'role' => 'user', + 'content' => 'Hello, how are you?', + 'id' => 'user-1', + ), + array( + 'role' => 'assistant', + 'content' => 'I am doing well, thank you for asking!', + 'id' => 'assistant-1', + ), + array( + 'role' => 'user', + 'content' => 'Can you help me with a task?', + 'id' => 'user-2', + ), + array( + 'role' => 'assistant', + 'content' => 'Of course! I would be happy to help you.', + 'id' => 'assistant-2', + ), + ); + } + + /** + * Test save_backscroll creates a new post when one doesn't exist + */ + public function test_save_backscroll_creates_new_post() { + $backscroll = $this->get_sample_backscroll(); + $config = array( + 'name' => 'test-chat-1', + 'post_title' => 'Test Chat Session', + ); + + // Use reflection to access the private method + $reflection = new ReflectionClass( $this->module ); + $method = $reflection->getMethod( 'save_backscroll' ); + $method->setAccessible( true ); + + $post_id = $method->invokeArgs( $this->module, array( $backscroll, $config ) ); + + $this->assertIsInt( $post_id ); + $this->assertGreaterThan( 0, $post_id ); + + $post = get_post( $post_id ); + $this->assertEquals( 'Test Chat Session', $post->post_title ); + $this->assertEquals( 'test-chat-1', $post->post_name ); + $this->assertEquals( 'notes', $post->post_type ); + $this->assertEquals( 'private', $post->post_status ); + + // Check content contains message blocks + $this->assertStringContainsString( 'wp:pos/ai-message', $post->post_content ); + $this->assertStringContainsString( 'Hello, how are you?', $post->post_content ); + $this->assertStringContainsString( 'I am doing well, thank you for asking!', $post->post_content ); + + // Check notebook assignment + $notebooks = wp_get_object_terms( $post_id, 'notebook' ); + $this->assertCount( 1, $notebooks ); + $this->assertEquals( 'ai-chats', $notebooks[0]->slug ); + } + + /** + * Test save_backscroll updates existing post when found + */ + public function test_save_backscroll_updates_existing_post() { + $backscroll = $this->get_sample_backscroll(); + $config = array( + 'post_name' => 'test-chat-update', + 'post_title' => 'Original Title', + ); + + // Use reflection to access the private method + $reflection = new ReflectionClass( $this->module ); + $method = $reflection->getMethod( 'save_backscroll' ); + $method->setAccessible( true ); + + // Create initial post + $post_id_1 = $method->invokeArgs( $this->module, array( $backscroll, $config ) ); + + // Update with new backscroll and title + $updated_backscroll = array_merge( $backscroll, array( + array( + 'role' => 'user', + 'content' => 'This is an updated message', + 'id' => 'user-3', + ), + ) ); + $updated_config = array( + 'post_name' => 'test-chat-update', + 'post_title' => 'Updated Title', + ); + + $post_id_2 = $method->invokeArgs( $this->module, array( $updated_backscroll, $updated_config ) ); + + // Should return the same post ID + $this->assertEquals( $post_id_1, $post_id_2 ); + + $post = get_post( $post_id_2 ); + $this->assertStringContainsString( 'This is an updated message', $post->post_content ); + } + + /** + * Test save_backscroll with custom notebook + */ + public function test_save_backscroll_custom_notebook() { + // Create custom notebook + $custom_notebook = wp_insert_term( 'Custom Notebook', 'notebook', array( 'slug' => 'custom-notebook' ) ); + + $backscroll = $this->get_sample_backscroll(); + $config = array( + 'post_name' => 'test-chat-custom', + 'notebook' => 'custom-notebook', + ); + + // Use reflection to access the private method + $reflection = new ReflectionClass( $this->module ); + $method = $reflection->getMethod( 'save_backscroll' ); + $method->setAccessible( true ); + + $post_id = $method->invokeArgs( $this->module, array( $backscroll, $config ) ); + + // Check notebook assignment + $notebooks = wp_get_object_terms( $post_id, 'notebook' ); + $this->assertCount( 1, $notebooks ); + $this->assertEquals( 'custom-notebook', $notebooks[0]->slug ); + } + + /** + * Test save_backscroll creates notebook if it doesn't exist + */ + public function test_save_backscroll_creates_notebook() { + $backscroll = $this->get_sample_backscroll(); + $config = array( + 'post_name' => 'test-chat-new-notebook', + 'notebook' => 'new-test-notebook', + ); + + // Ensure notebook doesn't exist - check both term_exists and get_term_by + $existing_term = get_term_by( 'slug', 'new-test-notebook', 'notebook' ); + $this->assertFalse( $existing_term, 'Notebook should not exist before test' ); + + // Use reflection to access the private method + $reflection = new ReflectionClass( $this->module ); + $method = $reflection->getMethod( 'save_backscroll' ); + $method->setAccessible( true ); + + $post_id = $method->invokeArgs( $this->module, array( $backscroll, $config ) ); + + // Check notebook was created + $created_term = get_term_by( 'slug', 'new-test-notebook', 'notebook' ); + $this->assertNotFalse( $created_term, 'Notebook should be created' ); + + // Check notebook assignment + $notebooks = wp_get_object_terms( $post_id, 'notebook' ); + $this->assertCount( 1, $notebooks ); + $this->assertEquals( 'new-test-notebook', $notebooks[0]->slug ); + $this->assertEquals( 'New Test Notebook', $notebooks[0]->name ); + } + + /** + * Test save_backscroll error handling when notes module is not available + */ + public function test_save_backscroll_notes_module_unavailable() { + $backscroll = $this->get_sample_backscroll(); + $config = array( 'name' => 'test-chat' ); + + // Mock POS class to return null for notes module + $original_notes_module = $this->notes_module; + + // Use a temporary workaround - we'll test this indirectly by checking the method behavior + // when the notes module would be unavailable in a real scenario + $this->markTestSkipped( 'Testing notes module unavailability requires mocking POS::get_module_by_id which is complex in this context' ); + } + + /** + * Test save_backscroll uses notes module's list method + */ + public function test_save_backscroll_uses_notes_module_list() { + // Create an existing post first + $existing_post_id = wp_insert_post( array( + 'post_type' => 'notes', + 'name' => 'existing-chat', + 'post_title' => 'Existing Chat', + 'post_status' => 'private', + ) ); + + $backscroll = $this->get_sample_backscroll(); + $config = array( + 'name' => 'existing-chat', + ); + + // Use reflection to access the private method + $reflection = new ReflectionClass( $this->module ); + $method = $reflection->getMethod( 'save_backscroll' ); + $method->setAccessible( true ); + + $post_id = $method->invokeArgs( $this->module, array( $backscroll, $config ) ); + + // Should return the same post ID as the existing one + $this->assertEquals( $existing_post_id, $post_id ); + + $post = get_post( $post_id ); + $this->assertEquals( 'Existing Chat', $post->post_title ); + } + + /** + * Test that only user and assistant messages are saved to content + */ + public function test_save_backscroll_filters_message_types() { + $backscroll = array( + array( + 'role' => 'user', + 'content' => 'User message', + 'id' => 'user-1', + ), + array( + 'role' => 'assistant', + 'content' => 'Assistant message', + 'id' => 'assistant-1', + ), + array( + 'role' => 'system', + 'content' => 'System message - should be ignored', + 'id' => 'system-1', + ), + array( + 'role' => 'tool', + 'content' => 'Tool message - should be ignored', + 'id' => 'tool-1', + ), + ); + + $config = array( + 'post_name' => 'test-chat-filtered', + ); + + // Use reflection to access the private method + $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 ); + + // Should contain user and assistant messages + $this->assertStringContainsString( 'User message', $post->post_content ); + $this->assertStringContainsString( 'Assistant message', $post->post_content ); + + // Should not contain system or tool messages + $this->assertStringNotContainsString( 'System message', $post->post_content ); + $this->assertStringNotContainsString( 'Tool message', $post->post_content ); + } +} \ No newline at end of file From 304a0603ca9c15fc129dbc29d90b632f84880cb3 Mon Sep 17 00:00:00 2001 From: artpi Date: Sun, 1 Jun 2025 18:39:41 +0200 Subject: [PATCH 5/8] Tests --- modules/openai/class-openai-module.php | 2 +- tests/unit/OpenAIModuleTest.php | 54 +++++++++---- .../OpenAIModuleVercelChatTest.php} | 78 ++++++++++++------- 3 files changed, 93 insertions(+), 41 deletions(-) rename tests/{integration/OpenAIModuleIntegrationTest.php => unit/OpenAIModuleVercelChatTest.php} (78%) diff --git a/modules/openai/class-openai-module.php b/modules/openai/class-openai-module.php index b5a6589..0ab2167 100644 --- a/modules/openai/class-openai-module.php +++ b/modules/openai/class-openai-module.php @@ -1062,7 +1062,7 @@ public function vercel_chat( WP_REST_Request $request ) { $this->save_backscroll( $response, array( - 'post_name' => $params['id'], + 'name' => $params['id'], ) ); diff --git a/tests/unit/OpenAIModuleTest.php b/tests/unit/OpenAIModuleTest.php index dbc33d2..c3790d1 100644 --- a/tests/unit/OpenAIModuleTest.php +++ b/tests/unit/OpenAIModuleTest.php @@ -11,8 +11,8 @@ public function set_up() { wp_set_current_user( 1 ); // Create default notebook term if it doesn't exist - if ( ! term_exists( 'openai-chats', 'notebook' ) ) { - wp_insert_term( 'OpenAI Chats', 'notebook', array( 'slug' => 'openai-chats' ) ); + if ( ! term_exists( 'ai-chats', 'notebook' ) ) { + wp_insert_term( 'AI Chats', 'notebook', array( 'slug' => 'ai-chats' ) ); } } @@ -52,7 +52,7 @@ private function get_sample_backscroll() { public function test_save_backscroll_creates_new_post() { $backscroll = $this->get_sample_backscroll(); $config = array( - 'name' => 'test-chat-1', + 'name' => 'test-chat-1', 'post_title' => 'Test Chat Session', ); @@ -89,7 +89,7 @@ public function test_save_backscroll_creates_new_post() { public function test_save_backscroll_updates_existing_post() { $backscroll = $this->get_sample_backscroll(); $config = array( - 'post_name' => 'test-chat-update', + 'name' => 'test-chat-update', 'post_title' => 'Original Title', ); @@ -110,8 +110,8 @@ public function test_save_backscroll_updates_existing_post() { ), ) ); $updated_config = array( - 'post_name' => 'test-chat-update', - 'post_title' => 'Updated Title', + 'name' => 'test-chat-update', + 'post_title' => 'Updated Title', // This won't be applied to existing posts ); $post_id_2 = $method->invokeArgs( $this->module, array( $updated_backscroll, $updated_config ) ); @@ -120,6 +120,8 @@ public function test_save_backscroll_updates_existing_post() { $this->assertEquals( $post_id_1, $post_id_2 ); $post = get_post( $post_id_2 ); + // Title should remain the same since save_backscroll only updates content for existing posts + $this->assertEquals( 'Original Title', $post->post_title ); $this->assertStringContainsString( 'This is an updated message', $post->post_content ); } @@ -132,8 +134,8 @@ public function test_save_backscroll_custom_notebook() { $backscroll = $this->get_sample_backscroll(); $config = array( - 'post_name' => 'test-chat-custom', - 'notebook' => 'custom-notebook', + 'name' => 'test-chat-custom', + 'notebook' => 'custom-notebook', ); // Use reflection to access the private method @@ -155,8 +157,8 @@ public function test_save_backscroll_custom_notebook() { public function test_save_backscroll_creates_notebook() { $backscroll = $this->get_sample_backscroll(); $config = array( - 'post_name' => 'test-chat-new-notebook', - 'notebook' => 'new-test-notebook', + 'name' => 'test-chat-new-notebook', + 'notebook' => 'new-test-notebook', ); // Ensure notebook doesn't exist - check both term_exists and get_term_by @@ -181,6 +183,28 @@ public function test_save_backscroll_creates_notebook() { $this->assertEquals( 'New Test Notebook', $notebooks[0]->name ); } + /** + * Test save_backscroll error handling for missing name + */ + public function test_save_backscroll_missing_post_name() { + $backscroll = $this->get_sample_backscroll(); + $config = array(); // Missing name + + // Use reflection to access the private method + $reflection = new ReflectionClass( $this->module ); + $method = $reflection->getMethod( 'save_backscroll' ); + $method->setAccessible( true ); + + $result = $method->invokeArgs( $this->module, array( $backscroll, $config ) ); + + // Should succeed since name is now optional with a default + $this->assertIsInt( $result ); + $this->assertGreaterThan( 0, $result ); + + $post = get_post( $result ); + $this->assertStringContainsString( 'chat-', $post->post_name ); // Default name format + } + /** * Test save_backscroll error handling when notes module is not available */ @@ -203,14 +227,15 @@ public function test_save_backscroll_uses_notes_module_list() { // Create an existing post first $existing_post_id = wp_insert_post( array( 'post_type' => 'notes', - 'name' => 'existing-chat', + 'post_name' => 'existing-chat', 'post_title' => 'Existing Chat', 'post_status' => 'private', ) ); $backscroll = $this->get_sample_backscroll(); $config = array( - 'name' => 'existing-chat', + 'name' => 'existing-chat', + 'post_title' => 'Updated Chat', // This won't be applied to existing posts ); // Use reflection to access the private method @@ -224,7 +249,10 @@ public function test_save_backscroll_uses_notes_module_list() { $this->assertEquals( $existing_post_id, $post_id ); $post = get_post( $post_id ); + // Title should remain the same since save_backscroll only updates content for existing posts $this->assertEquals( 'Existing Chat', $post->post_title ); + // But content should be updated + $this->assertStringContainsString( 'wp:pos/ai-message', $post->post_content ); } /** @@ -255,7 +283,7 @@ public function test_save_backscroll_filters_message_types() { ); $config = array( - 'post_name' => 'test-chat-filtered', + 'name' => 'test-chat-filtered', ); // Use reflection to access the private method diff --git a/tests/integration/OpenAIModuleIntegrationTest.php b/tests/unit/OpenAIModuleVercelChatTest.php similarity index 78% rename from tests/integration/OpenAIModuleIntegrationTest.php rename to tests/unit/OpenAIModuleVercelChatTest.php index f5353bc..a908259 100644 --- a/tests/integration/OpenAIModuleIntegrationTest.php +++ b/tests/unit/OpenAIModuleVercelChatTest.php @@ -1,6 +1,6 @@ 'openai-chats' ) ); + if ( ! term_exists( 'ai-chats', 'notebook' ) ) { + wp_insert_term( 'AI Chats', 'notebook', array( 'slug' => 'ai-chats' ) ); } } @@ -30,21 +30,14 @@ private function create_mock_request( array $params ) { } /** - * Test vercel_chat integration with save_backscroll - * - * @group integration + * Test vercel_chat method with save_backscroll */ public function test_vercel_chat_saves_conversation() { - // Skip if OpenAI is not configured (no API key) - if ( ! $this->module->is_configured() ) { - $this->markTestSkipped( 'OpenAI module not configured with API key' ); - } - - $chat_id = 'test-integration-chat-' . time(); + $chat_id = 'test-unit-chat-' . time(); $params = array( 'id' => $chat_id, 'message' => array( - 'content' => 'Hello, this is a test message for integration testing.', + 'content' => 'Hello, this is a test message for unit testing.', ), ); @@ -54,17 +47,14 @@ public function test_vercel_chat_saves_conversation() { $mock_response = array( array( 'role' => 'user', - 'content' => 'Hello, this is a test message for integration testing.', + 'content' => 'Hello, this is a test message for unit testing.', ), array( 'role' => 'assistant', - 'content' => 'Hello! I understand this is a test message for integration testing. How can I help you today?', + 'content' => 'Hello! I understand this is a test message for unit testing. How can I help you today?', ), ); - // Use a different approach - we'll capture the output and check for saved posts - // Since vercel_chat calls die(), we need to handle this carefully - // Set up transient to simulate existing conversation set_transient( 'vercel_chat_' . $chat_id, array(), 60 * 60 ); @@ -75,7 +65,7 @@ public function test_vercel_chat_saves_conversation() { // Since vercel_chat ends with die(), we need to test save_backscroll directly // but in the context of how vercel_chat would call it $config = array( - 'post_name' => $chat_id, + 'name' => $chat_id, ); // Use reflection to access the private method @@ -97,13 +87,13 @@ public function test_vercel_chat_saves_conversation() { // Verify content structure $this->assertStringContainsString( 'wp:pos/ai-message', $post->post_content ); - $this->assertStringContainsString( 'Hello, this is a test message for integration testing.', $post->post_content ); + $this->assertStringContainsString( 'Hello, this is a test message for unit testing.', $post->post_content ); $this->assertStringContainsString( 'Hello! I understand this is a test message', $post->post_content ); // Verify notebook assignment $notebooks = wp_get_object_terms( $post_id, 'notebook' ); $this->assertCount( 1, $notebooks ); - $this->assertEquals( 'openai-chats', $notebooks[0]->slug ); + $this->assertEquals( 'ai-chats', $notebooks[0]->slug ); // Test updating the same conversation $updated_response = array_merge( $mock_response, array( @@ -130,8 +120,6 @@ public function test_vercel_chat_saves_conversation() { /** * Test vercel_chat with missing content - * - * @group integration */ public function test_vercel_chat_missing_content() { $params = array( @@ -150,8 +138,6 @@ public function test_vercel_chat_missing_content() { /** * Test vercel_chat with messages array format - * - * @group integration */ public function test_vercel_chat_with_messages_array() { $chat_id = 'test-messages-array-' . time(); @@ -203,8 +189,6 @@ public function test_vercel_chat_with_messages_array() { /** * Test transient handling in vercel_chat - * - * @group integration */ public function test_vercel_chat_transient_handling() { $chat_id = 'test-transient-' . time(); @@ -246,4 +230,44 @@ public function test_vercel_chat_transient_handling() { $stored_transient = get_transient( 'vercel_chat_' . $chat_id ); $this->assertEquals( $response, $stored_transient ); } + + /** + * Test save_backscroll config parameter handling in vercel_chat context + */ + public function test_save_backscroll_config_in_vercel_chat() { + $chat_id = 'test-config-' . time(); + $backscroll = array( + array( + 'role' => 'user', + 'content' => 'Test config message', + ), + array( + 'role' => 'assistant', + 'content' => 'Config response', + ), + ); + + // Test the config array that vercel_chat passes to save_backscroll + $config = array( + 'name' => $chat_id, + ); + + // Use reflection to access the private method + $reflection = new ReflectionClass( $this->module ); + $method = $reflection->getMethod( 'save_backscroll' ); + $method->setAccessible( true ); + + $post_id = $method->invokeArgs( $this->module, array( $backscroll, $config ) ); + + // Verify the post was created with correct properties + $post = get_post( $post_id ); + $this->assertEquals( $chat_id, $post->post_name ); + $this->assertStringContainsString( 'Chat ', $post->post_title ); // Default title format + $this->assertEquals( 'private', $post->post_status ); + + // Verify default notebook assignment (ai-chats) + $notebooks = wp_get_object_terms( $post_id, 'notebook' ); + $this->assertCount( 1, $notebooks ); + $this->assertEquals( 'ai-chats', $notebooks[0]->slug ); + } } \ No newline at end of file From 73eab780cfd563bb83fbb499f3da5ab54f0a5f7c Mon Sep 17 00:00:00 2001 From: artpi Date: Sun, 1 Jun 2025 19:42:40 +0200 Subject: [PATCH 6/8] Ok tests now too --- .cursor/rules/dev-environment.mdc | 31 +- modules/openai/class-ollama.php | 762 +---------------- modules/openai/class-openai-module.php | 19 +- modules/openai/class-pos-ollama-server.php | 734 ++++++++++++++++ tests/unit/OllamaServerTest.php | 934 +++++++++++++++++++++ 5 files changed, 1706 insertions(+), 774 deletions(-) mode change 100644 => 120000 modules/openai/class-ollama.php create mode 100644 modules/openai/class-pos-ollama-server.php create mode 100644 tests/unit/OllamaServerTest.php diff --git a/.cursor/rules/dev-environment.mdc b/.cursor/rules/dev-environment.mdc index fc4fe77..df546de 100644 --- a/.cursor/rules/dev-environment.mdc +++ b/.cursor/rules/dev-environment.mdc @@ -6,9 +6,32 @@ alwaysApply: true The dev environment is managed by the `wp-env` package. In order to run code and run tests, you need to use the proper syntax to run commands. -For example: +## Testing Commands -- To run unit tests, you would `npm run test:unit` -- To read the debug.log you would `npm run wp-env run cli -- tail -n 100 wp-content/debug.log` +### Unit Tests +- To run all unit tests: `npm run test:unit` +- To run specific unit test class: `npm run test:unit -- --filter=ClassName` +- Example: `npm run test:unit -- --filter=OpenAIModuleTest` -Feel free to do both as often as you think is reasonable. DO NOT run local commands on this machine, as the wp-env manages the environment in a docker container. \ No newline at end of file +### Integration Tests +- Integration tests in this project are tests that run API calls to external APIs. All other tests are unit. +- To run all integration tests: `npm run test:integration` +- To run specific integration test class: `npm run test:integration -- --filter=ClassName` +- **IMPORTANT**: Do NOT run integration tests automatically - only run when specifically requested + +### Other Useful Commands +- To read the debug.log: `npm run wp-env run cli -- tail -n 100 wp-content/debug.log` +- To check PHP syntax/linting: Use the linter errors shown in the interface +- To run specific test groups: `npm run test:unit -- --group=groupname` + +## Testing Guidelines + +1. **Always run unit tests** after making code changes to verify functionality +2. **Run specific test classes** when working on particular modules (faster feedback) +3. **Only run integration tests** when specifically asked or when testing full system flows +4. **Feel free to run unit tests often** - they are fast and help catch regressions +5. **DO NOT run local commands** on this machine, as wp-env manages the environment in a docker container + +## Test Structure +- Unit tests: `tests/unit/` - Test individual methods and classes in isolation +- Integration tests: `tests/integration/` - Test full workflows and system interactions \ No newline at end of file diff --git a/modules/openai/class-ollama.php b/modules/openai/class-ollama.php deleted file mode 100644 index 560a6ae..0000000 --- a/modules/openai/class-ollama.php +++ /dev/null @@ -1,761 +0,0 @@ -module = $module; - $token = $this->module->get_setting( 'ollama_auth_token' ); - $this->module->settings['ollama_auth_token'] = array( - 'type' => 'text', - 'name' => 'Token for authorizing OLLAMA mock API.', - 'label' => strlen( $token ) < 3 ? 'Set a token to enable Ollama-compatible API for external clients' : 'OLLAMA Api accessible at here', - 'default' => '0', - ); - if ( strlen( $token ) >= 3 ) { - $this->init_models(); - add_action( 'rest_api_init', array( $this, 'register_routes' ) ); - } - } - - /** - * Initialize the models array. - */ - private function init_models(): void { - $this->models = array( - 'personalos:4o' => array( - 'name' => 'personalos:4o', - 'model' => 'personalos:4o', - 'modified_at' => gmdate( 'c' ), - 'size' => 4299915632, - 'digest' => 'sha256:a2af6cc3eb7fa8be8504abaf9b04e88f17a119ec3f04a3addf55f92841195f5a', - 'details' => array( - 'parent_model' => '', - 'format' => 'gguf', - 'family' => 'personalos', - 'families' => array( 'personalos' ), - 'parameter_size' => '4.0B', - 'quantization_level' => 'Q4_K_M', - ), - ), - ); - } - - /** - * Get a specific model by name. - * - * @param string $name Model name. - * @return array|null Model data or null if not found. - */ - private function get_model( string $name ): ?array { - return $this->models[ $name ] ?? null; - } - - /** - * Get all models as indexed array. - * - * @return array Array of models. - */ - private function get_all_models(): array { - return array_values( $this->models ); - } - - /** - * Check if model exists. - * - * @param string $name Model name. - * @return bool True if model exists. - */ - private function model_exists( string $name ): bool { - return isset( $this->models[ $name ] ); - } - - public function check_permission( WP_REST_Request $request ) { - $token = $request->get_param( 'token' ); - if ( $token === $this->module->get_setting( 'ollama_auth_token' ) ) { - return true; - } - return false; - } - - /** - * Register all REST API routes. - */ - public function register_routes(): void { - // GET /api/tags - list models - register_rest_route( - $this->rest_namespace, - '/api/tags', - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_tags' ), - 'permission_callback' => array( $this, 'check_permission' ), - ) - ); - - // GET /api/version - version info - register_rest_route( - $this->rest_namespace, - '/api/version', - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_version' ), - 'permission_callback' => array( $this, 'check_permission' ), - ) - ); - - // POST /api/chat - chat endpoint - register_rest_route( - $this->rest_namespace, - '/api/chat', - array( - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => array( $this, 'post_chat' ), - 'permission_callback' => array( $this, 'check_permission' ), - ) - ); - - // POST /api/generate - text generation - register_rest_route( - $this->rest_namespace, - '/api/generate', - array( - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => array( $this, 'post_generate' ), - 'permission_callback' => array( $this, 'check_permission' ), - ) - ); - - // POST /api/pull - pull model - register_rest_route( - $this->rest_namespace, - '/api/pull', - array( - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => array( $this, 'post_pull' ), - 'permission_callback' => array( $this, 'check_permission' ), - ) - ); - - // POST /api/show - show model info - register_rest_route( - $this->rest_namespace, - '/api/show', - array( - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => array( $this, 'post_show' ), - 'permission_callback' => array( $this, 'check_permission' ), - ) - ); - - // POST /api/create - create model - register_rest_route( - $this->rest_namespace, - '/api/create', - array( - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => array( $this, 'post_create' ), - 'permission_callback' => array( $this, 'check_permission' ), - ) - ); - - // DELETE /api/delete - delete model - register_rest_route( - $this->rest_namespace, - '/api/delete', - array( - 'methods' => WP_REST_Server::DELETABLE, - 'callback' => array( $this, 'delete_model' ), - 'permission_callback' => array( $this, 'check_permission' ), - ) - ); - - // POST /api/copy - copy model - register_rest_route( - $this->rest_namespace, - '/api/copy', - array( - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => array( $this, 'post_copy' ), - 'permission_callback' => array( $this, 'check_permission' ), - ) - ); - - // POST /api/push - push model - register_rest_route( - $this->rest_namespace, - '/api/push', - array( - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => array( $this, 'post_push' ), - 'permission_callback' => array( $this, 'check_permission' ), - ) - ); - - // GET /api/ps - list running models - register_rest_route( - $this->rest_namespace, - '/api/ps', - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_ps' ), - 'permission_callback' => array( $this, 'check_permission' ), - ) - ); - } - - /** - * Get model license text. - * - * @param string $family Model family. - * @return string License text. - */ - private function get_model_license( string $family ): string { - switch ( $family ) { - case 'personalos': - return 'PersonalOS Mock License - -This is a mock license for the PersonalOS model family. -Used for testing and development purposes only. - -[Truncated for brevity - full PersonalOS license text would be here]'; - - default: - return 'Mock license for ' . $family . ' model family.'; - } - } - - /** - * Get model template. - * - * @param string $family Model family. - * @return string Template text. - */ - private function get_model_template( string $family ): string { - switch ( $family ) { - case 'personalos': - return '{{ if .System }}<|start_header_id|>system<|end_header_id|> - -{{ .System }}<|eot_id|>{{ end }}{{ if .Prompt }}<|start_header_id|>user<|end_header_id|> - -{{ .Prompt }}<|eot_id|>{{ end }}<|start_header_id|>assistant<|end_header_id|> - -{{ .Response }}<|eot_id|>'; - - default: - return 'Default template for {{ .Prompt }}'; - } - } - - /** - * GET /api/tags - List models. - * - * @param WP_REST_Request $request Request object. - * @return WP_REST_Response - */ - public function get_tags( WP_REST_Request $request ): WP_REST_Response { - return new WP_REST_Response( - array( 'models' => $this->get_all_models() ), - 200 - ); - } - - /** - * GET /api/version - Get version info. - * - * @param WP_REST_Request $request Request object. - * @return WP_REST_Response - */ - public function get_version( WP_REST_Request $request ): WP_REST_Response { - return new WP_REST_Response( - array( 'version' => '0.7.1' ), - 200 - ); - } - - private function calculate_rolling_hash( $messages ) { - $hash = ''; - $last_assistant_index = -1; - foreach ( $messages as $index => $message ) { - $message = (array) $message; - if ( in_array( $message['role'], array( 'assistant', 'system' ), true ) ) { - $last_assistant_index = $index; - } - } - foreach ( $messages as $index => $message ) { - $message = (array) $message; - if ( ( $index <= $last_assistant_index || $last_assistant_index === -1 ) && in_array( $message['role'], array( 'user', 'assistant' ), true ) ) { - $hash .= "\n\n" . trim( $message['content'] ); - } - } - return hash( 'sha256', trim( $hash ) ); - } - - /** - * POST /api/chat - Chat endpoint. - * - * @param WP_REST_Request $request Request object. - * @return WP_REST_Response - */ - public function post_chat( WP_REST_Request $request ): WP_REST_Response { - $data = $request->get_json_params(); - if ( ! $data ) { - return new WP_REST_Response( - array( 'error' => 'Invalid JSON' ), - 400 - ); - } - - $model = $data['model'] ?? 'personalos:4o'; - $messages = $data['messages'] ?? array(); - $stream = $data['stream'] ?? false; - - // Validate model exists - if ( ! $this->model_exists( $model ) ) { - return new WP_REST_Response( - array( 'error' => 'Model not found' ), - 404 - ); - } - - $non_system_messages = array_filter( - $messages, - function( $message ) { - return $message['role'] !== 'system'; - } - ); - - $hash = $this->calculate_rolling_hash( $messages ); - - // Get post with ollama-hash meta equal to $hash, or create one if it doesn't exist - $existing_posts = get_posts( - array( - 'post_type' => 'notes', - 'post_status' => 'private', - 'numberposts' => 1, - 'meta_query' => array( - array( - 'key' => 'ollama-hash', - 'value' => $hash, - ), - ), - ) - ); - error_log( 'existing_posts: ' . print_r( $existing_posts, true ) . ' hash: [' . $hash . ']' ); - - if ( ! empty( $existing_posts ) ) { - $post_id = $existing_posts[0]->ID; - } else { - // Create new post with ollama-hash meta - $post_id = wp_insert_post( - array( - 'post_title' => 'Ollama Chat ' . gmdate( 'Y-m-d H:i:s' ), - 'post_status' => 'private', - 'post_type' => 'notes', - ) - ); - } - - $result = $this->module->complete_backscroll( $non_system_messages ); - - $content_blocks = array(); - foreach ( $result as $message ) { - $message = (array) $message; - if ( in_array( $message['role'], array( 'user', 'assistant' ), true ) ) { - // Create message block - $content_blocks[] = get_comment_delimited_block_content( - 'pos/ai-message', - array( - 'role' => $message['role'], - 'content' => $message['content'], - 'id' => $message['id'] ?? '', - ), - '' - ); - } - } - - wp_update_post( - array( - 'ID' => $post_id, - 'post_content' => implode( "\n\n", $content_blocks ), - 'meta_input' => array( - 'ollama-hash' => $this->calculate_rolling_hash( $result ), - ), - ) - ); - - $last_message = (array) end( $result ); - $answer = $last_message['content'] ?? 'Hello from PersonalOS Mock Ollama!'; - // $answer = 'Echo: ' . json_encode( $data ); //$content; - - if ( $stream ) { - // For streaming, we'll return a simple response since WordPress doesn't handle streaming well - return new WP_REST_Response( - array( - 'model' => $model, - 'created_at' => gmdate( 'c' ), - 'message' => array( - 'role' => 'assistant', - 'content' => $answer, - ), - 'done' => true, - 'total_duration' => 1000000000, - 'load_duration' => 100000000, - 'prompt_eval_count' => 10, - 'prompt_eval_duration' => 200000000, - 'eval_count' => str_word_count( $answer ), - 'eval_duration' => 700000000, - ), - 200 - ); - } else { - return new WP_REST_Response( - array( - 'model' => $model, - 'created_at' => gmdate( 'c' ), - 'message' => array( - 'role' => 'assistant', - 'content' => $answer, - ), - 'done' => true, - 'total_duration' => 1000000000, - 'load_duration' => 100000000, - 'prompt_eval_count' => 10, - 'prompt_eval_duration' => 200000000, - 'eval_count' => str_word_count( $answer ), - 'eval_duration' => 700000000, - ), - 200 - ); - } - } - - /** - * POST /api/generate - Text generation. - * - * @param WP_REST_Request $request Request object. - * @return WP_REST_Response - */ - public function post_generate( WP_REST_Request $request ): WP_REST_Response { - $data = $request->get_json_params(); - if ( ! $data ) { - return new WP_REST_Response( - array( 'error' => 'Invalid JSON' ), - 400 - ); - } - - $model = $data['model'] ?? 'personalos:4o'; - $prompt = $data['prompt'] ?? 'Hello!'; - $stream = $data['stream'] ?? false; - $response = 'Generated response to: ' . $prompt; - - // Validate model exists - if ( ! $this->model_exists( $model ) ) { - return new WP_REST_Response( - array( 'error' => 'Model not found' ), - 404 - ); - } - - return new WP_REST_Response( - array( - 'model' => $model, - 'created_at' => gmdate( 'c' ), - 'response' => $response, - 'done' => true, - 'total_duration' => 1000000000, - 'load_duration' => 100000000, - 'prompt_eval_count' => str_word_count( $prompt ), - 'prompt_eval_duration' => 200000000, - 'eval_count' => str_word_count( $response ), - 'eval_duration' => 700000000, - ), - 200 - ); - } - - /** - * POST /api/pull - Pull model. - * - * @param WP_REST_Request $request Request object. - * @return WP_REST_Response - */ - public function post_pull( WP_REST_Request $request ): WP_REST_Response { - $data = $request->get_json_params(); - if ( ! $data || empty( $data['name'] ) ) { - return new WP_REST_Response( - array( 'error' => 'Body must contain {"name": "model"}' ), - 400 - ); - } - - $name = $data['name']; - - if ( ! $this->model_exists( $name ) ) { - return new WP_REST_Response( - array( 'error' => 'Model not available. Only personalos:4o is supported.' ), - 404 - ); - } - - return new WP_REST_Response( - array( 'status' => 'success' ), - 200 - ); - } - - /** - * POST /api/show - Show model info. - * - * @param WP_REST_Request $request Request object. - * @return WP_REST_Response - */ - public function post_show( WP_REST_Request $request ): WP_REST_Response { - $data = $request->get_json_params(); - if ( ! $data || ( empty( $data['name'] ) && empty( $data['model'] ) ) ) { - return new WP_REST_Response( - array( 'error' => 'Body must contain {"name": "model"} or {"model": "model"}' ), - 400 - ); - } - - $name = $data['name'] ?? $data['model']; - $model_data = $this->get_model( $name ); - - if ( ! $model_data ) { - return new WP_REST_Response( - array( 'error' => 'Model not found' ), - 404 - ); - } - - $family = $model_data['details']['family'] ?? 'personalos'; - - $modelfile = "# Modelfile generated by \"ollama show\"\n"; - $modelfile .= "# To build a new Modelfile based on this, replace FROM with:\n"; - $modelfile .= '# FROM ' . $model_data['name'] . "\n\n"; - $modelfile .= "FROM /fake/path/to/model/blob\n"; - $modelfile .= 'TEMPLATE """' . $this->get_model_template( $family ) . '"""' . "\n"; - $modelfile .= "PARAMETER num_keep 24\n"; - $modelfile .= 'PARAMETER stop "<|start_header_id|>"' . "\n"; - $modelfile .= 'PARAMETER stop "<|end_header_id|>"' . "\n"; - $modelfile .= 'PARAMETER stop "<|eot_id|>"' . "\n"; - $modelfile .= 'LICENSE """' . $this->get_model_license( $family ) . '"""' . "\n"; - - $model_info = array( - 'personalos.attention.head_count' => 32, - 'personalos.attention.head_count_kv' => 8, - 'personalos.attention.layer_norm_rms_epsilon' => 0.00001, - 'personalos.block_count' => 32, - 'personalos.context_length' => 8192, - 'personalos.embedding_length' => 4096, - 'personalos.feed_forward_length' => 14336, - 'general.architecture' => 'personalos', - 'general.parameter_count' => 4000000000, - 'tokenizer.ggml.model' => 'personalos', - ); - - $tensors = array( - array( - 'name' => 'token_embd.weight', - 'type' => 'F32', - 'shape' => array( 2560, 2560 ), - ), - array( - 'name' => 'output_norm.weight', - 'type' => 'Q4_K', - 'shape' => array( 2560, 2560 ), - ), - array( - 'name' => 'output.weight', - 'type' => 'Q6_K', - 'shape' => array( 2560, 2560 ), - ), - ); - - $capabilities = array( 'completion' ); - - return new WP_REST_Response( - array( - 'license' => $this->get_model_license( $family ), - 'modelfile' => $modelfile, - 'parameters' => "num_keep 24\nstop \"<|start_header_id|>\"\nstop \"<|end_header_id|>\"\nstop \"<|eot_id|>\"", - 'template' => $this->get_model_template( $family ), - 'details' => $model_data['details'], - 'model_info' => $model_info, - 'tensors' => $tensors, - 'capabilities' => $capabilities, - 'modified_at' => $model_data['modified_at'], - ), - 200 - ); - } - - /** - * POST /api/create - Create model. - * - * @param WP_REST_Request $request Request object. - * @return WP_REST_Response - */ - public function post_create( WP_REST_Request $request ): WP_REST_Response { - $data = $request->get_json_params(); - if ( ! $data || empty( $data['name'] ) ) { - return new WP_REST_Response( - array( 'error' => 'Body must contain {"name": "model"}' ), - 400 - ); - } - - return new WP_REST_Response( - array( 'status' => 'success' ), - 200 - ); - } - - /** - * DELETE /api/delete - Delete model. - * - * @param WP_REST_Request $request Request object. - * @return WP_REST_Response - */ - public function delete_model( WP_REST_Request $request ): WP_REST_Response { - $data = $request->get_json_params(); - if ( ! $data || empty( $data['name'] ) ) { - return new WP_REST_Response( - array( 'error' => 'Body must contain {"name": "model"}' ), - 400 - ); - } - - $name = $data['name']; - if ( ! $this->model_exists( $name ) ) { - return new WP_REST_Response( - array( 'error' => 'Model not found' ), - 404 - ); - } - - return new WP_REST_Response( - array( 'error' => 'Cannot delete the only available model' ), - 400 - ); - } - - /** - * POST /api/copy - Copy model. - * - * @param WP_REST_Request $request Request object. - * @return WP_REST_Response - */ - public function post_copy( WP_REST_Request $request ): WP_REST_Response { - $data = $request->get_json_params(); - if ( ! $data || empty( $data['source'] ) || empty( $data['destination'] ) ) { - return new WP_REST_Response( - array( 'error' => 'Body must contain {"source": "model", "destination": "model"}' ), - 400 - ); - } - - $source = $data['source']; - if ( ! $this->model_exists( $source ) ) { - return new WP_REST_Response( - array( 'error' => 'Source model not found' ), - 404 - ); - } - - return new WP_REST_Response( - array( 'status' => 'success' ), - 200 - ); - } - - /** - * POST /api/push - Push model. - * - * @param WP_REST_Request $request Request object. - * @return WP_REST_Response - */ - public function post_push( WP_REST_Request $request ): WP_REST_Response { - $data = $request->get_json_params(); - if ( ! $data || empty( $data['name'] ) ) { - return new WP_REST_Response( - array( 'error' => 'Body must contain {"name": "model"}' ), - 400 - ); - } - - return new WP_REST_Response( - array( 'status' => 'success' ), - 200 - ); - } - - /** - * GET /api/ps - List running models. - * - * @param WP_REST_Request $request Request object. - * @return WP_REST_Response - */ - public function get_ps( WP_REST_Request $request ): WP_REST_Response { - $running_models = array(); - - foreach ( $this->models as $model_data ) { - $running_models[] = array( - 'name' => $model_data['name'], - 'model' => $model_data['model'], - 'size' => $model_data['size'], - 'digest' => $model_data['digest'], - 'details' => $model_data['details'], - 'expires_at' => gmdate( 'c', time() + 300 ), - 'size_vram' => $model_data['size'], - ); - } - - return new WP_REST_Response( - array( 'models' => $running_models ), - 200 - ); - } -} diff --git a/modules/openai/class-ollama.php b/modules/openai/class-ollama.php new file mode 120000 index 0000000..8218270 --- /dev/null +++ b/modules/openai/class-ollama.php @@ -0,0 +1 @@ +class-pos-ollama-server.php \ No newline at end of file diff --git a/modules/openai/class-openai-module.php b/modules/openai/class-openai-module.php index 0ab2167..ea4da25 100644 --- a/modules/openai/class-openai-module.php +++ b/modules/openai/class-openai-module.php @@ -908,13 +908,14 @@ public function api_call( $url, $data ) { * Save conversation backscroll as a note * * @param array $backscroll Array of conversation messages - * @param array $config Configuration array for finding/creating the post - * - 'name': The post slug/name to search for - * - 'post_title': Title for new posts (optional) - * - 'notebook': Notebook slug to assign (optional, defaults to 'openai-chats') + * @param array $search_args Search arguments for get_posts to find existing notes, also used for new post configuration + * - 'name': The post slug/name to search for and use when creating new posts + * - 'post_title': Title for new posts (optional, defaults to auto-generated) + * - 'notebook': Notebook slug to assign to new posts (optional, defaults to 'ai-chats') + * - Any other valid get_posts() arguments for finding existing posts * @return int|WP_Error Post ID on success, WP_Error on failure */ - private function save_backscroll( array $backscroll, array $config ) { + public function save_backscroll( array $backscroll, array $search_args ) { $notes_module = POS::get_module_by_id( 'notes' ); if ( ! $notes_module ) { return new WP_Error( 'notes_module_not_found', 'Notes module not available' ); @@ -949,14 +950,14 @@ private function save_backscroll( array $backscroll, array $config ) { } // Use notes module's list method to find existing posts - $existing_posts = $notes_module->list( $config, 'ai-chats' ); + $existing_posts = $notes_module->list( $search_args, 'ai-chats' ); // Prepare post data $post_data = array( // TODO: generate title with AI. - 'post_title' => $config['post_title'] ?? 'Chat ' . gmdate( 'Y-m-d H:i:s' ), + 'post_title' => $search_args['post_title'] ?? 'Chat ' . gmdate( 'Y-m-d H:i:s' ), 'post_type' => $notes_module->id, - 'post_name' => $config['name'] ?? 'chat-' . gmdate( 'Y-m-d-H-i-s' ), + 'post_name' => $search_args['name'] ?? 'chat-' . gmdate( 'Y-m-d-H-i-s' ), 'post_status' => 'private', ); @@ -973,7 +974,7 @@ private function save_backscroll( array $backscroll, array $config ) { $post_id = wp_insert_post( $post_data ); // Add to specified notebook or default to OpenAI chats - $notebook_slug = $config['notebook'] ?? 'ai-chats'; + $notebook_slug = $search_args['notebook'] ?? 'ai-chats'; $notebook = get_term_by( 'slug', $notebook_slug, 'notebook' ); if ( ! $notebook ) { diff --git a/modules/openai/class-pos-ollama-server.php b/modules/openai/class-pos-ollama-server.php new file mode 100644 index 0000000..bbbf3d0 --- /dev/null +++ b/modules/openai/class-pos-ollama-server.php @@ -0,0 +1,734 @@ +module = $module; + $token = $this->module->get_setting( 'ollama_auth_token' ); + $this->module->settings['ollama_auth_token'] = array( + 'type' => 'text', + 'name' => 'Token for authorizing OLLAMA mock API.', + 'label' => strlen( $token ) < 3 ? 'Set a token to enable Ollama-compatible API for external clients' : 'OLLAMA Api accessible at here', + 'default' => '0', + ); + if ( strlen( $token ) >= 3 ) { + $this->init_models(); + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + } + + /** + * Initialize the models array. + */ + private function init_models(): void { + $this->models = array( + 'personalos:4o' => array( + 'name' => 'personalos:4o', + 'model' => 'personalos:4o', + 'modified_at' => gmdate( 'c' ), + 'size' => 4299915632, + 'digest' => 'sha256:a2af6cc3eb7fa8be8504abaf9b04e88f17a119ec3f04a3addf55f92841195f5a', + 'details' => array( + 'parent_model' => '', + 'format' => 'gguf', + 'family' => 'personalos', + 'families' => array( 'personalos' ), + 'parameter_size' => '4.0B', + 'quantization_level' => 'Q4_K_M', + ), + ), + ); + } + + /** + * Get a specific model by name. + * + * @param string $name Model name. + * @return array|null Model data or null if not found. + */ + private function get_model( string $name ): ?array { + return $this->models[ $name ] ?? null; + } + + /** + * Get all models as indexed array. + * + * @return array Array of models. + */ + private function get_all_models(): array { + return array_values( $this->models ); + } + + /** + * Check if model exists. + * + * @param string $name Model name. + * @return bool True if model exists. + */ + private function model_exists( string $name ): bool { + return isset( $this->models[ $name ] ); + } + + public function check_permission( WP_REST_Request $request ) { + $token = $request->get_param( 'token' ); + if ( $token === $this->module->get_setting( 'ollama_auth_token' ) ) { + return true; + } + return false; + } + + /** + * Register all REST API routes. + */ + public function register_routes(): void { + // GET /api/tags - list models + register_rest_route( + $this->rest_namespace, + '/api/tags', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_tags' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + + // GET /api/version - version info + register_rest_route( + $this->rest_namespace, + '/api/version', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_version' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + + // POST /api/chat - chat endpoint + register_rest_route( + $this->rest_namespace, + '/api/chat', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'post_chat' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + + // POST /api/generate - text generation + register_rest_route( + $this->rest_namespace, + '/api/generate', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'post_generate' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + + // POST /api/pull - pull model + register_rest_route( + $this->rest_namespace, + '/api/pull', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'post_pull' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + + // POST /api/show - show model info + register_rest_route( + $this->rest_namespace, + '/api/show', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'post_show' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + + // POST /api/create - create model + register_rest_route( + $this->rest_namespace, + '/api/create', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'post_create' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + + // DELETE /api/delete - delete model + register_rest_route( + $this->rest_namespace, + '/api/delete', + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_model' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + + // POST /api/copy - copy model + register_rest_route( + $this->rest_namespace, + '/api/copy', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'post_copy' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + + // POST /api/push - push model + register_rest_route( + $this->rest_namespace, + '/api/push', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'post_push' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + + // GET /api/ps - list running models + register_rest_route( + $this->rest_namespace, + '/api/ps', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_ps' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + } + + /** + * Get model license text. + * + * @param string $family Model family. + * @return string License text. + */ + private function get_model_license( string $family ): string { + switch ( $family ) { + case 'personalos': + return 'PersonalOS Mock License + +This is a mock license for the PersonalOS model family. +Used for testing and development purposes only. + +[Truncated for brevity - full PersonalOS license text would be here]'; + + default: + return 'Mock license for ' . $family . ' model family.'; + } + } + + /** + * Get model template. + * + * @param string $family Model family. + * @return string Template text. + */ + private function get_model_template( string $family ): string { + switch ( $family ) { + case 'personalos': + return '{{ if .System }}<|start_header_id|>system<|end_header_id|> + +{{ .System }}<|eot_id|>{{ end }}{{ if .Prompt }}<|start_header_id|>user<|end_header_id|> + +{{ .Prompt }}<|eot_id|>{{ end }}<|start_header_id|>assistant<|end_header_id|> + +{{ .Response }}<|eot_id|>'; + + default: + return 'Default template for {{ .Prompt }}'; + } + } + + /** + * GET /api/tags - List models. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response + */ + public function get_tags( WP_REST_Request $request ): WP_REST_Response { + return new WP_REST_Response( + array( 'models' => $this->get_all_models() ), + 200 + ); + } + + /** + * GET /api/version - Get version info. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response + */ + public function get_version( WP_REST_Request $request ): WP_REST_Response { + return new WP_REST_Response( + array( 'version' => '0.7.1' ), + 200 + ); + } + + private function calculate_rolling_hash( $messages ) { + $hash = ''; + $last_assistant_index = -1; + foreach ( $messages as $index => $message ) { + $message = (array) $message; + if ( in_array( $message['role'], array( 'assistant', 'system' ), true ) ) { + $last_assistant_index = $index; + } + } + foreach ( $messages as $index => $message ) { + $message = (array) $message; + if ( ( $index <= $last_assistant_index || $last_assistant_index === -1 ) && in_array( $message['role'], array( 'user', 'assistant' ), true ) ) { + $hash .= "\n\n" . trim( $message['content'] ); + } + } + return hash( 'sha256', trim( $hash ) ); + } + + /** + * POST /api/chat - Chat endpoint. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response + */ + public function post_chat( WP_REST_Request $request ): WP_REST_Response { + $data = $request->get_json_params(); + if ( ! $data ) { + return new WP_REST_Response( + array( 'error' => 'Invalid JSON' ), + 400 + ); + } + + $model = $data['model'] ?? 'personalos:4o'; + $messages = $data['messages'] ?? array(); + $stream = $data['stream'] ?? false; + + // Validate model exists + if ( ! $this->model_exists( $model ) ) { + return new WP_REST_Response( + array( 'error' => 'Model not found' ), + 404 + ); + } + + $non_system_messages = array_filter( + $messages, + function( $message ) { + return $message['role'] !== 'system'; + } + ); + + $hash = $this->calculate_rolling_hash( $messages ); + + $result = $this->module->complete_backscroll( $non_system_messages ); + + // Use the OpenAI module's save_backscroll method with hash as identifier + $post_id = $this->module->save_backscroll( + $result, + array( + 'meta_input' => array( + 'ollama-hash' => $hash, + ), + ) + ); + + if ( is_wp_error( $post_id ) ) { + return new WP_REST_Response( + array( 'error' => 'Failed to save conversation: ' . $post_id->get_error_message() ), + 500 + ); + } + + // In case we have edited an existing post, we are updating the hash with the result information so the subsequent search will find the correct post. + wp_update_post( + array( + 'ID' => $post_id, + 'meta_input' => array( + 'ollama-hash' => $this->calculate_rolling_hash( $result ), + ), + ) + ); + + $last_message = (array) end( $result ); + $answer = $last_message['content'] ?? 'Hello from PersonalOS Mock Ollama!'; + // $answer = 'Echo: ' . json_encode( $data ); //$content; + + if ( $stream ) { + // For streaming, we'll return a simple response since WordPress doesn't handle streaming well + return new WP_REST_Response( + array( + 'model' => $model, + 'created_at' => gmdate( 'c' ), + 'message' => array( + 'role' => 'assistant', + 'content' => $answer, + ), + 'done' => true, + 'total_duration' => 1000000000, + 'load_duration' => 100000000, + 'prompt_eval_count' => 10, + 'prompt_eval_duration' => 200000000, + 'eval_count' => str_word_count( $answer ), + 'eval_duration' => 700000000, + ), + 200 + ); + } else { + return new WP_REST_Response( + array( + 'model' => $model, + 'created_at' => gmdate( 'c' ), + 'message' => array( + 'role' => 'assistant', + 'content' => $answer, + ), + 'done' => true, + 'total_duration' => 1000000000, + 'load_duration' => 100000000, + 'prompt_eval_count' => 10, + 'prompt_eval_duration' => 200000000, + 'eval_count' => str_word_count( $answer ), + 'eval_duration' => 700000000, + ), + 200 + ); + } + } + + /** + * POST /api/generate - Text generation. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response + */ + public function post_generate( WP_REST_Request $request ): WP_REST_Response { + $data = $request->get_json_params(); + if ( ! $data ) { + return new WP_REST_Response( + array( 'error' => 'Invalid JSON' ), + 400 + ); + } + + $model = $data['model'] ?? 'personalos:4o'; + $prompt = $data['prompt'] ?? 'Hello!'; + $stream = $data['stream'] ?? false; + $response = 'Generated response to: ' . $prompt; + + // Validate model exists + if ( ! $this->model_exists( $model ) ) { + return new WP_REST_Response( + array( 'error' => 'Model not found' ), + 404 + ); + } + + return new WP_REST_Response( + array( + 'model' => $model, + 'created_at' => gmdate( 'c' ), + 'response' => $response, + 'done' => true, + 'total_duration' => 1000000000, + 'load_duration' => 100000000, + 'prompt_eval_count' => str_word_count( $prompt ), + 'prompt_eval_duration' => 200000000, + 'eval_count' => str_word_count( $response ), + 'eval_duration' => 700000000, + ), + 200 + ); + } + + /** + * POST /api/pull - Pull model. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response + */ + public function post_pull( WP_REST_Request $request ): WP_REST_Response { + $data = $request->get_json_params(); + if ( ! $data || empty( $data['name'] ) ) { + return new WP_REST_Response( + array( 'error' => 'Body must contain {"name": "model"}' ), + 400 + ); + } + + $name = $data['name']; + + if ( ! $this->model_exists( $name ) ) { + return new WP_REST_Response( + array( 'error' => 'Model not available. Only personalos:4o is supported.' ), + 404 + ); + } + + return new WP_REST_Response( + array( 'status' => 'success' ), + 200 + ); + } + + /** + * POST /api/show - Show model info. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response + */ + public function post_show( WP_REST_Request $request ): WP_REST_Response { + $data = $request->get_json_params(); + if ( ! $data || ( empty( $data['name'] ) && empty( $data['model'] ) ) ) { + return new WP_REST_Response( + array( 'error' => 'Body must contain {"name": "model"} or {"model": "model"}' ), + 400 + ); + } + + $name = $data['name'] ?? $data['model']; + $model_data = $this->get_model( $name ); + + if ( ! $model_data ) { + return new WP_REST_Response( + array( 'error' => 'Model not found' ), + 404 + ); + } + + $family = $model_data['details']['family'] ?? 'personalos'; + + $modelfile = "# Modelfile generated by \"ollama show\"\n"; + $modelfile .= "# To build a new Modelfile based on this, replace FROM with:\n"; + $modelfile .= '# FROM ' . $model_data['name'] . "\n\n"; + $modelfile .= "FROM /fake/path/to/model/blob\n"; + $modelfile .= 'TEMPLATE """' . $this->get_model_template( $family ) . '"""' . "\n"; + $modelfile .= "PARAMETER num_keep 24\n"; + $modelfile .= 'PARAMETER stop "<|start_header_id|>"' . "\n"; + $modelfile .= 'PARAMETER stop "<|end_header_id|>"' . "\n"; + $modelfile .= 'PARAMETER stop "<|eot_id|>"' . "\n"; + $modelfile .= 'LICENSE """' . $this->get_model_license( $family ) . '"""' . "\n"; + + $model_info = array( + 'personalos.attention.head_count' => 32, + 'personalos.attention.head_count_kv' => 8, + 'personalos.attention.layer_norm_rms_epsilon' => 0.00001, + 'personalos.block_count' => 32, + 'personalos.context_length' => 8192, + 'personalos.embedding_length' => 4096, + 'personalos.feed_forward_length' => 14336, + 'general.architecture' => 'personalos', + 'general.parameter_count' => 4000000000, + 'tokenizer.ggml.model' => 'personalos', + ); + + $tensors = array( + array( + 'name' => 'token_embd.weight', + 'type' => 'F32', + 'shape' => array( 2560, 2560 ), + ), + array( + 'name' => 'output_norm.weight', + 'type' => 'Q4_K', + 'shape' => array( 2560, 2560 ), + ), + array( + 'name' => 'output.weight', + 'type' => 'Q6_K', + 'shape' => array( 2560, 2560 ), + ), + ); + + $capabilities = array( 'completion' ); + + return new WP_REST_Response( + array( + 'license' => $this->get_model_license( $family ), + 'modelfile' => $modelfile, + 'parameters' => "num_keep 24\nstop \"<|start_header_id|>\"\nstop \"<|end_header_id|>\"\nstop \"<|eot_id|>\"", + 'template' => $this->get_model_template( $family ), + 'details' => $model_data['details'], + 'model_info' => $model_info, + 'tensors' => $tensors, + 'capabilities' => $capabilities, + 'modified_at' => $model_data['modified_at'], + ), + 200 + ); + } + + /** + * POST /api/create - Create model. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response + */ + public function post_create( WP_REST_Request $request ): WP_REST_Response { + $data = $request->get_json_params(); + if ( ! $data || empty( $data['name'] ) ) { + return new WP_REST_Response( + array( 'error' => 'Body must contain {"name": "model"}' ), + 400 + ); + } + + return new WP_REST_Response( + array( 'status' => 'success' ), + 200 + ); + } + + /** + * DELETE /api/delete - Delete model. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response + */ + public function delete_model( WP_REST_Request $request ): WP_REST_Response { + $data = $request->get_json_params(); + if ( ! $data || empty( $data['name'] ) ) { + return new WP_REST_Response( + array( 'error' => 'Body must contain {"name": "model"}' ), + 400 + ); + } + + $name = $data['name']; + if ( ! $this->model_exists( $name ) ) { + return new WP_REST_Response( + array( 'error' => 'Model not found' ), + 404 + ); + } + + return new WP_REST_Response( + array( 'error' => 'Cannot delete the only available model' ), + 400 + ); + } + + /** + * POST /api/copy - Copy model. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response + */ + public function post_copy( WP_REST_Request $request ): WP_REST_Response { + $data = $request->get_json_params(); + if ( ! $data || empty( $data['source'] ) || empty( $data['destination'] ) ) { + return new WP_REST_Response( + array( 'error' => 'Body must contain {"source": "model", "destination": "model"}' ), + 400 + ); + } + + $source = $data['source']; + if ( ! $this->model_exists( $source ) ) { + return new WP_REST_Response( + array( 'error' => 'Source model not found' ), + 404 + ); + } + + return new WP_REST_Response( + array( 'status' => 'success' ), + 200 + ); + } + + /** + * POST /api/push - Push model. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response + */ + public function post_push( WP_REST_Request $request ): WP_REST_Response { + $data = $request->get_json_params(); + if ( ! $data || empty( $data['name'] ) ) { + return new WP_REST_Response( + array( 'error' => 'Body must contain {"name": "model"}' ), + 400 + ); + } + + return new WP_REST_Response( + array( 'status' => 'success' ), + 200 + ); + } + + /** + * GET /api/ps - List running models. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response + */ + public function get_ps( WP_REST_Request $request ): WP_REST_Response { + $running_models = array(); + + foreach ( $this->models as $model_data ) { + $running_models[] = array( + 'name' => $model_data['name'], + 'model' => $model_data['model'], + 'size' => $model_data['size'], + 'digest' => $model_data['digest'], + 'details' => $model_data['details'], + 'expires_at' => gmdate( 'c', time() + 300 ), + 'size_vram' => $model_data['size'], + ); + } + + return new WP_REST_Response( + array( 'models' => $running_models ), + 200 + ); + } +} diff --git a/tests/unit/OllamaServerTest.php b/tests/unit/OllamaServerTest.php new file mode 100644 index 0000000..14d9915 --- /dev/null +++ b/tests/unit/OllamaServerTest.php @@ -0,0 +1,934 @@ +mock_openai_module = $this->createMock( OpenAI_Module::class ); + $this->mock_openai_module->settings = array(); + + // Mock get_setting method to return test token + $this->mock_openai_module->method( 'get_setting' ) + ->with( 'ollama_auth_token' ) + ->willReturn( 'test-token-123' ); + + // Create Ollama server instance + $this->ollama_server = new POS_Ollama_Server( $this->mock_openai_module ); + } + + /** + * Test GET /api/tags endpoint. + * + * @covers POS_Ollama_Server::get_tags + */ + public function test_get_tags() { + // Create request with valid token + $request = new WP_REST_Request( 'GET', '/ollama/v1/api/tags' ); + $request->set_param( 'token', 'test-token-123' ); + + $response = $this->ollama_server->get_tags( $request ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'models', $data ); + $this->assertIsArray( $data['models'] ); + $this->assertCount( 1, $data['models'] ); + + $model = $data['models'][0]; + $this->assertEquals( 'personalos:4o', $model['name'] ); + $this->assertEquals( 'personalos:4o', $model['model'] ); + $this->assertArrayHasKey( 'modified_at', $model ); + $this->assertArrayHasKey( 'size', $model ); + $this->assertArrayHasKey( 'digest', $model ); + $this->assertArrayHasKey( 'details', $model ); + + // Test model details + $details = $model['details']; + $this->assertEquals( 'personalos', $details['family'] ); + $this->assertEquals( '4.0B', $details['parameter_size'] ); + $this->assertEquals( 'Q4_K_M', $details['quantization_level'] ); + } + + /** + * Test GET /api/tags endpoint with invalid token. + * + * @covers POS_Ollama_Server::check_permission + */ + public function test_get_tags_invalid_token() { + $request = new WP_REST_Request( 'GET', '/ollama/v1/api/tags' ); + $request->set_param( 'token', 'invalid-token' ); + + $result = $this->ollama_server->check_permission( $request ); + + $this->assertFalse( $result ); + } + + /** + * Test POST /api/show endpoint with valid model. + * + * @covers POS_Ollama_Server::post_show + */ + public function test_post_show_valid_model() { + $request = new WP_REST_Request( 'POST', '/ollama/v1/api/show' ); + $request->set_param( 'token', 'test-token-123' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'name' => 'personalos:4o' ) ) ); + + $response = $this->ollama_server->post_show( $request ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'license', $data ); + $this->assertArrayHasKey( 'modelfile', $data ); + $this->assertArrayHasKey( 'parameters', $data ); + $this->assertArrayHasKey( 'template', $data ); + $this->assertArrayHasKey( 'details', $data ); + $this->assertArrayHasKey( 'model_info', $data ); + $this->assertArrayHasKey( 'tensors', $data ); + $this->assertArrayHasKey( 'capabilities', $data ); + $this->assertArrayHasKey( 'modified_at', $data ); + + // Test license contains PersonalOS text + $this->assertStringContainsString( 'PersonalOS Mock License', $data['license'] ); + + // Test template contains PersonalOS format + $this->assertStringContainsString( '|start_header_id|', $data['template'] ); + + // Test capabilities + $this->assertContains( 'completion', $data['capabilities'] ); + } + + /** + * Test POST /api/show endpoint with model parameter instead of name. + * + * @covers POS_Ollama_Server::post_show + */ + public function test_post_show_with_model_param() { + $request = new WP_REST_Request( 'POST', '/ollama/v1/api/show' ); + $request->set_param( 'token', 'test-token-123' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'model' => 'personalos:4o' ) ) ); + + $response = $this->ollama_server->post_show( $request ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * Test POST /api/show endpoint with invalid model. + * + * @covers POS_Ollama_Server::post_show + */ + public function test_post_show_invalid_model() { + $request = new WP_REST_Request( 'POST', '/ollama/v1/api/show' ); + $request->set_param( 'token', 'test-token-123' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'name' => 'nonexistent:model' ) ) ); + + $response = $this->ollama_server->post_show( $request ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $this->assertEquals( 404, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'error', $data ); + $this->assertEquals( 'Model not found', $data['error'] ); + } + + /** + * Test POST /api/show endpoint with missing model name. + * + * @covers POS_Ollama_Server::post_show + */ + public function test_post_show_missing_model() { + $request = new WP_REST_Request( 'POST', '/ollama/v1/api/show' ); + $request->set_param( 'token', 'test-token-123' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array() ) ); + + $response = $this->ollama_server->post_show( $request ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $this->assertEquals( 400, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'error', $data ); + $this->assertStringContainsString( 'Body must contain', $data['error'] ); + } + + /** + * Test POST /api/chat endpoint with simple message. + * + * @covers POS_Ollama_Server::post_chat + */ + public function test_post_chat_simple() { + // Mock the complete_backscroll method + $mock_response = array( + array( + 'role' => 'user', + 'content' => 'Hello', + ), + array( + 'role' => 'assistant', + 'content' => 'Hello! How can I help you today?', + ), + ); + + $this->mock_openai_module->method( 'complete_backscroll' ) + ->willReturn( $mock_response ); + + // Mock save_backscroll to return a post ID + $this->mock_openai_module->method( 'save_backscroll' ) + ->willReturn( 123 ); + + $request = new WP_REST_Request( 'POST', '/ollama/v1/api/chat' ); + $request->set_param( 'token', 'test-token-123' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'model' => 'personalos:4o', + 'messages' => array( + array( + 'role' => 'user', + 'content' => 'Hello', + ), + ), + 'stream' => false, + ) + ) + ); + + $response = $this->ollama_server->post_chat( $request ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'model', $data ); + $this->assertArrayHasKey( 'created_at', $data ); + $this->assertArrayHasKey( 'message', $data ); + $this->assertArrayHasKey( 'done', $data ); + $this->assertTrue( $data['done'] ); + + $message = $data['message']; + $this->assertEquals( 'assistant', $message['role'] ); + $this->assertEquals( 'Hello! How can I help you today?', $message['content'] ); + } + + /** + * Test POST /api/chat endpoint with backscroll functionality. + * + * This test simulates the scenario where a client sends only the current + * backscroll and the server needs to use the hash to find the correct + * post to continue the conversation. + * + * @covers POS_Ollama_Server::post_chat + * @covers POS_Ollama_Server::calculate_rolling_hash + */ + public function test_post_chat_backscroll_functionality() { + $messages = array( + array( + 'role' => 'user', + 'content' => 'What is the weather like?', + ), + array( + 'role' => 'assistant', + 'content' => 'I don\'t have access to current weather data.', + ), + array( + 'role' => 'user', + 'content' => 'Can you help me with something else?', + ), + ); + + // Mock the complete_backscroll method to return the messages plus a new response + $mock_response = array_merge( + $messages, + array( + array( + 'role' => 'assistant', + 'content' => 'Of course! I\'d be happy to help you with something else.', + ), + ) + ); + + $this->mock_openai_module->method( 'complete_backscroll' ) + ->willReturn( $mock_response ); + + // Mock save_backscroll to return a post ID and verify the hash is passed + $this->mock_openai_module->expects( $this->once() ) + ->method( 'save_backscroll' ) + ->with( + $this->equalTo( $mock_response ), + $this->callback( + function( $args ) { + // Verify that meta_input contains ollama-hash + return isset( $args['meta_input']['ollama-hash'] ) && + is_string( $args['meta_input']['ollama-hash'] ) && + strlen( $args['meta_input']['ollama-hash'] ) === 64; // SHA256 hash + } + ) + ) + ->willReturn( 456 ); + + $request = new WP_REST_Request( 'POST', '/ollama/v1/api/chat' ); + $request->set_param( 'token', 'test-token-123' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'model' => 'personalos:4o', + 'messages' => $messages, + 'stream' => false, + ) + ) + ); + + $response = $this->ollama_server->post_chat( $request ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertEquals( 'personalos:4o', $data['model'] ); + $this->assertEquals( 'assistant', $data['message']['role'] ); + $this->assertEquals( 'Of course! I\'d be happy to help you with something else.', $data['message']['content'] ); + } + + /** + * Test POST /api/chat endpoint with system messages. + * + * System messages should be filtered out when calculating the hash and + * passed to complete_backscroll, but only non-system messages should be + * used for hash calculation. + * + * @covers POS_Ollama_Server::post_chat + * @covers POS_Ollama_Server::calculate_rolling_hash + */ + public function test_post_chat_with_system_messages() { + $messages = array( + array( + 'role' => 'system', + 'content' => 'You are a helpful assistant.', + ), + array( + 'role' => 'user', + 'content' => 'Hello', + ), + ); + + $mock_response = array( + array( + 'role' => 'user', + 'content' => 'Hello', + ), + array( + 'role' => 'assistant', + 'content' => 'Hello! How can I help you?', + ), + ); + + // complete_backscroll should receive only non-system messages + $this->mock_openai_module->method( 'complete_backscroll' ) + ->willReturn( $mock_response ); + + $this->mock_openai_module->method( 'save_backscroll' ) + ->willReturn( 789 ); + + $request = new WP_REST_Request( 'POST', '/ollama/v1/api/chat' ); + $request->set_param( 'token', 'test-token-123' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'model' => 'personalos:4o', + 'messages' => $messages, + ) + ) + ); + + $response = $this->ollama_server->post_chat( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'message', $data ); + $this->assertEquals( 'assistant', $data['message']['role'] ); + $this->assertEquals( 'Hello! How can I help you?', $data['message']['content'] ); + } + + /** + * Test backscroll hash calculation with system messages excluded. + * + * @covers POS_Ollama_Server::calculate_rolling_hash + */ + public function test_calculate_rolling_hash_excludes_system_messages() { + // Messages with system messages + $messages_with_system = array( + array( + 'role' => 'system', + 'content' => 'You are a helpful assistant.', + ), + array( + 'role' => 'user', + 'content' => 'Hello', + ), + array( + 'role' => 'assistant', + 'content' => 'Hi there!', + ), + ); + + // Same messages without system message + $messages_without_system = array( + array( + 'role' => 'user', + 'content' => 'Hello', + ), + array( + 'role' => 'assistant', + 'content' => 'Hi there!', + ), + ); + + // Use reflection to test private method + $reflection = new ReflectionClass( $this->ollama_server ); + $method = $reflection->getMethod( 'calculate_rolling_hash' ); + $method->setAccessible( true ); + + $hash_with_system = $method->invoke( $this->ollama_server, $messages_with_system ); + $hash_without_system = $method->invoke( $this->ollama_server, $messages_without_system ); + + // Hashes should be the same since system messages are excluded + $this->assertEquals( $hash_with_system, $hash_without_system ); + } + + /** + * Test backscroll hash calculation with messages after last assistant message. + * + * The hash should only include messages up to and including the last assistant message. + * + * @covers POS_Ollama_Server::calculate_rolling_hash + */ + public function test_calculate_rolling_hash_last_assistant_cutoff() { + $messages = array( + array( + 'role' => 'user', + 'content' => 'First user message', + ), + array( + 'role' => 'assistant', + 'content' => 'First assistant response', + ), + array( + 'role' => 'user', + 'content' => 'Second user message', + ), + array( + 'role' => 'assistant', + 'content' => 'Second assistant response', + ), + array( + 'role' => 'user', + 'content' => 'Third user message that should be excluded from hash', + ), + ); + + // Messages that should be included in hash (up to last assistant message) + $expected_messages = array( + array( + 'role' => 'user', + 'content' => 'First user message', + ), + array( + 'role' => 'assistant', + 'content' => 'First assistant response', + ), + array( + 'role' => 'user', + 'content' => 'Second user message', + ), + array( + 'role' => 'assistant', + 'content' => 'Second assistant response', + ), + ); + + // Use reflection to test private method + $reflection = new ReflectionClass( $this->ollama_server ); + $method = $reflection->getMethod( 'calculate_rolling_hash' ); + $method->setAccessible( true ); + + $hash_full = $method->invoke( $this->ollama_server, $messages ); + $hash_expected = $method->invoke( $this->ollama_server, $expected_messages ); + + // Hashes should be the same since messages after last assistant are excluded + $this->assertEquals( $hash_full, $hash_expected ); + } + + /** + * Test backscroll functionality with conversation continuation. + * + * This test simulates a realistic scenario where a client sends conversation + * history and the server needs to continue the conversation and update the hash. + * + * @covers POS_Ollama_Server::post_chat + * @covers POS_Ollama_Server::calculate_rolling_hash + */ + public function test_post_chat_conversation_continuation() { + $conversation_history = array( + array( + 'role' => 'user', + 'content' => 'Tell me about cats', + ), + array( + 'role' => 'assistant', + 'content' => 'Cats are wonderful pets. They are independent and affectionate.', + ), + array( + 'role' => 'user', + 'content' => 'What about their behavior?', + ), + ); + + $expected_new_conversation = array_merge( + $conversation_history, + array( + array( + 'role' => 'assistant', + 'content' => 'Cats exhibit many interesting behaviors like hunting, grooming, and purring.', + ), + ) + ); + + $this->mock_openai_module->method( 'complete_backscroll' ) + ->willReturn( $expected_new_conversation ); + + $this->mock_openai_module->method( 'save_backscroll' ) + ->willReturn( 555 ); + + $request = new WP_REST_Request( 'POST', '/ollama/v1/api/chat' ); + $request->set_param( 'token', 'test-token-123' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'model' => 'personalos:4o', + 'messages' => $conversation_history, + ) + ) + ); + + $response = $this->ollama_server->post_chat( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertEquals( 'personalos:4o', $data['model'] ); + $this->assertEquals( 'assistant', $data['message']['role'] ); + $this->assertEquals( 'Cats exhibit many interesting behaviors like hunting, grooming, and purring.', $data['message']['content'] ); + $this->assertTrue( $data['done'] ); + $this->assertArrayHasKey( 'total_duration', $data ); + $this->assertArrayHasKey( 'eval_count', $data ); + } + + /** + * Test error handling when save_backscroll fails. + * + * @covers POS_Ollama_Server::post_chat + */ + public function test_post_chat_save_backscroll_error() { + $messages = array( + array( + 'role' => 'user', + 'content' => 'Test message', + ), + ); + + $mock_response = array( + array( + 'role' => 'user', + 'content' => 'Test message', + ), + array( + 'role' => 'assistant', + 'content' => 'Test response', + ), + ); + + $this->mock_openai_module->method( 'complete_backscroll' ) + ->willReturn( $mock_response ); + + // Mock save_backscroll to return a WP_Error + $this->mock_openai_module->method( 'save_backscroll' ) + ->willReturn( new WP_Error( 'save_error', 'Failed to save conversation' ) ); + + $request = new WP_REST_Request( 'POST', '/ollama/v1/api/chat' ); + $request->set_param( 'token', 'test-token-123' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'model' => 'personalos:4o', + 'messages' => $messages, + ) + ) + ); + + $response = $this->ollama_server->post_chat( $request ); + + $this->assertEquals( 500, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'error', $data ); + $this->assertStringContainsString( 'Failed to save conversation', $data['error'] ); + } + + /** + * Test rolling hash calculation with only user messages. + * + * @covers POS_Ollama_Server::calculate_rolling_hash + */ + public function test_calculate_rolling_hash_user_only() { + $messages = array( + array( + 'role' => 'user', + 'content' => 'First message', + ), + array( + 'role' => 'user', + 'content' => 'Second message', + ), + ); + + // Use reflection to test private method + $reflection = new ReflectionClass( $this->ollama_server ); + $method = $reflection->getMethod( 'calculate_rolling_hash' ); + $method->setAccessible( true ); + + $hash = $method->invoke( $this->ollama_server, $messages ); + + // Should produce a valid hash even with only user messages + $this->assertIsString( $hash ); + $this->assertEquals( 64, strlen( $hash ) ); + } + + /** + * Test rolling hash calculation with empty messages array. + * + * @covers POS_Ollama_Server::calculate_rolling_hash + */ + public function test_calculate_rolling_hash_empty_messages() { + $messages = array(); + + // Use reflection to test private method + $reflection = new ReflectionClass( $this->ollama_server ); + $method = $reflection->getMethod( 'calculate_rolling_hash' ); + $method->setAccessible( true ); + + $hash = $method->invoke( $this->ollama_server, $messages ); + + // Should handle empty array gracefully + $this->assertIsString( $hash ); + $this->assertEquals( 64, strlen( $hash ) ); + } + + /** + * Test POST /api/chat endpoint with invalid model. + * + * @covers POS_Ollama_Server::post_chat + */ + public function test_post_chat_invalid_model() { + $request = new WP_REST_Request( 'POST', '/ollama/v1/api/chat' ); + $request->set_param( 'token', 'test-token-123' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'model' => 'invalid:model', + 'messages' => array( + array( + 'role' => 'user', + 'content' => 'Hello', + ), + ), + ) + ) + ); + + $response = $this->ollama_server->post_chat( $request ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $this->assertEquals( 404, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'error', $data ); + $this->assertEquals( 'Model not found', $data['error'] ); + } + + /** + * Test POST /api/chat endpoint with invalid JSON. + * + * @covers POS_Ollama_Server::post_chat + */ + public function test_post_chat_invalid_json() { + $request = new WP_REST_Request( 'POST', '/ollama/v1/api/chat' ); + $request->set_param( 'token', 'test-token-123' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( 'invalid json' ); + + $response = $this->ollama_server->post_chat( $request ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $this->assertEquals( 400, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'error', $data ); + $this->assertEquals( 'Invalid JSON', $data['error'] ); + } + + /** + * Test POST /api/chat endpoint with streaming enabled. + * + * @covers POS_Ollama_Server::post_chat + */ + public function test_post_chat_streaming() { + $mock_response = array( + array( + 'role' => 'user', + 'content' => 'Hello', + ), + array( + 'role' => 'assistant', + 'content' => 'Hello! Streaming response.', + ), + ); + + $this->mock_openai_module->method( 'complete_backscroll' ) + ->willReturn( $mock_response ); + + $this->mock_openai_module->method( 'save_backscroll' ) + ->willReturn( 321 ); + + $request = new WP_REST_Request( 'POST', '/ollama/v1/api/chat' ); + $request->set_param( 'token', 'test-token-123' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'model' => 'personalos:4o', + 'messages' => array( + array( + 'role' => 'user', + 'content' => 'Hello', + ), + ), + 'stream' => true, + ) + ) + ); + + $response = $this->ollama_server->post_chat( $request ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertTrue( $data['done'] ); + $this->assertEquals( 'Hello! Streaming response.', $data['message']['content'] ); + } + + /** + * Test rolling hash calculation. + * + * @covers POS_Ollama_Server::calculate_rolling_hash + */ + public function test_calculate_rolling_hash() { + $messages = array( + array( + 'role' => 'user', + 'content' => 'First message', + ), + array( + 'role' => 'assistant', + 'content' => 'First response', + ), + array( + 'role' => 'user', + 'content' => 'Second message', + ), + ); + + // Use reflection to test private method + $reflection = new ReflectionClass( $this->ollama_server ); + $method = $reflection->getMethod( 'calculate_rolling_hash' ); + $method->setAccessible( true ); + + $hash1 = $method->invoke( $this->ollama_server, $messages ); + + // Hash should be consistent + $hash2 = $method->invoke( $this->ollama_server, $messages ); + $this->assertEquals( $hash1, $hash2 ); + + // Hash should be a 64-character string (SHA256) + $this->assertIsString( $hash1 ); + $this->assertEquals( 64, strlen( $hash1 ) ); + + // Different messages should produce different hashes + $different_messages = array( + array( + 'role' => 'user', + 'content' => 'Different message', + ), + ); + $hash3 = $method->invoke( $this->ollama_server, $different_messages ); + $this->assertNotEquals( $hash1, $hash3 ); + } + + /** + * Test that wp_update_post is called to update hash after save_backscroll. + * + * @covers POS_Ollama_Server::post_chat + */ + public function test_post_chat_updates_hash_after_save() { + $messages = array( + array( + 'role' => 'user', + 'content' => 'Test message', + ), + ); + + $mock_response = array_merge( + $messages, + array( + array( + 'role' => 'assistant', + 'content' => 'Test response', + ), + ) + ); + + $this->mock_openai_module->method( 'complete_backscroll' ) + ->willReturn( $mock_response ); + + $this->mock_openai_module->method( 'save_backscroll' ) + ->willReturn( 999 ); + + // We can't easily mock wp_update_post, but we can test that the method completes successfully + $request = new WP_REST_Request( 'POST', '/ollama/v1/api/chat' ); + $request->set_param( 'token', 'test-token-123' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'model' => 'personalos:4o', + 'messages' => $messages, + ) + ) + ); + + $response = $this->ollama_server->post_chat( $request ); + + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * Test that system messages are filtered out when calling complete_backscroll. + * + * @covers POS_Ollama_Server::post_chat + */ + public function test_post_chat_filters_system_messages_for_complete_backscroll() { + $messages = array( + array( + 'role' => 'system', + 'content' => 'You are a helpful assistant.', + ), + array( + 'role' => 'user', + 'content' => 'Hello there', + ), + array( + 'role' => 'system', + 'content' => 'Another system message', + ), + ); + + $mock_response = array( + array( + 'role' => 'user', + 'content' => 'Hello there', + ), + array( + 'role' => 'assistant', + 'content' => 'Hello! How can I help you today?', + ), + ); + + // Capture what gets passed to complete_backscroll + $captured_messages = array(); + $this->mock_openai_module->method( 'complete_backscroll' ) + ->willReturnCallback( + function ( $messages ) use ( $mock_response, &$captured_messages ) { + $captured_messages = $messages; + return $mock_response; + } + ); + + $this->mock_openai_module->method( 'save_backscroll' ) + ->willReturn( 999 ); + + $request = new WP_REST_Request( 'POST', '/ollama/v1/api/chat' ); + $request->set_param( 'token', 'test-token-123' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'model' => 'personalos:4o', + 'messages' => $messages, + ) + ) + ); + + $response = $this->ollama_server->post_chat( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + // Verify that only 1 message was passed (the user message, system messages filtered out) + $this->assertIsArray( $captured_messages ); + $this->assertCount( 1, $captured_messages ); + + // Verify no system messages were passed + foreach ( $captured_messages as $message ) { + $this->assertNotEquals( 'system', $message['role'] ); + } + + // Verify the user message was passed + $user_messages = array_filter( + $captured_messages, + function ( $message ) { + return $message['role'] === 'user'; + } + ); + $this->assertCount( 1, $user_messages ); + } +} \ No newline at end of file From 0acf37916c31a6b5c1b63ea9c27ba3eb853698c7 Mon Sep 17 00:00:00 2001 From: artpi Date: Sun, 1 Jun 2025 19:46:22 +0200 Subject: [PATCH 7/8] Fix some stuff --- modules/openai/class-ollama.php | 1 - modules/openai/class-openai-module.php | 2 +- tests/unit/OllamaServerTest.php | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) delete mode 120000 modules/openai/class-ollama.php diff --git a/modules/openai/class-ollama.php b/modules/openai/class-ollama.php deleted file mode 120000 index 8218270..0000000 --- a/modules/openai/class-ollama.php +++ /dev/null @@ -1 +0,0 @@ -class-pos-ollama-server.php \ No newline at end of file diff --git a/modules/openai/class-openai-module.php b/modules/openai/class-openai-module.php index ea4da25..30bd5b5 100644 --- a/modules/openai/class-openai-module.php +++ b/modules/openai/class-openai-module.php @@ -1,7 +1,7 @@ Date: Sun, 1 Jun 2025 20:08:39 +0200 Subject: [PATCH 8/8] New tests for matching workflow --- tests/unit/OllamaServerTest.php | 343 +++++++++++++++++++++++++++++++- 1 file changed, 342 insertions(+), 1 deletion(-) diff --git a/tests/unit/OllamaServerTest.php b/tests/unit/OllamaServerTest.php index ef417f2..f64dd65 100644 --- a/tests/unit/OllamaServerTest.php +++ b/tests/unit/OllamaServerTest.php @@ -568,7 +568,7 @@ public function test_post_chat_save_backscroll_error() { $mock_response = array( array( 'role' => 'user', - 'content' => 'Test message', + 'content' => 'Test response', ), array( 'role' => 'assistant', @@ -931,4 +931,345 @@ function ( $message ) { ); $this->assertCount( 1, $user_messages ); } + + /** + * Integration test: Test real hash calculation and post matching behavior. + * + * This test verifies that identical conversation histories produce the same hash + * and that the hash calculation follows the expected rules. + * + * @covers POS_Ollama_Server::calculate_rolling_hash + */ + public function test_backscroll_hash_integration_real_calculation() { + // Test the actual hash calculation with real server + $real_openai_module = \POS::get_module_by_id( 'openai' ); + if ( ! $real_openai_module ) { + $this->markTestSkipped( 'OpenAI module not available' ); + return; + } + + // Set up authentication BEFORE creating the server so init_models() is called + if ( ! isset( $real_openai_module->settings ) ) { + $real_openai_module->settings = array(); + } + $real_openai_module->settings['ollama_auth_token'] = array( 'value' => 'test-token-123' ); + + $real_ollama_server = new POS_Ollama_Server( $real_openai_module ); + + // Test conversation history + $conversation_1 = array( + array( + 'role' => 'user', + 'content' => 'What is WordPress?', + ), + array( + 'role' => 'assistant', + 'content' => 'WordPress is a content management system.', + ), + ); + + // Same conversation with additional system message (should produce same hash) + $conversation_2 = array( + array( + 'role' => 'system', + 'content' => 'You are a helpful assistant.', + ), + array( + 'role' => 'user', + 'content' => 'What is WordPress?', + ), + array( + 'role' => 'assistant', + 'content' => 'WordPress is a content management system.', + ), + ); + + // Extended conversation with additional user message AFTER assistant + // This should produce the SAME hash as the first two because hash only + // includes messages up to and including the last assistant message + $conversation_3 = array( + array( + 'role' => 'user', + 'content' => 'What is WordPress?', + ), + array( + 'role' => 'assistant', + 'content' => 'WordPress is a content management system.', + ), + array( + 'role' => 'user', + 'content' => 'Tell me more about themes.', + ), + ); + + // Truly different conversation with different assistant response + $conversation_4 = array( + array( + 'role' => 'user', + 'content' => 'What is WordPress?', + ), + array( + 'role' => 'assistant', + 'content' => 'WordPress is a blog publishing platform.', + ), + ); + + // Use reflection to test the real hash calculation + $reflection = new ReflectionClass( $real_ollama_server ); + $hash_method = $reflection->getMethod( 'calculate_rolling_hash' ); + $hash_method->setAccessible( true ); + + $hash_1 = $hash_method->invoke( $real_ollama_server, $conversation_1 ); + $hash_2 = $hash_method->invoke( $real_ollama_server, $conversation_2 ); + $hash_3 = $hash_method->invoke( $real_ollama_server, $conversation_3 ); + $hash_4 = $hash_method->invoke( $real_ollama_server, $conversation_4 ); + + // Verify that system messages are excluded (hash_1 should equal hash_2) + $this->assertEquals( $hash_1, $hash_2, 'System messages should not affect hash calculation' ); + + // Verify that messages after last assistant are excluded (hash_1 should equal hash_3) + $this->assertEquals( $hash_1, $hash_3, 'Messages after last assistant should not affect hash calculation' ); + + // Verify that different assistant responses produce different hashes + $this->assertNotEquals( $hash_1, $hash_4, 'Different assistant responses should produce different hashes' ); + + // Verify hash format (SHA256) + $this->assertIsString( $hash_1 ); + $this->assertEquals( 64, strlen( $hash_1 ), 'Hash should be 64 characters (SHA256)' ); + $this->assertMatchesRegularExpression( '/^[a-f0-9]{64}$/', $hash_1, 'Hash should be valid hexadecimal' ); + } + + /** + * Integration test: Test that save_backscroll creates posts with correct metadata. + * + * This test uses the real OpenAI module's save_backscroll method to ensure + * posts are created with proper structure and metadata. + */ + public function test_backscroll_save_integration_real_posts() { + $real_openai_module = \POS::get_module_by_id( 'openai' ); + if ( ! $real_openai_module ) { + $this->markTestSkipped( 'OpenAI module not available' ); + return; + } + + // Test conversation data + $backscroll = array( + array( + 'role' => 'user', + 'content' => 'Hello integration test', + ), + array( + 'role' => 'assistant', + 'content' => 'Hello! This is an integration test response.', + ), + ); + + // Test hash calculation + $test_hash = 'integration_test_hash_' . time(); + + // Create post using real save_backscroll + $post_id = $real_openai_module->save_backscroll( $backscroll, array() ); + + $this->assertIsInt( $post_id ); + $this->assertGreaterThan( 0, $post_id ); + + // Add metadata manually since save_backscroll doesn't handle meta_input + update_post_meta( $post_id, 'ollama-hash', $test_hash ); + + // Verify post was created correctly + $created_post = get_post( $post_id ); + $this->assertNotNull( $created_post ); + $this->assertEquals( 'notes', $created_post->post_type ); + $this->assertEquals( 'private', $created_post->post_status ); + + // Verify post content contains conversation + $this->assertStringContainsString( 'Hello integration test', $created_post->post_content ); + $this->assertStringContainsString( 'Hello! This is an integration test response.', $created_post->post_content ); + + // Verify metadata was stored + $stored_hash = get_post_meta( $post_id, 'ollama-hash', true ); + $this->assertEquals( $test_hash, $stored_hash ); + + // Test that searching by hash works + $found_posts = get_posts( + array( + 'post_type' => 'notes', + 'post_status' => 'private', + 'meta_query' => array( + array( + 'key' => 'ollama-hash', + 'value' => $test_hash, + ), + ), + 'numberposts' => 1, + ) + ); + + $this->assertCount( 1, $found_posts ); + $this->assertEquals( $post_id, $found_posts[0]->ID ); + + // Clean up + wp_delete_post( $post_id, true ); + } + + /** + * Integration test: Test complete workflow from hash calculation to post matching. + * + * This simulates the workflow where: + * 1. Initial conversation creates a post with hash + * 2. Continued conversation with same history should find the same post + * 3. Different conversation should create different post + */ + public function test_backscroll_complete_workflow_integration() { + $real_openai_module = \POS::get_module_by_id( 'openai' ); + if ( ! $real_openai_module ) { + $this->markTestSkipped( 'OpenAI module not available' ); + return; + } + + // Set up authentication for hash calculation + if ( ! isset( $real_openai_module->settings ) ) { + $real_openai_module->settings = array(); + } + $real_openai_module->settings['ollama_auth_token'] = array( 'value' => 'test-token-123' ); + + $real_ollama_server = new POS_Ollama_Server( $real_openai_module ); + + // Step 1: Initial conversation + $initial_conversation = array( + array( + 'role' => 'user', + 'content' => 'What is WordPress development?', + ), + array( + 'role' => 'assistant', + 'content' => 'WordPress development involves creating themes, plugins, and custom functionality.', + ), + ); + + // Calculate hash for initial conversation + $reflection = new ReflectionClass( $real_ollama_server ); + $hash_method = $reflection->getMethod( 'calculate_rolling_hash' ); + $hash_method->setAccessible( true ); + $initial_hash = $hash_method->invoke( $real_ollama_server, $initial_conversation ); + + // Create initial post + $initial_post_id = $real_openai_module->save_backscroll( + $initial_conversation, + array( + 'name' => 'test-conversation-1-' . time(), + ) + ); + + $this->assertIsInt( $initial_post_id ); + $this->assertGreaterThan( 0, $initial_post_id ); + + // Add hash metadata manually + update_post_meta( $initial_post_id, 'ollama-hash', $initial_hash ); + + // Step 2: Continue the same conversation + $continued_conversation = array( + array( + 'role' => 'user', + 'content' => 'What is WordPress development?', + ), + array( + 'role' => 'assistant', + 'content' => 'WordPress development involves creating themes, plugins, and custom functionality.', + ), + array( + 'role' => 'user', + 'content' => 'Can you tell me about hooks?', + ), + array( + 'role' => 'assistant', + 'content' => 'WordPress hooks are filters and actions that allow you to modify behavior.', + ), + ); + + $continued_hash = $hash_method->invoke( $real_ollama_server, $continued_conversation ); + + // The hash of the continued conversation should be different from initial + $this->assertNotEquals( $initial_hash, $continued_hash ); + + // But if we search for a post by the previous hash, we should find the original post + $matching_posts = get_posts( + array( + 'post_type' => 'notes', + 'post_status' => 'private', + 'meta_query' => array( + array( + 'key' => 'ollama-hash', + 'value' => $initial_hash, + ), + ), + 'numberposts' => 1, + ) + ); + + $this->assertCount( 1, $matching_posts ); + $this->assertEquals( $initial_post_id, $matching_posts[0]->ID ); + + // Step 3: Create a completely different conversation + $different_conversation = array( + array( + 'role' => 'user', + 'content' => 'Tell me about PHP programming', + ), + array( + 'role' => 'assistant', + 'content' => 'PHP is a server-side scripting language for web development.', + ), + ); + + $different_hash = $hash_method->invoke( $real_ollama_server, $different_conversation ); + + // This should produce a completely different hash + $this->assertNotEquals( $initial_hash, $different_hash ); + $this->assertNotEquals( $continued_hash, $different_hash ); + + // Create a new post for the different conversation + $different_post_id = $real_openai_module->save_backscroll( + $different_conversation, + array( + 'name' => 'test-conversation-2-' . time(), + ) + ); + + $this->assertIsInt( $different_post_id ); + $this->assertGreaterThan( 0, $different_post_id ); + $this->assertNotEquals( $initial_post_id, $different_post_id ); + + // Add hash metadata manually + update_post_meta( $different_post_id, 'ollama-hash', $different_hash ); + + // Verify we now have 2 different posts with different hashes + $all_test_posts = get_posts( + array( + 'post_type' => 'notes', + 'post_status' => 'private', + 'meta_query' => array( + array( + 'key' => 'ollama-hash', + 'compare' => 'EXISTS', + ), + ), + 'numberposts' => -1, + ) + ); + + $test_post_ids = array( $initial_post_id, $different_post_id ); + $found_test_posts = array_filter( + $all_test_posts, + function ( $post ) use ( $test_post_ids ) { + return in_array( $post->ID, $test_post_ids, true ); + } + ); + + $this->assertCount( 2, $found_test_posts ); + + // Clean up + wp_delete_post( $initial_post_id, true ); + wp_delete_post( $different_post_id, true ); + } } \ No newline at end of file