mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 19:20:16 +00:00
Merge remote-tracking branch 'origin/master' into feat/webui-notes-sources
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -7,6 +7,18 @@
|
||||
|
||||
- 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.117] — 2026-05-22 — Release CO (stage-pr2766 — 1-PR — in-flight recovery storage quota-safe)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2766** by @george-andraws — Make in-flight recovery storage quota-safe so a full browser `localStorage` budget no longer blocks chat submission with `QuotaExceededError: Failed to execute 'setItem' on 'Storage': Setting the value of 'hermes-webui-inflight' exceeded the quota.` The fix has four parts: (1) compact recovery snapshots before writing to keep only recent messages, tool calls, uploaded-file metadata, stream id, and updated timestamp; (2) truncate individual large strings per field (`60000` chars default) and prune the serialized payload (`1500000` chars default); (3) catch quota errors specifically on `markInflight()`, drop the larger `hermes-webui-inflight-state` key, then retry the tiny marker write; (4) expose 5 new configurable settings (`inflight_state_max_sessions`, `_max_messages`, `_max_tool_calls`, `_max_string_chars`, `_max_json_chars`) with int-range validation so operators can tune the budget. Self-healing on the very first chat submit after upgrade for users with already-quota-exhausted storage — no manual reload required. Graceful degradation if storage is still too full even after compaction (clears recovery snapshots but never blocks chat submit).
|
||||
|
||||
## [v0.51.116] — 2026-05-22 — Release CN (stage-pr2676 — 1-PR — per-skill enable/disable toggle in Skills panel, CLI-parity with `hermes skills config`)
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2676** by @lucasrc — Each skill in the Skills panel now has a toggle pill (enabled/disabled) so users can turn individual skills on or off directly from the WebUI without editing `config.yaml`. Achieves parity with the existing `hermes skills config` CLI subcommand (interactive TUI that toggles `skills.disabled` in config). The disabled state is mirrored through to `skills.platform_disabled.webui` when that key is present. Disabled skills remain visible in the panel (muted via `opacity: .45`) instead of being filtered out, so users can re-enable them later. New endpoint: `POST /api/skills/toggle` validates the skill exists in the filesystem before mutating config, wraps the YAML read-modify-write under the existing `_cfg_lock` for thread safety, and calls `reload_config()` so the change takes effect immediately. Toggle pill uses theme variables (`--accent-bg-strong`, `--accent`, `--border`, `--muted`, `--accent-text`) so it adapts automatically to each skin: gold for default, red for ares, blue for poseidon, purple for sisyphus, grey for mono — verified empirically across light + dark variants. i18n keys (`skill_enabled`, `skill_disabled`, `skill_toggle_failed`) translated across all 10 locales. Default-state safety: fresh installs (no `skills.disabled` key in config) return `disabled: False` for every skill — no regression risk for new users.
|
||||
|
||||
## [v0.51.115] — 2026-05-22 — Release CM (stage-pr2731 — 1-PR — clarify prompt collapse/expand with chevron-icon polish)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -4355,6 +4355,11 @@ _SETTINGS_DEFAULTS = {
|
||||
"session_jump_buttons": False, # show Start/End transcript jump pills
|
||||
"session_endless_scroll": False, # auto-load older transcript pages while scrolling upward
|
||||
"pinned_sessions_limit": 3, # maximum active pinned sessions shown in the sidebar
|
||||
"inflight_state_max_sessions": 8, # max active-stream recovery snapshots kept in browser localStorage
|
||||
"inflight_state_max_messages": 24, # max recent messages kept per recovery snapshot
|
||||
"inflight_state_max_tool_calls": 48, # max recent tool-call records kept per recovery snapshot
|
||||
"inflight_state_max_string_chars": 60000, # max string length kept inside a recovery snapshot field
|
||||
"inflight_state_max_json_chars": 1500000, # max serialized recovery snapshot payload before pruning
|
||||
"hidden_tabs": [], # sidebar tab panel names hidden by user (e.g. ["tasks","kanban"]); chat and settings are always visible
|
||||
"language": "en", # UI locale code; must match a key in static/i18n.js LOCALES
|
||||
"bot_name": os.getenv(
|
||||
@@ -4485,6 +4490,11 @@ _SETTINGS_ENUM_VALUES = {
|
||||
}
|
||||
_SETTINGS_INT_RANGES = {
|
||||
"pinned_sessions_limit": (1, 99),
|
||||
"inflight_state_max_sessions": (1, 25),
|
||||
"inflight_state_max_messages": (1, 100),
|
||||
"inflight_state_max_tool_calls": (1, 200),
|
||||
"inflight_state_max_string_chars": (1000, 500000),
|
||||
"inflight_state_max_json_chars": (100000, 4000000),
|
||||
}
|
||||
_SETTINGS_BOOL_KEYS = {
|
||||
"onboarding_completed",
|
||||
|
||||
+85
-1
@@ -231,7 +231,7 @@ def _skills_list_from_dir(skills_dir: Path, category: str | None = None) -> dict
|
||||
if not skill_matches_platform(frontmatter):
|
||||
continue
|
||||
name = frontmatter.get("name", skill_dir.name)[:64]
|
||||
if name in seen_names or name in disabled:
|
||||
if name in seen_names:
|
||||
continue
|
||||
description = frontmatter.get("description", "")
|
||||
if not description:
|
||||
@@ -248,6 +248,7 @@ def _skills_list_from_dir(skills_dir: Path, category: str | None = None) -> dict
|
||||
"name": name,
|
||||
"description": description,
|
||||
"category": _skill_category_from_path(skill_md, search_dirs),
|
||||
"disabled": name in disabled,
|
||||
}
|
||||
)
|
||||
except (UnicodeDecodeError, PermissionError) as e:
|
||||
@@ -939,6 +940,11 @@ from api.config import (
|
||||
get_webui_session_save_mode,
|
||||
STREAM_GOAL_RELATED,
|
||||
PENDING_GOAL_CONTINUATION,
|
||||
_get_config_path,
|
||||
_load_yaml_config_file,
|
||||
_save_yaml_config_file,
|
||||
reload_config,
|
||||
_cfg_lock,
|
||||
)
|
||||
from api.helpers import (
|
||||
require,
|
||||
@@ -5492,6 +5498,9 @@ def handle_post(handler, parsed) -> bool:
|
||||
if parsed.path == "/api/skills/delete":
|
||||
return _handle_skill_delete(handler, body)
|
||||
|
||||
if parsed.path == "/api/skills/toggle":
|
||||
return _handle_skill_toggle(handler, body)
|
||||
|
||||
# ── Memory (POST) ──
|
||||
if parsed.path == "/api/memory/write":
|
||||
return _handle_memory_write(handler, body)
|
||||
@@ -10959,6 +10968,81 @@ def _handle_skill_delete(handler, body):
|
||||
return j(handler, {"ok": True, "name": body["name"]})
|
||||
|
||||
|
||||
def _normalize_names_list(names) -> list[str]:
|
||||
"""Normalize a config value (None/str/list) into a deduplicated str list."""
|
||||
if names is None:
|
||||
return []
|
||||
if isinstance(names, str):
|
||||
names = [names]
|
||||
elif not isinstance(names, list):
|
||||
names = list(names) if names else []
|
||||
return list(dict.fromkeys(str(d).strip() for d in names if str(d).strip()))
|
||||
|
||||
|
||||
def _toggle_name_in_list(names, name: str, enabled: bool) -> list[str]:
|
||||
"""Add or remove *name* from *names*, returning a new list."""
|
||||
names = _normalize_names_list(names)
|
||||
if enabled:
|
||||
return [d for d in names if d != name]
|
||||
if name not in names:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def _handle_skill_toggle(handler, body):
|
||||
"""Toggle a skill's enabled/disabled state in the active profile's config.yaml.
|
||||
|
||||
Writes through to ``skills.platform_disabled.webui`` when that key exists
|
||||
so the toggle takes effect for WebUI sessions (the agent's
|
||||
``get_disabled_skill_names`` checks platform-specific lists first when
|
||||
``HERMES_SESSION_PLATFORM`` is set).
|
||||
"""
|
||||
try:
|
||||
require(body, "name", "enabled")
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
|
||||
name = body["name"].strip()
|
||||
enabled = bool(body["enabled"])
|
||||
|
||||
# Validate the skill exists in the filesystem
|
||||
skills_dir = _active_skills_dir()
|
||||
search_dirs = _active_skill_search_dirs(skills_dir)
|
||||
skill_dir, skill_md = _find_skill_in_dirs(name, search_dirs)
|
||||
if not skill_md:
|
||||
return bad(handler, f"Skill '{name}' not found", 404)
|
||||
|
||||
config_path = _get_config_path()
|
||||
with _cfg_lock:
|
||||
cfg = _load_yaml_config_file(config_path)
|
||||
|
||||
# Ensure skills section exists as a dict
|
||||
if "skills" not in cfg or not isinstance(cfg["skills"], dict):
|
||||
cfg["skills"] = {}
|
||||
skills_cfg = cfg["skills"]
|
||||
|
||||
# Always update the global disabled list
|
||||
skills_cfg["disabled"] = _toggle_name_in_list(
|
||||
skills_cfg.get("disabled"), name, enabled
|
||||
)
|
||||
|
||||
# Write-through to platform_disabled.webui if it exists so that the
|
||||
# toggle takes effect for WebUI sessions (the agent checks the
|
||||
# platform-specific list first when HERMES_SESSION_PLATFORM=webui).
|
||||
platform_disabled = skills_cfg.get("platform_disabled")
|
||||
if isinstance(platform_disabled, dict) and "webui" in platform_disabled:
|
||||
platform_disabled["webui"] = _toggle_name_in_list(
|
||||
platform_disabled["webui"], name, enabled
|
||||
)
|
||||
|
||||
cfg["skills"] = skills_cfg
|
||||
_save_yaml_config_file(config_path, cfg)
|
||||
|
||||
reload_config() # outside with block — reload_config() acquires the lock itself
|
||||
|
||||
return j(handler, {"ok": True, "name": name, "enabled": enabled})
|
||||
|
||||
|
||||
def _handle_memory_write(handler, body):
|
||||
try:
|
||||
require(body, "section", "content")
|
||||
|
||||
@@ -1459,6 +1459,13 @@ function applyBotName(){
|
||||
window._simplifiedToolCalling=s.simplified_tool_calling!==false;
|
||||
window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');
|
||||
window._pinnedSessionsLimit=parseInt(s.pinned_sessions_limit||3,10)||3;
|
||||
window._inflightStateLimits={
|
||||
maxSessions:parseInt(s.inflight_state_max_sessions||8,10)||8,
|
||||
messages:parseInt(s.inflight_state_max_messages||24,10)||24,
|
||||
toolCalls:parseInt(s.inflight_state_max_tool_calls||48,10)||48,
|
||||
stringChars:parseInt(s.inflight_state_max_string_chars||60000,10)||60000,
|
||||
jsonChars:parseInt(s.inflight_state_max_json_chars||1500000,10)||1500000,
|
||||
};
|
||||
window._busyInputMode=(s.busy_input_mode||'queue');
|
||||
window._sessionEndlessScrollEnabled=!!s.session_endless_scroll;
|
||||
window._botName=s.bot_name||'Hermes';
|
||||
|
||||
@@ -1050,6 +1050,9 @@ const LOCALES = {
|
||||
clear_conversation_message: 'Clear all messages? This cannot be undone.',
|
||||
clear_failed: 'Clear failed: ',
|
||||
skills_no_match: 'No skills match.',
|
||||
skill_enabled: 'Enabled',
|
||||
skill_disabled: 'Disabled',
|
||||
skill_toggle_failed: 'Failed to toggle skill: ',
|
||||
linked_files: 'Linked Files',
|
||||
skill_load_failed: 'Could not load skill: ',
|
||||
skill_file_load_failed: 'Could not load file: ',
|
||||
@@ -2286,6 +2289,9 @@ const LOCALES = {
|
||||
clear_conversation_message: 'Cancellare tutti i messaggi? Questa azione non può essere annullata.',
|
||||
clear_failed: 'Cancellazione fallita: ',
|
||||
skills_no_match: 'Nessuna skill corrispondente.',
|
||||
skill_enabled: 'Abilitato',
|
||||
skill_disabled: 'Disabilitato',
|
||||
skill_toggle_failed: 'Errore nell\'attivare la skill: ',
|
||||
linked_files: 'File Collegati',
|
||||
skill_load_failed: 'Impossibile caricare la skill: ',
|
||||
skill_file_load_failed: 'Impossibile caricare il file: ',
|
||||
@@ -3527,6 +3533,9 @@ const LOCALES = {
|
||||
clear_conversation_message: 'すべてのメッセージをクリアしますか? この操作は取り消せません。',
|
||||
clear_failed: 'クリア失敗: ',
|
||||
skills_no_match: '一致するスキルがありません。',
|
||||
skill_enabled: '有効',
|
||||
skill_disabled: '無効',
|
||||
skill_toggle_failed: 'スキルの切り替えに失敗しました: ',
|
||||
linked_files: 'リンクされたファイル',
|
||||
skill_load_failed: 'スキルを読み込めませんでした: ',
|
||||
skill_file_load_failed: 'ファイルを読み込めませんでした: ',
|
||||
@@ -4514,6 +4523,9 @@ const LOCALES = {
|
||||
clear_conversation_message: 'Очистить все сообщения? Это действие нельзя отменить.',
|
||||
clear_failed: 'Не удалось очистить: ',
|
||||
skills_no_match: 'Подходящих навыков не найдено.',
|
||||
skill_enabled: 'Включено',
|
||||
skill_disabled: 'Отключено',
|
||||
skill_toggle_failed: 'Не удалось переключить навык: ',
|
||||
linked_files: 'Связанные файлы',
|
||||
skill_load_failed: 'Не удалось загрузить навык: ',
|
||||
skill_file_load_failed: 'Не удалось загрузить файл: ',
|
||||
@@ -5694,6 +5706,9 @@ const LOCALES = {
|
||||
clear_conversation_message: 'Clear all messages? This cannot be undone.',
|
||||
clear_failed: 'Clear failed: ',
|
||||
skills_no_match: 'No skills match.',
|
||||
skill_enabled: 'Habilitado',
|
||||
skill_disabled: 'Deshabilitado',
|
||||
skill_toggle_failed: 'Error al cambiar skill: ',
|
||||
linked_files: 'Linked Files',
|
||||
skill_load_failed: 'Could not load skill: ',
|
||||
skill_file_load_failed: 'Could not load file: ',
|
||||
@@ -7059,6 +7074,9 @@ const LOCALES = {
|
||||
clear_conversation_message: 'Alle Nachrichten werden gelöscht.',
|
||||
clear_failed: 'Löschen fehlgeschlagen.',
|
||||
skills_no_match: 'Keine passende Fähigkeit.',
|
||||
skill_enabled: 'Aktiviert',
|
||||
skill_disabled: 'Deaktiviert',
|
||||
skill_toggle_failed: 'Fehler beim Umschalten der Fähigkeit: ',
|
||||
linked_files: 'Verknüpfte Dateien',
|
||||
skill_load_failed: 'Fähigkeit konnte nicht geladen werden.',
|
||||
skill_file_load_failed: 'Datei konnte nicht geladen werden.',
|
||||
@@ -8051,6 +8069,9 @@ const LOCALES = {
|
||||
clear_conversation_message: '要清空所有消息吗?此操作无法撤销。',
|
||||
clear_failed: '清空失败:',
|
||||
skills_no_match: '没有匹配的技能。',
|
||||
skill_enabled: '已启用',
|
||||
skill_disabled: '已禁用',
|
||||
skill_toggle_failed: '切换技能失败:',
|
||||
linked_files: '关联文件',
|
||||
skill_load_failed: '加载技能失败:',
|
||||
skill_file_load_failed: '加载文件失败:',
|
||||
@@ -10549,6 +10570,9 @@ const LOCALES = {
|
||||
clear_conversation_message: 'Limpar todas mensagens? Isso não pode ser desfeito.',
|
||||
clear_failed: 'Falha ao limpar: ',
|
||||
skills_no_match: 'Nenhuma skill corresponde.',
|
||||
skill_enabled: 'Habilitado',
|
||||
skill_disabled: 'Desabilitado',
|
||||
skill_toggle_failed: 'Falha ao alternar skill: ',
|
||||
linked_files: 'Arquivos vinculados',
|
||||
skill_load_failed: 'Não foi possível carregar skill: ',
|
||||
skill_file_load_failed: 'Não foi possível carregar arquivo: ',
|
||||
@@ -11689,6 +11713,9 @@ const LOCALES = {
|
||||
clear_conversation_message: 'Clear all messages? This cannot be undone.',
|
||||
clear_failed: 'Clear failed: ',
|
||||
skills_no_match: 'No skills match.',
|
||||
skill_enabled: '활성화됨',
|
||||
skill_disabled: '비활성화됨',
|
||||
skill_toggle_failed: '스킬 전환 실패: ',
|
||||
linked_files: 'Linked Files',
|
||||
skill_load_failed: 'Could not load skill: ',
|
||||
skill_file_load_failed: 'Could not load file: ',
|
||||
@@ -12847,6 +12874,9 @@ const LOCALES = {
|
||||
clear_conversation_message: 'Effacer tous les messages ? Cela ne peut pas être annulé.',
|
||||
clear_failed: 'Échec de la suppression :',
|
||||
skills_no_match: 'Aucune compétence ne correspond.',
|
||||
skill_enabled: 'Activé',
|
||||
skill_disabled: 'Désactivé',
|
||||
skill_toggle_failed: 'Échec du basculement de compétence: ',
|
||||
linked_files: 'Fichiers liés',
|
||||
skill_load_failed: 'Impossible de charger la compétence :',
|
||||
skill_file_load_failed: 'Impossible de charger le fichier :',
|
||||
|
||||
+38
-2
@@ -3315,9 +3315,23 @@ function renderSkills(skills) {
|
||||
sec.appendChild(hdr);
|
||||
for (const skill of items.sort((a,b) => a.name.localeCompare(b.name))) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'skill-item';
|
||||
el.className = 'skill-item' + (skill.disabled ? ' disabled' : '');
|
||||
el.style.display = collapsed ? 'none' : '';
|
||||
el.innerHTML = `<span class="skill-name">${esc(skill.name)}</span><span class="skill-desc">${esc(skill.description||'')}</span>`;
|
||||
const isDisabled = skill.disabled || false;
|
||||
const toggle = document.createElement('span');
|
||||
toggle.className = 'skill-toggle' + (isDisabled ? '' : ' enabled');
|
||||
toggle.title = isDisabled ? t('skill_disabled') : t('skill_enabled');
|
||||
toggle.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
toggleSkill(skill.name, !isDisabled);
|
||||
});
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'skill-name';
|
||||
nameEl.textContent = skill.name;
|
||||
const descEl = document.createElement('span');
|
||||
descEl.className = 'skill-desc';
|
||||
descEl.textContent = skill.description || '';
|
||||
el.append(toggle, nameEl, descEl);
|
||||
el.onclick = () => openSkill(skill.name, el);
|
||||
sec.appendChild(el);
|
||||
}
|
||||
@@ -3329,6 +3343,28 @@ function filterSkills() {
|
||||
if (_skillsData) renderSkills(_skillsData);
|
||||
}
|
||||
|
||||
|
||||
async function toggleSkill(name, currentlyEnabled) {
|
||||
const newEnabled = !currentlyEnabled;
|
||||
try {
|
||||
const result = await api('/api/skills/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, enabled: newEnabled })
|
||||
});
|
||||
if (result && result.ok) {
|
||||
if (_skillsData) {
|
||||
const skill = _skillsData.find(s => s.name === name);
|
||||
if (skill) skill.disabled = !newEnabled;
|
||||
}
|
||||
renderSkills(_skillsData || []);
|
||||
} else {
|
||||
setStatus((result && result.error) || t('skill_toggle_failed'));
|
||||
}
|
||||
} catch(e) {
|
||||
setStatus(t('skill_toggle_failed') + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Currently selected skill detail — kept across panel switches so re-entering
|
||||
// the Skills view shows the last-viewed skill.
|
||||
let _currentSkillDetail = null; // { name, category, content }
|
||||
|
||||
@@ -1097,6 +1097,12 @@
|
||||
.skill-item.active{background:var(--accent-bg);color:var(--accent-text);}
|
||||
.skill-name{font-weight:500;flex-shrink:0;}
|
||||
.skill-desc{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;font-size:11px;opacity:.7;}
|
||||
.skill-item.disabled{opacity:.5;}
|
||||
.skill-item.disabled .skill-name{opacity:.45;}
|
||||
.skill-toggle{flex-shrink:0;width:28px;height:14px;border-radius:7px;border:1px solid var(--border2);background:var(--border);cursor:pointer;position:relative;transition:all .15s;margin-top:3px;}
|
||||
.skill-toggle.enabled{background:var(--accent-bg-strong);border-color:var(--accent);}
|
||||
.skill-toggle::after{content:'';position:absolute;top:1px;left:1px;width:10px;height:10px;border-radius:50%;background:var(--muted);transition:transform .15s;}
|
||||
.skill-toggle.enabled::after{transform:translateX(14px);background:var(--accent-text);}
|
||||
/* Memory panel */
|
||||
.memory-panel{flex:1;overflow-y:auto;padding:12px;}
|
||||
.memory-section{margin-bottom:16px;}
|
||||
|
||||
+98
-4
@@ -4068,6 +4068,29 @@ function autoReadLastAssistant(){
|
||||
// ── Reconnect banner (B4/B5: reload resilience) ──
|
||||
const INFLIGHT_KEY = 'hermes-webui-inflight'; // localStorage key for in-flight session tracking
|
||||
const INFLIGHT_STATE_KEY = 'hermes-webui-inflight-state'; // localStorage snapshots for mid-stream reload recovery
|
||||
const INFLIGHT_STATE_DEFAULT_LIMITS = {
|
||||
maxSessions:8,
|
||||
messages:24,
|
||||
toolCalls:48,
|
||||
stringChars:60000,
|
||||
jsonChars:1500000,
|
||||
};
|
||||
|
||||
function _boundedInflightInt(value, fallback, min, max){
|
||||
const n=parseInt(value,10);
|
||||
if(!Number.isFinite(n)) return fallback;
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
function _inflightStateLimits(){
|
||||
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),
|
||||
messages:_boundedInflightInt(configured.messages, INFLIGHT_STATE_DEFAULT_LIMITS.messages, 1, 100),
|
||||
toolCalls:_boundedInflightInt(configured.toolCalls, INFLIGHT_STATE_DEFAULT_LIMITS.toolCalls, 1, 200),
|
||||
stringChars:_boundedInflightInt(configured.stringChars, INFLIGHT_STATE_DEFAULT_LIMITS.stringChars, 1000, 500000),
|
||||
jsonChars:_boundedInflightInt(configured.jsonChars, INFLIGHT_STATE_DEFAULT_LIMITS.jsonChars, 100000, 4000000),
|
||||
};
|
||||
}
|
||||
|
||||
function _readInflightStateMap(){
|
||||
try{
|
||||
@@ -4078,13 +4101,75 @@ function _readInflightStateMap(){
|
||||
return {};
|
||||
}
|
||||
}
|
||||
function _isStorageQuotaError(err){
|
||||
return !!err && (
|
||||
err.name==='QuotaExceededError' ||
|
||||
err.name==='NS_ERROR_DOM_QUOTA_REACHED' ||
|
||||
err.code===22 ||
|
||||
err.code===1014
|
||||
);
|
||||
}
|
||||
function _truncateInflightValue(value, maxChars){
|
||||
const limits=_inflightStateLimits();
|
||||
const stringLimit=_boundedInflightInt(maxChars, limits.stringChars, 1000, 500000);
|
||||
if(typeof value==='string'){
|
||||
if(value.length<=stringLimit) return value;
|
||||
return value.slice(0,stringLimit)+'\n\n[truncated for browser recovery storage]';
|
||||
}
|
||||
if(Array.isArray(value)) return value.map(v=>_truncateInflightValue(v, Math.max(2000, Math.floor(stringLimit/2))));
|
||||
if(value&&typeof value==='object'){
|
||||
const out={};
|
||||
for(const [k,v] of Object.entries(value)) out[k]=_truncateInflightValue(v, stringLimit);
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function _compactInflightState(state){
|
||||
const limits=_inflightStateLimits();
|
||||
const messages=Array.isArray(state.messages)?state.messages.slice(-limits.messages):[];
|
||||
const toolCalls=Array.isArray(state.toolCalls)?state.toolCalls.slice(-limits.toolCalls):[];
|
||||
return _truncateInflightValue({
|
||||
streamId:state.streamId||null,
|
||||
messages,
|
||||
uploaded:Array.isArray(state.uploaded)?state.uploaded.slice(-20):[],
|
||||
toolCalls,
|
||||
}, limits.stringChars);
|
||||
}
|
||||
function _writeInflightStateMap(all){
|
||||
const limits=_inflightStateLimits();
|
||||
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);
|
||||
const compact={};
|
||||
for(const [sid,entry] of entries) compact[sid]=entry;
|
||||
let json=JSON.stringify(compact);
|
||||
if(json.length>limits.jsonChars){
|
||||
const current=entries[0];
|
||||
json=JSON.stringify(current?{[current[0]]:current[1]}:{});
|
||||
}
|
||||
if(json.length>limits.jsonChars){
|
||||
localStorage.removeItem(INFLIGHT_STATE_KEY);
|
||||
return false;
|
||||
}
|
||||
localStorage.setItem(INFLIGHT_STATE_KEY,json);
|
||||
return true;
|
||||
}
|
||||
function saveInflightState(sid, state){
|
||||
if(!sid||!state) return;
|
||||
const entry={..._compactInflightState(state),updated_at:Date.now()};
|
||||
try{
|
||||
const all=_readInflightStateMap();
|
||||
all[sid]={...state,updated_at:Date.now()};
|
||||
localStorage.setItem(INFLIGHT_STATE_KEY, JSON.stringify(all));
|
||||
}catch(_){ }
|
||||
all[sid]=entry;
|
||||
_writeInflightStateMap(all);
|
||||
}catch(err){
|
||||
if(!_isStorageQuotaError(err)) return;
|
||||
try{
|
||||
localStorage.removeItem(INFLIGHT_STATE_KEY);
|
||||
_writeInflightStateMap({[sid]:entry});
|
||||
}catch(_){
|
||||
try{localStorage.removeItem(INFLIGHT_STATE_KEY);}catch(__){}
|
||||
}
|
||||
}
|
||||
}
|
||||
function loadInflightState(sid, streamId){
|
||||
if(!sid) return null;
|
||||
@@ -4171,7 +4256,16 @@ function restoreLiveTurnHtmlForSession(sid){
|
||||
}
|
||||
|
||||
function markInflight(sid, streamId) {
|
||||
localStorage.setItem(INFLIGHT_KEY, JSON.stringify({sid, streamId, ts: Date.now()}));
|
||||
const payload=JSON.stringify({sid, streamId, ts: Date.now()});
|
||||
try{
|
||||
localStorage.setItem(INFLIGHT_KEY, payload);
|
||||
}catch(err){
|
||||
if(!_isStorageQuotaError(err)) return;
|
||||
try{
|
||||
localStorage.removeItem(INFLIGHT_STATE_KEY);
|
||||
localStorage.setItem(INFLIGHT_KEY, payload);
|
||||
}catch(_){}
|
||||
}
|
||||
}
|
||||
function clearInflight() {
|
||||
localStorage.removeItem(INFLIGHT_KEY);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Regression coverage for browser in-flight localStorage quota handling."""
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
CONFIG_PY = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8")
|
||||
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _function_body(src: str, name: str) -> str:
|
||||
marker = f"function {name}"
|
||||
start = src.index(marker)
|
||||
brace = src.index("{", start)
|
||||
depth = 1
|
||||
i = brace + 1
|
||||
while depth and i < len(src):
|
||||
if src[i] == "{":
|
||||
depth += 1
|
||||
elif src[i] == "}":
|
||||
depth -= 1
|
||||
i += 1
|
||||
return src[brace + 1 : i - 1]
|
||||
|
||||
|
||||
def test_inflight_state_is_compacted_before_localstorage_write():
|
||||
"""Persisted recovery state must stay bounded instead of storing full long sessions."""
|
||||
save_body = _function_body(UI_JS, "saveInflightState")
|
||||
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 ".slice(-limits.messages)" in compact_body
|
||||
assert ".slice(-limits.toolCalls)" in compact_body
|
||||
assert "limits.jsonChars" in UI_JS
|
||||
|
||||
|
||||
def test_inflight_state_limits_are_configurable_from_settings():
|
||||
"""Recovery snapshots should be bounded by settings, not hardcoded at 3 sessions / 8 messages."""
|
||||
assert '\"inflight_state_max_sessions\": 8' in CONFIG_PY
|
||||
assert '\"inflight_state_max_messages\": 24' in CONFIG_PY
|
||||
assert '\"inflight_state_max_tool_calls\": 48' in CONFIG_PY
|
||||
assert '\"inflight_state_max_string_chars\": 60000' in CONFIG_PY
|
||||
assert '\"inflight_state_max_json_chars\": 1500000' in CONFIG_PY
|
||||
assert '\"inflight_state_max_sessions\": (1, 25)' in CONFIG_PY
|
||||
assert '\"inflight_state_max_messages\": (1, 100)' in CONFIG_PY
|
||||
assert '\"inflight_state_max_tool_calls\": (1, 200)' in CONFIG_PY
|
||||
assert '\"inflight_state_max_string_chars\": (1000, 500000)' in CONFIG_PY
|
||||
assert '\"inflight_state_max_json_chars\": (100000, 4000000)' in CONFIG_PY
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
def test_inflight_marker_write_handles_quota_by_dropping_recovery_snapshots():
|
||||
"""The tiny active-stream marker must not crash submit when recovery snapshots fill quota."""
|
||||
mark_body = _function_body(UI_JS, "markInflight")
|
||||
|
||||
assert "try{" in mark_body
|
||||
assert "localStorage.setItem(INFLIGHT_KEY, payload);" in mark_body
|
||||
assert "_isStorageQuotaError(err)" in mark_body
|
||||
assert "localStorage.removeItem(INFLIGHT_STATE_KEY);" in mark_body
|
||||
assert mark_body.index("localStorage.removeItem(INFLIGHT_STATE_KEY);") < mark_body.rindex(
|
||||
"localStorage.setItem(INFLIGHT_KEY, payload);"
|
||||
)
|
||||
|
||||
|
||||
def test_save_inflight_state_clears_snapshots_when_quota_retry_fails():
|
||||
"""Quota failures should degrade recovery, not preserve a storage-filling blob."""
|
||||
save_body = _function_body(UI_JS, "saveInflightState")
|
||||
|
||||
assert "catch(err)" in save_body
|
||||
assert "if(!_isStorageQuotaError(err)) return;" in save_body
|
||||
assert "localStorage.removeItem(INFLIGHT_STATE_KEY);" in save_body
|
||||
assert "_writeInflightStateMap({[sid]:entry});" in save_body
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Tests for skill toggle (enable/disable) API and frontend."""
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PANELS_JS = (Path(__file__).resolve().parent.parent / "static" / "panels.js").read_text("utf-8")
|
||||
I18N_JS = (Path(__file__).resolve().parent.parent / "static" / "i18n.js").read_text("utf-8")
|
||||
STYLE_CSS = (Path(__file__).resolve().parent.parent / "static" / "style.css").read_text("utf-8")
|
||||
|
||||
|
||||
def test_toggle_endpoint_signature_in_routes():
|
||||
"""Verify the toggle endpoint code exists in routes.py."""
|
||||
from api.routes import _handle_skill_toggle
|
||||
assert callable(_handle_skill_toggle)
|
||||
|
||||
|
||||
def test_toggle_path_registered():
|
||||
"""Verify /api/skills/toggle path is registered in POST routing."""
|
||||
routes_source = (Path(__file__).resolve().parent.parent / "api" / "routes.py").read_text("utf-8")
|
||||
assert '/api/skills/toggle' in routes_source
|
||||
|
||||
|
||||
def test_skills_list_includes_disabled_flag():
|
||||
"""Each skill dict in the API response must have a 'disabled' boolean field."""
|
||||
routes_source = (Path(__file__).resolve().parent.parent / "api" / "routes.py").read_text("utf-8")
|
||||
assert '"disabled": name in disabled' in routes_source
|
||||
|
||||
|
||||
def test_i18n_keys_added():
|
||||
"""The three new i18n keys must exist in the English locale."""
|
||||
assert "skill_enabled" in I18N_JS
|
||||
assert "skill_disabled" in I18N_JS
|
||||
assert "skill_toggle_failed" in I18N_JS
|
||||
|
||||
|
||||
def test_toggle_css_classes_exist():
|
||||
"""Toggle switch CSS classes must be in style.css."""
|
||||
assert ".skill-toggle" in STYLE_CSS
|
||||
assert ".skill-toggle.enabled" in STYLE_CSS
|
||||
assert ".skill-item.disabled" in STYLE_CSS
|
||||
|
||||
|
||||
def test_render_skills_produces_toggle_buttons():
|
||||
"""renderSkills() must include toggleSkill and skill-toggle."""
|
||||
assert "toggleSkill(" in PANELS_JS
|
||||
assert "skill-toggle" in PANELS_JS
|
||||
|
||||
|
||||
def test_toggle_skill_function_defined():
|
||||
"""toggleSkill() async function must be defined."""
|
||||
assert "async function toggleSkill(" in PANELS_JS
|
||||
|
||||
|
||||
def test_disabled_list_round_trip(tmp_path):
|
||||
"""Verify that writing and reading the disabled list through the config
|
||||
module's YAML functions preserves values correctly, including normalization
|
||||
of None/str/list shapes."""
|
||||
from api.config import _load_yaml_config_file, _save_yaml_config_file
|
||||
|
||||
config_path = tmp_path / "config.yaml"
|
||||
|
||||
# Write initial config
|
||||
_save_yaml_config_file(config_path, {"skills": {"disabled": []}})
|
||||
|
||||
# Read, add skill, write
|
||||
cfg = _load_yaml_config_file(config_path)
|
||||
cfg.setdefault("skills", {})
|
||||
disabled = cfg["skills"].get("disabled", [])
|
||||
disabled.append("skill-a")
|
||||
disabled.append("skill-b")
|
||||
cfg["skills"]["disabled"] = disabled
|
||||
_save_yaml_config_file(config_path, cfg)
|
||||
|
||||
# Read back and verify
|
||||
cfg2 = _load_yaml_config_file(config_path)
|
||||
assert cfg2["skills"]["disabled"] == ["skill-a", "skill-b"]
|
||||
|
||||
# Remove one skill, write, verify
|
||||
cfg2["skills"]["disabled"] = [d for d in cfg2["skills"]["disabled"] if d != "skill-a"]
|
||||
_save_yaml_config_file(config_path, cfg2)
|
||||
|
||||
cfg3 = _load_yaml_config_file(config_path)
|
||||
assert cfg3["skills"]["disabled"] == ["skill-b"]
|
||||
|
||||
|
||||
def test_normalize_names_list():
|
||||
"""_normalize_names_list handles None, str, list, and deduplicates."""
|
||||
from api.routes import _normalize_names_list
|
||||
|
||||
assert _normalize_names_list(None) == []
|
||||
assert _normalize_names_list("foo") == ["foo"]
|
||||
assert _normalize_names_list(["a", "b"]) == ["a", "b"]
|
||||
assert _normalize_names_list(["a", "a"]) == ["a"]
|
||||
assert _normalize_names_list([" a ", "b"]) == ["a", "b"]
|
||||
assert _normalize_names_list("") == []
|
||||
assert _normalize_names_list([]) == []
|
||||
|
||||
|
||||
def test_toggle_name_in_list():
|
||||
"""_toggle_name_in_list adds when enabled=False, removes when enabled=True."""
|
||||
from api.routes import _toggle_name_in_list
|
||||
|
||||
# Add to empty list
|
||||
assert _toggle_name_in_list([], "foo", enabled=False) == ["foo"]
|
||||
# Add to existing list
|
||||
assert _toggle_name_in_list(["bar"], "foo", enabled=False) == ["bar", "foo"]
|
||||
# Idempotent add
|
||||
assert _toggle_name_in_list(["foo"], "foo", enabled=False) == ["foo"]
|
||||
# Remove from list
|
||||
assert _toggle_name_in_list(["foo", "bar"], "foo", enabled=True) == ["bar"]
|
||||
# Remove non-existent is a no-op
|
||||
assert _toggle_name_in_list(["bar"], "foo", enabled=True) == ["bar"]
|
||||
# Remove from empty is a no-op
|
||||
assert _toggle_name_in_list([], "foo", enabled=True) == []
|
||||
# Handles str input (normalization)
|
||||
assert _toggle_name_in_list("foo", "bar", enabled=False) == ["foo", "bar"]
|
||||
assert _toggle_name_in_list("foo", "foo", enabled=True) == []
|
||||
|
||||
|
||||
def test_platform_disabled_write_through(tmp_path, monkeypatch):
|
||||
"""Toggle writes through to platform_disabled.webui when that key exists."""
|
||||
from unittest.mock import MagicMock
|
||||
from api.routes import _handle_skill_toggle
|
||||
from api.config import _load_yaml_config_file
|
||||
|
||||
config_path = tmp_path / "config.yaml"
|
||||
monkeypatch.setattr("api.routes._get_config_path", lambda: config_path)
|
||||
|
||||
import yaml
|
||||
|
||||
config = {
|
||||
"skills": {
|
||||
"disabled": ["skill-a"],
|
||||
"platform_disabled": {
|
||||
"webui": ["skill-a", "skill-b"],
|
||||
"telegram": ["skill-c"],
|
||||
},
|
||||
}
|
||||
}
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(yaml.dump(config), encoding="utf-8")
|
||||
|
||||
# Mock _find_skill_in_dirs to avoid agent.skill_utils import in CI
|
||||
fake_dir = tmp_path / "skills" / "skill-a"
|
||||
fake_dir.mkdir(parents=True, exist_ok=True)
|
||||
fake_md = fake_dir / "SKILL.md"
|
||||
fake_md.write_text("---\nname: skill-a\n---\nA skill", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
"api.routes._find_skill_in_dirs",
|
||||
lambda name, dirs: (fake_dir, fake_md),
|
||||
)
|
||||
|
||||
handler = MagicMock()
|
||||
|
||||
# Toggle skill-a OFF — it's already disabled, verify idempotency
|
||||
_handle_skill_toggle(handler, {"name": "skill-a", "enabled": False})
|
||||
cfg_after = _load_yaml_config_file(config_path)
|
||||
assert "skill-a" in cfg_after["skills"]["disabled"]
|
||||
assert "skill-a" in cfg_after["skills"]["platform_disabled"]["webui"]
|
||||
|
||||
# Toggle skill-a ON
|
||||
_handle_skill_toggle(handler, {"name": "skill-a", "enabled": True})
|
||||
cfg_after2 = _load_yaml_config_file(config_path)
|
||||
assert "skill-a" not in cfg_after2["skills"]["disabled"]
|
||||
assert "skill-a" not in cfg_after2["skills"]["platform_disabled"]["webui"]
|
||||
# Other platform keys are untouched
|
||||
assert cfg_after2["skills"]["platform_disabled"]["telegram"] == ["skill-c"]
|
||||
|
||||
|
||||
def test_platform_disabled_no_write_through_when_key_absent(tmp_path, monkeypatch):
|
||||
"""Toggle does NOT create platform_disabled.webui when it doesn't exist."""
|
||||
from unittest.mock import MagicMock
|
||||
from api.routes import _handle_skill_toggle
|
||||
from api.config import _load_yaml_config_file
|
||||
|
||||
config_path = tmp_path / "config.yaml"
|
||||
monkeypatch.setattr("api.routes._get_config_path", lambda: config_path)
|
||||
|
||||
import yaml
|
||||
|
||||
config = {"skills": {"disabled": []}}
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(yaml.dump(config), encoding="utf-8")
|
||||
|
||||
# Mock _find_skill_in_dirs to avoid agent.skill_utils import in CI
|
||||
fake_dir = tmp_path / "skills" / "skill-b"
|
||||
fake_dir.mkdir(parents=True, exist_ok=True)
|
||||
fake_md = fake_dir / "SKILL.md"
|
||||
fake_md.write_text("---\nname: skill-b\n---\nB skill", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
"api.routes._find_skill_in_dirs",
|
||||
lambda name, dirs: (fake_dir, fake_md),
|
||||
)
|
||||
|
||||
handler = MagicMock()
|
||||
_handle_skill_toggle(handler, {"name": "skill-b", "enabled": False})
|
||||
cfg_after = _load_yaml_config_file(config_path)
|
||||
assert "skill-b" in cfg_after["skills"]["disabled"]
|
||||
# platform_disabled was never created
|
||||
assert "platform_disabled" not in cfg_after.get("skills", {})
|
||||
Reference in New Issue
Block a user