Support webui JS/HTML extensions and plugin paths

Add full support for loading web UI extensions from plugins: - Frontend: new extensions loader (js/extensions.js) that loads and caches JS and HTML extensions, calls JS hooks, imports HTML as <x-component> tags, normalizes paths, and replaces the old plugins.js auto-inject logic. - Messages: make setMessages async, introduce extension hooks (set_messages_before_loop / after_loop) and integrate JS extension calls; adjust Scroller usage accordingly. - Components: fix x-component path handling in several templates and components import logic. - Backend: add filters to /api/load_webui_extensions and pass them to plugin helper; update helpers to look for plugin extensions under extensions/python instead of backend. - Plugins helper: extend get_webui_extensions to accept file glob filters, dedupe matches, return relative paths, and improve error logging. - Project config: include plugins and usr/plugins JS files in jsconfig.json. - Add a small test extension file and move several plugin extension files from backend/ to python/ directories. - Minor: reset error_retries earlier in agent message loop to avoid stale retry counts. These changes enable a flexible plugin extension system for both JS and HTML assets, improve caching and path normalization, and wire up backend APIs and helpers to support the new structure.

frdel committed Feb 17, 2026 at 10:17 UTC 3bfdf91637b8b5053c2441f24a785ac82283a676
20 files changed +194 -97
agent.py
+3 -1
@@ -491,6 +491,8 @@ class Agent:
491 if tools_result: # final response of message loop available
492 return tools_result # break the execution if the task is done
493
494 + error_retries = 0 # reset retry counter on successful iteration
495 +
496 # exceptions inside message loop:
497 except InterventionException as e:
498 error_retries = 0 # reset retry counter on user intervention
@@ -515,7 +517,7 @@ class Agent:
517 "message_loop_end", loop_data=self.loop_data
518 )
519
518 - error_retries = 0 # reset retry counter on successful iteration
520 +
521
522 # exceptions outside message loop:
523 except InterventionException as e:
jsconfig.json
+1 -1
@@ -7,5 +7,5 @@
7 "/usr/plugins/*": ["usr/plugins/*"]
8 }
9 },
10 - "include": ["webui/**/*.js"]
10 + "include": ["webui/**/*.js", "plugins/**/*.js", "usr/plugins/**/*.js"]
11 }
\ No newline at end of file
plugins/memory/extensions/python/embedding_model_changed/_10_memory_reload.py renamed
plugins/memory/extensions/python/message_loop_prompts_after/_50_recall_memories.py renamed
plugins/memory/extensions/python/message_loop_prompts_after/_91_recall_wait.py renamed
plugins/memory/extensions/python/monologue_end/_50_memorize_fragments.py renamed
plugins/memory/extensions/python/monologue_end/_51_memorize_solutions.py renamed
plugins/memory/extensions/python/monologue_start/_10_memory_init.py renamed
plugins/memory/extensions/python/system_prompt/_20_behaviour_prompt.py renamed
plugins/memory/extensions/webui/set_messages_before_loop/testingext.js new
+4
@@ -0,0 +1,4 @@
1 +
2 +export default function extension(context){
3 + console.log("set_messages_before_loop extension called - textingext.js - REMOVE ME", context);
4 +}
\ No newline at end of file
python/api/load_webui_extensions.py
+2 -1
@@ -10,10 +10,11 @@ class LoadWebuiExtensions(ApiHandler):
10
11 async def process(self, input: dict, request: Request) -> dict | Response:
12 extension_point = input.get("extension_point", [])
13 + filters = input.get("filters", [])
14
15 if not extension_point:
16 return Response(status=400, response="Missing extension_point")
17
17 - exts = plugins.get_webui_extensions(extension_point)
18 + exts = plugins.get_webui_extensions(extension_point, filters)
19
20 return {"extensions": exts or []}
python/helpers/extension.py
+2 -2
@@ -32,8 +32,8 @@ async def call_extensions(
32 # search for extension folders in all agent's paths
33 paths = subagents.get_paths(agent, "extensions", extension_point, default_root="python")
34
35 - # Add plugin backend extension paths (plugins/*/extensions/backend/{extension_point})
36 - plugin_paths = plugins.get_plugin_paths("extensions", "backend", extension_point)
35 + # Add plugin backend extension paths (plugins/*/extensions/python/{extension_point})
36 + plugin_paths = plugins.get_plugin_paths("extensions", "python", extension_point)
37 paths.extend(p for p in plugin_paths if p not in paths)
38
39 all_exts = [cls for path in paths for cls in _get_extensions(path)]
python/helpers/plugins.py
+18 -6
@@ -74,21 +74,33 @@ def get_plugin_paths(*subpaths: str) -> List[str]:
74 return paths
75
76
77 -def get_webui_extensions(extension_point:str) -> List[Dict[str, Any]]:
77 +def get_webui_extensions(extension_point:str, filters:List[str]|None=None) -> List[Dict[str, Any]]:
78 entries: List[Dict[str, Any]] = []
79 + effective_filters = filters or ["*"]
80 for plugin in list_plugins():
81 frontend_dir = plugin.path / "extensions" / "webui" / extension_point
82 if not frontend_dir.is_dir():
83 continue
83 - for html_file in sorted(frontend_dir.rglob("*.html"), key=lambda p: p.name):
84 + matched_files: List[Path] = []
85 + seen: set[str] = set()
86 + for pattern in effective_filters:
87 + for p in frontend_dir.rglob(pattern):
88 + if not p.is_file():
89 + continue
90 + p_str = str(p)
91 + if p_str in seen:
92 + continue
93 + seen.add(p_str)
94 + matched_files.append(p)
95 +
96 + for ext_file in sorted(matched_files, key=lambda p: p.name):
97 try:
85 - rel_path = html_file.relative_to(plugin.path).as_posix()
98 + rel_path = files.deabsolute_path(str(ext_file))
99 entry: Dict[str, Any] = {
100 "plugin_id": plugin.id,
88 - "component_url": f"{plugin.path}/{rel_path}",
89 - "html": html_file.read_text(encoding="utf-8"),
101 + "path": rel_path,
102 }
103 entries.append(entry)
104 except Exception:
93 - print_style.PrintStyle.error(f"Failed to load frontend extension file {html_file}")
105 + print_style.PrintStyle.error(f"Failed to load frontend extension file {ext_file}")
106 return entries
webui/components/chat/input/chat-bar.html
+1 -1
@@ -14,7 +14,7 @@
14
15 <!-- Attachment Preview section -->
16 <div>
17 - <x-component path="/chat/attachments/inputPreview.html" />
17 + <x-component path="chat/attachments/inputPreview.html" />
18 </div>
19
20 <x-component path="chat/input/chat-bar-input.html"></x-component>
webui/components/settings/agent/speech.html
+1 -1
@@ -18,7 +18,7 @@
18 <div class="field-description">Select the microphone device to use for speech-to-text.</div>
19 </div>
20 <div class="field-control">
21 - <x-component path="/settings/speech/microphone.html"></x-component>
21 + <x-component path="settings/speech/microphone.html"></x-component>
22 </div>
23 </div>
24
webui/index.js
+4 -4
@@ -77,7 +77,7 @@ export async function sendMessage() {
77 : "";
78
79 // Render user message with attachments
80 - setMessages([{ id: messageId, type: "user", heading, content: message, kvps: {
80 + await setMessages([{ id: messageId, type: "user", heading, content: message, kvps: {
81 // attachments: attachmentsWithUrls, // skip here, let the backend properly log them
82 }}]);
83
@@ -211,8 +211,8 @@ async function updateUserTime() {
211 updateUserTime();
212 setInterval(updateUserTime, 1000);
213
214 -function setMessages(...params) {
215 - return msgs.setMessages(...params);
214 +async function setMessages(...params) {
215 + return await msgs.setMessages(...params);
216 }
217
218 globalThis.loadKnowledge = async function () {
@@ -332,7 +332,7 @@ export async function applySnapshot(snapshot, options = {}) {
332
333 if (lastLogVersion != snapshot.log_version) {
334 updated = true;
335 - setMessages(snapshot.logs);
335 + await setMessages(snapshot.logs);
336 afterMessagesUpdate(snapshot.logs);
337 }
338
webui/js/components.js
+1 -2
@@ -30,8 +30,7 @@ export async function importComponent(path, targetElement) {
30 targetElement.innerHTML = '<div class="loading"></div>';
31
32 // full component url
33 - const trimmedPath = path.replace(/^\/+/, "");
34 - const componentUrl = trimmedPath.startsWith("components/") ? trimmedPath : "components/" + trimmedPath;
33 + const componentUrl = path.startsWith("/") ? path : (path.startsWith("components/") ? path : "components/" + path);
34
35 // get html from cache or fetch it
36 let html;
webui/js/extensions.js
+122 -10
@@ -1,12 +1,95 @@
1 import * as api from "./api.js";
2
3 +/**
4 + * @typedef {Object} WebuiExtension
5 + * @property {string} path
6 + */
7 +
8 +
9 +
10 +/**
11 + * @typedef {Object} LoadWebuiExtensionsResponse
12 + * @property {WebuiExtension[]} extensions
13 + */
14 +
15 +/**
16 + * @typedef {Object} JsExtensionImport
17 + * @property {string} path
18 + * @property {{ default: (data: any) => (void|Promise<void>) }} module
19 + */
20 +
21 +/** @type {Map<string, JsExtensionImport[]>} */
22 +const jsExtensionsCache = new Map();
23 +
24 +/** @type {Map<string, string>} */
25 +const htmlExtensionsCache = new Map();
26 +
27 +export function invalidateCache() {
28 + jsExtensionsCache.clear();
29 + htmlExtensionsCache.clear();
30 +}
31 +
32 +/**
33 + * Call all JS extensions for a given extension point.
34 + *
35 + * @param {string} extensionPoint
36 + * @param {any} data
37 + * @returns {Promise<void>}
38 + */
39 +export async function callJsExtensions(extensionPoint, data){
40 + const extensions = jsExtensionsCache.get(extensionPoint) || await loadJsExtensions(extensionPoint);
41 + for(const extension of extensions){
42 + try{
43 + await extension.module.default(data);
44 + }catch(error){
45 + console.error(`Error calling extension: ${extension.path}`, error);
46 + }
47 + }
48 +}
49 +
50 +/**
51 + * Load JS extension modules for an extension point.
52 + *
53 + * @param {string} extensionPoint
54 + * @returns {Promise<JsExtensionImport[]>}
55 + */
56 +export async function loadJsExtensions(extensionPoint) {
57 + try {
58 + /** @type {LoadWebuiExtensionsResponse} */
59 + const response = await api.callJsonApi(`/api/load_webui_extensions`, {
60 + extension_point: extensionPoint,
61 + filters: ["*.js", "*.mjs"],
62 + });
63 + /** @type {JsExtensionImport[]} */
64 + const imports = await Promise.all(
65 + response.extensions.map(async extension => ({
66 + path: extension.path,
67 + module: await import(normalizePath(extension.path))
68 + }))
69 + );
70 + jsExtensionsCache.set(extensionPoint, imports);
71 + return imports;
72 + } catch (error) {
73 + console.error("Error loading JS extensions:", error);
74 + return [];
75 + }
76 +}
77 +
78 // Load all x-component tags starting from root elements
4 -export async function loadExtensions(roots = [document.documentElement]) {
79 +/**
80 + * Load and render all HTML extensions in the given DOM roots.
81 + *
82 + * @param {Element | Document | Array<Element | Document>} [roots]
83 + * @returns {Promise<void>}
84 + */
85 +export async function loadHtmlExtensions(roots = [document.documentElement]) {
86 try {
87 // Convert single root to array if needed
88 + /** @type {Array<Element | Document>} */
89 const rootElements = Array.isArray(roots) ? roots : [roots];
90
91 // Find all top-level components and load them in parallel
92 + /** @type {Element[]} */
93 const extensions = rootElements.flatMap((root) =>
94 Array.from(root.querySelectorAll("x-extension")),
95 );
@@ -20,41 +103,70 @@ export async function loadExtensions(roots = [document.documentElement]) {
103 console.error("x-extension missing id attribute:", extension);
104 return;
105 }
23 - await importExtensions(path, extension);
106 + await importHtmlExtensions(path, /** @type {HTMLElement} */ (extension));
107 }),
108 );
109 } catch (error) {
27 - console.error("Error loading extensions:", error);
110 + console.error("Error loading HTML extensions:", error);
111 }
112 }
113
114 // import all extensions for extension point via backend api
32 -export async function importExtensions(extensionPointId, targetElement) {
115 +/**
116 + * Import all HTML extensions for an extension point and inject them as `<x-component>` tags.
117 + *
118 + * @param {string} extensionPoint
119 + * @param {HTMLElement} targetElement
120 + * @returns {Promise<void>}
121 + */
122 +export async function importHtmlExtensions(extensionPoint, targetElement) {
123 try {
124 + const cachedHtml = htmlExtensionsCache.get(extensionPoint);
125 + if (cachedHtml != null) {
126 + targetElement.innerHTML = cachedHtml;
127 + return;
128 + }
129 +
130 + /** @type {LoadWebuiExtensionsResponse} */
131 const response = await api.callJsonApi(`/api/load_webui_extensions`, {
35 - extension_point: extensionPointId,
132 + extension_point: extensionPoint,
133 + filters: ["*.html", "*.htm", "*.xhtml"],
134 });
135 let combinedHTML = "";
136 for (const extension of response.extensions) {
39 - combinedHTML += extension.html.trim();
137 + const path = normalizePath(extension.path);
138 + combinedHTML += `<x-component path="${path}"></x-component>`;
139 }
140 + htmlExtensionsCache.set(extensionPoint, combinedHTML);
141 targetElement.innerHTML = combinedHTML;
142 } catch (error) {
43 - console.error("Error importing extensions:", error);
143 + console.error("Error importing HTML extensions:", error);
144 + return [];
145 }
146 }
147
148 +/**
149 + * @param {string} path
150 + * @returns {string}
151 + */
152 +function normalizePath(path) {
153 + return path.startsWith("/") ? path : "/" + path;
154 +}
155 +
156 // Watch for DOM changes to dynamically load x-extensions
157 +/** @type {MutationCallback} */
158 const extensionObserver = new MutationObserver((mutations) => {
159 for (const mutation of mutations) {
160 for (const node of mutation.addedNodes) {
161 if (node.nodeType === 1) {
162 // ELEMENT_NODE
163 // Check if this node or its descendants contain x-extension(s)
54 - if (node.matches?.("x-extension")) {
55 - importExtensions(node.getAttribute("id"), node);
164 + const el = /** @type {Element} */ (node);
165 + if (el.matches?.("x-extension")) {
166 + const id = el.getAttribute("id");
167 + if (id) importHtmlExtensions(id, /** @type {HTMLElement} */ (el));
168 } else if (node.querySelectorAll) {
57 - loadExtensions([node]);
169 + loadHtmlExtensions([node]);
170 }
171 }
172 }
webui/js/messages.js
+35 -21
@@ -12,6 +12,7 @@ import { store as stepDetailStore } from "/components/modals/process-step-detail
12 import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
13 import { formatDuration } from "./time-utils.js";
14 import { Scroller } from "./scroller.js";
15 +import { callJsExtensions } from "/js/extensions.js";
16
17 // Delay before collapsing previous steps when a new step is added
18 const STEP_COLLAPSE_DELAY = {
@@ -72,39 +73,52 @@ export function getMessageHandler(type) {
73
74 // entrypoint called from poll/WS communication, this is how all messages are rendered and updated
75 // input is raw log format
75 -export function setMessages(messages) {
76 - // set _massRender flag for handlers to know how to behave
77 - const history = getChatHistoryEl();
78 - const historyEmpty = !history || history.childElementCount === 0;
79 - const isLargeAppend = !historyEmpty && messages.length > 10;
80 - const cutoff = isLargeAppend ? Math.max(0, messages.length - 2) : 0;
81 - const massRender = historyEmpty || isLargeAppend;
82 -
83 - const mainScroller = new Scroller(history, {
84 - smooth: !massRender,
85 - toleranceRem: 4,
86 - reapplyDelayMs: 1000,
87 - applyStabilization: true,
88 - });
76 +export async function setMessages(messages) {
77 + const context = {
78 + messages,
79 + history: getChatHistoryEl(),
80 + historyEmpty: false,
81 + isLargeAppend: false,
82 + cutoff: 0,
83 + massRender: false,
84 + scrollerOptions: {
85 + smooth: true,
86 + toleranceRem: 4,
87 + reapplyDelayMs: 1000,
88 + applyStabilization: true,
89 + },
90 + mainScroller: null,
91 + results: [],
92 + };
93
90 - const results = [];
94 + context.historyEmpty = !context.history || context.history.childElementCount === 0;
95 + context.isLargeAppend = !context.historyEmpty && context.messages.length > 10;
96 + context.cutoff = context.isLargeAppend ? Math.max(0, context.messages.length - 2) : 0;
97 + context.massRender = context.historyEmpty || context.isLargeAppend;
98 + context.scrollerOptions.smooth = !context.massRender;
99 +
100 + await callJsExtensions("set_messages_before_loop", context);
101 +
102 + context.mainScroller = new Scroller(context.history, context.scrollerOptions);
103
104 // process messages
93 - for (let i = 0; i < messages.length; i++) {
94 - _massRender = historyEmpty || (isLargeAppend && i < cutoff);
95 - results.push(setMessage(messages[i]) || {});
105 + for (let i = 0; i < context.messages.length; i++) {
106 + _massRender = context.historyEmpty || (context.isLargeAppend && i < context.cutoff);
107 + context.results.push(setMessage(context.messages[i]) || {});
108 }
109
110 + await callJsExtensions("set_messages_after_loop", context);
111 +
112 // reset _massRender flag
113 _massRender = false;
114
101 - const shouldScroll = historyEmpty || !results[results.length - 1]?.dontScroll;
115 + const shouldScroll = context.historyEmpty || !context.results[context.results.length - 1]?.dontScroll;
116
103 - if (shouldScroll) mainScroller.reApplyScroll();
117 + if (shouldScroll) context.mainScroller.reApplyScroll();
118
119 if (_scrollOnNextProcessGroup === "scroll") {
120 requestAnimationFrame(() => {
107 - mainScroller.scrollToBottom();
121 + context.mainScroller.scrollToBottom();
122 _scrollOnNextProcessGroup = null;
123 });
124 }
webui/js/plugins.js deleted
-47
@@ -1,47 +0,0 @@
1 -// Plugin frontend auto-injection.
2 -// Imported once in initFw.js. The backend resolves plugin components and their
3 -// injection targets (parsed from <meta name="plugin-target"> server-side).
4 -// This module simply creates <x-component> elements at the declared targets;
5 -// the standard components.js MutationObserver handles loading automatically.
6 -import { callJsonApi } from "/js/api.js";
7 -
8 -const injected = new Set();
9 -
10 -function tryInject(entry) {
11 - const key = `${entry.component_url}|${entry.target}`;
12 - if (injected.has(key)) return true;
13 - const host = document.querySelector(entry.target);
14 - if (!host) return false;
15 - injected.add(key);
16 - const el = document.createElement("x-component");
17 - el.setAttribute("path", `../${entry.component_url.replace(/^\/+/, "")}`);
18 - el.className = "plugin-slot-entry";
19 - host.appendChild(el);
20 - return true;
21 -}
22 -
23 -(async () => {
24 - let res;
25 - try {
26 - res = await callJsonApi("/plugins_resolve", {});
27 - } catch (e) {
28 - console.warn("Plugin resolve failed:", e);
29 - return;
30 - }
31 - if (!res.ok || !Array.isArray(res.data)) return;
32 -
33 - // Only auto-inject entries that declare a target; others are standalone (modals etc.)
34 - const pending = res.data.filter(e => e?.component_url && e?.target);
35 - const remaining = pending.filter(e => !tryInject(e));
36 -
37 - // Retry for targets that load after initial render (e.g. sidebar components)
38 - if (remaining.length > 0) {
39 - const obs = new MutationObserver(() => {
40 - for (let i = remaining.length - 1; i >= 0; i--) {
41 - if (tryInject(remaining[i])) remaining.splice(i, 1);
42 - }
43 - if (remaining.length === 0) obs.disconnect();
44 - });
45 - obs.observe(document.body, { childList: true, subtree: true });
46 - }
47 -})();