From a43ffdf8f48cdd14b14e8f7290953957e727f537 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Thu, 18 Dec 2025 16:02:21 -0500 Subject: [PATCH 1/8] Add AI Request Logging experiment Adds a comprehensive logging system for AI requests that provides: - Observability: Track all AI operations with request/response previews - Cost tracking: Automatic cost estimation based on provider/model pricing - Admin UI: Full-featured log viewer with filtering, search, and stats - REST API: Endpoints for querying logs programmatically Architecture: - AI_Request_Log_Manager: Facade coordinating all logging operations - AI_Request_Log_Schema: Database table creation and management - AI_Request_Log_Repository: CRUD with caching and cursor pagination - AI_Request_Cost_Calculator: Provider pricing and cost estimation - Logging_HTTP_Client: Decorator for automatic request capture Features: - Configurable retention period and max rows - Daily cleanup cron job - FULLTEXT search with LIKE fallback - Filter by type, provider, status, operation, date range - Summary statistics by period --- docs/experiments/ai-request-logging.md | 33 + includes/Experiment_Loader.php | 1 + .../AI_Request_Logging/AI_Request_Logging.php | 153 ++ .../Logging/AI_Request_Cost_Calculator.php | 189 ++ includes/Logging/AI_Request_Log_Manager.php | 412 ++++ includes/Logging/AI_Request_Log_Page.php | 131 ++ .../Logging/AI_Request_Log_Repository.php | 949 +++++++++ includes/Logging/AI_Request_Log_Schema.php | 278 +++ .../Logging/Logging_Discovery_Strategy.php | 92 + includes/Logging/Logging_HTTP_Client.php | 645 ++++++ .../REST/AI_Request_Log_Controller.php | 460 ++++ includes/helpers.php | 17 + src/admin/_common.scss | 150 ++ src/admin/_dataviews.scss | 26 + src/admin/ai-request-logs/_dataviews-base.css | 1847 +++++++++++++++++ .../components/HeaderPeriodSelector.tsx | 53 + .../components/LogDetailModal.tsx | 352 ++++ .../ai-request-logs/components/LogsTable.tsx | 561 +++++ .../components/SettingsPanel.tsx | 129 ++ .../components/SummaryCards.tsx | 108 + src/admin/ai-request-logs/index.tsx | 419 ++++ src/admin/ai-request-logs/style.scss | 469 +++++ src/admin/ai-request-logs/types.ts | 85 + .../components/ProviderTooltipContent.tsx | 81 + src/admin/components/icons/AiIcon.tsx | 26 + src/admin/components/icons/AnthropicIcon.tsx | 22 + src/admin/components/icons/CloudflareIcon.tsx | 29 + src/admin/components/icons/DeepSeekIcon.tsx | 22 + src/admin/components/icons/FalIcon.tsx | 26 + src/admin/components/icons/GoogleIcon.tsx | 22 + src/admin/components/icons/GrokIcon.tsx | 22 + src/admin/components/icons/GroqIcon.tsx | 22 + .../components/icons/HuggingFaceIcon.tsx | 45 + src/admin/components/icons/McpIcon.tsx | 25 + src/admin/components/icons/OllamaIcon.tsx | 25 + src/admin/components/icons/OpenAiIcon.tsx | 22 + src/admin/components/icons/OpenRouterIcon.tsx | 25 + src/admin/components/icons/XaiIcon.tsx | 22 + src/admin/components/icons/index.ts | 14 + src/admin/components/provider-icons.tsx | 56 + src/admin/hooks/usePersistedView.ts | 99 + .../Logging/AI_Request_Log_ManagerTest.php | 148 ++ webpack.config.js | 5 + 43 files changed, 8317 insertions(+) create mode 100644 docs/experiments/ai-request-logging.md create mode 100644 includes/Experiments/AI_Request_Logging/AI_Request_Logging.php create mode 100644 includes/Logging/AI_Request_Cost_Calculator.php create mode 100644 includes/Logging/AI_Request_Log_Manager.php create mode 100644 includes/Logging/AI_Request_Log_Page.php create mode 100644 includes/Logging/AI_Request_Log_Repository.php create mode 100644 includes/Logging/AI_Request_Log_Schema.php create mode 100644 includes/Logging/Logging_Discovery_Strategy.php create mode 100644 includes/Logging/Logging_HTTP_Client.php create mode 100644 includes/Logging/REST/AI_Request_Log_Controller.php create mode 100644 src/admin/_common.scss create mode 100644 src/admin/_dataviews.scss create mode 100644 src/admin/ai-request-logs/_dataviews-base.css create mode 100644 src/admin/ai-request-logs/components/HeaderPeriodSelector.tsx create mode 100644 src/admin/ai-request-logs/components/LogDetailModal.tsx create mode 100644 src/admin/ai-request-logs/components/LogsTable.tsx create mode 100644 src/admin/ai-request-logs/components/SettingsPanel.tsx create mode 100644 src/admin/ai-request-logs/components/SummaryCards.tsx create mode 100644 src/admin/ai-request-logs/index.tsx create mode 100644 src/admin/ai-request-logs/style.scss create mode 100644 src/admin/ai-request-logs/types.ts create mode 100644 src/admin/components/ProviderTooltipContent.tsx create mode 100644 src/admin/components/icons/AiIcon.tsx create mode 100644 src/admin/components/icons/AnthropicIcon.tsx create mode 100644 src/admin/components/icons/CloudflareIcon.tsx create mode 100644 src/admin/components/icons/DeepSeekIcon.tsx create mode 100644 src/admin/components/icons/FalIcon.tsx create mode 100644 src/admin/components/icons/GoogleIcon.tsx create mode 100644 src/admin/components/icons/GrokIcon.tsx create mode 100644 src/admin/components/icons/GroqIcon.tsx create mode 100644 src/admin/components/icons/HuggingFaceIcon.tsx create mode 100644 src/admin/components/icons/McpIcon.tsx create mode 100644 src/admin/components/icons/OllamaIcon.tsx create mode 100644 src/admin/components/icons/OpenAiIcon.tsx create mode 100644 src/admin/components/icons/OpenRouterIcon.tsx create mode 100644 src/admin/components/icons/XaiIcon.tsx create mode 100644 src/admin/components/icons/index.ts create mode 100644 src/admin/components/provider-icons.tsx create mode 100644 src/admin/hooks/usePersistedView.ts create mode 100644 tests/Integration/Includes/Logging/AI_Request_Log_ManagerTest.php diff --git a/docs/experiments/ai-request-logging.md b/docs/experiments/ai-request-logging.md new file mode 100644 index 00000000..4f653d31 --- /dev/null +++ b/docs/experiments/ai-request-logging.md @@ -0,0 +1,33 @@ +# AI Request Logging + +## Summary +Provides an opt-in observability surface that records every AI request (provider, model, duration, token counts, status, cost estimate) and exposes them through a React-powered dashboard under `Settings → AI Request Logs`. When enabled, outbound HTTP calls made via the WP AI Client are wrapped with a logging HTTP client. + +## Key Hooks & Entry Points +- `WordPress\AI\Experiments\AI_Request_Logging\AI_Request_Logging::register()`: + - `rest_api_init` → registers `WordPress\AI\Logging\REST\AI_Request_Log_Controller`, which exposes `/ai/v1/logs`, `/summary`, `/filters`, and per-log endpoints. + - `is_admin()` guard → instantiates `WordPress\AI\Logging\AI_Request_Log_Page` to add the Settings submenu and enqueue assets. +- Plugin bootstrap now only initializes `WordPress\AI\Logging\Logging_Discovery_Strategy` (and `AI_Request_Log_Manager::init()`) when the experiment toggle is enabled, ensuring the HTTP wrapper and daily cleanup job stay disabled otherwise. +- Database + cleanup are handled inside `AI_Request_Log_Manager::init()` (table creation, cron scheduling, option storage). + +## Assets & Data Flow +1. When `AI Request Logs` is visited, `Asset_Loader` enqueues `admin/ai-request-logs` (`src/admin/ai-request-logs/index.tsx`) plus its stylesheet. The localized payload (`window.AiRequestLogsSettings`) includes REST routes, a nonce, and initial state (enabled flag, retention days, summary, filters). +2. The React app: + - Configures `@wordpress/api-fetch` with the nonce/root. + - Fetches logs (`GET /ai/v1/logs` with search/filter params) and displays them in a table with pagination. + - Fetches summaries (`GET /ai/v1/logs/summary`) for the KPI cards and filter metadata (`GET /ai/v1/logs/filters`). + - Posts to `/ai/v1/logs` to toggle logging and retention, and sends `DELETE /ai/v1/logs` to purge the table. +3. On the backend, every AI HTTP request flows through `Logging_HTTP_Client`, which records metrics via `AI_Request_Log_Manager::log()` before returning the response to callers. Logs are stored in the `wp_ai_request_logs` table alongside JSON context for later inspection. + +## Testing +1. Enable Experiments globally, toggle **AI Request Logging**, and ensure valid AI credentials exist (the experiment won’t enable otherwise). +2. Trigger an AI-powered feature (e.g., Type Ahead or Title Generation) so the system issues at least one completion request. +3. Navigate to `Settings → AI Request Logs`. Confirm the chart and table populate and that the “Logging enabled” toggle reflects the settings page switch. +4. Change the retention days value, save, and verify the option persists (reload the page or inspect `ai_request_logs_retention_days`). +5. Click “Purge logs”, confirm the success notice, and check the table empties. +6. Disable the experiment and reload a front-end AI feature; no new rows should appear, and the `Logging_Discovery_Strategy` hooks should remain inactive. + +## Notes +- The HTTP logging layer only boots when both the global experiment switch and the `ai-request-logging` toggle are on, preventing unnecessary DB tables or cron events on installs that don’t need observability. +- REST endpoints require `manage_options`. +- Model cost estimates rely on the static pricing table inside `AI_Request_Log_Manager`; update it as provider pricing evolves. diff --git a/includes/Experiment_Loader.php b/includes/Experiment_Loader.php index c7223b27..3aafa2f5 100644 --- a/includes/Experiment_Loader.php +++ b/includes/Experiment_Loader.php @@ -107,6 +107,7 @@ private function get_default_experiments(): array { \WordPress\AI\Experiments\Image_Generation\Image_Generation::class, \WordPress\AI\Experiments\Title_Generation\Title_Generation::class, \WordPress\AI\Experiments\Excerpt_Generation\Excerpt_Generation::class, + \WordPress\AI\Experiments\AI_Request_Logging\AI_Request_Logging::class, ); /** diff --git a/includes/Experiments/AI_Request_Logging/AI_Request_Logging.php b/includes/Experiments/AI_Request_Logging/AI_Request_Logging.php new file mode 100644 index 00000000..881811ca --- /dev/null +++ b/includes/Experiments/AI_Request_Logging/AI_Request_Logging.php @@ -0,0 +1,153 @@ + 'ai-request-logging', + 'label' => esc_html__( 'AI Request Logging', 'ai' ), + 'description' => esc_html__( 'Log AI requests for observability, debugging, and cost tracking. View logs under Settings → AI Request Logs.', 'ai' ), + ); + } + + /** + * {@inheritDoc} + */ + public function register(): void { + $manager = $this->get_manager(); + + add_action( + 'rest_api_init', + static function () use ( $manager ) { + $controller = new AI_Request_Log_Controller( $manager ); + $controller->register_routes(); + } + ); + + if ( ! is_admin() ) { + return; + } + + $page = new AI_Request_Log_Page( $manager ); + $page->init(); + } + + /** + * {@inheritDoc} + */ + public function register_settings(): void { + register_setting( + Settings_Registration::OPTION_GROUP, + AI_Request_Log_Manager::OPTION_RETENTION_DAYS, + array( + 'type' => 'integer', + 'default' => AI_Request_Log_Manager::DEFAULT_RETENTION_DAYS, + 'sanitize_callback' => array( $this, 'sanitize_retention_days' ), + ) + ); + } + + /** + * {@inheritDoc} + */ + public function render_settings_fields(): void { + $retention_days = $this->get_manager()->get_retention_days(); + ?> +
+ + +

+ +

+
+ manager ) { + $this->manager = get_request_log_manager() ?? new AI_Request_Log_Manager(); + } + + return $this->manager; + } + + /** + * {@inheritDoc} + */ + public function get_entry_points(): array { + return array( + array( + 'label' => esc_html__( 'Dashboard', 'ai' ), + 'url' => admin_url( 'options-general.php?page=ai-request-logs' ), + 'type' => 'dashboard', + ), + ); + } +} diff --git a/includes/Logging/AI_Request_Cost_Calculator.php b/includes/Logging/AI_Request_Cost_Calculator.php new file mode 100644 index 00000000..3584d67d --- /dev/null +++ b/includes/Logging/AI_Request_Cost_Calculator.php @@ -0,0 +1,189 @@ +> + */ + private static array $model_costs = array( + 'openai' => array( + 'gpt-5.1' => array( + 'input' => 0.00125, + 'output' => 0.01, + ), + 'gpt-5-mini' => array( + 'input' => 0.00025, + 'output' => 0.002, + ), + 'gpt-5-nano' => array( + 'input' => 0.00005, + 'output' => 0.0004, + ), + 'gpt-5-pro' => array( + 'input' => 0.015, + 'output' => 0.12, + ), + 'gpt-4' => array( + 'input' => 0.03, + 'output' => 0.06, + ), + 'gpt-4-turbo' => array( + 'input' => 0.01, + 'output' => 0.03, + ), + 'gpt-4o' => array( + 'input' => 0.005, + 'output' => 0.015, + ), + 'gpt-4o-mini' => array( + 'input' => 0.00015, + 'output' => 0.0006, + ), + 'gpt-3.5-turbo' => array( + 'input' => 0.0005, + 'output' => 0.0015, + ), + ), + 'anthropic' => array( + 'claude-4.5-opus' => array( + 'input' => 0.005, + 'output' => 0.025, + ), + 'claude-4.5-sonnet' => array( + 'input' => 0.003, + 'output' => 0.015, + ), + 'claude-4.5-haiku' => array( + 'input' => 0.001, + 'output' => 0.005, + ), + 'claude-haiku-4-5' => array( + 'input' => 0.001, + 'output' => 0.005, + ), + 'claude-3-opus' => array( + 'input' => 0.015, + 'output' => 0.075, + ), + 'claude-3-5-sonnet' => array( + 'input' => 0.003, + 'output' => 0.015, + ), + 'claude-3-sonnet' => array( + 'input' => 0.003, + 'output' => 0.015, + ), + 'claude-3-haiku' => array( + 'input' => 0.00025, + 'output' => 0.00125, + ), + ), + 'google' => array( + 'gemini-3-pro-preview' => array( + 'input' => 0.002, + 'output' => 0.012, + ), + 'gemini-3-pro-preview-high-context' => array( + 'input' => 0.004, + 'output' => 0.018, + ), + 'gemini-2.5-pro' => array( + 'input' => 0.00125, + 'output' => 0.01, + ), + 'gemini-2.5-pro-high-context' => array( + 'input' => 0.0025, + 'output' => 0.015, + ), + 'gemini-2.5-flash' => array( + 'input' => 0.0003, + 'output' => 0.0025, + ), + 'gemini-2.5-flash-lite' => array( + 'input' => 0.0001, + 'output' => 0.0004, + ), + 'gemini-1.5-pro' => array( + 'input' => 0.00125, + 'output' => 0.005, + ), + 'gemini-1.5-flash' => array( + 'input' => 0.000075, + 'output' => 0.0003, + ), + ), + ); + + /** + * Estimates the cost of an AI request based on token usage. + * + * @since x.x.x + * + * @param string $provider The AI provider identifier. + * @param string $model The model identifier. + * @param int $tokens_input Number of input tokens. + * @param int $tokens_output Number of output tokens. + * @return float|null Estimated cost in USD, or null if pricing is unknown. + */ + public function estimate( string $provider, string $model, int $tokens_input, int $tokens_output ): ?float { + $costs = $this->get_model_costs(); + + $provider_lower = strtolower( $provider ); + $model_lower = strtolower( $model ); + + // Try exact match first. + if ( isset( $costs[ $provider_lower ][ $model_lower ] ) ) { + $pricing = $costs[ $provider_lower ][ $model_lower ]; + return ( $tokens_input / 1000 * $pricing['input'] ) + + ( $tokens_output / 1000 * $pricing['output'] ); + } + + // Try prefix match for model variants (e.g., gpt-4-turbo-preview -> gpt-4-turbo). + if ( isset( $costs[ $provider_lower ] ) ) { + foreach ( $costs[ $provider_lower ] as $model_key => $pricing ) { + if ( str_starts_with( $model_lower, $model_key ) ) { + return ( $tokens_input / 1000 * $pricing['input'] ) + + ( $tokens_output / 1000 * $pricing['output'] ); + } + } + } + + return null; + } + + /** + * Returns the model costs registry, allowing for filtering. + * + * @since x.x.x + * + * @return array> Model pricing data. + */ + public function get_model_costs(): array { + /** + * Filters the model cost registry. + * + * Allows plugins to add or modify pricing data for AI models. + * + * @since x.x.x + * + * @param array> $model_costs Model pricing data. + */ + return apply_filters( 'ai_model_costs', self::$model_costs ); + } +} diff --git a/includes/Logging/AI_Request_Log_Manager.php b/includes/Logging/AI_Request_Log_Manager.php new file mode 100644 index 00000000..ab3e9fde --- /dev/null +++ b/includes/Logging/AI_Request_Log_Manager.php @@ -0,0 +1,412 @@ +schema = $schema ?? new AI_Request_Log_Schema(); + $this->cost_calculator = $cost_calculator ?? new AI_Request_Cost_Calculator(); + $this->repository = $repository ?? new AI_Request_Log_Repository( $this->schema, $this->cost_calculator ); + } + + /** + * Initializes the log manager. + * + * @since 0.1.0 + */ + public function init(): void { + add_action( 'admin_init', array( $this->schema, 'maybe_create_table' ) ); + add_action( 'ai_request_logs_cleanup', array( $this, 'cleanup_old_logs' ) ); + + $this->schema->maybe_create_table(); + + if ( wp_next_scheduled( 'ai_request_logs_cleanup' ) ) { + return; + } + + wp_schedule_event( time(), 'daily', 'ai_request_logs_cleanup' ); + } + + /** + * Whether logging is currently enabled. + * + * @since 0.1.0 + * + * @return bool True if logging is enabled. + */ + public function is_logging_enabled(): bool { + return (bool) get_option( self::OPTION_LOGGING_ENABLED, true ); + } + + /** + * Enables or disables logging. + * + * @since 0.1.0 + * + * @param bool $enabled Whether to enable logging. + */ + public function set_logging_enabled( bool $enabled ): void { + $current_value = get_option( self::OPTION_LOGGING_ENABLED, null ); + + // get_option() returns false when the option is missing, so explicitly check for null. + if ( null === $current_value ) { + add_option( self::OPTION_LOGGING_ENABLED, $enabled, '', false ); + return; + } + + update_option( self::OPTION_LOGGING_ENABLED, $enabled, false ); + } + + /** + * Gets the retention period in days. + * + * @since 0.1.0 + * + * @return int Number of days to retain logs. + */ + public function get_retention_days(): int { + return (int) get_option( self::OPTION_RETENTION_DAYS, self::DEFAULT_RETENTION_DAYS ); + } + + /** + * Sets the retention period in days. + * + * @since 0.1.0 + * + * @param int $days Number of days to retain logs. + */ + public function set_retention_days( int $days ): void { + update_option( self::OPTION_RETENTION_DAYS, max( 1, $days ), false ); + } + + /** + * Gets the maximum number of rows to retain. + * + * @since 0.1.0 + * + * @return int Maximum rows. + */ + public function get_max_rows(): int { + return (int) get_option( self::OPTION_MAX_ROWS, self::DEFAULT_MAX_ROWS ); + } + + /** + * Sets the maximum number of rows to retain. + * + * @since 0.1.0 + * + * @param int $max_rows Maximum rows. + */ + public function set_max_rows( int $max_rows ): void { + update_option( self::OPTION_MAX_ROWS, max( 1000, $max_rows ), false ); + } + + /** + * Starts a timer for measuring request duration. + * + * @since 0.1.0 + * + * @return array{start: int, memory_start: int} Timer data. + */ + public function start_timer(): array { + return array( + 'start' => hrtime( true ), + 'memory_start' => memory_get_usage(), + ); + } + + /** + * Ends a timer and returns duration in milliseconds. + * + * @since 0.1.0 + * + * @param array{start: int, memory_start: int} $timer Timer data from start_timer(). + * @return int Duration in milliseconds. + */ + public function end_timer( array $timer ): int { + $end = hrtime( true ); + return (int) ( ( $end - $timer['start'] ) / 1e6 ); + } + + /** + * Logs an AI request. + * + * @since 0.1.0 + * + * @param array{ + * type: string, + * operation: string, + * provider?: string, + * model?: string, + * duration_ms?: int, + * tokens_input?: int, + * tokens_output?: int, + * status: string, + * error_message?: string, + * user_id?: int, + * context?: array + * } $data Log data. + * @return string|false The log ID on success, false on failure. + */ + public function log( array $data ) { + if ( ! $this->is_logging_enabled() ) { + return false; + } + + return $this->repository->insert( $data ); + } + + /** + * Retrieves a single log entry by ID. + * + * @since 0.1.0 + * + * @param string $log_id The log identifier. + * @return array|null The log entry or null if not found. + */ + public function get_log( string $log_id ): ?array { + return $this->repository->find( $log_id ); + } + + /** + * Retrieves logs with filtering and pagination. + * + * @since 0.1.0 + * + * @param array $args Query arguments. + * @return array{items: list>, total: int, pages: int, next_cursor?: array{id: int, timestamp: string}} Results. + */ + public function get_logs( array $args = array() ): array { + return $this->repository->query( $args ); + } + + /** + * Gets aggregate statistics for the dashboard. + * + * @since 0.1.0 + * + * @param string $period Time period: 'day', 'week', 'month', or 'all'. + * @param bool $force_refresh Whether to bypass the cache. + * @return array Aggregated statistics. + */ + public function get_summary( string $period = 'day', bool $force_refresh = false ): array { + return $this->repository->get_summary( $period, $force_refresh ); + } + + /** + * Gets distinct values for filter dropdowns. + * + * @since 0.1.0 + * + * @param bool $force_refresh Whether to bypass the cache. + * @return array{types: list, providers: list, statuses: list, operations: list} Filter options. + */ + public function get_filter_options( bool $force_refresh = false ): array { + return $this->repository->get_filter_options( $force_refresh ); + } + + /** + * Deletes logs older than the retention period. + * + * @since 0.1.0 + * + * @return int Number of logs deleted. + */ + public function cleanup_old_logs(): int { + $total_deleted = $this->repository->cleanup_by_retention( $this->get_retention_days() ); + $total_deleted += $this->repository->cleanup_by_max_rows( $this->get_max_rows() ); + + if ( $total_deleted > 0 ) { + $this->repository->invalidate_caches(); + } + + return $total_deleted; + } + + /** + * Purges all logs from the database. + * + * @since 0.1.0 + * + * @return int Number of logs deleted. + */ + public function purge_all_logs(): int { + return $this->repository->purge_all(); + } + + /** + * Gets table statistics for admin display. + * + * @since 0.1.0 + * + * @return array{row_count: int, table_size_bytes: int, table_size_formatted: string, max_rows: int, retention_days: int} Table stats. + */ + public function get_table_stats(): array { + $stats = $this->schema->get_table_stats(); + + return array_merge( + $stats, + array( + 'max_rows' => $this->get_max_rows(), + 'retention_days' => $this->get_retention_days(), + ) + ); + } + + /** + * Estimates the cost of an AI request based on token usage. + * + * @since 0.1.0 + * + * @param string $provider The AI provider. + * @param string $model The model identifier. + * @param int $tokens_input Number of input tokens. + * @param int $tokens_output Number of output tokens. + * @return float|null Estimated cost in USD, or null if unknown. + */ + public function estimate_cost( string $provider, string $model, int $tokens_input, int $tokens_output ): ?float { + return $this->cost_calculator->estimate( $provider, $model, $tokens_input, $tokens_output ); + } + + /** + * Invalidates the filter options cache. + * + * @since 0.1.0 + */ + public function invalidate_filter_cache(): void { + $this->repository->invalidate_filter_cache(); + } + + /** + * Invalidates the summary cache. + * + * @since 0.1.0 + */ + public function invalidate_summary_cache(): void { + $this->repository->invalidate_summary_cache(); + } + + /** + * Invalidates all caches. + * + * @since 0.1.0 + */ + public function invalidate_caches(): void { + $this->repository->invalidate_caches(); + } + + /** + * Returns the schema manager for direct access if needed. + * + * @since 0.1.0 + * + * @return \WordPress\AI\Logging\AI_Request_Log_Schema The schema manager. + */ + public function get_schema(): AI_Request_Log_Schema { + return $this->schema; + } + + /** + * Returns the repository for direct access if needed. + * + * @since 0.1.0 + * + * @return \WordPress\AI\Logging\AI_Request_Log_Repository The repository. + */ + public function get_repository(): AI_Request_Log_Repository { + return $this->repository; + } + + /** + * Returns the cost calculator for direct access if needed. + * + * @since 0.1.0 + * + * @return \WordPress\AI\Logging\AI_Request_Cost_Calculator The cost calculator. + */ + public function get_cost_calculator(): AI_Request_Cost_Calculator { + return $this->cost_calculator; + } +} diff --git a/includes/Logging/AI_Request_Log_Page.php b/includes/Logging/AI_Request_Log_Page.php new file mode 100644 index 00000000..93e17fdc --- /dev/null +++ b/includes/Logging/AI_Request_Log_Page.php @@ -0,0 +1,131 @@ +manager = $manager; + } + + /** + * Bootstraps hooks. + */ + public function init(): void { + add_action( 'admin_menu', array( $this, 'register_menu' ) ); + } + + /** + * Registers the options page under Settings. + */ + public function register_menu(): void { + $page_hook = add_options_page( + __( 'AI Request Logs', 'ai' ), + __( 'AI Request Logs', 'ai' ), + 'manage_options', + self::PAGE_SLUG, + array( $this, 'render_page' ) + ); + + if ( ! $page_hook ) { + return; + } + + add_action( "load-{$page_hook}", array( $this, 'on_load' ) ); + } + + /** + * Ensures assets are loaded when the page is visited. + */ + public function on_load(): void { + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + } + + /** + * Enqueues the React bundle and passes localized data. + */ + public function enqueue_assets(): void { + Asset_Loader::enqueue_script( 'ai_request_logs', 'admin/ai-request-logs' ); + Asset_Loader::enqueue_style( 'ai_request_logs', 'admin/style-ai-request-logs' ); + + Asset_Loader::localize_script( + 'ai_request_logs', + 'AiRequestLogsSettings', + array( + 'rest' => array( + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'root' => esc_url_raw( rest_url() ), + 'routes' => array( + 'logs' => 'ai/v1/logs', + 'summary' => 'ai/v1/logs/summary', + 'filters' => 'ai/v1/logs/filters', + ), + ), + 'initialState' => array( + 'enabled' => $this->manager->is_logging_enabled(), + 'retentionDays' => $this->manager->get_retention_days(), + 'summary' => $this->manager->get_summary( 'day' ), + 'filters' => $this->manager->get_filter_options(), + ), + 'providerMetadata' => Provider_Metadata_Registry::get_metadata(), + ) + ); + } + + /** + * Outputs the root DOM node for the React app. + */ + public function render_page(): void { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + ?> +
+
+
+
+ + + +
+

+
+
+
+
+
+
+
+ schema = $schema; + $this->cost_calculator = $cost_calculator; + } + + /** + * Logs an AI request. + * + * @since x.x.x + * + * @param array{ + * type: string, + * operation: string, + * provider?: string, + * model?: string, + * duration_ms?: int, + * tokens_input?: int, + * tokens_output?: int, + * status: string, + * error_message?: string, + * user_id?: int, + * context?: array + * } $data Log data. + * @return string|false The log ID on success, false on failure. + */ + public function insert( array $data ) { + global $wpdb; + + $log_id = wp_generate_uuid4(); + $tokens_total = ( $data['tokens_input'] ?? 0 ) + ( $data['tokens_output'] ?? 0 ); + + $cost_estimate = $this->cost_calculator->estimate( + $data['provider'] ?? '', + $data['model'] ?? '', + $data['tokens_input'] ?? 0, + $data['tokens_output'] ?? 0 + ); + + $context = $data['context'] ?? array(); + $request_preview = $context['input_preview'] ?? null; + $response_preview = $context['output_preview'] ?? null; + + $insert_data = array( + 'log_id' => $log_id, + 'timestamp' => current_time( 'mysql', true ), + 'type' => $data['type'], + 'operation' => $data['operation'], + 'provider' => $data['provider'] ?? null, + 'model' => $data['model'] ?? null, + 'duration_ms' => $data['duration_ms'] ?? null, + 'tokens_input' => $data['tokens_input'] ?? null, + 'tokens_output' => $data['tokens_output'] ?? null, + 'tokens_total' => $tokens_total > 0 ? $tokens_total : null, + 'cost_estimate' => $cost_estimate, + 'status' => $data['status'], + 'error_message' => $data['error_message'] ?? null, + 'user_id' => $data['user_id'] ?? get_current_user_id(), + 'context' => ! empty( $context ) ? wp_json_encode( $context ) : null, + 'request_preview' => $request_preview, + 'response_preview' => $response_preview, + ); + + $result = $wpdb->insert( + $this->schema->get_table_name(), + $insert_data, + array( '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%f', '%s', '%s', '%d', '%s', '%s', '%s' ) + ); + + if ( false === $result ) { + return false; + } + + $this->invalidate_summary_cache(); + + /** + * Fires after an AI request is logged. + * + * @since x.x.x + * + * @param string $log_id The unique log identifier. + * @param array $data The log data. + */ + do_action( 'ai_request_logged', $log_id, $insert_data ); + + return $log_id; + } + + /** + * Retrieves a single log entry by ID. + * + * @since x.x.x + * + * @param string $log_id The log identifier. + * @return array|null The log entry or null if not found. + */ + public function find( string $log_id ): ?array { + global $wpdb; + + $table_name = $this->schema->get_table_name(); + + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT * FROM {$table_name} WHERE log_id = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $log_id + ), + ARRAY_A + ); + + if ( ! $row ) { + return null; + } + + return $this->format_log_row( $row ); + } + + /** + * Retrieves logs with filtering and pagination. + * + * @since x.x.x + * + * @param array{ + * type?: string, + * status?: string, + * provider?: string, + * operation?: string, + * operation_pattern?: string, + * user_id?: int, + * date_from?: string, + * date_to?: string, + * search?: string, + * page?: int, + * per_page?: int, + * orderby?: string, + * order?: string, + * cursor_id?: int, + * cursor_timestamp?: string + * } $args Query arguments. + * @return array{items: list>, total: int, pages: int, next_cursor?: array{id: int, timestamp: string}} Results. + */ + public function query( array $args = array() ): array { + global $wpdb; + + $defaults = array( + 'type' => '', + 'status' => '', + 'provider' => '', + 'operation' => '', + 'operation_pattern' => '', + 'tokens_gt' => null, + 'tokens_lt' => null, + 'tokens_filter' => '', + 'user_id' => 0, + 'date_from' => '', + 'date_to' => '', + 'search' => '', + 'page' => 1, + 'per_page' => 25, + 'orderby' => 'timestamp', + 'order' => 'DESC', + 'cursor_id' => null, + 'cursor_timestamp' => null, + ); + + $args = wp_parse_args( $args, $defaults ); + + $use_cursor = 'timestamp' === $args['orderby'] + && null !== $args['cursor_id'] + && null !== $args['cursor_timestamp']; + + $table_name = $this->schema->get_table_name(); + $where = array( '1=1' ); + $values = array(); + + $this->build_where_clauses( $args, $where, $values ); + + $where_clause = implode( ' AND ', $where ); + + $allowed_orderby = array( 'timestamp', 'type', 'operation', 'duration_ms', 'tokens_total', 'cost_estimate', 'status' ); + $orderby = in_array( $args['orderby'], $allowed_orderby, true ) ? $args['orderby'] : 'timestamp'; + $order = 'ASC' === strtoupper( $args['order'] ) ? 'ASC' : 'DESC'; + + $count_sql = "SELECT COUNT(*) FROM {$table_name} WHERE {$where_clause}"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + if ( ! empty( $values ) ) { + $count_sql = $wpdb->prepare( $count_sql, $values ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } + $total = (int) $wpdb->get_var( $count_sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + + $per_page = max( 1, min( 100, (int) $args['per_page'] ) ); + $pages = (int) ceil( $total / $per_page ); + $page = max( 1, min( ( $pages ? $pages : 1 ), (int) $args['page'] ) ); + + if ( $use_cursor ) { + $rows = $this->query_with_cursor( $table_name, $where_clause, $values, $args, $orderby, $order, $per_page ); + } else { + $rows = $this->query_with_offset( $table_name, $where_clause, $values, $orderby, $order, $per_page, $page ); + } + + $items = array_map( array( $this, 'format_log_row' ), ( $rows ? $rows : array() ) ); + + $result = array( + 'items' => $items, + 'total' => $total, + 'pages' => max( 1, $pages ), + ); + + if ( ! empty( $rows ) ) { + $last_row = end( $rows ); + $result['next_cursor'] = array( + 'id' => (int) $last_row['id'], + 'timestamp' => $last_row['timestamp'], + ); + } + + return $result; + } + + /** + * Gets aggregate statistics for the dashboard. + * + * @since x.x.x + * + * @param string $period Time period: 'day', 'week', 'month', or 'all'. + * @param bool $force_refresh Whether to bypass the cache. + * @return array{ + * total_requests: int, + * total_tokens: int, + * total_cost: float, + * avg_duration_ms: float, + * success_rate: float, + * by_type: array, + * by_provider: array, + * by_status: array + * } Aggregated statistics. + */ + public function get_summary( string $period = 'day', bool $force_refresh = false ): array { + $cache_key = self::CACHE_GROUP . '_summary_' . $period; + + if ( ! $force_refresh ) { + $cached = get_transient( $cache_key ); + if ( false !== $cached ) { + return $cached; + } + } + + global $wpdb; + + $table_name = $this->schema->get_table_name(); + $date_condition = $this->get_date_condition( $period ); + + $sql = "SELECT + COUNT(*) as total_requests, + COALESCE(SUM(tokens_total), 0) as total_tokens, + COALESCE(SUM(cost_estimate), 0) as total_cost, + COALESCE(AVG(duration_ms), 0) as avg_duration_ms, + SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_count, + type, + provider, + status + FROM {$table_name} + WHERE 1=1 {$date_condition} + GROUP BY type, provider, status"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + $rows = $wpdb->get_results( $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + + $result = $this->aggregate_summary_rows( ( $rows ? $rows : array() ) ); + + set_transient( $cache_key, $result, self::SUMMARY_CACHE_EXPIRATION ); + + return $result; + } + + /** + * Gets distinct values for filter dropdowns. + * + * @since x.x.x + * + * @param bool $force_refresh Whether to bypass the cache. + * @return array{types: list, providers: list, statuses: list, operations: list} Filter options. + */ + public function get_filter_options( bool $force_refresh = false ): array { + $cache_key = self::CACHE_GROUP . '_filter_options'; + + if ( ! $force_refresh ) { + $cached = get_transient( $cache_key ); + if ( false !== $cached ) { + return $cached; + } + } + + global $wpdb; + + $table_name = $this->schema->get_table_name(); + + $sql = "SELECT 'type' as category, type as value FROM {$table_name} WHERE type IS NOT NULL GROUP BY type + UNION ALL + SELECT 'provider' as category, provider as value FROM {$table_name} WHERE provider IS NOT NULL GROUP BY provider + UNION ALL + SELECT 'status' as category, status as value FROM {$table_name} WHERE status IS NOT NULL GROUP BY status + UNION ALL + SELECT 'operation' as category, operation as value FROM {$table_name} WHERE operation IS NOT NULL GROUP BY operation"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + $rows = $wpdb->get_results( $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + + $result = $this->aggregate_filter_options( ( $rows ? $rows : array() ) ); + + set_transient( $cache_key, $result, self::FILTER_CACHE_EXPIRATION ); + + return $result; + } + + /** + * Deletes logs older than the retention period. + * + * @since x.x.x + * + * @param int $retention_days Number of days to retain logs. + * @return int Number of logs deleted. + */ + public function cleanup_by_retention( int $retention_days ): int { + global $wpdb; + + $table_name = $this->schema->get_table_name(); + $total_deleted = 0; + + do { + $deleted = $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$table_name} WHERE timestamp < DATE_SUB(NOW(), INTERVAL %d DAY) LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $retention_days, + self::DELETE_BATCH_SIZE + ) + ); + + $batch_deleted = ( $deleted ? $deleted : 0 ); + $total_deleted += $batch_deleted; + + if ( $batch_deleted < self::DELETE_BATCH_SIZE ) { + continue; + } + + usleep( 100000 ); + } while ( $batch_deleted >= self::DELETE_BATCH_SIZE ); + + return $total_deleted; + } + + /** + * Deletes oldest logs when table exceeds max rows limit. + * + * @since x.x.x + * + * @param int $max_rows Maximum number of rows to retain. + * @return int Number of logs deleted. + */ + public function cleanup_by_max_rows( int $max_rows ): int { + global $wpdb; + + $table_name = $this->schema->get_table_name(); + + $current_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table_name}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + if ( $current_count <= $max_rows ) { + return 0; + } + + $rows_to_delete = $current_count - $max_rows; + $total_deleted = 0; + + while ( $rows_to_delete > 0 ) { + $batch_size = min( $rows_to_delete, self::DELETE_BATCH_SIZE ); + + $deleted = $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$table_name} WHERE id IN (SELECT id FROM (SELECT id FROM {$table_name} ORDER BY timestamp ASC LIMIT %d) AS oldest)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $batch_size + ) + ); + + $batch_deleted = ( $deleted ? $deleted : 0 ); + $total_deleted += $batch_deleted; + $rows_to_delete -= $batch_deleted; + + if ( 0 === $batch_deleted ) { + break; + } + + if ( $batch_deleted < self::DELETE_BATCH_SIZE ) { + continue; + } + + usleep( 100000 ); + } + + return $total_deleted; + } + + /** + * Purges all logs from the database. + * + * @since x.x.x + * + * @return int Number of logs deleted. + */ + public function purge_all(): int { + global $wpdb; + + $table_name = $this->schema->get_table_name(); + + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table_name}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + $wpdb->query( "TRUNCATE TABLE {$table_name}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + $this->invalidate_caches(); + + return $count; + } + + /** + * Invalidates all caches. + * + * @since x.x.x + */ + public function invalidate_caches(): void { + $this->invalidate_filter_cache(); + $this->invalidate_summary_cache(); + } + + /** + * Invalidates the filter options cache. + * + * @since x.x.x + */ + public function invalidate_filter_cache(): void { + delete_transient( self::CACHE_GROUP . '_filter_options' ); + } + + /** + * Invalidates the summary cache. + * + * @since x.x.x + */ + public function invalidate_summary_cache(): void { + $periods = array( 'minute', 'hour', 'day', 'week', 'month', 'all' ); + foreach ( $periods as $period ) { + delete_transient( self::CACHE_GROUP . '_summary_' . $period ); + } + } + + /** + * Builds WHERE clauses for the query. + * + * @since x.x.x + * + * @param array $args Query arguments. + * @param list $where WHERE clauses array (modified by reference). + * @param list $values Values array for prepared statement (modified by reference). + */ + private function build_where_clauses( array $args, array &$where, array &$values ): void { + if ( ! empty( $args['type'] ) ) { + $where[] = 'type = %s'; + $values[] = $args['type']; + } + + if ( ! empty( $args['status'] ) ) { + $where[] = 'status = %s'; + $values[] = $args['status']; + } + + if ( ! empty( $args['provider'] ) ) { + $where[] = 'provider = %s'; + $values[] = $args['provider']; + } + + if ( ! empty( $args['operation'] ) ) { + $operations = array_filter( array_map( 'trim', explode( ',', $args['operation'] ) ) ); + if ( 1 === count( $operations ) ) { + $where[] = 'operation = %s'; + $values[] = $operations[0]; + } elseif ( count( $operations ) > 1 ) { + $placeholders = implode( ', ', array_fill( 0, count( $operations ), '%s' ) ); + $where[] = "operation IN ( $placeholders )"; + $values = array_merge( $values, $operations ); + } + } + + if ( ! empty( $args['operation_pattern'] ) ) { + $where[] = 'operation REGEXP %s'; + $values[] = $args['operation_pattern']; + } + + $this->build_token_filter_clauses( $args, $where, $values ); + + if ( ! empty( $args['user_id'] ) ) { + $where[] = 'user_id = %d'; + $values[] = $args['user_id']; + } + + if ( ! empty( $args['date_from'] ) ) { + $where[] = 'timestamp >= %s'; + $values[] = $args['date_from']; + } + + if ( ! empty( $args['date_to'] ) ) { + $where[] = 'timestamp <= %s'; + $values[] = $args['date_to']; + } + + if ( empty( $args['search'] ) ) { + return; + } + + $this->build_search_clause( $args['search'], $where, $values ); + } + + /** + * Builds token filter clauses. + * + * @since x.x.x + * + * @param array $args Query arguments. + * @param list $where WHERE clauses array (modified by reference). + * @param list $values Values array (modified by reference). + */ + private function build_token_filter_clauses( array $args, array &$where, array &$values ): void { + if ( isset( $args['tokens_gt'] ) && is_numeric( $args['tokens_gt'] ) ) { + $where[] = 'tokens_total > %d'; + $values[] = (int) $args['tokens_gt']; + } + + if ( isset( $args['tokens_lt'] ) && is_numeric( $args['tokens_lt'] ) ) { + $where[] = 'tokens_total < %d'; + $values[] = (int) $args['tokens_lt']; + } + + if ( empty( $args['tokens_filter'] ) ) { + return; + } + + $filter = $args['tokens_filter']; + if ( 'none' === $filter ) { + $where[] = '(tokens_total IS NULL OR tokens_total = 0)'; + } elseif ( str_starts_with( $filter, 'gt:' ) ) { + $where[] = 'tokens_total > %d'; + $values[] = (int) substr( $filter, 3 ); + } elseif ( str_starts_with( $filter, 'lt:' ) ) { + $where[] = 'tokens_total < %d'; + $values[] = (int) substr( $filter, 3 ); + } + } + + /** + * Builds the search clause for full-text or LIKE search. + * + * @since x.x.x + * + * @param string $search Search term. + * @param list $where WHERE clauses array (modified by reference). + * @param list $values Values array (modified by reference). + */ + private function build_search_clause( string $search, array &$where, array &$values ): void { + global $wpdb; + + $search_like = '%' . $wpdb->esc_like( $search ) . '%'; + + // Only use fulltext if index exists AND autocommit is enabled (tests use transactions). + $use_fulltext = $this->schema->has_fulltext_index() && $this->is_autocommit_enabled(); + + if ( $use_fulltext ) { + $boolean_query = $this->build_fulltext_search_query( $search ); + + if ( '' !== $boolean_query ) { + $where[] = '(MATCH(operation, request_preview, response_preview) AGAINST(%s IN BOOLEAN MODE) OR error_message LIKE %s)'; + $values[] = $boolean_query; + $values[] = $search_like; + return; + } + } + + $where[] = '(operation LIKE %s OR error_message LIKE %s OR request_preview LIKE %s OR response_preview LIKE %s)'; + $values[] = $search_like; + $values[] = $search_like; + $values[] = $search_like; + $values[] = $search_like; + } + + /** + * Builds a boolean-mode FULLTEXT query string. + * + * @since x.x.x + * + * @param string $search Raw search string. + * @return string Boolean FULLTEXT query or empty string. + */ + private function build_fulltext_search_query( string $search ): string { + $search = trim( $search ); + + if ( '' === $search ) { + return ''; + } + + $tokens = preg_split( '/\s+/', $search ); + if ( ! $tokens ) { + return ''; + } + + $clauses = array(); + + foreach ( $tokens as $token ) { + $token = trim( (string) $token ); + if ( '' === $token ) { + continue; + } + + $token = preg_replace( '/[+\-><()~*"@]+/', ' ', $token ); + $token = trim( (string) $token ); + + if ( '' === $token ) { + continue; + } + + $length = function_exists( 'mb_strlen' ) + ? mb_strlen( $token, 'UTF-8' ) + : strlen( $token ); + + if ( $length < 3 ) { + continue; + } + + $clauses[] = '+' . $token . '*'; + } + + return implode( ' ', $clauses ); + } + + /** + * Determines if the current database connection has autocommit enabled. + * + * In transactional test environments (e.g., WP_UnitTestCase), fulltext indexes + * are not updated until commit, so prefer LIKE queries when autocommit is off. + * + * @since x.x.x + * + * @return bool True if autocommit is enabled. + */ + private function is_autocommit_enabled(): bool { + global $wpdb; + + $autocommit = $wpdb->get_var( 'SELECT @@autocommit' ); + + if ( null === $autocommit ) { + return true; + } + + return 1 === (int) $autocommit; + } + + /** + * Queries logs using cursor-based pagination. + * + * @since x.x.x + * + * @param string $table_name Table name. + * @param string $where_clause WHERE clause. + * @param list $values Prepared values. + * @param array $args Query arguments. + * @param string $orderby Order by column. + * @param string $order Sort order. + * @param int $per_page Results per page. + * @return list> Query results. + */ + private function query_with_cursor( string $table_name, string $where_clause, array $values, array $args, string $orderby, string $order, int $per_page ): array { + global $wpdb; + + $cursor_values = $values; + + if ( 'DESC' === $order ) { + $cursor_values[] = $args['cursor_timestamp']; + $cursor_values[] = $args['cursor_timestamp']; + $cursor_values[] = (int) $args['cursor_id']; + $cursor_condition = '((timestamp < %s) OR (timestamp = %s AND id < %d))'; + } else { + $cursor_values[] = $args['cursor_timestamp']; + $cursor_values[] = $args['cursor_timestamp']; + $cursor_values[] = (int) $args['cursor_id']; + $cursor_condition = '((timestamp > %s) OR (timestamp = %s AND id > %d))'; + } + + $sql = "SELECT * FROM {$table_name} WHERE {$where_clause} AND {$cursor_condition} ORDER BY {$orderby} {$order}, id {$order} LIMIT %d"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $cursor_values[] = $per_page; + + $results = $wpdb->get_results( + $wpdb->prepare( $sql, $cursor_values ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + ARRAY_A + ); + + return ( $results ? $results : array() ); + } + + /** + * Queries logs using offset-based pagination. + * + * @since x.x.x + * + * @param string $table_name Table name. + * @param string $where_clause WHERE clause. + * @param list $values Prepared values. + * @param string $orderby Order by column. + * @param string $order Sort order. + * @param int $per_page Results per page. + * @param int $page Current page number. + * @return list> Query results. + */ + private function query_with_offset( string $table_name, string $where_clause, array $values, string $orderby, string $order, int $per_page, int $page ): array { + global $wpdb; + + $offset = ( $page - 1 ) * $per_page; + + $sql = "SELECT * FROM {$table_name} WHERE {$where_clause} ORDER BY {$orderby} {$order}, id {$order} LIMIT %d OFFSET %d"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $values[] = $per_page; + $values[] = $offset; + + $results = $wpdb->get_results( + $wpdb->prepare( $sql, $values ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + ARRAY_A + ); + + return ( $results ? $results : array() ); + } + + /** + * Gets the SQL date condition for a time period. + * + * @since x.x.x + * + * @param string $period Time period. + * @return string SQL condition. + */ + private function get_date_condition( string $period ): string { + switch ( $period ) { + case 'minute': + return 'AND timestamp >= DATE_SUB(NOW(), INTERVAL 1 MINUTE)'; + case 'hour': + return 'AND timestamp >= DATE_SUB(NOW(), INTERVAL 1 HOUR)'; + case 'day': + return 'AND timestamp >= DATE_SUB(NOW(), INTERVAL 1 DAY)'; + case 'week': + return 'AND timestamp >= DATE_SUB(NOW(), INTERVAL 1 WEEK)'; + case 'month': + return 'AND timestamp >= DATE_SUB(NOW(), INTERVAL 1 MONTH)'; + default: + return ''; + } + } + + /** + * Aggregates summary rows into statistics. + * + * @since x.x.x + * + * @param list> $rows Raw query rows. + * @return array Aggregated statistics. + */ + private function aggregate_summary_rows( array $rows ): array { + $total_requests = 0; + $total_tokens = 0; + $total_cost = 0.0; + $total_duration = 0.0; + $success_count = 0; + $by_type = array(); + $by_provider = array(); + $by_status = array(); + + foreach ( $rows as $row ) { + $count = (int) $row['total_requests']; + + $total_requests += $count; + $total_tokens += (int) $row['total_tokens']; + $total_cost += (float) $row['total_cost']; + $total_duration += (float) $row['avg_duration_ms'] * $count; + $success_count += (int) $row['success_count']; + + if ( $row['type'] ) { + $by_type[ $row['type'] ] = ( $by_type[ $row['type'] ] ?? 0 ) + $count; + } + + if ( $row['provider'] ) { + $by_provider[ $row['provider'] ] = ( $by_provider[ $row['provider'] ] ?? 0 ) + $count; + } + + if ( ! $row['status'] ) { + continue; + } + + $by_status[ $row['status'] ] = ( $by_status[ $row['status'] ] ?? 0 ) + $count; + } + + $avg_duration_ms = $total_requests > 0 ? $total_duration / $total_requests : 0; + $success_rate = $total_requests > 0 ? $success_count / $total_requests * 100 : 0; + + return array( + 'total_requests' => $total_requests, + 'total_tokens' => $total_tokens, + 'total_cost' => $total_cost, + 'avg_duration_ms' => round( $avg_duration_ms, 2 ), + 'success_rate' => round( $success_rate, 2 ), + 'by_type' => $by_type, + 'by_provider' => $by_provider, + 'by_status' => $by_status, + ); + } + + /** + * Aggregates filter option rows. + * + * @since x.x.x + * + * @param list> $rows Raw query rows. + * @return array{types: list, providers: list, statuses: list, operations: list} Filter options. + */ + private function aggregate_filter_options( array $rows ): array { + $result = array( + 'types' => array(), + 'providers' => array(), + 'statuses' => array(), + 'operations' => array(), + ); + + foreach ( $rows as $row ) { + switch ( $row['category'] ) { + case 'type': + $result['types'][] = $row['value']; + break; + case 'provider': + $result['providers'][] = $row['value']; + break; + case 'status': + $result['statuses'][] = $row['value']; + break; + case 'operation': + $result['operations'][] = $row['value']; + break; + } + } + + sort( $result['types'] ); + sort( $result['providers'] ); + sort( $result['statuses'] ); + sort( $result['operations'] ); + + return $result; + } + + /** + * Formats a raw database row into a structured log entry. + * + * @since x.x.x + * + * @param array $row Raw database row. + * @return array Formatted log entry. + */ + private function format_log_row( array $row ): array { + $duration = $row['duration_ms'] ? (int) $row['duration_ms'] : null; + $tokens_total = $row['tokens_total'] ? (int) $row['tokens_total'] : null; + $tokens_per_second = null; + + if ( null !== $tokens_total && $duration && $duration > 0 ) { + $tokens_per_second = $tokens_total / ( $duration / 1000 ); + } + + return array( + 'id' => $row['log_id'], + 'timestamp' => $row['timestamp'], + 'type' => $row['type'], + 'operation' => $row['operation'], + 'provider' => $row['provider'], + 'model' => $row['model'], + 'duration_ms' => $duration, + 'tokens_input' => $row['tokens_input'] ? (int) $row['tokens_input'] : null, + 'tokens_output' => $row['tokens_output'] ? (int) $row['tokens_output'] : null, + 'tokens_total' => $tokens_total, + 'tokens_per_second' => null !== $tokens_per_second ? (float) $tokens_per_second : null, + 'cost_estimate' => $row['cost_estimate'] ? (float) $row['cost_estimate'] : null, + 'status' => $row['status'], + 'error_message' => $row['error_message'], + 'user_id' => $row['user_id'] ? (int) $row['user_id'] : null, + 'context' => $row['context'] ? json_decode( $row['context'], true ) : null, + ); + } +} diff --git a/includes/Logging/AI_Request_Log_Schema.php b/includes/Logging/AI_Request_Log_Schema.php new file mode 100644 index 00000000..9da607ae --- /dev/null +++ b/includes/Logging/AI_Request_Log_Schema.php @@ -0,0 +1,278 @@ +get_table_name(); + + // Skip if table already exists. + $existing_table = $wpdb->get_var( + $wpdb->prepare( + 'SHOW TABLES LIKE %s', + $table_name + ) + ); + + if ( $existing_table === $table_name ) { + return; + } + + $this->create_table(); + } + + /** + * Returns the full table name with prefix. + * + * @since x.x.x + * + * @return string The prefixed table name. + */ + public function get_table_name(): string { + global $wpdb; + return $wpdb->prefix . self::TABLE_NAME; + } + + /** + * Creates the logs database table. + * + * @since x.x.x + */ + private function create_table(): void { + global $wpdb; + + $table_name = $this->get_table_name(); + $charset_collate = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE {$table_name} ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + log_id VARCHAR(36) NOT NULL, + timestamp DATETIME NOT NULL, + type VARCHAR(32) NOT NULL, + operation VARCHAR(255) NOT NULL, + provider VARCHAR(64) DEFAULT NULL, + model VARCHAR(128) DEFAULT NULL, + duration_ms INT UNSIGNED DEFAULT NULL, + tokens_input INT UNSIGNED DEFAULT NULL, + tokens_output INT UNSIGNED DEFAULT NULL, + tokens_total INT UNSIGNED DEFAULT NULL, + cost_estimate DECIMAL(10, 6) DEFAULT NULL, + status VARCHAR(32) NOT NULL, + error_message TEXT DEFAULT NULL, + user_id BIGINT UNSIGNED DEFAULT NULL, + context JSON DEFAULT NULL, + request_preview TEXT DEFAULT NULL, + response_preview TEXT DEFAULT NULL, + INDEX idx_timestamp (timestamp), + INDEX idx_type (type), + INDEX idx_status (status), + INDEX idx_user_id (user_id), + INDEX idx_log_id (log_id), + INDEX idx_provider (provider), + INDEX idx_operation (operation(191)), + INDEX idx_timestamp_type_status (timestamp, type, status), + INDEX idx_timestamp_provider (timestamp, provider) + ) {$charset_collate};"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( $sql ); + + $this->maybe_add_columns(); + $this->maybe_add_indexes(); + } + + /** + * Adds missing columns to existing tables. + * + * @since x.x.x + */ + private function maybe_add_columns(): void { + global $wpdb; + + $table_name = $this->get_table_name(); + + $existing_columns = array(); + $columns = $wpdb->get_results( "SHOW COLUMNS FROM {$table_name}", ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + if ( $columns ) { + foreach ( $columns as $column ) { + $existing_columns[ $column['Field'] ] = true; + } + } + + if ( ! isset( $existing_columns['request_preview'] ) ) { + $wpdb->query( "ALTER TABLE {$table_name} ADD COLUMN request_preview TEXT DEFAULT NULL AFTER context" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + if ( isset( $existing_columns['response_preview'] ) ) { + return; + } + + $wpdb->query( "ALTER TABLE {$table_name} ADD COLUMN response_preview TEXT DEFAULT NULL AFTER request_preview" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + /** + * Adds missing indexes to existing tables. + * + * @since x.x.x + */ + private function maybe_add_indexes(): void { + global $wpdb; + + $table_name = $this->get_table_name(); + + $existing_indexes = array(); + $indexes = $wpdb->get_results( "SHOW INDEX FROM {$table_name}", ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + if ( $indexes ) { + foreach ( $indexes as $index ) { + $existing_indexes[ $index['Key_name'] ] = true; + } + } + + $indexes_to_add = array( + 'idx_provider' => "ALTER TABLE {$table_name} ADD INDEX idx_provider (provider)", + 'idx_operation' => "ALTER TABLE {$table_name} ADD INDEX idx_operation (operation(191))", + 'idx_timestamp_type_status' => "ALTER TABLE {$table_name} ADD INDEX idx_timestamp_type_status (timestamp, type, status)", + 'idx_timestamp_provider' => "ALTER TABLE {$table_name} ADD INDEX idx_timestamp_provider (timestamp, provider)", + ); + + foreach ( $indexes_to_add as $index_name => $sql ) { + if ( isset( $existing_indexes[ $index_name ] ) ) { + continue; + } + + $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } + + $this->maybe_add_fulltext_index( $existing_indexes ); + } + + /** + * Adds FULLTEXT index for search if MySQL supports it. + * + * @since x.x.x + * + * @param array $existing_indexes Map of existing index names. + */ + private function maybe_add_fulltext_index( array $existing_indexes ): void { + if ( isset( $existing_indexes['ft_search'] ) ) { + return; + } + + global $wpdb; + + $table_name = $this->get_table_name(); + $mysql_version = $wpdb->get_var( 'SELECT VERSION()' ); + + if ( ! version_compare( $mysql_version, '5.6', '>=' ) ) { + return; + } + + $wpdb->suppress_errors( true ); + $wpdb->query( "ALTER TABLE {$table_name} ADD FULLTEXT INDEX ft_search (operation, request_preview, response_preview)" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->suppress_errors( false ); + } + + /** + * Checks if the FULLTEXT search index exists on the table. + * + * @since x.x.x + * + * @return bool True if FULLTEXT index exists. + */ + public function has_fulltext_index(): bool { + static $has_index = null; + + if ( null !== $has_index ) { + return $has_index; + } + + global $wpdb; + + $table_name = $this->get_table_name(); + + $result = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = %s + AND table_name = %s + AND index_name = 'ft_search' + AND index_type = 'FULLTEXT'", + DB_NAME, + $table_name + ) + ); + + $has_index = (int) $result > 0; + + return $has_index; + } + + /** + * Gets table statistics for admin display. + * + * @since x.x.x + * + * @return array{row_count: int, table_size_bytes: int, table_size_formatted: string} Table stats. + */ + public function get_table_stats(): array { + global $wpdb; + + $table_name = $this->get_table_name(); + + $row_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table_name}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + $table_status = $wpdb->get_row( + $wpdb->prepare( + 'SELECT data_length + index_length as size FROM information_schema.tables WHERE table_schema = %s AND table_name = %s', + DB_NAME, + $table_name + ), + ARRAY_A + ); + + $table_size_bytes = (int) ( $table_status['size'] ?? 0 ); + + return array( + 'row_count' => $row_count, + 'table_size_bytes' => $table_size_bytes, + 'table_size_formatted' => size_format( $table_size_bytes ), + ); + } +} diff --git a/includes/Logging/Logging_Discovery_Strategy.php b/includes/Logging/Logging_Discovery_Strategy.php new file mode 100644 index 00000000..54d0578e --- /dev/null +++ b/includes/Logging/Logging_Discovery_Strategy.php @@ -0,0 +1,92 @@ +> + */ + public static function getCandidates( $type ) { + // Only handle PSR-18 HTTP Client discovery. + if ( ClientInterface::class !== $type ) { + return array(); + } + + // If logging is disabled or manager not set, return empty to fall through. + if ( ! self::$log_manager || ! self::$log_manager->is_logging_enabled() ) { + return array(); + } + + return array( + array( + 'class' => static function () { + return self::createLoggingClient(); + }, + ), + ); + } + + /** + * Create an instance of the logging HTTP client. + * + * @return \WordPress\AI\Logging\Logging_HTTP_Client + */ + private static function createLoggingClient(): Logging_HTTP_Client { + $psr17_factory = new Psr17Factory(); + + // Create the underlying WordPress HTTP client. + $wordpress_client = new WordPress_HTTP_Client( + $psr17_factory, + $psr17_factory + ); + + // Wrap it with logging. + return new Logging_HTTP_Client( $wordpress_client, self::$log_manager ); + } +} diff --git a/includes/Logging/Logging_HTTP_Client.php b/includes/Logging/Logging_HTTP_Client.php new file mode 100644 index 00000000..8dec53c4 --- /dev/null +++ b/includes/Logging/Logging_HTTP_Client.php @@ -0,0 +1,645 @@ +client = $client; + $this->log_manager = $log_manager; + } + + /** + * Sends a PSR-7 request and returns a PSR-7 response, logging the request. + * + * @param \Psr\Http\Message\RequestInterface $request The PSR-7 request. + * @return \Psr\Http\Message\ResponseInterface The PSR-7 response. + * @throws \Psr\Http\Client\ClientExceptionInterface If an error happens during processing. + */ + public function sendRequest( RequestInterface $request ): ResponseInterface { + $timer = $this->log_manager->start_timer(); + $log_data = $this->extract_request_data( $request ); + $error_msg = null; + $status = 'success'; + + try { + $response = $this->client->sendRequest( $request ); + + $this->extract_response_data( $response, $log_data ); + + return $response; + } catch ( Throwable $e ) { + $status = 'error'; + $error_msg = $e->getMessage(); + throw $e; + } finally { + $log_data['duration_ms'] = $this->log_manager->end_timer( $timer ); + $log_data['status'] = $status; + $log_data['error_message'] = $error_msg; + + $this->log_manager->log( $log_data ); + } + } + + /** + * Sends a PSR-7 request with options and returns a PSR-7 response, logging the request. + * + * @param \Psr\Http\Message\RequestInterface $request The PSR-7 request. + * @param \WordPress\AiClient\Providers\Http\DTO\RequestOptions $options Transport options for the request. + * @return \Psr\Http\Message\ResponseInterface The PSR-7 response. + * @throws \Psr\Http\Client\ClientExceptionInterface If an error happens during processing. + */ + public function sendRequestWithOptions( RequestInterface $request, RequestOptions $options ): ResponseInterface { + // If the wrapped client doesn't support options, fall back to regular send. + if ( ! $this->client instanceof ClientWithOptionsInterface ) { + return $this->sendRequest( $request ); + } + + $timer = $this->log_manager->start_timer(); + $log_data = $this->extract_request_data( $request ); + $error_msg = null; + $status = 'success'; + + try { + $response = $this->client->sendRequestWithOptions( $request, $options ); + + $this->extract_response_data( $response, $log_data ); + + return $response; + } catch ( Throwable $e ) { + $status = 'error'; + $error_msg = $e->getMessage(); + throw $e; + } finally { + $log_data['duration_ms'] = $this->log_manager->end_timer( $timer ); + $log_data['status'] = $status; + $log_data['error_message'] = $error_msg; + + $this->log_manager->log( $log_data ); + } + } + + /** + * Extracts logging data from the request. + * + * @param \Psr\Http\Message\RequestInterface $request The PSR-7 request. + * @return array Initial log data. + */ + private function extract_request_data( RequestInterface $request ): array { + $uri = (string) $request->getUri(); + $provider = $this->detect_provider( $uri ); + $model = null; + $decoded = null; + + // Try to extract model from request body. + $body = (string) $request->getBody(); + if ( $body && $request->getBody()->isSeekable() ) { + $request->getBody()->rewind(); + } + + if ( $body ) { + $decoded = json_decode( $body, true ); + if ( is_array( $decoded ) && isset( $decoded['model'] ) ) { + $model = (string) $decoded['model']; + } + } + + // Build operation name from URL path. + $path = $request->getUri()->getPath(); + $operation = $provider ? $provider . ':' . basename( $path ) : basename( $path ); + + $context = array( + 'url' => $uri, + 'method' => $request->getMethod(), + ); + + if ( is_array( $decoded ) ) { + $input_preview = $this->extract_input_preview( $decoded ); + if ( $input_preview ) { + $context['input_preview'] = $input_preview; + } + } + + $context['request_kind'] = $this->detect_request_kind( $provider, $path, $decoded ); + + return array( + 'type' => 'ai_client', + 'operation' => $operation, + 'provider' => $provider, + 'model' => $model, + 'context' => $context, + ); + } + + /** + * Extracts token usage and other data from the response. + * + * @param \Psr\Http\Message\ResponseInterface $response The PSR-7 response. + * @param array $log_data Log data to update (passed by reference). + */ + private function extract_response_data( ResponseInterface $response, array &$log_data ): void { + $body = (string) $response->getBody(); + if ( $response->getBody()->isSeekable() ) { + $response->getBody()->rewind(); + } + + if ( ! $body ) { + return; + } + + $decoded = json_decode( $body, true ); + if ( ! is_array( $decoded ) ) { + return; + } + + // Extract model if not already set. + if ( empty( $log_data['model'] ) && isset( $decoded['model'] ) ) { + $log_data['model'] = (string) $decoded['model']; + } + + // Extract token usage - OpenAI format. + if ( isset( $decoded['usage'] ) && is_array( $decoded['usage'] ) ) { + $usage = $decoded['usage']; + $log_data['tokens_input'] = $usage['prompt_tokens'] ?? $usage['input_tokens'] ?? null; + $log_data['tokens_output'] = $usage['completion_tokens'] ?? $usage['output_tokens'] ?? null; + } + + // Anthropic format. + if ( isset( $decoded['usage']['input_tokens'] ) ) { + $log_data['tokens_input'] = $decoded['usage']['input_tokens']; + $log_data['tokens_output'] = $decoded['usage']['output_tokens'] ?? null; + } + + // Google format. + if ( isset( $decoded['usageMetadata'] ) && is_array( $decoded['usageMetadata'] ) ) { + $usage = $decoded['usageMetadata']; + $log_data['tokens_input'] = $usage['promptTokenCount'] ?? null; + $log_data['tokens_output'] = $usage['candidatesTokenCount'] ?? null; + } + + $context = array(); + if ( isset( $log_data['context'] ) && is_array( $log_data['context'] ) ) { + $context = $log_data['context']; + } elseif ( isset( $log_data['context'] ) && is_string( $log_data['context'] ) ) { + $maybe_context = json_decode( $log_data['context'], true ); + if ( is_array( $maybe_context ) ) { + $context = $maybe_context; + } + } + + $output_preview = $this->extract_output_preview( $decoded ); + if ( $output_preview ) { + $context['output_preview'] = $output_preview; + } + + if ( is_array( $decoded ) ) { + $media_context = $this->extract_media_context( $decoded ); + if ( ! empty( $media_context ) ) { + $context = array_merge( $context, $media_context ); + } + } + + if ( empty( $context ) ) { + return; + } + + $log_data['context'] = $context; + } + + /** + * Detects the AI provider from the request URL. + * + * @param string $url The request URL. + * @return string|null The detected provider name or null. + */ + private function detect_provider( string $url ): ?string { + $host = parse_url( $url, PHP_URL_HOST ); + + if ( ! $host ) { + return null; + } + + $host_lower = strtolower( $host ); + + if ( strpos( $host_lower, 'openai' ) !== false ) { + return 'openai'; + } + + if ( strpos( $host_lower, 'anthropic' ) !== false ) { + return 'anthropic'; + } + + if ( strpos( $host_lower, 'googleapis' ) !== false || strpos( $host_lower, 'google' ) !== false ) { + return 'google'; + } + + if ( strpos( $host_lower, 'fal.run' ) !== false || strpos( $host_lower, 'fal.ai' ) !== false ) { + return 'fal'; + } + + if ( strpos( $host_lower, 'cloudflare' ) !== false || strpos( $host_lower, 'workers.ai' ) !== false ) { + return 'cloudflare'; + } + + if ( strpos( $host_lower, 'groq' ) !== false ) { + return 'groq'; + } + + if ( strpos( $host_lower, 'x.ai' ) !== false || strpos( $host_lower, 'xai' ) !== false ) { + return 'grok'; + } + + if ( strpos( $host_lower, 'huggingface' ) !== false ) { + return 'huggingface'; + } + + if ( strpos( $host_lower, 'deepseek' ) !== false ) { + return 'deepseek'; + } + + if ( strpos( $host_lower, 'ollama' ) !== false ) { + return 'ollama'; + } + + if ( strpos( $host_lower, 'openrouter' ) !== false ) { + return 'openrouter'; + } + + if ( strpos( $host_lower, 'azure' ) !== false ) { + return 'azure'; + } + + if ( strpos( $host_lower, 'cohere' ) !== false ) { + return 'cohere'; + } + + if ( strpos( $host_lower, 'mistral' ) !== false ) { + return 'mistral'; + } + + return null; + } + + /** + * Extracts a human-readable preview of the prompt/input payload. + * + * @param array|null $payload Request payload. + * @return string|null + */ + private function extract_input_preview( ?array $payload ): ?string { + if ( empty( $payload ) ) { + return null; + } + + if ( isset( $payload['messages'] ) && is_array( $payload['messages'] ) ) { + $segments = array(); + + foreach ( $payload['messages'] as $message ) { + if ( ! is_array( $message ) ) { + continue; + } + + $role = $message['role'] ?? 'user'; + $content = $this->stringify_content( $message['content'] ?? '' ); + + if ( '' === $content ) { + continue; + } + + $segments[] = sprintf( '[%s] %s', $role, $content ); + + if ( strlen( implode( "\n", $segments ) ) >= self::PAYLOAD_PREVIEW_LIMIT ) { + break; + } + } + + if ( $segments ) { + return $this->truncate_string( implode( "\n", $segments ) ); + } + } + + foreach ( array( 'prompt', 'input', 'contents' ) as $field ) { + if ( ! isset( $payload[ $field ] ) ) { + continue; + } + + $content = $this->stringify_content( $payload[ $field ] ); + if ( '' !== $content ) { + return $this->truncate_string( $content ); + } + } + + return null; + } + + /** + * Extracts a human-readable preview of the response payload. + * + * @param array|null $payload Response payload. + * @return string|null + */ + private function extract_output_preview( ?array $payload ): ?string { + if ( empty( $payload ) ) { + return null; + } + + if ( isset( $payload['choices'] ) && is_array( $payload['choices'] ) ) { + foreach ( $payload['choices'] as $choice ) { + if ( ! is_array( $choice ) ) { + continue; + } + + if ( isset( $choice['message']['content'] ) ) { + $content = $this->stringify_content( $choice['message']['content'] ); + if ( '' !== $content ) { + return $this->truncate_string( $content ); + } + } + + if ( ! isset( $choice['text'] ) ) { + continue; + } + + $content = $this->stringify_content( $choice['text'] ); + if ( '' !== $content ) { + return $this->truncate_string( $content ); + } + } + } + + if ( isset( $payload['output'] ) ) { + $content = $this->stringify_content( $payload['output'] ); + if ( '' !== $content ) { + return $this->truncate_string( $content ); + } + } + + if ( isset( $payload['candidates'] ) && is_array( $payload['candidates'] ) ) { + foreach ( $payload['candidates'] as $candidate ) { + if ( ! is_array( $candidate ) ) { + continue; + } + + if ( isset( $candidate['content'] ) ) { + $content = $this->stringify_content( $candidate['content'] ); + if ( '' !== $content ) { + return $this->truncate_string( $content ); + } + } + + if ( ! isset( $candidate['output'] ) ) { + continue; + } + + $content = $this->stringify_content( $candidate['output'] ); + if ( '' !== $content ) { + return $this->truncate_string( $content ); + } + } + } + + return null; + } + + /** + * Converts structured content into a plain string. + * + * @param mixed $content Structured content (string|array). + * @return string + */ + private function stringify_content( $content ): string { + if ( is_string( $content ) ) { + return trim( $content ); + } + + if ( is_array( $content ) ) { + if ( isset( $content['b64_json'] ) && is_string( $content['b64_json'] ) ) { + return '[base64 image]'; + } + + if ( isset( $content['image_base64'] ) && is_string( $content['image_base64'] ) ) { + return '[base64 image]'; + } + + $parts = array(); + + foreach ( $content as $chunk ) { + if ( is_array( $chunk ) && isset( $chunk['text'] ) ) { + $parts[] = (string) $chunk['text']; + continue; + } + + if ( is_array( $chunk ) && isset( $chunk['content'] ) ) { + $parts[] = $this->stringify_content( $chunk['content'] ); + continue; + } + + if ( is_scalar( $chunk ) ) { + $parts[] = (string) $chunk; + continue; + } + + if ( ! is_array( $chunk ) ) { + continue; + } + + $parts[] = $this->stringify_content( $chunk ); + } + + if ( $parts ) { + return trim( implode( "\n", array_filter( $parts ) ) ); + } + + return trim( (string) wp_json_encode( $content ) ); + } + + if ( is_scalar( $content ) ) { + return trim( (string) $content ); + } + + return ''; + } + + /** + * Truncates a string to the configured preview limit. + * + * @param string $value The string to truncate. + * @param int $limit Maximum length. + * @return string + */ + private function truncate_string( string $value, int $limit = self::PAYLOAD_PREVIEW_LIMIT ): string { + $value = trim( $value ); + + if ( '' === $value ) { + return $value; + } + + if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) { + if ( mb_strlen( $value, 'UTF-8' ) <= $limit ) { + return $value; + } + + return mb_substr( $value, 0, $limit, 'UTF-8' ) . '...'; + } + + if ( strlen( $value ) <= $limit ) { + return $value; + } + + return substr( $value, 0, $limit ) . '...'; + } + + /** + * Determines the high-level request kind. + * + * @param string|null $provider Provider identifier. + * @param string $path Request path. + * @param array|null $payload Request payload. + * @return string + */ + private function detect_request_kind( ?string $provider, string $path, ?array $payload ): string { + $path_lower = strtolower( $path ); + + if ( 'fal' === $provider ) { + return 'image'; + } + + if ( str_contains( $path_lower, 'embeddings' ) ) { + return 'embeddings'; + } + + if ( + str_contains( $path_lower, '/images' ) || + str_contains( $path_lower, 'imagegeneration' ) || + str_contains( $path_lower, 'image-generation' ) + ) { + return 'image'; + } + + if ( isset( $payload['response_format']['type'] ) && 'json_schema' === $payload['response_format']['type'] ) { + return 'text'; + } + + return 'text'; + } + + /** + * Extracts image/media metadata from a response payload. + * + * @param array $payload Response data. + * @return array + */ + private function extract_media_context( array $payload ): array { + $context = array(); + $image_urls = array(); + $base64_data = array(); + + if ( isset( $payload['images'] ) && is_array( $payload['images'] ) ) { + foreach ( $payload['images'] as $image ) { + if ( ! is_array( $image ) ) { + continue; + } + + if ( isset( $image['url'] ) && is_string( $image['url'] ) ) { + $image_urls[] = $image['url']; + } + + $encoded = $image['b64_json'] ?? ( $image['image_base64'] ?? null ); + if ( ! is_string( $encoded ) || '' === $encoded ) { + continue; + } + + $base64_data[] = array( + 'mime' => $image['content_type'] ?? 'image/png', + 'data' => $encoded, + ); + } + } + + if ( isset( $payload['data'] ) && is_array( $payload['data'] ) ) { + foreach ( $payload['data'] as $entry ) { + if ( ! is_array( $entry ) ) { + continue; + } + + if ( isset( $entry['url'] ) && is_string( $entry['url'] ) ) { + $image_urls[] = $entry['url']; + } + + if ( ! isset( $entry['b64_json'] ) || ! is_string( $entry['b64_json'] ) ) { + continue; + } + + $base64_data[] = array( + 'mime' => $entry['mime'] ?? 'image/png', + 'data' => $entry['b64_json'], + ); + } + } + + if ( $image_urls ) { + $context['image_urls'] = array_slice( $image_urls, 0, self::MAX_IMAGE_SAMPLES ); + $context['output_preview'] = $context['output_preview'] ?? sprintf( + 'Generated %d image(s).', + count( $image_urls ) + ); + } + + if ( $base64_data ) { + $context['image_base64_samples'] = array_slice( $base64_data, 0, self::MAX_IMAGE_SAMPLES ); + if ( empty( $context['output_preview'] ) ) { + $context['output_preview'] = sprintf( + 'Generated %d image(s).', + count( $base64_data ) + ); + } + } + + return $context; + } +} diff --git a/includes/Logging/REST/AI_Request_Log_Controller.php b/includes/Logging/REST/AI_Request_Log_Controller.php new file mode 100644 index 00000000..8545aa25 --- /dev/null +++ b/includes/Logging/REST/AI_Request_Log_Controller.php @@ -0,0 +1,460 @@ +namespace = 'ai/v1'; + $this->rest_base = 'logs'; + $this->manager = $manager; + } + + /** + * Registers REST routes. + */ + public function register_routes(): void { + // GET /ai/v1/logs - List logs with filtering. + // POST /ai/v1/logs - Update settings (enabled, retention). + // DELETE /ai/v1/logs - Purge all logs. + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_logs' ), + 'permission_callback' => array( $this, 'permissions_check' ), + 'args' => $this->get_collection_params(), + ), + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_settings' ), + 'permission_callback' => array( $this, 'permissions_check' ), + 'args' => array( + 'enabled' => array( + 'type' => 'boolean', + 'required' => false, + ), + 'retention_days' => array( + 'type' => 'integer', + 'required' => false, + 'minimum' => 1, + 'maximum' => 365, + ), + 'max_rows' => array( + 'type' => 'integer', + 'required' => false, + 'minimum' => 1000, + 'maximum' => 10000000, + ), + ), + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'purge_logs' ), + 'permission_callback' => array( $this, 'permissions_check' ), + ), + ) + ); + + // GET /ai/v1/logs/summary - Get aggregate statistics. + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/summary', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_summary' ), + 'permission_callback' => array( $this, 'permissions_check' ), + 'args' => array( + 'period' => array( + 'type' => 'string', + 'enum' => array( 'day', 'week', 'month', 'all' ), + 'default' => 'day', + ), + ), + ), + ) + ); + + // GET /ai/v1/logs/filters - Get filter options. + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/filters', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_filters' ), + 'permission_callback' => array( $this, 'permissions_check' ), + ), + ) + ); + + // GET /ai/v1/logs/stats - Get table statistics. + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/stats', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_stats' ), + 'permission_callback' => array( $this, 'permissions_check' ), + ), + ) + ); + + // GET /ai/v1/logs/{id} - Get single log entry. + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[a-f0-9\-]+)', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_log' ), + 'permission_callback' => array( $this, 'permissions_check' ), + 'args' => array( + 'id' => array( + 'type' => 'string', + 'required' => true, + 'validate_callback' => array( $this, 'validate_uuid' ), + ), + ), + ), + ) + ); + } + + /** + * Permission check - restricted to administrators. + */ + public function permissions_check(): bool { + return current_user_can( 'manage_options' ); + } + + /** + * Validates a UUID format. + * + * @param string $value The value to validate. + * @return bool Whether the value is a valid UUID. + */ + public function validate_uuid( string $value ): bool { + return (bool) preg_match( '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i', $value ); + } + + /** + * Retrieves logs with filtering and pagination. + * + * @param \WP_REST_Request $request Request. + * @return \WP_REST_Response + */ + public function get_logs( WP_REST_Request $request ): WP_REST_Response { + $args = array( + 'type' => $request->get_param( 'type' ) ?? '', + 'status' => $request->get_param( 'status' ) ?? '', + 'provider' => $request->get_param( 'provider' ) ?? '', + 'operation' => $request->get_param( 'operation' ) ?? '', + 'operation_pattern' => $request->get_param( 'operation_pattern' ) ?? '', + 'tokens_gt' => $request->get_param( 'tokens_gt' ), + 'tokens_lt' => $request->get_param( 'tokens_lt' ), + 'tokens_filter' => $request->get_param( 'tokens_filter' ) ?? '', + 'user_id' => $request->get_param( 'user_id' ) ?? 0, + 'date_from' => $request->get_param( 'date_from' ) ?? '', + 'date_to' => $request->get_param( 'date_to' ) ?? '', + 'search' => $request->get_param( 'search' ) ?? '', + 'page' => $request->get_param( 'page' ) ?? 1, + 'per_page' => $request->get_param( 'per_page' ) ?? 25, + 'orderby' => $request->get_param( 'orderby' ) ?? 'timestamp', + 'order' => $request->get_param( 'order' ) ?? 'DESC', + 'cursor_id' => $request->get_param( 'cursor_id' ), + 'cursor_timestamp' => $request->get_param( 'cursor_timestamp' ), + ); + + $result = $this->manager->get_logs( $args ); + + $response = rest_ensure_response( $result['items'] ); + $response->header( 'X-WP-Total', (string) $result['total'] ); + $response->header( 'X-WP-TotalPages', (string) $result['pages'] ); + + // Include cursor info for cursor-based pagination. + if ( isset( $result['next_cursor'] ) ) { + $response->header( 'X-WP-NextCursorId', (string) $result['next_cursor']['id'] ); + $response->header( 'X-WP-NextCursorTimestamp', $result['next_cursor']['timestamp'] ); + } + + return $response; + } + + /** + * Retrieves a single log entry. + * + * @param \WP_REST_Request $request Request. + * @return \WP_REST_Response|\WP_Error + */ + public function get_log( WP_REST_Request $request ) { + $log_id = $request->get_param( 'id' ); + $log = $this->manager->get_log( $log_id ); + + if ( ! $log ) { + return new WP_Error( + 'ai_log_not_found', + __( 'Log entry not found.', 'ai' ), + array( 'status' => 404 ) + ); + } + + return rest_ensure_response( $log ); + } + + /** + * Retrieves aggregate statistics. + * + * @param \WP_REST_Request $request Request. + * @return \WP_REST_Response + */ + public function get_summary( WP_REST_Request $request ): WP_REST_Response { + $period = $request->get_param( 'period' ) ?? 'day'; + $summary = $this->manager->get_summary( $period ); + + return rest_ensure_response( $summary ); + } + + /** + * Retrieves filter options. + * + * @param \WP_REST_Request $request Request. + * @return \WP_REST_Response + */ + public function get_filters( WP_REST_Request $request ): WP_REST_Response { + $filters = $this->manager->get_filter_options(); + + return rest_ensure_response( $filters ); + } + + /** + * Retrieves table statistics. + * + * @param \WP_REST_Request $request Request. + * @return \WP_REST_Response + */ + public function get_stats( WP_REST_Request $request ): WP_REST_Response { + $stats = $this->manager->get_table_stats(); + + // Add warning thresholds. + $stats['warnings'] = array(); + + // Warn if table is over 80% of max rows. + if ( $stats['row_count'] > $stats['max_rows'] * 0.8 ) { + $stats['warnings'][] = array( + 'type' => 'row_count', + 'level' => $stats['row_count'] >= $stats['max_rows'] ? 'error' : 'warning', + 'message' => $stats['row_count'] >= $stats['max_rows'] + ? __( 'Log table has reached the maximum row limit. Oldest logs will be automatically deleted.', 'ai' ) + : sprintf( + /* translators: %d: Percentage of max rows used. */ + __( 'Log table is at %d%% capacity. Consider increasing the max rows limit or reducing retention.', 'ai' ), + (int) ( $stats['row_count'] / $stats['max_rows'] * 100 ) + ), + ); + } + + // Warn if table size exceeds 100MB. + $size_warning_threshold = 100 * 1024 * 1024; // 100MB. + $size_error_threshold = 500 * 1024 * 1024; // 500MB. + + if ( $stats['table_size_bytes'] > $size_warning_threshold ) { + $stats['warnings'][] = array( + 'type' => 'table_size', + 'level' => $stats['table_size_bytes'] >= $size_error_threshold ? 'error' : 'warning', + 'message' => sprintf( + /* translators: %s: Table size formatted. */ + __( 'Log table size is %s. Consider reducing retention period or max rows to improve performance.', 'ai' ), + $stats['table_size_formatted'] + ), + ); + } + + return rest_ensure_response( $stats ); + } + + /** + * Updates logging settings. + * + * @param \WP_REST_Request $request Request. + * @return \WP_REST_Response + */ + public function update_settings( WP_REST_Request $request ): WP_REST_Response { + if ( $request->has_param( 'enabled' ) ) { + $this->manager->set_logging_enabled( (bool) $request->get_param( 'enabled' ) ); + } + + if ( $request->has_param( 'retention_days' ) ) { + $this->manager->set_retention_days( (int) $request->get_param( 'retention_days' ) ); + } + + if ( $request->has_param( 'max_rows' ) ) { + $this->manager->set_max_rows( (int) $request->get_param( 'max_rows' ) ); + } + + return rest_ensure_response( + array( + 'enabled' => $this->manager->is_logging_enabled(), + 'retention_days' => $this->manager->get_retention_days(), + 'max_rows' => $this->manager->get_max_rows(), + ) + ); + } + + /** + * Purges all logs. + * + * @param \WP_REST_Request $request Request. + * @return \WP_REST_Response + */ + public function purge_logs( WP_REST_Request $request ): WP_REST_Response { + $deleted = $this->manager->purge_all_logs(); + + return rest_ensure_response( + array( + 'success' => true, + 'deleted' => $deleted, + 'message' => sprintf( + /* translators: %d: Number of deleted logs. */ + __( 'Successfully purged %d log entries.', 'ai' ), + $deleted + ), + ) + ); + } + + /** + * Gets collection parameters for logs list endpoint. + * + * @return array> Parameter definitions. + */ + public function get_collection_params(): array { + return array( + 'type' => array( + 'description' => __( 'Filter by log type.', 'ai' ), + 'type' => 'string', + 'enum' => array( '', 'ai_client', 'mcp_tool', 'ability' ), + 'default' => '', + ), + 'status' => array( + 'description' => __( 'Filter by status.', 'ai' ), + 'type' => 'string', + 'enum' => array( '', 'success', 'error', 'timeout' ), + 'default' => '', + ), + 'provider' => array( + 'description' => __( 'Filter by AI provider.', 'ai' ), + 'type' => 'string', + 'default' => '', + ), + 'user_id' => array( + 'description' => __( 'Filter by user ID.', 'ai' ), + 'type' => 'integer', + 'default' => 0, + ), + 'date_from' => array( + 'description' => __( 'Filter logs from this date (YYYY-MM-DD HH:MM:SS).', 'ai' ), + 'type' => 'string', + 'format' => 'date-time', + ), + 'date_to' => array( + 'description' => __( 'Filter logs until this date (YYYY-MM-DD HH:MM:SS).', 'ai' ), + 'type' => 'string', + 'format' => 'date-time', + ), + 'search' => array( + 'description' => __( 'Search in operations, request previews, response previews, and error messages.', 'ai' ), + 'type' => 'string', + 'default' => '', + ), + 'operation_pattern' => array( + 'description' => __( 'Regex pattern to filter operations (e.g., ":completions$" for completion requests only).', 'ai' ), + 'type' => 'string', + 'default' => '', + ), + 'tokens_gt' => array( + 'description' => __( 'Filter logs with tokens greater than this value.', 'ai' ), + 'type' => 'integer', + 'minimum' => 0, + ), + 'tokens_lt' => array( + 'description' => __( 'Filter logs with tokens less than this value.', 'ai' ), + 'type' => 'integer', + 'minimum' => 0, + ), + 'tokens_filter' => array( + 'description' => __( 'Filter by tokens: "gt:N", "lt:N", or "none".', 'ai' ), + 'type' => 'string', + 'default' => '', + ), + 'page' => array( + 'description' => __( 'Current page of the collection.', 'ai' ), + 'type' => 'integer', + 'default' => 1, + 'minimum' => 1, + ), + 'per_page' => array( + 'description' => __( 'Maximum number of items per page.', 'ai' ), + 'type' => 'integer', + 'default' => 25, + 'minimum' => 1, + 'maximum' => 100, + ), + 'orderby' => array( + 'description' => __( 'Sort collection by attribute.', 'ai' ), + 'type' => 'string', + 'enum' => array( 'timestamp', 'type', 'operation', 'duration_ms', 'tokens_total', 'cost_estimate', 'status' ), + 'default' => 'timestamp', + ), + 'order' => array( + 'description' => __( 'Order sort attribute ascending or descending.', 'ai' ), + 'type' => 'string', + 'enum' => array( 'ASC', 'DESC' ), + 'default' => 'DESC', + ), + 'cursor_id' => array( + 'description' => __( 'Cursor ID for cursor-based pagination (use with cursor_timestamp).', 'ai' ), + 'type' => 'integer', + 'minimum' => 1, + ), + 'cursor_timestamp' => array( + 'description' => __( 'Cursor timestamp for cursor-based pagination (use with cursor_id).', 'ai' ), + 'type' => 'string', + 'format' => 'date-time', + ), + ); + } +} diff --git a/includes/helpers.php b/includes/helpers.php index f902c1db..e9a146d7 100644 --- a/includes/helpers.php +++ b/includes/helpers.php @@ -260,3 +260,20 @@ function has_valid_ai_credentials(): bool { return false; } } + +/** + * Returns the request log manager singleton instance. + * + * @since 0.1.0 + * + * @return \WordPress\AI\Logging\AI_Request_Log_Manager|null The log manager instance, or null if not available. + */ +function get_request_log_manager(): ?Logging\AI_Request_Log_Manager { + static $log_manager = null; + + if ( null === $log_manager && class_exists( Logging\AI_Request_Log_Manager::class ) ) { + $log_manager = new Logging\AI_Request_Log_Manager(); + } + + return $log_manager; +} diff --git a/src/admin/_common.scss b/src/admin/_common.scss new file mode 100644 index 00000000..1094ab4d --- /dev/null +++ b/src/admin/_common.scss @@ -0,0 +1,150 @@ +/** + * Common Admin Page Styles + * + * Shared styles for AI admin pages including page headers with icons. + * + * @package WordPress\AI + */ + +/* Remove default .wrap top margin/padding for AI pages */ +.wrap.ai-mcp-server, +.wrap.ai-request-logs, +.wrap.ai-experiments-page { + margin-top: 0; + padding-top: 0; +} + +/* Force AI admin screens to use white page backgrounds */ +$ai-admin-white-pages: ( + 'settings_page_ai-request-logs', + 'settings_page_wp-ai-client', + 'settings_page_ai-experiments', + 'toplevel_page_ai-mcp' +); + +@each $page-class in $ai-admin-white-pages { + body.#{$page-class}, + body.#{$page-class} #wpwrap, + body.#{$page-class} #wpcontent, + body.#{$page-class} #wpbody, + body.#{$page-class} #wpbody-content { + background-color: #fff; + } +} + +/* Full-width page header (privacy-style) */ +.ai-admin-header { + background: #fff; + border-bottom: 1px solid #dcdcde; + margin: 0 0 1.5rem; + padding: 16px 20px; + + // Extend to full width by pulling out of .wrap padding + .wrap > & { + margin-left: -20px; + margin-right: -20px; + + @media screen and (max-width: 782px) { + margin-left: -10px; + margin-right: -10px; + padding-left: 10px; + padding-right: 10px; + } + } +} + +/* Card border-radius standardization (4px) */ +.ai-mcp-server, +.ai-request-logs { + .components-card { + border-radius: 4px; + } +} + +.ai-admin-header__inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + max-width: 1400px; +} + +.ai-admin-header__left { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.ai-admin-header__right { + display: flex; + align-items: center; + gap: 1rem; + + // Header toggle needs proper alignment + .components-toggle-control { + margin: 0; + + .components-base-control__field { + margin-bottom: 0; + } + } +} + +.ai-admin-header__icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 36px; + height: 36px; + padding: 6px; + background: #f6f7f7; + border-radius: 8px; + color: #1d2327; + + svg { + width: 100%; + height: 100%; + } +} + +.ai-admin-header__title { + h1 { + margin: 0; + padding: 0; + font-size: 23px; + font-weight: 600; + line-height: 1.3; + } +} + +/* Legacy page header with icon (for inline use) */ +.ai-page-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.5rem; + + h1 { + margin: 0; + padding: 0; + } +} + +.ai-page-header__icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 36px; + height: 36px; + padding: 6px; + background: #fff; + border-radius: 8px; + color: #1d2327; + + svg { + width: 100%; + height: 100%; + } +} diff --git a/src/admin/_dataviews.scss b/src/admin/_dataviews.scss new file mode 100644 index 00000000..2f295bbe --- /dev/null +++ b/src/admin/_dataviews.scss @@ -0,0 +1,26 @@ +// DataViews base styles (from @wordpress/dataviews package) +// Copied from node_modules/@wordpress/dataviews/build-style/style.css +@import './_dataviews-base.css'; + +// Project-specific overrides + +// Reduce default padding on edges (default is 24px) +.components-card__body:has(> .dataviews-wrapper) { + .dataviews__view-actions, + .dataviews-filters__container, + .dataviews-footer, + .dataviews-loading, + .dataviews-no-results { + padding-inline: 16px; + } + + .dataviews-view-table tr th:first-child, + .dataviews-view-table tr td:first-child { + padding-inline-start: 16px; + } + + .dataviews-view-table tr th:last-child, + .dataviews-view-table tr td:last-child { + padding-inline-end: 16px; + } +} diff --git a/src/admin/ai-request-logs/_dataviews-base.css b/src/admin/ai-request-logs/_dataviews-base.css new file mode 100644 index 00000000..404347e1 --- /dev/null +++ b/src/admin/ai-request-logs/_dataviews-base.css @@ -0,0 +1,1847 @@ +/** + * Colors + */ +/** + * SCSS Variables. + * + * Please use variables from this sheet to ensure consistency across the UI. + * Don't add to this sheet unless you're pretty sure the value will be reused in many places. + * For example, don't add rules to this sheet that affect block visuals. It's purely for UI. + */ +/** + * Fonts & basic variables. + */ +/** + * Typography + */ +/** + * Grid System. + * https://make.wordpress.org/design/2019/10/31/proposal-a-consistent-spacing-system-for-wordpress/ + */ +/** + * Radius scale. + */ +/** + * Elevation scale. + */ +/** + * Dimensions. + */ +/** + * Mobile specific styles + */ +/** + * Editor styles. + */ +/** + * Block & Editor UI. + */ +/** + * Block paddings. + */ +/** + * React Native specific. + * These variables do not appear to be used anywhere else. + */ +/** + * Typography + */ +/** + * Breakpoints & Media Queries + */ +/** +* Converts a hex value into the rgb equivalent. +* +* @param {string} hex - the hexadecimal value to convert +* @return {string} comma separated rgb values +*/ +/** + * Long content fade mixin + * + * Creates a fading overlay to signify that the content is longer + * than the space allows. + */ +/** + * Breakpoint mixins + */ +/** + * Focus styles. + */ +/** + * Applies editor left position to the selector passed as argument + */ +/** + * Styles that are reused verbatim in a few places + */ +/** + * Allows users to opt-out of animations via OS-level preferences. + */ +/** + * Reset default styles for JavaScript UI based pages. + * This is a WP-admin agnostic reset + */ +/** + * Reset the WP Admin page styles for Gutenberg-like pages. + */ +.dataviews-wrapper, +.dataviews-picker-wrapper { + height: 100%; + overflow: auto; + box-sizing: border-box; + scroll-padding-bottom: 64px; + /* stylelint-disable-next-line property-no-unknown -- '@container' not globally permitted */ + container: dataviews-wrapper/inline-size; + display: flex; + flex-direction: column; + font-size: 13px; + line-height: 1.4; +} + +.dataviews__view-actions, +.dataviews-filters__container { + box-sizing: border-box; + padding: 16px 48px; + flex-shrink: 0; + position: sticky; + left: 0; +} +@media not (prefers-reduced-motion) { + .dataviews__view-actions, + .dataviews-filters__container { + transition: padding ease-out 0.1s; + } +} + +.dataviews-no-results, +.dataviews-loading { + padding: 0 48px; + flex-grow: 1; + display: flex; + align-items: center; + justify-content: center; +} +@media not (prefers-reduced-motion) { + .dataviews-no-results, + .dataviews-loading { + transition: padding ease-out 0.1s; + } +} + +.dataviews-loading-more { + text-align: center; +} + +@container (max-width: 430px) { + .dataviews__view-actions, + .dataviews-filters__container { + padding: 12px 24px; + } + .dataviews-no-results, + .dataviews-loading { + padding-left: 24px; + padding-right: 24px; + } +} +.dataviews-title-field { + font-size: 13px; + font-weight: 499; + color: #2f2f2f; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; +} +.dataviews-title-field a { + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + display: block; + flex-grow: 0; + color: #2f2f2f; +} +.dataviews-title-field a:hover { + color: var(--wp-admin-theme-color); +} +.dataviews-title-field a:focus { + color: var(--wp-admin-theme-color--rgb); + box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color, #007cba); + border-radius: 2px; +} +.dataviews-title-field button.components-button.is-link { + text-decoration: none; + font-weight: inherit; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + display: block; + width: 100%; + color: #1e1e1e; +} +.dataviews-title-field button.components-button.is-link:hover { + color: var(--wp-admin-theme-color); +} + +.dataviews-title-field--clickable { + cursor: pointer; + color: #2f2f2f; +} +.dataviews-title-field--clickable:hover { + color: var(--wp-admin-theme-color); +} +.dataviews-title-field--clickable:focus { + color: var(--wp-admin-theme-color--rgb); + box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color, #007cba); + border-radius: 2px; +} + +/** + * Applying a consistent 24px padding when DataViews are placed within cards. + */ +.components-card__body:has(> .dataviews-wrapper), +.components-card__body:has(> .dataviews-picker-wrapper) { + padding: 8px 0 0; + overflow: hidden; +} +.components-card__body:has(> .dataviews-wrapper) .dataviews__view-actions, +.components-card__body:has(> .dataviews-wrapper) .dataviews-filters__container, +.components-card__body:has(> .dataviews-wrapper) .dataviews-footer, +.components-card__body:has(> .dataviews-wrapper) .dataviews-view-grid, +.components-card__body:has(> .dataviews-wrapper) .dataviews-loading, +.components-card__body:has(> .dataviews-wrapper) .dataviews-no-results, +.components-card__body:has(> .dataviews-picker-wrapper) .dataviews__view-actions, +.components-card__body:has(> .dataviews-picker-wrapper) .dataviews-filters__container, +.components-card__body:has(> .dataviews-picker-wrapper) .dataviews-footer, +.components-card__body:has(> .dataviews-picker-wrapper) .dataviews-view-grid, +.components-card__body:has(> .dataviews-picker-wrapper) .dataviews-loading, +.components-card__body:has(> .dataviews-picker-wrapper) .dataviews-no-results { + padding-inline: 24px; +} +.components-card__body:has(> .dataviews-wrapper) .dataviews-view-table tr td:first-child, +.components-card__body:has(> .dataviews-wrapper) .dataviews-view-table tr th:first-child, +.components-card__body:has(> .dataviews-picker-wrapper) .dataviews-view-table tr td:first-child, +.components-card__body:has(> .dataviews-picker-wrapper) .dataviews-view-table tr th:first-child { + padding-inline-start: 24px; +} +.components-card__body:has(> .dataviews-wrapper) .dataviews-view-table tr td:last-child, +.components-card__body:has(> .dataviews-wrapper) .dataviews-view-table tr th:last-child, +.components-card__body:has(> .dataviews-picker-wrapper) .dataviews-view-table tr td:last-child, +.components-card__body:has(> .dataviews-picker-wrapper) .dataviews-view-table tr th:last-child { + padding-inline-end: 24px; +} + +.dataviews-bulk-actions-footer__item-count { + color: #1e1e1e; + font-weight: 499; + font-size: 11px; + text-transform: uppercase; +} + +.dataviews-bulk-actions-footer__container { + margin-right: auto; + min-height: 32px; +} + +.dataviews-filters__button { + position: relative; +} + +.dataviews-filters__container { + padding-top: 0; +} + +.dataviews-filters__reset-button.dataviews-filters__reset-button[aria-disabled=true], .dataviews-filters__reset-button.dataviews-filters__reset-button[aria-disabled=true]:hover { + opacity: 0; +} +.dataviews-filters__reset-button.dataviews-filters__reset-button[aria-disabled=true]:focus { + opacity: 1; +} + +.dataviews-filters__summary-popover { + font-size: 13px; + line-height: 1.4; +} +.dataviews-filters__summary-popover .components-popover__content { + width: 100%; + min-width: 230px; + max-width: 250px; + border-radius: 4px; +} +.dataviews-filters__summary-popover.components-dropdown__content .components-popover__content { + padding: 0; +} + +.dataviews-filters__summary-operators-container { + padding: 8px 16px; +} +.dataviews-filters__summary-operators-container:has(+ .dataviews-filters__search-widget-listbox), .dataviews-filters__summary-operators-container:has(+ .dataviews-filters__search-widget-no-elements), .dataviews-filters__summary-operators-container:has(+ .dataviews-filters__user-input-widget) { + border-bottom: 1px solid #e0e0e0; +} +.dataviews-filters__summary-operators-container:empty { + display: none; +} +.dataviews-filters__summary-operators-container .dataviews-filters__summary-operators-filter-name { + color: #757575; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 0; /* Prevents this element from shrinking */ + max-width: calc(100% - 55px); +} +.dataviews-filters__summary-operators-container .dataviews-filters__summary-operators-filter-select { + width: 100%; + white-space: nowrap; + overflow: hidden; +} + +.dataviews-filters__summary-chip-container { + position: relative; + white-space: pre-wrap; +} +.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip { + border-radius: 16px; + border: 1px solid transparent; + cursor: pointer; + padding: 4px 12px; + min-height: 32px; + background: #f0f0f0; + color: #2f2f2f; + position: relative; + display: flex; + align-items: center; + box-sizing: border-box; +} +.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip.is-not-clickable { + cursor: default; +} +.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip.has-reset { + padding-inline-end: 28px; +} +.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip:hover:not(.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip.is-not-clickable), .dataviews-filters__summary-chip-container .dataviews-filters__summary-chip:focus-visible, .dataviews-filters__summary-chip-container .dataviews-filters__summary-chip[aria-expanded=true] { + background: #e0e0e0; + color: #1e1e1e; +} +.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip.has-values { + color: var(--wp-admin-theme-color); + background: rgba(var(--wp-admin-theme-color--rgb), 0.04); +} +.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip.has-values:hover, .dataviews-filters__summary-chip-container .dataviews-filters__summary-chip.has-values[aria-expanded=true] { + background: rgba(var(--wp-admin-theme-color--rgb), 0.12); +} +.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip:focus-visible { + outline: none; + box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); +} +.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip .dataviews-filters-__summary-filter-text-name { + font-weight: 499; +} +.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip-remove { + width: 24px; + height: 24px; + border-radius: 50%; + border: 0; + padding: 0; + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + background: transparent; + cursor: pointer; +} +.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip-remove svg { + fill: #757575; +} +.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip-remove:hover, .dataviews-filters__summary-chip-container .dataviews-filters__summary-chip-remove:focus { + background: #e0e0e0; +} +.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip-remove:hover svg, .dataviews-filters__summary-chip-container .dataviews-filters__summary-chip-remove:focus svg { + fill: #1e1e1e; +} +.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip-remove.has-values svg { + fill: var(--wp-admin-theme-color); +} +.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip-remove.has-values:hover { + background: rgba(var(--wp-admin-theme-color--rgb), 0.08); +} +.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip-remove:focus-visible { + outline: none; + box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); +} + +.dataviews-filters__search-widget-filter-combobox-list { + max-height: 184px; + padding: 4px; + overflow: auto; + border-top: 1px solid #e0e0e0; +} +.dataviews-filters__search-widget-filter-combobox-list .dataviews-filters__search-widget-filter-combobox-item-value [data-user-value] { + font-weight: 600; +} + +.dataviews-filters__search-widget-listbox { + padding: 4px; + overflow: auto; +} + +.dataviews-filters__search-widget-listitem { + display: flex; + align-items: center; + gap: 8px; + border-radius: 2px; + box-sizing: border-box; + padding: 4px 12px; + cursor: default; + min-height: 32px; + font-family: -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-weight: 400; + font-size: 13px; + line-height: 20px; +} +.dataviews-filters__search-widget-listitem:last-child { + margin-block-end: 0; +} +.dataviews-filters__search-widget-listitem:hover, .dataviews-filters__search-widget-listitem[data-active-item], .dataviews-filters__search-widget-listitem:focus { + background-color: var(--wp-admin-theme-color); + color: #fff; +} +.dataviews-filters__search-widget-listitem:hover .dataviews-filters__search-widget-listitem-description, .dataviews-filters__search-widget-listitem[data-active-item] .dataviews-filters__search-widget-listitem-description, .dataviews-filters__search-widget-listitem:focus .dataviews-filters__search-widget-listitem-description { + color: #fff; +} +.dataviews-filters__search-widget-listitem:hover .dataviews-filters__search-widget-listitem-single-selection, .dataviews-filters__search-widget-listitem[data-active-item] .dataviews-filters__search-widget-listitem-single-selection, .dataviews-filters__search-widget-listitem:focus .dataviews-filters__search-widget-listitem-single-selection { + border-color: var(--wp-admin-theme-color-darker-20, #183ad6); + background: #fff; +} +.dataviews-filters__search-widget-listitem:hover .dataviews-filters__search-widget-listitem-single-selection.is-selected, .dataviews-filters__search-widget-listitem[data-active-item] .dataviews-filters__search-widget-listitem-single-selection.is-selected, .dataviews-filters__search-widget-listitem:focus .dataviews-filters__search-widget-listitem-single-selection.is-selected { + border-color: var(--wp-admin-theme-color-darker-20, #183ad6); + background: var(--wp-admin-theme-color-darker-20, #183ad6); +} +.dataviews-filters__search-widget-listitem:hover .dataviews-filters__search-widget-listitem-multi-selection, .dataviews-filters__search-widget-listitem[data-active-item] .dataviews-filters__search-widget-listitem-multi-selection, .dataviews-filters__search-widget-listitem:focus .dataviews-filters__search-widget-listitem-multi-selection { + border-color: var(--wp-admin-theme-color-darker-20, #183ad6); +} +.dataviews-filters__search-widget-listitem:hover .dataviews-filters__search-widget-listitem-multi-selection.is-selected, .dataviews-filters__search-widget-listitem[data-active-item] .dataviews-filters__search-widget-listitem-multi-selection.is-selected, .dataviews-filters__search-widget-listitem:focus .dataviews-filters__search-widget-listitem-multi-selection.is-selected { + border-color: var(--wp-admin-theme-color-darker-20, #183ad6); + background: var(--wp-admin-theme-color-darker-20, #183ad6); +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-description { + display: block; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + line-height: 16px; + color: #757575; +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-single-selection { + border: 1px solid #1e1e1e; + margin-right: 12px; + transition: none; + border-radius: 50%; + width: 24px; + height: 24px; + min-width: 24px; + max-width: 24px; + position: relative; +} +@media not (prefers-reduced-motion) { + .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-single-selection { + transition: box-shadow 0.1s linear; + } +} +@media (min-width: 600px) { + .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-single-selection { + height: 16px; + width: 16px; + min-width: 16px; + max-width: 16px; + } +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-single-selection:checked::before { + box-sizing: inherit; + width: 12px; + height: 12px; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; + background-color: #fff; + border: 4px solid #fff; +} +@media (min-width: 600px) { + .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-single-selection:checked::before { + width: 8px; + height: 8px; + } +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-single-selection:focus { + box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--wp-admin-theme-color); + outline: 2px solid transparent; +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-single-selection:checked { + background: var(--wp-admin-theme-color); + border: none; +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-single-selection { + margin: 0; + padding: 0; +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-single-selection.is-selected { + background: var(--wp-admin-theme-color, #3858e9); + border-color: var(--wp-admin-theme-color, #3858e9); +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-single-selection.is-selected::before { + content: ""; + border-radius: 50%; + box-sizing: inherit; + width: 12px; + height: 12px; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; + background-color: #fff; + border: 4px solid #fff; +} +@media (min-width: 600px) { + .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-single-selection.is-selected::before { + width: 8px; + height: 8px; + } +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection { + --checkbox-size: 24px; + border: 1px solid #1e1e1e; + margin-right: 12px; + transition: none; + border-radius: 2px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + padding: 6px 8px; + /* Fonts smaller than 16px causes mobile safari to zoom. */ + font-size: 16px; + /* Override core line-height. To be reviewed. */ + line-height: normal; + box-shadow: 0 0 0 transparent; + border-radius: 2px; + border: 1px solid #949494; +} +@media not (prefers-reduced-motion) { + .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection { + transition: box-shadow 0.1s linear; + } +} +@media (min-width: 600px) { + .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection { + font-size: 13px; + /* Override core line-height. To be reviewed. */ + line-height: normal; + } +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection:focus { + border-color: var(--wp-admin-theme-color); + box-shadow: 0 0 0 0.5px var(--wp-admin-theme-color); + outline: 2px solid transparent; +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection::-webkit-input-placeholder { + color: rgba(30, 30, 30, 0.62); +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection::-moz-placeholder { + color: rgba(30, 30, 30, 0.62); +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection:-ms-input-placeholder { + color: rgba(30, 30, 30, 0.62); +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection:focus { + box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--wp-admin-theme-color); + outline: 2px solid transparent; +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection:checked { + background: var(--wp-admin-theme-color); + border-color: var(--wp-admin-theme-color); +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection:checked::-ms-check { + opacity: 0; +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection:checked::before, .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection[aria-checked=mixed]::before { + margin: -3px -5px; + color: #fff; +} +@media (min-width: 782px) { + .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection:checked::before, .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection[aria-checked=mixed]::before { + margin: -4px 0 0 -5px; + } +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection[aria-checked=mixed] { + background: var(--wp-admin-theme-color); + border-color: var(--wp-admin-theme-color); +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection[aria-checked=mixed]::before { + content: "\f460"; + float: left; + display: inline-block; + vertical-align: middle; + width: 16px; + /* stylelint-disable-next-line font-family-no-missing-generic-family-keyword -- dashicons don't need a generic family keyword. */ + font: normal 30px/1 dashicons; + speak: none; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +@media (min-width: 782px) { + .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection[aria-checked=mixed]::before { + float: none; + font-size: 21px; + } +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection[aria-disabled=true], .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection:disabled { + background: #f0f0f0; + border-color: #ddd; + cursor: default; + opacity: 1; +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection { + position: relative; + background: #fff; + color: #1e1e1e; + margin: 0; + padding: 0; + width: var(--checkbox-size); + height: var(--checkbox-size); +} +@media (min-width: 600px) { + .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection { + --checkbox-size: 16px; + } +} +@media not (prefers-reduced-motion) { + .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection { + transition: 0.1s border-color ease-in-out; + } +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection.is-selected { + background: var(--wp-admin-theme-color, #3858e9); + border-color: var(--wp-admin-theme-color, #3858e9); +} +.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection.is-selected svg { + --checkmark-size: var(--checkbox-size); + fill: #fff; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: var(--checkmark-size); + height: var(--checkmark-size); +} +@media (min-width: 600px) { + .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection.is-selected svg { + --checkmark-size: calc(var(--checkbox-size) + 4px); + } +} + +.dataviews-filters__search-widget-filter-combobox__wrapper { + position: relative; + padding: 8px; +} +.dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + padding: 6px 8px; + /* Fonts smaller than 16px causes mobile safari to zoom. */ + font-size: 16px; + /* Override core line-height. To be reviewed. */ + line-height: normal; + box-shadow: 0 0 0 transparent; + border-radius: 2px; + border: 1px solid #949494; +} +@media not (prefers-reduced-motion) { + .dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input { + transition: box-shadow 0.1s linear; + } +} +@media (min-width: 600px) { + .dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input { + font-size: 13px; + /* Override core line-height. To be reviewed. */ + line-height: normal; + } +} +.dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input:focus { + border-color: var(--wp-admin-theme-color); + box-shadow: 0 0 0 0.5px var(--wp-admin-theme-color); + outline: 2px solid transparent; +} +.dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input::-webkit-input-placeholder { + color: rgba(30, 30, 30, 0.62); +} +.dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input::-moz-placeholder { + color: rgba(30, 30, 30, 0.62); +} +.dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input:-ms-input-placeholder { + color: rgba(30, 30, 30, 0.62); +} +.dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input { + display: block; + padding: 0 8px 0 32px; + width: 100%; + height: 32px; + margin-left: 0; + margin-right: 0; + /* Fonts smaller than 16px causes mobile safari to zoom. */ + font-size: 16px; +} +@media (min-width: 600px) { + .dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input { + font-size: 13px; + } +} +.dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input:focus { + background: #fff; + box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); +} +.dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input::placeholder { + color: #757575; +} +.dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input::-webkit-search-decoration, .dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input::-webkit-search-cancel-button, .dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input::-webkit-search-results-button, .dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input::-webkit-search-results-decoration { + -webkit-appearance: none; +} +.dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__icon { + position: absolute; + inset-inline-start: 12px; + top: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + width: 24px; +} +.dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__icon:dir(ltr) { + transform: scaleX(-1); +} + +.dataviews-filters__container-visibility-toggle { + position: relative; + flex-shrink: 0; +} + +.dataviews-filters-toggle__count { + position: absolute; + top: 0; + right: 0; + transform: translate(50%, -50%); + background: var(--wp-admin-theme-color, #3858e9); + height: 16px; + min-width: 16px; + line-height: 16px; + padding: 0 4px; + text-align: center; + border-radius: 8px; + font-size: 11px; + outline: var(--wp-admin-border-width-focus) solid #fff; + color: #fff; + box-sizing: border-box; +} + +.dataviews-search { + width: fit-content; +} + +.dataviews-filters__user-input-widget { + padding: 16px; +} +.dataviews-filters__user-input-widget .components-input-control__prefix { + padding-left: 8px; +} + +.dataviews-filters__search-widget-no-elements { + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.dataviews-footer { + position: sticky; + bottom: 0; + left: 0; + background-color: #fff; + padding: 12px 48px; + border-top: 1px solid #f0f0f0; + flex-shrink: 0; +} +@media not (prefers-reduced-motion) { + .dataviews-footer { + transition: padding ease-out 0.1s; + } +} +.dataviews-footer { + z-index: 2; +} + +@container (max-width: 430px) { + .dataviews-footer { + padding: 12px 24px; + } +} +@container (max-width: 560px) { + .dataviews-footer { + flex-direction: column !important; + } + .dataviews-footer .dataviews-bulk-actions-footer__container { + width: 100%; + } + .dataviews-footer .dataviews-bulk-actions-footer__item-count { + flex-grow: 1; + } + .dataviews-footer .dataviews-pagination { + width: 100%; + justify-content: space-between; + } +} +.dataviews-pagination__page-select { + font-size: 11px; + font-weight: 499; + text-transform: uppercase; +} +@media (min-width: 600px) { + .dataviews-pagination__page-select .components-select-control__input { + font-size: 11px !important; + font-weight: 499; + } +} + +.dataviews-action-modal { + z-index: 1000001; +} + +.dataviews-picker-footer__bulk-selection { + align-self: flex-start; + height: 32px; +} + +.dataviews-picker-footer__actions { + align-self: flex-end; +} + +.dataviews-selection-checkbox { + --checkbox-input-size: 24px; +} +@media (min-width: 600px) { + .dataviews-selection-checkbox { + --checkbox-input-size: 16px; + } +} +.dataviews-selection-checkbox { + line-height: 0; + flex-shrink: 0; +} +.dataviews-selection-checkbox .components-checkbox-control__input-container { + margin: 0; +} + +.dataviews-view-config { + width: 320px; + /* stylelint-disable-next-line property-no-unknown -- the linter needs to be updated to accepted the container-type property */ + container-type: inline-size; + font-size: 13px; + line-height: 1.4; +} + +.dataviews-config__popover.is-expanded .dataviews-config__popover-content-wrapper { + overflow-y: scroll; + height: 100%; +} +.dataviews-config__popover.is-expanded .dataviews-config__popover-content-wrapper .dataviews-view-config { + width: auto; +} + +.dataviews-view-config__sort-direction .components-toggle-group-control-option-base { + text-transform: uppercase; +} + +.dataviews-settings-section__title.dataviews-settings-section__title { + line-height: 24px; + font-size: 15px; +} + +.dataviews-settings-section__sidebar { + grid-column: span 4; +} + +.dataviews-settings-section__content, +.dataviews-settings-section__content > * { + grid-column: span 8; +} + +.dataviews-settings-section__content .is-divided-in-two { + display: contents; +} +.dataviews-settings-section__content .is-divided-in-two > * { + grid-column: span 4; +} + +.dataviews-settings-section:has(.dataviews-settings-section__content:empty) { + display: none; +} + +@container (max-width: 500px) { + .dataviews-settings-section.dataviews-settings-section { + grid-template-columns: repeat(2, 1fr); + } + .dataviews-settings-section.dataviews-settings-section .dataviews-settings-section__sidebar { + grid-column: span 2; + } + .dataviews-settings-section.dataviews-settings-section .dataviews-settings-section__content { + grid-column: span 2; + } +} +.dataviews-view-config__label { + text-wrap: nowrap; +} + +.dataviews-view-grid-items { + margin-bottom: auto; + display: grid; + gap: 32px; + grid-template-rows: max-content; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + padding: 0 48px 24px; + container-type: inline-size; + /** + * Breakpoints were adjusted from media queries breakpoints to account for + * the sidebar width. This was done to match the existing styles we had. + */ +} +@container (max-width: 430px) { + .dataviews-view-grid-items { + padding-left: 24px; + padding-right: 24px; + } +} +@media not (prefers-reduced-motion) { + .dataviews-view-grid-items { + transition: padding ease-out 0.1s; + } +} + +.dataviews-view-grid .dataviews-view-grid__card { + height: 100%; + justify-content: flex-start; + position: relative; +} +.dataviews-view-grid .dataviews-view-grid__card .dataviews-view-grid__title-actions { + padding: 8px 0 4px; +} +.dataviews-view-grid .dataviews-view-grid__card .dataviews-view-grid__title-field { + min-height: 24px; + overflow: hidden; + align-content: center; + text-align: start; +} +.dataviews-view-grid .dataviews-view-grid__card .dataviews-view-grid__title-field--clickable { + width: fit-content; +} +.dataviews-view-grid .dataviews-view-grid__card.is-selected .dataviews-view-grid__fields .dataviews-view-grid__field .dataviews-view-grid__field-value { + color: #1e1e1e; +} +.dataviews-view-grid .dataviews-view-grid__card.is-selected .dataviews-view-grid__media::after, +.dataviews-view-grid .dataviews-view-grid__card .dataviews-view-grid__media:focus::after { + background-color: rgba(var(--wp-admin-theme-color--rgb), 0.08); +} +.dataviews-view-grid .dataviews-view-grid__card.is-selected .dataviews-view-grid__media::after { + box-shadow: inset 0 0 0 1px var(--wp-admin-theme-color); +} +.dataviews-view-grid .dataviews-view-grid__card .dataviews-view-grid__media:focus::after { + box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); +} +.dataviews-view-grid .dataviews-view-grid__media { + width: 100%; + aspect-ratio: 1/1; + background-color: #fff; + border-radius: 4px; + overflow: hidden; + position: relative; +} +.dataviews-view-grid .dataviews-view-grid__media img { + object-fit: cover; + width: 100%; + height: 100%; +} +.dataviews-view-grid .dataviews-view-grid__media::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1); + border-radius: 4px; + pointer-events: none; +} +.dataviews-view-grid .dataviews-view-grid__fields { + position: relative; + font-size: 12px; + line-height: 16px; +} +.dataviews-view-grid .dataviews-view-grid__fields:not(:empty) { + padding: 0 0 12px; +} +.dataviews-view-grid .dataviews-view-grid__fields .dataviews-view-grid__field-value:not(:empty) { + min-height: 24px; + line-height: 20px; + padding-top: 2px; +} +.dataviews-view-grid .dataviews-view-grid__fields .dataviews-view-grid__field { + min-height: 24px; + align-items: center; +} +.dataviews-view-grid .dataviews-view-grid__fields .dataviews-view-grid__field .dataviews-view-grid__field-name { + width: 35%; + color: #757575; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.dataviews-view-grid .dataviews-view-grid__fields .dataviews-view-grid__field .dataviews-view-grid__field-value { + width: 65%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.dataviews-view-grid .dataviews-view-grid__fields .dataviews-view-grid__field:not(:has(.dataviews-view-grid__field-value:not(:empty))) { + display: none; +} +.dataviews-view-grid .dataviews-view-grid__badge-fields:not(:empty) { + padding-bottom: 12px; +} + +.dataviews-view-grid__field-value:empty, +.dataviews-view-grid__field:empty { + display: none; +} + +.dataviews-view-grid__card .dataviews-selection-checkbox { + position: absolute; + top: -9999em; + left: 8px; + z-index: 1; + opacity: 0; +} +@media not (prefers-reduced-motion) { + .dataviews-view-grid__card .dataviews-selection-checkbox { + transition: opacity 0.1s linear; + } +} +@media (hover: none) { + .dataviews-view-grid__card .dataviews-selection-checkbox { + opacity: 1; + top: 8px; + } +} + +.dataviews-view-grid__card:hover .dataviews-selection-checkbox, +.dataviews-view-grid__card:focus-within .dataviews-selection-checkbox, +.dataviews-view-grid__card.is-selected .dataviews-selection-checkbox { + opacity: 1; + top: 8px; +} + +.dataviews-view-grid__card .dataviews-view-grid__media-actions { + position: absolute; + top: 4px; + opacity: 0; + right: 4px; +} +.dataviews-view-grid__card .dataviews-view-grid__media-actions .dataviews-all-actions-button { + background-color: #fff; +} +@media not (prefers-reduced-motion) { + .dataviews-view-grid__card .dataviews-view-grid__media-actions { + transition: opacity 0.1s linear; + } +} +@media (hover: none) { + .dataviews-view-grid__card .dataviews-view-grid__media-actions { + opacity: 1; + top: 4px; + } +} + +.dataviews-view-grid__card:hover .dataviews-view-grid__media-actions, +.dataviews-view-grid__card:focus-within .dataviews-view-grid__media-actions, +.dataviews-view-grid__card .dataviews-view-grid__media-actions:has(.dataviews-all-actions-button[aria-expanded=true]) { + opacity: 1; +} + +.dataviews-view-grid__media--clickable { + cursor: pointer; +} + +.dataviews-view-grid__group-header { + font-size: 15px; + font-weight: 499; + color: #1e1e1e; + margin: 0 0 8px 0; + padding: 0 48px; + container-type: inline-size; +} +@container (max-width: 430px) { + .dataviews-view-grid__group-header { + padding-left: 24px; + padding-right: 24px; + } +} + +div.dataviews-view-list { + list-style-type: none; +} + +.dataviews-view-list { + margin: 0 0 auto; +} +.dataviews-view-list div[role=row], +.dataviews-view-list div[role=article] { + margin: 0; + border-top: 1px solid #f0f0f0; +} +.dataviews-view-list div[role=row] .dataviews-view-list__item-wrapper, +.dataviews-view-list div[role=article] .dataviews-view-list__item-wrapper { + position: relative; + padding: 16px 24px; + box-sizing: border-box; +} +.dataviews-view-list div[role=row] .dataviews-view-list__item-actions, +.dataviews-view-list div[role=article] .dataviews-view-list__item-actions { + display: flex; + width: max-content; + flex: 0 0 auto; + gap: 4px; +} +.dataviews-view-list div[role=row] .dataviews-view-list__item-actions .components-button, +.dataviews-view-list div[role=article] .dataviews-view-list__item-actions .components-button { + position: relative; + z-index: 1; +} +.dataviews-view-list div[role=row] .dataviews-view-list__item-actions > div, +.dataviews-view-list div[role=article] .dataviews-view-list__item-actions > div { + height: 24px; +} +.dataviews-view-list div[role=row] .dataviews-view-list__item-actions > :not(:last-child), +.dataviews-view-list div[role=article] .dataviews-view-list__item-actions > :not(:last-child) { + flex: 0; + overflow: hidden; + width: 0; +} +.dataviews-view-list div[role=row]:where(.is-selected, .is-hovered, :focus-within) .dataviews-view-list__item-actions > :not(:last-child), +.dataviews-view-list div[role=article]:where(.is-selected, .is-hovered, :focus-within) .dataviews-view-list__item-actions > :not(:last-child) { + flex-basis: min-content; + width: auto; + overflow: unset; +} +@media (hover: none) { + .dataviews-view-list div[role=row] .dataviews-view-list__item-actions > :not(:last-child), + .dataviews-view-list div[role=article] .dataviews-view-list__item-actions > :not(:last-child) { + flex-basis: min-content; + width: auto; + overflow: unset; + } +} +.dataviews-view-list div[role=row].is-selected.is-selected, +.dataviews-view-list div[role=article].is-selected.is-selected { + border-top: 1px solid rgba(var(--wp-admin-theme-color--rgb), 0.12); +} +.dataviews-view-list div[role=row].is-selected.is-selected + div[role=row], .dataviews-view-list div[role=row].is-selected.is-selected + div[role=article], +.dataviews-view-list div[role=article].is-selected.is-selected + div[role=row], +.dataviews-view-list div[role=article].is-selected.is-selected + div[role=article] { + border-top: 1px solid rgba(var(--wp-admin-theme-color--rgb), 0.12); +} +.dataviews-view-list div[role=row]:not(.is-selected) .dataviews-view-list__title-field, +.dataviews-view-list div[role=article]:not(.is-selected) .dataviews-view-list__title-field { + color: #1e1e1e; +} +.dataviews-view-list div[role=row]:not(.is-selected):hover, .dataviews-view-list div[role=row]:not(.is-selected).is-hovered, .dataviews-view-list div[role=row]:not(.is-selected):focus-within, +.dataviews-view-list div[role=article]:not(.is-selected):hover, +.dataviews-view-list div[role=article]:not(.is-selected).is-hovered, +.dataviews-view-list div[role=article]:not(.is-selected):focus-within { + color: var(--wp-admin-theme-color); + background-color: #f8f8f8; +} +.dataviews-view-list div[role=row]:not(.is-selected):hover .dataviews-view-list__title-field, +.dataviews-view-list div[role=row]:not(.is-selected):hover .dataviews-view-list__fields, .dataviews-view-list div[role=row]:not(.is-selected).is-hovered .dataviews-view-list__title-field, +.dataviews-view-list div[role=row]:not(.is-selected).is-hovered .dataviews-view-list__fields, .dataviews-view-list div[role=row]:not(.is-selected):focus-within .dataviews-view-list__title-field, +.dataviews-view-list div[role=row]:not(.is-selected):focus-within .dataviews-view-list__fields, +.dataviews-view-list div[role=article]:not(.is-selected):hover .dataviews-view-list__title-field, +.dataviews-view-list div[role=article]:not(.is-selected):hover .dataviews-view-list__fields, +.dataviews-view-list div[role=article]:not(.is-selected).is-hovered .dataviews-view-list__title-field, +.dataviews-view-list div[role=article]:not(.is-selected).is-hovered .dataviews-view-list__fields, +.dataviews-view-list div[role=article]:not(.is-selected):focus-within .dataviews-view-list__title-field, +.dataviews-view-list div[role=article]:not(.is-selected):focus-within .dataviews-view-list__fields { + color: var(--wp-admin-theme-color); +} +.dataviews-view-list div[role=row].is-selected .dataviews-view-list__item-wrapper, +.dataviews-view-list div[role=row].is-selected:focus-within .dataviews-view-list__item-wrapper, +.dataviews-view-list div[role=article].is-selected .dataviews-view-list__item-wrapper, +.dataviews-view-list div[role=article].is-selected:focus-within .dataviews-view-list__item-wrapper { + background-color: rgba(var(--wp-admin-theme-color--rgb), 0.04); + color: #1e1e1e; +} +.dataviews-view-list div[role=row].is-selected .dataviews-view-list__item-wrapper .dataviews-view-list__title-field, +.dataviews-view-list div[role=row].is-selected .dataviews-view-list__item-wrapper .dataviews-view-list__fields, +.dataviews-view-list div[role=row].is-selected:focus-within .dataviews-view-list__item-wrapper .dataviews-view-list__title-field, +.dataviews-view-list div[role=row].is-selected:focus-within .dataviews-view-list__item-wrapper .dataviews-view-list__fields, +.dataviews-view-list div[role=article].is-selected .dataviews-view-list__item-wrapper .dataviews-view-list__title-field, +.dataviews-view-list div[role=article].is-selected .dataviews-view-list__item-wrapper .dataviews-view-list__fields, +.dataviews-view-list div[role=article].is-selected:focus-within .dataviews-view-list__item-wrapper .dataviews-view-list__title-field, +.dataviews-view-list div[role=article].is-selected:focus-within .dataviews-view-list__item-wrapper .dataviews-view-list__fields { + color: var(--wp-admin-theme-color); +} +.dataviews-view-list .dataviews-view-list__item { + position: absolute; + z-index: 1; + inset: 0; + scroll-margin: 8px 0; + appearance: none; + border: none; + background: none; + padding: 0; + cursor: pointer; +} +.dataviews-view-list .dataviews-view-list__item:focus-visible { + outline: none; +} +.dataviews-view-list .dataviews-view-list__item:focus-visible::before { + position: absolute; + content: ""; + inset: var(--wp-admin-border-width-focus); + box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); + border-radius: 2px; + outline: 2px solid transparent; +} +.dataviews-view-list .dataviews-view-list__title-field { + flex: 1; + min-height: 24px; + line-height: 24px; + overflow: hidden; +} +.dataviews-view-list .dataviews-view-list__title-field:has(a, button) { + z-index: 1; +} +.dataviews-view-list .dataviews-view-list__media-wrapper { + width: 52px; + height: 52px; + overflow: hidden; + position: relative; + flex-shrink: 0; + background-color: #fff; + border-radius: 4px; +} +.dataviews-view-list .dataviews-view-list__media-wrapper img { + width: 100%; + height: 100%; + object-fit: cover; +} +.dataviews-view-list .dataviews-view-list__media-wrapper::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1); + border-radius: 4px; +} +.dataviews-view-list .dataviews-view-list__field-wrapper { + min-height: 52px; + flex-grow: 1; +} +.dataviews-view-list .dataviews-view-list__fields { + color: #757575; + display: flex; + gap: 12px; + row-gap: 4px; + flex-wrap: wrap; + font-size: 12px; +} +.dataviews-view-list .dataviews-view-list__fields:empty { + display: none; +} +.dataviews-view-list .dataviews-view-list__fields .dataviews-view-list__field:has(.dataviews-view-list__field-value:empty) { + display: none; +} +.dataviews-view-list .dataviews-view-list__fields .dataviews-view-list__field-value { + min-height: 24px; + line-height: 20px; + display: flex; + align-items: center; +} +.dataviews-view-list + .dataviews-pagination { + justify-content: space-between; +} + +.dataviews-view-list__group-header { + font-size: 15px; + font-weight: 499; + color: #1e1e1e; + margin: 0 0 8px 0; + padding: 0 24px; +} + +.dataviews-view-table { + width: 100%; + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + position: relative; + color: #757575; + margin-bottom: auto; +} +.dataviews-view-table th { + text-align: left; + color: #1e1e1e; + font-weight: normal; + font-size: 13px; +} +.dataviews-view-table td, +.dataviews-view-table th { + padding: 12px; +} +.dataviews-view-table td.dataviews-view-table__actions-column, +.dataviews-view-table th.dataviews-view-table__actions-column { + text-align: right; +} +.dataviews-view-table td.dataviews-view-table__actions-column--sticky, +.dataviews-view-table th.dataviews-view-table__actions-column--sticky { + position: sticky; + right: 0; + background-color: #fff; +} +.dataviews-view-table td.dataviews-view-table__actions-column--stuck::after, +.dataviews-view-table th.dataviews-view-table__actions-column--stuck::after { + display: block; + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 1px; + background-color: #f0f0f0; +} +.dataviews-view-table td.dataviews-view-table__checkbox-column, +.dataviews-view-table th.dataviews-view-table__checkbox-column { + padding-right: 0; +} +.dataviews-view-table td.dataviews-view-table__checkbox-column .dataviews-view-table__cell-content-wrapper, +.dataviews-view-table th.dataviews-view-table__checkbox-column .dataviews-view-table__cell-content-wrapper { + max-width: auto; + min-width: auto; +} +.dataviews-view-table tr { + border-top: 1px solid #f0f0f0; +} +.dataviews-view-table tr .dataviews-view-table-header-button { + gap: 4px; +} +.dataviews-view-table tr td:first-child, +.dataviews-view-table tr th:first-child { + padding-left: 48px; +} +.dataviews-view-table tr td:first-child .dataviews-view-table-header-button, +.dataviews-view-table tr th:first-child .dataviews-view-table-header-button { + margin-left: -8px; +} +.dataviews-view-table tr td:last-child, +.dataviews-view-table tr th:last-child { + padding-right: 48px; +} +.dataviews-view-table tr:last-child { + border-bottom: 0; +} +.dataviews-view-table tr.is-hovered, .dataviews-view-table tr.is-hovered .dataviews-view-table__actions-column--sticky { + background-color: #f8f8f8; +} +.dataviews-view-table tr .components-checkbox-control__input.components-checkbox-control__input { + opacity: 0; +} +.dataviews-view-table tr .components-checkbox-control__input.components-checkbox-control__input:checked, .dataviews-view-table tr .components-checkbox-control__input.components-checkbox-control__input:indeterminate, .dataviews-view-table tr .components-checkbox-control__input.components-checkbox-control__input:focus { + opacity: 1; +} +.dataviews-view-table tr .dataviews-item-actions .components-button:not(.dataviews-all-actions-button) { + opacity: 0; +} +.dataviews-view-table tr:focus-within .components-checkbox-control__input, +.dataviews-view-table tr:focus-within .dataviews-item-actions .components-button:not(.dataviews-all-actions-button), .dataviews-view-table tr.is-hovered .components-checkbox-control__input, +.dataviews-view-table tr.is-hovered .dataviews-item-actions .components-button:not(.dataviews-all-actions-button), .dataviews-view-table tr:hover .components-checkbox-control__input, +.dataviews-view-table tr:hover .dataviews-item-actions .components-button:not(.dataviews-all-actions-button) { + opacity: 1; +} +@media (hover: none) { + .dataviews-view-table tr .components-checkbox-control__input.components-checkbox-control__input, + .dataviews-view-table tr .dataviews-item-actions .components-button:not(.dataviews-all-actions-button) { + opacity: 1; + } +} +.dataviews-view-table tr.is-selected { + background-color: rgba(var(--wp-admin-theme-color--rgb), 0.04); + color: #757575; +} +.dataviews-view-table tr.is-selected, .dataviews-view-table tr.is-selected + tr { + border-top: 1px solid rgba(var(--wp-admin-theme-color--rgb), 0.12); +} +.dataviews-view-table tr.is-selected:hover { + background-color: rgba(var(--wp-admin-theme-color--rgb), 0.08); +} +.dataviews-view-table tr.is-selected .dataviews-view-table__actions-column--sticky { + background-color: color-mix(in srgb, rgb(var(--wp-admin-theme-color--rgb)) 4%, #fff); +} +.dataviews-view-table tr.is-selected:hover .dataviews-view-table__actions-column--sticky { + background-color: color-mix(in srgb, rgb(var(--wp-admin-theme-color--rgb)) 8%, #fff); +} +.dataviews-view-table thead { + position: sticky; + inset-block-start: 0; + z-index: 1; +} +.dataviews-view-table thead tr { + border: 0; +} +.dataviews-view-table thead tr .components-checkbox-control__input.components-checkbox-control__input { + opacity: 1; +} +.dataviews-view-table thead th { + background-color: #fff; + padding-top: 8px; + padding-bottom: 8px; + padding-left: 12px; + font-size: 11px; + text-transform: uppercase; + font-weight: 499; +} +.dataviews-view-table thead th:has(.dataviews-view-table-header-button):not(:first-child) { + padding-left: 4px; +} +.dataviews-view-table tbody td { + vertical-align: top; +} +.dataviews-view-table tbody .dataviews-view-table__cell-content-wrapper { + min-height: 32px; + display: flex; + align-items: center; + white-space: nowrap; +} +.dataviews-view-table tbody .dataviews-view-table__cell-content-wrapper.dataviews-view-table__cell-align-end { + justify-content: flex-end; +} +.dataviews-view-table tbody .dataviews-view-table__cell-content-wrapper.dataviews-view-table__cell-align-center { + justify-content: center; +} +.dataviews-view-table tbody .components-v-stack > .dataviews-view-table__cell-content-wrapper:not(:first-child) { + min-height: 0; +} +.dataviews-view-table .dataviews-view-table-header-button { + padding: 4px 8px; + font-size: 11px; + text-transform: uppercase; + font-weight: 499; +} +.dataviews-view-table .dataviews-view-table-header-button:not(:hover) { + color: #1e1e1e; +} +.dataviews-view-table .dataviews-view-table-header-button span { + speak: none; +} +.dataviews-view-table .dataviews-view-table-header-button span:empty { + display: none; +} +.dataviews-view-table .dataviews-view-table-header { + padding-left: 4px; +} +.dataviews-view-table .dataviews-view-table__actions-column { + width: auto; + white-space: nowrap; +} +.dataviews-view-table:has(tr.is-selected) .components-checkbox-control__input { + opacity: 1; +} +.dataviews-view-table.has-compact-density thead th:has(.dataviews-view-table-header-button):not(:first-child) { + padding-left: 0; +} +.dataviews-view-table.has-compact-density td, +.dataviews-view-table.has-compact-density th { + padding: 4px 8px; +} +.dataviews-view-table.has-comfortable-density td, +.dataviews-view-table.has-comfortable-density th { + padding: 16px 12px; +} +.dataviews-view-table.has-compact-density td.dataviews-view-table__checkbox-column, +.dataviews-view-table.has-compact-density th.dataviews-view-table__checkbox-column, .dataviews-view-table.has-comfortable-density td.dataviews-view-table__checkbox-column, +.dataviews-view-table.has-comfortable-density th.dataviews-view-table__checkbox-column { + padding-right: 0; +} + +@container (max-width: 430px) { + .dataviews-view-table tr td:first-child, + .dataviews-view-table tr th:first-child { + padding-left: 24px; + } + .dataviews-view-table tr td:last-child, + .dataviews-view-table tr th:last-child { + padding-right: 24px; + } +} +.dataviews-view-table-selection-checkbox { + --checkbox-input-size: 24px; +} +@media (min-width: 600px) { + .dataviews-view-table-selection-checkbox { + --checkbox-input-size: 16px; + } +} + +.dataviews-column-primary__media { + max-width: 60px; + overflow: hidden; + position: relative; + flex-shrink: 0; + background-color: #fff; + border-radius: 4px; +} +.dataviews-column-primary__media img { + width: 100%; + height: 100%; + object-fit: cover; +} +.dataviews-column-primary__media::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1); + border-radius: 4px; +} + +.dataviews-view-table__cell-content-wrapper:not(.dataviews-column-primary__media), +.dataviews-view-table__primary-column-content:not(.dataviews-column-primary__media) { + min-width: 15ch; + max-width: 80ch; +} + +.dataviews-view-table__group-header-row .dataviews-view-table__group-header-cell { + font-weight: 499; + padding: 12px 48px; + color: #1e1e1e; +} + +/* Column width intents via colgroup: make non-primary columns shrink-to-fit */ +.dataviews-view-table col[class^=dataviews-view-table__col-]:not(.dataviews-view-table__col-primary) { + width: 1%; +} + +.dataviews-view-picker-grid .dataviews-view-picker-grid__card { + height: 100%; + justify-content: flex-start; + position: relative; +} +.dataviews-view-picker-grid .dataviews-view-picker-grid__card .dataviews-view-picker-grid__title-actions { + padding: 8px 0 4px; +} +.dataviews-view-picker-grid .dataviews-view-picker-grid__card .dataviews-view-picker-grid__title-field { + min-height: 24px; + overflow: hidden; + align-content: center; + text-align: start; +} +.dataviews-view-picker-grid .dataviews-view-picker-grid__card .dataviews-view-picker-grid__title-field--clickable { + width: fit-content; +} +.dataviews-view-picker-grid .dataviews-view-picker-grid__card.is-selected .dataviews-view-picker-grid__fields .dataviews-view-picker-grid__field .dataviews-view-picker-grid__field-value { + color: #1e1e1e; +} +.dataviews-view-picker-grid .dataviews-view-picker-grid__card.is-selected .dataviews-view-picker-grid__media::after, +.dataviews-view-picker-grid .dataviews-view-picker-grid__card .dataviews-view-picker-grid__media:focus::after { + background-color: rgba(var(--wp-admin-theme-color--rgb), 0.08); +} +.dataviews-view-picker-grid .dataviews-view-picker-grid__card.is-selected .dataviews-view-picker-grid__media::after { + box-shadow: inset 0 0 0 1px var(--wp-admin-theme-color); +} +.dataviews-view-picker-grid .dataviews-view-picker-grid__card .dataviews-view-picker-grid__media:focus::after { + box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); +} +.dataviews-view-picker-grid:focus-visible[aria-activedescendant] { + outline: none; +} +.dataviews-view-picker-grid:focus-visible [data-active-item=true] { + outline: 2px solid var(--wp-admin-theme-color); +} +.dataviews-view-picker-grid .dataviews-selection-checkbox { + top: 8px !important; +} +.dataviews-view-picker-grid .dataviews-selection-checkbox input { + pointer-events: none; +} +.dataviews-view-picker-grid .dataviews-view-picker-grid__media { + width: 100%; + aspect-ratio: 1/1; + background-color: #fff; + border-radius: 4px; + position: relative; +} +.dataviews-view-picker-grid .dataviews-view-picker-grid__media img { + object-fit: cover; + width: 100%; + height: 100%; +} +.dataviews-view-picker-grid .dataviews-view-picker-grid__media::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1); + border-radius: 4px; + pointer-events: none; +} +.dataviews-view-picker-grid .dataviews-view-picker-grid__fields { + position: relative; + font-size: 12px; + line-height: 16px; +} +.dataviews-view-picker-grid .dataviews-view-picker-grid__fields:not(:empty) { + padding: 0 0 12px; +} +.dataviews-view-picker-grid .dataviews-view-picker-grid__fields .dataviews-view-picker-grid__field-value:not(:empty) { + min-height: 24px; + line-height: 20px; + padding-top: 2px; +} +.dataviews-view-picker-grid .dataviews-view-picker-grid__fields .dataviews-view-picker-grid__field { + min-height: 24px; + align-items: center; +} +.dataviews-view-picker-grid .dataviews-view-picker-grid__fields .dataviews-view-picker-grid__field .dataviews-view-picker-grid__field-name { + width: 35%; + color: #757575; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.dataviews-view-picker-grid .dataviews-view-picker-grid__fields .dataviews-view-picker-grid__field .dataviews-view-picker-grid__field-value { + width: 65%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.dataviews-view-picker-grid .dataviews-view-picker-grid__fields .dataviews-view-picker-grid__field:not(:has(.dataviews-view-picker-grid__field-value:not(:empty))) { + display: none; +} +.dataviews-view-picker-grid .dataviews-view-picker-grid__badge-fields:not(:empty) { + padding-bottom: 12px; +} + +.dataviews-view-picker-grid__field-value:empty, +.dataviews-view-picker-grid__field:empty { + display: none; +} + +.dataviews-view-picker-grid__card .dataviews-selection-checkbox { + position: absolute; + top: -9999em; + left: 8px; + z-index: 1; +} +@media (hover: none) { + .dataviews-view-picker-grid__card .dataviews-selection-checkbox { + top: 8px; + } +} + +.dataviews-view-picker-grid__card:hover .dataviews-selection-checkbox, +.dataviews-view-picker-grid__card:focus-within .dataviews-selection-checkbox, +.dataviews-view-picker-grid__card.is-selected .dataviews-selection-checkbox { + top: 8px; +} + +.dataviews-view-picker-grid__media--clickable { + cursor: pointer; +} + +.dataviews-view-picker-grid-group__header { + font-size: 15px; + font-weight: 499; + color: #1e1e1e; + margin: 0 0 8px 0; + padding: 0 48px; +} + +.dataviews-view-picker-table tbody:focus-visible[aria-activedescendant] { + outline: none; +} +.dataviews-view-picker-table tbody:focus-visible [data-active-item=true] { + outline: 2px solid var(--wp-admin-theme-color); +} +.dataviews-view-picker-table .dataviews-selection-checkbox .components-checkbox-control__input.components-checkbox-control__input { + pointer-events: none; + opacity: 1; +} +.dataviews-view-picker-table .dataviews-view-table__row { + cursor: pointer; +} +.dataviews-view-picker-table .dataviews-view-table__row.is-selected { + background-color: rgba(var(--wp-admin-theme-color--rgb), 0.04); +} +.dataviews-view-picker-table .dataviews-view-table__row.is-hovered { + background-color: rgba(var(--wp-admin-theme-color--rgb), 0.08); +} +.dataviews-view-picker-table .dataviews-view-table__row.is-selected.is-hovered { + background-color: rgba(var(--wp-admin-theme-color--rgb), 0.12); +} + +.dataviews-controls__datetime { + border: none; + padding: 0; +} + +.dataviews-controls__relative-date-number, +.dataviews-controls__relative-date-unit { + flex: 1 1 50%; +} + +.dataviews-controls__date input[type=date]::-webkit-inner-spin-button, +.dataviews-controls__date input[type=date]::-webkit-calendar-picker-indicator { + display: none; + -webkit-appearance: none; +} + +.dataviews-controls__date-preset { + border: 1px solid #ddd; +} +.dataviews-controls__date-preset:active { + background-color: #000; +} + +.dataforms-layouts-panel__field { + width: 100%; + min-height: 32px; + justify-content: flex-start !important; + align-items: flex-start !important; +} + +.dataforms-layouts-panel__field-label { + width: 38%; + flex-shrink: 0; + min-height: 32px; + display: flex; + align-items: center; + line-height: 20px; + hyphens: auto; +} +.dataforms-layouts-panel__field-label--label-position-side { + align-self: center; +} + +.dataforms-layouts-panel__field-control { + flex-grow: 1; + min-height: 32px; + display: flex; + align-items: center; +} +.dataforms-layouts-panel__field-control .components-button { + max-width: 100%; + text-align: left; + white-space: normal; + text-wrap: balance; + text-wrap: pretty; + min-height: 32px; +} +.dataforms-layouts-panel__field-control.components-button.is-link[aria-disabled=true] { + text-decoration: none; +} +.dataforms-layouts-panel__field-control .components-dropdown { + max-width: 100%; +} + +.dataforms-layouts-panel__field-dropdown .components-popover__content { + min-width: 320px; + padding: 16px; +} + +.dataforms-layouts-panel__dropdown-header { + margin-bottom: 16px; +} + +.dataforms-layouts-panel__modal-footer { + margin-top: 16px; +} + +.components-popover.components-dropdown__content.dataforms-layouts-panel__field-dropdown { + z-index: 159990; +} + +.dataforms-layouts-regular__field { + width: 100%; + min-height: 32px; + justify-content: flex-start !important; + align-items: flex-start !important; +} + +.dataforms-layouts-regular__field-label { + width: 38%; + flex-shrink: 0; + min-height: 32px; + display: flex; + align-items: center; + line-height: 20px; + hyphens: auto; +} +.dataforms-layouts-regular__field-label--label-position-side { + align-self: center; +} + +.dataforms-layouts-regular__field-control { + flex-grow: 1; + min-height: 32px; + display: flex; + align-items: center; +} + +.dataforms-layouts-card__field-header-label { + font-family: -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-weight: 499; + font-size: 15px; + line-height: 20px; +} + +.dataforms-layouts-card__field { + width: 100%; +} + +.dataforms-layouts-card__field-description { + color: #757575; + display: block; + font-size: 13px; + margin-bottom: 16px; +} + +.dataforms-layouts-card__field-summary { + display: flex; + flex-direction: row; + gap: 16px; +} + +.dataforms-layouts-details__content { + padding-top: 12px; +} + +.dataforms-layouts-row__field-control { + width: 100%; +} + +.dataforms-layouts__wrapper { + font-family: -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-weight: 400; + font-size: 13px; + line-height: 20px; +} \ No newline at end of file diff --git a/src/admin/ai-request-logs/components/HeaderPeriodSelector.tsx b/src/admin/ai-request-logs/components/HeaderPeriodSelector.tsx new file mode 100644 index 00000000..873af393 --- /dev/null +++ b/src/admin/ai-request-logs/components/HeaderPeriodSelector.tsx @@ -0,0 +1,53 @@ +/** + * WordPress dependencies + */ +import { SelectControl } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +/** + * External dependencies + */ +import React from 'react'; +import { createPortal } from 'react-dom'; + +type Period = 'minute' | 'hour' | 'day' | 'week' | 'month' | 'all'; + +interface HeaderPeriodSelectorProps { + period: Period; + onPeriodChange: ( period: Period ) => void; + loading: boolean; +} + +const HeaderPeriodSelector: React.FC< HeaderPeriodSelectorProps > = ( { + period, + onPeriodChange, + loading, +} ) => { + const container = document.getElementById( + 'ai-request-logs-header-period' + ); + + if ( ! container ) { + return null; + } + + return createPortal( + onPeriodChange( value as Period ) } + disabled={ loading } + __nextHasNoMarginBottom + __next40pxDefaultSize + />, + container + ); +}; + +export default HeaderPeriodSelector; diff --git a/src/admin/ai-request-logs/components/LogDetailModal.tsx b/src/admin/ai-request-logs/components/LogDetailModal.tsx new file mode 100644 index 00000000..f0c6a28b --- /dev/null +++ b/src/admin/ai-request-logs/components/LogDetailModal.tsx @@ -0,0 +1,352 @@ +/** + * WordPress dependencies + */ +import { Button, Modal } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; + +/** + * External dependencies + */ +import React from 'react'; + +/** + * Internal dependencies + */ +import type { LogEntry } from '../types'; + +interface LogDetailModalProps { + log: LogEntry; + onClose: () => void; +} + +const formatTimestamp = ( timestamp: string ): string => { + const date = new Date( timestamp + 'Z' ); + return date.toLocaleString( undefined, { + dateStyle: 'full', + timeStyle: 'medium', + } ); +}; + +const formatCost = ( cost: number | null ): string => { + if ( cost === null ) { + return '-'; + } + if ( cost < 0.01 ) { + return '$' + cost.toFixed( 6 ); + } + return '$' + cost.toFixed( 4 ); +}; + +const formatKindLabel = ( value: string ): string => + value + .split( '_' ) + .map( ( part ) => part.charAt( 0 ).toUpperCase() + part.slice( 1 ) ) + .join( ' ' ); + +const formatTokensPerSecond = ( value: number | null ): string => { + if ( value === null ) { + return '-'; + } + if ( value >= 1000 ) { + return ( value / 1000 ).toFixed( 1 ) + 'K'; + } + return value.toFixed( 1 ); +}; + +const LogDetailModal: React.FC< LogDetailModalProps > = ( { + log, + onClose, +} ) => { + const inputPreview = + typeof log.context?.input_preview === 'string' + ? log.context.input_preview + : null; + const outputPreview = + typeof log.context?.output_preview === 'string' + ? log.context.output_preview + : null; + const requestKind = + typeof log.context?.request_kind === 'string' + ? log.context.request_kind + : null; + + const imageUrlValue = log.context?.image_urls; + const imageUrls = Array.isArray( imageUrlValue ) + ? imageUrlValue.filter( + ( url ): url is string => + typeof url === 'string' && url.length > 0 + ) + : []; + + const base64Value = log.context?.image_base64_samples; + const base64Images = Array.isArray( base64Value ) + ? base64Value + .map( ( sample ) => + typeof sample === 'object' && sample !== null + ? sample + : null + ) + .filter( + ( + sample + ): sample is { data: string; mime?: string } => + Boolean( + sample && + typeof sample === 'object' && + typeof ( sample as { data?: unknown } ).data === + 'string' && + ( sample as { data: string } ).data.length > 0 + ) + ) + : []; + + const handleCopyId = async () => { + try { + await navigator.clipboard.writeText( log.id ); + } catch ( e ) { + // Fallback for older browsers + const textarea = document.createElement( 'textarea' ); + textarea.value = log.id; + document.body.appendChild( textarea ); + textarea.select(); + document.execCommand( 'copy' ); + document.body.removeChild( textarea ); + } + }; + + const getStatusIcon = ( status: string ): string => { + switch ( status ) { + case 'success': + return '\u2713'; // Checkmark + case 'error': + return '\u2717'; // X mark + case 'timeout': + return '\u23F1'; // Stopwatch + default: + return ''; + } + }; + + return ( + +
+
+ + { log.operation } + + + { getStatusIcon( log.status ) } { log.status } + +
+ +
+

{ __( 'General', 'ai' ) }

+ + + + + + + + + + + + + + + { requestKind && ( + + + + + ) } + { log.user_id && ( + + + + + ) } + +
{ __( 'Timestamp', 'ai' ) }{ formatTimestamp( log.timestamp ) }
{ __( 'Duration', 'ai' ) } + { log.duration_ms !== null + ? sprintf( + /* translators: %d: request duration in milliseconds. */ + __( '%d ms', 'ai' ), + log.duration_ms + ) + : '-' } +
{ __( 'Type', 'ai' ) }{ log.type }
{ __( 'Request Kind', 'ai' ) }{ formatKindLabel( requestKind ) }
{ __( 'User ID', 'ai' ) }{ log.user_id }
+
+ + { ( log.provider || log.model ) && ( +
+

{ __( 'Provider & Model', 'ai' ) }

+ + + { log.provider && ( + + + + + ) } + { log.model && ( + + + + + ) } + +
{ __( 'Provider', 'ai' ) }{ log.provider }
{ __( 'Model', 'ai' ) }{ log.model }
+
+ ) } + + { ( log.tokens_input !== null || + log.tokens_output !== null ) && ( +
+

{ __( 'Token Usage', 'ai' ) }

+ + + + + + + + + + + + + + + + + + + + + + + +
{ __( 'Input Tokens', 'ai' ) } + { log.tokens_input?.toLocaleString() ?? + '-' } +
{ __( 'Output Tokens', 'ai' ) } + { log.tokens_output?.toLocaleString() ?? + '-' } +
{ __( 'Total Tokens', 'ai' ) } + { log.tokens_total?.toLocaleString() ?? + '-' } +
{ __( 'Tokens per Second', 'ai' ) } + { formatTokensPerSecond( + log.tokens_per_second ?? null + ) } +
{ __( 'Estimated Cost', 'ai' ) }{ formatCost( log.cost_estimate ) }
+
+ ) } + + { log.error_message && ( +
+

{ __( 'Error', 'ai' ) }

+
+							{ log.error_message }
+						
+
+ ) } + + { inputPreview && ( +
+

{ __( 'Input Preview', 'ai' ) }

+
+							{ inputPreview }
+						
+
+ ) } + + { outputPreview && ( +
+

{ __( 'Output Preview', 'ai' ) }

+
+							{ outputPreview }
+						
+
+ ) } + + { ( imageUrls.length > 0 || base64Images.length > 0 ) && ( +
+

{ __( 'Generated Images', 'ai' ) }

+
+ { imageUrls.map( ( url, index ) => ( +
+ { +
+ { sprintf( + /* translators: %d: image index */ + __( 'Image %d', 'ai' ), + index + 1 + ) } +
+
+ ) ) } + { base64Images.map( ( sample, index ) => ( +
+ { +
+ { sprintf( + /* translators: %d: image index */ + __( 'Image %d', 'ai' ), + imageUrls.length + index + 1 + ) } +
+
+ ) ) } +
+
+ ) } + + { log.context && Object.keys( log.context ).length > 0 && ( +
+

{ __( 'Context', 'ai' ) }

+
+							{ JSON.stringify( log.context, null, 2 ) }
+						
+
+ ) } + +
+ + { log.id } + + +
+
+
+ ); +}; + +export default LogDetailModal; diff --git a/src/admin/ai-request-logs/components/LogsTable.tsx b/src/admin/ai-request-logs/components/LogsTable.tsx new file mode 100644 index 00000000..02f6aed4 --- /dev/null +++ b/src/admin/ai-request-logs/components/LogsTable.tsx @@ -0,0 +1,561 @@ +/** + * WordPress dependencies + */ +import { + Button, + Card, + CardBody, + CardHeader, + Popover, +} from '@wordpress/components'; +import { DataViews } from '@wordpress/dataviews/wp'; +import type { DataViewField, View, Filter } from '@wordpress/dataviews'; +import { __, sprintf } from '@wordpress/i18n'; + +/** + * External dependencies + */ +import React, { useCallback, useMemo, useState } from 'react'; + +/** + * Internal dependencies + */ +import { getProviderIconComponent } from '../../components/provider-icons'; +import type { FilterOptions, LogEntry } from '../types'; +import type { ProviderMetadata } from '../../types/providers'; + +interface LogsTableProps { + logs: LogEntry[]; + filterOptions: FilterOptions; + onViewLog: ( log: LogEntry ) => void; + loading: boolean; + totalPages: number; + total: number; + view: View; + setView: ( next: View | ( ( prev: View ) => View ) ) => void; + providerMetadata: Record< string, ProviderMetadata >; +} + +const formatTimestamp = ( timestamp: string ): string => { + const date = new Date( timestamp + 'Z' ); + return date.toLocaleString(); +}; + +const formatDuration = ( ms: number | null ): string => { + if ( ms === null ) { + return '-'; + } + if ( ms < 1000 ) { + return ms + 'ms'; + } + return ( ms / 1000 ).toFixed( 1 ) + 's'; +}; + +const formatTokens = ( tokens: number | null ): string => { + if ( tokens === null ) { + return '-'; + } + if ( tokens >= 1000 ) { + return ( tokens / 1000 ).toFixed( 1 ) + 'K'; + } + return tokens.toLocaleString(); +}; + +const formatTokensPerSecond = ( value: number | null ): string => { + if ( value === null ) { + return '-'; + } + if ( value >= 1000 ) { + return ( value / 1000 ).toFixed( 1 ) + 'K'; + } + return value.toFixed( 1 ); +}; + +const getStatusClass = ( status: string ): string => { + switch ( status ) { + case 'success': + return 'ai-request-logs__status--success'; + case 'error': + return 'ai-request-logs__status--error'; + case 'timeout': + return 'ai-request-logs__status--timeout'; + default: + return ''; + } +}; + +const formatSelectLabel = ( value: string ): string => + value + .split( '_' ) + .map( ( part ) => part.charAt( 0 ).toUpperCase() + part.slice( 1 ) ) + .join( ' ' ); + +const getRequestKind = ( entry: LogEntry ): string => { + const raw = entry.context?.request_kind; + return typeof raw === 'string' ? raw : 'text'; +}; + +const LogsTable: React.FC< LogsTableProps > = ( { + logs, + filterOptions, + onViewLog, + loading, + totalPages, + total, + view, + setView, + providerMetadata, +} ) => { + const typeElements = useMemo( + () => + filterOptions.types.map( ( value ) => ( { + label: formatSelectLabel( value ), + value, + } ) ), + [ filterOptions.types ] + ); + + const statusElements = useMemo( + () => + filterOptions.statuses.map( ( value ) => ( { + label: formatSelectLabel( value ), + value, + } ) ), + [ filterOptions.statuses ] + ); + + const providerElements = useMemo( + () => + filterOptions.providers.map( ( value ) => ( { + label: value, + value, + } ) ), + [ filterOptions.providers ] + ); + + const operationElements = useMemo( + () => + ( filterOptions.operations ?? [] ).map( ( value ) => ( { + label: formatSelectLabel( value ), + value, + } ) ), + [ filterOptions.operations ] + ); + + // Token filter elements with useful ranges + const tokenFilterElements = useMemo( + () => [ + { + label: __( 'Has Tokens (> 0)', 'ai' ), + value: 'gt:0', + }, + { + label: __( '< 100 tokens', 'ai' ), + value: 'lt:100', + }, + { + label: __( '< 500 tokens', 'ai' ), + value: 'lt:500', + }, + { + label: __( '< 1K tokens', 'ai' ), + value: 'lt:1000', + }, + { + label: __( '< 5K tokens', 'ai' ), + value: 'lt:5000', + }, + { + label: __( '> 1K tokens', 'ai' ), + value: 'gt:1000', + }, + { + label: __( '> 5K tokens', 'ai' ), + value: 'gt:5000', + }, + { + label: __( '> 10K tokens', 'ai' ), + value: 'gt:10000', + }, + { + label: __( 'No Tokens', 'ai' ), + value: 'none', + }, + ], + [] + ); + + const requestKindElements = useMemo( () => { + const kinds = new Set(); + logs.forEach( ( entry ) => kinds.add( getRequestKind( entry ) ) ); + return Array.from( kinds ).map( ( value ) => ( { + label: formatSelectLabel( value ), + value, + } ) ); + }, [ logs ] ); + + const fields = useMemo< DataViewField< LogEntry >[] >( + () => [ + { + id: 'timestamp', + label: __( 'Time', 'ai' ), + type: 'datetime', + getValue: ( { item } ) => item.timestamp, + enableSorting: false, + render: ( { item } ) => ( + + { formatTimestamp( item.timestamp ) } + + ), + }, + { + id: 'operation', + label: __( 'Operation', 'ai' ), + type: 'text', + getValue: ( { item } ) => item.operation, + elements: operationElements, + filterBy: + operationElements.length > 0 + ? { operators: [ 'isAny' ] } + : false, + render: ( { item } ) => ( +
+ { item.operation } + { item.error_message && ( +
+ { item.error_message.substring( 0, 50 ) } + { item.error_message.length > 50 ? '…' : '' } +
+ ) } +
+ ), + }, + { + id: 'operation_pattern', + label: __( 'Request Type', 'ai' ), + type: 'text', + getValue: ( { item } ) => getRequestKind( item ), + elements: requestKindElements, + filterBy: + requestKindElements.length > 0 + ? { operators: [ 'is' ] } + : false, + enableHiding: false, + render: ( { item } ) => ( + + { formatSelectLabel( getRequestKind( item ) ) } + + ), + }, + { + id: 'type', + label: __( 'Type', 'ai' ), + type: 'text', + getValue: ( { item } ) => item.type, + elements: typeElements, + filterBy: + typeElements.length > 0 ? { operators: [ 'is' ] } : false, + enableHiding: false, + isVisible: () => false, + }, + { + id: 'provider', + label: __( 'Provider / Model', 'ai' ), + type: 'text', + getValue: ( { item } ) => item.provider ?? '', + elements: providerElements, + filterBy: + providerElements.length > 0 + ? { operators: [ 'is' ] } + : false, + render: ( { item } ) => ( + + ), + }, + { + id: 'tokens_total', + label: __( 'Tokens', 'ai' ), + type: 'text', + getValue: ( { item } ) => { + // Return filter value for matching + const tokens = item.tokens_total ?? 0; + if ( tokens === 0 || item.tokens_total === null ) { + return 'none'; + } + if ( tokens > 10000 ) return 'gt:10000'; + if ( tokens > 5000 ) return 'gt:5000'; + if ( tokens > 1000 ) return 'gt:1000'; + return 'gt:0'; + }, + elements: tokenFilterElements, + filterBy: { operators: [ 'is' ] }, + render: ( { item } ) => formatTokens( item.tokens_total ), + }, + { + id: 'duration_ms', + label: __( 'Duration', 'ai' ), + type: 'number', + getValue: ( { item } ) => item.duration_ms ?? 0, + render: ( { item } ) => formatDuration( item.duration_ms ), + }, + { + id: 'tokens_per_second', + label: __( 'Tokens/s', 'ai' ), + type: 'number', + getValue: ( { item } ) => item.tokens_per_second ?? 0, + render: ( { item } ) => + formatTokensPerSecond( item.tokens_per_second ), + }, + { + id: 'status', + label: __( 'Status', 'ai' ), + type: 'text', + getValue: ( { item } ) => item.status, + elements: statusElements, + filterBy: + statusElements.length > 0 ? { operators: [ 'is' ] } : false, + render: ( { item } ) => ( + + { formatSelectLabel( item.status ) } + + ), + }, + { + id: 'actions', + label: __( 'Details', 'ai' ), + type: 'text', + enableSorting: false, + enableHiding: false, + filterBy: false, + render: ( { item } ) => ( + + ), + }, + ], + [ + onViewLog, + operationElements, + providerElements, + providerMetadata, + statusElements, + tokenFilterElements, + typeElements, + requestKindElements, + ] + ); + + const handleViewChange = useCallback( + ( nextView: View ) => { + setView( ( previous ) => { + // Deduplicate filters by field - keep only the last filter for each field + const filters = nextView.filters ?? []; + const deduplicatedFilters = filters.reduce( + ( acc: Filter[], filter ) => { + const existingIndex = acc.findIndex( + ( f ) => f.field === filter.field + ); + if ( existingIndex >= 0 ) { + acc[ existingIndex ] = filter; + } else { + acc.push( filter ); + } + return acc; + }, + [] + ); + + return { + ...previous, + ...nextView, + filters: deduplicatedFilters, + layout: nextView.layout ?? previous.layout, + }; + } ); + }, + [ setView ] + ); + + const hasActiveFilters = Boolean( + view.search || ( view.filters && view.filters.length > 0 ) + ); + + return ( + + +

{ __( 'Request Logs', 'ai' ) }

+ { total > 0 && ( + + { sprintf( + /* translators: %d: total number of logged requests. */ + __( '%d total', 'ai' ), + total + ) } + + ) } +
+ + item.id } + defaultLayouts={ { + table: { + layout: { + density: 'comfortable', + enableMoving: false, + }, + }, + } } + isLoading={ loading } + paginationInfo={ { + totalItems: total, + totalPages, + } } + config={ { perPageSizes: [ 25 ] } } + empty={ +

+ { hasActiveFilters + ? __( 'No logs match your filters.', 'ai' ) + : __( + 'No AI requests have been logged yet.', + 'ai' + ) } +

+ } + searchLabel={ __( 'Search logs', 'ai' ) } + /> +
+
+ ); +}; + +interface ProviderCellProps { + provider: string | null; + model: string | null; + metadata?: ProviderMetadata; +} + +const ProviderCell: React.FC< ProviderCellProps > = ( { + provider, + model, + metadata, +} ) => { + const [ isPopoverVisible, setIsPopoverVisible ] = useState( false ); + + if ( ! provider && ! model ) { + return -; + } + + if ( ! metadata ) { + return ( +
+
+ { provider && ( + + { provider } + + ) } +
+ { model && ( +
{ model }
+ ) } +
+ ); + } + + const IconComponent = getProviderIconComponent( + metadata.icon || metadata.id, + provider || undefined + ); + + return ( +
setIsPopoverVisible( true ) } + onMouseLeave={ () => setIsPopoverVisible( false ) } + > +
+ + + + + { metadata.name } + +
+ { model && ( +
{ model }
+ ) } + { isPopoverVisible && ( + +
+
+ + + + + { metadata.name } + + + { metadata.type === 'client' + ? __( 'Local', 'ai' ) + : __( 'Cloud', 'ai' ) } + +
+ { model && ( +
+ { model } +
+ ) } + +
+
+ ) } +
+ ); +}; + +export default LogsTable; diff --git a/src/admin/ai-request-logs/components/SettingsPanel.tsx b/src/admin/ai-request-logs/components/SettingsPanel.tsx new file mode 100644 index 00000000..0d1b7ad6 --- /dev/null +++ b/src/admin/ai-request-logs/components/SettingsPanel.tsx @@ -0,0 +1,129 @@ +/** + * WordPress dependencies + */ +import { + Button, + Card, + CardBody, + CardHeader, + RangeControl, + ToggleControl, +} from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +/** + * External dependencies + */ +import React, { useState } from 'react'; + +interface SettingsPanelProps { + enabled: boolean; + retentionDays: number; + onToggleEnabled: ( enabled: boolean ) => void; + onRetentionChange: ( days: number ) => void; + onPurgeLogs: () => void; + saving: boolean; + purging: boolean; +} + +const SettingsPanel: React.FC< SettingsPanelProps > = ( { + enabled, + retentionDays, + onToggleEnabled, + onRetentionChange, + onPurgeLogs, + saving, + purging, +} ) => { + const [ showPurgeConfirm, setShowPurgeConfirm ] = useState( false ); + + const handlePurge = () => { + if ( showPurgeConfirm ) { + onPurgeLogs(); + setShowPurgeConfirm( false ); + } else { + setShowPurgeConfirm( true ); + } + }; + + return ( + + +

{ __( 'Settings', 'ai' ) }

+
+ + + + onRetentionChange( value ?? 30 ) } + min={ 1 } + max={ 365 } + disabled={ saving } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + +
+

{ __( 'Danger Zone', 'ai' ) }

+

+ { __( + 'Permanently delete all logged requests. This action cannot be undone.', + 'ai' + ) } +

+ { showPurgeConfirm ? ( +
+ { __( 'Are you sure?', 'ai' ) } + + +
+ ) : ( + + ) } +
+
+
+ ); +}; + +export default SettingsPanel; diff --git a/src/admin/ai-request-logs/components/SummaryCards.tsx b/src/admin/ai-request-logs/components/SummaryCards.tsx new file mode 100644 index 00000000..067f0830 --- /dev/null +++ b/src/admin/ai-request-logs/components/SummaryCards.tsx @@ -0,0 +1,108 @@ +/** + * WordPress dependencies + */ +import { Card, CardBody } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +/** + * External dependencies + */ +import React from 'react'; + +/** + * Internal dependencies + */ +import type { LogSummary } from '../types'; + +interface SummaryCardsProps { + summary: LogSummary; + loading: boolean; +} + +const formatNumber = ( num: number ): string => { + if ( num >= 1000000 ) { + return ( num / 1000000 ).toFixed( 1 ) + 'M'; + } + if ( num >= 1000 ) { + return ( num / 1000 ).toFixed( 1 ) + 'K'; + } + return num.toLocaleString(); +}; + +const formatCost = ( cost: number ): string => { + if ( cost < 0.01 ) { + return '$' + cost.toFixed( 4 ); + } + return '$' + cost.toFixed( 2 ); +}; + +const formatDuration = ( ms: number ): string => { + if ( ms < 1000 ) { + return ms.toFixed( 0 ) + 'ms'; + } + return ( ms / 1000 ).toFixed( 1 ) + 's'; +}; + +const SummaryCards: React.FC< SummaryCardsProps > = ( { summary } ) => { + return ( +
+
+ + +
+ { formatNumber( summary.total_requests ) } +
+
+ { __( 'Requests', 'ai' ) } +
+
+
+ + + +
+ { formatNumber( summary.total_tokens ) } +
+
+ { __( 'Tokens', 'ai' ) } +
+
+
+ + + +
+ { formatDuration( summary.avg_duration_ms ) } +
+
+ { __( 'Avg Time', 'ai' ) } +
+
+
+ + + +
+ { formatCost( summary.total_cost ) } +
+
+ { __( 'Est. Cost', 'ai' ) } +
+
+
+ + + +
+ { summary.success_rate.toFixed( 1 ) }% +
+
+ { __( 'Success Rate', 'ai' ) } +
+
+
+
+
+ ); +}; + +export default SummaryCards; diff --git a/src/admin/ai-request-logs/index.tsx b/src/admin/ai-request-logs/index.tsx new file mode 100644 index 00000000..a652e0a4 --- /dev/null +++ b/src/admin/ai-request-logs/index.tsx @@ -0,0 +1,419 @@ +/** + * Internal dependencies + */ +import './style.scss'; + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { Notice } from '@wordpress/components'; +import { dispatch } from '@wordpress/data'; +import type { Filter, View } from '@wordpress/dataviews'; +import { store as noticesStore } from '@wordpress/notices'; +import { __ } from '@wordpress/i18n'; + +/** + * External dependencies + */ +import React, { useCallback, useEffect, useState } from 'react'; +import { createRoot } from 'react-dom/client'; + +/** + * Internal dependencies + */ +import { usePersistedView } from '../hooks/usePersistedView'; + +import HeaderPeriodSelector from './components/HeaderPeriodSelector'; +import LogDetailModal from './components/LogDetailModal'; +import LogsTable from './components/LogsTable'; +import SettingsPanel from './components/SettingsPanel'; +import SummaryCards from './components/SummaryCards'; +import type { + FilterOptions, + LocalizedSettings, + LogEntry, + LogSummary, +} from './types'; + +const settings: LocalizedSettings = + window.aiAiRequestLogsSettings ?? + window.AiRequestLogsSettings ?? + ( () => { + throw new Error( 'AiRequestLogsSettings is not defined.' ); + } )(); + +const providerMetadata = settings.providerMetadata ?? {}; + +apiFetch.use( apiFetch.createNonceMiddleware( settings.rest.nonce ) ); +apiFetch.use( apiFetch.createRootURLMiddleware( settings.rest.root ) ); + +const showNotice = ( + status: 'success' | 'error' | 'warning', + message: string +) => + dispatch( noticesStore ).createNotice( status, message, { + type: 'snackbar', + } ); + +const getErrorMessage = ( error: unknown ): string => { + if ( typeof error === 'string' ) { + return error; + } + if ( error && typeof error === 'object' && 'message' in error ) { + return String( ( error as { message: string } ).message ); + } + return __( 'Something went wrong. Please try again.', 'ai' ); +}; + +const defaultView: View = { + type: 'table', + perPage: 25, + page: 1, + search: '', + filters: [], + fields: [ + 'timestamp', + 'operation', + 'provider', + 'tokens_total', + 'duration_ms', + 'status', + 'actions', + ], + sort: { + field: 'timestamp', + direction: 'desc', + }, + layout: { + density: 'comfortable', + }, +}; + +const normalizeFilterValue = ( raw: unknown ): string => { + if ( Array.isArray( raw ) ) { + return normalizeFilterValue( raw[ 0 ] ); + } + if ( + raw && + typeof raw === 'object' && + 'value' in ( raw as Record< string, unknown > ) + ) { + return normalizeFilterValue( ( raw as { value: unknown } ).value ); + } + if ( typeof raw === 'string' ) { + return raw; + } + return ''; +}; + +const normalizeFilterArrayValue = ( raw: unknown ): string[] => { + if ( Array.isArray( raw ) ) { + return raw.map( ( item ) => { + if ( typeof item === 'string' ) { + return item; + } + if ( item && typeof item === 'object' && 'value' in item ) { + return String( ( item as { value: unknown } ).value ); + } + return String( item ); + } ); + } + if ( typeof raw === 'string' && raw ) { + return [ raw ]; + } + return []; +}; + +const extractFilterValue = ( + filters: Filter[] | undefined, + field: string +): string => { + const match = filters?.find( ( entry ) => entry.field === field ); + return normalizeFilterValue( match?.value ?? '' ); +}; + +const extractFilterArrayValue = ( + filters: Filter[] | undefined, + field: string +): string[] => { + const match = filters?.find( ( entry ) => entry.field === field ); + return normalizeFilterArrayValue( match?.value ?? [] ); +}; + +const App: React.FC = () => { + // Settings state + const [ enabled, setEnabled ] = useState( settings.initialState.enabled ); + const [ retentionDays, setRetentionDays ] = useState( + settings.initialState.retentionDays + ); + + // Summary state + const [ summary, setSummary ] = useState< LogSummary >( + settings.initialState.summary + ); + const [ summaryPeriod, setSummaryPeriod ] = useState< + 'minute' | 'hour' | 'day' | 'week' | 'month' | 'all' + >( 'day' ); + const [ summaryLoading, setSummaryLoading ] = useState( false ); + + // Logs state + const [ logs, setLogs ] = useState< LogEntry[] >( [] ); + const [ logsLoading, setLogsLoading ] = useState( true ); + const [ totalPages, setTotalPages ] = useState( 1 ); + const [ total, setTotal ] = useState( 0 ); + + const { view, setView } = usePersistedView< View >( + 'ai-request-logs', + defaultView + ); + const [ filterOptions, setFilterOptions ] = useState< FilterOptions >( + settings.initialState.filters + ); + + // UI state + const [ selectedLog, setSelectedLog ] = useState< LogEntry | null >( null ); + const [ error, setError ] = useState< string | null >( null ); + const [ saving, setSaving ] = useState( false ); + const [ purging, setPurging ] = useState( false ); + + // Fetch summary + const fetchSummary = useCallback( async ( period: string ) => { + setSummaryLoading( true ); + try { + const response = await apiFetch< LogSummary >( { + path: `${ settings.rest.routes.summary }?period=${ period }`, + } ); + setSummary( response ); + } catch ( apiError ) { + showNotice( 'error', __( 'Unable to load summary data.', 'ai' ) ); + } finally { + setSummaryLoading( false ); + } + }, [] ); + + // Fetch logs + const fetchLogs = useCallback( async () => { + setLogsLoading( true ); + try { + const params = new URLSearchParams( { + page: String( view.page ?? 1 ), + per_page: '25', + } ); + const typeFilter = extractFilterValue( view.filters, 'type' ); + const statusFilter = extractFilterValue( view.filters, 'status' ); + const providerFilter = extractFilterValue( + view.filters, + 'provider' + ); + const operationFilter = extractFilterArrayValue( + view.filters, + 'operation' + ); + const operationPatternFilter = extractFilterValue( + view.filters, + 'operation_pattern' + ); + const tokensFilterValue = extractFilterValue( + view.filters, + 'tokens_total' + ); + const searchTerm = view.search ?? ''; + + if ( typeFilter ) { + params.append( 'type', typeFilter ); + } + if ( statusFilter ) { + params.append( 'status', statusFilter ); + } + if ( providerFilter ) { + params.append( 'provider', providerFilter ); + } + if ( operationFilter.length > 0 ) { + params.append( 'operation', operationFilter.join( ',' ) ); + } + if ( operationPatternFilter ) { + params.append( 'operation_pattern', operationPatternFilter ); + } + if ( tokensFilterValue ) { + params.append( 'tokens_filter', tokensFilterValue ); + } + if ( searchTerm ) { + params.append( 'search', searchTerm ); + } + + const response = ( await apiFetch< LogEntry[] >( { + path: `${ settings.rest.routes.logs }?${ params.toString() }`, + parse: false, + } ) ) as Response; + + const data = await response.json(); + setLogs( data ); + setTotal( + parseInt( response.headers.get( 'X-WP-Total' ) || '0', 10 ) + ); + setTotalPages( + parseInt( response.headers.get( 'X-WP-TotalPages' ) || '1', 10 ) + ); + setError( null ); + } catch ( apiError ) { + setError( getErrorMessage( apiError ) ); + } finally { + setLogsLoading( false ); + } + }, [ view.filters, view.page, view.search ] ); + + // Fetch filter options + const fetchFilters = useCallback( async () => { + try { + const response = await apiFetch< FilterOptions >( { + path: settings.rest.routes.filters, + } ); + // Ensure operations array exists (fallback for older backends) + setFilterOptions( { + ...response, + operations: response.operations ?? [], + } ); + } catch ( apiError ) { + showNotice( + 'error', + __( 'Unable to load filter metadata.', 'ai' ) + ); + } + }, [] ); + + // Initial load + useEffect( () => { + fetchLogs(); + fetchFilters(); + }, [ fetchLogs, fetchFilters ] ); + + // Handle period change + const handlePeriodChange = ( + period: 'minute' | 'hour' | 'day' | 'week' | 'month' | 'all' + ) => { + setSummaryPeriod( period ); + fetchSummary( period ); + }; + + // Handle settings update + const handleSettingsUpdate = async ( + newEnabled?: boolean, + newRetention?: number + ) => { + setSaving( true ); + try { + const data: Record< string, unknown > = {}; + if ( newEnabled !== undefined ) { + data.enabled = newEnabled; + } + if ( newRetention !== undefined ) { + data.retention_days = newRetention; + } + + await apiFetch( { + path: settings.rest.routes.logs, + method: 'POST', + data, + } ); + + if ( newEnabled !== undefined ) { + setEnabled( newEnabled ); + } + if ( newRetention !== undefined ) { + setRetentionDays( newRetention ); + } + + showNotice( 'success', __( 'Settings saved.', 'ai' ) ); + } catch ( apiError ) { + showNotice( 'error', getErrorMessage( apiError ) ); + } finally { + setSaving( false ); + } + }; + + // Handle purge + const handlePurge = async () => { + setPurging( true ); + try { + await apiFetch( { + path: settings.rest.routes.logs, + method: 'DELETE', + } ); + + // Clear logs immediately for instant UI feedback + setLogs( [] ); + setTotal( 0 ); + setTotalPages( 1 ); + setView( ( prev ) => ( { ...prev, page: 1 } ) ); + + showNotice( 'success', __( 'All logs have been purged.', 'ai' ) ); + fetchSummary( summaryPeriod ); + fetchFilters(); + } catch ( apiError ) { + showNotice( 'error', getErrorMessage( apiError ) ); + } finally { + setPurging( false ); + } + }; + + return ( +
+ + + { error && ( + setError( null ) }> + { error } + + ) } + + + +
+ + + + handleSettingsUpdate( value, undefined ) + } + onRetentionChange={ ( value ) => + handleSettingsUpdate( undefined, value ) + } + onPurgeLogs={ handlePurge } + saving={ saving } + purging={ purging } + /> +
+ + { selectedLog && ( + setSelectedLog( null ) } + /> + ) } +
+ ); +}; + +const mountNode = document.getElementById( 'ai-request-logs-root' ); + +if ( mountNode ) { + const root = createRoot( mountNode ); + root.render( ); +} diff --git a/src/admin/ai-request-logs/style.scss b/src/admin/ai-request-logs/style.scss new file mode 100644 index 00000000..b6243c39 --- /dev/null +++ b/src/admin/ai-request-logs/style.scss @@ -0,0 +1,469 @@ +@use '../dataviews'; +@use '../common'; + +.ai-request-logs__app { + max-width: 1400px; + + .components-notice { + margin: 0 0 20px; + } +} + +// Summary Cards +.ai-request-logs__summary { + margin-bottom: 24px; +} + +.ai-request-logs__summary-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 16px; +} + +.ai-request-logs__stat-card { + text-align: center; + + .components-card__body { + padding: 20px 16px; + } +} + +.ai-request-logs__stat-value { + font-size: 28px; + font-weight: 600; + color: #1e1e1e; + line-height: 1.2; +} + +.ai-request-logs__stat-label { + font-size: 13px; + color: #757575; + margin-top: 4px; +} + +// Main layout +.ai-request-logs__main { + display: grid; + grid-template-columns: 1fr 320px; + gap: 24px; + align-items: start; + + @media (max-width: 1200px) { + grid-template-columns: 1fr; + } +} + +// Cards +.ai-request-logs__card { + .components-card__header { + display: flex; + justify-content: space-between; + align-items: center; + + h2 { + margin: 0; + font-size: 14px; + font-weight: 600; + } + } +} + +.ai-request-logs__count { + font-size: 13px; + color: #757575; +} + +// Filters +.ai-request-logs__dataviews { + margin-top: 16px; +} + +.ai-request-logs__cell--time { + white-space: nowrap; + color: #757575; + font-size: 12px; +} + +.ai-request-logs__operation { + code { + font-size: 12px; + background: #f0f0f0; + padding: 2px 6px; + border-radius: 3px; + } +} + +.ai-request-logs__error-preview { + font-size: 11px; + color: #d63638; + margin-top: 4px; +} + +// Provider cell - two row layout +.ai-request-logs__provider-cell { + position: relative; + cursor: default; +} + +.ai-request-logs__provider-row { + display: flex; + align-items: center; + gap: 6px; +} + +.ai-request-logs__provider-icon { + flex-shrink: 0; + display: flex; + align-items: center; + color: #757575; + + svg { + width: 16px; + height: 16px; + } +} + +.ai-request-logs__provider-name { + font-size: 13px; + font-weight: 400; + color: #757575; +} + +.ai-request-logs__model-row { + font-size: 11px; + color: #757575; + margin-top: 2px; + padding-left: 22px; // align with text after icon +} + +.ai-request-logs__cell-muted { + color: #757575; +} + +// White popover styling +.ai-request-logs__provider-popover { + .components-popover__content { + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + } +} + +.ai-request-logs__popover-content { + padding: 12px; + min-width: 200px; +} + +.ai-request-logs__popover-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.ai-request-logs__popover-icon { + flex-shrink: 0; + display: flex; + align-items: center; + color: #1e1e1e; + + svg { + width: 20px; + height: 20px; + } +} + +.ai-request-logs__popover-title { + font-size: 14px; + font-weight: 600; + color: #1e1e1e; +} + +.ai-request-logs__popover-badge { + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + padding: 2px 6px; + border-radius: 3px; + background: #f0f0f0; + color: #757575; +} + +.ai-request-logs__popover-model { + font-size: 12px; + color: #757575; + padding: 8px 0; + border-bottom: 1px solid #f0f0f0; + margin-bottom: 8px; +} + +.ai-request-logs__popover-links { + display: flex; + flex-direction: column; + gap: 6px; +} + +.ai-request-logs__popover-link { + font-size: 12px; + color: #2271b1; + text-decoration: none; + + &:hover { + color: #135e96; + text-decoration: underline; + } +} + +// Status badges +.ai-request-logs__status { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 500; + text-transform: uppercase; +} + +.ai-request-logs__status--success { + background: #d4edda; + color: #155724; +} + +.ai-request-logs__status--error { + background: #f8d7da; + color: #721c24; +} + +.ai-request-logs__status--timeout { + background: #fff3cd; + color: #856404; +} + +.ai-request-logs__kind { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 500; + text-transform: capitalize; + background: #f6f7f7; + color: #1d2327; +} + +.ai-request-logs__kind--image { + background: #fff7ed; + color: #c2410c; +} + +.ai-request-logs__kind--embeddings { + background: #eef2ff; + color: #4338ca; +} + +.ai-request-logs__image-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; +} + +.ai-request-logs__image-grid figure { + margin: 0; +} + +.ai-request-logs__image-grid img { + width: 100%; + height: auto; + border-radius: 4px; + background: #f6f7f7; + object-fit: cover; +} + +.ai-request-logs__image-grid figcaption { + margin-top: 4px; + font-size: 11px; + color: var(--wp-components-color-foreground-muted, #5c5f62); +} + +.ai-request-logs__empty { + text-align: center; + color: #757575; + padding: 40px; +} + +.ai-provider-tooltip { + display: flex; + flex-direction: column; + gap: 4px; + max-width: 280px; +} + +.ai-provider-tooltip__section-title { + font-size: 12px; + text-transform: uppercase; + color: var(--wp-components-color-foreground-muted, #50575e); +} + +.ai-provider-tooltip__models ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.ai-provider-tooltip__models li { + font-size: 12px; +} + +.ai-provider-tooltip__capabilities { + display: block; + font-size: 11px; + color: var(--wp-components-color-foreground-muted, #50575e); +} + +.ai-provider-tooltip__link, +.ai-provider-tooltip__hint, +.ai-provider-tooltip__type, +.ai-provider-tooltip__model { + font-size: 11px; + color: var(--wp-components-color-foreground-muted, #50575e); +} + +// Settings panel +.ai-request-logs__settings { + .components-toggle-control { + margin-bottom: 24px; + } + + .components-range-control { + margin-bottom: 24px; + } +} + +.ai-request-logs__settings-danger { + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid #e0e0e0; + + h3 { + color: #d63638; + font-size: 14px; + margin: 0 0 8px; + } + + .description { + margin-bottom: 12px; + } +} + +.ai-request-logs__purge-confirm { + display: flex; + align-items: center; + gap: 8px; + + span { + font-weight: 500; + } +} + +// Modal +.ai-request-logs__modal { + .components-modal__content { + width: 100%; + max-width: none; + } +} + +.ai-request-logs__detail { + font-size: 13px; +} + +.ai-request-logs__detail-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid #e0e0e0; +} + +.ai-request-logs__detail-operation { + font-size: 16px; + background: #f0f0f0; + padding: 4px 12px; + border-radius: 4px; +} + +.ai-request-logs__detail-section { + margin-bottom: 20px; + + h3 { + font-size: 13px; + font-weight: 600; + margin: 0 0 12px; + color: #1e1e1e; + } +} + +.ai-request-logs__detail-section--error { + h3 { + color: #d63638; + } +} + +.ai-request-logs__detail-table { + width: 100%; + + th, + td { + padding: 8px 0; + border-bottom: 1px solid #f0f0f0; + } + + th { + width: 140px; + font-weight: 500; + color: #757575; + text-align: left; + } + + td { + color: #1e1e1e; + } +} + +.ai-request-logs__detail-error, +.ai-request-logs__detail-context { + background: #f6f7f7; + padding: 12px; + border-radius: 4px; + overflow-x: auto; + font-size: 12px; + font-family: monospace; + white-space: pre-wrap; + word-break: break-word; + margin: 0; +} + +.ai-request-logs__detail-error { + background: #fcf0f1; + color: #8a1f1f; +} + +.ai-request-logs__detail-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid #e0e0e0; +} + +.ai-request-logs__detail-id { + font-size: 11px; + color: #757575; +} diff --git a/src/admin/ai-request-logs/types.ts b/src/admin/ai-request-logs/types.ts new file mode 100644 index 00000000..ef73911d --- /dev/null +++ b/src/admin/ai-request-logs/types.ts @@ -0,0 +1,85 @@ +import type { ProviderMetadataMap } from '../types/providers'; + +export interface LogEntry { + id: string; + timestamp: string; + type: 'ai_client' | 'mcp_tool' | 'ability'; + operation: string; + provider: string | null; + model: string | null; + duration_ms: number | null; + tokens_input: number | null; + tokens_output: number | null; + tokens_total: number | null; + tokens_per_second: number | null; + cost_estimate: number | null; + status: 'success' | 'error' | 'timeout'; + error_message: string | null; + user_id: number | null; + context: Record< string, unknown > | null; +} + +export interface LogSummary { + total_requests: number; + total_tokens: number; + total_cost: number; + avg_duration_ms: number; + success_rate: number; + by_type: Record< string, number >; + by_provider: Record< string, number >; + by_status: Record< string, number >; +} + +export interface OperationPatternFilter { + label: string; + pattern: string; + description?: string; +} + +export interface FilterOptions { + types: string[]; + providers: string[]; + statuses: string[]; + operations: string[]; + operationPatterns?: OperationPatternFilter[]; +} + +export interface LogFilters { + type: string; + status: string; + provider: string; + operation: string[]; + search: string; + period: 'day' | 'week' | 'month' | 'all'; +} + +export interface LogSettings { + enabled: boolean; + retentionDays: number; +} + +export interface LocalizedSettings { + rest: { + nonce: string; + root: string; + routes: { + logs: string; + summary: string; + filters: string; + }; + }; + initialState: { + enabled: boolean; + retentionDays: number; + summary: LogSummary; + filters: FilterOptions; + }; + providerMetadata: ProviderMetadataMap; +} + +declare global { + interface Window { + AiRequestLogsSettings?: LocalizedSettings; + aiAiRequestLogsSettings?: LocalizedSettings; + } +} diff --git a/src/admin/components/ProviderTooltipContent.tsx b/src/admin/components/ProviderTooltipContent.tsx new file mode 100644 index 00000000..49f48a9e --- /dev/null +++ b/src/admin/components/ProviderTooltipContent.tsx @@ -0,0 +1,81 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { ProviderMetadata } from '../types/providers'; + +interface ProviderTooltipContentProps { + metadata: ProviderMetadata; + activeModel?: string | null; +} + +const ProviderTooltipContent: React.FC< ProviderTooltipContentProps > = ( { + metadata, + activeModel, +} ) => { + const topModels = metadata.models?.slice( 0, 4 ) ?? []; + + return ( +
+
+
+ { metadata.name } + + { metadata.type === 'client' + ? __( 'Local', 'ai' ) + : __( 'Cloud', 'ai' ) } + +
+ { activeModel && ( + + { sprintf( + /* translators: %s: AI model name. */ + __( 'Requested model: %s', 'ai' ), + activeModel + ) } + + ) } + { metadata.tooltip && ( +

{ metadata.tooltip }

+ ) } + { topModels.length > 0 && ( +
+ + { __( 'Available models', 'ai' ) } + +
    + { topModels.map( ( model ) => ( +
  • + { model.name } + { model.capabilities?.length > 0 && ( + + { model.capabilities.join( ', ' ) } + + ) } +
  • + ) ) } +
+
+ ) } +
+ { metadata.url && ( + + ) } +
+ ); +}; + +export default ProviderTooltipContent; diff --git a/src/admin/components/icons/AiIcon.tsx b/src/admin/components/icons/AiIcon.tsx new file mode 100644 index 00000000..f1daf850 --- /dev/null +++ b/src/admin/components/icons/AiIcon.tsx @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * AI Icon - Cauldron with sparkles design used for AI features. + */ +const AiIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + + + + + +); + +export default AiIcon; diff --git a/src/admin/components/icons/AnthropicIcon.tsx b/src/admin/components/icons/AnthropicIcon.tsx new file mode 100644 index 00000000..d64f80bd --- /dev/null +++ b/src/admin/components/icons/AnthropicIcon.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Anthropic (Claude) Icon + */ +const AnthropicIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default AnthropicIcon; diff --git a/src/admin/components/icons/CloudflareIcon.tsx b/src/admin/components/icons/CloudflareIcon.tsx new file mode 100644 index 00000000..804c6573 --- /dev/null +++ b/src/admin/components/icons/CloudflareIcon.tsx @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Cloudflare Icon + */ +const CloudflareIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + + +); + +export default CloudflareIcon; diff --git a/src/admin/components/icons/DeepSeekIcon.tsx b/src/admin/components/icons/DeepSeekIcon.tsx new file mode 100644 index 00000000..effcfe19 --- /dev/null +++ b/src/admin/components/icons/DeepSeekIcon.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * DeepSeek Icon + */ +const DeepSeekIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default DeepSeekIcon; diff --git a/src/admin/components/icons/FalIcon.tsx b/src/admin/components/icons/FalIcon.tsx new file mode 100644 index 00000000..e5f43adb --- /dev/null +++ b/src/admin/components/icons/FalIcon.tsx @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Fal.ai Icon + */ +const FalIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default FalIcon; diff --git a/src/admin/components/icons/GoogleIcon.tsx b/src/admin/components/icons/GoogleIcon.tsx new file mode 100644 index 00000000..5ec6b0ee --- /dev/null +++ b/src/admin/components/icons/GoogleIcon.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Google Icon + */ +const GoogleIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default GoogleIcon; diff --git a/src/admin/components/icons/GrokIcon.tsx b/src/admin/components/icons/GrokIcon.tsx new file mode 100644 index 00000000..e6fcb836 --- /dev/null +++ b/src/admin/components/icons/GrokIcon.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Grok Icon + */ +const GrokIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default GrokIcon; diff --git a/src/admin/components/icons/GroqIcon.tsx b/src/admin/components/icons/GroqIcon.tsx new file mode 100644 index 00000000..4f87cfd2 --- /dev/null +++ b/src/admin/components/icons/GroqIcon.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Groq Icon + */ +const GroqIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default GroqIcon; diff --git a/src/admin/components/icons/HuggingFaceIcon.tsx b/src/admin/components/icons/HuggingFaceIcon.tsx new file mode 100644 index 00000000..ba89ab6d --- /dev/null +++ b/src/admin/components/icons/HuggingFaceIcon.tsx @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Hugging Face Icon + */ +const HuggingFaceIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + + + + + + +); + +export default HuggingFaceIcon; diff --git a/src/admin/components/icons/McpIcon.tsx b/src/admin/components/icons/McpIcon.tsx new file mode 100644 index 00000000..abe8496a --- /dev/null +++ b/src/admin/components/icons/McpIcon.tsx @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * MCP Icon - Model Context Protocol logo. + */ +const McpIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + Model Context Protocol + + + +); + +export default McpIcon; diff --git a/src/admin/components/icons/OllamaIcon.tsx b/src/admin/components/icons/OllamaIcon.tsx new file mode 100644 index 00000000..eb7211df --- /dev/null +++ b/src/admin/components/icons/OllamaIcon.tsx @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Ollama Icon + */ +const OllamaIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default OllamaIcon; diff --git a/src/admin/components/icons/OpenAiIcon.tsx b/src/admin/components/icons/OpenAiIcon.tsx new file mode 100644 index 00000000..6d4803c3 --- /dev/null +++ b/src/admin/components/icons/OpenAiIcon.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * OpenAI Icon + */ +const OpenAiIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default OpenAiIcon; diff --git a/src/admin/components/icons/OpenRouterIcon.tsx b/src/admin/components/icons/OpenRouterIcon.tsx new file mode 100644 index 00000000..1d769c21 --- /dev/null +++ b/src/admin/components/icons/OpenRouterIcon.tsx @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * OpenRouter Icon + */ +const OpenRouterIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default OpenRouterIcon; diff --git a/src/admin/components/icons/XaiIcon.tsx b/src/admin/components/icons/XaiIcon.tsx new file mode 100644 index 00000000..aeca554c --- /dev/null +++ b/src/admin/components/icons/XaiIcon.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * xAI Icon + */ +const XaiIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default XaiIcon; diff --git a/src/admin/components/icons/index.ts b/src/admin/components/icons/index.ts new file mode 100644 index 00000000..a950307d --- /dev/null +++ b/src/admin/components/icons/index.ts @@ -0,0 +1,14 @@ +export { default as AiIcon } from './AiIcon'; +export { default as AnthropicIcon } from './AnthropicIcon'; +export { default as CloudflareIcon } from './CloudflareIcon'; +export { default as DeepSeekIcon } from './DeepSeekIcon'; +export { default as FalIcon } from './FalIcon'; +export { default as GoogleIcon } from './GoogleIcon'; +export { default as GroqIcon } from './GroqIcon'; +export { default as GrokIcon } from './GrokIcon'; +export { default as HuggingFaceIcon } from './HuggingFaceIcon'; +export { default as McpIcon } from './McpIcon'; +export { default as OpenAiIcon } from './OpenAiIcon'; +export { default as OpenRouterIcon } from './OpenRouterIcon'; +export { default as OllamaIcon } from './OllamaIcon'; +export { default as XaiIcon } from './XaiIcon'; diff --git a/src/admin/components/provider-icons.tsx b/src/admin/components/provider-icons.tsx new file mode 100644 index 00000000..c7580348 --- /dev/null +++ b/src/admin/components/provider-icons.tsx @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import type { ComponentType, SVGProps } from 'react'; + +/** + * Internal dependencies + */ +import { + AiIcon, + AnthropicIcon, + CloudflareIcon, + DeepSeekIcon, + FalIcon, + GoogleIcon, + GrokIcon, + GroqIcon, + HuggingFaceIcon, + OllamaIcon, + OpenAiIcon, + OpenRouterIcon, + XaiIcon, +} from './icons'; + +const ICON_COMPONENTS: Record< string, ComponentType< SVGProps< SVGSVGElement > > > = + Object.freeze( { + anthropic: AnthropicIcon, + openai: OpenAiIcon, + google: GoogleIcon, + fal: FalIcon, + 'fal-ai': FalIcon, + deepseek: DeepSeekIcon, + cloudflare: CloudflareIcon, + huggingface: HuggingFaceIcon, + ollama: OllamaIcon, + openrouter: OpenRouterIcon, + groq: GroqIcon, + grok: GrokIcon, + xai: XaiIcon, + default: AiIcon, + } ); + +export const getProviderIconComponent = ( + iconKey?: string, + fallbackKey?: string +): ComponentType< SVGProps< SVGSVGElement > > => { + const normalized = + ( iconKey || fallbackKey || '' ).toLowerCase().replace( /\s+/g, '' ); + + return ( + ICON_COMPONENTS[ normalized ] || + ICON_COMPONENTS[ iconKey || '' ] || + ICON_COMPONENTS[ fallbackKey || '' ] || + ICON_COMPONENTS.default + ); +}; diff --git a/src/admin/hooks/usePersistedView.ts b/src/admin/hooks/usePersistedView.ts new file mode 100644 index 00000000..7305648f --- /dev/null +++ b/src/admin/hooks/usePersistedView.ts @@ -0,0 +1,99 @@ +/** + * WordPress dependencies + */ +import type { View } from '@wordpress/dataviews'; +import { useView } from '@wordpress/views'; + +/** + * External dependencies + */ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +interface PersistedViewReturn< T extends View > { + view: T; + setView: ( next: T | ( ( prev: T ) => T ) ) => void; + resetView: () => void; + isModified: boolean; +} + +const VIEW_KIND = 'root'; +const VIEW_NAME = 'ai-admin'; + +/** + * Thin wrapper around `@wordpress/views` that mimics the previous + * usePersistedView signature so existing DataViews components can persist + * their view configuration using WordPress preferences (available in 6.9+). + */ +export function usePersistedView< T extends View >( + slug: string, + defaultView: T +): PersistedViewReturn< T > { + const defaultQuery = useMemo( + () => ( { + page: defaultView.page ?? 1, + search: defaultView.search ?? '', + } ), + [ defaultView.page, defaultView.search ] + ); + + const [ queryParams, setQueryParams ] = useState( defaultQuery ); + + // Keep query params in sync if defaults change (should be rare). + useEffect( () => { + setQueryParams( ( previous ) => ( { + page: previous.page ?? defaultQuery.page, + search: + typeof previous.search === 'string' + ? previous.search + : defaultQuery.search, + } ) ); + }, [ defaultQuery.page, defaultQuery.search ] ); + + const { view, updateView, resetToDefault, isModified } = useView( { + kind: VIEW_KIND, + name: VIEW_NAME, + slug, + defaultView, + queryParams, + onChangeQueryParams: ( params ) => { + setQueryParams( { + page: + typeof params.page === 'number' + ? params.page + : defaultQuery.page, + search: + typeof params.search === 'string' + ? params.search + : defaultQuery.search, + } ); + }, + } ); + + const latestViewRef = useRef< View >( view ); + useEffect( () => { + latestViewRef.current = view; + }, [ view ] ); + + const setView = useCallback( + ( next: T | ( ( prev: T ) => T ) ) => { + updateView( + typeof next === 'function' + ? ( next as ( prev: T ) => T )( latestViewRef.current as T ) + : next + ); + }, + [ updateView ] + ); + + const resetView = useCallback( () => { + resetToDefault(); + setQueryParams( defaultQuery ); + }, [ resetToDefault, defaultQuery ] ); + + return { + view: view as T, + setView, + resetView, + isModified, + }; +} diff --git a/tests/Integration/Includes/Logging/AI_Request_Log_ManagerTest.php b/tests/Integration/Includes/Logging/AI_Request_Log_ManagerTest.php new file mode 100644 index 00000000..fb01d2a3 --- /dev/null +++ b/tests/Integration/Includes/Logging/AI_Request_Log_ManagerTest.php @@ -0,0 +1,148 @@ +manager = new AI_Request_Log_Manager(); + $this->manager->init(); + + global $wpdb; + $table = $wpdb->prefix . 'ai_request_logs'; + $wpdb->query( "TRUNCATE TABLE {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + + delete_option( AI_Request_Log_Manager::OPTION_LOGGING_ENABLED ); + } + + public function test_log_returns_false_when_logging_disabled(): void { + $this->manager->set_logging_enabled( false ); + + $result = $this->manager->log( + array( + 'type' => 'ui', + 'operation' => 'completion', + 'status' => 'success', + ) + ); + + $this->assertFalse( $result ); + + global $wpdb; + $table = $wpdb->prefix . 'ai_request_logs'; + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $this->assertSame( 0, $count ); + } + + public function test_log_persists_entry_when_enabled(): void { + $this->manager->set_logging_enabled( true ); + + $log_id = $this->manager->log( + array( + 'type' => 'ui', + 'operation' => 'completion', + 'provider' => 'openai', + 'model' => 'gpt-5-nano', + 'duration_ms' => 120, + 'tokens_input' => 200, + 'tokens_output' => 50, + 'status' => 'success', + 'user_id' => get_current_user_id(), + 'context' => array( 'ability' => 'ai/example' ), + ) + ); + + $this->assertNotFalse( $log_id ); + $this->assertIsString( $log_id ); + + global $wpdb; + $table = $wpdb->prefix . 'ai_request_logs'; + $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $wpdb->prepare( + "SELECT operation, status, tokens_total, cost_estimate FROM {$table} WHERE log_id = %s", + $log_id + ), + ARRAY_A + ); + + $this->assertIsArray( $row ); + $this->assertSame( 'completion', $row['operation'] ); + $this->assertSame( 'success', $row['status'] ); + $this->assertSame( 250, (int) $row['tokens_total'] ); + $this->assertGreaterThan( 0, (float) $row['cost_estimate'] ); + } + + public function test_get_logs_search_matches_request_preview(): void { + $this->manager->set_logging_enabled( true ); + + $matching_id = $this->manager->log( + array( + 'type' => 'ai_client', + 'operation' => 'openai:images/generations', + 'status' => 'success', + 'context' => array( + 'input_preview' => 'Prompt: A llama sitting on a mountain', + ), + ) + ); + + // Non matching entry to ensure search filters correctly. + $this->manager->log( + array( + 'type' => 'ai_client', + 'operation' => 'openai:images/generations', + 'status' => 'success', + 'context' => array( + 'input_preview' => 'Prompt: Sunset over the ocean', + ), + ) + ); + + $result = $this->manager->get_logs( + array( + 'search' => 'llama', + ) + ); + + $this->assertSame( 1, $result['total'] ); + $this->assertSame( $matching_id, $result['items'][0]['id'] ); + } + + public function test_get_logs_search_falls_back_for_short_terms(): void { + $this->manager->set_logging_enabled( true ); + + $matching_id = $this->manager->log( + array( + 'type' => 'ai_client', + 'operation' => 'openai:responses', + 'status' => 'success', + 'context' => array( + 'input_preview' => 'AI prompt about colors', + ), + ) + ); + + $result = $this->manager->get_logs( + array( + 'search' => 'AI', + ) + ); + + $this->assertSame( 1, $result['total'] ); + $this->assertSame( $matching_id, $result['items'][0]['id'] ); + } +} diff --git a/webpack.config.js b/webpack.config.js index 49a296e9..4e81f2c9 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -19,6 +19,11 @@ module.exports = { 'src/admin/settings', 'index.scss' ), + 'admin/ai-request-logs': path.resolve( + process.cwd(), + 'src/admin/ai-request-logs', + 'index.tsx' + ), 'experiments/example-experiment': path.resolve( process.cwd(), 'src/experiments/example-experiment', From 22384c7556ff6f50b80bb3d2b897323b135cf54d Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:20:23 -0500 Subject: [PATCH 2/8] Add Provider_Metadata_Registry dependency for log page --- includes/Admin/Provider_Metadata_Registry.php | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 includes/Admin/Provider_Metadata_Registry.php diff --git a/includes/Admin/Provider_Metadata_Registry.php b/includes/Admin/Provider_Metadata_Registry.php new file mode 100644 index 00000000..68bdd324 --- /dev/null +++ b/includes/Admin/Provider_Metadata_Registry.php @@ -0,0 +1,289 @@ +> + */ + public static function get_metadata(): array { + $registry = AiClient::defaultRegistry(); + $providers = array(); + $overrides = self::get_branding_overrides(); + $credentials = get_option( 'wp_ai_client_provider_credentials', array() ); + + foreach ( $registry->getRegisteredProviderIds() as $provider_id ) { + $class_name = $registry->getProviderClassName( $provider_id ); + + if ( ! method_exists( $class_name, 'metadata' ) ) { + continue; + } + + /** @var ProviderMetadata $metadata */ + $metadata = $class_name::metadata(); + $brand = $overrides[ $metadata->getId() ] ?? array(); + + $providers[ $metadata->getId() ] = array( + 'id' => $metadata->getId(), + 'name' => $metadata->getName(), + 'type' => $metadata->getType()->value, + 'icon' => $brand['icon'] ?? $metadata->getId(), + 'initials' => $brand['initials'] ?? self::get_initials( $metadata->getName() ), + 'color' => $brand['color'] ?? '#1d2327', + 'url' => $brand['url'] ?? '', + 'tooltip' => $brand['tooltip'] ?? '', + 'keepDescription' => ! empty( $brand['keepDescription'] ), + 'isConfigured' => self::has_credentials( $metadata->getId(), $credentials ), + 'models' => self::get_models_for_provider( $class_name, $metadata->getId(), $credentials ), + ); + } + + return $providers; + } + + /** + * Builds a fallback initials string for providers without a brand override. + * + * @param string $name Provider display name. + * @return string + */ + private static function get_initials( string $name ): string { + $parts = preg_split( '/\s+/', trim( $name ) ); + if ( empty( $parts ) ) { + return strtoupper( substr( $name, 0, 2 ) ); + } + + $initials = ''; + foreach ( $parts as $part ) { + $initials .= strtoupper( substr( $part, 0, 1 ) ); + if ( strlen( $initials ) >= 2 ) { + break; + } + } + + return substr( $initials, 0, 2 ); + } + + /** + * Retrieves model metadata for a provider. + * + * @param string $provider_class Provider class name. + * @return array> + */ + private static function get_models_for_provider( string $provider_class, string $provider_id, array $credentials ): array { + if ( ! method_exists( $provider_class, 'modelMetadataDirectory' ) ) { + return array(); + } + + $cache_key = self::get_models_cache_key( $provider_id, $credentials[ $provider_id ] ?? '' ); + if ( $cache_key ) { + $cached = get_transient( $cache_key ); + if ( false !== $cached ) { + return $cached; + } + } + + try { + $directory = $provider_class::modelMetadataDirectory(); + $metadata = $directory->listModelMetadata(); + } catch ( \Throwable $error ) { + return array(); + } + + $models = array(); + + foreach ( $metadata as $model_metadata ) { + if ( ! $model_metadata instanceof ModelMetadata ) { + continue; + } + + $models[] = array( + 'id' => $model_metadata->getId(), + 'name' => $model_metadata->getName(), + 'capabilities' => array_map( + static function ( CapabilityEnum $capability ): string { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ), + ); + } + + if ( $cache_key ) { + set_transient( $cache_key, $models, self::MODEL_CACHE_TTL ); + } + + return $models; + } + + /** + * Determines whether stored credentials exist for a provider. + * + * @param string $provider_id Provider identifier. + * @param array $credentials Raw credentials map. + * @return bool + */ + private static function has_credentials( string $provider_id, array $credentials ): bool { + if ( 'ollama' === $provider_id ) { + return true; + } + + if ( ! isset( $credentials[ $provider_id ] ) ) { + return false; + } + + $value = $credentials[ $provider_id ]; + if ( is_array( $value ) ) { + $value = wp_json_encode( $value ); + } + + return is_string( $value ) && '' !== trim( $value ); + } + + /** + * Builds a cache key for provider models. + * + * @param string $provider_id Provider identifier. + * @param string|array $credential Credential value. + * @return string|null + */ + private static function get_models_cache_key( string $provider_id, $credential ): ?string { + if ( '' === $provider_id ) { + return null; + } + + if ( is_array( $credential ) ) { + $credential = wp_json_encode( $credential ); + } + + return 'ai_provider_models_' . md5( $provider_id . '|' . (string) $credential ); + } + + /** + * Defines manual branding overrides per provider ID. + * + * @return array> + */ + private static function get_branding_overrides(): array { + $link_template = esc_html__( 'Create and manage your %s API keys in these account settings.', 'ai' ); + + return array( + 'anthropic' => array( + 'icon' => 'anthropic', + 'initials' => 'An', + 'color' => '#111111', + 'url' => 'https://console.anthropic.com/settings/keys', + 'tooltip' => sprintf( $link_template, 'Anthropic' ), + ), + 'cohere' => array( + 'color' => '#6f2cff', + 'url' => 'https://dashboard.cohere.com/api-keys', + 'tooltip' => sprintf( $link_template, 'Cohere' ), + ), + 'cloudflare' => array( + 'icon' => 'cloudflare', + 'color' => '#f3801a', + 'url' => 'https://dash.cloudflare.com/profile/api-tokens', + 'tooltip' => sprintf( $link_template, 'Cloudflare Workers AI' ), + ), + 'deepseek' => array( + 'icon' => 'deepseek', + 'color' => '#0f172a', + 'url' => 'https://platform.deepseek.com/api_keys', + 'tooltip' => sprintf( $link_template, 'DeepSeek' ), + ), + 'fal' => array( + 'icon' => 'fal', + 'color' => '#0ea5e9', + 'url' => 'https://fal.ai/dashboard/keys', + 'tooltip' => sprintf( $link_template, 'Fal.ai' ), + ), + 'fal-ai' => array( + 'icon' => 'fal-ai', + 'color' => '#0ea5e9', + 'url' => 'https://fal.ai/dashboard/keys', + 'tooltip' => sprintf( $link_template, 'Fal.ai' ), + ), + 'grok' => array( + 'icon' => 'grok', + 'color' => '#ff6f00', + 'url' => 'https://console.x.ai/api-keys', + 'tooltip' => sprintf( $link_template, 'Grok' ), + ), + 'groq' => array( + 'icon' => 'groq', + 'color' => '#f43f5e', + 'url' => 'https://console.groq.com/keys', + 'tooltip' => sprintf( $link_template, 'Groq' ), + ), + 'google' => array( + 'icon' => 'google', + 'color' => '#4285f4', + 'url' => 'https://aistudio.google.com/app/api-keys', + 'tooltip' => sprintf( $link_template, 'Google' ), + ), + 'huggingface' => array( + 'icon' => 'huggingface', + 'color' => '#ffbe3c', + 'url' => 'https://huggingface.co/settings/tokens', + 'tooltip' => sprintf( $link_template, 'Hugging Face' ), + ), + 'openai' => array( + 'icon' => 'openai', + 'color' => '#10a37f', + 'url' => 'https://platform.openai.com/api-keys', + 'tooltip' => sprintf( $link_template, 'OpenAI' ), + ), + 'openrouter' => array( + 'icon' => 'openrouter', + 'color' => '#0f172a', + 'url' => 'https://openrouter.ai/settings/keys', + 'tooltip' => sprintf( $link_template, 'OpenRouter' ), + ), + 'ollama' => array( + 'icon' => 'ollama', + 'color' => '#111111', + 'tooltip' => esc_html__( 'Local Ollama instances at http://localhost:11434 do not require an API key. If you are calling https://ollama.com/api, create a key from your ollama.com account (for example via the dashboard or the `ollama signin` command) and paste it here.', 'ai' ), + 'keepDescription' => true, + ), + 'xai' => array( + 'icon' => 'xai', + 'color' => '#000000', + 'url' => 'https://console.x.ai/api-keys', + 'tooltip' => sprintf( $link_template, 'xAI' ), + ), + ); + } +} From f18326c749394feca94378ba51566723bf357dea Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:21:52 -0500 Subject: [PATCH 3/8] Add get_ai_icon_svg helper and icon asset --- assets/images/ai-icon.svg | 7 ++ includes/helpers.php | 191 ++++++++++++++++++++++++++++++++++---- 2 files changed, 182 insertions(+), 16 deletions(-) create mode 100644 assets/images/ai-icon.svg diff --git a/assets/images/ai-icon.svg b/assets/images/ai-icon.svg new file mode 100644 index 00000000..0c97519f --- /dev/null +++ b/assets/images/ai-icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/includes/helpers.php b/includes/helpers.php index e9a146d7..2e28f71a 100644 --- a/includes/helpers.php +++ b/includes/helpers.php @@ -9,8 +9,8 @@ namespace WordPress\AI; -use Throwable; -use WordPress\AI_Client\AI_Client; +use WordPress\AI\Logging\AI_Request_Log_Manager; +use WordPress\AI\Settings\Settings_Registration; /** * Normalizes the content by cleaning it and removing unwanted HTML tags. @@ -119,6 +119,44 @@ function get_post_context( int $post_id ): array { return $context; } +/** + * Extends HTTP timeout for OpenAI requests. + * + * The default WordPress timeout (5 seconds) is too short for some AI + * completions and results in cURL error 28. Increase it to 20 seconds + * whenever we call known AI REST endpoints, guarding against direct CLI + * execution where WordPress functions are unavailable. + * + * @since 0.1.0 + * + * @param array $args HTTP request args. + * @param string $url Request URL. + * @return array + */ +if ( function_exists( 'add_filter' ) ) { + add_filter( + 'http_request_args', + static function ( array $args, string $url ): array { + $ai_hosts = array( + 'api.openai.com', + 'api.anthropic.com', + 'generativelanguage.googleapis.com', + ); + + foreach ( $ai_hosts as $host ) { + if ( false !== strpos( $url, $host ) ) { + $args['timeout'] = max( (float) ( $args['timeout'] ?? 5 ), 20 ); + break; + } + } + + return $args; + }, + 10, + 2 + ); +} + /** * Returns the preferred models. * @@ -130,11 +168,11 @@ function get_preferred_models(): array { $preferred_models = array( array( 'anthropic', - 'claude-haiku-4-5', + 'claude-3-5-sonnet-20240620', ), array( - 'google', - 'gemini-2.5-flash', + 'anthropic', + 'claude-3-haiku-20240307', ), array( 'openai', @@ -142,7 +180,11 @@ function get_preferred_models(): array { ), array( 'openai', - 'gpt-4.1', + 'gpt-4o', + ), + array( + 'google', + 'gemini-1.5-flash', ), ); @@ -154,7 +196,36 @@ function get_preferred_models(): array { * @param array $preferred_models The preferred models. * @return array The filtered preferred models. */ - return (array) apply_filters( 'ai_experiments_preferred_models', $preferred_models ); + $preferred_models = (array) apply_filters( 'ai_experiments_preferred_models', $preferred_models ); + + $grouped = array( + 'anthropic' => array(), + 'openai' => array(), + 'google' => array(), + 'other' => array(), + ); + + foreach ( $preferred_models as $model ) { + if ( ! is_array( $model ) || count( $model ) < 2 ) { + continue; + } + + $provider = strtolower( (string) $model[0] ); + + if ( isset( $grouped[ $provider ] ) ) { + $grouped[ $provider ][] = $model; + continue; + } + + $grouped['other'][] = $model; + } + + return array_merge( + $grouped['anthropic'], + $grouped['openai'], + $grouped['google'], + $grouped['other'] + ); } /** @@ -253,27 +324,115 @@ function has_valid_ai_credentials(): bool { return (bool) $valid; } - // See if we have credentials that give us access to generate text. - try { - return AI_Client::prompt( 'Test' )->is_supported_for_text_generation(); - } catch ( Throwable $t ) { + return true; +} + +/** + * Checks if a specific experiment is enabled via global + per-experiment toggles. + * + * Mirrors {@see Abstract_Experiment::is_enabled()} so infrastructure that runs + * before experiments register can honor user settings. + * + * @since 0.1.0 + * + * @param string $experiment_id Experiment identifier (e.g. 'ai-request-logging'). + * @return bool + */ +function is_experiment_enabled( string $experiment_id ): bool { + $global_enabled = (bool) get_option( Settings_Registration::GLOBAL_OPTION, false ); + if ( ! $global_enabled ) { + return false; + } + + $experiment_enabled = (bool) get_option( "ai_experiment_{$experiment_id}_enabled", false ); + + /** + * Filters the enabled status for a specific experiment. + * + * @since 0.1.0 + * + * @param bool $experiment_enabled Default enabled state from the option. + */ + $is_enabled = (bool) apply_filters( "ai_experiment_{$experiment_id}_enabled", $experiment_enabled ); + + if ( ! has_valid_ai_credentials() ) { return false; } + + return $is_enabled; } /** - * Returns the request log manager singleton instance. + * Get the shared AI request log manager instance. * * @since 0.1.0 * - * @return \WordPress\AI\Logging\AI_Request_Log_Manager|null The log manager instance, or null if not available. + * @return AI_Request_Log_Manager|null */ -function get_request_log_manager(): ?Logging\AI_Request_Log_Manager { +function get_request_log_manager(): ?AI_Request_Log_Manager { static $log_manager = null; - if ( null === $log_manager && class_exists( Logging\AI_Request_Log_Manager::class ) ) { - $log_manager = new Logging\AI_Request_Log_Manager(); + if ( null === $log_manager && class_exists( AI_Request_Log_Manager::class ) ) { + $log_manager = new AI_Request_Log_Manager(); } return $log_manager; } + +/** + * Returns the AI icon SVG markup for inline use. + * + * Reads the icon from assets/images/ai-icon.svg and adds width/height attributes. + * + * @since 0.1.0 + * + * @param string $width Optional. Width of the icon. Default '1em'. + * @param string $height Optional. Height of the icon. Default '1em'. + * @return string The SVG markup for the AI icon. + */ +function get_ai_icon_svg( string $width = '1em', string $height = '1em' ): string { + static $svg_content = null; + + if ( null === $svg_content ) { + $svg_path = dirname( __DIR__ ) . '/assets/images/ai-icon.svg'; + $svg_content = file_exists( $svg_path ) ? file_get_contents( $svg_path ) : ''; + } + + if ( empty( $svg_content ) ) { + return ''; + } + + // Add width and height attributes, and fill="currentColor" for theme compatibility. + return preg_replace( + '/ Date: Thu, 18 Dec 2025 19:19:06 -0500 Subject: [PATCH 4/8] Initialize logging discovery strategy in experiment register --- .../Experiments/AI_Request_Logging/AI_Request_Logging.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/includes/Experiments/AI_Request_Logging/AI_Request_Logging.php b/includes/Experiments/AI_Request_Logging/AI_Request_Logging.php index 881811ca..42a7d75f 100644 --- a/includes/Experiments/AI_Request_Logging/AI_Request_Logging.php +++ b/includes/Experiments/AI_Request_Logging/AI_Request_Logging.php @@ -12,6 +12,7 @@ use WordPress\AI\Abstracts\Abstract_Experiment; use WordPress\AI\Logging\AI_Request_Log_Manager; use WordPress\AI\Logging\AI_Request_Log_Page; +use WordPress\AI\Logging\Logging_Discovery_Strategy; use WordPress\AI\Logging\REST\AI_Request_Log_Controller; use WordPress\AI\Settings\Settings_Registration; use function WordPress\AI\get_request_log_manager; @@ -52,6 +53,12 @@ protected function load_experiment_metadata(): array { public function register(): void { $manager = $this->get_manager(); + // Initialize the log manager (creates table, schedules cleanup). + $manager->init(); + + // Register the logging HTTP client wrapper so requests get logged. + Logging_Discovery_Strategy::init( $manager ); + add_action( 'rest_api_init', static function () use ( $manager ) { From b9285c79e0d0b14362b08cd2292d00e18826cd23 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Thu, 18 Dec 2025 19:46:49 -0500 Subject: [PATCH 5/8] Refactor logging to use SDK public API instead of reflection Replace the PSR-18 client discovery approach with a cleaner architecture that uses the AiClient SDK's public setHttpTransporter() API: - Add Logging_Http_Transporter: decorator implementing HttpTransporterInterface - Add Logging_Integration: initializes logging by wrapping the SDK transporter - Remove Logging_HTTP_Client and Logging_Discovery_Strategy (reflection-based) - Update bootstrap to use new Logging_Integration class - Update documentation with new architecture details This approach is more production-ready as it: - Uses the SDK's public API rather than reflection hacks - Wraps at the correct abstraction level (transporter, not HTTP client) - Is resilient to SDK internal changes - Follows the decorator pattern cleanly --- docs/experiments/ai-request-logging.md | 30 ++- .../AI_Request_Logging/AI_Request_Logging.php | 8 +- .../Logging/Logging_Discovery_Strategy.php | 92 --------- ...lient.php => Logging_Http_Transporter.php} | 181 ++++++------------ includes/Logging/Logging_Integration.php | 112 +++++++++++ includes/bootstrap.php | 13 ++ 6 files changed, 203 insertions(+), 233 deletions(-) delete mode 100644 includes/Logging/Logging_Discovery_Strategy.php rename includes/Logging/{Logging_HTTP_Client.php => Logging_Http_Transporter.php} (73%) create mode 100644 includes/Logging/Logging_Integration.php diff --git a/docs/experiments/ai-request-logging.md b/docs/experiments/ai-request-logging.md index 4f653d31..61aee178 100644 --- a/docs/experiments/ai-request-logging.md +++ b/docs/experiments/ai-request-logging.md @@ -1,15 +1,27 @@ # AI Request Logging ## Summary -Provides an opt-in observability surface that records every AI request (provider, model, duration, token counts, status, cost estimate) and exposes them through a React-powered dashboard under `Settings → AI Request Logs`. When enabled, outbound HTTP calls made via the WP AI Client are wrapped with a logging HTTP client. +Provides an opt-in observability surface that records every AI request (provider, model, duration, token counts, status, cost estimate) and exposes them through a React-powered dashboard under `Settings → AI Request Logs`. When enabled, the SDK's HTTP transporter is wrapped with a logging decorator using the public `setHttpTransporter()` API. ## Key Hooks & Entry Points - `WordPress\AI\Experiments\AI_Request_Logging\AI_Request_Logging::register()`: - `rest_api_init` → registers `WordPress\AI\Logging\REST\AI_Request_Log_Controller`, which exposes `/ai/v1/logs`, `/summary`, `/filters`, and per-log endpoints. - `is_admin()` guard → instantiates `WordPress\AI\Logging\AI_Request_Log_Page` to add the Settings submenu and enqueue assets. -- Plugin bootstrap now only initializes `WordPress\AI\Logging\Logging_Discovery_Strategy` (and `AI_Request_Log_Manager::init()`) when the experiment toggle is enabled, ensuring the HTTP wrapper and daily cleanup job stay disabled otherwise. +- Plugin bootstrap initializes `WordPress\AI\Logging\Logging_Integration` (and `AI_Request_Log_Manager::init()`) when the experiment toggle is enabled, ensuring the transporter wrapper and daily cleanup job stay disabled otherwise. - Database + cleanup are handled inside `AI_Request_Log_Manager::init()` (table creation, cron scheduling, option storage). +## Architecture +The logging system uses the decorator pattern to wrap the SDK's HTTP transporter: + +1. `Logging_Integration::init()` is called during bootstrap when the experiment is enabled. +2. On `wp_loaded` or `admin_init`, `Logging_Integration::wrap_transporter()`: + - Gets the current transporter from `AiClient::defaultRegistry()` + - Creates a `Logging_Http_Transporter` decorator around it + - Uses `registry->setHttpTransporter()` to swap in the logging version +3. All subsequent AI requests go through the logging transporter, which records metrics before delegating to the underlying transporter. + +This approach uses the SDK's public API rather than reflection or internal hacks, making it resilient to SDK updates. + ## Assets & Data Flow 1. When `AI Request Logs` is visited, `Asset_Loader` enqueues `admin/ai-request-logs` (`src/admin/ai-request-logs/index.tsx`) plus its stylesheet. The localized payload (`window.AiRequestLogsSettings`) includes REST routes, a nonce, and initial state (enabled flag, retention days, summary, filters). 2. The React app: @@ -17,17 +29,17 @@ Provides an opt-in observability surface that records every AI request (provider - Fetches logs (`GET /ai/v1/logs` with search/filter params) and displays them in a table with pagination. - Fetches summaries (`GET /ai/v1/logs/summary`) for the KPI cards and filter metadata (`GET /ai/v1/logs/filters`). - Posts to `/ai/v1/logs` to toggle logging and retention, and sends `DELETE /ai/v1/logs` to purge the table. -3. On the backend, every AI HTTP request flows through `Logging_HTTP_Client`, which records metrics via `AI_Request_Log_Manager::log()` before returning the response to callers. Logs are stored in the `wp_ai_request_logs` table alongside JSON context for later inspection. +3. On the backend, every AI HTTP request flows through `Logging_Http_Transporter`, which records metrics via `AI_Request_Log_Manager::log()` before returning the response to callers. Logs are stored in the `wp_ai_request_logs` table alongside JSON context for later inspection. ## Testing -1. Enable Experiments globally, toggle **AI Request Logging**, and ensure valid AI credentials exist (the experiment won’t enable otherwise). +1. Enable Experiments globally, toggle **AI Request Logging**, and ensure valid AI credentials exist (the experiment won't enable otherwise). 2. Trigger an AI-powered feature (e.g., Type Ahead or Title Generation) so the system issues at least one completion request. -3. Navigate to `Settings → AI Request Logs`. Confirm the chart and table populate and that the “Logging enabled” toggle reflects the settings page switch. +3. Navigate to `Settings → AI Request Logs`. Confirm the chart and table populate and that the "Logging enabled" toggle reflects the settings page switch. 4. Change the retention days value, save, and verify the option persists (reload the page or inspect `ai_request_logs_retention_days`). -5. Click “Purge logs”, confirm the success notice, and check the table empties. -6. Disable the experiment and reload a front-end AI feature; no new rows should appear, and the `Logging_Discovery_Strategy` hooks should remain inactive. +5. Click "Purge logs", confirm the success notice, and check the table empties. +6. Disable the experiment and reload a front-end AI feature; no new rows should appear, and the logging integration should remain inactive. ## Notes -- The HTTP logging layer only boots when both the global experiment switch and the `ai-request-logging` toggle are on, preventing unnecessary DB tables or cron events on installs that don’t need observability. +- The HTTP logging layer only boots when both the global experiment switch and the `ai-request-logging` toggle are on, preventing unnecessary DB tables or cron events on installs that don't need observability. - REST endpoints require `manage_options`. -- Model cost estimates rely on the static pricing table inside `AI_Request_Log_Manager`; update it as provider pricing evolves. +- Model cost estimates rely on the static pricing table inside `AI_Request_Cost_Calculator`; update it as provider pricing evolves. diff --git a/includes/Experiments/AI_Request_Logging/AI_Request_Logging.php b/includes/Experiments/AI_Request_Logging/AI_Request_Logging.php index 42a7d75f..44c3af59 100644 --- a/includes/Experiments/AI_Request_Logging/AI_Request_Logging.php +++ b/includes/Experiments/AI_Request_Logging/AI_Request_Logging.php @@ -12,7 +12,6 @@ use WordPress\AI\Abstracts\Abstract_Experiment; use WordPress\AI\Logging\AI_Request_Log_Manager; use WordPress\AI\Logging\AI_Request_Log_Page; -use WordPress\AI\Logging\Logging_Discovery_Strategy; use WordPress\AI\Logging\REST\AI_Request_Log_Controller; use WordPress\AI\Settings\Settings_Registration; use function WordPress\AI\get_request_log_manager; @@ -53,11 +52,8 @@ protected function load_experiment_metadata(): array { public function register(): void { $manager = $this->get_manager(); - // Initialize the log manager (creates table, schedules cleanup). - $manager->init(); - - // Register the logging HTTP client wrapper so requests get logged. - Logging_Discovery_Strategy::init( $manager ); + // Note: Log manager and discovery strategy are initialized in bootstrap.php + // BEFORE AI_Client::init() to ensure the HTTP wrapper is registered first. add_action( 'rest_api_init', diff --git a/includes/Logging/Logging_Discovery_Strategy.php b/includes/Logging/Logging_Discovery_Strategy.php deleted file mode 100644 index 54d0578e..00000000 --- a/includes/Logging/Logging_Discovery_Strategy.php +++ /dev/null @@ -1,92 +0,0 @@ -> - */ - public static function getCandidates( $type ) { - // Only handle PSR-18 HTTP Client discovery. - if ( ClientInterface::class !== $type ) { - return array(); - } - - // If logging is disabled or manager not set, return empty to fall through. - if ( ! self::$log_manager || ! self::$log_manager->is_logging_enabled() ) { - return array(); - } - - return array( - array( - 'class' => static function () { - return self::createLoggingClient(); - }, - ), - ); - } - - /** - * Create an instance of the logging HTTP client. - * - * @return \WordPress\AI\Logging\Logging_HTTP_Client - */ - private static function createLoggingClient(): Logging_HTTP_Client { - $psr17_factory = new Psr17Factory(); - - // Create the underlying WordPress HTTP client. - $wordpress_client = new WordPress_HTTP_Client( - $psr17_factory, - $psr17_factory - ); - - // Wrap it with logging. - return new Logging_HTTP_Client( $wordpress_client, self::$log_manager ); - } -} diff --git a/includes/Logging/Logging_HTTP_Client.php b/includes/Logging/Logging_Http_Transporter.php similarity index 73% rename from includes/Logging/Logging_HTTP_Client.php rename to includes/Logging/Logging_Http_Transporter.php index 8dec53c4..787b389e 100644 --- a/includes/Logging/Logging_HTTP_Client.php +++ b/includes/Logging/Logging_Http_Transporter.php @@ -1,6 +1,6 @@ client = $client; + public function __construct( HttpTransporterInterface $transporter, AI_Request_Log_Manager $log_manager ) { + $this->transporter = $transporter; $this->log_manager = $log_manager; } /** - * Sends a PSR-7 request and returns a PSR-7 response, logging the request. - * - * @param \Psr\Http\Message\RequestInterface $request The PSR-7 request. - * @return \Psr\Http\Message\ResponseInterface The PSR-7 response. - * @throws \Psr\Http\Client\ClientExceptionInterface If an error happens during processing. - */ - public function sendRequest( RequestInterface $request ): ResponseInterface { - $timer = $this->log_manager->start_timer(); - $log_data = $this->extract_request_data( $request ); - $error_msg = null; - $status = 'success'; - - try { - $response = $this->client->sendRequest( $request ); - - $this->extract_response_data( $response, $log_data ); - - return $response; - } catch ( Throwable $e ) { - $status = 'error'; - $error_msg = $e->getMessage(); - throw $e; - } finally { - $log_data['duration_ms'] = $this->log_manager->end_timer( $timer ); - $log_data['status'] = $status; - $log_data['error_message'] = $error_msg; - - $this->log_manager->log( $log_data ); - } - } - - /** - * Sends a PSR-7 request with options and returns a PSR-7 response, logging the request. + * Sends an HTTP request and returns the response, logging the request. * - * @param \Psr\Http\Message\RequestInterface $request The PSR-7 request. - * @param \WordPress\AiClient\Providers\Http\DTO\RequestOptions $options Transport options for the request. - * @return \Psr\Http\Message\ResponseInterface The PSR-7 response. - * @throws \Psr\Http\Client\ClientExceptionInterface If an error happens during processing. + * @param \WordPress\AiClient\Providers\Http\DTO\Request $request The request to send. + * @param \WordPress\AiClient\Providers\Http\DTO\RequestOptions|null $options Optional transport options. + * @return \WordPress\AiClient\Providers\Http\DTO\Response The response received. */ - public function sendRequestWithOptions( RequestInterface $request, RequestOptions $options ): ResponseInterface { - // If the wrapped client doesn't support options, fall back to regular send. - if ( ! $this->client instanceof ClientWithOptionsInterface ) { - return $this->sendRequest( $request ); - } - + public function send( Request $request, ?RequestOptions $options = null ): Response { $timer = $this->log_manager->start_timer(); $log_data = $this->extract_request_data( $request ); $error_msg = null; $status = 'success'; try { - $response = $this->client->sendRequestWithOptions( $request, $options ); + $response = $this->transporter->send( $request, $options ); $this->extract_response_data( $response, $log_data ); @@ -127,20 +91,17 @@ public function sendRequestWithOptions( RequestInterface $request, RequestOption /** * Extracts logging data from the request. * - * @param \Psr\Http\Message\RequestInterface $request The PSR-7 request. + * @param \WordPress\AiClient\Providers\Http\DTO\Request $request The SDK request. * @return array Initial log data. */ - private function extract_request_data( RequestInterface $request ): array { - $uri = (string) $request->getUri(); + private function extract_request_data( Request $request ): array { + $uri = $request->getUri(); $provider = $this->detect_provider( $uri ); $model = null; $decoded = null; // Try to extract model from request body. - $body = (string) $request->getBody(); - if ( $body && $request->getBody()->isSeekable() ) { - $request->getBody()->rewind(); - } + $body = $request->getBody(); if ( $body ) { $decoded = json_decode( $body, true ); @@ -150,12 +111,13 @@ private function extract_request_data( RequestInterface $request ): array { } // Build operation name from URL path. - $path = $request->getUri()->getPath(); - $operation = $provider ? $provider . ':' . basename( $path ) : basename( $path ); + $parsed_url = wp_parse_url( $uri ); + $path = $parsed_url['path'] ?? ''; + $operation = $provider ? $provider . ':' . basename( $path ) : basename( $path ); $context = array( 'url' => $uri, - 'method' => $request->getMethod(), + 'method' => $request->getMethod()->value, ); if ( is_array( $decoded ) ) { @@ -179,14 +141,11 @@ private function extract_request_data( RequestInterface $request ): array { /** * Extracts token usage and other data from the response. * - * @param \Psr\Http\Message\ResponseInterface $response The PSR-7 response. + * @param \WordPress\AiClient\Providers\Http\DTO\Response $response The SDK response. * @param array $log_data Log data to update (passed by reference). */ - private function extract_response_data( ResponseInterface $response, array &$log_data ): void { - $body = (string) $response->getBody(); - if ( $response->getBody()->isSeekable() ) { - $response->getBody()->rewind(); - } + private function extract_response_data( Response $response, array &$log_data ): void { + $body = $response->getBody(); if ( ! $body ) { return; @@ -258,7 +217,8 @@ private function extract_response_data( ResponseInterface $response, array &$log * @return string|null The detected provider name or null. */ private function detect_provider( string $url ): ?string { - $host = parse_url( $url, PHP_URL_HOST ); + $parsed = wp_parse_url( $url ); + $host = $parsed['host'] ?? ''; if ( ! $host ) { return null; @@ -266,60 +226,29 @@ private function detect_provider( string $url ): ?string { $host_lower = strtolower( $host ); - if ( strpos( $host_lower, 'openai' ) !== false ) { - return 'openai'; - } - - if ( strpos( $host_lower, 'anthropic' ) !== false ) { - return 'anthropic'; - } - - if ( strpos( $host_lower, 'googleapis' ) !== false || strpos( $host_lower, 'google' ) !== false ) { - return 'google'; - } - - if ( strpos( $host_lower, 'fal.run' ) !== false || strpos( $host_lower, 'fal.ai' ) !== false ) { - return 'fal'; - } - - if ( strpos( $host_lower, 'cloudflare' ) !== false || strpos( $host_lower, 'workers.ai' ) !== false ) { - return 'cloudflare'; - } - - if ( strpos( $host_lower, 'groq' ) !== false ) { - return 'groq'; - } - - if ( strpos( $host_lower, 'x.ai' ) !== false || strpos( $host_lower, 'xai' ) !== false ) { - return 'grok'; - } - - if ( strpos( $host_lower, 'huggingface' ) !== false ) { - return 'huggingface'; - } - - if ( strpos( $host_lower, 'deepseek' ) !== false ) { - return 'deepseek'; - } - - if ( strpos( $host_lower, 'ollama' ) !== false ) { - return 'ollama'; - } - - if ( strpos( $host_lower, 'openrouter' ) !== false ) { - return 'openrouter'; - } - - if ( strpos( $host_lower, 'azure' ) !== false ) { - return 'azure'; - } - - if ( strpos( $host_lower, 'cohere' ) !== false ) { - return 'cohere'; - } + $providers = array( + 'openai' => array( 'openai' ), + 'anthropic' => array( 'anthropic' ), + 'google' => array( 'googleapis', 'google' ), + 'fal' => array( 'fal.run', 'fal.ai' ), + 'cloudflare' => array( 'cloudflare', 'workers.ai' ), + 'groq' => array( 'groq' ), + 'grok' => array( 'x.ai', 'xai' ), + 'huggingface' => array( 'huggingface' ), + 'deepseek' => array( 'deepseek' ), + 'ollama' => array( 'ollama' ), + 'openrouter' => array( 'openrouter' ), + 'azure' => array( 'azure' ), + 'cohere' => array( 'cohere' ), + 'mistral' => array( 'mistral' ), + ); - if ( strpos( $host_lower, 'mistral' ) !== false ) { - return 'mistral'; + foreach ( $providers as $name => $patterns ) { + foreach ( $patterns as $pattern ) { + if ( strpos( $host_lower, $pattern ) !== false ) { + return $name; + } + } } return null; diff --git a/includes/Logging/Logging_Integration.php b/includes/Logging/Logging_Integration.php new file mode 100644 index 00000000..e09856bc --- /dev/null +++ b/includes/Logging/Logging_Integration.php @@ -0,0 +1,112 @@ +is_logging_enabled() ) { + return; + } + + try { + $registry = AiClient::defaultRegistry(); + + // Get the current transporter. + $current_transporter = $registry->getHttpTransporter(); + + if ( ! $current_transporter instanceof HttpTransporterInterface ) { + return; + } + + // Don't wrap if already wrapped. + if ( $current_transporter instanceof Logging_Http_Transporter ) { + $wrapped = true; + return; + } + + // Create a logging wrapper around the existing transporter. + $logging_transporter = new Logging_Http_Transporter( + $current_transporter, + self::$log_manager + ); + + // Replace the transporter with the logging version. + $registry->setHttpTransporter( $logging_transporter ); + + $wrapped = true; + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch + // Silently fail - logging is optional and shouldn't break the site. + } + } +} diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 3ad96844..fc87d1dd 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -12,6 +12,8 @@ namespace WordPress\AI; use WordPress\AI\Abilities\Utilities\Posts; +use WordPress\AI\Logging\AI_Request_Log_Manager; +use WordPress\AI\Logging\Logging_Integration; use WordPress\AI\Settings\Settings_Page; use WordPress\AI\Settings\Settings_Registration; use WordPress\AI_Client\AI_Client; @@ -204,6 +206,17 @@ function initialize_experiments(): void { // Initialize the WP AI Client. AI_Client::init(); + // Initialize logging after AI_Client::init(). + // The Logging_Integration class wraps the SDK's HTTP transporter + // using the public setHttpTransporter() API. + if ( get_option( 'ai_experiment_ai-request-logging_enabled', false ) ) { + $log_manager = get_request_log_manager(); + if ( $log_manager ) { + $log_manager->init(); + Logging_Integration::init( $log_manager ); + } + } + $registry = new Experiment_Registry(); $loader = new Experiment_Loader( $registry ); $loader->register_default_experiments(); From 3879d655cdfcc22cb4aac8b2824bb97f4156477e Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Fri, 19 Dec 2025 09:08:18 -0500 Subject: [PATCH 6/8] Add Log_Data_Extractor for extensible request parsing Extract data parsing logic from Logging_Http_Transporter into a dedicated Log_Data_Extractor class with filter hooks for extensibility: - ai_request_log_providers: customize provider detection patterns - ai_request_log_context: filter context data before storage - ai_request_log_tokens: custom token extraction for new providers - ai_request_log_kind: customize request kind detection Architecture improvements: - Separation of concerns: transport vs extraction logic - Transporter reduced from 574 to 130 lines - Media metadata stored instead of full base64 data (prevents DB bloat) - All extraction methods are public for testability Supports 14 providers out of the box: OpenAI, Anthropic, Google, Fal, Cloudflare, Groq, Grok, HuggingFace, DeepSeek, Ollama, OpenRouter, Azure, Cohere, and Mistral. --- docs/experiments/ai-request-logging.md | 50 ++ includes/Logging/Log_Data_Extractor.php | 614 ++++++++++++++++++ includes/Logging/Logging_Http_Transporter.php | 509 +-------------- includes/Logging/Logging_Integration.php | 1 + 4 files changed, 697 insertions(+), 477 deletions(-) create mode 100644 includes/Logging/Log_Data_Extractor.php diff --git a/docs/experiments/ai-request-logging.md b/docs/experiments/ai-request-logging.md index 61aee178..de83ae2f 100644 --- a/docs/experiments/ai-request-logging.md +++ b/docs/experiments/ai-request-logging.md @@ -19,9 +19,59 @@ The logging system uses the decorator pattern to wrap the SDK's HTTP transporter - Creates a `Logging_Http_Transporter` decorator around it - Uses `registry->setHttpTransporter()` to swap in the logging version 3. All subsequent AI requests go through the logging transporter, which records metrics before delegating to the underlying transporter. +4. Data extraction is handled by `Log_Data_Extractor`, which parses request/response payloads and extracts provider, model, tokens, and previews. This approach uses the SDK's public API rather than reflection or internal hacks, making it resilient to SDK updates. +## Filter Hooks +The logging system exposes several filter hooks for extensibility: + +### `ai_request_log_providers` +Filters the provider detection patterns. Allows adding custom providers or modifying detection patterns. + +```php +add_filter( 'ai_request_log_providers', function( $patterns ) { + $patterns['my_provider'] = array( 'my-api.com', 'api.myprovider.io' ); + return $patterns; +} ); +``` + +### `ai_request_log_context` +Filters the log context data before it's stored. Allows adding custom context or removing sensitive data. + +```php +add_filter( 'ai_request_log_context', function( $context, $decoded, $log_data ) { + $context['custom_field'] = 'custom_value'; + unset( $context['sensitive_field'] ); + return $context; +}, 10, 3 ); +``` + +### `ai_request_log_tokens` +Filters the extracted token usage. Allows custom providers to supply their own token extraction logic. + +```php +add_filter( 'ai_request_log_tokens', function( $tokens, $response ) { + if ( isset( $response['my_token_field'] ) ) { + $tokens['input'] = $response['my_token_field']['in']; + $tokens['output'] = $response['my_token_field']['out']; + } + return $tokens; +}, 10, 2 ); +``` + +### `ai_request_log_kind` +Filters the detected request kind (text, image, embeddings, audio). + +```php +add_filter( 'ai_request_log_kind', function( $kind, $provider, $path, $payload ) { + if ( str_contains( $path, '/my-custom-endpoint' ) ) { + return 'custom'; + } + return $kind; +}, 10, 4 ); +``` + ## Assets & Data Flow 1. When `AI Request Logs` is visited, `Asset_Loader` enqueues `admin/ai-request-logs` (`src/admin/ai-request-logs/index.tsx`) plus its stylesheet. The localized payload (`window.AiRequestLogsSettings`) includes REST routes, a nonce, and initial state (enabled flag, retention days, summary, filters). 2. The React app: diff --git a/includes/Logging/Log_Data_Extractor.php b/includes/Logging/Log_Data_Extractor.php new file mode 100644 index 00000000..c9b6b33c --- /dev/null +++ b/includes/Logging/Log_Data_Extractor.php @@ -0,0 +1,614 @@ +> + */ + private array $provider_patterns; + + /** + * Constructor. + */ + public function __construct() { + $this->provider_patterns = $this->get_default_provider_patterns(); + } + + /** + * Returns the default provider detection patterns. + * + * @return array> Provider name => URL patterns. + */ + private function get_default_provider_patterns(): array { + $patterns = array( + 'openai' => array( 'openai' ), + 'anthropic' => array( 'anthropic' ), + 'google' => array( 'googleapis', 'google' ), + 'fal' => array( 'fal.run', 'fal.ai' ), + 'cloudflare' => array( 'cloudflare', 'workers.ai' ), + 'groq' => array( 'groq' ), + 'grok' => array( 'x.ai', 'xai' ), + 'huggingface' => array( 'huggingface' ), + 'deepseek' => array( 'deepseek' ), + 'ollama' => array( 'ollama' ), + 'openrouter' => array( 'openrouter' ), + 'azure' => array( 'azure' ), + 'cohere' => array( 'cohere' ), + 'mistral' => array( 'mistral' ), + ); + + /** + * Filters the provider detection patterns. + * + * Allows adding custom providers or modifying detection patterns. + * + * @since 0.1.0 + * + * @param array> $patterns Provider name => URL patterns map. + */ + return (array) apply_filters( 'ai_request_log_providers', $patterns ); + } + + /** + * Extracts log data from a request. + * + * @param string $uri Request URI. + * @param string $method HTTP method. + * @param string|null $body Request body (JSON). + * @return array Extracted log data. + */ + public function extract_request_data( string $uri, string $method, ?string $body ): array { + $provider = $this->detect_provider( $uri ); + $model = null; + $decoded = null; + + if ( $body ) { + $decoded = json_decode( $body, true ); + if ( is_array( $decoded ) && isset( $decoded['model'] ) ) { + $model = (string) $decoded['model']; + } + } + + $parsed_url = wp_parse_url( $uri ); + $path = $parsed_url['path'] ?? ''; + $operation = $provider ? $provider . ':' . basename( $path ) : basename( $path ); + + $context = array( + 'url' => $uri, + 'method' => $method, + 'request_kind' => $this->detect_request_kind( $provider, $path, $decoded ), + ); + + if ( is_array( $decoded ) ) { + $input_preview = $this->extract_input_preview( $decoded ); + if ( $input_preview ) { + $context['input_preview'] = $input_preview; + } + } + + return array( + 'type' => 'ai_client', + 'operation' => $operation, + 'provider' => $provider, + 'model' => $model, + 'context' => $context, + ); + } + + /** + * Extracts additional data from a response. + * + * @param string|null $body Response body (JSON). + * @param array $log_data Existing log data to augment. + * @return array Augmented log data. + */ + public function extract_response_data( ?string $body, array $log_data ): array { + if ( ! $body ) { + return $log_data; + } + + $decoded = json_decode( $body, true ); + if ( ! is_array( $decoded ) ) { + return $log_data; + } + + // Extract model if not already set. + if ( empty( $log_data['model'] ) && isset( $decoded['model'] ) ) { + $log_data['model'] = (string) $decoded['model']; + } + + // Extract token usage. + $tokens = $this->extract_token_usage( $decoded ); + if ( null !== $tokens['input'] ) { + $log_data['tokens_input'] = $tokens['input']; + } + if ( null !== $tokens['output'] ) { + $log_data['tokens_output'] = $tokens['output']; + } + + // Build context. + $context = $this->normalize_context( $log_data['context'] ?? array() ); + + $output_preview = $this->extract_output_preview( $decoded ); + if ( $output_preview ) { + $context['output_preview'] = $output_preview; + } + + $media_context = $this->extract_media_metadata( $decoded ); + if ( ! empty( $media_context ) ) { + $context = array_merge( $context, $media_context ); + } + + /** + * Filters the log context data. + * + * @since 0.1.0 + * + * @param array $context The context data. + * @param array $decoded The decoded response. + * @param array $log_data The full log data. + */ + $context = (array) apply_filters( 'ai_request_log_context', $context, $decoded, $log_data ); + + if ( ! empty( $context ) ) { + $log_data['context'] = $context; + } + + return $log_data; + } + + /** + * Detects the AI provider from the request URL. + * + * @param string $url The request URL. + * @return string|null The detected provider name or null. + */ + public function detect_provider( string $url ): ?string { + $parsed = wp_parse_url( $url ); + $host = $parsed['host'] ?? ''; + + if ( ! $host ) { + return null; + } + + $host_lower = strtolower( $host ); + + foreach ( $this->provider_patterns as $name => $patterns ) { + foreach ( $patterns as $pattern ) { + if ( strpos( $host_lower, $pattern ) !== false ) { + return $name; + } + } + } + + return null; + } + + /** + * Extracts token usage from various provider response formats. + * + * @param array $response Decoded response data. + * @return array{input: int|null, output: int|null} Token counts. + */ + public function extract_token_usage( array $response ): array { + $input = null; + $output = null; + + // OpenAI format. + if ( isset( $response['usage'] ) && is_array( $response['usage'] ) ) { + $usage = $response['usage']; + $input = $usage['prompt_tokens'] ?? $usage['input_tokens'] ?? null; + $output = $usage['completion_tokens'] ?? $usage['output_tokens'] ?? null; + } + + // Anthropic format (also uses 'usage' but different keys). + if ( isset( $response['usage']['input_tokens'] ) ) { + $input = $response['usage']['input_tokens']; + $output = $response['usage']['output_tokens'] ?? null; + } + + // Google format. + if ( isset( $response['usageMetadata'] ) && is_array( $response['usageMetadata'] ) ) { + $usage = $response['usageMetadata']; + $input = $usage['promptTokenCount'] ?? null; + $output = $usage['candidatesTokenCount'] ?? null; + } + + /** + * Filters the extracted token usage. + * + * Allows custom providers to supply their own token extraction logic. + * + * @since 0.1.0 + * + * @param array{input: int|null, output: int|null} $tokens Extracted token counts. + * @param array $response The full response data. + */ + return (array) apply_filters( + 'ai_request_log_tokens', + array( + 'input' => $input, + 'output' => $output, + ), + $response + ); + } + + /** + * Determines the high-level request kind. + * + * @param string|null $provider Provider identifier. + * @param string $path Request path. + * @param array|null $payload Request payload. + * @return string Request kind: 'text', 'image', 'embeddings', etc. + */ + public function detect_request_kind( ?string $provider, string $path, ?array $payload ): string { + $path_lower = strtolower( $path ); + + // Provider-specific detection. + if ( 'fal' === $provider ) { + return 'image'; + } + + // Path-based detection. + if ( str_contains( $path_lower, 'embeddings' ) ) { + return 'embeddings'; + } + + if ( + str_contains( $path_lower, '/images' ) || + str_contains( $path_lower, 'imagegeneration' ) || + str_contains( $path_lower, 'image-generation' ) + ) { + return 'image'; + } + + if ( str_contains( $path_lower, 'audio' ) || str_contains( $path_lower, 'speech' ) ) { + return 'audio'; + } + + /** + * Filters the detected request kind. + * + * @since 0.1.0 + * + * @param string $kind Detected request kind. + * @param string|null $provider Provider identifier. + * @param string $path Request path. + * @param array|null $payload Request payload. + */ + return (string) apply_filters( 'ai_request_log_kind', 'text', $provider, $path, $payload ); + } + + /** + * Extracts a human-readable preview of the prompt/input payload. + * + * @param array $payload Request payload. + * @return string|null Truncated preview or null. + */ + public function extract_input_preview( array $payload ): ?string { + // Chat messages format (OpenAI, Anthropic). + if ( isset( $payload['messages'] ) && is_array( $payload['messages'] ) ) { + $segments = array(); + + foreach ( $payload['messages'] as $message ) { + if ( ! is_array( $message ) ) { + continue; + } + + $role = $message['role'] ?? 'user'; + $content = $this->stringify_content( $message['content'] ?? '' ); + + if ( '' === $content ) { + continue; + } + + $segments[] = sprintf( '[%s] %s', $role, $content ); + + if ( strlen( implode( "\n", $segments ) ) >= self::PAYLOAD_PREVIEW_LIMIT ) { + break; + } + } + + if ( $segments ) { + return $this->truncate_string( implode( "\n", $segments ) ); + } + } + + // Alternative input formats. + foreach ( array( 'prompt', 'input', 'contents' ) as $field ) { + if ( ! isset( $payload[ $field ] ) ) { + continue; + } + + $content = $this->stringify_content( $payload[ $field ] ); + if ( '' !== $content ) { + return $this->truncate_string( $content ); + } + } + + return null; + } + + /** + * Extracts a human-readable preview of the response payload. + * + * @param array $payload Response payload. + * @return string|null Truncated preview or null. + */ + public function extract_output_preview( array $payload ): ?string { + // OpenAI choices format. + if ( isset( $payload['choices'] ) && is_array( $payload['choices'] ) ) { + foreach ( $payload['choices'] as $choice ) { + if ( ! is_array( $choice ) ) { + continue; + } + + if ( isset( $choice['message']['content'] ) ) { + $content = $this->stringify_content( $choice['message']['content'] ); + if ( '' !== $content ) { + return $this->truncate_string( $content ); + } + } + + if ( ! isset( $choice['text'] ) ) { + continue; + } + + $content = $this->stringify_content( $choice['text'] ); + if ( '' !== $content ) { + return $this->truncate_string( $content ); + } + } + } + + // Anthropic content format. + if ( isset( $payload['content'] ) && is_array( $payload['content'] ) ) { + $content = $this->stringify_content( $payload['content'] ); + if ( '' !== $content ) { + return $this->truncate_string( $content ); + } + } + + // Direct output field. + if ( isset( $payload['output'] ) ) { + $content = $this->stringify_content( $payload['output'] ); + if ( '' !== $content ) { + return $this->truncate_string( $content ); + } + } + + // Google candidates format. + if ( isset( $payload['candidates'] ) && is_array( $payload['candidates'] ) ) { + foreach ( $payload['candidates'] as $candidate ) { + if ( ! is_array( $candidate ) ) { + continue; + } + + if ( ! isset( $candidate['content'] ) ) { + continue; + } + + $content = $this->stringify_content( $candidate['content'] ); + if ( '' !== $content ) { + return $this->truncate_string( $content ); + } + } + } + + return null; + } + + /** + * Extracts media metadata from a response (without storing raw data). + * + * For production use, we store only metadata about generated media + * rather than the full base64 data to prevent database bloat. + * + * @param array $payload Response data. + * @return array Media metadata. + */ + public function extract_media_metadata( array $payload ): array { + $context = array(); + $image_count = 0; + $image_urls = array(); + $image_metas = array(); + + // OpenAI DALL-E format. + if ( isset( $payload['data'] ) && is_array( $payload['data'] ) ) { + foreach ( $payload['data'] as $entry ) { + if ( ! is_array( $entry ) ) { + continue; + } + + if ( isset( $entry['url'] ) && is_string( $entry['url'] ) ) { + $image_urls[] = $entry['url']; + ++$image_count; + } elseif ( isset( $entry['b64_json'] ) && is_string( $entry['b64_json'] ) ) { + // Store metadata only, not the actual data. + $image_metas[] = array( + 'format' => 'base64', + 'size' => strlen( $entry['b64_json'] ), + ); + ++$image_count; + } + } + } + + // Alternative images array format. + if ( isset( $payload['images'] ) && is_array( $payload['images'] ) ) { + foreach ( $payload['images'] as $image ) { + if ( ! is_array( $image ) ) { + continue; + } + + if ( isset( $image['url'] ) && is_string( $image['url'] ) ) { + $image_urls[] = $image['url']; + ++$image_count; + } elseif ( isset( $image['b64_json'] ) || isset( $image['image_base64'] ) ) { + $encoded = $image['b64_json'] ?? $image['image_base64']; + $image_metas[] = array( + 'format' => 'base64', + 'mime_type' => $image['content_type'] ?? 'image/png', + 'size' => is_string( $encoded ) ? strlen( $encoded ) : 0, + ); + ++$image_count; + } + } + } + + if ( $image_count > 0 ) { + $context['media_type'] = 'image'; + $context['media_count'] = $image_count; + + if ( ! empty( $image_urls ) ) { + $context['image_urls'] = array_slice( $image_urls, 0, self::MAX_MEDIA_SAMPLES ); + } + + if ( ! empty( $image_metas ) ) { + $context['image_metadata'] = array_slice( $image_metas, 0, self::MAX_MEDIA_SAMPLES ); + } + + $context['output_preview'] = sprintf( + 'Generated %d image(s).', + $image_count + ); + } + + return $context; + } + + /** + * Normalizes context data to ensure it's an array. + * + * @param mixed $context Raw context data. + * @return array Normalized context array. + */ + private function normalize_context( $context ): array { + if ( is_array( $context ) ) { + return $context; + } + + if ( is_string( $context ) ) { + $decoded = json_decode( $context, true ); + if ( is_array( $decoded ) ) { + return $decoded; + } + } + + return array(); + } + + /** + * Converts structured content into a plain string. + * + * @param mixed $content Structured content (string|array). + * @return string Plain text content. + */ + public function stringify_content( $content ): string { + if ( is_string( $content ) ) { + return trim( $content ); + } + + if ( is_array( $content ) ) { + // Handle base64 image markers. + if ( isset( $content['b64_json'] ) || isset( $content['image_base64'] ) ) { + return '[base64 image]'; + } + + $parts = array(); + + foreach ( $content as $chunk ) { + if ( is_array( $chunk ) ) { + if ( isset( $chunk['text'] ) ) { + $parts[] = (string) $chunk['text']; + } elseif ( isset( $chunk['content'] ) ) { + $parts[] = $this->stringify_content( $chunk['content'] ); + } elseif ( isset( $chunk['type'] ) && 'text' === $chunk['type'] && isset( $chunk['text'] ) ) { + $parts[] = (string) $chunk['text']; + } else { + $nested = $this->stringify_content( $chunk ); + if ( '' !== $nested ) { + $parts[] = $nested; + } + } + } elseif ( is_scalar( $chunk ) ) { + $parts[] = (string) $chunk; + } + } + + if ( $parts ) { + return trim( implode( "\n", array_filter( $parts ) ) ); + } + + // Fallback to JSON for unrecognized structures. + return trim( (string) wp_json_encode( $content ) ); + } + + if ( is_scalar( $content ) ) { + return trim( (string) $content ); + } + + return ''; + } + + /** + * Truncates a string to the configured preview limit. + * + * @param string $value The string to truncate. + * @param int $limit Maximum length. + * @return string Truncated string. + */ + public function truncate_string( string $value, int $limit = self::PAYLOAD_PREVIEW_LIMIT ): string { + $value = trim( $value ); + + if ( '' === $value ) { + return $value; + } + + if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) { + if ( mb_strlen( $value, 'UTF-8' ) <= $limit ) { + return $value; + } + + return mb_substr( $value, 0, $limit, 'UTF-8' ) . '…'; + } + + if ( strlen( $value ) <= $limit ) { + return $value; + } + + return substr( $value, 0, $limit ) . '...'; + } +} diff --git a/includes/Logging/Logging_Http_Transporter.php b/includes/Logging/Logging_Http_Transporter.php index 787b389e..18312b68 100644 --- a/includes/Logging/Logging_Http_Transporter.php +++ b/includes/Logging/Logging_Http_Transporter.php @@ -21,39 +21,49 @@ * This class wraps the SDK's HttpTransporterInterface rather than the lower-level * PSR-18 ClientInterface, providing cleaner integration with the AI Client SDK. * + * Extraction logic is delegated to Log_Data_Extractor for better separation of + * concerns and extensibility via WordPress filter hooks. + * * @since 0.1.0 */ class Logging_Http_Transporter implements HttpTransporterInterface { /** * The wrapped HTTP transporter. + * + * @var \WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface */ private HttpTransporterInterface $transporter; /** * The log manager instance. + * + * @var \WordPress\AI\Logging\AI_Request_Log_Manager */ private AI_Request_Log_Manager $log_manager; /** - * Maximum characters to retain for input/output previews. - */ - private const PAYLOAD_PREVIEW_LIMIT = 1200; - - /** - * Maximum number of media samples to retain per response. + * The data extractor instance. + * + * @var \WordPress\AI\Logging\Log_Data_Extractor */ - private const MAX_IMAGE_SAMPLES = 3; + private Log_Data_Extractor $extractor; /** * Constructor. * - * @param \WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface $transporter The HTTP transporter to wrap. - * @param \WordPress\AI\Logging\AI_Request_Log_Manager $log_manager The log manager. + * @param \WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface $transporter The HTTP transporter to wrap. + * @param \WordPress\AI\Logging\AI_Request_Log_Manager $log_manager The log manager. + * @param \WordPress\AI\Logging\Log_Data_Extractor|null $extractor Optional data extractor (created if not provided). */ - public function __construct( HttpTransporterInterface $transporter, AI_Request_Log_Manager $log_manager ) { + public function __construct( + HttpTransporterInterface $transporter, + AI_Request_Log_Manager $log_manager, + ?Log_Data_Extractor $extractor = null + ) { $this->transporter = $transporter; $this->log_manager = $log_manager; + $this->extractor = $extractor ?? new Log_Data_Extractor(); } /** @@ -72,7 +82,7 @@ public function send( Request $request, ?RequestOptions $options = null ): Respo try { $response = $this->transporter->send( $request, $options ); - $this->extract_response_data( $response, $log_data ); + $log_data = $this->extract_response_data( $response, $log_data ); return $response; } catch ( Throwable $e ) { @@ -84,6 +94,7 @@ public function send( Request $request, ?RequestOptions $options = null ): Respo $log_data['status'] = $status; $log_data['error_message'] = $error_msg; + // @phpstan-ignore argument.type (array shape is built incrementally) $this->log_manager->log( $log_data ); } } @@ -95,46 +106,10 @@ public function send( Request $request, ?RequestOptions $options = null ): Respo * @return array Initial log data. */ private function extract_request_data( Request $request ): array { - $uri = $request->getUri(); - $provider = $this->detect_provider( $uri ); - $model = null; - $decoded = null; - - // Try to extract model from request body. - $body = $request->getBody(); - - if ( $body ) { - $decoded = json_decode( $body, true ); - if ( is_array( $decoded ) && isset( $decoded['model'] ) ) { - $model = (string) $decoded['model']; - } - } - - // Build operation name from URL path. - $parsed_url = wp_parse_url( $uri ); - $path = $parsed_url['path'] ?? ''; - $operation = $provider ? $provider . ':' . basename( $path ) : basename( $path ); - - $context = array( - 'url' => $uri, - 'method' => $request->getMethod()->value, - ); - - if ( is_array( $decoded ) ) { - $input_preview = $this->extract_input_preview( $decoded ); - if ( $input_preview ) { - $context['input_preview'] = $input_preview; - } - } - - $context['request_kind'] = $this->detect_request_kind( $provider, $path, $decoded ); - - return array( - 'type' => 'ai_client', - 'operation' => $operation, - 'provider' => $provider, - 'model' => $model, - 'context' => $context, + return $this->extractor->extract_request_data( + $request->getUri(), + $request->getMethod()->value, + $request->getBody() ); } @@ -142,433 +117,13 @@ private function extract_request_data( Request $request ): array { * Extracts token usage and other data from the response. * * @param \WordPress\AiClient\Providers\Http\DTO\Response $response The SDK response. - * @param array $log_data Log data to update (passed by reference). + * @param array $log_data Log data to augment. + * @return array Augmented log data. */ - private function extract_response_data( Response $response, array &$log_data ): void { - $body = $response->getBody(); - - if ( ! $body ) { - return; - } - - $decoded = json_decode( $body, true ); - if ( ! is_array( $decoded ) ) { - return; - } - - // Extract model if not already set. - if ( empty( $log_data['model'] ) && isset( $decoded['model'] ) ) { - $log_data['model'] = (string) $decoded['model']; - } - - // Extract token usage - OpenAI format. - if ( isset( $decoded['usage'] ) && is_array( $decoded['usage'] ) ) { - $usage = $decoded['usage']; - $log_data['tokens_input'] = $usage['prompt_tokens'] ?? $usage['input_tokens'] ?? null; - $log_data['tokens_output'] = $usage['completion_tokens'] ?? $usage['output_tokens'] ?? null; - } - - // Anthropic format. - if ( isset( $decoded['usage']['input_tokens'] ) ) { - $log_data['tokens_input'] = $decoded['usage']['input_tokens']; - $log_data['tokens_output'] = $decoded['usage']['output_tokens'] ?? null; - } - - // Google format. - if ( isset( $decoded['usageMetadata'] ) && is_array( $decoded['usageMetadata'] ) ) { - $usage = $decoded['usageMetadata']; - $log_data['tokens_input'] = $usage['promptTokenCount'] ?? null; - $log_data['tokens_output'] = $usage['candidatesTokenCount'] ?? null; - } - - $context = array(); - if ( isset( $log_data['context'] ) && is_array( $log_data['context'] ) ) { - $context = $log_data['context']; - } elseif ( isset( $log_data['context'] ) && is_string( $log_data['context'] ) ) { - $maybe_context = json_decode( $log_data['context'], true ); - if ( is_array( $maybe_context ) ) { - $context = $maybe_context; - } - } - - $output_preview = $this->extract_output_preview( $decoded ); - if ( $output_preview ) { - $context['output_preview'] = $output_preview; - } - - if ( is_array( $decoded ) ) { - $media_context = $this->extract_media_context( $decoded ); - if ( ! empty( $media_context ) ) { - $context = array_merge( $context, $media_context ); - } - } - - if ( empty( $context ) ) { - return; - } - - $log_data['context'] = $context; - } - - /** - * Detects the AI provider from the request URL. - * - * @param string $url The request URL. - * @return string|null The detected provider name or null. - */ - private function detect_provider( string $url ): ?string { - $parsed = wp_parse_url( $url ); - $host = $parsed['host'] ?? ''; - - if ( ! $host ) { - return null; - } - - $host_lower = strtolower( $host ); - - $providers = array( - 'openai' => array( 'openai' ), - 'anthropic' => array( 'anthropic' ), - 'google' => array( 'googleapis', 'google' ), - 'fal' => array( 'fal.run', 'fal.ai' ), - 'cloudflare' => array( 'cloudflare', 'workers.ai' ), - 'groq' => array( 'groq' ), - 'grok' => array( 'x.ai', 'xai' ), - 'huggingface' => array( 'huggingface' ), - 'deepseek' => array( 'deepseek' ), - 'ollama' => array( 'ollama' ), - 'openrouter' => array( 'openrouter' ), - 'azure' => array( 'azure' ), - 'cohere' => array( 'cohere' ), - 'mistral' => array( 'mistral' ), + private function extract_response_data( Response $response, array $log_data ): array { + return $this->extractor->extract_response_data( + $response->getBody(), + $log_data ); - - foreach ( $providers as $name => $patterns ) { - foreach ( $patterns as $pattern ) { - if ( strpos( $host_lower, $pattern ) !== false ) { - return $name; - } - } - } - - return null; - } - - /** - * Extracts a human-readable preview of the prompt/input payload. - * - * @param array|null $payload Request payload. - * @return string|null - */ - private function extract_input_preview( ?array $payload ): ?string { - if ( empty( $payload ) ) { - return null; - } - - if ( isset( $payload['messages'] ) && is_array( $payload['messages'] ) ) { - $segments = array(); - - foreach ( $payload['messages'] as $message ) { - if ( ! is_array( $message ) ) { - continue; - } - - $role = $message['role'] ?? 'user'; - $content = $this->stringify_content( $message['content'] ?? '' ); - - if ( '' === $content ) { - continue; - } - - $segments[] = sprintf( '[%s] %s', $role, $content ); - - if ( strlen( implode( "\n", $segments ) ) >= self::PAYLOAD_PREVIEW_LIMIT ) { - break; - } - } - - if ( $segments ) { - return $this->truncate_string( implode( "\n", $segments ) ); - } - } - - foreach ( array( 'prompt', 'input', 'contents' ) as $field ) { - if ( ! isset( $payload[ $field ] ) ) { - continue; - } - - $content = $this->stringify_content( $payload[ $field ] ); - if ( '' !== $content ) { - return $this->truncate_string( $content ); - } - } - - return null; - } - - /** - * Extracts a human-readable preview of the response payload. - * - * @param array|null $payload Response payload. - * @return string|null - */ - private function extract_output_preview( ?array $payload ): ?string { - if ( empty( $payload ) ) { - return null; - } - - if ( isset( $payload['choices'] ) && is_array( $payload['choices'] ) ) { - foreach ( $payload['choices'] as $choice ) { - if ( ! is_array( $choice ) ) { - continue; - } - - if ( isset( $choice['message']['content'] ) ) { - $content = $this->stringify_content( $choice['message']['content'] ); - if ( '' !== $content ) { - return $this->truncate_string( $content ); - } - } - - if ( ! isset( $choice['text'] ) ) { - continue; - } - - $content = $this->stringify_content( $choice['text'] ); - if ( '' !== $content ) { - return $this->truncate_string( $content ); - } - } - } - - if ( isset( $payload['output'] ) ) { - $content = $this->stringify_content( $payload['output'] ); - if ( '' !== $content ) { - return $this->truncate_string( $content ); - } - } - - if ( isset( $payload['candidates'] ) && is_array( $payload['candidates'] ) ) { - foreach ( $payload['candidates'] as $candidate ) { - if ( ! is_array( $candidate ) ) { - continue; - } - - if ( isset( $candidate['content'] ) ) { - $content = $this->stringify_content( $candidate['content'] ); - if ( '' !== $content ) { - return $this->truncate_string( $content ); - } - } - - if ( ! isset( $candidate['output'] ) ) { - continue; - } - - $content = $this->stringify_content( $candidate['output'] ); - if ( '' !== $content ) { - return $this->truncate_string( $content ); - } - } - } - - return null; - } - - /** - * Converts structured content into a plain string. - * - * @param mixed $content Structured content (string|array). - * @return string - */ - private function stringify_content( $content ): string { - if ( is_string( $content ) ) { - return trim( $content ); - } - - if ( is_array( $content ) ) { - if ( isset( $content['b64_json'] ) && is_string( $content['b64_json'] ) ) { - return '[base64 image]'; - } - - if ( isset( $content['image_base64'] ) && is_string( $content['image_base64'] ) ) { - return '[base64 image]'; - } - - $parts = array(); - - foreach ( $content as $chunk ) { - if ( is_array( $chunk ) && isset( $chunk['text'] ) ) { - $parts[] = (string) $chunk['text']; - continue; - } - - if ( is_array( $chunk ) && isset( $chunk['content'] ) ) { - $parts[] = $this->stringify_content( $chunk['content'] ); - continue; - } - - if ( is_scalar( $chunk ) ) { - $parts[] = (string) $chunk; - continue; - } - - if ( ! is_array( $chunk ) ) { - continue; - } - - $parts[] = $this->stringify_content( $chunk ); - } - - if ( $parts ) { - return trim( implode( "\n", array_filter( $parts ) ) ); - } - - return trim( (string) wp_json_encode( $content ) ); - } - - if ( is_scalar( $content ) ) { - return trim( (string) $content ); - } - - return ''; - } - - /** - * Truncates a string to the configured preview limit. - * - * @param string $value The string to truncate. - * @param int $limit Maximum length. - * @return string - */ - private function truncate_string( string $value, int $limit = self::PAYLOAD_PREVIEW_LIMIT ): string { - $value = trim( $value ); - - if ( '' === $value ) { - return $value; - } - - if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) { - if ( mb_strlen( $value, 'UTF-8' ) <= $limit ) { - return $value; - } - - return mb_substr( $value, 0, $limit, 'UTF-8' ) . '...'; - } - - if ( strlen( $value ) <= $limit ) { - return $value; - } - - return substr( $value, 0, $limit ) . '...'; - } - - /** - * Determines the high-level request kind. - * - * @param string|null $provider Provider identifier. - * @param string $path Request path. - * @param array|null $payload Request payload. - * @return string - */ - private function detect_request_kind( ?string $provider, string $path, ?array $payload ): string { - $path_lower = strtolower( $path ); - - if ( 'fal' === $provider ) { - return 'image'; - } - - if ( str_contains( $path_lower, 'embeddings' ) ) { - return 'embeddings'; - } - - if ( - str_contains( $path_lower, '/images' ) || - str_contains( $path_lower, 'imagegeneration' ) || - str_contains( $path_lower, 'image-generation' ) - ) { - return 'image'; - } - - if ( isset( $payload['response_format']['type'] ) && 'json_schema' === $payload['response_format']['type'] ) { - return 'text'; - } - - return 'text'; - } - - /** - * Extracts image/media metadata from a response payload. - * - * @param array $payload Response data. - * @return array - */ - private function extract_media_context( array $payload ): array { - $context = array(); - $image_urls = array(); - $base64_data = array(); - - if ( isset( $payload['images'] ) && is_array( $payload['images'] ) ) { - foreach ( $payload['images'] as $image ) { - if ( ! is_array( $image ) ) { - continue; - } - - if ( isset( $image['url'] ) && is_string( $image['url'] ) ) { - $image_urls[] = $image['url']; - } - - $encoded = $image['b64_json'] ?? ( $image['image_base64'] ?? null ); - if ( ! is_string( $encoded ) || '' === $encoded ) { - continue; - } - - $base64_data[] = array( - 'mime' => $image['content_type'] ?? 'image/png', - 'data' => $encoded, - ); - } - } - - if ( isset( $payload['data'] ) && is_array( $payload['data'] ) ) { - foreach ( $payload['data'] as $entry ) { - if ( ! is_array( $entry ) ) { - continue; - } - - if ( isset( $entry['url'] ) && is_string( $entry['url'] ) ) { - $image_urls[] = $entry['url']; - } - - if ( ! isset( $entry['b64_json'] ) || ! is_string( $entry['b64_json'] ) ) { - continue; - } - - $base64_data[] = array( - 'mime' => $entry['mime'] ?? 'image/png', - 'data' => $entry['b64_json'], - ); - } - } - - if ( $image_urls ) { - $context['image_urls'] = array_slice( $image_urls, 0, self::MAX_IMAGE_SAMPLES ); - $context['output_preview'] = $context['output_preview'] ?? sprintf( - 'Generated %d image(s).', - count( $image_urls ) - ); - } - - if ( $base64_data ) { - $context['image_base64_samples'] = array_slice( $base64_data, 0, self::MAX_IMAGE_SAMPLES ); - if ( empty( $context['output_preview'] ) ) { - $context['output_preview'] = sprintf( - 'Generated %d image(s).', - count( $base64_data ) - ); - } - } - - return $context; } } diff --git a/includes/Logging/Logging_Integration.php b/includes/Logging/Logging_Integration.php index e09856bc..fc628a07 100644 --- a/includes/Logging/Logging_Integration.php +++ b/includes/Logging/Logging_Integration.php @@ -85,6 +85,7 @@ public static function wrap_transporter(): void { // Get the current transporter. $current_transporter = $registry->getHttpTransporter(); + // @phpstan-ignore instanceof.alwaysTrue (defensive check for SDK compatibility) if ( ! $current_transporter instanceof HttpTransporterInterface ) { return; } From 864d4525e3eb27ff80d31db06969cc459fec2156 Mon Sep 17 00:00:00 2001 From: Jeffrey Paul Date: Mon, 2 Feb 2026 12:17:01 -0600 Subject: [PATCH 7/8] basic commit to try and trigger playground generation --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 87744885..111588e3 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![Test](https://github.com/WordPress/ai/actions/workflows/test.yml/badge.svg)](https://github.com/WordPress/ai/actions/workflows/test.yml) [![Dependency Review](https://github.com/WordPress/ai/actions/workflows/dependency-review.yml/badge.svg)](https://github.com/WordPress/ai/actions/workflows/dependency-review.yml) -> AI experiments for WordPress. Modular framework for testing AI capabilities. +> AI experiments for WordPress. Modular framework for testing AI capabilities. ## Description From 3731f8fc5ff9775350d7621fd68e5904ccb55d06 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 2 Feb 2026 16:48:06 -0700 Subject: [PATCH 8/8] Add required NPM packages --- package-lock.json | 732 ++++++++++++++++++++++++++++------------------ package.json | 2 + 2 files changed, 451 insertions(+), 283 deletions(-) diff --git a/package-lock.json b/package-lock.json index 957738ab..21568db0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@wordpress/components": "^30.7.0", "@wordpress/compose": "^7.34.0", "@wordpress/data": "^10.34.0", + "@wordpress/dataviews": "^11.3.0", "@wordpress/edit-post": "^8.36.0", "@wordpress/editor": "^14.34.0", "@wordpress/element": "^6.34.0", @@ -21,6 +22,7 @@ "@wordpress/icons": "^11.1.0", "@wordpress/notices": "^5.35.0", "@wordpress/plugins": "^7.34.0", + "@wordpress/views": "^1.6.0", "react": "^18.3.1" }, "devDependencies": { @@ -2058,6 +2060,73 @@ "node": ">=6.9.0" } }, + "node_modules/@base-ui/react": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.1.0.tgz", + "integrity": "sha512-ikcJRNj1mOiF2HZ5jQHrXoVoHcNHdBU5ejJljcBl+VTLoYXR6FidjTN86GjO6hyshi6TZFuNvv0dEOgaOFv6Lw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@base-ui/utils": "0.2.4", + "@floating-ui/react-dom": "^2.1.6", + "@floating-ui/utils": "^0.2.10", + "reselect": "^5.1.1", + "tabbable": "^6.4.0", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@base-ui/react/node_modules/@floating-ui/react-dom": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", + "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.5" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@base-ui/utils": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.2.4.tgz", + "integrity": "sha512-smZwpMhjO29v+jrZusBSc5T+IJ3vBb9cjIiBjtKcvWmRj9Z4DWGVR3efr1eHR56/bqY5a4qyY9ElkOY5ljo3ng==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@floating-ui/utils": "^0.2.10", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@bcoe/v8-coverage": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", @@ -2069,7 +2138,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.7.tgz", "integrity": "sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@cacheable/utils": "^2.3.3", @@ -2082,7 +2151,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.0.tgz", "integrity": "sha512-KT01GjzV6AQD5+IYrcpoYLkCu1Jod3nau1Z7EsEuViO3TZGRacSbO9MfHmbJ1WaOXFtWLxPVj169cn2WNKPkIg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "hashery": "^1.2.0", @@ -2099,7 +2168,7 @@ "version": "5.5.5", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.5.tgz", "integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@keyv/serialize": "^1.1.1" @@ -2109,7 +2178,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.3.3.tgz", "integrity": "sha512-JsXDL70gQ+1Vc2W/KUFfkAJzgb4puKwwKehNLuB+HrNKWf91O736kGfxn4KujXCCSuh6mRRL4XEB0PkAFjWS0A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "hashery": "^1.3.0", @@ -2120,7 +2189,7 @@ "version": "5.5.5", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.5.tgz", "integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@keyv/serialize": "^1.1.1" @@ -2202,7 +2271,7 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -2225,7 +2294,7 @@ "version": "1.0.23", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.23.tgz", "integrity": "sha512-YEmgyklR6l/oKUltidNVYdjSmLSW88vMsKx0pmiS3r71s8ZZRpd8A0Yf0U+6p/RzElmMnPBv27hNWjDQMSZRtQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -2245,7 +2314,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -2311,7 +2380,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz", "integrity": "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "type": "github", @@ -2647,21 +2716,21 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", "license": "MIT", "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.3", + "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, @@ -4084,7 +4153,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@kwsites/file-exists": { @@ -4138,7 +4207,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -4152,7 +4221,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 8" @@ -4162,7 +4231,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -8352,13 +8421,13 @@ } }, "node_modules/@wordpress/a11y": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/a11y/-/a11y-4.37.0.tgz", - "integrity": "sha512-OxJL0sBNy2IwFkrLv0X9tOgmdHbvgVajciN8T73S6jTh96iOmdISSb3n+I9fc81X0BB03rv4dh8q6zBb/67U6A==", + "version": "4.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/a11y/-/a11y-4.39.0.tgz", + "integrity": "sha512-uFy3FIF6MOo67tTVC2SaNyBQbFafu+DRirt2/IUQlY7w2MOiXWPaQFi3Oyy81gc8TfsooSFBlK4lrujd7O4gEw==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/dom-ready": "^4.37.0", - "@wordpress/i18n": "^6.10.0" + "@wordpress/dom-ready": "^4.39.0", + "@wordpress/i18n": "^6.12.0" }, "engines": { "node": ">=18.12.0", @@ -8692,9 +8761,9 @@ } }, "node_modules/@wordpress/base-styles": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/@wordpress/base-styles/-/base-styles-6.13.0.tgz", - "integrity": "sha512-+APLd5GqzzJ/atVVs3LGPcCRRy8mVfVQi1QY+cseNAQbRe4LvsDarLbzkblWEwuksxgUGmVGDC3fDNxrwszJ2A==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@wordpress/base-styles/-/base-styles-6.15.0.tgz", + "integrity": "sha512-AJ569cP6WqG0/RWx+x7MDNFxnCrZ5Pz1dHCLSE+7B0lLMXh3RGC1+Fj0YIZtUQ2ULLf2I3LRn6kuHdwyWNGiNA==", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -9180,19 +9249,19 @@ } }, "node_modules/@wordpress/compose": { - "version": "7.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-7.37.0.tgz", - "integrity": "sha512-MF3HETEL/gd7AGZ8dmswZujx/vCUD2JtJEHDb0bW+h5JE4xi/RJOP+Nh5K4LxFS7wKBPga8yGU8m+8QXH44R+g==", + "version": "7.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-7.39.0.tgz", + "integrity": "sha512-9VyaqEDojUEnXKVxNamamEyYrWuI9vc2r33iV7hAe+8W0ISujRAFXp/baDiSOlYAcsbauRD4yAkEXuM0C4Vyhg==", "license": "GPL-2.0-or-later", "dependencies": { "@types/mousetrap": "^1.6.8", - "@wordpress/deprecated": "^4.37.0", - "@wordpress/dom": "^4.37.0", - "@wordpress/element": "^6.37.0", - "@wordpress/is-shallow-equal": "^5.37.0", - "@wordpress/keycodes": "^4.37.0", - "@wordpress/priority-queue": "^3.37.0", - "@wordpress/undo-manager": "^1.37.0", + "@wordpress/deprecated": "^4.39.0", + "@wordpress/dom": "^4.39.0", + "@wordpress/element": "^6.39.0", + "@wordpress/is-shallow-equal": "^5.39.0", + "@wordpress/keycodes": "^4.39.0", + "@wordpress/priority-queue": "^3.39.0", + "@wordpress/undo-manager": "^1.39.0", "change-case": "^4.1.2", "clipboard": "^2.0.11", "mousetrap": "^1.6.5", @@ -9244,18 +9313,18 @@ } }, "node_modules/@wordpress/data": { - "version": "10.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/data/-/data-10.37.0.tgz", - "integrity": "sha512-6bKkEoD5WR/lCmJogx9WxgldhMQPvgV1TlCIXhx6xp9uVzqjjgdRmSwZ8IJR13QQ9GGHn7vWb59GtW4lF2FMNA==", + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/data/-/data-10.39.0.tgz", + "integrity": "sha512-L04X/Vawzx6RgvvSQga8JhvPxr1A6seBrWJoRBBP7+1zdCV7nmmR8339yTEPRnCH6m70qb0xSwTByUmTzWYzLg==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/compose": "^7.37.0", - "@wordpress/deprecated": "^4.37.0", - "@wordpress/element": "^6.37.0", - "@wordpress/is-shallow-equal": "^5.37.0", - "@wordpress/priority-queue": "^3.37.0", - "@wordpress/private-apis": "^1.37.0", - "@wordpress/redux-routine": "^5.37.0", + "@wordpress/compose": "^7.39.0", + "@wordpress/deprecated": "^4.39.0", + "@wordpress/element": "^6.39.0", + "@wordpress/is-shallow-equal": "^5.39.0", + "@wordpress/priority-queue": "^3.39.0", + "@wordpress/private-apis": "^1.39.0", + "@wordpress/redux-routine": "^5.39.0", "deepmerge": "^4.3.0", "equivalent-key-map": "^0.2.2", "is-plain-object": "^5.0.0", @@ -9273,27 +9342,29 @@ } }, "node_modules/@wordpress/dataviews": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/@wordpress/dataviews/-/dataviews-11.1.0.tgz", - "integrity": "sha512-gu5UzMH4jSOH9wXT/3C7tRoo8eTLsqgoAq38ND3ERFqwO9hEPmkPRK+4JFW6mkckTHjSOc173cJXrV2wIXd2+Q==", + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@wordpress/dataviews/-/dataviews-11.3.0.tgz", + "integrity": "sha512-y3IT733p3ji7WoXm67WNWvy79bnSML3cMo2qvL1XPLl0cI1p6V7P2WC3TGRJdFKQl72I0Lph8pnRPtlUHJwwkw==", "license": "GPL-2.0-or-later", "dependencies": { "@ariakit/react": "^0.4.15", - "@wordpress/base-styles": "^6.13.0", - "@wordpress/components": "^31.0.0", - "@wordpress/compose": "^7.37.0", - "@wordpress/data": "^10.37.0", - "@wordpress/date": "^5.37.0", - "@wordpress/deprecated": "^4.37.0", - "@wordpress/dom": "^4.37.0", - "@wordpress/element": "^6.37.0", - "@wordpress/i18n": "^6.10.0", - "@wordpress/icons": "^11.4.0", - "@wordpress/keycodes": "^4.37.0", - "@wordpress/primitives": "^4.37.0", - "@wordpress/private-apis": "^1.37.0", - "@wordpress/url": "^4.37.0", - "@wordpress/warning": "^3.37.0", + "@wordpress/base-styles": "^6.15.0", + "@wordpress/components": "^32.1.0", + "@wordpress/compose": "^7.39.0", + "@wordpress/data": "^10.39.0", + "@wordpress/date": "^5.39.0", + "@wordpress/deprecated": "^4.39.0", + "@wordpress/dom": "^4.39.0", + "@wordpress/element": "^6.39.0", + "@wordpress/i18n": "^6.12.0", + "@wordpress/icons": "^11.6.0", + "@wordpress/keycodes": "^4.39.0", + "@wordpress/primitives": "^4.39.0", + "@wordpress/private-apis": "^1.39.0", + "@wordpress/theme": "^0.6.0", + "@wordpress/ui": "^0.6.0", + "@wordpress/url": "^4.39.0", + "@wordpress/warning": "^3.39.0", "clsx": "^2.1.1", "colord": "^2.7.0", "date-fns": "^4.1.0", @@ -9311,9 +9382,9 @@ } }, "node_modules/@wordpress/dataviews/node_modules/@wordpress/components": { - "version": "31.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-31.0.0.tgz", - "integrity": "sha512-2Oz4r+4KCDTV5fMC2l9bDYfRAsLQLs29fLB4PelckW+X3KhhFM6gz5vx5N7hO3WLY19Fn6qKZgtgJjOKWG7+1w==", + "version": "32.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-32.1.0.tgz", + "integrity": "sha512-Q4dUTWhVqV4pgW3AX9DaAwC4qsG5xiU1x9zVjo8Tm/B275hSWjumqN+spEe97T1QWx7b2RBAWK/OKVaIjkePTw==", "license": "GPL-2.0-or-later", "dependencies": { "@ariakit/react": "^0.4.15", @@ -9327,28 +9398,30 @@ "@floating-ui/react-dom": "2.0.8", "@types/gradient-parser": "1.1.0", "@types/highlight-words-core": "1.2.1", + "@types/react": "^18.3.27", "@use-gesture/react": "^10.3.1", - "@wordpress/a11y": "^4.37.0", - "@wordpress/base-styles": "^6.13.0", - "@wordpress/compose": "^7.37.0", - "@wordpress/date": "^5.37.0", - "@wordpress/deprecated": "^4.37.0", - "@wordpress/dom": "^4.37.0", - "@wordpress/element": "^6.37.0", - "@wordpress/escape-html": "^3.37.0", - "@wordpress/hooks": "^4.37.0", - "@wordpress/html-entities": "^4.37.0", - "@wordpress/i18n": "^6.10.0", - "@wordpress/icons": "^11.4.0", - "@wordpress/is-shallow-equal": "^5.37.0", - "@wordpress/keycodes": "^4.37.0", - "@wordpress/primitives": "^4.37.0", - "@wordpress/private-apis": "^1.37.0", - "@wordpress/rich-text": "^7.37.0", - "@wordpress/warning": "^3.37.0", + "@wordpress/a11y": "^4.39.0", + "@wordpress/base-styles": "^6.15.0", + "@wordpress/compose": "^7.39.0", + "@wordpress/date": "^5.39.0", + "@wordpress/deprecated": "^4.39.0", + "@wordpress/dom": "^4.39.0", + "@wordpress/element": "^6.39.0", + "@wordpress/escape-html": "^3.39.0", + "@wordpress/hooks": "^4.39.0", + "@wordpress/html-entities": "^4.39.0", + "@wordpress/i18n": "^6.12.0", + "@wordpress/icons": "^11.6.0", + "@wordpress/is-shallow-equal": "^5.39.0", + "@wordpress/keycodes": "^4.39.0", + "@wordpress/primitives": "^4.39.0", + "@wordpress/private-apis": "^1.39.0", + "@wordpress/rich-text": "^7.39.0", + "@wordpress/warning": "^3.39.0", "change-case": "^4.1.2", "clsx": "^2.1.1", "colord": "^2.7.0", + "csstype": "^3.2.3", "date-fns": "^3.6.0", "deepmerge": "^4.3.0", "fast-deep-equal": "^3.1.3", @@ -9359,7 +9432,7 @@ "memize": "^2.1.0", "path-to-regexp": "^6.2.1", "re-resizable": "^6.4.0", - "react-colorful": "^5.3.1", + "react-colorful": "^5.6.1", "react-day-picker": "^9.7.0", "remove-accents": "^0.5.0", "uuid": "^9.0.1" @@ -9394,12 +9467,12 @@ } }, "node_modules/@wordpress/date": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/date/-/date-5.37.0.tgz", - "integrity": "sha512-T5YF5WLQu71bgw/KXhKcIqIRmAyf6nCq7J448MZlPIr0M9DAVPARXCOxYA4t/FW3PzwpFUARIuO2aNXTTO+nsA==", + "version": "5.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/date/-/date-5.39.0.tgz", + "integrity": "sha512-Wy8z+I0ZQjSTP/S4M9JqaDEUuucxGy70g+I8UCOFRgDFIO5v4hZVKo1uOH0NO5gGdlhmg4Scb9l9uzYVp8oqmg==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/deprecated": "^4.37.0", + "@wordpress/deprecated": "^4.39.0", "moment": "^2.29.4", "moment-timezone": "^0.5.40" }, @@ -9433,12 +9506,12 @@ "license": "BSD" }, "node_modules/@wordpress/deprecated": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/deprecated/-/deprecated-4.37.0.tgz", - "integrity": "sha512-QCV1akN9TXq7uRMsFQh0NyO4oHZvNP5NJWp1MSia1iqq8yLhMjcLaXVvMTnnJ2rQnVey0V0600f3BZZUyiZtEA==", + "version": "4.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/deprecated/-/deprecated-4.39.0.tgz", + "integrity": "sha512-/vTXUsh2MGOuh8nhzgpBKIKsbgdtgjE2hFiCPw8rP4wwEbKUlxhNdtZdqkaumNtZywfHbRUBWO9xTpAVX17XfA==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/hooks": "^4.37.0" + "@wordpress/hooks": "^4.39.0" }, "engines": { "node": ">=18.12.0", @@ -9446,12 +9519,12 @@ } }, "node_modules/@wordpress/dom": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/dom/-/dom-4.37.0.tgz", - "integrity": "sha512-OY78iz+3bkkjOygE7pZ8Z4gSIfm+d72W6WOx5/LCuaiCKfZN2RvKzbS9r9ML5/gGgeWmhTIbwSMx6YSBIBXsXw==", + "version": "4.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/dom/-/dom-4.39.0.tgz", + "integrity": "sha512-PCZVqTIV3V797rjWxZYoBJ+5g8girPZB0GzKZWxgsB6Y/+bS5OvBCJ70FgeZwO7oReo7ktXVNz/ZQMb5JsPosw==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/deprecated": "^4.37.0" + "@wordpress/deprecated": "^4.39.0" }, "engines": { "node": ">=18.12.0", @@ -9459,9 +9532,9 @@ } }, "node_modules/@wordpress/dom-ready": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/dom-ready/-/dom-ready-4.37.0.tgz", - "integrity": "sha512-igored8VegL2n/koKIyUhgPLhUfTa4N6zWO4gZpyeznr49M5wP/9Ak/tvIljUts9McHc2CVnCYMjZV0Zyz2aWg==", + "version": "4.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/dom-ready/-/dom-ready-4.39.0.tgz", + "integrity": "sha512-qHhRnlSK0E2GTMo1D2gOtcr9FW11HG8X8lZLkmf3N0zhjem+MP7G15jFB7wophpypL3plnBHMxiDD6qUHh9dSg==", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -9729,14 +9802,14 @@ } }, "node_modules/@wordpress/element": { - "version": "6.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-6.37.0.tgz", - "integrity": "sha512-8+hvjtbsPX1Jz55a5uJi6o8jNOaGlAUwV55lUJsH+iE3OHA6PyE6r9atosGRRHvfXPDlKA5ckfbrtoh7h586GA==", + "version": "6.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-6.39.0.tgz", + "integrity": "sha512-yVEjTddIVEsYjwRCY1pEzav/dGVH9S1xfwYwRyGsUGOQw5hXtIVnTbCIfQ2t3OCOGgSIYEh2ZmsSt0eTxZvtBw==", "license": "GPL-2.0-or-later", "dependencies": { - "@types/react": "^18.2.79", - "@types/react-dom": "^18.2.25", - "@wordpress/escape-html": "^3.37.0", + "@types/react": "^18.3.27", + "@types/react-dom": "^18.3.1", + "@wordpress/escape-html": "^3.39.0", "change-case": "^4.1.2", "is-plain-object": "^5.0.0", "react": "^18.3.0", @@ -9776,9 +9849,9 @@ } }, "node_modules/@wordpress/escape-html": { - "version": "3.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/escape-html/-/escape-html-3.37.0.tgz", - "integrity": "sha512-hJ2yytDPaZ7Gx+Zj+1iUBzZYED+323MTFbkpydJecWA48K+cNyutEEuHPi9bzmJXirI0YSnkN5i1tZoYQPTiGQ==", + "version": "3.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/escape-html/-/escape-html-3.39.0.tgz", + "integrity": "sha512-YhAmZYQsOnnfafMwInzy/M1AR77O59u4aaIVSqiQjvCLkY+BQABsU6AizDckCg57mF2SoDnARl6xQY/7FCwEmw==", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -10083,9 +10156,9 @@ } }, "node_modules/@wordpress/hooks": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.37.0.tgz", - "integrity": "sha512-MJpPAT7hQZS5JBnQm4/f5bHSETofGOw5zt7/mNoSEby5z3yTiIyEmBmzNo4Lu1xzIiU+g0OGmkBaOvn42LBibg==", + "version": "4.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.39.0.tgz", + "integrity": "sha512-FTKdGF5jHHmC8GSO6/ATQqh1IFQeDwapRtlp7t4VaTGwZtX+uzawgq/7QDIhFi3cfg9hNsFF0CSFp/Ul3nEeUA==", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -10093,9 +10166,9 @@ } }, "node_modules/@wordpress/html-entities": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/html-entities/-/html-entities-4.37.0.tgz", - "integrity": "sha512-d3uaAoGs20xpvdOTWlpTbxO4a8YwKYyBjoNhkL+w9qAg8NqLk9r2Z1pTj4EbYF9iS6SorDUdnXsTMuZ0/pm4Sw==", + "version": "4.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/html-entities/-/html-entities-4.39.0.tgz", + "integrity": "sha512-gKpDo9vXxws4L03x6JYal8zrv+HjtQ4KMicpUdsHY8hhH5Asid2UGOBkCA1pQ1j9KVSWWj+3RsF0Ay1lem06Fw==", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -10103,13 +10176,13 @@ } }, "node_modules/@wordpress/i18n": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-6.10.0.tgz", - "integrity": "sha512-5tLAtnRQNxzA/d0GvVWCyo34Jb18w7xWTnup8hlh1+ehp7ZYTWR1QJihzVAteHoyrxAbTmzzsKyNtr8m+4ZpSQ==", + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-6.12.0.tgz", + "integrity": "sha512-KMleg8p/HtnoX1d/WoRDI51VTZsA4RGNUvBYn+Cc3avaeeNKROb91+viMcOc8NHuLplEzl7zH9/mrOSs9aY3rg==", "license": "GPL-2.0-or-later", "dependencies": { "@tannin/sprintf": "^1.3.2", - "@wordpress/hooks": "^4.37.0", + "@wordpress/hooks": "^4.39.0", "gettext-parser": "^1.3.1", "memize": "^2.1.0", "tannin": "^1.2.0" @@ -10123,13 +10196,13 @@ } }, "node_modules/@wordpress/icons": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/@wordpress/icons/-/icons-11.4.0.tgz", - "integrity": "sha512-3sis5bwTyDOrLy83Mp799NNJuEgbTOWwHpxnGNmabEDA3BbBYPnr0soSGSFe201nqNKiVwL/TcpRaQ91SoEHTQ==", + "version": "11.6.0", + "resolved": "https://registry.npmjs.org/@wordpress/icons/-/icons-11.6.0.tgz", + "integrity": "sha512-X3Tp3ARJWRokFOTJ1SJQef3J+bykyZHuHL6NSaMnEtbQzs7Tre+3HEghP5S5K/AGnLr+RvpGnwEN0uNy53uIiA==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/element": "^6.37.0", - "@wordpress/primitives": "^4.37.0" + "@wordpress/element": "^6.39.0", + "@wordpress/primitives": "^4.39.0" }, "engines": { "node": ">=18.12.0", @@ -10283,9 +10356,9 @@ } }, "node_modules/@wordpress/is-shallow-equal": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/is-shallow-equal/-/is-shallow-equal-5.37.0.tgz", - "integrity": "sha512-aOW5Yw0uiuekmVb3KAkoWnCopBIOUOiL4XcSWAcSgRxUmtxOg6CE7M9mb5LTI35MUeQj0yay3NVyIXf5Z16LMA==", + "version": "5.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/is-shallow-equal/-/is-shallow-equal-5.39.0.tgz", + "integrity": "sha512-v/yDkQj/VpdR8y4b0xNTeuFkfdU2AYf/0YABbry6GB6zG+mkPgi2+cglQ681V621korKOX6JRAsMQuRX6D2UNA==", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -10347,12 +10420,12 @@ } }, "node_modules/@wordpress/keycodes": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-4.37.0.tgz", - "integrity": "sha512-VPysLigCr6J15oMkI5YLbIM7n9D9uNTtbJpw8/SgX4gOaamfH3nH/hUJeV440JlshwX13p+hPILHq4zpOoBntg==", + "version": "4.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-4.39.0.tgz", + "integrity": "sha512-RN7Py7vvvmBOGuRM8X4hHOvXXG57jWDW9pIXUnVGc33EvTrwtnr16f8Xt4nKWs8L/UNUdrrAtA2HAeqRkdxr+A==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/i18n": "^6.10.0" + "@wordpress/i18n": "^6.12.0" }, "engines": { "node": ">=18.12.0", @@ -10780,21 +10853,21 @@ } }, "node_modules/@wordpress/preferences": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/preferences/-/preferences-4.37.0.tgz", - "integrity": "sha512-+GOmfe+i47SA74zDi+j6jYxoI0sZv2056tDbf+uGBF8rOEceQaLEhs+24fY/shAJV3Umf09yltXph5kdfs05/w==", + "version": "4.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/preferences/-/preferences-4.39.0.tgz", + "integrity": "sha512-4rUwrdglMKxGrru5l/JTEKsv7/ncsVQFxMtlG+VmFV2f/x/WboDDz2VGQwRO2/bOTyOM4KZJpZxeJ7E1Lo2B0A==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/a11y": "^4.37.0", - "@wordpress/base-styles": "^6.13.0", - "@wordpress/components": "^31.0.0", - "@wordpress/compose": "^7.37.0", - "@wordpress/data": "^10.37.0", - "@wordpress/deprecated": "^4.37.0", - "@wordpress/element": "^6.37.0", - "@wordpress/i18n": "^6.10.0", - "@wordpress/icons": "^11.4.0", - "@wordpress/private-apis": "^1.37.0", + "@wordpress/a11y": "^4.39.0", + "@wordpress/base-styles": "^6.15.0", + "@wordpress/components": "^32.1.0", + "@wordpress/compose": "^7.39.0", + "@wordpress/data": "^10.39.0", + "@wordpress/deprecated": "^4.39.0", + "@wordpress/element": "^6.39.0", + "@wordpress/i18n": "^6.12.0", + "@wordpress/icons": "^11.6.0", + "@wordpress/private-apis": "^1.39.0", "clsx": "^2.1.1" }, "engines": { @@ -10807,9 +10880,9 @@ } }, "node_modules/@wordpress/preferences/node_modules/@wordpress/components": { - "version": "31.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-31.0.0.tgz", - "integrity": "sha512-2Oz4r+4KCDTV5fMC2l9bDYfRAsLQLs29fLB4PelckW+X3KhhFM6gz5vx5N7hO3WLY19Fn6qKZgtgJjOKWG7+1w==", + "version": "32.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-32.1.0.tgz", + "integrity": "sha512-Q4dUTWhVqV4pgW3AX9DaAwC4qsG5xiU1x9zVjo8Tm/B275hSWjumqN+spEe97T1QWx7b2RBAWK/OKVaIjkePTw==", "license": "GPL-2.0-or-later", "dependencies": { "@ariakit/react": "^0.4.15", @@ -10823,28 +10896,30 @@ "@floating-ui/react-dom": "2.0.8", "@types/gradient-parser": "1.1.0", "@types/highlight-words-core": "1.2.1", + "@types/react": "^18.3.27", "@use-gesture/react": "^10.3.1", - "@wordpress/a11y": "^4.37.0", - "@wordpress/base-styles": "^6.13.0", - "@wordpress/compose": "^7.37.0", - "@wordpress/date": "^5.37.0", - "@wordpress/deprecated": "^4.37.0", - "@wordpress/dom": "^4.37.0", - "@wordpress/element": "^6.37.0", - "@wordpress/escape-html": "^3.37.0", - "@wordpress/hooks": "^4.37.0", - "@wordpress/html-entities": "^4.37.0", - "@wordpress/i18n": "^6.10.0", - "@wordpress/icons": "^11.4.0", - "@wordpress/is-shallow-equal": "^5.37.0", - "@wordpress/keycodes": "^4.37.0", - "@wordpress/primitives": "^4.37.0", - "@wordpress/private-apis": "^1.37.0", - "@wordpress/rich-text": "^7.37.0", - "@wordpress/warning": "^3.37.0", + "@wordpress/a11y": "^4.39.0", + "@wordpress/base-styles": "^6.15.0", + "@wordpress/compose": "^7.39.0", + "@wordpress/date": "^5.39.0", + "@wordpress/deprecated": "^4.39.0", + "@wordpress/dom": "^4.39.0", + "@wordpress/element": "^6.39.0", + "@wordpress/escape-html": "^3.39.0", + "@wordpress/hooks": "^4.39.0", + "@wordpress/html-entities": "^4.39.0", + "@wordpress/i18n": "^6.12.0", + "@wordpress/icons": "^11.6.0", + "@wordpress/is-shallow-equal": "^5.39.0", + "@wordpress/keycodes": "^4.39.0", + "@wordpress/primitives": "^4.39.0", + "@wordpress/private-apis": "^1.39.0", + "@wordpress/rich-text": "^7.39.0", + "@wordpress/warning": "^3.39.0", "change-case": "^4.1.2", "clsx": "^2.1.1", "colord": "^2.7.0", + "csstype": "^3.2.3", "date-fns": "^3.6.0", "deepmerge": "^4.3.0", "fast-deep-equal": "^3.1.3", @@ -10855,7 +10930,7 @@ "memize": "^2.1.0", "path-to-regexp": "^6.2.1", "re-resizable": "^6.4.0", - "react-colorful": "^5.3.1", + "react-colorful": "^5.6.1", "react-day-picker": "^9.7.0", "remove-accents": "^0.5.0", "uuid": "^9.0.1" @@ -10884,12 +10959,12 @@ } }, "node_modules/@wordpress/primitives": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/primitives/-/primitives-4.37.0.tgz", - "integrity": "sha512-iPpiS1tu1U5cXxVW6CQ45rqdnIc4Ev5FGyicuuaru0wboC+d2CwoNHxPFDOOpGL16yp8OfSDA13vQY3MJHw7QA==", + "version": "4.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/primitives/-/primitives-4.39.0.tgz", + "integrity": "sha512-RV9s+KzyuS8p0YKczLecmhFAtOHIWkCToHyDSmRf8N3XOy4id/T0+B5SJ2nMZJleG1oYINf5lrVcccqP2VmFJg==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/element": "^6.37.0", + "@wordpress/element": "^6.39.0", "clsx": "^2.1.1" }, "engines": { @@ -10901,9 +10976,9 @@ } }, "node_modules/@wordpress/priority-queue": { - "version": "3.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/priority-queue/-/priority-queue-3.37.0.tgz", - "integrity": "sha512-9psU2Sb498WvZNpZkXS0m7JlFdOAp4Ohcj1BfRDDITyWH1xkRhjbp91sPsZGYtaJuDTxa8QEMyb2SSNCqcsRbQ==", + "version": "3.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/priority-queue/-/priority-queue-3.39.0.tgz", + "integrity": "sha512-IyiB5y55dIYJZnC5Vl3v2cKuRDR1hYSEA++fijpD4AJZTu+2bhtdN9PnmFE9T25WGzWqnfJEdBSHPglby9MhRA==", "license": "GPL-2.0-or-later", "dependencies": { "requestidlecallback": "^0.3.0" @@ -10914,9 +10989,9 @@ } }, "node_modules/@wordpress/private-apis": { - "version": "1.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-1.37.0.tgz", - "integrity": "sha512-BR5GEHontWnza1tfBm2aX6/GjCZ1xZRrRNN1P0oj9xuvtut3YzCr//pZuyQ+P5maByDthUZjNrvN3UEF1iucbA==", + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-1.39.0.tgz", + "integrity": "sha512-ssMAzKtjPV/T1IEFWNSVeaecKG3mhRHYHPMy2Ajh7V8RpOCyYBOZ48kZGyOwd25uzQX5k634tmfW27qJ/SMVQA==", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -10924,9 +10999,9 @@ } }, "node_modules/@wordpress/redux-routine": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/redux-routine/-/redux-routine-5.37.0.tgz", - "integrity": "sha512-gavsOxTobcOquwl9Kpra0qR30H0vQPK1Rw+K7GDK9bNyJ+/9t4Jio0F6uVN3uQg48h/HomgRX86KCrTET9ntNA==", + "version": "5.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/redux-routine/-/redux-routine-5.39.0.tgz", + "integrity": "sha512-XvAn9I/rfVWpv9KSKLc24mYqfVHJ4o9PiwcHQb6FyJyeVvCbrHpTFN1iAaXSsBSTCs6mMVKNuZFZv7Sg7c++bg==", "license": "GPL-2.0-or-later", "dependencies": { "is-plain-object": "^5.0.0", @@ -11033,19 +11108,20 @@ } }, "node_modules/@wordpress/rich-text": { - "version": "7.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/rich-text/-/rich-text-7.37.0.tgz", - "integrity": "sha512-uvLLVg77F4meoMMYjtdYcvo4eQxj3mKH8f9dKnkCrOyKOKNCdV5wHDUYWrySkgrAyoBJjTm8hZKK/U3adlPgEg==", + "version": "7.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/rich-text/-/rich-text-7.39.0.tgz", + "integrity": "sha512-4wpQyyaZ9vmInRk0oiBpPFmLtGn6w+ejrQvaCeDeOAeA+IYdEvrTMEbGp7NhYZ9llZvEqEQ8/yNcoAczE4DLSg==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/a11y": "^4.37.0", - "@wordpress/compose": "^7.37.0", - "@wordpress/data": "^10.37.0", - "@wordpress/deprecated": "^4.37.0", - "@wordpress/element": "^6.37.0", - "@wordpress/escape-html": "^3.37.0", - "@wordpress/i18n": "^6.10.0", - "@wordpress/keycodes": "^4.37.0", + "@wordpress/a11y": "^4.39.0", + "@wordpress/compose": "^7.39.0", + "@wordpress/data": "^10.39.0", + "@wordpress/deprecated": "^4.39.0", + "@wordpress/dom": "^4.39.0", + "@wordpress/element": "^6.39.0", + "@wordpress/escape-html": "^3.39.0", + "@wordpress/i18n": "^6.12.0", + "@wordpress/keycodes": "^4.39.0", "colord": "2.9.3", "memize": "^2.1.0" }, @@ -11366,6 +11442,32 @@ "npm": ">=8.19.2" } }, + "node_modules/@wordpress/theme": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@wordpress/theme/-/theme-0.6.0.tgz", + "integrity": "sha512-n/O1djUn+jny46JyqCwD77nPV4zCUBIn+0ICp8fDYLXpQ7FfCfCrEfhlkQkcVB45KC1iu6IMAsLOA/9hzavHoQ==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/element": "^6.39.0", + "@wordpress/private-apis": "^1.39.0", + "colorjs.io": "^0.6.0", + "memize": "^2.1.0" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0", + "stylelint": "^16.8.2" + }, + "peerDependenciesMeta": { + "stylelint": { + "optional": true + } + } + }, "node_modules/@wordpress/token-list": { "version": "3.37.0", "resolved": "https://registry.npmjs.org/@wordpress/token-list/-/token-list-3.37.0.tgz", @@ -11376,13 +11478,38 @@ "npm": ">=8.19.2" } }, + "node_modules/@wordpress/ui": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@wordpress/ui/-/ui-0.6.0.tgz", + "integrity": "sha512-KV7JkQ4bvuCWZ2+82jnhzthomCIS7wO8+6cSrkhQUgeVbp0TdGCXCFHwKXfw1O/P/S6IClqpTFgInXFz/gA1DQ==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@base-ui/react": "^1.0.0", + "@wordpress/a11y": "^4.39.0", + "@wordpress/element": "^6.39.0", + "@wordpress/i18n": "^6.12.0", + "@wordpress/icons": "^11.6.0", + "@wordpress/primitives": "^4.39.0", + "@wordpress/private-apis": "^1.39.0", + "@wordpress/theme": "^0.6.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/@wordpress/undo-manager": { - "version": "1.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/undo-manager/-/undo-manager-1.37.0.tgz", - "integrity": "sha512-grx0GdEHMgIBj8RHym+FcK/hB4wksQ/ErStFFRCIDhew1i5wAF/boNkxBoGgI42yO5ofSAolcTlGgRCpwTzG5g==", + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/undo-manager/-/undo-manager-1.39.0.tgz", + "integrity": "sha512-LcCqVZk3K6tltAuUB1gXyo7lAbS48WP/RsRLrm4GBchY+kQwUT+kme30zCrRfsEJJaYCtfcGo1POIgda/HwHpw==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/is-shallow-equal": "^5.37.0" + "@wordpress/is-shallow-equal": "^5.39.0" }, "engines": { "node": ">=18.12.0", @@ -11416,9 +11543,9 @@ } }, "node_modules/@wordpress/url": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-4.37.0.tgz", - "integrity": "sha512-8ofI1OzPON9twQIPczG5WfAtef5hhfZY+FB6cPmwDT5BftQGGO/+v0cHge7pgrRQftSzj4iQ+fQXIdEpIFacgw==", + "version": "4.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-4.39.0.tgz", + "integrity": "sha512-pWOtqLcApB1EZnD2am/vMHxkCLgHzpysUypP8N8jz+USdLyOKy7vaY3v94+HAL1M9HBoT9VpllWEg+LxsO/eqQ==", "license": "GPL-2.0-or-later", "dependencies": { "remove-accents": "^0.5.0" @@ -11446,10 +11573,27 @@ "react": "^18.0.0" } }, + "node_modules/@wordpress/views": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@wordpress/views/-/views-1.6.0.tgz", + "integrity": "sha512-M1GTCktDgi7dtS1jdWByFUfVrcFl/eoMzhMACVZEGw3r/KAhRSP8AqVG2B+nRCFUFMa/YUAdsHSDdwOoOZotYw==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/data": "^10.39.0", + "@wordpress/dataviews": "^11.3.0", + "@wordpress/element": "^6.39.0", + "@wordpress/preferences": "^4.39.0", + "dequal": "^2.0.3" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "node_modules/@wordpress/warning": { - "version": "3.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-3.37.0.tgz", - "integrity": "sha512-oXWyKiYJIa9SuPRNEJiOWn2Qk0RzfxOsDqXcus1OL44swCRtSM+ypm16CJpRhZpMUcsJ6d23PBxTC97C/iiJpQ==", + "version": "3.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-3.39.0.tgz", + "integrity": "sha512-NV2WoU4XxTqZU/3k2D5m5cHIw/uM2dlDgW5u1U5fmFIYLGg43WWMlSHQEu6F4tm7fqFt7k4qwT/FymucqHYp7Q==", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -11831,7 +11975,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -11841,7 +11985,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -11980,7 +12124,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -12140,7 +12284,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -12725,7 +12869,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -12862,7 +13006,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.1.tgz", "integrity": "sha512-yr+FSHWn1ZUou5LkULX/S+jhfgfnLbuKQjE40tyEd4fxGZVMbBL5ifno0J0OauykS8UiCSgHi+DV/YD+rjFxFg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@cacheable/memory": "^2.0.6", @@ -12905,7 +13049,7 @@ "version": "5.5.5", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.5.tgz", "integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@keyv/serialize": "^1.1.1" @@ -13439,7 +13583,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -13452,7 +13596,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/colord": { @@ -13468,6 +13612,16 @@ "dev": true, "license": "MIT" }, + "node_modules/colorjs.io": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.6.1.tgz", + "integrity": "sha512-8lyR2wHzuIykCpqHKgluGsqQi5iDm3/a2IgP2GBZrasn2sBRkE4NOGsglZxWLs/jZQoNkmA/KM/8NV16rLUdBg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/color" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -13916,7 +14070,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.3.tgz", "integrity": "sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12 || >=16" @@ -13992,7 +14146,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "mdn-data": "2.12.2", @@ -14019,7 +14173,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -14642,7 +14796,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "path-type": "^4.0.0" @@ -14971,7 +15125,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -16435,7 +16589,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -16452,7 +16606,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -16479,7 +16633,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -16496,7 +16650,7 @@ "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 4.9.1" @@ -16506,7 +16660,7 @@ "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -16591,7 +16745,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -16768,7 +16922,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/follow-redirects": { @@ -17359,7 +17513,7 @@ "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "array-union": "^2.1.0", @@ -17380,7 +17534,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", "integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/good-listener": { @@ -17503,7 +17657,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -17571,7 +17725,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.4.0.tgz", "integrity": "sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "hookified": "^1.14.0" @@ -17649,7 +17803,7 @@ "version": "1.15.0", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.0.tgz", "integrity": "sha512-51w+ZZGt7Zw5q7rM3nC4t3aLn/xvKDETsXqMczndvwyVQhAHfUmUuFBRFcos8Iyebtk7OAE9dL26wFNzZVVOkw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/hosted-git-info": { @@ -17745,7 +17899,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -17962,7 +18116,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 4" @@ -18100,7 +18254,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.8.19" @@ -18138,7 +18292,7 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/internal-slot": { @@ -18438,7 +18592,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -18464,7 +18618,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -18504,7 +18658,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -18601,7 +18755,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -18883,7 +19037,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/isobject": { @@ -20077,7 +20231,7 @@ "version": "0.37.0", "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/language-subtag-registry": { @@ -20507,7 +20661,7 @@ "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/lodash.uniq": { @@ -20819,7 +20973,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", "integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "type": "github", @@ -20830,7 +20984,7 @@ "version": "2.12.2", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "dev": true, + "devOptional": true, "license": "CC0-1.0" }, "node_modules/mdurl": { @@ -20961,7 +21115,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 8" @@ -20988,7 +21142,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -21439,7 +21593,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -22370,7 +22524,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -23197,14 +23351,14 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz", "integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/postcss-safe-parser": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -23616,7 +23770,7 @@ "version": "0.5.3", "resolved": "https://registry.npmjs.org/qified/-/qified-0.5.3.tgz", "integrity": "sha512-kXuQdQTB6oN3KhI6V4acnBSZx8D2I4xzZvn9+wFLLFCoBNQY/sFnCW6c43OL7pOQ2HvGV4lnWIXNmgfp7cTWhQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "hookified": "^1.13.0" @@ -24216,7 +24370,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -24260,6 +24414,12 @@ "dev": true, "license": "MIT" }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -24328,7 +24488,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -24402,7 +24562,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -24547,7 +24707,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -25498,7 +25658,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": ">=14" @@ -25598,7 +25758,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -25608,7 +25768,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -26008,7 +26168,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -26046,7 +26206,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/string.prototype.includes": { @@ -26166,7 +26326,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -26303,7 +26463,7 @@ "version": "16.26.1", "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.26.1.tgz", "integrity": "sha512-v20V59/crfc8sVTAtge0mdafI3AdnzQ2KsWe6v523L4OA1bJO02S7MO2oyXDCS6iWb9ckIPnqAFVItqSBQr7jw==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -26458,7 +26618,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.3.tgz", "integrity": "sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -26482,7 +26642,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -26505,21 +26665,21 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, + "devOptional": true, "license": "Python-2.0" }, "node_modules/stylelint/node_modules/balanced-match": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz", "integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/stylelint/node_modules/cosmiconfig": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.1", @@ -26546,7 +26706,7 @@ "version": "11.1.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.1.tgz", "integrity": "sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "flat-cache": "^6.1.19" @@ -26556,7 +26716,7 @@ "version": "6.1.19", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.19.tgz", "integrity": "sha512-l/K33newPTZMTGAnnzaiqSl6NnH7Namh8jBNjrgjprWxGmZUuxx/sJNIRaijOh3n7q7ESbhNZC+pvVZMFdeU4A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "cacheable": "^2.2.0", @@ -26568,7 +26728,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "global-prefix": "^3.0.0" @@ -26581,7 +26741,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ini": "^1.3.5", @@ -26596,7 +26756,7 @@ "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 4" @@ -26606,7 +26766,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -26619,7 +26779,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -26629,7 +26789,7 @@ "version": "13.2.0", "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -26642,7 +26802,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -26656,7 +26816,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -26669,7 +26829,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", @@ -26689,7 +26849,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -26702,7 +26862,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0", @@ -26738,7 +26898,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", - "dev": true + "devOptional": true }, "node_modules/svgo": { "version": "3.3.2", @@ -26820,11 +26980,17 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, "node_modules/table": { "version": "6.9.0", "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "ajv": "^8.0.1", @@ -26841,7 +27007,7 @@ "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -26858,7 +27024,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/tannin": { @@ -27302,7 +27468,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" diff --git a/package.json b/package.json index 33d47f65..675f6b11 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@wordpress/components": "^30.7.0", "@wordpress/compose": "^7.34.0", "@wordpress/data": "^10.34.0", + "@wordpress/dataviews": "^11.3.0", "@wordpress/edit-post": "^8.36.0", "@wordpress/editor": "^14.34.0", "@wordpress/element": "^6.34.0", @@ -60,6 +61,7 @@ "@wordpress/icons": "^11.1.0", "@wordpress/notices": "^5.35.0", "@wordpress/plugins": "^7.34.0", + "@wordpress/views": "^1.6.0", "react": "^18.3.1" }, "overrides": {