diff --git a/README.md b/README.md index 3f06b5d9..d57f66db 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 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/docs/experiments/ai-request-logging.md b/docs/experiments/ai-request-logging.md new file mode 100644 index 00000000..de83ae2f --- /dev/null +++ b/docs/experiments/ai-request-logging.md @@ -0,0 +1,95 @@ +# 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, 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 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. +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: + - 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_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). +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 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. +- REST endpoints require `manage_options`. +- Model cost estimates rely on the static pricing table inside `AI_Request_Cost_Calculator`; update it as provider pricing evolves. 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' ), + ), + ); + } +} diff --git a/includes/Experiment_Loader.php b/includes/Experiment_Loader.php index eef246ac..feef5a3b 100644 --- a/includes/Experiment_Loader.php +++ b/includes/Experiment_Loader.php @@ -106,6 +106,7 @@ private function get_default_experiments(): array { $experiment_classes = array( \WordPress\AI\Experiments\Abilities_Explorer\Abilities_Explorer::class, \WordPress\AI\Experiments\Excerpt_Generation\Excerpt_Generation::class, + \WordPress\AI\Experiments\AI_Request_Logging\AI_Request_Logging::class, \WordPress\AI\Experiments\Image_Generation\Image_Generation::class, \WordPress\AI\Experiments\Summarization\Summarization::class, \WordPress\AI\Experiments\Title_Generation\Title_Generation::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..44c3af59 --- /dev/null +++ b/includes/Experiments/AI_Request_Logging/AI_Request_Logging.php @@ -0,0 +1,156 @@ + '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(); + + // 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', + 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/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 new file mode 100644 index 00000000..18312b68 --- /dev/null +++ b/includes/Logging/Logging_Http_Transporter.php @@ -0,0 +1,129 @@ +transporter = $transporter; + $this->log_manager = $log_manager; + $this->extractor = $extractor ?? new Log_Data_Extractor(); + } + + /** + * Sends an HTTP request and returns the response, logging the request. + * + * @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 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->transporter->send( $request, $options ); + + $log_data = $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; + + // @phpstan-ignore argument.type (array shape is built incrementally) + $this->log_manager->log( $log_data ); + } + } + + /** + * Extracts logging data from the request. + * + * @param \WordPress\AiClient\Providers\Http\DTO\Request $request The SDK request. + * @return array Initial log data. + */ + private function extract_request_data( Request $request ): array { + return $this->extractor->extract_request_data( + $request->getUri(), + $request->getMethod()->value, + $request->getBody() + ); + } + + /** + * 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 augment. + * @return array Augmented log data. + */ + private function extract_response_data( Response $response, array $log_data ): array { + return $this->extractor->extract_response_data( + $response->getBody(), + $log_data + ); + } +} diff --git a/includes/Logging/Logging_Integration.php b/includes/Logging/Logging_Integration.php new file mode 100644 index 00000000..fc628a07 --- /dev/null +++ b/includes/Logging/Logging_Integration.php @@ -0,0 +1,113 @@ +is_logging_enabled() ) { + return; + } + + try { + $registry = AiClient::defaultRegistry(); + + // Get the current transporter. + $current_transporter = $registry->getHttpTransporter(); + + // @phpstan-ignore instanceof.alwaysTrue (defensive check for SDK compatibility) + 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/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/bootstrap.php b/includes/bootstrap.php index 4177abe3..8302bc3d 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; @@ -213,6 +215,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(); diff --git a/includes/helpers.php b/includes/helpers.php index 1f1b02db..23b9b69b 100644 --- a/includes/helpers.php +++ b/includes/helpers.php @@ -10,6 +10,8 @@ namespace WordPress\AI; use Throwable; +use WordPress\AI\Logging\AI_Request_Log_Manager; +use WordPress\AI\Settings\Settings_Registration; use WordPress\AI\Services\AI_Service; use WordPress\AI_Client\AI_Client; @@ -130,6 +132,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 x.x.x + * + * @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 for text generation. * @@ -141,11 +181,11 @@ function get_preferred_models_for_text_generation(): 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', @@ -153,7 +193,11 @@ function get_preferred_models_for_text_generation(): array { ), array( 'openai', - 'gpt-4.1', + 'gpt-4o', + ), + array( + 'google', + 'gemini-1.5-flash', ), ); @@ -165,7 +209,36 @@ function get_preferred_models_for_text_generation(): array { * @param array $preferred_models The preferred models for text generation. * @return array The filtered preferred models. */ - return (array) apply_filters( 'ai_experiments_preferred_models_for_text_generation', $preferred_models ); + $preferred_models = (array) apply_filters( 'ai_experiments_preferred_models_for_text_generation', $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'] + ); } /** @@ -300,10 +373,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; +} + +/** + * Get the shared AI request log manager instance. + * + * @since 0.1.0 + * + * @return AI_Request_Log_Manager|null + */ +function get_request_log_manager(): ?AI_Request_Log_Manager { + static $log_manager = null; + + 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( + '/=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": { 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 54f5e637..33adc83f 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/abilities-explorer': path.resolve( process.cwd(), 'src/experiments/abilities-explorer',