mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 03:30:36 +00:00
Merge pull request #2884 from nesquena/release/stage-batch13
Release/stage-batch13
This commit is contained in:
@@ -3,6 +3,38 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.131] — 2026-05-24 — Release DC (stage-batch13 — 6-PR notes-drawer + context-parity + PWA-swipe + locale polish)
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2868** by @AJV20 — Installed/mobile PWA sessions now support an edge swipe from the left side of the screen to open the mobile sidebar drawer, while preserving the existing hamburger and overlay controls. PWA-standalone-gated, edge X<28px, vertical-tolerance 48px, interactive-target exclusion. Defends against accidental triggers from text selection or button taps.
|
||||
|
||||
- **PR #2527** by @AJV20 — Default-off, read-only Third-party notes drawer in the Memory panel. Lists configured note/knowledge MCP sources (Joplin, Obsidian, Notion, llm-wiki) when explicitly enabled via `webui_external_notes_sources` config or `HERMES_WEBUI_EXTERNAL_NOTES_SOURCES=1`. Automatic session recall unchanged. 4 API endpoints (`/api/notes/sources`, `/api/notes/search`, `/api/notes/item`, plus `external_notes_enabled` in memory_read response) all gated behind the feature flag.
|
||||
|
||||
- **PR #2547** by @AJV20 — SSE stream runtime diagnostics in deep health checks: active stream count, subscriber totals, and offline buffered-event counts for stuck or slow WebUI chat investigations. Non-sensitive payload only.
|
||||
|
||||
- **PR #2547** by @AJV20 — WebUI session prefill parity for bounded JSON files. Browser-originated chat turns can load configured prefill context from `prefill_messages_file`, pass it to Hermes Agent as ephemeral model context, and surface a compact context status event in the chat UI without exposing prefill message bodies. WebUI intentionally does not execute `prefill_messages_script`; executable recall should go through the existing MCP/tool surface. Backward-compatible: degrades gracefully on older agent builds that don't support the `prefill_messages` kwarg.
|
||||
|
||||
### Changed
|
||||
|
||||
- **PR #2547** by @AJV20 — Browser-surface session context is now attached to WebUI agent turns so the agent can distinguish a WebUI chat from messaging-platform transcripts. Context is ephemeral (not saved to history). WebUI progress guidance now preserves the normal Hermes messaging style instead of encouraging extra browser-only status chatter.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2865** by @AJV20 — New WebUI sessions no longer persist `display.personality` into per-session `Session.personality`; only explicit personality changes remain durable, preventing stale global display defaults from overriding profile-scoped session behavior. Closes #2845.
|
||||
|
||||
- **PR #2882** by @ycj — zh-CN (Simplified Chinese) session-time relative labels are now clearer: `${n}分钟前`, `${n}小时前`, `${n}天前`, and the more natural last-week phrasing `上周` instead of the previous bare-unit shorthand. Also corrects a small indentation glitch in the zh-TW (Traditional Chinese) locale. (Cherry-picked onto fresh stage with `Co-authored-by` attribution — original PR was based on stale master.)
|
||||
|
||||
- **PR #2873** by @Charanis — The WebUI launcher (`ctl.sh` + `bootstrap.py`) now preserves environment variables that have already been resolved by the shell (for example `HERMES_WEBUI_PORT`, `HERMES_WEBUI_STATE_DIR`, `HERMES_WEBUI_HOST`) instead of letting a repo-level `.env` clobber them mid-launch. The `.env` keeps working as a default-only source for unset variables, gated by `HERMES_WEBUI_PRESERVE_ENV=1` set by the launcher subshell.
|
||||
|
||||
### Notes
|
||||
|
||||
- **6,503 pytest passed** (sequential mode; the test infrastructure uses a single test server that doesn't support xdist parallelism — known limitation, tracked separately).
|
||||
- **Opus Advisor verdict: SHIP-AS-IS.** Zero MUST-FIX. Three SHOULD-FIX items filed as follow-up issues (incomplete locale coverage for notes-drawer i18n keys, `_joplin_api_get` URL-token defense-in-depth, prefill `setattr` cache-reuse safety net).
|
||||
- **#2527 i18n coverage**: 10 of the 11 non-en locales currently ship the English string `'Third-party notes'` for the drawer header. Since the drawer is default-off, user impact is zero today; follow-up issue tracks proper translations before any default-on transition.
|
||||
|
||||
|
||||
|
||||
## [v0.51.130] — 2026-05-24 — Release DB (stage-batch12 — 3-PR profile-isolation + boot-precedence + workspace Artifacts tab)
|
||||
|
||||
### Fixed
|
||||
|
||||
+1
-13
@@ -1945,18 +1945,6 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p
|
||||
if model_provider:
|
||||
effective_model_provider = model_provider
|
||||
|
||||
# Read default personality from config display.personality
|
||||
_default_personality = None
|
||||
try:
|
||||
from api.config import get_config as _get_cfg_for_personality
|
||||
_cfg_personality = (_get_cfg_for_personality().get('display') or {}).get('personality')
|
||||
if _cfg_personality and isinstance(_cfg_personality, str):
|
||||
_cfg_personality = _cfg_personality.strip().lower()
|
||||
if _cfg_personality and _cfg_personality not in ('default', 'none', 'neutral'):
|
||||
_default_personality = _cfg_personality
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
wt = worktree_info if isinstance(worktree_info, dict) else None
|
||||
workspace_path = (wt.get('path') if wt and wt.get('path') else workspace) if wt else workspace
|
||||
s = Session(
|
||||
@@ -1965,7 +1953,7 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p
|
||||
model_provider=effective_model_provider,
|
||||
profile=profile,
|
||||
project_id=project_id,
|
||||
personality=_default_personality,
|
||||
personality=None,
|
||||
worktree_path=wt.get('path') if wt else None,
|
||||
worktree_branch=wt.get('branch') if wt else None,
|
||||
worktree_repo_root=wt.get('repo_root') if wt else None,
|
||||
|
||||
+451
@@ -4634,6 +4634,13 @@ def handle_get(handler, parsed) -> bool:
|
||||
if parsed.path == "/api/mcp/tools":
|
||||
return _handle_mcp_tools_list(handler)
|
||||
|
||||
if parsed.path == "/api/notes/sources":
|
||||
return _handle_notes_sources_list(handler)
|
||||
if parsed.path == "/api/notes/search":
|
||||
return _handle_notes_search(handler, parsed)
|
||||
if parsed.path == "/api/notes/item":
|
||||
return _handle_notes_item(handler, parsed)
|
||||
|
||||
# ── Checkpoints / Rollback (GET) ──
|
||||
if parsed.path == "/api/rollback/list":
|
||||
qs = parse_qs(parsed.query)
|
||||
@@ -8129,6 +8136,7 @@ def _handle_memory_read(handler):
|
||||
"memory_mtime": mem_file.stat().st_mtime if mem_file.exists() else None,
|
||||
"user_mtime": user_file.stat().st_mtime if user_file.exists() else None,
|
||||
"soul_mtime": soul_file.stat().st_mtime if soul_file.exists() else None,
|
||||
"external_notes_enabled": _external_notes_sources_enabled(),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -12020,6 +12028,449 @@ def _handle_mcp_tools_list(handler):
|
||||
})
|
||||
|
||||
|
||||
def _webui_truthy(value) -> bool:
|
||||
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _external_notes_sources_enabled(config_data: dict | None = None) -> bool:
|
||||
"""Return whether the third-party notes drawer is explicitly enabled.
|
||||
|
||||
The Memory panel is a primary surface, so this power-user drawer stays
|
||||
default-off unless a deployment opts in through config or environment.
|
||||
"""
|
||||
env_value = os.getenv("HERMES_WEBUI_EXTERNAL_NOTES_SOURCES", "")
|
||||
if env_value:
|
||||
return _webui_truthy(env_value)
|
||||
cfg = config_data if isinstance(config_data, dict) else get_config()
|
||||
if not isinstance(cfg, dict):
|
||||
return False
|
||||
return _webui_truthy(
|
||||
cfg.get("webui_external_notes_sources")
|
||||
or cfg.get("external_notes_sources")
|
||||
or cfg.get("notes_sources_drawer")
|
||||
)
|
||||
|
||||
|
||||
_NOTES_SOURCE_SERVER_HINTS = {
|
||||
"joplin", "obsidian", "notion", "llm-wiki", "llmwiki", "wiki",
|
||||
"notes", "note", "knowledge", "kb", "readwise", "logseq",
|
||||
}
|
||||
_NOTES_SOURCE_TOOL_HINTS = {
|
||||
"note", "notes", "notebook", "page", "pages", "wiki", "knowledge",
|
||||
"search_notes", "get_note", "list_notes", "read_note",
|
||||
}
|
||||
_NOTES_SOURCE_CONFIGURED_TOOL_HINTS = {
|
||||
"joplin": [
|
||||
{"name": "search_notes", "description": "Search Joplin notes by keyword."},
|
||||
{"name": "list_notes", "description": "List notes from a Joplin notebook."},
|
||||
{"name": "get_note", "description": "Read a specific Joplin note by ID."},
|
||||
],
|
||||
"obsidian": [
|
||||
{"name": "search_notes", "description": "Search Obsidian notes by keyword."},
|
||||
{"name": "read_note", "description": "Read a specific Obsidian note or file."},
|
||||
],
|
||||
"notion": [
|
||||
{"name": "search_pages", "description": "Search Notion pages or databases."},
|
||||
{"name": "get_page", "description": "Read a specific Notion page."},
|
||||
],
|
||||
"llm-wiki": [
|
||||
{"name": "query_knowledge_base", "description": "Query the LLM Wiki knowledge base."},
|
||||
{"name": "read_page", "description": "Read a specific wiki page."},
|
||||
],
|
||||
"llmwiki": [
|
||||
{"name": "query_knowledge_base", "description": "Query the LLM Wiki knowledge base."},
|
||||
{"name": "read_page", "description": "Read a specific wiki page."},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _note_source_label(name: str) -> str:
|
||||
labels = {
|
||||
"joplin": "Joplin",
|
||||
"obsidian": "Obsidian",
|
||||
"notion": "Notion",
|
||||
"llm-wiki": "LLM Wiki",
|
||||
"llmwiki": "LLM Wiki",
|
||||
"readwise": "Readwise",
|
||||
"logseq": "Logseq",
|
||||
}
|
||||
lowered = str(name or "").strip().lower()
|
||||
return labels.get(lowered, str(name or "").replace("_", " ").replace("-", " ").title())
|
||||
|
||||
|
||||
def _looks_like_notes_source(server_name: str, tool_rows: list[dict]) -> bool:
|
||||
server_l = str(server_name or "").lower()
|
||||
if any(hint in server_l for hint in _NOTES_SOURCE_SERVER_HINTS):
|
||||
return True
|
||||
for tool in tool_rows:
|
||||
haystack = " ".join([
|
||||
str(tool.get("name") or ""),
|
||||
str(tool.get("description") or ""),
|
||||
]).lower()
|
||||
if any(hint in haystack for hint in _NOTES_SOURCE_TOOL_HINTS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _configured_note_tool_hints(server_name: str) -> list[dict]:
|
||||
"""Return safe expected note-tool hints for configured known sources."""
|
||||
server_l = str(server_name or "").strip().lower()
|
||||
hints = _NOTES_SOURCE_CONFIGURED_TOOL_HINTS.get(server_l)
|
||||
if hints is None:
|
||||
if any(hint in server_l for hint in ("wiki", "knowledge", "kb")):
|
||||
hints = [
|
||||
{"name": "search", "description": "Search this configured knowledge source."},
|
||||
{"name": "read", "description": "Read an item from this configured knowledge source."},
|
||||
]
|
||||
elif any(hint in server_l for hint in ("note", "notes")):
|
||||
hints = [
|
||||
{"name": "search_notes", "description": "Search this configured notes source."},
|
||||
{"name": "read_note", "description": "Read a note from this configured notes source."},
|
||||
]
|
||||
else:
|
||||
hints = []
|
||||
return [
|
||||
{
|
||||
"name": _mcp_safe_display_text(row.get("name") or "", limit=96),
|
||||
"description": _mcp_safe_display_text(row.get("description") or "", limit=180),
|
||||
"inferred": True,
|
||||
}
|
||||
for row in hints
|
||||
if isinstance(row, dict)
|
||||
]
|
||||
|
||||
|
||||
def _notes_sources_from_mcp_inventory(server_summaries: dict, tools: list[dict]) -> list[dict]:
|
||||
"""Build a safe notes/knowledge-source inventory from MCP servers/tools.
|
||||
|
||||
Some WebUI deployments can read ``mcp_servers`` from config before their
|
||||
local runtime/tool registry has hydrated MCP tool metadata. Still show
|
||||
configured note/knowledge servers (for example Joplin) in that case so the
|
||||
drawer reflects connection/configuration state instead of appearing empty.
|
||||
"""
|
||||
by_server: dict[str, list[dict]] = {}
|
||||
for tool in tools or []:
|
||||
if not isinstance(tool, dict):
|
||||
continue
|
||||
server = str(tool.get("server") or "").strip()
|
||||
if not server:
|
||||
continue
|
||||
by_server.setdefault(server, []).append(tool)
|
||||
|
||||
if isinstance(server_summaries, dict):
|
||||
for server, summary in server_summaries.items():
|
||||
server_name = str(server or "").strip()
|
||||
if not server_name or server_name in by_server:
|
||||
continue
|
||||
if _looks_like_notes_source(server_name, []):
|
||||
by_server.setdefault(server_name, [])
|
||||
|
||||
sources = []
|
||||
for server, tool_rows in by_server.items():
|
||||
if not _looks_like_notes_source(server, tool_rows):
|
||||
continue
|
||||
summary = server_summaries.get(server, {"name": server}) if isinstance(server_summaries, dict) else {"name": server}
|
||||
safe_tools = []
|
||||
tool_source = "runtime"
|
||||
for tool in tool_rows[:8]:
|
||||
desc = _mcp_safe_display_text(tool.get("description") or "", limit=180)
|
||||
desc = re.sub(r"(?i)\b(api[_-]?key|token|password|secret)\s*[:=]\s*\S+", "[REDACTED]", desc)
|
||||
safe_tools.append({
|
||||
"name": _mcp_safe_display_text(tool.get("name") or "", limit=96),
|
||||
"description": desc,
|
||||
})
|
||||
if not safe_tools:
|
||||
safe_tools = _configured_note_tool_hints(server)
|
||||
if safe_tools:
|
||||
tool_source = "configured_hint"
|
||||
sources.append({
|
||||
"name": server,
|
||||
"label": _note_source_label(server),
|
||||
"enabled": bool(summary.get("enabled", True)),
|
||||
"active": bool(summary.get("active")),
|
||||
"status": summary.get("status") or "unknown",
|
||||
"tool_count": len(safe_tools),
|
||||
"tool_source": tool_source,
|
||||
"tools": safe_tools,
|
||||
})
|
||||
sources.sort(key=lambda row: (not row.get("active"), row.get("label", "")))
|
||||
return sources
|
||||
|
||||
|
||||
def _handle_notes_sources_list(handler):
|
||||
"""List note/knowledge MCP sources for the WebUI Notes drawer."""
|
||||
cfg = get_config()
|
||||
if not _external_notes_sources_enabled(cfg):
|
||||
return j(handler, {
|
||||
"enabled": False,
|
||||
"sources": [],
|
||||
"source": "disabled",
|
||||
"inventory_scope": "disabled_by_default",
|
||||
"attach_supported": False,
|
||||
"automatic_recall_unchanged": True,
|
||||
"recent_ai_notes": [],
|
||||
})
|
||||
servers = cfg.get("mcp_servers", {})
|
||||
if not isinstance(servers, dict):
|
||||
servers = {}
|
||||
runtime = _mcp_runtime_status_by_name()
|
||||
server_summaries = {
|
||||
str(name): _server_summary(str(name), scfg, runtime.get(str(name)))
|
||||
for name, scfg in servers.items()
|
||||
}
|
||||
tools = _mcp_tools_from_runtime_status(runtime, server_summaries)
|
||||
source = "mcp_runtime_status"
|
||||
if not tools:
|
||||
tools = _mcp_tools_from_registry(server_summaries)
|
||||
source = "tool_registry" if tools else "none"
|
||||
return j(handler, {
|
||||
"enabled": True,
|
||||
"sources": _notes_sources_from_mcp_inventory(server_summaries, tools),
|
||||
"source": source,
|
||||
"inventory_scope": "already_known_runtime_only",
|
||||
"attach_supported": False,
|
||||
"automatic_recall_unchanged": True,
|
||||
"recent_ai_notes": _joplin_recent_ai_notes(limit=6),
|
||||
})
|
||||
|
||||
|
||||
def _notes_configured_server(source: str) -> dict:
|
||||
cfg = get_config()
|
||||
servers = cfg.get("mcp_servers", {}) if isinstance(cfg, dict) else {}
|
||||
if not isinstance(servers, dict):
|
||||
return {}
|
||||
source_l = str(source or "").strip().lower()
|
||||
for name, server_cfg in servers.items():
|
||||
if str(name or "").strip().lower() == source_l and isinstance(server_cfg, dict):
|
||||
return server_cfg
|
||||
return {}
|
||||
|
||||
|
||||
def _joplin_connection_from_config() -> tuple[str, str]:
|
||||
cfg = _notes_configured_server("joplin")
|
||||
env = cfg.get("env", {}) if isinstance(cfg, dict) else {}
|
||||
if not isinstance(env, dict):
|
||||
env = {}
|
||||
url = str(env.get("JOPLIN_URL") or os.environ.get("JOPLIN_URL") or "http://127.0.0.1:41184").rstrip("/")
|
||||
token = str(env.get("JOPLIN_TOKEN") or os.environ.get("JOPLIN_TOKEN") or "")
|
||||
return url, token
|
||||
|
||||
|
||||
def _joplin_api_get(path: str, params: dict | None = None) -> dict:
|
||||
"""Call the local Joplin Web Clipper API without logging credentials."""
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import urlopen
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
base_url, token = _joplin_connection_from_config()
|
||||
if not token:
|
||||
raise ValueError("Joplin token is not configured")
|
||||
safe_path = "/" + str(path or "").lstrip("/")
|
||||
query = dict(params or {})
|
||||
query["token"] = token
|
||||
url = f"{base_url}{safe_path}?{urlencode(query)}"
|
||||
try:
|
||||
with urlopen(url, timeout=8) as response:
|
||||
raw = response.read(2_000_000).decode("utf-8", errors="replace")
|
||||
except HTTPError as exc:
|
||||
raise ValueError(f"Joplin API returned HTTP {exc.code}") from None
|
||||
except URLError as exc:
|
||||
raise ValueError("Joplin API is not reachable") from None
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
raise ValueError("Joplin API returned invalid JSON") from None
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _note_snippet(body: str, query: str = "", *, limit: int = 220) -> str:
|
||||
text = re.sub(r"\s+", " ", str(body or "")).strip()
|
||||
if not text:
|
||||
return ""
|
||||
q = str(query or "").strip().lower()
|
||||
if q:
|
||||
idx = text.lower().find(q)
|
||||
if idx > 40:
|
||||
text = "…" + text[max(0, idx - 60):]
|
||||
if len(text) > limit:
|
||||
return text[:limit].rstrip() + "…"
|
||||
return text
|
||||
|
||||
|
||||
def _joplin_search_notes(query: str, *, limit: int = 20) -> list[dict]:
|
||||
query = str(query or "").strip()
|
||||
if not query:
|
||||
return []
|
||||
limit = max(1, min(int(limit or 20), 50))
|
||||
data = _joplin_api_get("/search", {
|
||||
"query": query,
|
||||
"type": "note",
|
||||
"fields": "id,title,body,parent_id,updated_time",
|
||||
"limit": limit,
|
||||
})
|
||||
rows = data.get("items") if isinstance(data, dict) else []
|
||||
results = []
|
||||
for row in rows if isinstance(rows, list) else []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
note_id = _mcp_safe_display_text(row.get("id") or "", limit=64)
|
||||
if not note_id:
|
||||
continue
|
||||
title = _mcp_safe_display_text(row.get("title") or "Untitled", limit=180)
|
||||
body = str(row.get("body") or "")
|
||||
results.append({
|
||||
"id": note_id,
|
||||
"title": title,
|
||||
"snippet": _mcp_safe_display_text(_note_snippet(body, query), limit=260),
|
||||
"parent_id": _mcp_safe_display_text(row.get("parent_id") or "", limit=64),
|
||||
"updated_time": row.get("updated_time"),
|
||||
"source": "joplin",
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def _joplin_get_note(note_id: str) -> dict:
|
||||
note_id = str(note_id or "").strip()
|
||||
if not re.fullmatch(r"[A-Za-z0-9]{16,64}", note_id):
|
||||
raise ValueError("Invalid Joplin note id")
|
||||
data = _joplin_api_get(f"/notes/{note_id}", {
|
||||
"fields": "id,title,body,parent_id,updated_time,created_time",
|
||||
})
|
||||
if not data.get("id"):
|
||||
raise ValueError("Joplin note not found")
|
||||
body = str(data.get("body") or "")
|
||||
if len(body) > 50_000:
|
||||
body = body[:50_000].rstrip() + "\n\n[Preview truncated at 50,000 characters]"
|
||||
return {
|
||||
"id": _mcp_safe_display_text(data.get("id") or "", limit=64),
|
||||
"title": _mcp_safe_display_text(data.get("title") or "Untitled", limit=180),
|
||||
"body": _redact_text(body),
|
||||
"parent_id": _mcp_safe_display_text(data.get("parent_id") or "", limit=64),
|
||||
"updated_time": data.get("updated_time"),
|
||||
"created_time": data.get("created_time"),
|
||||
"source": "joplin",
|
||||
}
|
||||
|
||||
|
||||
_JOPLIN_AI_RECALL_NOTE_PRIORITY = [
|
||||
("CURRENT_CONTEXT_ID", "Current Context"),
|
||||
("OPEN_ISSUES_ID", "Open Issues"),
|
||||
("AGENT_MEMORY_ID", "Agent Memory"),
|
||||
("CONVENTIONS_ID", "Conventions / Preferences"),
|
||||
("INFRA_ID", "Infrastructure"),
|
||||
("SERVICES_ID", "Services"),
|
||||
]
|
||||
|
||||
|
||||
def _joplin_prefill_script_path() -> Path | None:
|
||||
cfg = get_config()
|
||||
path_value = cfg.get("prefill_messages_script") if isinstance(cfg, dict) else None
|
||||
if not path_value:
|
||||
return None
|
||||
try:
|
||||
return Path(str(path_value)).expanduser()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _joplin_recall_note_refs(script_path: Path | None = None) -> list[dict]:
|
||||
"""Find stable Joplin note IDs referenced by the configured recall script.
|
||||
|
||||
This keeps the WebUI generic: it does not hard-code a user's note IDs, but
|
||||
can still surface the notes that the configured AI prefill/recall script is
|
||||
known to read for automatic context.
|
||||
"""
|
||||
script_path = script_path or _joplin_prefill_script_path()
|
||||
if not script_path or not script_path.exists() or not script_path.is_file():
|
||||
return []
|
||||
try:
|
||||
text = script_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
return []
|
||||
constants = {
|
||||
match.group(1): match.group(2)
|
||||
for match in re.finditer(r'(?m)^\s*([A-Z0-9_]+_ID)\s*=\s*["\']([A-Fa-f0-9]{16,64})["\']', text)
|
||||
}
|
||||
refs = []
|
||||
seen = set()
|
||||
for const_name, label in _JOPLIN_AI_RECALL_NOTE_PRIORITY:
|
||||
note_id = constants.get(const_name)
|
||||
if not note_id or note_id in seen:
|
||||
continue
|
||||
seen.add(note_id)
|
||||
refs.append({
|
||||
"id": note_id,
|
||||
"label": label,
|
||||
"constant": const_name,
|
||||
"used_by": "ai_prefill",
|
||||
"used_reason": "automatic_recall",
|
||||
})
|
||||
return refs
|
||||
|
||||
|
||||
def _joplin_recent_ai_notes(*, limit: int = 6) -> list[dict]:
|
||||
"""Return safe Joplin notes that the configured AI recall path recently uses."""
|
||||
try:
|
||||
limit = max(1, min(int(limit or 6), 20))
|
||||
except Exception:
|
||||
limit = 6
|
||||
notes = []
|
||||
for ref in _joplin_recall_note_refs()[:limit]:
|
||||
try:
|
||||
data = _joplin_api_get(f"/notes/{ref['id']}", {
|
||||
"fields": "id,title,parent_id,updated_time,user_updated_time,created_time",
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
note_id = _mcp_safe_display_text(data.get("id") or ref.get("id") or "", limit=64)
|
||||
if not note_id:
|
||||
continue
|
||||
notes.append({
|
||||
"id": note_id,
|
||||
"title": _mcp_safe_display_text(data.get("title") or ref.get("label") or "Untitled", limit=180),
|
||||
"label": _mcp_safe_display_text(ref.get("label") or "", limit=120),
|
||||
"parent_id": _mcp_safe_display_text(data.get("parent_id") or "", limit=64),
|
||||
"updated_time": data.get("user_updated_time") or data.get("updated_time"),
|
||||
"created_time": data.get("created_time"),
|
||||
"source": "joplin",
|
||||
"used_by": ref.get("used_by") or "ai_prefill",
|
||||
"used_reason": ref.get("used_reason") or "automatic_recall",
|
||||
})
|
||||
return notes
|
||||
|
||||
|
||||
def _handle_notes_search(handler, parsed):
|
||||
if not _external_notes_sources_enabled():
|
||||
return j(handler, {"source": "disabled", "results": [], "error": "External notes sources are disabled."}, status=404)
|
||||
query = parse_qs(parsed.query or "")
|
||||
source = str(query.get("source", ["joplin"])[0] or "joplin").strip().lower()
|
||||
q = str(query.get("q", [""])[0] or "").strip()
|
||||
try:
|
||||
limit = int(query.get("limit", ["20"])[0] or 20)
|
||||
except Exception:
|
||||
limit = 20
|
||||
if source != "joplin":
|
||||
return j(handler, {"source": source, "results": [], "error": "Search is currently implemented for Joplin sources only."}, status=400)
|
||||
try:
|
||||
return j(handler, {"source": "joplin", "query": q, "results": _joplin_search_notes(q, limit=limit)})
|
||||
except ValueError as exc:
|
||||
return j(handler, {"source": "joplin", "query": q, "results": [], "error": str(exc)}, status=502)
|
||||
|
||||
|
||||
def _handle_notes_item(handler, parsed):
|
||||
if not _external_notes_sources_enabled():
|
||||
return j(handler, {"source": "disabled", "error": "External notes sources are disabled."}, status=404)
|
||||
query = parse_qs(parsed.query or "")
|
||||
source = str(query.get("source", ["joplin"])[0] or "joplin").strip().lower()
|
||||
note_id = str(query.get("id", [""])[0] or "").strip()
|
||||
if source != "joplin":
|
||||
return j(handler, {"source": source, "error": "Preview is currently implemented for Joplin sources only."}, status=400)
|
||||
try:
|
||||
return j(handler, {"source": "joplin", "note": _joplin_get_note(note_id)})
|
||||
except ValueError as exc:
|
||||
return j(handler, {"source": "joplin", "error": str(exc)}, status=502)
|
||||
|
||||
|
||||
def _handle_mcp_servers_list(handler):
|
||||
"""List configured MCP servers with safe, read-only runtime visibility."""
|
||||
cfg = get_config()
|
||||
|
||||
+139
-6
@@ -10,6 +10,7 @@ import mimetypes
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
@@ -189,9 +190,10 @@ def _clarify_timeout_seconds(default: int = 120) -> int:
|
||||
_CANCEL_MARKER_PATTERNS = ('task cancelled', 'task canceled', 'response interrupted')
|
||||
|
||||
|
||||
_WEBUI_VISIBLE_PROGRESS_PROMPT = """
|
||||
WebUI progress contract:
|
||||
- For multi-step work that uses tools, provide brief user-visible progress updates as normal assistant content before continuing with tool calls.
|
||||
_WEBUI_PROGRESS_PROMPT = """
|
||||
WebUI progress guidance:
|
||||
- Match the normal Hermes messaging style; do not add extra status updates solely because this is a browser session.
|
||||
- For long multi-step work that uses tools, you may provide brief user-visible progress updates before continuing with tool calls.
|
||||
- Each update should say what you are about to check, what you just confirmed, or why the next tool call is needed.
|
||||
- Keep updates concise, factual, and in the user's language. One or two short sentences are enough.
|
||||
- Do not reveal hidden reasoning, chain-of-thought, private scratchpads, secrets, raw logs, or long tool output.
|
||||
@@ -199,15 +201,126 @@ WebUI progress contract:
|
||||
""".strip()
|
||||
|
||||
|
||||
def _webui_ephemeral_system_prompt(personality_prompt: Optional[str]) -> str:
|
||||
def _webui_surface_context_prompt(surface_context: Optional[dict]) -> str:
|
||||
"""Return safe WebUI session metadata for the agent's ephemeral context.
|
||||
|
||||
Messaging gateways inject platform/channel context before each run. Browser
|
||||
sessions do not have a chat platform wrapper, so provide an explicit, small
|
||||
surface description here instead of relying on the model to infer where it
|
||||
is running from the transcript alone.
|
||||
"""
|
||||
if not isinstance(surface_context, dict):
|
||||
return ""
|
||||
|
||||
lines = [
|
||||
"WebUI session context:",
|
||||
"- This browser session is not the same live transcript as Telegram, Discord, Slack, or other messaging surfaces.",
|
||||
"- Use durable memory, saved sessions, and available tools for cross-surface recall instead of assuming those transcripts are in this browser chat.",
|
||||
]
|
||||
fields = (
|
||||
("source", "Source"),
|
||||
("session_id", "Session ID"),
|
||||
("profile", "Profile"),
|
||||
("workspace", "Workspace"),
|
||||
)
|
||||
for key, label in fields:
|
||||
raw = surface_context.get(key)
|
||||
value = str(raw).strip() if raw is not None else ""
|
||||
if value:
|
||||
lines.append(f"- {label}: {value}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _webui_ephemeral_system_prompt(
|
||||
personality_prompt: Optional[str],
|
||||
surface_context: Optional[dict] = None,
|
||||
) -> str:
|
||||
"""Build WebUI-only runtime instructions that are not persisted to history."""
|
||||
parts = []
|
||||
if personality_prompt:
|
||||
parts.append(str(personality_prompt).strip())
|
||||
parts.append(_WEBUI_VISIBLE_PROGRESS_PROMPT)
|
||||
surface_prompt = _webui_surface_context_prompt(surface_context)
|
||||
if surface_prompt:
|
||||
parts.append(surface_prompt)
|
||||
parts.append(_WEBUI_PROGRESS_PROMPT)
|
||||
return "\n\n".join(part for part in parts if part)
|
||||
|
||||
|
||||
_SECRET_SHAPED_RE = re.compile(
|
||||
r"(?i)(api[_-]?key|token|password|secret)\s*[:=]\s*[^\s]+|"
|
||||
r"\b(?:sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})\b|"
|
||||
r"[A-Za-z0-9_-]{24,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}"
|
||||
)
|
||||
|
||||
def _redact_prefill_status_text(text: str) -> str:
|
||||
"""Return a short, non-secret diagnostic string for prefill status."""
|
||||
clean = _SECRET_SHAPED_RE.sub("[REDACTED]", str(text or ""))
|
||||
return " ".join(clean.split())[:240]
|
||||
|
||||
|
||||
def _valid_prefill_messages(value) -> list[dict]:
|
||||
"""Normalize a prefill payload to role/content messages."""
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
messages: list[dict] = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
role = item.get("role")
|
||||
content = item.get("content")
|
||||
if role not in {"system", "user", "assistant"} or not isinstance(content, str) or not content.strip():
|
||||
continue
|
||||
messages.append({"role": role, "content": content})
|
||||
return messages
|
||||
|
||||
|
||||
def _resolve_prefill_path(raw: str) -> Path:
|
||||
path = Path(str(raw)).expanduser()
|
||||
if not path.is_absolute():
|
||||
try:
|
||||
from api.config import _get_config_path
|
||||
path = _get_config_path().parent / path
|
||||
except Exception:
|
||||
path = Path.cwd() / path
|
||||
return path
|
||||
|
||||
|
||||
def _load_webui_prefill_context(
|
||||
config_data: Optional[dict] = None,
|
||||
) -> dict:
|
||||
"""Load configured WebUI session prefill messages.
|
||||
|
||||
Supports the same bounded JSON-file shape used by Hermes Agent. WebUI does
|
||||
not execute a configured prefill script here; session recall that requires
|
||||
code execution should go through the normal MCP/tool path instead of an
|
||||
always-on per-turn subprocess before SSE starts.
|
||||
"""
|
||||
cfg = config_data if isinstance(config_data, dict) else get_config()
|
||||
file_raw = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "") or str(cfg.get("prefill_messages_file") or "")
|
||||
if file_raw:
|
||||
path = _resolve_prefill_path(file_raw)
|
||||
label = path.name or "prefill file"
|
||||
if not path.exists():
|
||||
return {"status": "error", "source": "file", "label": label, "messages": [], "message_count": 0, "error": "prefill file not found"}
|
||||
try:
|
||||
messages = _valid_prefill_messages(json.loads(path.read_text(encoding="utf-8")))
|
||||
return {"status": "loaded", "source": "file", "label": label, "messages": messages, "message_count": len(messages)}
|
||||
except Exception as exc:
|
||||
return {"status": "error", "source": "file", "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(str(exc))}
|
||||
return {"status": "not_configured", "source": "none", "label": "", "messages": [], "message_count": 0}
|
||||
|
||||
|
||||
def _public_prefill_context_status(prefill_context: dict) -> dict:
|
||||
"""Strip message bodies before sending context status to the browser."""
|
||||
return {
|
||||
"status": prefill_context.get("status", "not_configured"),
|
||||
"source": prefill_context.get("source", "none"),
|
||||
"label": prefill_context.get("label", ""),
|
||||
"message_count": int(prefill_context.get("message_count") or 0),
|
||||
**({"error": prefill_context.get("error", "")} if prefill_context.get("error") else {}),
|
||||
}
|
||||
|
||||
|
||||
def _has_new_assistant_reply(all_messages: list, prev_count: int) -> bool:
|
||||
"""Return True if *new* messages (beyond ``prev_count``) contain an
|
||||
assistant message with non-empty content.
|
||||
@@ -3896,6 +4009,12 @@ def _run_agent_streaming(
|
||||
# Read per-profile config at call time (not module-level snapshot)
|
||||
from api.config import get_config as _get_config
|
||||
_cfg = _get_config()
|
||||
_prefill_context = _load_webui_prefill_context(_cfg)
|
||||
_prefill_messages = _prefill_context.get('messages') or []
|
||||
put('context_status', {
|
||||
'session_id': session_id,
|
||||
'prefill': _public_prefill_context_status(_prefill_context),
|
||||
})
|
||||
|
||||
# Per-profile toolsets — use _resolve_cli_toolsets() so MCP
|
||||
# server toolsets are included, matching native CLI behaviour.
|
||||
@@ -4018,6 +4137,7 @@ def _run_agent_streaming(
|
||||
fallback_model=_fallback_resolved,
|
||||
session_id=session_id,
|
||||
session_db=_session_db,
|
||||
prefill_messages=_prefill_messages,
|
||||
stream_delta_callback=on_token,
|
||||
reasoning_callback=on_reasoning,
|
||||
tool_progress_callback=on_tool,
|
||||
@@ -4031,6 +4151,8 @@ def _run_agent_streaming(
|
||||
# but guard defensively to avoid TypeError on an older agent build.
|
||||
if 'reasoning_config' in _agent_params and _reasoning_config is not None:
|
||||
_agent_kwargs['reasoning_config'] = _reasoning_config
|
||||
if 'prefill_messages' not in _agent_params:
|
||||
_agent_kwargs.pop('prefill_messages', None)
|
||||
if 'interim_assistant_callback' in _agent_params:
|
||||
_agent_kwargs['interim_assistant_callback'] = on_interim_assistant
|
||||
if 'tool_start_callback' in _agent_params:
|
||||
@@ -4084,6 +4206,7 @@ def _run_agent_streaming(
|
||||
_fallback_resolved or {},
|
||||
sorted(_toolsets) if _toolsets else [],
|
||||
_reasoning_config or {},
|
||||
_public_prefill_context_status(_prefill_context),
|
||||
# #1897: profile_home is part of the agent's identity because
|
||||
# AIAgent caches `_cached_system_prompt` from `load_soul_md()`
|
||||
# at construction time, sourced from HERMES_HOME. Same-session
|
||||
@@ -4143,6 +4266,8 @@ def _run_agent_streaming(
|
||||
agent.reasoning_callback = _agent_kwargs.get('reasoning_callback')
|
||||
if hasattr(agent, 'clarify_callback'):
|
||||
agent.clarify_callback = _agent_kwargs.get('clarify_callback')
|
||||
if hasattr(agent, 'prefill_messages'):
|
||||
agent.prefill_messages = list(_agent_kwargs.get('prefill_messages') or [])
|
||||
if _session_db is not None:
|
||||
# Close any previously held SessionDB connection before
|
||||
# replacing it. Without this, each streaming request creates
|
||||
@@ -4258,7 +4383,15 @@ def _run_agent_streaming(
|
||||
# (agent's own mechanism). This preserves any selected personality
|
||||
# while making long tool runs emit real user-visible interim text
|
||||
# through interim_assistant_callback instead of frontend guesses.
|
||||
agent.ephemeral_system_prompt = _webui_ephemeral_system_prompt(_personality_prompt)
|
||||
agent.ephemeral_system_prompt = _webui_ephemeral_system_prompt(
|
||||
_personality_prompt,
|
||||
surface_context={
|
||||
'source': 'webui',
|
||||
'session_id': session_id,
|
||||
'profile': getattr(s, 'profile', None),
|
||||
'workspace': s.workspace,
|
||||
},
|
||||
)
|
||||
_pending_started_at = getattr(s, 'pending_started_at', None)
|
||||
# Normal chat-start sets pending_started_at before spawning this thread;
|
||||
# fallback to now only for recovered/legacy flows where that marker is absent
|
||||
|
||||
+10
-2
@@ -28,8 +28,8 @@ def _load_repo_dotenv() -> None:
|
||||
``python3 bootstrap.py`` directly behaves identically to ``./start.sh``.
|
||||
Variables are set unconditionally (matching shell source semantics), so a
|
||||
value in .env overrides one already present in the shell environment.
|
||||
To keep a CLI-supplied value, unset it from .env or launch via start.sh
|
||||
and override there.
|
||||
``ctl.sh`` sets HERMES_WEBUI_PRESERVE_ENV=1 when it has already resolved
|
||||
launcher-specific values such as HERMES_HOME or HERMES_WEBUI_STATE_DIR.
|
||||
|
||||
Only loads the webui repo .env — not ~/.hermes/.env, which the server
|
||||
loads independently at startup for provider credentials.
|
||||
@@ -41,6 +41,12 @@ def _load_repo_dotenv() -> None:
|
||||
if not env_path.exists():
|
||||
return
|
||||
try:
|
||||
preserve_existing = os.getenv("HERMES_WEBUI_PRESERVE_ENV", "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
@@ -52,6 +58,8 @@ def _load_repo_dotenv() -> None:
|
||||
k = k[7:].strip()
|
||||
v = v.strip().strip('"').strip("'")
|
||||
if k:
|
||||
if preserve_existing and k in os.environ:
|
||||
continue
|
||||
os.environ[k] = v
|
||||
except Exception as exc:
|
||||
import sys as _sys
|
||||
|
||||
@@ -219,7 +219,9 @@ start_cmd() {
|
||||
: >> "${LOG_FILE}"
|
||||
(
|
||||
cd "${REPO_ROOT}"
|
||||
exec "${python_exe}" "${REPO_ROOT}/bootstrap.py" --no-browser --foreground --host "${CTL_HOST}" "${CTL_PORT}" ${CTL_BOOTSTRAP_ARGS[@]+"${CTL_BOOTSTRAP_ARGS[@]}"}
|
||||
trap '' HUP
|
||||
export HERMES_WEBUI_PRESERVE_ENV=1
|
||||
exec nohup "${python_exe}" "${REPO_ROOT}/bootstrap.py" --no-browser --foreground --host "${CTL_HOST}" "${CTL_PORT}" ${CTL_BOOTSTRAP_ARGS[@]+"${CTL_BOOTSTRAP_ARGS[@]}"}
|
||||
) >> "${LOG_FILE}" 2>&1 &
|
||||
pid=$!
|
||||
|
||||
|
||||
@@ -228,6 +228,71 @@ function closeMobileSidebar(){
|
||||
if(overlay)overlay.classList.remove('visible');
|
||||
}
|
||||
|
||||
const _PWA_SIDEBAR_SWIPE_EDGE=28;
|
||||
const _PWA_SIDEBAR_SWIPE_TRIGGER=72;
|
||||
const _PWA_SIDEBAR_SWIPE_MAX_VERTICAL=48;
|
||||
let _pwaSidebarSwipe=null;
|
||||
|
||||
function _isPwaStandalone(){
|
||||
try{
|
||||
return document.documentElement.classList.contains('pwa-standalone')
|
||||
|| window.matchMedia('(display-mode: standalone)').matches
|
||||
|| window.navigator.standalone===true;
|
||||
}catch(_){return false;}
|
||||
}
|
||||
|
||||
function _isInteractiveSwipeTarget(target){
|
||||
try{return !!(target&&target.closest&&target.closest('input,textarea,select,button,a,[contenteditable="true"],.topbar-chips,.composer-left,.sidebar,.rightpanel'));}
|
||||
catch(_){return false;}
|
||||
}
|
||||
|
||||
function _openMobileSidebarFromGesture(){
|
||||
if(_isDesktopWidth())return;
|
||||
const sidebar=document.querySelector('.sidebar');
|
||||
const overlay=$('mobileOverlay');
|
||||
if(!sidebar)return;
|
||||
const layout=document.querySelector('.layout');
|
||||
if(layout)layout.classList.remove('sidebar-collapsed');
|
||||
sidebar.classList.remove('sidebar-collapsed');
|
||||
try{document.documentElement.removeAttribute('data-sidebar-collapsed');}catch(_){}
|
||||
sidebar.classList.add('mobile-open');
|
||||
if(overlay)overlay.classList.add('visible');
|
||||
}
|
||||
|
||||
function _onPwaSidebarSwipeStart(e){
|
||||
if(!_isPwaStandalone()||_isDesktopWidth())return;
|
||||
if(e.pointerType==='mouse'||(e.pointerType&&e.pointerType!=='touch'&&e.pointerType!=='pen'))return;
|
||||
if(document.querySelector('.sidebar')?.classList.contains('mobile-open'))return;
|
||||
const clientX=Number(e.clientX)||0;
|
||||
if(clientX>_PWA_SIDEBAR_SWIPE_EDGE)return;
|
||||
if(_isInteractiveSwipeTarget(e.target))return;
|
||||
_pwaSidebarSwipe={startX:clientX,startY:Number(e.clientY)||0,active:true,opened:false};
|
||||
}
|
||||
|
||||
function _onPwaSidebarSwipeMove(e){
|
||||
const swipe=_pwaSidebarSwipe;
|
||||
if(!swipe||!swipe.active||swipe.opened)return;
|
||||
const dx=(Number(e.clientX)||0)-swipe.startX;
|
||||
const dy=(Number(e.clientY)||0)-swipe.startY;
|
||||
if(dx<0||Math.abs(dy)>_PWA_SIDEBAR_SWIPE_MAX_VERTICAL*1.5){_pwaSidebarSwipe=null;return;}
|
||||
if(dx>=_PWA_SIDEBAR_SWIPE_TRIGGER&&Math.abs(dy)<=_PWA_SIDEBAR_SWIPE_MAX_VERTICAL&&dx>Math.abs(dy)*1.5){
|
||||
if(e.cancelable)e.preventDefault();
|
||||
swipe.opened=true;
|
||||
_openMobileSidebarFromGesture();
|
||||
}
|
||||
}
|
||||
|
||||
function _onPwaSidebarSwipeEnd(){_pwaSidebarSwipe=null;}
|
||||
function _onPwaSidebarSwipeCancel(){_pwaSidebarSwipe=null;}
|
||||
|
||||
function _installPwaSidebarSwipeGesture(){
|
||||
window.addEventListener('pointerdown', _onPwaSidebarSwipeStart, {passive:true});
|
||||
window.addEventListener('pointermove', _onPwaSidebarSwipeMove, {passive:false});
|
||||
window.addEventListener('pointerup', _onPwaSidebarSwipeEnd, {passive:true});
|
||||
window.addEventListener('pointercancel', _onPwaSidebarSwipeCancel, {passive:true});
|
||||
}
|
||||
_installPwaSidebarSwipeGesture();
|
||||
|
||||
// ── Desktop sidebar collapse toggle ────────────────────────────────────────
|
||||
// Two discoverability paths into the same state:
|
||||
// (1) Click the already-active rail icon → collapse / expand the sidebar.
|
||||
|
||||
+147
-4
@@ -1101,6 +1101,19 @@ const LOCALES = {
|
||||
memory_saved: 'Memory saved',
|
||||
my_notes: 'My Notes',
|
||||
user_profile: 'User Profile',
|
||||
external_notes_sources: 'Third-party notes',
|
||||
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
|
||||
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
|
||||
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
|
||||
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
|
||||
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
|
||||
external_notes_recent_ai: 'Recently used by AI',
|
||||
external_notes_auto: 'auto',
|
||||
external_notes_recent_ai_reason: 'Automatic recall',
|
||||
external_notes_search_placeholder: 'Search notes…',
|
||||
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
|
||||
source_active: 'active',
|
||||
source_configured: 'configured',
|
||||
no_notes_yet: 'No notes yet.',
|
||||
no_profile_yet: 'No profile yet.',
|
||||
agent_soul: 'Agent Soul',
|
||||
@@ -2352,6 +2365,19 @@ const LOCALES = {
|
||||
memory_saved: 'Memoria salvata',
|
||||
my_notes: 'Le Mie Note',
|
||||
user_profile: 'Profilo Utente',
|
||||
external_notes_sources: 'Third-party notes',
|
||||
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
|
||||
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
|
||||
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
|
||||
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
|
||||
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
|
||||
external_notes_recent_ai: 'Recently used by AI',
|
||||
external_notes_auto: 'auto',
|
||||
external_notes_recent_ai_reason: 'Automatic recall',
|
||||
external_notes_search_placeholder: 'Search notes…',
|
||||
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
|
||||
source_active: 'active',
|
||||
source_configured: 'configured',
|
||||
no_notes_yet: 'Ancora nessuna nota.',
|
||||
no_profile_yet: 'Ancora nessun profilo.',
|
||||
agent_soul: 'Anima dell\'Agente',
|
||||
@@ -3608,6 +3634,19 @@ const LOCALES = {
|
||||
memory_saved: 'メモリを保存しました',
|
||||
my_notes: 'マイノート',
|
||||
user_profile: 'ユーザープロファイル',
|
||||
external_notes_sources: 'Third-party notes',
|
||||
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
|
||||
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
|
||||
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
|
||||
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
|
||||
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
|
||||
external_notes_recent_ai: 'Recently used by AI',
|
||||
external_notes_auto: 'auto',
|
||||
external_notes_recent_ai_reason: 'Automatic recall',
|
||||
external_notes_search_placeholder: 'Search notes…',
|
||||
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
|
||||
source_active: 'active',
|
||||
source_configured: 'configured',
|
||||
no_notes_yet: 'まだノートはありません。',
|
||||
no_profile_yet: 'まだプロファイルはありません。',
|
||||
agent_soul: 'エージェントソウル',
|
||||
@@ -4597,6 +4636,19 @@ const LOCALES = {
|
||||
memory_saved: 'Память сохранена',
|
||||
my_notes: 'Мои заметки',
|
||||
user_profile: 'Пользовательский профиль',
|
||||
external_notes_sources: 'Third-party notes',
|
||||
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
|
||||
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
|
||||
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
|
||||
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
|
||||
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
|
||||
external_notes_recent_ai: 'Recently used by AI',
|
||||
external_notes_auto: 'auto',
|
||||
external_notes_recent_ai_reason: 'Automatic recall',
|
||||
external_notes_search_placeholder: 'Search notes…',
|
||||
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
|
||||
source_active: 'active',
|
||||
source_configured: 'configured',
|
||||
no_notes_yet: 'Пока нет заметок.',
|
||||
no_profile_yet: 'Пока нет профиля.',
|
||||
agent_soul: 'Душа агента',
|
||||
@@ -5805,6 +5857,19 @@ const LOCALES = {
|
||||
memory_saved: 'Memory saved',
|
||||
my_notes: 'My Notes',
|
||||
user_profile: 'User Profile',
|
||||
external_notes_sources: 'Third-party notes',
|
||||
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
|
||||
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
|
||||
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
|
||||
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
|
||||
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
|
||||
external_notes_recent_ai: 'Recently used by AI',
|
||||
external_notes_auto: 'auto',
|
||||
external_notes_recent_ai_reason: 'Automatic recall',
|
||||
external_notes_search_placeholder: 'Search notes…',
|
||||
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
|
||||
source_active: 'active',
|
||||
source_configured: 'configured',
|
||||
no_notes_yet: 'No notes yet.',
|
||||
no_profile_yet: 'No profile yet.',
|
||||
agent_soul: 'Agent Soul',
|
||||
@@ -7185,6 +7250,19 @@ const LOCALES = {
|
||||
memory_saved: 'Notiz gespeichert.',
|
||||
my_notes: 'Meine Notizen',
|
||||
user_profile: 'Benutzerprofil',
|
||||
external_notes_sources: 'Third-party notes',
|
||||
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
|
||||
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
|
||||
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
|
||||
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
|
||||
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
|
||||
external_notes_recent_ai: 'Recently used by AI',
|
||||
external_notes_auto: 'auto',
|
||||
external_notes_recent_ai_reason: 'Automatic recall',
|
||||
external_notes_search_placeholder: 'Search notes…',
|
||||
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
|
||||
source_active: 'active',
|
||||
source_configured: 'configured',
|
||||
no_notes_yet: 'Noch keine Notizen.',
|
||||
no_profile_yet: 'Noch kein Profil.',
|
||||
agent_soul: 'Agenten-Seele',
|
||||
@@ -7871,10 +7949,10 @@ const LOCALES = {
|
||||
new_conversation: '新建对话',
|
||||
filter_conversations: '筛选对话…',
|
||||
session_time_unknown: '未知',
|
||||
session_time_minutes_ago: (n) => `${n}分`,
|
||||
session_time_hours_ago: (n) => `${n}小时`,
|
||||
session_time_days_ago: (n) => `${n}天`,
|
||||
session_time_last_week: '1周',
|
||||
session_time_minutes_ago: (n) => `${n}分钟前`,
|
||||
session_time_hours_ago: (n) => `${n}小时前`,
|
||||
session_time_days_ago: (n) => `${n}天前`,
|
||||
session_time_last_week: '上周',
|
||||
session_time_bucket_today: '今天',
|
||||
session_time_bucket_yesterday: '昨天',
|
||||
session_time_bucket_this_week: '本周',
|
||||
@@ -8179,6 +8257,19 @@ const LOCALES = {
|
||||
memory_saved: '记忆已保存',
|
||||
my_notes: '我的备注',
|
||||
user_profile: '用户画像',
|
||||
external_notes_sources: 'Third-party notes',
|
||||
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
|
||||
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
|
||||
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
|
||||
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
|
||||
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
|
||||
external_notes_recent_ai: 'Recently used by AI',
|
||||
external_notes_auto: 'auto',
|
||||
external_notes_recent_ai_reason: 'Automatic recall',
|
||||
external_notes_search_placeholder: 'Search notes…',
|
||||
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
|
||||
source_active: 'active',
|
||||
source_configured: 'configured',
|
||||
no_notes_yet: '暂无备注。',
|
||||
no_profile_yet: '暂无用户画像。',
|
||||
agent_soul: '智能体灵魂',
|
||||
@@ -10720,6 +10811,19 @@ const LOCALES = {
|
||||
memory_saved: 'Memória salva',
|
||||
my_notes: 'Minhas Notas',
|
||||
user_profile: 'Perfil do Usuário',
|
||||
external_notes_sources: 'Third-party notes',
|
||||
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
|
||||
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
|
||||
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
|
||||
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
|
||||
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
|
||||
external_notes_recent_ai: 'Recently used by AI',
|
||||
external_notes_auto: 'auto',
|
||||
external_notes_recent_ai_reason: 'Automatic recall',
|
||||
external_notes_search_placeholder: 'Search notes…',
|
||||
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
|
||||
source_active: 'active',
|
||||
source_configured: 'configured',
|
||||
no_notes_yet: 'Nenhuma nota ainda.',
|
||||
no_profile_yet: 'Nenhum perfil definido.',
|
||||
agent_soul: 'Alma do Agente',
|
||||
@@ -11880,6 +11984,19 @@ const LOCALES = {
|
||||
memory_saved: 'Memory saved',
|
||||
my_notes: 'My Notes',
|
||||
user_profile: 'User Profile',
|
||||
external_notes_sources: 'Third-party notes',
|
||||
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
|
||||
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
|
||||
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
|
||||
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
|
||||
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
|
||||
external_notes_recent_ai: 'Recently used by AI',
|
||||
external_notes_auto: 'auto',
|
||||
external_notes_recent_ai_reason: 'Automatic recall',
|
||||
external_notes_search_placeholder: 'Search notes…',
|
||||
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
|
||||
source_active: 'active',
|
||||
source_configured: 'configured',
|
||||
no_notes_yet: 'No notes yet.',
|
||||
no_profile_yet: 'No profile yet.',
|
||||
agent_soul: 'Agent Soul',
|
||||
@@ -13051,6 +13168,19 @@ const LOCALES = {
|
||||
memory_saved: 'Mémoire sauvegardée',
|
||||
my_notes: 'Mes notes',
|
||||
user_profile: 'Profil utilisateur',
|
||||
external_notes_sources: 'Third-party notes',
|
||||
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
|
||||
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
|
||||
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
|
||||
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
|
||||
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
|
||||
external_notes_recent_ai: 'Recently used by AI',
|
||||
external_notes_auto: 'auto',
|
||||
external_notes_recent_ai_reason: 'Automatic recall',
|
||||
external_notes_search_placeholder: 'Search notes…',
|
||||
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
|
||||
source_active: 'active',
|
||||
source_configured: 'configured',
|
||||
no_notes_yet: 'Aucune note pour l\'instant.',
|
||||
no_profile_yet: 'Pas encore de profil.',
|
||||
agent_soul: 'Âme de l\'agent',
|
||||
@@ -14315,6 +14445,19 @@ const LOCALES = {
|
||||
memory_saved: 'Bellek kaydedildi',
|
||||
my_notes: 'Notlarım',
|
||||
user_profile: 'Kullanıcı Profili',
|
||||
external_notes_sources: 'Third-party notes',
|
||||
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
|
||||
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
|
||||
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
|
||||
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
|
||||
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
|
||||
external_notes_recent_ai: 'Recently used by AI',
|
||||
external_notes_auto: 'auto',
|
||||
external_notes_recent_ai_reason: 'Automatic recall',
|
||||
external_notes_search_placeholder: 'Search notes…',
|
||||
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
|
||||
source_active: 'active',
|
||||
source_configured: 'configured',
|
||||
no_notes_yet: 'Henüz not yok.',
|
||||
no_profile_yet: 'Henüz profil yok.',
|
||||
agent_soul: 'Ajan Ruhu',
|
||||
|
||||
+16
-1
@@ -1633,6 +1633,21 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
}catch(_){}
|
||||
});
|
||||
|
||||
source.addEventListener('context_status',e=>{
|
||||
let d={};
|
||||
try{ d=JSON.parse(e.data||'{}'); }catch(_){}
|
||||
if((d.session_id||activeSid)!==activeSid) return;
|
||||
const prefill=d.prefill||{};
|
||||
const status=String(prefill.status||'not_configured');
|
||||
const label=String(prefill.label||'session recall');
|
||||
if(status==='loaded'){
|
||||
setComposerStatus(`Context loaded: ${label}`);
|
||||
}else if(status==='error'){
|
||||
setComposerStatus(`Context unavailable: ${label}`);
|
||||
if(typeof showToast==='function') showToast(`Context unavailable: ${String(prefill.error||label)}`,3600,'warning');
|
||||
}
|
||||
});
|
||||
|
||||
function _resolveGoalMessage(d){
|
||||
const key=String(d && d.message_key ? d.message_key : '').trim();
|
||||
const args=Array.isArray(d && d.message_args) ? d.message_args : [];
|
||||
@@ -2147,7 +2162,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
_setActivePaneIdleIfOwner();
|
||||
});
|
||||
|
||||
for(const _runJournalEventName of ['token','interim_assistant','reasoning','tool','tool_complete','approval','clarify','title','title_status','goal','goal_continue','done','stream_end','pending_steer_leftover','compressing','compressed','metering','apperror','warning','error','cancel']){
|
||||
for(const _runJournalEventName of ['token','interim_assistant','reasoning','tool','tool_complete','approval','clarify','title','title_status','context_status','goal','goal_continue','done','stream_end','pending_steer_leftover','compressing','compressed','metering','apperror','warning','error','cancel']){
|
||||
source.addEventListener(_runJournalEventName,_rememberRunJournalCursor);
|
||||
}
|
||||
}
|
||||
|
||||
+153
-4
@@ -3854,13 +3854,20 @@ async function deleteCurrentSkill() {
|
||||
|
||||
// ── Memory (main view) ──
|
||||
let _memoryData = null;
|
||||
let _currentMemorySection = null; // 'memory' | 'user' | 'soul'
|
||||
let _notesSourcesData = null;
|
||||
let _notesSearchResults = [];
|
||||
let _notesSelectedSource = 'joplin';
|
||||
let _notesPreviewNote = null;
|
||||
let _notesSearchError = '';
|
||||
let _notesSearchLoading = false;
|
||||
let _currentMemorySection = null; // 'memory' | 'user' | 'soul' | 'external_notes'
|
||||
let _memoryMode = 'empty'; // 'empty' | 'read' | 'edit'
|
||||
|
||||
const MEMORY_SECTIONS = [
|
||||
{ key: 'memory', labelKey: 'my_notes', emptyKey: 'no_notes_yet', iconKey: 'brain' },
|
||||
{ key: 'user', labelKey: 'user_profile', emptyKey: 'no_profile_yet', iconKey: 'user' },
|
||||
{ key: 'soul', labelKey: 'agent_soul', emptyKey: 'no_soul_yet', iconKey: 'sparkles' },
|
||||
{ key: 'external_notes', labelKey: 'external_notes_sources', emptyKey: 'external_notes_empty', iconKey: 'book-open' },
|
||||
];
|
||||
|
||||
function _memorySectionMeta(key) {
|
||||
@@ -3887,12 +3894,84 @@ function _setMemoryHeaderButtons(mode) {
|
||||
const editBtn = $('btnEditMemoryDetail');
|
||||
const cancelBtn = $('btnCancelMemoryDetail');
|
||||
const saveBtn = $('btnSaveMemoryDetail');
|
||||
if (mode === 'read') { show(editBtn); hide(cancelBtn); hide(saveBtn); }
|
||||
if (mode === 'read' && _currentMemorySection !== 'external_notes') { show(editBtn); hide(cancelBtn); hide(saveBtn); }
|
||||
else if (mode === 'edit') { hide(editBtn); show(cancelBtn); show(saveBtn); }
|
||||
else { hide(editBtn); hide(cancelBtn); hide(saveBtn); }
|
||||
}
|
||||
|
||||
function _renderExternalNotesSources() {
|
||||
const title = $('memoryDetailTitle');
|
||||
const body = $('memoryDetailBody');
|
||||
const empty = $('memoryDetailEmpty');
|
||||
if (!title || !body) return;
|
||||
title.textContent = t('external_notes_sources');
|
||||
const data = _notesSourcesData || {};
|
||||
const sources = Array.isArray(data.sources) ? data.sources : [];
|
||||
const recall = data.automatic_recall_unchanged !== false
|
||||
? `<div class="memory-detail-mtime">${esc(t('external_notes_auto_recall_hint'))}</div>`
|
||||
: '';
|
||||
if (!sources.length) {
|
||||
body.innerHTML = `<div class="main-view-content">${recall}<div class="memory-empty">${esc(t('external_notes_empty'))}</div></div>`;
|
||||
} else {
|
||||
const selected = sources.find(src => (src.name || '').toLowerCase() === (_notesSelectedSource || '').toLowerCase()) || sources[0];
|
||||
_notesSelectedSource = (selected && selected.name) || 'joplin';
|
||||
const sourceOptions = sources.map(src => `<option value="${esc(src.name||'')}" ${src.name===_notesSelectedSource?'selected':''}>${esc(src.label||src.name||'')}</option>`).join('');
|
||||
const recentAiNotes = Array.isArray(data.recent_ai_notes) ? data.recent_ai_notes : [];
|
||||
const recentAiHtml = recentAiNotes.length
|
||||
? `<section class="notes-source-card notes-ai-recent-card">
|
||||
<div class="notes-source-card-head notes-ai-recent-head"><strong>${li('bot', 14)}${esc(t('external_notes_recent_ai'))}</strong><span class="detail-badge">${esc(t('external_notes_auto'))}</span></div>
|
||||
<div class="notes-ai-recent-list">${recentAiNotes.map(note => {
|
||||
const updated = note.updated_time ? new Date(Number(note.updated_time)).toLocaleString() : '';
|
||||
return `<button type="button" class="notes-result-card notes-ai-recent-item" onclick="previewExternalNote('${esc(note.source||'joplin')}','${esc(note.id||'')}')"><strong>${esc(note.title||note.label||'Untitled')}</strong><span>${li('clock', 14)}${esc(note.label||t('external_notes_recent_ai_reason'))}${updated ? ` · ${esc(updated)}` : ''}</span></button>`;
|
||||
}).join('')}</div>
|
||||
</section>`
|
||||
: '';
|
||||
const searchError = _notesSearchError ? `<div class="detail-form-error">${esc(_notesSearchError)}</div>` : '';
|
||||
const resultHtml = _notesSearchResults.length
|
||||
? `<div class="notes-search-results">${_notesSearchResults.map(note => `<button type="button" class="notes-result-card" onclick="previewExternalNote('${esc(note.source||_notesSelectedSource)}','${esc(note.id||'')}')"><strong>${esc(note.title||'Untitled')}</strong>${note.snippet?`<span>${esc(note.snippet)}</span>`:''}</button>`).join('')}</div>`
|
||||
: `<div class="memory-empty">${esc(t('external_notes_search_empty'))}</div>`;
|
||||
const previewHtml = _notesPreviewNote
|
||||
? `<section class="notes-source-card notes-preview-card"><div class="notes-source-card-head"><strong>${esc(_notesPreviewNote.title||'Untitled')}</strong><span class="detail-badge">${esc(_notesPreviewNote.source||_notesSelectedSource)}</span></div><div class="memory-content preview-md">${renderMd(_notesPreviewNote.body||'')}</div></section>`
|
||||
: '';
|
||||
const cards = sources.map(src => {
|
||||
const status = src.active ? t('source_active') : (src.status || t('source_configured'));
|
||||
const tools = Array.isArray(src.tools) ? src.tools : [];
|
||||
const hintHtml = src.tool_source === 'configured_hint'
|
||||
? `<div class="memory-detail-mtime">${esc(t('external_notes_configured_hint'))}</div>`
|
||||
: '';
|
||||
const toolHtml = tools.length
|
||||
? `<ul class="notes-source-tools">${tools.map(tool => `<li><strong>${esc(tool.name||'')}</strong>${tool.description?` — ${esc(tool.description)}`:''}</li>`).join('')}</ul>`
|
||||
: `<div class="memory-empty">${esc(t('external_notes_no_tools'))}</div>`;
|
||||
return `<section class="notes-source-card">
|
||||
<div class="notes-source-card-head"><strong>${esc(src.label||src.name||'')}</strong><span class="detail-badge ${src.active?'active':''}">${esc(status)}</span></div>
|
||||
<div class="memory-detail-mtime">${esc(t('external_notes_tool_count', src.tool_count||0))}</div>
|
||||
${hintHtml}
|
||||
${toolHtml}
|
||||
</section>`;
|
||||
}).join('');
|
||||
const searchUi = `<section class="notes-source-card notes-search-card">
|
||||
<form class="notes-search-form" onsubmit="event.preventDefault(); searchExternalNotes();">
|
||||
<select id="externalNotesSource" onchange="selectExternalNotesSource(this.value)">${sourceOptions}</select>
|
||||
<input id="externalNotesQuery" type="search" placeholder="${esc(t('external_notes_search_placeholder'))}" />
|
||||
<button type="submit" class="btn-secondary">${esc(_notesSearchLoading ? t('loading') : t('search'))}</button>
|
||||
</form>
|
||||
${searchError}
|
||||
${resultHtml}
|
||||
</section>`;
|
||||
body.innerHTML = `<div class="main-view-content">${recall}${recentAiHtml}${searchUi}${previewHtml}${cards}</div>`;
|
||||
}
|
||||
body.style.display = '';
|
||||
if (empty) empty.style.display = 'none';
|
||||
_memoryMode = 'read';
|
||||
_setMemoryHeaderButtons('read');
|
||||
}
|
||||
|
||||
function _renderMemoryDetail(section) {
|
||||
if (section === 'external_notes') {
|
||||
_renderExternalNotesSources();
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = _memorySectionMeta(section);
|
||||
const title = $('memoryDetailTitle');
|
||||
const body = $('memoryDetailBody');
|
||||
@@ -3939,15 +4018,78 @@ function _renderMemoryEdit(section) {
|
||||
if (ta) ta.focus();
|
||||
}
|
||||
|
||||
function openMemorySection(section, el) {
|
||||
async function loadNotesSources(force) {
|
||||
if (_notesSourcesData && !force) return _notesSourcesData;
|
||||
try {
|
||||
_notesSourcesData = await api('/api/notes/sources');
|
||||
} catch (e) {
|
||||
_notesSourcesData = {sources: [], automatic_recall_unchanged: true, error: e && e.message ? e.message : String(e)};
|
||||
}
|
||||
return _notesSourcesData;
|
||||
}
|
||||
|
||||
function selectExternalNotesSource(source) {
|
||||
_notesSelectedSource = source || 'joplin';
|
||||
_notesSearchResults = [];
|
||||
_notesPreviewNote = null;
|
||||
_notesSearchError = '';
|
||||
_renderExternalNotesSources();
|
||||
}
|
||||
|
||||
async function searchExternalNotes() {
|
||||
const input = $('externalNotesQuery');
|
||||
const sourceEl = $('externalNotesSource');
|
||||
const q = input ? input.value.trim() : '';
|
||||
_notesSelectedSource = sourceEl ? sourceEl.value : (_notesSelectedSource || 'joplin');
|
||||
_notesPreviewNote = null;
|
||||
_notesSearchError = '';
|
||||
if (!q) {
|
||||
_notesSearchResults = [];
|
||||
_renderExternalNotesSources();
|
||||
return;
|
||||
}
|
||||
_notesSearchLoading = true;
|
||||
_renderExternalNotesSources();
|
||||
try {
|
||||
const data = await api(`/api/notes/search?source=${encodeURIComponent(_notesSelectedSource)}&q=${encodeURIComponent(q)}&limit=20`);
|
||||
_notesSearchResults = Array.isArray(data.results) ? data.results : [];
|
||||
_notesSearchError = data.error || '';
|
||||
} catch (e) {
|
||||
_notesSearchResults = [];
|
||||
_notesSearchError = e && e.message ? e.message : String(e);
|
||||
} finally {
|
||||
_notesSearchLoading = false;
|
||||
_renderExternalNotesSources();
|
||||
const nextInput = $('externalNotesQuery');
|
||||
if (nextInput) nextInput.value = q;
|
||||
}
|
||||
}
|
||||
|
||||
async function previewExternalNote(source, id) {
|
||||
_notesSearchError = '';
|
||||
try {
|
||||
const data = await api(`/api/notes/item?source=${encodeURIComponent(source||_notesSelectedSource)}&id=${encodeURIComponent(id||'')}`);
|
||||
_notesPreviewNote = data && data.note ? data.note : null;
|
||||
} catch (e) {
|
||||
_notesPreviewNote = null;
|
||||
_notesSearchError = e && e.message ? e.message : String(e);
|
||||
}
|
||||
_renderExternalNotesSources();
|
||||
}
|
||||
|
||||
async function openMemorySection(section, el) {
|
||||
if (section === 'external_notes' && _memoryData && !_memoryData.external_notes_enabled) return;
|
||||
_currentMemorySection = section;
|
||||
document.querySelectorAll('#memoryPanel .side-menu-item').forEach(e => e.classList.remove('active'));
|
||||
if (el) el.classList.add('active');
|
||||
if (section === 'external_notes') {
|
||||
await loadNotesSources(false);
|
||||
}
|
||||
_renderMemoryDetail(section);
|
||||
}
|
||||
|
||||
function editCurrentMemory() {
|
||||
if (!_currentMemorySection) return;
|
||||
if (!_currentMemorySection || _currentMemorySection === 'external_notes') return;
|
||||
_renderMemoryEdit(_currentMemorySection);
|
||||
}
|
||||
|
||||
@@ -5328,9 +5470,16 @@ async function loadMemory(force) {
|
||||
try {
|
||||
const data = await api('/api/memory');
|
||||
_memoryData = data;
|
||||
if (_currentMemorySection === 'external_notes' && !data.external_notes_enabled) {
|
||||
_currentMemorySection = null;
|
||||
}
|
||||
if (_currentMemorySection === 'external_notes') {
|
||||
await loadNotesSources(!!force);
|
||||
}
|
||||
if (panel) {
|
||||
panel.innerHTML = '';
|
||||
for (const s of MEMORY_SECTIONS) {
|
||||
if (s.key === 'external_notes' && !_memoryData.external_notes_enabled) continue;
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.className = 'side-menu-item';
|
||||
|
||||
@@ -1166,6 +1166,24 @@
|
||||
.memory-content p{margin-bottom:6px;}
|
||||
.memory-empty{color:var(--muted);font-size:12px;font-style:italic;}
|
||||
.memory-detail-mtime{font-size:11px;color:var(--muted);opacity:.7;margin-bottom:12px;}
|
||||
.notes-source-card{border:1px solid var(--border);border-radius:12px;background:rgba(255,255,255,.03);padding:14px 16px;margin:0 0 12px;}
|
||||
.notes-source-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:4px;}
|
||||
.notes-source-tools{margin:10px 0 0 18px;padding:0;color:var(--muted);font-size:12px;line-height:1.55;}
|
||||
.notes-search-card{gap:12px;}
|
||||
.notes-search-form{display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
|
||||
.notes-search-form select,.notes-search-form input{min-height:34px;border:1px solid var(--border);border-radius:10px;background:var(--panel);color:var(--text);padding:6px 10px;}
|
||||
.notes-search-form input{flex:1;min-width:220px;}
|
||||
.notes-search-form button{min-height:34px;border:1px solid var(--border);border-radius:10px;background:var(--panel);color:var(--text);padding:6px 12px;font-size:12px;font-weight:600;cursor:pointer;transition:border-color .15s,color .15s,background .15s;}
|
||||
.notes-search-form button:hover{border-color:var(--accent);color:var(--accent);background:var(--panel-soft);}
|
||||
.notes-search-form button:disabled{opacity:.55;cursor:default;}
|
||||
.notes-search-results{display:grid;gap:8px;}
|
||||
.notes-ai-recent-list{display:grid;gap:8px;margin-top:10px;}
|
||||
.notes-ai-recent-head strong{display:inline-flex;align-items:center;gap:7px;}
|
||||
.notes-ai-recent-item span{display:inline-flex;align-items:center;gap:6px;}
|
||||
.notes-result-card{display:grid;gap:4px;width:100%;text-align:left;border:1px solid var(--border);border-radius:12px;background:var(--panel-soft);color:var(--text);padding:10px 12px;cursor:pointer;}
|
||||
.notes-result-card:hover{border-color:var(--accent);}
|
||||
.notes-result-card span{color:var(--muted);font-size:.9rem;}
|
||||
.notes-preview-card .memory-content{max-height:420px;overflow:auto;}
|
||||
.field-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;opacity:.8;}
|
||||
select{width:100%;background:var(--input-bg);border:1px solid var(--border2);border-radius:8px;color:var(--text);padding:8px 28px 8px 10px;font-size:12px;outline:none;appearance:none;margin-bottom:6px;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238888aa' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;}
|
||||
select:focus{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-bg);}
|
||||
|
||||
@@ -112,6 +112,18 @@ class TestLoadRepoDotenv:
|
||||
self._run(tmp_path, "HERMES_WEBUI_HOST=0.0.0.0\n")
|
||||
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
|
||||
|
||||
def test_preserve_existing_env_keeps_ctl_overrides(self, tmp_path):
|
||||
"""ctl.sh can ask bootstrap.py to keep wrapper-provided env values."""
|
||||
os.environ["HERMES_WEBUI_PRESERVE_ENV"] = "1"
|
||||
os.environ["HERMES_HOME"] = "/runtime/hermesOne"
|
||||
os.environ["HERMES_WEBUI_PASSWORD"] = ""
|
||||
self._run(
|
||||
tmp_path,
|
||||
"HERMES_HOME=/repo/default\nHERMES_WEBUI_PASSWORD=repo-password\n",
|
||||
)
|
||||
assert os.environ.get("HERMES_HOME") == "/runtime/hermesOne"
|
||||
assert os.environ.get("HERMES_WEBUI_PASSWORD") == ""
|
||||
|
||||
def test_does_not_set_empty_values(self, tmp_path):
|
||||
"""A key whose value is empty after stripping is not set to a non-empty string."""
|
||||
os.environ.pop("HERMES_EMPTY_KEY", None)
|
||||
|
||||
@@ -138,6 +138,13 @@ def test_start_writes_pid_under_hermes_home_runs_foreground_no_browser_and_logs(
|
||||
assert not pid_file.exists()
|
||||
|
||||
|
||||
def test_start_uses_nohup_so_daemon_survives_launcher_exit():
|
||||
ctl_text = CTL.read_text(encoding="utf-8")
|
||||
|
||||
assert "trap '' HUP" in ctl_text
|
||||
assert 'exec nohup "${python_exe}"' in ctl_text
|
||||
|
||||
|
||||
def test_start_loads_dotenv_but_inline_overrides_win(tmp_path):
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
|
||||
@@ -1,123 +1,48 @@
|
||||
"""Test that new_session() reads display.personality from config and uses it as default.
|
||||
"""Regression coverage for display.personality not becoming durable session state.
|
||||
|
||||
Regression test for the feature that makes /personality taleb sticky across
|
||||
new sessions — when display.personality is set in config.yaml, every new
|
||||
session should inherit it without requiring an explicit /personality command.
|
||||
Issue #2845: display.personality is a display/default hint, but new_session()
|
||||
previously copied it into Session.personality. That made cosmetic config durable
|
||||
per-session state and could override profile-scoped behavior later. Only an
|
||||
explicit /api/personality/set call should persist Session.personality.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R1: new_session() inherits display.personality from config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_new_session_reads_default_personality_from_config():
|
||||
"""When display.personality is set to 'taleb', new_session() should
|
||||
create a Session with personality='taleb'."""
|
||||
def test_new_session_does_not_inherit_display_personality_from_config():
|
||||
"""display.personality='taleb' must not stamp Session.personality."""
|
||||
import api.models as m
|
||||
import api.config as cfg_mod
|
||||
|
||||
_cfg = {
|
||||
cfg = {
|
||||
"display": {"personality": "taleb"},
|
||||
"agent": {"personalities": {"taleb": {"system_prompt": "Be like Taleb", "tone": "blunt"}}},
|
||||
}
|
||||
|
||||
with patch.object(cfg_mod, "get_config", return_value=_cfg), \
|
||||
with patch.object(cfg_mod, "get_config", return_value=cfg), \
|
||||
patch.object(m.Session, "save", return_value=None):
|
||||
s = m.new_session(workspace="/tmp/test-personality")
|
||||
|
||||
assert s.personality == "taleb", (
|
||||
f"Expected personality='taleb', got {s.personality!r}"
|
||||
)
|
||||
try:
|
||||
assert s.personality is None
|
||||
finally:
|
||||
with m.LOCK:
|
||||
m.SESSIONS.pop(s.session_id, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R2: 'none', 'default', 'neutral' are treated as no personality
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("personality_value", ["none", "default", "neutral", ""])
|
||||
def test_new_session_ignores_neutral_personality_values(personality_value):
|
||||
"""Values like 'none', 'default', 'neutral', and '' should NOT be set as
|
||||
the session personality — they mean 'no personality overlay'."""
|
||||
|
||||
def test_new_session_still_defaults_to_no_personality_when_config_missing():
|
||||
"""Missing display.personality continues to produce personality=None."""
|
||||
import api.models as m
|
||||
import api.config as cfg_mod
|
||||
|
||||
_cfg = {
|
||||
"display": {"personality": personality_value},
|
||||
"agent": {"personalities": {}},
|
||||
}
|
||||
cfg = {"agent": {"personalities": {}}}
|
||||
|
||||
with patch.object(cfg_mod, "get_config", return_value=_cfg), \
|
||||
patch.object(m.Session, "save", return_value=None):
|
||||
s = m.new_session(workspace="/tmp/test-personality-neutral")
|
||||
|
||||
assert s.personality is None, (
|
||||
f"Expected None for display.personality={personality_value!r}, "
|
||||
f"got {s.personality!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R3: Missing display.personality → personality=None
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_new_session_no_personality_when_config_missing():
|
||||
"""When config has no display.personality (or display section is absent),
|
||||
new_session() should set personality=None."""
|
||||
|
||||
import api.models as m
|
||||
import api.config as cfg_mod
|
||||
|
||||
_cfg = {"agent": {"personalities": {}}} # No display section at all
|
||||
|
||||
with patch.object(cfg_mod, "get_config", return_value=_cfg), \
|
||||
with patch.object(cfg_mod, "get_config", return_value=cfg), \
|
||||
patch.object(m.Session, "save", return_value=None):
|
||||
s = m.new_session(workspace="/tmp/test-personality-missing")
|
||||
|
||||
assert s.personality is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R4: Config exception is handled gracefully → personality=None
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_new_session_handles_config_exception_gracefully():
|
||||
"""If get_config() raises, we should still get a valid session with
|
||||
personality=None (the try/except should swallow the error)."""
|
||||
|
||||
import api.models as m
|
||||
import api.config as cfg_mod
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("config exploded")
|
||||
|
||||
with patch.object(cfg_mod, "get_config", side_effect=_boom), \
|
||||
patch.object(m.Session, "save", return_value=None):
|
||||
s = m.new_session(workspace="/tmp/test-personality-boom")
|
||||
|
||||
assert s.personality is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R5: display.personality is case-insensitive
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_new_session_personality_is_case_insensitive():
|
||||
"""display.personality='Taleb' should be normalized to 'taleb'."""
|
||||
|
||||
import api.models as m
|
||||
import api.config as cfg_mod
|
||||
|
||||
_cfg = {
|
||||
"display": {"personality": "Taleb"},
|
||||
"agent": {"personalities": {"taleb": {"system_prompt": "Be like Taleb"}}},
|
||||
}
|
||||
|
||||
with patch.object(cfg_mod, "get_config", return_value=_cfg), \
|
||||
patch.object(m.Session, "save", return_value=None):
|
||||
s = m.new_session(workspace="/tmp/test-personality-case")
|
||||
|
||||
assert s.personality == "taleb"
|
||||
try:
|
||||
assert s.personality is None
|
||||
finally:
|
||||
with m.LOCK:
|
||||
m.SESSIONS.pop(s.session_id, None)
|
||||
|
||||
@@ -290,6 +290,24 @@ def test_new_session_uses_explicit_profile_default_model_and_provider(tmp_path,
|
||||
m.SESSIONS.pop(s.session_id, None)
|
||||
|
||||
|
||||
def test_new_session_does_not_persist_display_personality(monkeypatch, tmp_path):
|
||||
"""display.personality is a UI/default hint, not durable per-session state."""
|
||||
import api.config as c
|
||||
import api.models as m
|
||||
|
||||
monkeypatch.setattr(c, "get_config", lambda: {"display": {"personality": "kawaii"}})
|
||||
with patch.object(m.Session, 'save', return_value=None):
|
||||
s = m.new_session(workspace=str(tmp_path), profile="default")
|
||||
try:
|
||||
assert s.personality is None, (
|
||||
"new_session() must not stamp display.personality into the persistent "
|
||||
"Session.personality field; only explicit /api/personality/set should."
|
||||
)
|
||||
finally:
|
||||
with m.LOCK:
|
||||
m.SESSIONS.pop(s.session_id, None)
|
||||
|
||||
|
||||
def test_get_hermes_home_for_profile_rejects_path_traversal():
|
||||
"""R19j: get_hermes_home_for_profile() must reject names that don't match
|
||||
_PROFILE_ID_RE (e.g. path traversal like '../../etc') and return the base
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_pwa_edge_swipe_gesture_is_registered_for_mobile_sidebar():
|
||||
assert "function _installPwaSidebarSwipeGesture" in BOOT_JS
|
||||
assert "window.addEventListener('pointerdown', _onPwaSidebarSwipeStart" in BOOT_JS
|
||||
assert "window.addEventListener('pointermove', _onPwaSidebarSwipeMove" in BOOT_JS
|
||||
assert "window.addEventListener('pointerup', _onPwaSidebarSwipeEnd" in BOOT_JS
|
||||
assert "window.addEventListener('pointercancel', _onPwaSidebarSwipeCancel" in BOOT_JS
|
||||
|
||||
|
||||
def test_pwa_sidebar_swipe_is_edge_gated_standalone_and_horizontal():
|
||||
assert "_isPwaStandalone()" in BOOT_JS
|
||||
assert "_PWA_SIDEBAR_SWIPE_EDGE" in BOOT_JS
|
||||
assert "_PWA_SIDEBAR_SWIPE_TRIGGER" in BOOT_JS
|
||||
assert "_PWA_SIDEBAR_SWIPE_MAX_VERTICAL" in BOOT_JS
|
||||
assert "clientX>_PWA_SIDEBAR_SWIPE_EDGE" in BOOT_JS.replace(" ", "")
|
||||
assert "dx>=_PWA_SIDEBAR_SWIPE_TRIGGER" in BOOT_JS.replace(" ", "")
|
||||
assert "Math.abs(dy)<=_PWA_SIDEBAR_SWIPE_MAX_VERTICAL" in BOOT_JS.replace(" ", "")
|
||||
assert "dx>Math.abs(dy)*1.5" in BOOT_JS.replace(" ", "")
|
||||
|
||||
assert "input,textarea,select,button,a,[contenteditable=\"true\"],.topbar-chips,.composer-left,.sidebar,.rightpanel" in BOOT_JS
|
||||
assert ".messages" not in BOOT_JS[BOOT_JS.find("function _isInteractiveSwipeTarget"):BOOT_JS.find("function _openMobileSidebarFromGesture")]
|
||||
|
||||
|
||||
def test_pwa_sidebar_swipe_opens_existing_mobile_drawer_without_desktop_collapse():
|
||||
assert "_openMobileSidebarFromGesture" in BOOT_JS
|
||||
assert "sidebar.classList.remove('sidebar-collapsed')" in BOOT_JS
|
||||
assert "sidebar.classList.add('mobile-open')" in BOOT_JS
|
||||
assert "overlay.classList.add('visible')" in BOOT_JS
|
||||
assert "toggleSidebar(" not in BOOT_JS[BOOT_JS.find("function _openMobileSidebarFromGesture"):BOOT_JS.find("function _installPwaSidebarSwipeGesture")]
|
||||
|
||||
|
||||
def test_pwa_sidebar_swipe_does_not_disable_horizontal_scrollers_globally():
|
||||
compact = STYLE_CSS.replace(" ", "")
|
||||
assert "html{touch-action" not in compact
|
||||
assert "body{touch-action" not in compact
|
||||
assert ".layout{touch-action" not in compact
|
||||
@@ -389,7 +389,8 @@ class TestRuntimeRouteInjection(unittest.TestCase):
|
||||
init_kwargs = captured["init_kwargs"]
|
||||
self.assertIsNotNone(init_kwargs["interim_assistant_callback"])
|
||||
self.assertTrue(callable(init_kwargs["interim_assistant_callback"]))
|
||||
self.assertIn("WebUI progress contract", captured["agent"].ephemeral_system_prompt)
|
||||
self.assertIn("WebUI progress guidance", captured["agent"].ephemeral_system_prompt)
|
||||
self.assertIn("Match the normal Hermes messaging style", captured["agent"].ephemeral_system_prompt)
|
||||
self.assertIn("user-visible progress updates", captured["agent"].ephemeral_system_prompt)
|
||||
|
||||
interim_events = []
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Regression tests for WebUI notes source discovery."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_notes_sources_identifies_note_or_knowledge_mcp_servers():
|
||||
from api.routes import _notes_sources_from_mcp_inventory
|
||||
|
||||
servers = {
|
||||
"joplin": {"name": "joplin", "enabled": True, "active": True, "status": "healthy"},
|
||||
"filesystem": {"name": "filesystem", "enabled": True, "active": True, "status": "healthy"},
|
||||
"llm-wiki": {"name": "llm-wiki", "enabled": True, "active": False, "status": "configured"},
|
||||
}
|
||||
tools = [
|
||||
{"server": "joplin", "name": "search_notes", "description": "Search notes by keyword"},
|
||||
{"server": "joplin", "name": "get_note", "description": "Get full note content"},
|
||||
{"server": "filesystem", "name": "read_text_file", "description": "Read files"},
|
||||
{"server": "llm-wiki", "name": "query_knowledge_base", "description": "Search wiki knowledge"},
|
||||
]
|
||||
|
||||
sources = _notes_sources_from_mcp_inventory(servers, tools)
|
||||
|
||||
assert [source["name"] for source in sources] == ["joplin", "llm-wiki"]
|
||||
assert sources[0]["label"] == "Joplin"
|
||||
assert sources[0]["tool_count"] == 2
|
||||
assert sources[0]["active"] is True
|
||||
assert sources[1]["active"] is False
|
||||
|
||||
|
||||
def test_notes_sources_redacts_tool_descriptions_and_omits_plain_file_tools():
|
||||
from api.routes import _notes_sources_from_mcp_inventory
|
||||
|
||||
servers = {"notion": {"name": "notion", "enabled": True, "active": True, "status": "healthy"}}
|
||||
tools = [
|
||||
{"server": "notion", "name": "search_pages", "description": "Search notes api_key=redaction-test-placeholder"},
|
||||
]
|
||||
|
||||
[source] = _notes_sources_from_mcp_inventory(servers, tools)
|
||||
|
||||
assert source["name"] == "notion"
|
||||
assert "token" not in source["tools"][0]["description"].lower()
|
||||
assert "[REDACTED]" in source["tools"][0]["description"]
|
||||
|
||||
|
||||
def test_notes_sources_shows_configured_note_servers_without_tool_inventory():
|
||||
from api.routes import _notes_sources_from_mcp_inventory
|
||||
|
||||
servers = {
|
||||
"joplin": {"name": "joplin", "enabled": True, "active": False, "status": "configured"},
|
||||
"filesystem": {"name": "filesystem", "enabled": True, "active": True, "status": "healthy"},
|
||||
}
|
||||
|
||||
sources = _notes_sources_from_mcp_inventory(servers, [])
|
||||
|
||||
assert [source["name"] for source in sources] == ["joplin"]
|
||||
assert sources[0]["label"] == "Joplin"
|
||||
assert sources[0]["tool_count"] == 3
|
||||
assert [tool["name"] for tool in sources[0]["tools"]] == ["search_notes", "list_notes", "get_note"]
|
||||
assert all(tool.get("inferred") is True for tool in sources[0]["tools"])
|
||||
assert sources[0]["tool_source"] == "configured_hint"
|
||||
assert sources[0]["status"] == "configured"
|
||||
|
||||
|
||||
def test_external_notes_sources_drawer_is_default_off(monkeypatch):
|
||||
from api import routes
|
||||
|
||||
monkeypatch.delenv("HERMES_WEBUI_EXTERNAL_NOTES_SOURCES", raising=False)
|
||||
|
||||
assert routes._external_notes_sources_enabled({}) is False
|
||||
assert routes._external_notes_sources_enabled({"webui_external_notes_sources": False}) is False
|
||||
|
||||
|
||||
def test_external_notes_sources_drawer_can_be_enabled_by_config_or_env(monkeypatch):
|
||||
from api import routes
|
||||
|
||||
monkeypatch.delenv("HERMES_WEBUI_EXTERNAL_NOTES_SOURCES", raising=False)
|
||||
assert routes._external_notes_sources_enabled({"webui_external_notes_sources": True}) is True
|
||||
assert routes._external_notes_sources_enabled({"external_notes_sources": "yes"}) is True
|
||||
|
||||
monkeypatch.setenv("HERMES_WEBUI_EXTERNAL_NOTES_SOURCES", "1")
|
||||
assert routes._external_notes_sources_enabled({}) is True
|
||||
|
||||
|
||||
def test_joplin_search_notes_returns_safe_snippets(monkeypatch):
|
||||
from api import routes
|
||||
|
||||
def fake_get(path, params=None):
|
||||
assert path == "/search"
|
||||
assert params["type"] == "note"
|
||||
return {"items": [{
|
||||
"id": "abc123def4567890",
|
||||
"title": "Hermes Context",
|
||||
"body": "This is a long Hermes context note with useful details.",
|
||||
"parent_id": "folder123",
|
||||
"updated_time": 123,
|
||||
}]}
|
||||
|
||||
monkeypatch.setattr(routes, "_joplin_api_get", fake_get)
|
||||
|
||||
results = routes._joplin_search_notes("Hermes")
|
||||
|
||||
assert results == [{
|
||||
"id": "abc123def4567890",
|
||||
"title": "Hermes Context",
|
||||
"snippet": "This is a long Hermes context note with useful details.",
|
||||
"parent_id": "folder123",
|
||||
"updated_time": 123,
|
||||
"source": "joplin",
|
||||
}]
|
||||
|
||||
|
||||
def test_joplin_get_note_validates_id_and_truncates_body(monkeypatch):
|
||||
from api import routes
|
||||
|
||||
def fake_get(path, params=None):
|
||||
assert path == "/notes/abc123def4567890"
|
||||
return {
|
||||
"id": "abc123def4567890",
|
||||
"title": "Big Note",
|
||||
"body": "x" * 60000,
|
||||
"parent_id": "folder123",
|
||||
"updated_time": 456,
|
||||
"created_time": 123,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(routes, "_joplin_api_get", fake_get)
|
||||
|
||||
note = routes._joplin_get_note("abc123def4567890")
|
||||
|
||||
assert note["title"] == "Big Note"
|
||||
assert note["source"] == "joplin"
|
||||
assert len(note["body"]) < 51000
|
||||
assert "Preview truncated" in note["body"]
|
||||
|
||||
|
||||
def test_joplin_recent_ai_notes_uses_configured_prefill_script(monkeypatch, tmp_path):
|
||||
from api import routes
|
||||
|
||||
script = tmp_path / "joplin_context.py"
|
||||
script.write_text(
|
||||
'\n'.join([
|
||||
'CURRENT_CONTEXT_ID = "5ba9ab822c344115939205ca4e8eaec0"',
|
||||
'OPEN_ISSUES_ID = "623aeb6e55cb4aa39a0541f2ac09aa36"',
|
||||
'AGENT_MEMORY_ID = "0a7a232ea46b4b8bb0bbd4358f725a84"',
|
||||
'RAW_CAPTURES_ID = "cb1087795c7d4129a863ab0a5642233d"',
|
||||
]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(routes, "get_config", lambda: {"prefill_messages_script": str(script)})
|
||||
|
||||
def fake_get(path, params=None):
|
||||
note_id = path.rsplit("/", 1)[-1]
|
||||
titles = {
|
||||
"5ba9ab822c344115939205ca4e8eaec0": "Current Context",
|
||||
"623aeb6e55cb4aa39a0541f2ac09aa36": "Open Issues",
|
||||
"0a7a232ea46b4b8bb0bbd4358f725a84": "Agent Memory",
|
||||
}
|
||||
assert note_id in titles
|
||||
return {"id": note_id, "title": titles[note_id], "updated_time": 123, "parent_id": "folder"}
|
||||
|
||||
monkeypatch.setattr(routes, "_joplin_api_get", fake_get)
|
||||
|
||||
notes = routes._joplin_recent_ai_notes(limit=3)
|
||||
|
||||
assert [note["title"] for note in notes] == ["Current Context", "Open Issues", "Agent Memory"]
|
||||
assert all(note["source"] == "joplin" for note in notes)
|
||||
assert all(note["used_by"] == "ai_prefill" for note in notes)
|
||||
assert all(note["used_reason"] == "automatic_recall" for note in notes)
|
||||
|
||||
|
||||
def test_external_notes_ui_uses_minimal_lucide_icons_for_ai_recent_notes():
|
||||
from pathlib import Path
|
||||
|
||||
panels = Path("static/panels.js").read_text(encoding="utf-8")
|
||||
start = panels.index("function _renderExternalNotesSources()")
|
||||
end = panels.index("function _renderMemoryDetail", start)
|
||||
notes_block = panels[start:end]
|
||||
assert "notes-ai-recent-card" in notes_block
|
||||
assert "li('bot', 14)" in notes_block
|
||||
assert "li('clock', 14)" in notes_block
|
||||
assert "Recently used by AI" not in notes_block # i18n key, not hard-coded UI copy
|
||||
assert "🤖" not in notes_block
|
||||
assert "📚" not in notes_block
|
||||
|
||||
|
||||
def test_external_notes_menu_item_is_default_off_from_memory_payload():
|
||||
from pathlib import Path
|
||||
|
||||
panels = Path("static/panels.js").read_text(encoding="utf-8")
|
||||
assert "external_notes_enabled" in panels
|
||||
assert "if (s.key === 'external_notes' && !_memoryData.external_notes_enabled) continue;" in panels
|
||||
|
||||
|
||||
def test_external_notes_search_button_matches_minimal_dark_controls():
|
||||
from pathlib import Path
|
||||
|
||||
css = Path("static/style.css").read_text(encoding="utf-8")
|
||||
assert ".notes-search-form button" in css
|
||||
button_block = css[css.index(".notes-search-form button"):css.index(".notes-search-form button:hover")]
|
||||
assert "background:var(--panel)" in button_block or "background:var(--surface)" in button_block
|
||||
assert "border:1px solid var(--border)" in button_block
|
||||
assert "color:var(--text)" in button_block
|
||||
assert "border-radius:10px" in button_block
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Regression tests for WebUI session prefill parity."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def test_prefill_json_file_keeps_valid_roles_and_drops_invalid_items(tmp_path):
|
||||
from api.streaming import _load_webui_prefill_context
|
||||
|
||||
prefill = tmp_path / "prefill.json"
|
||||
prefill.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{"role": "user", "content": "Pinned context"},
|
||||
{"role": "tool", "content": "drop invalid role"},
|
||||
{"role": "assistant", "content": "Useful assistant context"},
|
||||
{"role": "system", "content": " "},
|
||||
"not a message",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = _load_webui_prefill_context({"prefill_messages_file": str(prefill)})
|
||||
|
||||
assert result["status"] == "loaded"
|
||||
assert result["source"] == "file"
|
||||
assert result["label"] == "prefill.json"
|
||||
assert result["messages"] == [
|
||||
{"role": "user", "content": "Pinned context"},
|
||||
{"role": "assistant", "content": "Useful assistant context"},
|
||||
]
|
||||
|
||||
|
||||
def test_prefill_script_config_is_ignored_in_webui(tmp_path):
|
||||
from api.streaming import _load_webui_prefill_context
|
||||
|
||||
script = tmp_path / "recall.py"
|
||||
script.write_text("raise SystemExit('should not run')\n", encoding="utf-8")
|
||||
|
||||
result = _load_webui_prefill_context({"prefill_messages_script": str(script)})
|
||||
|
||||
assert result == {
|
||||
"status": "not_configured",
|
||||
"source": "none",
|
||||
"label": "",
|
||||
"messages": [],
|
||||
"message_count": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_public_prefill_status_strips_message_bodies():
|
||||
from api.streaming import _public_prefill_context_status
|
||||
|
||||
public = _public_prefill_context_status(
|
||||
{
|
||||
"status": "loaded",
|
||||
"source": "file",
|
||||
"label": "prefill.json",
|
||||
"message_count": 1,
|
||||
"messages": [{"role": "user", "content": "private recall payload"}],
|
||||
}
|
||||
)
|
||||
|
||||
assert public == {
|
||||
"status": "loaded",
|
||||
"source": "file",
|
||||
"label": "prefill.json",
|
||||
"message_count": 1,
|
||||
}
|
||||
assert "messages" not in public
|
||||
|
||||
|
||||
def test_prefill_status_redactor_handles_secret_shaped_text():
|
||||
from api.streaming import _redact_prefill_status_text
|
||||
|
||||
redacted = _redact_prefill_status_text("api_key=redaction-test-placeholder leaked")
|
||||
|
||||
assert "redaction-test-placeholder" not in redacted
|
||||
assert "[REDACTED]" in redacted
|
||||
@@ -0,0 +1,39 @@
|
||||
from api.streaming import _webui_ephemeral_system_prompt
|
||||
|
||||
|
||||
def test_webui_ephemeral_prompt_includes_browser_surface_context():
|
||||
prompt = _webui_ephemeral_system_prompt(
|
||||
"Use a concise tone.",
|
||||
surface_context={
|
||||
"source": "webui",
|
||||
"session_id": "session-123",
|
||||
"profile": "default",
|
||||
"workspace": "/tmp/example-workspace",
|
||||
},
|
||||
)
|
||||
|
||||
assert "Use a concise tone." in prompt
|
||||
assert "WebUI session context" in prompt
|
||||
assert "Source: webui" in prompt
|
||||
assert "Session ID: session-123" in prompt
|
||||
assert "Profile: default" in prompt
|
||||
assert "Workspace: /tmp/example-workspace" in prompt
|
||||
assert "not the same live transcript as Telegram" in prompt
|
||||
|
||||
|
||||
def test_webui_ephemeral_prompt_skips_empty_surface_fields():
|
||||
prompt = _webui_ephemeral_system_prompt(
|
||||
None,
|
||||
surface_context={
|
||||
"source": "webui",
|
||||
"session_id": "",
|
||||
"profile": None,
|
||||
"workspace": " ",
|
||||
},
|
||||
)
|
||||
|
||||
assert "WebUI session context" in prompt
|
||||
assert "Source: webui" in prompt
|
||||
assert "Session ID:" not in prompt
|
||||
assert "Profile:" not in prompt
|
||||
assert "Workspace:" not in prompt
|
||||
Reference in New Issue
Block a user