Add frontend cache and integrate with extensions

Introduce a reusable frontend cache (webui/js/cache.js) with area-based storage, glob clearing, and enable toggles. Integrate the cache into webui/js/extensions.js (add/get/clear cache usage, define JS/HTML cache area keys, make imported JS extension defaults variadic) and adjust the HTML import/loading flow and MutationObserver callback wiring. Update webui/js/messages.js to support async message handlers (typedef allowing Promise results), make getMessageHandler async and consult extensions for custom handlers, and await setMessage/setMessage handler results. Also remove an unused DockerContainerManager import from python/tools/code_execution_tool.py.

frdel committed Mar 4, 2026 at 16:34 UTC 83d369e7a063d2a21cfcb6f5d2297fa5cfb5b27a
4 files changed +166 -28
python/tools/code_execution_tool.py
-1
@@ -7,7 +7,6 @@ from python.helpers import files, rfc_exchange, projects, runtime, settings
7 from python.helpers.print_style import PrintStyle
8 from python.helpers.shell_local import LocalInteractiveSession
9 from python.helpers.shell_ssh import SSHInteractiveSession
10 -from python.helpers.docker import DockerContainerManager
10 from python.helpers.strings import truncate_text as truncate_text_string
11 from python.helpers.messages import truncate_text as truncate_text_agent
12 import re
webui/js/cache.js new
+125
@@ -0,0 +1,125 @@
1 +let enabledGlobal = true;
2 +
3 +/** @type {Map<string, boolean>} */
4 +const enabledAreas = new Map();
5 +
6 +/** @type {Map<string, Map<string, any>>} */
7 +const cache = new Map();
8 +
9 +export function toggle_global(enabled) {
10 + enabledGlobal = !!enabled;
11 +}
12 +
13 +export function toggle_area(area, enabled) {
14 + enabledAreas.set(area, !!enabled);
15 +}
16 +
17 +export function add(area, key, data) {
18 + if (!isEnabled(area)) return;
19 + let areaCache = cache.get(area);
20 + if (!areaCache) {
21 + areaCache = new Map();
22 + cache.set(area, areaCache);
23 + }
24 + areaCache.set(key, data);
25 +}
26 +
27 +export function get(area, key, defaultValue = null) {
28 + if (!isEnabled(area)) return defaultValue;
29 + const areaCache = cache.get(area);
30 + if (!areaCache) return defaultValue;
31 + return areaCache.has(key) ? areaCache.get(key) : defaultValue;
32 +}
33 +
34 +export function remove(area, key) {
35 + if (!isEnabled(area)) return;
36 + const areaCache = cache.get(area);
37 + if (!areaCache) return;
38 + areaCache.delete(key);
39 +}
40 +
41 +export function clear(area) {
42 + if (hasGlob(area)) {
43 + const re = globToRegExp(area);
44 + for (const k of cache.keys()) {
45 + if (re.test(k)) cache.delete(k);
46 + }
47 + return;
48 + }
49 + cache.delete(area);
50 +}
51 +
52 +export function clear_all() {
53 + cache.clear();
54 +}
55 +
56 +function isEnabled(area) {
57 + if (!enabledGlobal) return false;
58 + const v = enabledAreas.get(area);
59 + return v === undefined ? true : v;
60 +}
61 +
62 +function hasGlob(pattern) {
63 + return /[\*\?\[]/.test(pattern);
64 +}
65 +
66 +function escapeRegExpChar(ch) {
67 + return /[\\^$.*+?()[\]{}|]/.test(ch) ? `\\${ch}` : ch;
68 +}
69 +
70 +function globToRegExp(glob) {
71 + let out = "^";
72 + for (let i = 0; i < glob.length; i++) {
73 + const ch = glob[i];
74 +
75 + if (ch === "*") {
76 + out += ".*";
77 + continue;
78 + }
79 +
80 + if (ch === "?") {
81 + out += ".";
82 + continue;
83 + }
84 +
85 + if (ch === "[") {
86 + const end = glob.indexOf("]", i + 1);
87 + if (end === -1) {
88 + out += "\\[";
89 + continue;
90 + }
91 +
92 + const content = glob.slice(i + 1, end);
93 + let cls = "";
94 + let j = 0;
95 + if (content[0] === "!" || content[0] === "^") {
96 + cls += "^";
97 + j++;
98 + }
99 + for (; j < content.length; j++) {
100 + const c = content[j];
101 + if (c === "\\") {
102 + cls += "\\\\";
103 + continue;
104 + }
105 + if (c === "]") {
106 + cls += "\\]";
107 + continue;
108 + }
109 + if (c === "-") {
110 + cls += "-";
111 + continue;
112 + }
113 + cls += escapeRegExpChar(c);
114 + }
115 +
116 + out += `[${cls}]`;
117 + i = end;
118 + continue;
119 + }
120 +
121 + out += escapeRegExpChar(ch);
122 + }
123 + out += "$";
124 + return new RegExp(out);
125 +}
webui/js/extensions.js
+22 -18
@@ -1,4 +1,5 @@
1 import * as api from "./api.js";
2 +import * as cache from "./cache.js";
3
4 /**
5 * @typedef {string} WebuiExtension
@@ -14,29 +15,26 @@ import * as api from "./api.js";
15 /**
16 * @typedef {Object} JsExtensionImport
17 * @property {string} path
17 - * @property {{ default: (data: any) => (void|Promise<void>) }} module
18 + * @property {{ default: (...data: any[]) => (void|Promise<void>) }} module
19 */
20
20 -/** @type {Map<string, JsExtensionImport[]>} */
21 -const jsExtensionsCache = new Map();
22 -
23 -/** @type {Map<string, string>} */
24 -const htmlExtensionsCache = new Map();
21 +const JS_CACHE_AREA = "frontend_extensions_js(extensions)(plugins)";
22 +const HTML_CACHE_AREA = "frontend_extensions_html(extensions)(plugins)";
23
24 export function invalidateCache() {
27 - jsExtensionsCache.clear();
28 - htmlExtensionsCache.clear();
25 + cache.clear(JS_CACHE_AREA);
26 + cache.clear(HTML_CACHE_AREA);
27 }
28
29 /**
30 * Call all JS extensions for a given extension point.
31 *
32 * @param {string} extensionPoint
35 - * @param {any} data
33 + * @param {...any} data
34 * @returns {Promise<void>}
35 */
36 export async function callJsExtensions(extensionPoint, ...data){
39 - const extensions = jsExtensionsCache.get(extensionPoint) || await loadJsExtensions(extensionPoint);
37 + const extensions = cache.get(JS_CACHE_AREA, extensionPoint, null) || await loadJsExtensions(extensionPoint);
38 for(const extension of extensions){
39 try{
40 await extension.module.default(...data);
@@ -54,6 +52,9 @@ export async function callJsExtensions(extensionPoint, ...data){
52 */
53 export async function loadJsExtensions(extensionPoint) {
54 try {
55 + const cached = cache.get(JS_CACHE_AREA, extensionPoint, null);
56 + if (cached != null) return cached;
57 +
58 /** @type {LoadWebuiExtensionsResponse} */
59 const response = await api.callJsonApi(`/api/load_webui_extensions`, {
60 extension_point: extensionPoint,
@@ -66,7 +67,7 @@ export async function loadJsExtensions(extensionPoint) {
67 module: await import(normalizePath(path))
68 }))
69 );
69 - jsExtensionsCache.set(extensionPoint, imports);
70 + cache.add(JS_CACHE_AREA, extensionPoint, imports);
71 return imports;
72 } catch (error) {
73 console.error("Error loading JS extensions:", error);
@@ -120,7 +121,7 @@ export async function loadHtmlExtensions(roots = [document.documentElement]) {
121 */
122 export async function importHtmlExtensions(extensionPoint, targetElement) {
123 try {
123 - const cachedHtml = htmlExtensionsCache.get(extensionPoint);
124 + const cachedHtml = cache.get(HTML_CACHE_AREA, extensionPoint, null);
125 if (cachedHtml != null) {
126 targetElement.innerHTML = cachedHtml;
127 return;
@@ -136,11 +137,11 @@ export async function importHtmlExtensions(extensionPoint, targetElement) {
137 const path = normalizePath(extension);
138 combinedHTML += `<x-component path="${path}"></x-component>`;
139 }
139 - htmlExtensionsCache.set(extensionPoint, combinedHTML);
140 + cache.add(HTML_CACHE_AREA, extensionPoint, combinedHTML);
141 targetElement.innerHTML = combinedHTML;
142 } catch (error) {
143 console.error("Error importing HTML extensions:", error);
143 - return [];
144 + return;
145 }
146 }
147
@@ -154,7 +155,7 @@ function normalizePath(path) {
155
156 // Watch for DOM changes to dynamically load x-extensions
157 /** @type {MutationCallback} */
157 -const extensionObserver = new MutationObserver((mutations) => {
158 +const extensionObserverCallback = (mutations) => {
159 for (const mutation of mutations) {
160 for (const node of mutation.addedNodes) {
161 if (node.nodeType === 1) {
@@ -164,11 +165,14 @@ const extensionObserver = new MutationObserver((mutations) => {
165 if (el.matches?.("x-extension")) {
166 const id = el.getAttribute("id");
167 if (id) importHtmlExtensions(id, /** @type {HTMLElement} */ (el));
167 - } else if (node.querySelectorAll) {
168 - loadHtmlExtensions([node]);
168 + } else if (/** @type {any} */ (el)["querySelectorAll"]) {
169 + loadHtmlExtensions([el]);
170 }
171 }
172 }
173 }
173 -});
174 +};
175 +
176 +/** @type {MutationObserver} */
177 +const extensionObserver = new MutationObserver(extensionObserverCallback);
178 extensionObserver.observe(document.body, { childList: true, subtree: true });
webui/js/messages.js
+19 -9
@@ -52,7 +52,7 @@ let _scrollOnNextProcessGroup = null;
52 */
53
54 /**
55 - * @typedef {(args: MessageHandlerArgs & Record<string, any>) => MessageHandlerResult} MessageHandler
55 + * @typedef {(args: MessageHandlerArgs & Record<string, any>) => (MessageHandlerResult|Promise<MessageHandlerResult>)} MessageHandler
56 */
57
58 /**
@@ -82,9 +82,9 @@ export function scrollOnNextProcessGroup() {
82 * and may return a rich object `{ element, actionButtons?, ...additional }`.
83 *
84 * @param {string} type
85 - * @returns {MessageHandler}
85 + * @returns {Promise<MessageHandler>}
86 */
87 -export function getMessageHandler(type) {
87 +export async function getMessageHandler(type) {
88 switch (type) {
89 case "user":
90 return drawMessageUser;
@@ -117,10 +117,20 @@ export function getMessageHandler(type) {
117 case "hint":
118 return drawMessageHint;
119 default:
120 - return drawMessageDefault;
120 + return await getHandlerFromExtensions(type);
121 + }
122 +
123 + async function getHandlerFromExtensions(type){
124 + const extData = { type: type, handler: undefined }
125 + await callJsExtensions("getMessageHandler", extData);
126 + // return handler from extensions
127 + if(typeof extData.handler == "function") return extData.handler;
128 + //not set by extensions, return default
129 + return drawMessageDefault;
130 }
131 }
132
133 +
134 // entrypoint called from poll/WS communication, this is how all messages are rendered and updated
135 // input is raw log format
136 export async function setMessages(messages) {
@@ -157,7 +167,7 @@ export async function setMessages(messages) {
167 // process messages
168 for (let i = 0; i < context.messages.length; i++) {
169 _massRender = context.historyEmpty || (context.isLargeAppend && i < context.cutoff);
160 - context.results.push(setMessage(context.messages[i]));
170 + context.results.push(await setMessage(context.messages[i]));
171 }
172
173 await callJsExtensions("set_messages_after_loop", context);
@@ -181,9 +191,9 @@ export async function setMessages(messages) {
191 // input is raw log format
192 /**
193 * @param {MessageHandlerArgs & Record<string, any>} param0
184 - * @returns {SetMessageResult}
194 + * @returns {Promise<SetMessageResult>}
195 */
186 -export function setMessage({
196 +export async function setMessage({
197 no,
198 id,
199 type,
@@ -194,9 +204,9 @@ export function setMessage({
204 agentno,
205 ...additional
206 }) {
197 - const handler = getMessageHandler(type);
207 + const handler = await getMessageHandler(type);
208 // prefer log ID if set to match user message created on frontend with backend updates
199 - const handlerResult = handler({
209 + const handlerResult = await handler({
210 no,
211 id: id || String(no) || "",
212 type,