From 64cd9cde940e4034ecef9b24cb7d40492ffb146a Mon Sep 17 00:00:00 2001 From: pluginslab <57633278+pluginslab@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:29:13 +0100 Subject: [PATCH 1/2] feat: site owner configures the model source; no download address in the plugin (#228) WP.org has flagged WebLLM's built-in model download addresses in four review rounds (latest R agentic-admin/28May26/T5 27Sep26/4.3). The model files (1.2 GB+) and their compiled libraries can't ship in the plugin, so the download source becomes a site-owner setting, the same way the remote LLM endpoint already is. - tools/strip-webllm-prebuilt-loader.js: webpack loader that empties WebLLM's prebuiltAppConfig.model_list and modelLibURLPrefix. The build fails if the expected code is missing, so an upgrade can't bring the addresses back. Bundles now contain no huggingface.co or raw.githubusercontent.com address. - Option agentic_admin_model_source (weights_url, library_url), registered with show_in_rest so it saves through /wp/v2/settings (manage_options). No default. Sanitized with esc_url_raw (http/https) + trailing slash. Removed on uninstall. - model-source.js builds WebLLM's appConfig from the owner's addresses; model-loader passes it to CreateMLCEngine, CreateServiceWorkerMLCEngine and hasModelInCache. Loading is refused until a source is set. - Settings gets a "Model source" card; Load Model is disabled with a notice until a source is set. Remote and Connector engines are unaffected. - readme lists the MLC-AI addresses to paste, under External services. Co-Authored-By: Claude Opus 5.5 (1M context) --- includes/class-admin-page.php | 2 + includes/class-settings.php | 64 +++++++++ readme.txt | 14 +- src/extensions/components/ModelSourceCard.jsx | 134 ++++++++++++++++++ src/extensions/components/ModelStatus.jsx | 42 ++++-- src/extensions/components/SettingsTab.jsx | 2 + .../services/__tests__/model-source.test.js | 103 ++++++++++++++ src/extensions/services/model-loader.js | 18 ++- src/extensions/services/model-source.js | 121 ++++++++++++++++ tools/strip-webllm-prebuilt-loader.js | 80 +++++++++++ uninstall.php | 2 + webpack.config.js | 16 +++ 12 files changed, 582 insertions(+), 16 deletions(-) create mode 100644 src/extensions/components/ModelSourceCard.jsx create mode 100644 src/extensions/services/__tests__/model-source.test.js create mode 100644 src/extensions/services/model-source.js create mode 100644 tools/strip-webllm-prebuilt-loader.js diff --git a/includes/class-admin-page.php b/includes/class-admin-page.php index b315983..baa2bb8 100644 --- a/includes/class-admin-page.php +++ b/includes/class-admin-page.php @@ -209,6 +209,8 @@ public static function get_localized_data(): array { 'modelId' => $settings->get_field( 'agentic_admin_model_id', 'Qwen2.5-7B-Instruct-q4f16_1-MLC' ), 'confirmDestructive' => (bool) $settings->get_field( 'agentic_admin_confirm_destructive', 1 ), 'maxLogLines' => (int) $settings->get_field( 'agentic_admin_max_log_lines', 100 ), + 'modelSource' => Settings::get_model_source(), + 'canManageOptions' => current_user_can( 'manage_options' ), ), 'browserRequirements' => Utils::get_browser_requirements(), 'abilities' => $abilities_js_config, diff --git a/includes/class-settings.php b/includes/class-settings.php index 5009e43..98ca79f 100644 --- a/includes/class-settings.php +++ b/includes/class-settings.php @@ -55,6 +55,8 @@ public static function get_instance(): Settings { public function __construct() { $this->init_settings(); + add_action( 'init', array( $this, 'register_model_source_setting' ) ); + // UX: Add settings link to plugin list table. add_filter( 'plugin_action_links_' . plugin_basename( AGENTIC_ADMIN_FILE ), @@ -181,6 +183,68 @@ public function update_field( string $field, $value, string $type = 'text' ): vo $this->settings[ $field ] = $cleaned; } + /** + * Register the model source option. + * + * The local engine downloads model files only from the addresses the + * site owner enters here. The plugin ships no default. Exposed through + * the core /wp/v2/settings endpoint, which requires manage_options. + * + * @return void + */ + public function register_model_source_setting(): void { + register_setting( + 'agentic_admin', + 'agentic_admin_model_source', + array( + 'type' => 'object', + 'description' => __( 'Where the local AI engine downloads model files from.', 'agentic-admin' ), + 'default' => array( + 'weights_url' => '', + 'library_url' => '', + ), + 'sanitize_callback' => array( __CLASS__, 'sanitize_model_source' ), + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'object', + 'properties' => array( + 'weights_url' => array( 'type' => 'string' ), + 'library_url' => array( 'type' => 'string' ), + ), + 'additionalProperties' => false, + ), + ), + ) + ); + } + + /** + * Sanitize the model source option. + * + * @param mixed $value Raw value. + * @return array{weights_url: string, library_url: string} + */ + public static function sanitize_model_source( $value ): array { + $value = is_array( $value ) ? $value : array(); + $clean = array(); + + foreach ( array( 'weights_url', 'library_url' ) as $key ) { + $url = isset( $value[ $key ] ) ? esc_url_raw( trim( (string) $value[ $key ] ), array( 'https', 'http' ) ) : ''; + $clean[ $key ] = '' === $url ? '' : trailingslashit( $url ); + } + + return $clean; + } + + /** + * Get the configured model source. + * + * @return array{weights_url: string, library_url: string} + */ + public static function get_model_source(): array { + return self::sanitize_model_source( get_option( 'agentic_admin_model_source', array() ) ); + } + /** * Save settings to database. * diff --git a/readme.txt b/readme.txt index b4b9177..aafc785 100644 --- a/readme.txt +++ b/readme.txt @@ -41,7 +41,7 @@ Agentic Admin transforms your WordPress admin panel into an intelligent command 1. The Agentic Admin chat tab in wp-admin, mid-conversation. The model has just answered a question about installed plugins by calling the `plugin-list` tool locally — full ReAct trace (user question, thought process, tool call, answer) visible. 2. First-run model download in progress. The Qwen 3 1.7B weights (~1.2 GB) are fetched from the MLC-AI / HuggingFace CDN — once per browser, cancellable, cached for subsequent sessions. 3. The Abilities browser, listing every tool the assistant can call against the WordPress Abilities API on this site. -4. Settings panel. See detected GPU + VRAM, tune context-window size per model based on your hardware, toggle thinking mode, and switch between the local engine (WebLLM + WebGPU), a remote OpenAI-compatible endpoint, or the WordPress 7.0 Connector. +4. Settings panel. Set the model source, see detected GPU + VRAM, tune context-window size per model based on your hardware, toggle thinking mode, and switch between the local engine (WebLLM + WebGPU), a remote OpenAI-compatible endpoint, or the WordPress 7.0 Connector. 5. Multi-step workflow execution. "Do a performance check" is recognized as a 2-step workflow — the assistant runs `site-health` and `error-log-read` in sequence, then summarizes the environment (WP version, PHP, memory, debug mode, error log status) in one answer. 6. WordPress 7.0 AI Connector integration. The Connector tab picks up any AI provider registered via WP 7.0's built-in Connector API — Anthropic, Google, OpenAI, or any third-party `ai_provider` plugin — and uses it as the model backend with zero extra setup. @@ -51,8 +51,15 @@ This plugin runs AI locally in your browser by default, and your prompts and cha Separately from AI inference, some abilities query public data sources to do their job: a security scan checks your plugin versions against CVE databases, a checksum verification compares your files against WordPress.org, and a web search sends your query to a search engine. Every external request the plugin makes is listed here: -**AI model download service (MLC-AI, hosted on Hugging Face and GitHub)** — Only when the local engine is used. -The local engine is the plugin's core service: it runs a language model in the administrator's browser. The model is not part of the plugin, because model files are over 1 GB and are published and versioned by the MLC-AI project. Nothing is downloaded until an administrator selects a model (Qwen 3 1.7B by default, ~1.2 GB, or Qwen 2.5 7B, ~4.5 GB) and clicks Load Model. The browser then downloads that model's weights from `https://huggingface.co/mlc-ai/` and its compiled model library from `https://raw.githubusercontent.com/mlc-ai/binary-mlc-llm-libs/`. The site owner's choice of engine and model determines what is downloaded. No account or API key is needed. Only HTTP GET requests for static files are made, directly from the browser (the WordPress server makes no requests for them), and no prompts, admin data, or telemetry are sent. Files are cached in the browser after the first download. +**Model source for the local engine (site-owner configured)** — Only when an administrator sets a model source and loads a model. +The local engine runs a language model in the administrator's browser. The model is not part of the plugin: model files are over 1 GB. The plugin contains no model download address. An administrator enters where models are downloaded from under Settings → Model source, and until then the local engine is off. Nothing is downloaded until an administrator then selects a model and clicks Load Model. The browser fetches the files directly from the configured source (the WordPress server makes no requests for them), and no prompts, admin data, or telemetry are sent. Files are cached in the browser after the first download. + +To use the models published by the MLC-AI project, enter these addresses. No account or API key is needed: + +* Model weights URL: `https://huggingface.co/mlc-ai/` +* Model library URL: `https://raw.githubusercontent.com/mlc-ai/binary-mlc-llm-libs/main/web-llm-models/v0_2_80/` + +You can also host the same files yourself and enter your own addresses. Hugging Face terms: https://huggingface.co/terms-of-service — Privacy: https://huggingface.co/privacy GitHub terms: https://docs.github.com/en/site-policy/github-terms/github-terms-of-service — Privacy: https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement @@ -118,6 +125,7 @@ There is no build step for the PHP. The WebLLM engine is bundled into the plugin * Security: the external LLM proxy no longer relays the provider's response verbatim. Each streamed event is decoded and re-encoded as escaped JSON; anything else is dropped. * Changed: "Tested up to" is declared only in readme.txt. * Fixed: the model notice names where the model is downloaded from and its real size, instead of saying no data is sent to external servers. +* Changed: the plugin no longer contains any model download address. The site owner sets the model source under Settings → Model source (the MLC-AI addresses are listed under External services), and the local engine stays off until it is set. WebLLM's built-in model list is removed at build time. * Fixed: `.well-known` scanning resolves via get_home_path() instead of ABSPATH, so subdirectory installs scan the real site root. * Removed: 7 stale tab references and 6+ stale docs files (FEEDBACK-DEV.md). * Tests: 96 unit tests passing, plus the new manifest test suite (7 cases), index test suite (6 cases), and react-agent regression tests (3 cases for the per-call state cleanup fix). diff --git a/src/extensions/components/ModelSourceCard.jsx b/src/extensions/components/ModelSourceCard.jsx new file mode 100644 index 0000000..c61253d --- /dev/null +++ b/src/extensions/components/ModelSourceCard.jsx @@ -0,0 +1,134 @@ +/** + * Model Source Card + * + * Lets the site owner set where the local engine downloads model files + * from. Saved to the agentic_admin_model_source option through the core + * /wp/v2/settings endpoint (requires manage_options). + */ + +import { useState } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import { + Button, + Card, + CardBody, + CardHeader, + Notice, + TextControl, + __experimentalVStack as VStack, +} from '@wordpress/components'; +import { + getModelSource, + setModelSource, + isModelSourceConfigured, +} from '../services/model-source'; + +const ModelSourceCard = () => { + const canManage = Boolean( + window.agenticAdmin?.settings?.canManageOptions + ); + const saved = getModelSource(); + const [ weightsUrl, setWeightsUrl ] = useState( saved.weights_url ); + const [ libraryUrl, setLibraryUrl ] = useState( saved.library_url ); + const [ isSaving, setIsSaving ] = useState( false ); + const [ notice, setNotice ] = useState( null ); + + const handleSave = async () => { + setIsSaving( true ); + setNotice( null ); + try { + const response = await apiFetch( { + path: '/wp/v2/settings', + method: 'POST', + data: { + agentic_admin_model_source: { + weights_url: weightsUrl, + library_url: libraryUrl, + }, + }, + } ); + const stored = response.agentic_admin_model_source || {}; + setModelSource( stored ); + setWeightsUrl( stored.weights_url || '' ); + setLibraryUrl( stored.library_url || '' ); + setNotice( { status: 'success', text: 'Model source saved.' } ); + } catch ( err ) { + setNotice( { + status: 'error', + text: err?.message || 'Could not save the model source.', + } ); + } finally { + setIsSaving( false ); + } + }; + + return ( + + +

Model source

+
+ + +

+ The local engine downloads the AI model from the + addresses below, and from nowhere else. The plugin has + no default: you choose the source. The addresses of the + models published by the MLC-AI project are listed in the + plugin's readme, under External services. You can + also host the same files yourself. +

+ { ! isModelSourceConfigured() && ( + + No model source is set, so the local engine is off. + The Remote and Connector engines still work. + + ) } + + + { canManage ? ( +
+ +
+ ) : ( +

+ Only administrators can change the model source. +

+ ) } + { notice && ( + setNotice( null ) } + > + { notice.text } + + ) } +
+
+
+ ); +}; + +export default ModelSourceCard; diff --git a/src/extensions/components/ModelStatus.jsx b/src/extensions/components/ModelStatus.jsx index 462d636..269e614 100644 --- a/src/extensions/components/ModelStatus.jsx +++ b/src/extensions/components/ModelStatus.jsx @@ -21,6 +21,7 @@ import { __experimentalToggleGroupControl as ToggleGroupControl, __experimentalToggleGroupControlOption as ToggleGroupControlOption, } from '@wordpress/components'; +import { isModelSourceConfigured } from '../services/model-source'; import useConnectors from '../services/use-connectors'; import modelLoader, { ModelLoader, @@ -560,24 +561,41 @@ const ModelStatus = ( { - - Load Model downloads the selected model - once from MLC-AI, hosted on Hugging Face - and GitHub (about 1.2 GB for the - default), and caches it in your browser. - The model then runs in your browser - using WebGPU. Your prompts and site data - are not sent to these hosts. - + { isModelSourceConfigured() ? ( + + Load Model downloads the selected + model once from the model source set + in Settings (about 1.2 GB for the + default), and caches it in your + browser. The model then runs in your + browser using WebGPU. Your prompts + and site data are not sent to the + model source. + + ) : ( + + No model source is set. An + administrator can set where models + are downloaded from under Settings → + Model source. Until then, use the + Remote or Connector engine. + + ) } { Configure GPU, context windows, and model behavior.

+

GPU Information

diff --git a/src/extensions/services/__tests__/model-source.test.js b/src/extensions/services/__tests__/model-source.test.js new file mode 100644 index 0000000..4998414 --- /dev/null +++ b/src/extensions/services/__tests__/model-source.test.js @@ -0,0 +1,103 @@ +/** + * Model Source Tests + * + * The local engine must only download from the owner-configured source, + * and the built bundle must not contain WebLLM's default addresses. + */ + +import fs from 'fs'; +import path from 'path'; +import { + MODEL_RECORDS, + buildAppConfig, + getModelSource, + isModelSourceConfigured, + setModelSource, +} from '../model-source'; + +const stripWebllmPrebuilt = require( '../../../../tools/strip-webllm-prebuilt-loader' ); + +describe( 'model-source', () => { + beforeEach( () => { + setModelSource( { weights_url: '', library_url: '' } ); + } ); + + it( 'is not configured until both addresses are set', () => { + expect( isModelSourceConfigured() ).toBe( false ); + setModelSource( { + weights_url: 'https://models.example/w', + library_url: '', + } ); + expect( isModelSourceConfigured() ).toBe( false ); + setModelSource( { + weights_url: 'https://models.example/w', + library_url: 'https://models.example/lib', + } ); + expect( isModelSourceConfigured() ).toBe( true ); + } ); + + it( 'normalizes addresses to end with a slash', () => { + setModelSource( { + weights_url: ' https://models.example/w ', + library_url: 'https://models.example/lib/', + } ); + expect( getModelSource() ).toEqual( { + weights_url: 'https://models.example/w/', + library_url: 'https://models.example/lib/', + } ); + } ); + + it( 'builds every model URL from the configured source only', () => { + setModelSource( { + weights_url: 'https://models.example/w/', + library_url: 'https://models.example/lib/', + } ); + const config = buildAppConfig(); + + expect( config.model_list ).toHaveLength( MODEL_RECORDS.length ); + for ( const record of config.model_list ) { + expect( record.model ).toBe( + `https://models.example/w/${ record.model_id }` + ); + expect( + record.model_lib.startsWith( 'https://models.example/lib/' ) + ).toBe( true ); + } + } ); + + it( 'covers every model the loader offers', () => { + const ids = MODEL_RECORDS.map( ( r ) => r.id ); + expect( ids ).toEqual( + expect.arrayContaining( [ + 'Qwen3-1.7B-q4f16_1-MLC', + 'Qwen3-1.7B-q4f32_1-MLC', + 'Qwen2.5-7B-Instruct-q4f16_1-MLC', + 'Qwen2.5-7B-Instruct-q4f32_1-MLC', + ] ) + ); + } ); +} ); + +describe( 'strip-webllm-prebuilt-loader', () => { + const webllmPath = path.resolve( + __dirname, + '../../../../node_modules/@mlc-ai/web-llm/lib/index.js' + ); + + it( 'removes every model download address from WebLLM', () => { + const out = stripWebllmPrebuilt( + fs.readFileSync( webllmPath, 'utf8' ) + ); + expect( out ).toContain( 'const modelLibURLPrefix = "";' ); + expect( out ).toMatch( /model_list: \[\]/ ); + expect( out ).not.toMatch( + /["'`]https:\/\/(huggingface\.co|raw\.githubusercontent\.com)/ + ); + } ); + + it( 'fails loudly when WebLLM changes shape', () => { + expect( () => stripWebllmPrebuilt( 'const x = 1;' ) ).toThrow( + /modelLibURLPrefix not found/ + ); + } ); +} ); diff --git a/src/extensions/services/model-loader.js b/src/extensions/services/model-loader.js index d83e61d..cd2d43e 100644 --- a/src/extensions/services/model-loader.js +++ b/src/extensions/services/model-loader.js @@ -13,6 +13,7 @@ import * as webllm from '@mlc-ai/web-llm'; import { applyFilters } from '@wordpress/hooks'; import { ExternalEngine } from './external-engine'; import ConnectorEngine from './connector-engine'; +import { buildAppConfig, isModelSourceConfigured } from './model-source'; import { createLogger } from '../utils/logger'; /** @@ -349,7 +350,13 @@ class ModelLoader { async isModelCached( modelId = null ) { const id = modelId || this.modelId; try { - const isCached = await webllm.hasModelInCache( id ); + if ( ! isModelSourceConfigured() ) { + return false; + } + const isCached = await webllm.hasModelInCache( + id, + buildAppConfig() + ); log.info( `Model ${ id } cached:`, isCached ); return isCached; } catch ( err ) { @@ -506,6 +513,13 @@ class ModelLoader { this.modelId = modelId || DEFAULT_MODEL; try { + // The owner must set where models are downloaded from. + if ( ! isModelSourceConfigured() ) { + throw new Error( + 'No model source is set. An administrator can set it under Settings → Model source.' + ); + } + // Check WebGPU support first this.reportStatus( 'checking', 'Checking WebGPU support...' ); this.reportProgress( 0, 'Checking WebGPU support...' ); @@ -813,6 +827,7 @@ class ModelLoader { this.engine = await webllm.CreateServiceWorkerMLCEngine( this.modelId, { + appConfig: buildAppConfig(), initProgressCallback, }, undefined, // Let WebLLM use navigator.serviceWorker.controller @@ -848,6 +863,7 @@ class ModelLoader { // Create the regular MLCEngine (page-local) this.engine = await webllm.CreateMLCEngine( this.modelId, { + appConfig: buildAppConfig(), initProgressCallback, } ); diff --git a/src/extensions/services/model-source.js b/src/extensions/services/model-source.js new file mode 100644 index 0000000..e97ac4c --- /dev/null +++ b/src/extensions/services/model-source.js @@ -0,0 +1,121 @@ +/** + * Model Source + * + * The local engine downloads model files only from the addresses the site + * owner enters in Settings (option agentic_admin_model_source). The plugin + * ships no default download address: WebLLM's built-in list is stripped at + * build time (tools/strip-webllm-prebuilt-loader.js), and the WebLLM app + * config is built here from the owner's settings. + * + * - weights_url: base URL of the model weight folders. Each model is read + * from `${ weights_url }${ modelId }/`. + * - library_url: base URL of the compiled model libraries (.wasm). Each + * library is read from `${ library_url }${ lib }`. + */ + +/** + * Models the plugin supports, matching WebLLM 0.2.80's own records. + * `lib` is the file name of the compiled model library for that model. + */ +export const MODEL_RECORDS = [ + { + id: 'Qwen3-1.7B-q4f16_1-MLC', + lib: 'Qwen3-1.7B-q4f16_1-ctx4k_cs1k-webgpu.wasm', + vramRequiredMB: 2036.66, + lowResourceRequired: true, + }, + { + id: 'Qwen3-1.7B-q4f32_1-MLC', + lib: 'Qwen3-1.7B-q4f32_1-ctx4k_cs1k-webgpu.wasm', + vramRequiredMB: 2635.44, + lowResourceRequired: true, + }, + { + id: 'Qwen2.5-7B-Instruct-q4f16_1-MLC', + lib: 'Qwen2-7B-Instruct-q4f16_1-ctx4k_cs1k-webgpu.wasm', + vramRequiredMB: 5106.67, + lowResourceRequired: false, + }, + { + id: 'Qwen2.5-7B-Instruct-q4f32_1-MLC', + lib: 'Qwen2-7B-Instruct-q4f32_1-ctx4k_cs1k-webgpu.wasm', + vramRequiredMB: 5900.09, + lowResourceRequired: false, + }, +]; + +let current = null; + +/** + * Ensure a URL ends with a slash. + * + * @param {string} url URL. + * @return {string} URL with trailing slash, or '' when empty. + */ +function withSlash( url ) { + const trimmed = ( url || '' ).trim(); + if ( ! trimmed ) { + return ''; + } + return trimmed.endsWith( '/' ) ? trimmed : `${ trimmed }/`; +} + +/** + * Get the configured model source. + * + * @return {{weights_url: string, library_url: string}} Model source. + */ +export function getModelSource() { + if ( ! current ) { + const fromPage = window.agenticAdmin?.settings?.modelSource || {}; + current = { + weights_url: withSlash( fromPage.weights_url ), + library_url: withSlash( fromPage.library_url ), + }; + } + return current; +} + +/** + * Replace the in-memory model source after the owner saves Settings. + * + * @param {{weights_url: string, library_url: string}} source New source. + */ +export function setModelSource( source ) { + current = { + weights_url: withSlash( source?.weights_url ), + library_url: withSlash( source?.library_url ), + }; +} + +/** + * Whether both model source addresses are set. + * + * @param {Object} [source] Source to check (defaults to the current one). + * @return {boolean} True when the local engine can download models. + */ +export function isModelSourceConfigured( source = getModelSource() ) { + return Boolean( source.weights_url && source.library_url ); +} + +/** + * Build the WebLLM app config from the model source. + * + * @param {Object} [source] Source to use (defaults to the current one). + * @return {Object} WebLLM AppConfig. + */ +export function buildAppConfig( source = getModelSource() ) { + return { + useIndexedDBCache: false, + model_list: MODEL_RECORDS.map( ( record ) => ( { + model: `${ source.weights_url }${ record.id }`, + model_id: record.id, + model_lib: `${ source.library_url }${ record.lib }`, + vram_required_MB: record.vramRequiredMB, + low_resource_required: record.lowResourceRequired, + overrides: { + context_window_size: 4096, + }, + } ) ), + }; +} diff --git a/tools/strip-webllm-prebuilt-loader.js b/tools/strip-webllm-prebuilt-loader.js new file mode 100644 index 0000000..1287fa0 --- /dev/null +++ b/tools/strip-webllm-prebuilt-loader.js @@ -0,0 +1,80 @@ +/** + * Webpack loader: strip WebLLM's built-in model download addresses. + * + * WebLLM ships a default app config listing ~140 models, each with a + * Hugging Face weights URL and a GitHub model-library URL. Agentic Admin + * never uses it: the site owner configures the model source in Settings, + * and the plugin builds its own app config from that (see + * src/extensions/services/model-source.js). This loader empties the default + * list and the model-library URL prefix so the built plugin contains no + * hard-coded model download addresses. + * + * The build fails if the expected code is not found, so a WebLLM upgrade + * cannot silently bring the addresses back. + */ + +const PREFIX_PATTERN = /const modelLibURLPrefix = "https:\/\/[^"]*";/; +const LIST_START = 'const prebuiltAppConfig = {'; + +module.exports = function stripWebllmPrebuilt( source ) { + if ( ! PREFIX_PATTERN.test( source ) ) { + throw new Error( + 'strip-webllm-prebuilt-loader: modelLibURLPrefix not found. Check the WebLLM version.' + ); + } + let out = source.replace( PREFIX_PATTERN, 'const modelLibURLPrefix = "";' ); + + const start = out.indexOf( LIST_START ); + const listOpen = out.indexOf( 'model_list: [', start ); + if ( start === -1 || listOpen === -1 ) { + throw new Error( + 'strip-webllm-prebuilt-loader: prebuiltAppConfig.model_list not found. Check the WebLLM version.' + ); + } + + // Find the matching closing bracket of model_list. + const bodyStart = listOpen + 'model_list: ['.length; + let depth = 1; + let i = bodyStart; + let inString = null; + for ( ; i < out.length && depth > 0; i++ ) { + const ch = out[ i ]; + if ( inString ) { + if ( ch === '\\' ) { + i++; + } else if ( ch === inString ) { + inString = null; + } + continue; + } + if ( ch === '"' || ch === "'" || ch === '`' ) { + inString = ch; + } else if ( ch === '/' && out[ i + 1 ] === '/' ) { + i = out.indexOf( '\n', i ); + } else if ( ch === '[' ) { + depth++; + } else if ( ch === ']' ) { + depth--; + } + } + if ( depth !== 0 ) { + throw new Error( + 'strip-webllm-prebuilt-loader: could not find the end of model_list.' + ); + } + + out = out.slice( 0, bodyStart ) + out.slice( i - 1 ); + + // Only string literals matter; URLs in comments are removed by the minifier. + if ( + /["'`]https:\/\/(huggingface\.co|raw\.githubusercontent\.com)/.test( + out + ) + ) { + throw new Error( + 'strip-webllm-prebuilt-loader: model download addresses remain after stripping.' + ); + } + + return out; +}; diff --git a/uninstall.php b/uninstall.php index 7dc81df..f1c5d10 100644 --- a/uninstall.php +++ b/uninstall.php @@ -12,6 +12,7 @@ // 1. Single Site Cleanup. delete_option( 'agentic_admin_settings' ); +delete_option( 'agentic_admin_model_source' ); delete_option( 'agentic_admin_version' ); delete_transient( 'agentic_admin_cache' ); delete_transient( 'agentic_admin_post_types' ); @@ -24,6 +25,7 @@ switch_to_blog( $agentic_admin_site->blog_id ); delete_option( 'agentic_admin_settings' ); + delete_option( 'agentic_admin_model_source' ); delete_option( 'agentic_admin_version' ); delete_transient( 'agentic_admin_cache' ); delete_transient( 'agentic_admin_post_types' ); diff --git a/webpack.config.js b/webpack.config.js index 8637831..6afa79a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -33,6 +33,22 @@ module.exports = { // Re-add this entry to ship voice input again. }, + module: { + ...defaultConfig.module, + rules: [ + ...defaultConfig.module.rules, + // Strip WebLLM's built-in model download addresses. The site owner + // configures the model source; see tools/strip-webllm-prebuilt-loader.js. + { + test: /[\\/]node_modules[\\/]@mlc-ai[\\/]web-llm[\\/]lib[\\/]index\.js$/, + use: path.resolve( + __dirname, + 'tools/strip-webllm-prebuilt-loader.js' + ), + }, + ], + }, + output: { ...defaultConfig.output, path: path.resolve( __dirname, 'build-extensions' ), From 6efa590c56b32bd00225104ad4bbde5927245623 Mon Sep 17 00:00:00 2001 From: pluginslab <57633278+pluginslab@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:03:10 +0100 Subject: [PATCH 2/2] fix: address Ivelina's review of the model source (#239) - Library URL is a base address; buildAppConfig() appends WebLLM's modelVersion, so a WebLLM upgrade picks matching libraries without owners editing their settings. - Readme and Settings help document the self-hosting layout (//resolve/main/, //). - Model source accepts https only (http is blocked as mixed content). - Load Model and the Settings warning update live after saving, via a model-source subscription. - The "no model source" notice links to #model-source, which opens Settings and scrolls to the card. - Upgrade Notice for existing local-engine users; screenshot caption 2 no longer names a fixed host. Co-Authored-By: Claude Opus 5.5 (1M context) --- includes/class-settings.php | 5 +++- readme.txt | 11 +++++-- src/extensions/App.jsx | 20 ++++++++++++- src/extensions/components/ModelSourceCard.jsx | 26 ++++++++++++---- src/extensions/components/ModelStatus.jsx | 30 ++++++++++++++----- .../services/__tests__/model-source.test.js | 22 +++++++++++--- src/extensions/services/model-loader.js | 6 ++-- src/extensions/services/model-source.js | 28 +++++++++++++---- 8 files changed, 117 insertions(+), 31 deletions(-) diff --git a/includes/class-settings.php b/includes/class-settings.php index 98ca79f..0af411a 100644 --- a/includes/class-settings.php +++ b/includes/class-settings.php @@ -221,6 +221,9 @@ public function register_model_source_setting(): void { /** * Sanitize the model source option. * + * Only https addresses are kept: the admin screen is served over https on + * most sites, where browsers block http downloads as mixed content. + * * @param mixed $value Raw value. * @return array{weights_url: string, library_url: string} */ @@ -229,7 +232,7 @@ public static function sanitize_model_source( $value ): array { $clean = array(); foreach ( array( 'weights_url', 'library_url' ) as $key ) { - $url = isset( $value[ $key ] ) ? esc_url_raw( trim( (string) $value[ $key ] ), array( 'https', 'http' ) ) : ''; + $url = isset( $value[ $key ] ) ? esc_url_raw( trim( (string) $value[ $key ] ), array( 'https' ) ) : ''; $clean[ $key ] = '' === $url ? '' : trailingslashit( $url ); } diff --git a/readme.txt b/readme.txt index aafc785..ffe84b5 100644 --- a/readme.txt +++ b/readme.txt @@ -39,7 +39,7 @@ Agentic Admin transforms your WordPress admin panel into an intelligent command == Screenshots == 1. The Agentic Admin chat tab in wp-admin, mid-conversation. The model has just answered a question about installed plugins by calling the `plugin-list` tool locally — full ReAct trace (user question, thought process, tool call, answer) visible. -2. First-run model download in progress. The Qwen 3 1.7B weights (~1.2 GB) are fetched from the MLC-AI / HuggingFace CDN — once per browser, cancellable, cached for subsequent sessions. +2. First-run model download in progress. The Qwen 3 1.7B weights (~1.2 GB) are fetched from the configured model source — once per browser, cancellable, cached for subsequent sessions. 3. The Abilities browser, listing every tool the assistant can call against the WordPress Abilities API on this site. 4. Settings panel. Set the model source, see detected GPU + VRAM, tune context-window size per model based on your hardware, toggle thinking mode, and switch between the local engine (WebLLM + WebGPU), a remote OpenAI-compatible endpoint, or the WordPress 7.0 Connector. 5. Multi-step workflow execution. "Do a performance check" is recognized as a 2-step workflow — the assistant runs `site-health` and `error-log-read` in sequence, then summarizes the environment (WP version, PHP, memory, debug mode, error log status) in one answer. @@ -57,9 +57,9 @@ The local engine runs a language model in the administrator's browser. The model To use the models published by the MLC-AI project, enter these addresses. No account or API key is needed: * Model weights URL: `https://huggingface.co/mlc-ai/` -* Model library URL: `https://raw.githubusercontent.com/mlc-ai/binary-mlc-llm-libs/main/web-llm-models/v0_2_80/` +* Model library URL: `https://raw.githubusercontent.com/mlc-ai/binary-mlc-llm-libs/main/web-llm-models/` -You can also host the same files yourself and enter your own addresses. +You can also host the same files yourself and enter your own https addresses. Use the same layout: each model's weights in `//resolve/main/`, and the compiled model libraries in `//` (the plugin adds the version it needs, currently `v0_2_80`). Hugging Face terms: https://huggingface.co/terms-of-service — Privacy: https://huggingface.co/privacy GitHub terms: https://docs.github.com/en/site-policy/github-terms/github-terms-of-service — Privacy: https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement @@ -99,6 +99,11 @@ The files under `build-extensions/` are generated from the sources in `src/` wit There is no build step for the PHP. The WebLLM engine is bundled into the plugin from its npm package. The AI model weights and their compiled model libraries are loaded at runtime from the provider documented under External services above; these are large provider-hosted model files (over 1 GB), not part of the plugin code. +== Upgrade Notice == + += 0.11.0 = +The local engine now downloads models only from a source you set. If you used the local engine before, open Settings → Model source and enter the addresses listed under External services. Models already in your browser cache are reused. + == Changelog == = 0.11.0 = diff --git a/src/extensions/App.jsx b/src/extensions/App.jsx index 4c42140..f9904ab 100644 --- a/src/extensions/App.jsx +++ b/src/extensions/App.jsx @@ -27,6 +27,23 @@ const App = () => { ); const [ initProgress, setInitProgress ] = useState( 5 ); + // "#model-source" links (e.g. from the Load Model notice) open Settings. + const tabFromHash = () => + window.location.hash === '#model-source' ? 'settings' : 'chat'; + const [ initialTab, setInitialTab ] = useState( tabFromHash ); + const [ tabPanelKey, setTabPanelKey ] = useState( 0 ); + + useEffect( () => { + const onHashChange = () => { + if ( window.location.hash === '#model-source' ) { + setInitialTab( 'settings' ); + setTabPanelKey( ( key ) => key + 1 ); + } + }; + window.addEventListener( 'hashchange', onHashChange ); + return () => window.removeEventListener( 'hashchange', onHashChange ); + }, [] ); + const settings = window.agenticAdmin || {}; const { i18n = {}, @@ -258,9 +275,10 @@ const App = () => { return (
{ renderTab } diff --git a/src/extensions/components/ModelSourceCard.jsx b/src/extensions/components/ModelSourceCard.jsx index c61253d..7aeec50 100644 --- a/src/extensions/components/ModelSourceCard.jsx +++ b/src/extensions/components/ModelSourceCard.jsx @@ -6,7 +6,7 @@ * /wp/v2/settings endpoint (requires manage_options). */ -import { useState } from '@wordpress/element'; +import { useState, useEffect } from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; import { Button, @@ -21,6 +21,7 @@ import { getModelSource, setModelSource, isModelSourceConfigured, + subscribe, } from '../services/model-source'; const ModelSourceCard = () => { @@ -32,6 +33,21 @@ const ModelSourceCard = () => { const [ libraryUrl, setLibraryUrl ] = useState( saved.library_url ); const [ isSaving, setIsSaving ] = useState( false ); const [ notice, setNotice ] = useState( null ); + const [ sourceReady, setSourceReady ] = useState( isModelSourceConfigured ); + + useEffect( + () => subscribe( () => setSourceReady( isModelSourceConfigured() ) ), + [] + ); + + // Scroll into view when opened from a #model-source link. + useEffect( () => { + if ( window.location.hash === '#model-source' ) { + document + .getElementById( 'agentic-admin-model-source' ) + ?.scrollIntoView( { behavior: 'smooth', block: 'start' } ); + } + }, [] ); const handleSave = async () => { setIsSaving( true ); @@ -63,7 +79,7 @@ const ModelSourceCard = () => { }; return ( - +

Model source

@@ -77,7 +93,7 @@ const ModelSourceCard = () => { plugin's readme, under External services. You can also host the same files yourself.

- { ! isModelSourceConfigured() && ( + { ! sourceReady && ( No model source is set, so the local engine is off. The Remote and Connector engines still work. @@ -86,7 +102,7 @@ const ModelSourceCard = () => { { + subscribeModelSource( () => + setSourceReady( isModelSourceConfigured() ) + ), + [] + ); const [ isFromCache, setIsFromCache ] = useState( false ); const [ rawMessage, setRawMessage ] = useState( '' ); const [ loadedModelInfo, setLoadedModelInfo ] = useState( null ); @@ -561,16 +574,14 @@ const ModelStatus = ( { - { isModelSourceConfigured() ? ( + { sourceReady ? ( No model source is set. An administrator can set where models - are downloaded from under Settings → - Model source. Until then, use the - Remote or Connector engine. + are downloaded from under{ ' ' } + + Settings → Model source + + . Until then, use the Remote or + Connector engine. ) } { weights_url: 'https://models.example/w/', library_url: 'https://models.example/lib/', } ); - const config = buildAppConfig(); + const config = buildAppConfig( 'v0_2_80' ); expect( config.model_list ).toHaveLength( MODEL_RECORDS.length ); for ( const record of config.model_list ) { expect( record.model ).toBe( `https://models.example/w/${ record.model_id }` ); - expect( - record.model_lib.startsWith( 'https://models.example/lib/' ) - ).toBe( true ); + expect( record.model_lib ).toMatch( + /^https:\/\/models\.example\/lib\/v0_2_80\/[^/]+\.wasm$/ + ); } } ); + it( 'notifies subscribers when the source is saved', () => { + const listener = jest.fn(); + const unsubscribe = subscribe( listener ); + setModelSource( { + weights_url: 'https://models.example/w', + library_url: 'https://models.example/lib', + } ); + expect( listener ).toHaveBeenCalledTimes( 1 ); + unsubscribe(); + setModelSource( { weights_url: '', library_url: '' } ); + expect( listener ).toHaveBeenCalledTimes( 1 ); + } ); + it( 'covers every model the loader offers', () => { const ids = MODEL_RECORDS.map( ( r ) => r.id ); expect( ids ).toEqual( diff --git a/src/extensions/services/model-loader.js b/src/extensions/services/model-loader.js index cd2d43e..39737cc 100644 --- a/src/extensions/services/model-loader.js +++ b/src/extensions/services/model-loader.js @@ -355,7 +355,7 @@ class ModelLoader { } const isCached = await webllm.hasModelInCache( id, - buildAppConfig() + buildAppConfig( webllm.modelVersion ) ); log.info( `Model ${ id } cached:`, isCached ); return isCached; @@ -827,7 +827,7 @@ class ModelLoader { this.engine = await webllm.CreateServiceWorkerMLCEngine( this.modelId, { - appConfig: buildAppConfig(), + appConfig: buildAppConfig( webllm.modelVersion ), initProgressCallback, }, undefined, // Let WebLLM use navigator.serviceWorker.controller @@ -863,7 +863,7 @@ class ModelLoader { // Create the regular MLCEngine (page-local) this.engine = await webllm.CreateMLCEngine( this.modelId, { - appConfig: buildAppConfig(), + appConfig: buildAppConfig( webllm.modelVersion ), initProgressCallback, } ); diff --git a/src/extensions/services/model-source.js b/src/extensions/services/model-source.js index e97ac4c..954e307 100644 --- a/src/extensions/services/model-source.js +++ b/src/extensions/services/model-source.js @@ -7,10 +7,12 @@ * build time (tools/strip-webllm-prebuilt-loader.js), and the WebLLM app * config is built here from the owner's settings. * - * - weights_url: base URL of the model weight folders. Each model is read - * from `${ weights_url }${ modelId }/`. + * - weights_url: base URL of the model weight folders. WebLLM reads each + * model from `${ weights_url }${ modelId }/resolve/main/`. * - library_url: base URL of the compiled model libraries (.wasm). Each - * library is read from `${ library_url }${ lib }`. + * library is read from `${ library_url }${ modelVersion }/${ lib }`, where + * modelVersion comes from the bundled WebLLM, so a WebLLM upgrade picks + * the matching libraries without the owner changing anything. */ /** @@ -45,6 +47,18 @@ export const MODEL_RECORDS = [ ]; let current = null; +const listeners = new Set(); + +/** + * Subscribe to model source changes. + * + * @param {Function} listener Called after the source is saved. + * @return {Function} Unsubscribe function. + */ +export function subscribe( listener ) { + listeners.add( listener ); + return () => listeners.delete( listener ); +} /** * Ensure a URL ends with a slash. @@ -86,6 +100,7 @@ export function setModelSource( source ) { weights_url: withSlash( source?.weights_url ), library_url: withSlash( source?.library_url ), }; + listeners.forEach( ( listener ) => listener( current ) ); } /** @@ -101,16 +116,17 @@ export function isModelSourceConfigured( source = getModelSource() ) { /** * Build the WebLLM app config from the model source. * - * @param {Object} [source] Source to use (defaults to the current one). + * @param {string} modelVersion WebLLM model library version (webllm.modelVersion). + * @param {Object} [source] Source to use (defaults to the current one). * @return {Object} WebLLM AppConfig. */ -export function buildAppConfig( source = getModelSource() ) { +export function buildAppConfig( modelVersion, source = getModelSource() ) { return { useIndexedDBCache: false, model_list: MODEL_RECORDS.map( ( record ) => ( { model: `${ source.weights_url }${ record.id }`, model_id: record.id, - model_lib: `${ source.library_url }${ record.lib }`, + model_lib: `${ source.library_url }${ modelVersion }/${ record.lib }`, vram_required_MB: record.vramRequiredMB, low_resource_required: record.lowResourceRequired, overrides: {