From c789416e4097f7e487277a7f20eff0d8aca1c6bc Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:03:06 +0800 Subject: [PATCH] feat(desktop): polish shell and shared surfaces --- README.md | 2 +- desktop/package.json | 2 +- nanobot/agent/loop.py | 38 +- nanobot/agent/skills.py | 18 + nanobot/channels/manager.py | 4 + nanobot/channels/websocket.py | 131 ++---- nanobot/cli/commands.py | 58 ++- nanobot/command/builtin.py | 7 + nanobot/webui/gateway_services.py | 11 + nanobot/webui/session_automations.py | 56 +++ nanobot/webui/skills_api.py | 61 +++ nanobot/webui/token_usage.py | 34 +- nanobot/webui/transcript.py | 149 ++++++- nanobot/webui/ws_http.py | 61 ++- tests/agent/test_dream.py | 12 - tests/channels/test_websocket_http_routes.py | 161 ++++++- tests/cli/test_commands.py | 58 ++- tests/utils/test_webui_transcript.py | 49 ++ tests/webui/test_token_usage.py | 39 ++ webui/src/App.tsx | 37 +- webui/src/components/MarkdownTextRenderer.tsx | 72 +-- webui/src/components/MessageBubble.tsx | 31 +- webui/src/components/Sidebar.tsx | 11 +- .../src/components/settings/SettingsView.tsx | 30 +- .../settings/SkillsCatalogSettings.tsx | 417 ++++++++++++++++++ .../components/thread/SessionInfoPopover.tsx | 224 ++++++++++ webui/src/components/thread/ThreadHeader.tsx | 62 +-- webui/src/components/thread/ThreadShell.tsx | 5 + webui/src/components/ui/sheet.tsx | 14 +- webui/src/globals.css | 33 ++ webui/src/hooks/useNanobotStream.ts | 1 + webui/src/hooks/useSessionAutomationJobs.ts | 61 +++ webui/src/hooks/useSkills.ts | 20 + webui/src/i18n/locales/en/common.json | 62 ++- webui/src/i18n/locales/es/common.json | 64 ++- webui/src/i18n/locales/fr/common.json | 64 ++- webui/src/i18n/locales/id/common.json | 64 ++- webui/src/i18n/locales/ja/common.json | 64 ++- webui/src/i18n/locales/ko/common.json | 64 ++- webui/src/i18n/locales/vi/common.json | 64 ++- webui/src/i18n/locales/zh-CN/common.json | 62 ++- webui/src/i18n/locales/zh-TW/common.json | 64 ++- webui/src/lib/api.ts | 41 ++ webui/src/lib/types.ts | 50 +++ webui/src/tests/api.test.ts | 36 ++ webui/src/tests/app-layout.test.tsx | 122 +++-- .../src/tests/markdown-text-renderer.test.tsx | 36 ++ webui/src/tests/message-bubble.test.tsx | 16 + webui/src/tests/session-info-popover.test.tsx | 117 +++++ webui/src/tests/useNanobotStream.test.tsx | 22 + 50 files changed, 2689 insertions(+), 292 deletions(-) create mode 100644 nanobot/webui/session_automations.py create mode 100644 nanobot/webui/skills_api.py create mode 100644 webui/src/components/settings/SkillsCatalogSettings.tsx create mode 100644 webui/src/components/thread/SessionInfoPopover.tsx create mode 100644 webui/src/hooks/useSessionAutomationJobs.ts create mode 100644 webui/src/hooks/useSkills.ts create mode 100644 webui/src/tests/session-info-popover.test.tsx diff --git a/README.md b/README.md index 16a9091c1..e07956b1e 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@
-🐈 **nanobot** is an open-source, ultra-lightweight agent runtime for people who want to own their AI agent stack. It gives you a small, readable core plus the practical pieces for real long-running agents: WebUI, chat channels, tools, memory, MCP, model routing, and deployment. +🐈 **nanobot** is an open-source, ultra-lightweight personal AI agent you can truly own. It keeps the agent core small and readable while giving you the practical pieces for real long-running work: WebUI, chat channels, tools, memory, MCP, model routing, automation, and deployment. ## 📢 News diff --git a/desktop/package.json b/desktop/package.json index 77c4d82b6..83b816845 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -25,7 +25,7 @@ "typescript": "^5.7.2" }, "build": { - "appId": "ai.nanobot.desktop", + "appId": "wiki.nanobot.desktop", "productName": "nanobot", "asar": true, "files": [ diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 9bbc272c6..f31589cb9 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -658,31 +658,6 @@ class AgentLoop: budget = self.context_window_tokens - max(1, reserved_output) - 1024 return budget if budget > 0 else max(128, self.context_window_tokens // 2) - @staticmethod - def _hook_includes_ephemeral(hook: AgentHook) -> bool: - try: - return hook.include_ephemeral() is True - except Exception: - return False - - @staticmethod - def _usage_source_for_turn( - *, - channel: str, - session_key: str | None, - ephemeral: bool, - ) -> str: - key = session_key or "" - if key.startswith("dream:") or (ephemeral and key.startswith("dream")): - return "dream" - if key == "heartbeat" or key.startswith("cron:"): - return "cron" - if channel == "api" or key.startswith("api:"): - return "api" - if channel == "system": - return "system" - return "user" - async def _run_agent_loop( self, initial_messages: list[dict], @@ -726,12 +701,8 @@ class AgentLoop: on_iteration=lambda iteration: setattr(self, "_current_iteration", iteration), ) hook: AgentHook = loop_hook - extra_hooks = [ - h for h in self._extra_hooks - if not ephemeral or self._hook_includes_ephemeral(h) - ] - if extra_hooks: - hook = CompositeHook([loop_hook] + extra_hooks) + if not ephemeral and self._extra_hooks: + hook = CompositeHook([loop_hook] + self._extra_hooks) async def _checkpoint(payload: dict[str, Any]) -> None: if session is None: @@ -845,11 +816,6 @@ class AgentLoop: ), goal_active_predicate=lambda: sustained_goal_active(session.metadata) if session is not None else False, goal_continue_message=_goal_continue, - usage_source=self._usage_source_for_turn( - channel=channel, - session_key=active_session_key, - ephemeral=ephemeral, - ), )) finally: reset_workspace_scope(workspace_token) diff --git a/nanobot/agent/skills.py b/nanobot/agent/skills.py index b01ca74ee..b22724347 100644 --- a/nanobot/agent/skills.py +++ b/nanobot/agent/skills.py @@ -151,6 +151,24 @@ class SkillsLoader: + [f"ENV: {env_name}" for env_name in required_env_vars if not os.environ.get(env_name)] ) + def get_skill_availability(self, name: str) -> tuple[bool, str]: + """Return whether a skill can run and why not when it cannot.""" + meta = self._get_skill_meta(name) + available = self._check_requirements(meta) + return available, "" if available else self._get_missing_requirements(meta) + + def get_skill_requirements(self, name: str) -> dict[str, list[str]]: + """Return explicit command/env requirements and currently missing entries.""" + requires = self._get_skill_meta(name).get("requires", {}) + bins = [str(value) for value in requires.get("bins", [])] + env = [str(value) for value in requires.get("env", [])] + return { + "bins": bins, + "env": env, + "missing_bins": [value for value in bins if not shutil.which(value)], + "missing_env": [value for value in env if not os.environ.get(value)], + } + def _get_skill_description(self, name: str) -> str: """Get the description of a skill from its frontmatter.""" meta = self.get_skill_metadata(name) diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 5bbc8879d..ffa5cca67 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -56,6 +56,7 @@ class ChannelManager: bus: MessageBus, *, session_manager: "SessionManager | None" = None, + cron_service: Any | None = None, webui_runtime_model_name: Callable[[], str | None] | None = None, webui_static_dist: bool = True, webui_runtime_surface: str = "browser", @@ -64,6 +65,7 @@ class ChannelManager: self.config = config self.bus = bus self._session_manager = session_manager + self._cron_service = cron_service self._webui_runtime_model_name = webui_runtime_model_name self._webui_static_dist = webui_static_dist self._webui_runtime_surface = webui_runtime_surface @@ -124,9 +126,11 @@ class ChannelManager: static_dist_path=static_path, workspace_path=workspace, default_restrict_to_workspace=self.config.tools.restrict_to_workspace, + disabled_skills=set(self.config.agents.defaults.disabled_skills), runtime_model_name=self._webui_runtime_model_name, runtime_surface=self._webui_runtime_surface, runtime_capabilities_overrides=self._webui_runtime_capabilities, + cron_service=self._cron_service, logger=logger, ) kwargs["gateway"] = gateway diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 85f60148c..09b3f900e 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -45,7 +45,6 @@ from nanobot.webui.http_utils import ( query_first as _query_first, ) from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions -from nanobot.webui.transcript import append_transcript_object, build_user_transcript_event from nanobot.webui.websocket_logging import websockets_server_logger @@ -237,8 +236,6 @@ _VIDEO_MIME_ALLOWED: frozenset[str] = frozenset({ _UPLOAD_MIME_ALLOWED: frozenset[str] = _IMAGE_MIME_ALLOWED | _VIDEO_MIME_ALLOWED _DATA_URL_MIME_RE = re.compile(r"^data:([^;]+);base64,", re.DOTALL) -_WEBUI_TURN_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$") -_WEBUI_TURN_META_KEY = "webui_turn_id" def _extract_data_url_mime(url: str) -> str | None: @@ -262,14 +259,6 @@ def _is_websocket_upgrade(request: WsRequest) -> bool: return True -def _normalize_webui_turn_id(value: Any) -> str: - if isinstance(value, str): - candidate = value.strip() - if _WEBUI_TURN_ID_RE.fullmatch(candidate): - return candidate - return str(uuid.uuid4()) - - class WebSocketChannel(BaseChannel): """Run a local WebSocket server; forward text/JSON messages to the message bus.""" @@ -300,10 +289,10 @@ class WebSocketChannel(BaseChannel): self._http_router = gateway.http self._tokens = gateway.tokens self._media = gateway.media + self._transcripts = gateway.transcripts self._workspaces = gateway.workspaces self._stream_text_buffers: dict[tuple[str, str], list[str]] = {} - self._webui_turn_sequences: dict[tuple[str, str], int] = {} # -- Subscription bookkeeping ------------------------------------------- @@ -762,9 +751,9 @@ class WebSocketChannel(BaseChannel): self._attach(connection, cid) await self._hydrate_after_subscribe(cid) metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)} - metadata[_WEBUI_TURN_META_KEY] = _normalize_webui_turn_id(envelope.get("turn_id")) if envelope.get("webui") is True: metadata["webui"] = True + metadata.update(self._transcripts.client_turn_metadata(envelope.get("turn_id"))) cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps")) if cli_apps: metadata["cli_apps"] = cli_apps @@ -781,13 +770,13 @@ class WebSocketChannel(BaseChannel): "aspect_ratio": aspect_ratio if isinstance(aspect_ratio, str) else None, } if metadata.get("webui") is True and self.is_allowed(client_id): - self._try_append_webui_user_transcript( + self._transcripts.append_user_message( cid, content, metadata=metadata, - media_paths=media_paths, - cli_apps=cli_apps, - mcp_presets=mcp_presets, + media_paths=media_paths or None, + cli_apps=cli_apps or None, + mcp_presets=mcp_presets or None, ) await self._handle_message( sender_id=client_id, @@ -849,59 +838,6 @@ class WebSocketChannel(BaseChannel): self.logger.exception("send failed{}", label) raise - def _try_append_webui_transcript(self, chat_id: str, wire: dict[str, Any]) -> None: - sk = f"websocket:{chat_id}" - try: - dup = json.loads(json.dumps(wire, ensure_ascii=False)) - append_transcript_object(sk, dup) - except (OSError, ValueError, TypeError) as e: - self.logger.warning("webui transcript append failed: {}", e) - - def _try_append_webui_user_transcript( - self, - chat_id: str, - content: str, - *, - metadata: dict[str, Any] | None, - media_paths: list[str], - cli_apps: list[dict[str, Any]], - mcp_presets: list[dict[str, Any]], - ) -> None: - if content.strip() == "/stop" and not media_paths: - return - payload = build_user_transcript_event( - chat_id, - content, - media_paths=media_paths, - cli_apps=cli_apps, - mcp_presets=mcp_presets, - ) - if payload is None: - return - self._annotate_webui_turn(payload, chat_id, metadata, "user") - self._try_append_webui_transcript(chat_id, payload) - - def _next_webui_turn_seq(self, chat_id: str, turn_id: str) -> int: - key = (chat_id, turn_id) - seq = self._webui_turn_sequences.get(key, 0) + 1 - self._webui_turn_sequences[key] = seq - return seq - - def _annotate_webui_turn( - self, - payload: dict[str, Any], - chat_id: str, - metadata: dict[str, Any] | None, - phase: str, - ) -> None: - meta = metadata or {} - turn_id = meta.get(_WEBUI_TURN_META_KEY) - if not isinstance(turn_id, str) or not turn_id: - return - payload["turn_id"] = turn_id - payload["turn_phase"] = phase - payload["turn_seq"] = self._next_webui_turn_seq(chat_id, turn_id) - async def send(self, msg: OutboundMessage) -> None: if msg.metadata.get("_runtime_model_updated"): await self.send_runtime_model_updated( @@ -1001,10 +937,14 @@ class WebSocketChannel(BaseChannel): elif msg.metadata.get("_progress"): payload["kind"] = "progress" phase = "activity" if payload.get("kind") in ("tool_hint", "progress") else "answer" - self._annotate_webui_turn(payload, msg.chat_id, msg.metadata, phase) - transcript_payload = dict(payload) - transcript_payload["text"] = text - self._try_append_webui_transcript(msg.chat_id, transcript_payload) + self._transcripts.prepare_and_append( + msg.chat_id, + payload, + metadata=msg.metadata, + phase=phase, + include_source=True, + transcript_overrides={"text": text}, + ) raw = json.dumps(payload, ensure_ascii=False) for connection in conns: await self._safe_send_to(connection, raw, label=" ") @@ -1032,8 +972,12 @@ class WebSocketChannel(BaseChannel): stream_id = meta.get("_stream_id") if stream_id is not None: body["stream_id"] = stream_id - self._annotate_webui_turn(body, chat_id, meta, "reasoning") - self._try_append_webui_transcript(chat_id, body) + self._transcripts.prepare_and_append( + chat_id, + body, + metadata=meta, + phase="reasoning", + ) raw = json.dumps(body, ensure_ascii=False) for connection in conns: await self._safe_send_to(connection, raw, label=" reasoning ") @@ -1055,8 +999,12 @@ class WebSocketChannel(BaseChannel): stream_id = meta.get("_stream_id") if stream_id is not None: body["stream_id"] = stream_id - self._annotate_webui_turn(body, chat_id, meta, "reasoning") - self._try_append_webui_transcript(chat_id, body) + self._transcripts.prepare_and_append( + chat_id, + body, + metadata=meta, + phase="reasoning", + ) raw = json.dumps(body, ensure_ascii=False) for connection in conns: await self._safe_send_to(connection, raw, label=" reasoning_end ") @@ -1075,8 +1023,12 @@ class WebSocketChannel(BaseChannel): "chat_id": chat_id, "edits": edits, } - self._annotate_webui_turn(payload, chat_id, metadata, "activity") - self._try_append_webui_transcript(chat_id, payload) + self._transcripts.prepare_and_append( + chat_id, + payload, + metadata=metadata, + phase="activity", + ) raw = json.dumps(payload, ensure_ascii=False) for connection in conns: await self._safe_send_to(connection, raw, label=" file_edit ") @@ -1110,8 +1062,12 @@ class WebSocketChannel(BaseChannel): self._stream_text_buffers.setdefault(stream_key, []).append(delta) if meta.get("_stream_id") is not None: body["stream_id"] = meta["_stream_id"] - self._annotate_webui_turn(body, chat_id, meta, "answer") - self._try_append_webui_transcript(chat_id, body) + self._transcripts.prepare_and_append( + chat_id, + body, + metadata=meta, + phase="answer", + ) raw = json.dumps(body, ensure_ascii=False) for connection in conns: await self._safe_send_to(connection, raw, label=" stream ") @@ -1133,14 +1089,15 @@ class WebSocketChannel(BaseChannel): body["latency_ms"] = int(latency_ms) if goal_state is not None: body["goal_state"] = goal_state - self._annotate_webui_turn(body, chat_id, metadata, "complete") - self._try_append_webui_transcript(chat_id, body) + self._transcripts.prepare_and_append( + chat_id, + body, + metadata=metadata, + phase="complete", + ) raw = json.dumps(body, ensure_ascii=False) for connection in conns: await self._safe_send_to(connection, raw, label=" turn_end ") - turn_id = body.get("turn_id") - if isinstance(turn_id, str): - self._webui_turn_sequences.pop((chat_id, turn_id), None) async def send_goal_state(self, chat_id: str, blob: dict[str, Any]) -> None: """Push persisted goal-state snapshot for *chat_id* (multi-chat isolation).""" diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 57d41df25..8f60fd9ed 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -5,8 +5,10 @@ import os import select import signal import sys +import uuid from collections.abc import Callable from contextlib import nullcontext, suppress +from contextvars import ContextVar from pathlib import Path from typing import Any @@ -83,6 +85,34 @@ class SafeFileHistory(FileHistory): def store_string(self, string: str) -> None: super().store_string(_sanitize_surrogates(string)) + + +_WEBUI_TURN_META_KEY = "webui_turn_id" +_WEBUI_MESSAGE_SOURCE_META_KEY = "_webui_message_source" +_PROACTIVE_WEBUI_METADATA: ContextVar[dict[str, Any] | None] = ContextVar( + "proactive_webui_metadata", + default=None, +) + + +def _proactive_delivery_metadata( + channel: str, + metadata: dict[str, Any] | None, + *, + turn_seed: str, + source_label: str | None = None, +) -> dict[str, Any]: + """Return channel metadata for a fresh proactive delivery turn.""" + out = dict(metadata or {}) + out.pop(_WEBUI_TURN_META_KEY, None) + if channel == "websocket": + out[_WEBUI_TURN_META_KEY] = f"{turn_seed}:{uuid.uuid4().hex}" + source: dict[str, str] = {"kind": "cron"} + if source_label: + source["label"] = source_label + out[_WEBUI_MESSAGE_SOURCE_META_KEY] = source + return out + app = typer.Typer( name="nanobot", context_settings={"help_option_names": ["-h", "--help"]}, @@ -1010,6 +1040,9 @@ def _run_gateway( """Publish a user-visible message and mirror it into that channel's session.""" metadata = dict(msg.metadata or {}) record = record or bool(metadata.pop("_record_channel_delivery", False)) + proactive_webui_metadata = _PROACTIVE_WEBUI_METADATA.get() + if record and msg.channel == "websocket" and proactive_webui_metadata: + metadata = {**metadata, **proactive_webui_metadata} if metadata != (msg.metadata or {}): msg = OutboundMessage( channel=msg.channel, @@ -1081,6 +1114,13 @@ def _run_gateway( except Exception: logger.exception("Dream cron job failed") finally: + from nanobot.webui.token_usage import record_response_token_usage + + record_response_token_usage( + resp, + source="dream", + timezone_name=config.agents.defaults.timezone, + ) if store.git.is_initialized(): msg = build_dream_commit_message( "dream: periodic memory consolidation", resp, @@ -1171,6 +1211,14 @@ def _run_gateway( if isinstance(message_tool, MessageTool): message_record_token = message_tool.set_record_channel_delivery(True) + proactive_webui_metadata = _proactive_delivery_metadata( + "websocket", + None, + turn_seed=f"cron:{job.id}", + source_label=job.name, + ) + proactive_token = _PROACTIVE_WEBUI_METADATA.set(proactive_webui_metadata) + try: resp = await agent.process_direct( reminder_note, @@ -1180,6 +1228,7 @@ def _run_gateway( on_progress=_silent, ) finally: + _PROACTIVE_WEBUI_METADATA.reset(proactive_token) if isinstance(cron_tool, CronTool) and cron_token is not None: cron_tool.reset_cron_context(cron_token) if isinstance(message_tool, MessageTool) and message_record_token is not None: @@ -1195,12 +1244,18 @@ def _run_gateway( response, reminder_note, agent.provider, agent.model, ) if should_notify: + proactive_metadata = _proactive_delivery_metadata( + job.payload.channel or "cli", + job.payload.channel_meta, + turn_seed=f"cron:{job.id}", + source_label=job.name, + ) await _deliver_to_channel( OutboundMessage( channel=job.payload.channel or "cli", chat_id=job.payload.to, content=response, - metadata=dict(job.payload.channel_meta), + metadata=proactive_metadata, ), record=True, session_key=job.payload.session_key, @@ -1222,6 +1277,7 @@ def _run_gateway( config, bus, session_manager=session_manager, + cron_service=cron, webui_runtime_model_name=_webui_runtime_model_name, webui_static_dist=webui_static_dist, webui_runtime_surface=webui_runtime_surface, diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index fbcf46e1b..10eb995cf 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -350,6 +350,13 @@ async def cmd_dream(ctx: CommandContext) -> OutboundMessage: elapsed = time.monotonic() - t0 content = f"Dream failed after {elapsed:.1f}s: {e}" finally: + from nanobot.webui.token_usage import record_response_token_usage + + record_response_token_usage( + resp, + source="dream", + timezone_name=getattr(loop.context, "timezone", None), + ) if store.git.is_initialized(): commit_msg = build_dream_commit_message("dream: manual run", resp) sha = store.git.auto_commit(commit_msg) diff --git a/nanobot/webui/gateway_services.py b/nanobot/webui/gateway_services.py index cf3eede19..15649d08d 100644 --- a/nanobot/webui/gateway_services.py +++ b/nanobot/webui/gateway_services.py @@ -10,6 +10,7 @@ from loguru import logger as default_logger from nanobot.webui.gateway_tokens import GatewayTokenStore from nanobot.webui.media_gateway import WebUIMediaGateway +from nanobot.webui.transcript import WebUITranscriptRecorder from nanobot.webui.workspaces import WebUIWorkspaceController from nanobot.webui.ws_http import GatewayHTTPHandler @@ -21,8 +22,10 @@ class GatewayServices: http: GatewayHTTPHandler tokens: GatewayTokenStore media: WebUIMediaGateway + transcripts: WebUITranscriptRecorder workspaces: WebUIWorkspaceController session_manager: Any | None + cron_service: Any | None def build_gateway_services( @@ -36,6 +39,8 @@ def build_gateway_services( runtime_model_name: Any | None, runtime_surface: str, runtime_capabilities_overrides: dict[str, Any] | None, + disabled_skills: set[str] | None = None, + cron_service: Any | None = None, logger: Any = default_logger, ) -> GatewayServices: tokens = GatewayTokenStore() @@ -43,6 +48,7 @@ def build_gateway_services( workspace_path=workspace_path, logger=logger, ) + transcripts = WebUITranscriptRecorder(log=logger) workspaces = WebUIWorkspaceController( session_manager=session_manager, default_workspace=workspace_path, @@ -59,12 +65,17 @@ def build_gateway_services( tokens=tokens, media=media, workspaces=workspaces, + skills_workspace_path=workspace_path, + disabled_skills=disabled_skills, + cron_service=cron_service, log=logger, ) return GatewayServices( http=http, tokens=tokens, media=media, + transcripts=transcripts, workspaces=workspaces, session_manager=session_manager, + cron_service=cron_service, ) diff --git a/nanobot/webui/session_automations.py b/nanobot/webui/session_automations.py new file mode 100644 index 000000000..52d503f54 --- /dev/null +++ b/nanobot/webui/session_automations.py @@ -0,0 +1,56 @@ +"""Session-scoped automation payloads for the embedded WebUI.""" + +from __future__ import annotations + +from typing import Any, Protocol + +from nanobot.cron.types import CronJob + + +class _CronServiceLike(Protocol): + def list_jobs(self, *, include_disabled: bool = False) -> list[CronJob]: ... + + +def session_automations_payload( + cron_service: _CronServiceLike | None, + session_key: str, +) -> dict[str, Any]: + """Return user-created automation jobs attached to a WebUI session.""" + jobs: list[CronJob] = [] + if cron_service is not None: + all_jobs = cron_service.list_jobs(include_disabled=True) + jobs = [job for job in all_jobs if _job_matches_session(job, session_key)] + return {"jobs": [_serialize_job(job) for job in jobs]} + + +def _job_matches_session(job: CronJob, session_key: str) -> bool: + payload = job.payload + if payload.kind != "agent_turn": + return False + if payload.session_key: + return payload.session_key == session_key + if payload.channel and payload.to: + return f"{payload.channel}:{payload.to}" == session_key + return False + + +def _serialize_job(job: CronJob) -> dict[str, Any]: + return { + "id": job.id, + "name": job.name, + "enabled": job.enabled, + "schedule": { + "kind": job.schedule.kind, + "at_ms": job.schedule.at_ms, + "every_ms": job.schedule.every_ms, + "expr": job.schedule.expr, + "tz": job.schedule.tz, + }, + "payload": { + "message": job.payload.message, + }, + "state": { + "next_run_at_ms": job.state.next_run_at_ms, + "last_status": job.state.last_status, + }, + } diff --git a/nanobot/webui/skills_api.py b/nanobot/webui/skills_api.py new file mode 100644 index 000000000..6473dbb39 --- /dev/null +++ b/nanobot/webui/skills_api.py @@ -0,0 +1,61 @@ +"""Lightweight skill summaries for the WebUI.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from nanobot.agent.skills import SkillsLoader + + +def webui_skills_payload( + workspace_path: Path, + *, + disabled_skills: set[str] | None = None, +) -> dict[str, Any]: + """Return agent skills without leaking local filesystem paths.""" + loader = SkillsLoader(workspace_path, disabled_skills=disabled_skills) + entries = sorted( + loader.list_skills(filter_unavailable=False), + key=lambda entry: (entry.get("source") != "workspace", entry["name"]), + ) + return {"skills": [_skill_payload(loader, entry) for entry in entries]} + + +def webui_skill_detail_payload( + workspace_path: Path, + name: str, + *, + disabled_skills: set[str] | None = None, +) -> dict[str, Any] | None: + """Return a single skill's safe detail payload.""" + loader = SkillsLoader(workspace_path, disabled_skills=disabled_skills) + entries = loader.list_skills(filter_unavailable=False) + entry = next((item for item in entries if item["name"] == name), None) + if entry is None: + return None + return { + **_skill_payload(loader, entry), + "requirements": loader.get_skill_requirements(name), + "raw_markdown": loader.load_skill(name) or "", + } + + +def _skill_payload(loader: SkillsLoader, entry: dict[str, str]) -> dict[str, Any]: + name = entry["name"] + metadata = loader.get_skill_metadata(name) + available, unavailable_reason = loader.get_skill_availability(name) + return { + "name": name, + "description": _description(metadata, name), + "source": entry.get("source", "unknown"), + "available": available, + "unavailable_reason": unavailable_reason, + } + + +def _description(metadata: dict[str, Any] | None, fallback: str) -> str: + if metadata is None: + return fallback + value = metadata.get("description") + return value.strip() if isinstance(value, str) and value.strip() else fallback diff --git a/nanobot/webui/token_usage.py b/nanobot/webui/token_usage.py index 326b29c03..761cb63f8 100644 --- a/nanobot/webui/token_usage.py +++ b/nanobot/webui/token_usage.py @@ -75,6 +75,19 @@ def _clean_source(value: str | None) -> str: return value if value in _SOURCE_KEYS else "system" +def _source_from_session_key(session_key: str | None) -> str: + key = session_key or "" + if key.startswith("dream:"): + return "dream" + if key == "heartbeat" or key.startswith("cron:"): + return "cron" + if key.startswith("api:"): + return "api" + if key.startswith("system:"): + return "system" + return "user" + + def _normalize_usage(raw: dict[str, Any] | None) -> dict[str, int]: if not isinstance(raw, dict): return {} @@ -249,6 +262,22 @@ def record_token_usage( return write_token_usage_state(state) +def record_response_token_usage( + response: Any, + *, + source: str, + timezone_name: str | None = None, +) -> None: + try: + record_token_usage( + getattr(response, "usage", None), + source=source, + timezone_name=timezone_name, + ) + except Exception: + logger.exception("failed to record {} token usage", source) + + def token_usage_payload( *, days: int = 371, @@ -317,14 +346,11 @@ class TokenUsageHook(AgentHook): super().__init__() self._timezone_name = timezone_name - def include_ephemeral(self) -> bool: - return True - async def after_iteration(self, context: AgentHookContext) -> None: try: record_token_usage( context.usage, - source=context.usage_source, + source=_source_from_session_key(context.session_key), timezone_name=self._timezone_name, ) except Exception: diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py index cfcb2371d..bd10f70e0 100644 --- a/nanobot/webui/transcript.py +++ b/nanobot/webui/transcript.py @@ -18,6 +18,9 @@ from nanobot.session.manager import SessionManager WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3 _MAX_TRANSCRIPT_FILE_BYTES = 8 * 1024 * 1024 +_WEBUI_TURN_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$") +WEBUI_TURN_METADATA_KEY = "webui_turn_id" +WEBUI_MESSAGE_SOURCE_METADATA_KEY = "_webui_message_source" _MARKDOWN_LOCAL_IMAGE_RE = re.compile( r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)" ) @@ -152,6 +155,125 @@ def append_transcript_object(session_key: str, obj: dict[str, Any]) -> None: os.fsync(f.fileno()) +def normalize_webui_turn_id(value: Any) -> str: + if isinstance(value, str): + candidate = value.strip() + if _WEBUI_TURN_ID_RE.fullmatch(candidate): + return candidate + return str(uuid.uuid4()) + + +def webui_message_source(metadata: dict[str, Any] | None) -> dict[str, str] | None: + raw = (metadata or {}).get(WEBUI_MESSAGE_SOURCE_METADATA_KEY) + if not isinstance(raw, dict) or raw.get("kind") != "cron": + return None + source: dict[str, str] = {"kind": "cron"} + label = raw.get("label") + if isinstance(label, str) and label.strip(): + source["label"] = label.strip() + return source + + +class WebUITranscriptRecorder: + """Prepare and persist WebUI wire events without leaking UI rules into channels.""" + + def __init__(self, log: Any = logger) -> None: + self._log = log + self._turn_sequences: dict[tuple[str, str], int] = {} + + def client_turn_metadata(self, value: Any) -> dict[str, str]: + return {WEBUI_TURN_METADATA_KEY: normalize_webui_turn_id(value)} + + def prepare_event( + self, + chat_id: str, + event: dict[str, Any], + *, + metadata: dict[str, Any] | None = None, + phase: str | None = None, + include_source: bool = False, + ) -> None: + if include_source and (source := webui_message_source(metadata)): + event["source"] = source + self._annotate_turn(chat_id, event, metadata, phase) + + def prepare_and_append( + self, + chat_id: str, + event: dict[str, Any], + *, + metadata: dict[str, Any] | None = None, + phase: str | None = None, + include_source: bool = False, + transcript_overrides: dict[str, Any] | None = None, + ) -> None: + self.prepare_event( + chat_id, + event, + metadata=metadata, + phase=phase, + include_source=include_source, + ) + record = dict(event) + if transcript_overrides: + record.update(transcript_overrides) + self.append(chat_id, record) + + def append_user_message( + self, + chat_id: str, + text: str, + *, + metadata: dict[str, Any], + media_paths: list[str] | None = None, + cli_apps: list[dict[str, Any]] | None = None, + mcp_presets: list[dict[str, Any]] | None = None, + ) -> None: + if text.strip() == "/stop" and not media_paths: + return + payload = build_user_transcript_event( + chat_id, + text, + media_paths=media_paths, + cli_apps=cli_apps, + mcp_presets=mcp_presets, + ) + if payload is None: + return + self.prepare_and_append(chat_id, payload, metadata=metadata, phase="user") + + def append(self, chat_id: str, event: dict[str, Any]) -> None: + try: + dup = json.loads(json.dumps(event, ensure_ascii=False)) + append_transcript_object(f"websocket:{chat_id}", dup) + except (ValueError, TypeError) as e: + self._log.warning("webui transcript append failed: {}", e) + + def _next_turn_seq(self, chat_id: str, turn_id: str) -> int: + key = (chat_id, turn_id) + seq = self._turn_sequences.get(key, 0) + 1 + self._turn_sequences[key] = seq + return seq + + def _annotate_turn( + self, + chat_id: str, + event: dict[str, Any], + metadata: dict[str, Any] | None, + phase: str | None, + ) -> None: + if phase is None: + return + turn_id = (metadata or {}).get(WEBUI_TURN_METADATA_KEY) + if not isinstance(turn_id, str) or not turn_id: + return + event["turn_id"] = turn_id + event["turn_phase"] = phase + event["turn_seq"] = self._next_turn_seq(chat_id, turn_id) + if phase == "complete": + self._turn_sequences.pop((chat_id, turn_id), None) + + def delete_webui_transcript(session_key: str) -> bool: path = webui_transcript_path(session_key) if not path.is_file(): @@ -560,6 +682,8 @@ def replay_transcript_to_ui_messages( active_file_edit_segment_id: str | None = None activity_segment_counter = 0 _ts_base = int(time.time() * 1000) + closed_turn_ids: set[str] = set() + replay_turn_aliases: dict[str, str] = {} def _new_id(prefix: str, idx: int) -> str: return f"{prefix}-{idx}-{uuid.uuid4().hex[:8]}" @@ -576,7 +700,13 @@ def replay_transcript_to_ui_messages( fields: dict[str, Any] = {} turn_id = rec.get("turn_id") if isinstance(turn_id, str) and turn_id: - fields["turnId"] = turn_id + if turn_id in closed_turn_ids: + fields["turnId"] = replay_turn_aliases.setdefault( + turn_id, + f"{turn_id}:replay:{idx}", + ) + else: + fields["turnId"] = turn_id phase = rec.get("turn_phase") if isinstance(phase, str) and phase: fields["turnPhase"] = phase @@ -587,6 +717,16 @@ def replay_transcript_to_ui_messages( fields["turnSeq"] = int(seq) return fields + def _source_fields(rec: dict[str, Any]) -> dict[str, Any]: + source = rec.get("source") + if not isinstance(source, dict) or source.get("kind") != "cron": + return {} + out: dict[str, Any] = {"source": {"kind": "cron"}} + label = source.get("label") + if isinstance(label, str) and label.strip(): + out["source"]["label"] = label.strip() + return out + def _same_turn(message: dict[str, Any], turn_fields: dict[str, Any]) -> bool: turn_id = turn_fields.get("turnId") message_turn_id = message.get("turnId") @@ -1098,6 +1238,7 @@ def replay_transcript_to_ui_messages( if isinstance(lat, (int, float)) and lat >= 0: extra["latencyMs"] = int(lat) extra.update(_turn_fields(rec, "answer")) + extra.update(_source_fields(rec)) absorb_complete(extra, idx) if media: suppress_until_turn_end = True @@ -1107,6 +1248,12 @@ def replay_transcript_to_ui_messages( suppress_until_turn_end = False active_activity_segment_id = None active_file_edit_segment_id = None + turn_id = rec.get("turn_id") + if isinstance(turn_id, str) and turn_id: + if turn_id in replay_turn_aliases: + replay_turn_aliases.pop(turn_id, None) + else: + closed_turn_ids.add(turn_id) for i, m in enumerate(messages): if m.get("isStreaming"): messages[i] = {**m, "isStreaming": False} diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 4edf7eab2..d21261681 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -61,16 +61,19 @@ from nanobot.webui.http_utils import ( safe_host_header as _safe_host_header, ) from nanobot.webui.media_gateway import WebUIMediaGateway +from nanobot.webui.session_automations import session_automations_payload from nanobot.webui.sidebar_state import ( read_webui_sidebar_state, write_webui_sidebar_state, ) +from nanobot.webui.skills_api import webui_skill_detail_payload, webui_skills_payload from nanobot.webui.thread_disk import delete_webui_thread from nanobot.webui.transcript import build_webui_thread_response from nanobot.webui.workspaces import WebUIWorkspaceController if TYPE_CHECKING: from nanobot.bus.queue import MessageBus + from nanobot.cron.service import CronService from nanobot.session.manager import SessionManager @@ -96,7 +99,7 @@ def _default_model_name_from_config() -> str | None: def _resolve_bootstrap_model_name( runtime_name: Callable[[], str | None] | None, -) -> str | None: +) -> str: if runtime_name is not None: try: raw = runtime_name() @@ -107,7 +110,7 @@ def _resolve_bootstrap_model_name( stripped = raw.strip() if stripped: return stripped - return _default_model_name_from_config() + return _default_model_name_from_config() or "" # --------------------------------------------------------------------------- @@ -135,6 +138,9 @@ class GatewayHTTPHandler: tokens: GatewayTokenStore, media: WebUIMediaGateway, workspaces: WebUIWorkspaceController, + skills_workspace_path: Path, + disabled_skills: set[str] | None = None, + cron_service: CronService | None = None, log: Any = logger, ) -> None: self.config = config @@ -145,6 +151,9 @@ class GatewayHTTPHandler: self.tokens = tokens self.media = media self.workspaces = workspaces + self.skills_workspace_path = skills_workspace_path + self.disabled_skills = disabled_skills or set() + self.cron_service = cron_service self._log = log self._runtime_surface = runtime_surface @@ -299,6 +308,10 @@ class GatewayHTTPHandler: if m: return self._handle_file_preview(request, m.group(1)) + m = re.match(r"^/api/sessions/([^/]+)/automations$", got) + if m: + return self._handle_session_automations(request, m.group(1)) + m = re.match(r"^/api/sessions/([^/]+)/delete$", got) if m: return self._handle_session_delete(request, m.group(1)) @@ -395,6 +408,18 @@ class GatewayHTTPHandler: return _http_error(e.status, e.message) return _http_json_response(payload) + def _handle_session_automations(self, request: WsRequest, key: str) -> Response: + if not self.check_api_token(request): + return _http_error(401, "Unauthorized") + decoded_key = _decode_api_key(key) + if decoded_key is None: + return _http_error(400, "invalid session key") + if not _is_websocket_channel_session_key(decoded_key): + return _http_error(404, "session not found") + return _http_json_response( + session_automations_payload(self.cron_service, decoded_key) + ) + def _handle_session_delete(self, request: WsRequest, key: str) -> Response: if not self.check_api_token(request): return _http_error(401, "Unauthorized") @@ -437,6 +462,11 @@ class GatewayHTTPHandler: return self._handle_commands(request) if got == "/api/workspaces": return self._handle_workspaces(connection, request) + if got == "/api/webui/skills": + return self._handle_webui_skills(request) + m = re.match(r"^/api/webui/skills/([^/]+)$", got) + if m: + return self._handle_webui_skill_detail(request, m.group(1)) if got == "/api/webui/sidebar-state": return self._handle_webui_sidebar_state(request) if got == "/api/webui/sidebar-state/update": @@ -457,6 +487,33 @@ class GatewayHTTPHandler: ) ) + def _handle_webui_skills(self, request: WsRequest) -> Response: + if not self.check_api_token(request): + return _http_error(401, "Unauthorized") + return _http_json_response( + webui_skills_payload( + self.skills_workspace_path, + disabled_skills=self.disabled_skills, + ) + ) + + def _handle_webui_skill_detail(self, request: WsRequest, raw_name: str) -> Response: + if not self.check_api_token(request): + return _http_error(401, "Unauthorized") + from urllib.parse import unquote + + name = unquote(raw_name) + if not name or "/" in name or "\\" in name: + return _http_error(400, "invalid skill name") + payload = webui_skill_detail_payload( + self.skills_workspace_path, + name, + disabled_skills=self.disabled_skills, + ) + if payload is None: + return _http_error(404, "skill not found") + return _http_json_response(payload) + def _handle_webui_sidebar_state(self, request: WsRequest) -> Response: if not self.check_api_token(request): return _http_error(401, "Unauthorized") diff --git a/tests/agent/test_dream.py b/tests/agent/test_dream.py index f9c6f1d7b..937b7ae41 100644 --- a/tests/agent/test_dream.py +++ b/tests/agent/test_dream.py @@ -356,18 +356,6 @@ class TestEphemeralHooks: await loop.process_direct("test", session_key="cli:normal") spy.before_iteration.assert_called() - async def test_extra_hooks_can_opt_into_ephemeral(self, tmp_path, _make_loop_with_spy): - """Usage telemetry can opt into Dream without enabling all hooks.""" - loop, spy = _make_loop_with_spy - spy.include_ephemeral.return_value = True - - await loop.process_direct( - "test", session_key="dream:hook-test", ephemeral=True, - ) - - spy.before_iteration.assert_called() - - class TestDreamCommitMessage: async def test_commit_includes_response_summary(self, tmp_path): """Git auto-commit after Dream should include the LLM response in the body.""" diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 3eee4074c..8eba67588 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -12,6 +12,8 @@ import httpx import pytest from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig +from nanobot.cron.service import CronService +from nanobot.cron.types import CronJob, CronPayload, CronSchedule from nanobot.session.manager import Session, SessionManager from nanobot.webui.gateway_services import GatewayServices, build_gateway_services @@ -24,10 +26,12 @@ def _make_handler( *, session_manager: SessionManager | None = None, static_dist_path: Path | None = None, + workspace_path: Path | None = None, runtime_model_name: Any | None = None, + cron_service: CronService | None = None, ) -> GatewayServices: config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg - workspace = Path.cwd() + workspace = workspace_path or Path.cwd() return build_gateway_services( config=config, bus=bus, @@ -38,6 +42,7 @@ def _make_handler( runtime_model_name=runtime_model_name, runtime_surface="browser", runtime_capabilities_overrides=None, + cron_service=cron_service, ) @@ -46,8 +51,10 @@ def _ch( *, session_manager: SessionManager | None = None, static_dist_path: Path | None = None, + workspace_path: Path | None = None, port: int = _PORT, runtime_model_name: Any | None = None, + cron_service: CronService | None = None, **extra: Any, ) -> WebSocketChannel: cfg: dict[str, Any] = { @@ -63,7 +70,9 @@ def _ch( cfg, bus, session_manager=session_manager, static_dist_path=static_dist_path, + workspace_path=workspace_path, runtime_model_name=runtime_model_name, + cron_service=cron_service, ) return WebSocketChannel(cfg, bus, gateway=gateway) @@ -161,6 +170,156 @@ async def test_sessions_routes_require_bearer_token( await server_task +@pytest.mark.asyncio +async def test_session_automations_route_filters_by_webui_session( + bus: MagicMock, tmp_path: Path +) -> None: + cron = CronService(tmp_path / "cron" / "jobs.json") + hourly = CronSchedule(kind="every", every_ms=3_600_000) + for name, message, to in ( + ("Morning check", "Check the project status", "abc"), + ("Other session", "Do not show", "other"), + ): + cron.add_job( + name=name, + schedule=hourly, + message=message, + channel="websocket", + to=to, + session_key=f"websocket:{to}", + ) + cron.register_system_job( + CronJob( + id="heartbeat", + name="heartbeat", + schedule=CronSchedule(kind="every", every_ms=60_000), + payload=CronPayload(kind="system_event"), + ) + ) + channel = _ch( + bus, + session_manager=_seed_session(tmp_path, key="websocket:abc"), + cron_service=cron, + port=29914, + ) + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + try: + deny = await _http_get( + "http://127.0.0.1:29914/api/sessions/websocket:abc/automations" + ) + assert deny.status_code == 401 + + boot = await _http_get("http://127.0.0.1:29914/webui/bootstrap") + token = boot.json()["token"] + auth = {"Authorization": f"Bearer {token}"} + resp = await _http_get( + "http://127.0.0.1:29914/api/sessions/websocket%3Aabc/automations", + headers=auth, + ) + + assert resp.status_code == 200 + body = resp.json() + assert [job["name"] for job in body["jobs"]] == ["Morning check"] + job = body["jobs"][0] + assert job["schedule"]["kind"] == "every" + assert job["schedule"]["every_ms"] == 3_600_000 + assert job["payload"]["message"] == "Check the project status" + finally: + await channel.stop() + await server_task + + +@pytest.mark.asyncio +async def test_webui_skills_route_requires_token_and_hides_paths( + bus: MagicMock, tmp_path: Path +) -> None: + workspace_skill = tmp_path / "skills" / "workspace-skill" + workspace_skill.mkdir(parents=True) + (workspace_skill / "SKILL.md").write_text( + "---\nname: workspace-skill\ndescription: Workspace skill.\n---\n", + encoding="utf-8", + ) + unavailable_skill = tmp_path / "skills" / "zz-unavailable-skill" + unavailable_skill.mkdir(parents=True) + (unavailable_skill / "SKILL.md").write_text( + "\n".join([ + "---", + "name: zz-unavailable-skill", + "description: Missing CLI skill.", + "metadata:", + " nanobot:", + " requires:", + " bins:", + " - definitely-missing-nanobot-skill-cli", + " env:", + " - DEFINITELY_MISSING_NANOBOT_SKILL_ENV", + "---", + "Use the missing CLI and env var.", + ]), + encoding="utf-8", + ) + channel = _ch( + bus, + session_manager=_seed_session(tmp_path), + workspace_path=tmp_path, + port=29920, + ) + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + try: + deny = await _http_get("http://127.0.0.1:29920/api/webui/skills") + assert deny.status_code == 401 + deny_detail = await _http_get("http://127.0.0.1:29920/api/webui/skills/workspace-skill") + assert deny_detail.status_code == 401 + + boot = await _http_get("http://127.0.0.1:29920/webui/bootstrap") + token = boot.json()["token"] + resp = await _http_get( + "http://127.0.0.1:29920/api/webui/skills", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + body = resp.json() + names = [skill["name"] for skill in body["skills"]] + assert names[0] == "workspace-skill" + assert "cron" in names + assert all("path" not in skill for skill in body["skills"]) + workspace = body["skills"][0] + assert workspace == { + "name": "workspace-skill", + "description": "Workspace skill.", + "source": "workspace", + "available": True, + "unavailable_reason": "", + } + unavailable = next(skill for skill in body["skills"] if skill["name"] == "zz-unavailable-skill") + assert unavailable["available"] is False + assert unavailable["unavailable_reason"] == ( + "CLI: definitely-missing-nanobot-skill-cli, " + "ENV: DEFINITELY_MISSING_NANOBOT_SKILL_ENV" + ) + + detail = await _http_get( + "http://127.0.0.1:29920/api/webui/skills/zz-unavailable-skill", + headers={"Authorization": f"Bearer {token}"}, + ) + assert detail.status_code == 200 + detail_body = detail.json() + assert "path" not in detail_body + assert detail_body["requirements"] == { + "bins": ["definitely-missing-nanobot-skill-cli"], + "env": ["DEFINITELY_MISSING_NANOBOT_SKILL_ENV"], + "missing_bins": ["definitely-missing-nanobot-skill-cli"], + "missing_env": ["DEFINITELY_MISSING_NANOBOT_SKILL_ENV"], + } + assert "Use the missing CLI and env var." in detail_body["raw_markdown"] + finally: + await channel.stop() + await server_task + + @pytest.mark.asyncio async def test_cli_apps_routes_require_token_and_return_payload( bus: MagicMock, diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 8f13725c6..3e30de858 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -9,7 +9,7 @@ import pytest from typer.testing import CliRunner from nanobot.bus.events import OutboundMessage -from nanobot.cli.commands import app +from nanobot.cli.commands import _proactive_delivery_metadata, app from nanobot.config.schema import Config from nanobot.cron.types import CronJob, CronPayload from nanobot.providers.factory import ProviderSnapshot, make_provider @@ -19,6 +19,27 @@ from nanobot.providers.registry import find_by_name runner = CliRunner() +def test_proactive_websocket_delivery_gets_fresh_turn_id() -> None: + metadata = { + "webui": True, + "webui_turn_id": "turn-that-created-the-reminder", + "workspace_scope": {"mode": "default"}, + } + + out = _proactive_delivery_metadata( + "websocket", + metadata, + turn_seed="cron:drink-water", + source_label="drink water", + ) + + assert out["webui"] is True + assert out["workspace_scope"] == {"mode": "default"} + assert out["webui_turn_id"].startswith("cron:drink-water:") + assert out["webui_turn_id"] != metadata["webui_turn_id"] + assert out["_webui_message_source"] == {"kind": "cron", "label": "drink water"} + + def _fake_provider(): """Return a minimal fake provider that satisfies AgentLoop.__init__.""" p = MagicMock() @@ -1316,6 +1337,41 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context( } ] + bus.publish_outbound.reset_mock() + old_turn_id = "turn-that-created-the-reminder" + websocket_job = CronJob( + id="drink-water", + name="drink water", + payload=CronPayload( + message="Remind me to drink water.", + deliver=True, + channel="websocket", + to="chat-1", + channel_meta={ + "webui": True, + "webui_turn_id": old_turn_id, + "workspace_scope": {"mode": "default"}, + }, + session_key="websocket:chat-1", + ), + ) + + response = asyncio.run(cron.on_job(websocket_job)) + + assert response == "Time to stretch." + bus.publish_outbound.assert_awaited_once() + delivered = bus.publish_outbound.await_args.args[0] + assert delivered.channel == "websocket" + assert delivered.chat_id == "chat-1" + assert delivered.metadata["webui"] is True + assert delivered.metadata["workspace_scope"] == {"mode": "default"} + assert delivered.metadata["webui_turn_id"].startswith("cron:drink-water:") + assert delivered.metadata["webui_turn_id"] != old_turn_id + assert delivered.metadata["_webui_message_source"] == { + "kind": "cron", + "label": "drink water", + } + def test_gateway_cron_job_suppresses_intermediate_progress( monkeypatch, tmp_path: Path diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py index 55d886cd8..0cfe82bdb 100644 --- a/tests/utils/test_webui_transcript.py +++ b/tests/utils/test_webui_transcript.py @@ -92,6 +92,55 @@ def test_replay_preserves_turn_metadata(tmp_path, monkeypatch) -> None: assert msgs[1]["turnSeq"] == 3 +def test_replay_reused_turn_id_after_turn_end_starts_new_turn(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + key = "websocket:t-reused-turn" + + def event( + event: str, + phase: str, + seq: int, + text: str | None = None, + source: dict[str, str] | None = None, + ) -> dict[str, object]: + out = { + "event": event, + "chat_id": "t-reused-turn", + "turn_id": "turn-1", + "turn_phase": phase, + "turn_seq": seq, + } + if text is not None: + out["text"] = text + if source is not None: + out["source"] = source + return out + + for record in ( + event("user", "user", 1, "remind me later"), + event("message", "answer", 2, "Reminder set."), + event("turn_end", "complete", 3), + event( + "message", "answer", 1, "Time to drink water.", + {"kind": "cron", "label": "drink water"}, + ), + event("turn_end", "complete", 2), + ): + append_transcript_object(key, record) + + msgs = replay_transcript_to_ui_messages(read_transcript_lines(key)) + + assert [m["content"] for m in msgs] == [ + "remind me later", + "Reminder set.", + "Time to drink water.", + ] + assert msgs[1]["turnId"] == "turn-1" + assert msgs[2]["turnId"].startswith("turn-1:replay:") + assert msgs[2]["turnId"] != msgs[1]["turnId"] + assert msgs[2]["source"] == {"kind": "cron", "label": "drink water"} + + def test_build_response_restores_session_users_for_legacy_transcript( tmp_path, monkeypatch, diff --git a/tests/webui/test_token_usage.py b/tests/webui/test_token_usage.py index 2f07162ba..470c10230 100644 --- a/tests/webui/test_token_usage.py +++ b/tests/webui/test_token_usage.py @@ -1,8 +1,14 @@ from __future__ import annotations from datetime import datetime, timezone +from types import SimpleNamespace +import pytest + +from nanobot.agent.hook import AgentHookContext from nanobot.webui.token_usage import ( + TokenUsageHook, + record_response_token_usage, record_token_usage, token_usage_payload, ) @@ -107,3 +113,36 @@ def test_record_token_usage_keeps_source_breakdown(tmp_path, monkeypatch) -> Non assert row["sources"]["user"]["requests"] == 1 assert row["sources"]["dream"]["total_tokens"] == 25 assert row["sources"]["dream"]["requests"] == 1 + + +def test_record_response_token_usage_uses_response_usage(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui") + monkeypatch.setattr("nanobot.webui.token_usage._local_day", lambda *_, **__: "2026-06-03") + + record_response_token_usage( + SimpleNamespace(usage={"prompt_tokens": 20, "completion_tokens": 5}), + source="dream", + ) + + payload = token_usage_payload(now=datetime(2026, 6, 3, tzinfo=timezone.utc)) + assert payload["days"][0]["sources"]["dream"]["total_tokens"] == 25 + + +@pytest.mark.asyncio +async def test_token_usage_hook_classifies_source_from_session_key(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui") + monkeypatch.setattr("nanobot.webui.token_usage._local_day", lambda *_, **__: "2026-06-03") + + hook = TokenUsageHook() + await hook.after_iteration( + AgentHookContext( + iteration=0, + messages=[], + session_key="cron:drink-water", + usage={"prompt_tokens": 10, "completion_tokens": 5}, + ) + ) + + payload = token_usage_payload(now=datetime(2026, 6, 3, tzinfo=timezone.utc)) + + assert payload["days"][0]["sources"]["cron"]["total_tokens"] == 15 diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 4a104838d..982322d93 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -12,6 +12,7 @@ import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { useSessions } from "@/hooks/useSessions"; import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh"; import { useSidebarState } from "@/hooks/useSidebarState"; +import { useSkills } from "@/hooks/useSkills"; import { ThemeProvider, useTheme } from "@/hooks/useTheme"; import { cn } from "@/lib/utils"; import { @@ -60,7 +61,7 @@ const SIDEBAR_WIDTH = 272; const SIDEBAR_RAIL_WIDTH = 56; const TOKEN_REFRESH_MARGIN_MS = 30_000; const TOKEN_REFRESH_MIN_DELAY_MS = 5_000; -type ShellView = "chat" | "settings" | "apps"; +type ShellView = "chat" | "settings" | "apps" | "skills"; type ShellRoute = { view: ShellView; activeKey: string | null; @@ -74,6 +75,7 @@ const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [ "image", "browser", "apps", + "skills", "runtime", "advanced", ]; @@ -86,6 +88,11 @@ function defaultShellRoute(): ShellRoute { return { view: "chat", activeKey: null, settingsSection: "overview" }; } +function shellViewForSettingsSection(section: SettingsSectionKey): ShellView { + if (section === "apps" || section === "skills") return section; + return "settings"; +} + function readShellRoute(): ShellRoute { if (typeof window === "undefined") return defaultShellRoute(); const hash = window.location.hash.startsWith("#") @@ -102,11 +109,18 @@ function readShellRoute(): ShellRoute { const activeKey = params.get("chat")?.trim() || null; if (path === "/settings") { - return { view: "settings", activeKey, settingsSection }; + return { + view: shellViewForSettingsSection(settingsSection), + activeKey, + settingsSection, + }; } if (path === "/apps") { return { view: "apps", activeKey, settingsSection: "apps" }; } + if (path === "/skills") { + return { view: "skills", activeKey, settingsSection: "skills" }; + } if (path.startsWith("/chat/")) { const encoded = path.slice("/chat/".length); try { @@ -562,6 +576,7 @@ function Shell({ const [runningChatIds, setRunningChatIds] = useState{t("settings.sidebar.title")}
@@ -3170,9 +3187,11 @@ function AppsCatalogSettings({ const loading = (cliAppsLoading || mcpPresetsLoading) && !cliApps && !mcpPresets; const statusMessage = cliError || mcpError || (!focusedApp ? cliMessage || mcpMessage : null); const statusIsError = Boolean(cliError || mcpError); - const caption = tx("settings.apps.caption", "{{cli}} CLI · {{mcp}} MCP") - .replace("{{cli}}", String(cliApps?.installed_count ?? 0)) - .replace("{{mcp}}", String(mcpPresets?.installed_count ?? 0)); + const caption = t("settings.apps.caption", { + cli: cliApps?.installed_count ?? 0, + mcp: mcpPresets?.installed_count ?? 0, + defaultValue: "{{cli}} CLI · {{mcp}} MCP", + }); return (
{tx("settings.mcp.connectHint", "Add the key from your account settings.")}
diff --git a/webui/src/components/settings/SkillsCatalogSettings.tsx b/webui/src/components/settings/SkillsCatalogSettings.tsx
new file mode 100644
index 000000000..e1927b428
--- /dev/null
+++ b/webui/src/components/settings/SkillsCatalogSettings.tsx
@@ -0,0 +1,417 @@
+import { useEffect, useState, type ReactNode } from "react";
+import type { TFunction } from "i18next";
+import { Brain, Check, CircleAlert, KeyRound, Loader2, Terminal } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet";
+import { fetchSkillDetail } from "@/lib/api";
+import type { SkillDetail, SkillSummary } from "@/lib/types";
+import { cn } from "@/lib/utils";
+import { useClient } from "@/providers/ClientProvider";
+
+export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
+ const { t } = useTranslation();
+ const availableCount = skills.filter((skill) => skill.available).length;
+ const [selectedSkill, setSelectedSkill] = useState
+ {t("settings.skills.description", {
+ defaultValue: "Review the instruction skills this agent can load during a conversation.",
+ })}
+ {activeSkill.description}
+ {activeSkill.unavailable_reason}
+
+ {t("settings.skills.noRequirements", { defaultValue: "No explicit requirements." })}
+
+ {t("settings.skills.featured", { defaultValue: "Agent skills" })}
+
+
+ {skills.length}
+
+
+ {t("settings.skills.rawInstructions", { defaultValue: "Raw SKILL.md" })}
+
+
+ {content}
+
+ {title}
+ {children}
+