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,