mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
Merge remote-tracking branch 'origin/master' into feat/webui-notes-sources
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -7,6 +7,12 @@
|
||||
|
||||
- Add a default-off, read-only Third-party notes drawer to the Memory panel that lists configured note/knowledge MCP sources such as Joplin, Obsidian, Notion, and llm-wiki when explicitly enabled with `webui_external_notes_sources` or `HERMES_WEBUI_EXTERNAL_NOTES_SOURCES=1`, while leaving automatic session recall unchanged.
|
||||
|
||||
## [v0.51.118] — 2026-05-22 — Release CP (stage-pr2773 — 1-PR hotfix — v0.51.117 brick fix: chat input restored)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2773** by @nesquena-hermes — fix(chat): rename `_inflightStateLimits()` in `static/ui.js` to `_getInflightStateLimits()` so it no longer collides with the `window._inflightStateLimits` config object set in `static/boot.js`. Closes #2771. The v0.51.117 in-flight-recovery quota fix (#2766) declared a top-level helper with the same name as a window-attached config object; because top-level `function foo(){…}` declarations in classic (non-module) scripts attach to `window`, boot.js's `window._inflightStateLimits = {…}` assignment overwrote the function reference before any session could send. Every new chat broke on first `send()` with `TypeError: _inflightStateLimits is not a function`, leaving v0.51.117 effectively unusable. Renamed the function only (the public-ish window key is unchanged) and updated all 4 call sites. **New regression test `tests/test_window_function_collision.py` scans every static JS file for top-level `function NAME()` declarations whose name is also the target of `window.NAME = {…}` / `= <number>`, the exact shape that broke #2715 (`_pinnedSessionsLimit` in v0.51.106) and #2771 (`_inflightStateLimits` in v0.51.117). The test fails loudly with a precise file:name diagnostic if the bug class returns. Verified end-to-end against the live browser before merge: `_getInflightStateLimits()` returns the limits object and `saveInflightState()` persists to localStorage without throwing.
|
||||
|
||||
## [v0.51.117] — 2026-05-22 — Release CO (stage-pr2766 — 1-PR — in-flight recovery storage quota-safe)
|
||||
|
||||
### Fixed
|
||||
|
||||
+4
-4
@@ -4081,7 +4081,7 @@ function _boundedInflightInt(value, fallback, min, max){
|
||||
if(!Number.isFinite(n)) return fallback;
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
function _inflightStateLimits(){
|
||||
function _getInflightStateLimits(){
|
||||
const configured=(typeof window!=='undefined'&&window._inflightStateLimits&&typeof window._inflightStateLimits==='object')?window._inflightStateLimits:{};
|
||||
return {
|
||||
maxSessions:_boundedInflightInt(configured.maxSessions, INFLIGHT_STATE_DEFAULT_LIMITS.maxSessions, 1, 25),
|
||||
@@ -4110,7 +4110,7 @@ function _isStorageQuotaError(err){
|
||||
);
|
||||
}
|
||||
function _truncateInflightValue(value, maxChars){
|
||||
const limits=_inflightStateLimits();
|
||||
const limits=_getInflightStateLimits();
|
||||
const stringLimit=_boundedInflightInt(maxChars, limits.stringChars, 1000, 500000);
|
||||
if(typeof value==='string'){
|
||||
if(value.length<=stringLimit) return value;
|
||||
@@ -4125,7 +4125,7 @@ function _truncateInflightValue(value, maxChars){
|
||||
return value;
|
||||
}
|
||||
function _compactInflightState(state){
|
||||
const limits=_inflightStateLimits();
|
||||
const limits=_getInflightStateLimits();
|
||||
const messages=Array.isArray(state.messages)?state.messages.slice(-limits.messages):[];
|
||||
const toolCalls=Array.isArray(state.toolCalls)?state.toolCalls.slice(-limits.toolCalls):[];
|
||||
return _truncateInflightValue({
|
||||
@@ -4136,7 +4136,7 @@ function _compactInflightState(state){
|
||||
}, limits.stringChars);
|
||||
}
|
||||
function _writeInflightStateMap(all){
|
||||
const limits=_inflightStateLimits();
|
||||
const limits=_getInflightStateLimits();
|
||||
const entries=Object.entries(all||{})
|
||||
.sort((a,b)=>Number(b[1]&&b[1].updated_at||0)-Number(a[1]&&a[1].updated_at||0))
|
||||
.slice(0,limits.maxSessions);
|
||||
|
||||
@@ -28,7 +28,7 @@ def test_inflight_state_is_compacted_before_localstorage_write():
|
||||
compact_body = _function_body(UI_JS, "_compactInflightState")
|
||||
|
||||
assert "const entry={..._compactInflightState(state),updated_at:Date.now()};" in save_body
|
||||
assert "const limits=_inflightStateLimits();" in compact_body
|
||||
assert "const limits=_getInflightStateLimits();" in compact_body
|
||||
assert ".slice(-limits.messages)" in compact_body
|
||||
assert ".slice(-limits.toolCalls)" in compact_body
|
||||
assert "limits.jsonChars" in UI_JS
|
||||
@@ -49,7 +49,16 @@ def test_inflight_state_limits_are_configurable_from_settings():
|
||||
assert "window._inflightStateLimits={" in BOOT_JS
|
||||
assert "maxSessions:parseInt(s.inflight_state_max_sessions||8,10)||8" in BOOT_JS
|
||||
assert "messages:parseInt(s.inflight_state_max_messages||24,10)||24" in BOOT_JS
|
||||
assert "function _inflightStateLimits()" in UI_JS
|
||||
# The reader function MUST use a different name than the window-attached
|
||||
# config object — top-level `function foo(){}` in non-module scripts
|
||||
# attaches to `window`, so a collision causes boot.js to overwrite the
|
||||
# function with the config object and every later call throws
|
||||
# `_inflightStateLimits is not a function`. See #2771.
|
||||
assert "function _getInflightStateLimits()" in UI_JS
|
||||
assert "function _inflightStateLimits()" not in UI_JS, (
|
||||
"Function name must not collide with window._inflightStateLimits "
|
||||
"config object (#2771)."
|
||||
)
|
||||
assert "window._inflightStateLimits" in UI_JS
|
||||
assert "INFLIGHT_STATE_MAX_SESSIONS = 3" not in UI_JS
|
||||
assert "INFLIGHT_STATE_MAX_MESSAGES = 8" not in UI_JS
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Regression coverage for the function-name × window-attached-config collision class.
|
||||
|
||||
This test guards against a specific failure mode that has caused two
|
||||
brick-class regressions (v0.51.106 #2715 `_pinnedSessionsLimit`, v0.51.117
|
||||
#2771 `_inflightStateLimits`):
|
||||
|
||||
- Some module declares `function foo(){...}` at top level. Since the
|
||||
WebUI ships classic (non-module) scripts via `<script defer>`, top-
|
||||
level function declarations attach to `window` as `window.foo`.
|
||||
- Another module later does `window.foo = {...}` (or `= 8`, etc).
|
||||
- Boot order makes the assignment win, so by the time anyone tries
|
||||
`foo()` they're calling an Object/Number → `TypeError: foo is not a
|
||||
function`.
|
||||
|
||||
This is hard to spot in code review because the function and the config
|
||||
object live in different files and the name choice is locally innocuous.
|
||||
|
||||
The test below scans static JS for any top-level `function foo()` decl
|
||||
whose name also appears as the target of `window.foo = <non-function-
|
||||
non-identifier>`. False-positive shape (which we deliberately exclude):
|
||||
re-binding a function reference onto `window` (`window.foo = foo;` or
|
||||
`window.foo = function(){...};`) — this is the normal "expose to global"
|
||||
pattern.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
STATIC_JS = sorted(
|
||||
p for p in (REPO_ROOT / "static").glob("*.js")
|
||||
if not p.name.endswith(".min.js")
|
||||
)
|
||||
|
||||
|
||||
# Top-of-line: `function NAME(`
|
||||
TOP_LEVEL_FN_RE = re.compile(r"^function\s+([A-Za-z_\$][A-Za-z_\$0-9]*)\s*\(", re.MULTILINE)
|
||||
|
||||
# `window.NAME = <rhs>` where rhs is the next non-space chunk.
|
||||
# We only care about the *value shape* of rhs. Lookahead must be long enough
|
||||
# to see `function(` (8 chars) even after some whitespace; we use 32 chars to
|
||||
# be safe against newlines and odd indenting.
|
||||
WINDOW_ASSIGN_RE = re.compile(
|
||||
r"window\.([A-Za-z_\$][A-Za-z_\$0-9]*)\s*=\s*([^=].{0,32})",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _classify_rhs(rhs: str) -> str:
|
||||
"""Classify the right-hand side of `window.X = rhs`.
|
||||
|
||||
Returns one of:
|
||||
- 'function' — explicit `function(` literal (named or anonymous)
|
||||
- 'identifier' — bare identifier (almost certainly a function reference)
|
||||
- 'object' — `{...}` object literal (THE BUG SHAPE — function got
|
||||
replaced by config object)
|
||||
- 'number' — numeric literal (ALSO BUG SHAPE — #2715 was this)
|
||||
- 'arrow' — `() =>` / `x =>` arrow function (benign re-bind)
|
||||
- 'other' — anything else; treat as suspicious to be safe
|
||||
"""
|
||||
rhs = rhs.lstrip()
|
||||
if rhs.startswith("function"):
|
||||
return "function"
|
||||
if rhs.startswith("{"):
|
||||
return "object"
|
||||
# Arrow function: `(args) =>` or `x =>`
|
||||
if rhs.startswith("(") and "=>" in rhs:
|
||||
return "arrow"
|
||||
if re.match(r"[A-Za-z_\$][A-Za-z_\$0-9]*\s*=>", rhs):
|
||||
return "arrow"
|
||||
# Numeric literal: int or decimal
|
||||
if re.match(r"-?\d", rhs):
|
||||
return "number"
|
||||
# Bare identifier reference: looks like `_foo;` or `_foo,` or `_foo ` etc.
|
||||
# Allow ||, &&, ? chains too (e.g. `window.X = window.X || false;`).
|
||||
if re.match(r"[A-Za-z_\$][A-Za-z_\$0-9]*[\s;,)|&?.]", rhs):
|
||||
return "identifier"
|
||||
return "other"
|
||||
|
||||
|
||||
def test_no_top_level_function_shadowed_by_window_object_assignment():
|
||||
"""Catch the v0.51.106 / v0.51.117 collision class before it ships.
|
||||
|
||||
See #2715 (`_pinnedSessionsLimit` function shadowed by `window._pinnedSessionsLimit = <int>`)
|
||||
and #2771 (`_inflightStateLimits` function shadowed by
|
||||
`window._inflightStateLimits = {...}`). Both broke entire user
|
||||
workflows for everyone on the affected version.
|
||||
"""
|
||||
# Collect all top-level function names across every static JS file.
|
||||
fn_names: dict[str, list[str]] = {}
|
||||
for js_file in STATIC_JS:
|
||||
src = js_file.read_text(encoding="utf-8")
|
||||
for m in TOP_LEVEL_FN_RE.finditer(src):
|
||||
fn_names.setdefault(m.group(1), []).append(js_file.name)
|
||||
|
||||
# Find every window.NAME = <rhs> assignment, classify the rhs.
|
||||
collisions: list[str] = []
|
||||
for js_file in STATIC_JS:
|
||||
src = js_file.read_text(encoding="utf-8")
|
||||
for m in WINDOW_ASSIGN_RE.finditer(src):
|
||||
name, rhs_snippet = m.group(1), m.group(2)
|
||||
if name not in fn_names:
|
||||
continue
|
||||
kind = _classify_rhs(rhs_snippet)
|
||||
if kind in {"function", "identifier", "arrow"}:
|
||||
continue # benign exposure of a function to global scope.
|
||||
# 'object', 'number', and 'other' are the BUG shapes.
|
||||
collisions.append(
|
||||
f"In {js_file.name}: `window.{name} = ...` (rhs={kind!r}) "
|
||||
f"shadows the top-level `function {name}()` declared in "
|
||||
f"{', '.join(fn_names[name])}. This will cause "
|
||||
f"`TypeError: {name} is not a function` once boot.js's "
|
||||
f"assignment overwrites the function. See #2715, #2771."
|
||||
)
|
||||
|
||||
assert not collisions, (
|
||||
"Function-name × window-config collision detected — this is the "
|
||||
"brick-class regression shape from #2715 and #2771:\n - "
|
||||
+ "\n - ".join(collisions)
|
||||
)
|
||||
|
||||
|
||||
def test_inflight_state_limits_no_longer_collides_with_window_config():
|
||||
"""Issue-pinned regression for #2771 specifically.
|
||||
|
||||
Confirms the function rename landed and the old colliding name is gone.
|
||||
"""
|
||||
ui_js = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
boot_js = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
|
||||
# The window-attached config still exists (we deliberately kept this name).
|
||||
assert "window._inflightStateLimits={" in boot_js, (
|
||||
"boot.js should still expose the config under the documented name."
|
||||
)
|
||||
|
||||
# The function must use the renamed identifier.
|
||||
assert "function _getInflightStateLimits()" in ui_js, (
|
||||
"ui.js should declare the limit-reader as `_getInflightStateLimits()` "
|
||||
"to avoid the #2771 collision."
|
||||
)
|
||||
|
||||
# The old colliding name must not appear as a function declaration anywhere.
|
||||
assert "function _inflightStateLimits(" not in ui_js, (
|
||||
"`function _inflightStateLimits()` is the colliding name from #2771 "
|
||||
"and must not be reintroduced."
|
||||
)
|
||||
|
||||
# Every call site uses the new name.
|
||||
assert "_inflightStateLimits()" not in ui_js, (
|
||||
"Stale call sites to the old function name `_inflightStateLimits()` "
|
||||
"remain in ui.js (#2771). Update them to `_getInflightStateLimits()`."
|
||||
)
|
||||
Reference in New Issue
Block a user