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>(() => new Set()); const [completedChatIds, setCompletedChatIds] = useState>(readCompletedRunChatIds); const [workspaces, setWorkspaces] = useState(null); + const skills = useSkills(token); const [settingsSnapshot, setSettingsSnapshot] = useState(null); const [workspaceError, setWorkspaceError] = useState(null); const [draftWorkspaceScope, setDraftWorkspaceScope] = @@ -1128,10 +1143,16 @@ function Shell({ setMobileSidebarOpen(false); }, [activeKey, navigate]); + const onOpenSkills = useCallback(() => { + setSessionSearchOpen(false); + navigate({ view: "skills", activeKey, settingsSection: "skills" }); + setMobileSidebarOpen(false); + }, [activeKey, navigate]); + const onSettingsSectionChange = useCallback( (section: SettingsSectionKey) => { navigate({ - view: section === "apps" ? "apps" : "settings", + view: shellViewForSettingsSection(section), activeKey, settingsSection: section, }); @@ -1283,6 +1304,12 @@ function Shell({ }); return; } + if (view === "skills") { + document.title = t("app.documentTitle.chat", { + title: t("settings.nav.skills", { defaultValue: "Skills" }), + }); + return; + } document.title = activeSession ? t("app.documentTitle.chat", { title: headerTitle }) : t("app.documentTitle.base"); @@ -1304,8 +1331,9 @@ function Shell({ onNewChatInProject, onOpenSettings, onOpenApps, + onOpenSkills, onOpenSearch: onOpenSessionSearch, - activeUtility: view === "apps" ? "apps" as const : null, + activeUtility: view === "apps" || view === "skills" ? view : null, onToggleArchived, pinnedKeys: sidebarState.pinned_keys, archivedKeys: sidebarState.archived_keys, @@ -1486,6 +1514,7 @@ function Shell({ onBackToChat={onBackToChat} onModelNameChange={onModelNameChange} onSettingsChange={setSettingsSnapshot} + skills={skills} onWorkspaceSettingsChange={refreshWorkspaces} onSectionChange={onSettingsSectionChange} onLogout={onLogout} diff --git a/webui/src/components/MarkdownTextRenderer.tsx b/webui/src/components/MarkdownTextRenderer.tsx index 2ce9cfb5f..0461dec9d 100644 --- a/webui/src/components/MarkdownTextRenderer.tsx +++ b/webui/src/components/MarkdownTextRenderer.tsx @@ -1,8 +1,16 @@ -import { Children, isValidElement, useMemo, type ReactNode } from "react"; +import { + Children, + isValidElement, + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; import ReactMarkdown from "react-markdown"; import rehypeKatex from "rehype-katex"; -import { Check } from "lucide-react"; +import { Check, Globe2 } from "lucide-react"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; @@ -11,6 +19,7 @@ import { AttachmentTile } from "@/components/AttachmentTile"; import { CodeBlock } from "@/components/CodeBlock"; import { FileReferenceChip, isLikelyFilePath } from "@/components/FileReferenceChip"; import { inferMediaKind } from "@/lib/media"; +import { faviconUrls } from "@/lib/provider-brand"; import { cn } from "@/lib/utils"; import "katex/dist/katex.min.css"; @@ -33,10 +42,9 @@ type MarkdownAstNode = { type InlineLinkPreview = { href: string; - origin: string; + host: string; prefix?: string; title: string; - initials: string; }; const SAFE_INLINE_HTML_TAGS = new Set(["mark", "sub", "sup"]); @@ -249,16 +257,6 @@ function cleanLinkPreviewText(value: string): string { .trim(); } -function linkPreviewInitials(value: string): string { - const clean = value - .replace(/^https?:\/\//i, "") - .replace(/^www\./i, "") - .replace(/\.[a-z]{2,}$/i, ""); - const parts = clean.split(/[\s.-]+/).filter(Boolean); - return (parts.length > 1 ? parts.slice(0, 2).map((part) => part[0]).join("") : clean.slice(0, 2)) - .toUpperCase(); -} - function inlineLinkPreviewFromChildren(children: ReactNode): InlineLinkPreview | null { const { text: rawText, href } = linkPreviewParts(children); if (!href) return null; @@ -286,17 +284,18 @@ function inlineLinkPreviewFromChildren(children: ReactNode): InlineLinkPreview | return { href, - origin: url.origin, + host: url.hostname, prefix, title, - initials: linkPreviewInitials(prefix || url.hostname), }; } function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) { + const { favicon, onFaviconError } = useFaviconFallback(link.host); const label = link.prefix ? `${link.prefix} — ${link.title}` : link.title; + return ( - {link.initials} - { - event.currentTarget.style.display = "none"; - }} - /> + {favicon ? ( + + ) : ( + + )} {label} @@ -333,6 +333,24 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) { ); } +function useFaviconFallback(host: string) { + const faviconCandidates = useMemo(() => faviconUrls(host), [host]); + const [faviconIndex, setFaviconIndex] = useState(0); + + useEffect(() => { + setFaviconIndex(0); + }, [host]); + + const onFaviconError = useCallback(() => { + setFaviconIndex((index) => Math.min(index + 1, faviconCandidates.length)); + }, [faviconCandidates.length]); + + return { + favicon: faviconCandidates[faviconIndex] ?? null, + onFaviconError, + }; +} + function isRenderedCodeBlock(value: ReactNode): boolean { if (!isValidElement(value)) return false; const props = value.props as { code?: unknown }; diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index 5d4f137ae..acd470e14 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -6,7 +6,7 @@ import { useState, type ReactNode, } from "react"; -import { Check, ChevronRight, Copy, ImageIcon, Sparkles, Wrench } from "lucide-react"; +import { Check, ChevronRight, Clock3, Copy, ImageIcon, Sparkles, Wrench } from "lucide-react"; import { useTranslation } from "react-i18next"; import { AttachmentTile } from "@/components/AttachmentTile"; @@ -131,6 +131,10 @@ export function MessageBubble({ const reasoning = message.role === "assistant" ? message.reasoning ?? "" : ""; const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming); const hasReasoning = reasoning.length > 0 || reasoningStreaming; + const automationSourceLabel = message.source?.kind === "cron" + ? (message.source.label?.trim() || t("message.automationSourceFallback")) + : ""; + const automationTriggeredLabel = t("message.automationTriggered"); const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty; const showCopyButton = showAssistantCopyAction && showAssistantActions; @@ -155,6 +159,12 @@ export function MessageBubble({ ) : empty && message.isStreaming ? null : ( <> + {automationSourceLabel ? ( + + ) : null} + + {label} + · + {triggerLabel} + + ); +} + function mergeMcpMentionPresets( presets: McpPresetInfo[], attachments: UIMcpPresetAttachment[] | undefined, diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index 671ed701f..f50275b3b 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -1,6 +1,7 @@ import { useState, type ReactNode } from "react"; import { Archive, + Brain, Menu, Search, Settings, @@ -34,8 +35,9 @@ interface SidebarProps { onNewChatInProject: (projectPath: string, projectName: string) => void; onOpenSettings: () => void; onOpenApps: () => void; + onOpenSkills: () => void; onOpenSearch: () => void; - activeUtility?: "apps" | null; + activeUtility?: "apps" | "skills" | null; onToggleArchived: () => void; onCollapse: () => void; onExpand?: () => void; @@ -157,6 +159,13 @@ export function Sidebar(props: SidebarProps) { active={props.activeUtility === "apps"} icon={} /> + } + /> {props.archivedCount ? ( void; onModelNameChange: (modelName: string | null) => void; onSettingsChange?: (payload: SettingsPayload) => void; + skills?: SkillSummary[]; onWorkspaceSettingsChange?: () => void | Promise; onSectionChange?: (section: SettingsSectionKey) => void; onLogout?: () => void; @@ -449,6 +453,7 @@ export function SettingsView({ onBackToChat, onModelNameChange, onSettingsChange, + skills = [], onWorkspaceSettingsChange, onSectionChange, onLogout, @@ -1398,6 +1403,8 @@ export function SettingsView({ isRestarting={isRestarting || hostEngineApplying} /> ); + case "skills": + return ; case "runtime": return (
+ {!showSidebar ? ( + + ) : null}

{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 (
@@ -3554,7 +3573,10 @@ function McpAppsCatalogRow({
- {tx("settings.mcp.connectTitle", "Connect {{name}}").replace("{{name}}", preset.display_name)} + {t("settings.mcp.connectTitle", { + name: preset.display_name, + defaultValue: "Connect {{name}}", + })}

{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(null); + + return ( +

+
+

+ {t("settings.skills.description", { + defaultValue: "Review the instruction skills this agent can load during a conversation.", + })} +

+ + {t("settings.skills.caption", { + available: availableCount, + total: skills.length, + defaultValue: "{{available}} available · {{total}} total", + })} + +
+ +
+
+

+ {t("settings.skills.featured", { defaultValue: "Agent skills" })} +

+ + {skills.length} + +
+ {skills.length ? ( +
+ {skills.map((skill) => ( + + ))} +
+ ) : ( +
+ {t("settings.skills.empty", { defaultValue: "No skills are available." })} +
+ )} +
+ + { + if (!open) setSelectedSkill(null); + }} + /> +
+ ); +} + +function SkillCatalogRow({ + skill, + onSelect, +}: { + skill: SkillSummary; + onSelect: (skill: SkillSummary) => void; +}) { + const { t } = useTranslation(); + const sourceLabel = skillSourceLabel(skill.source, t); + const StatusIcon = skill.available ? Check : CircleAlert; + const statusLabel = skill.available + ? t("settings.skills.statusAvailable", { defaultValue: "Available" }) + : t("settings.skills.statusUnavailable", { defaultValue: "Unavailable" }); + + return ( + + ); +} + +function SkillDetailSheet({ + skill, + open, + onOpenChange, +}: { + skill: SkillSummary | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { token } = useClient(); + const { t } = useTranslation(); + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(false); + const [loadFailed, setLoadFailed] = useState(false); + + useEffect(() => { + if (!open || !skill) return; + let cancelled = false; + setDetail(null); + setLoading(true); + setLoadFailed(false); + fetchSkillDetail(token, skill.name) + .then((payload) => { + if (!cancelled) setDetail(payload); + }) + .catch(() => { + if (!cancelled) setLoadFailed(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, skill, token]); + + if (!skill) return null; + + const activeSkill = detail ?? skill; + const sourceLabel = skillSourceLabel(activeSkill.source, t); + const statusLabel = activeSkill.available + ? t("settings.skills.statusAvailable", { defaultValue: "Available" }) + : t("settings.skills.statusUnavailable", { defaultValue: "Unavailable" }); + + return ( + + +
+
+
+ +
+
+ + {activeSkill.name} + + + {t("settings.skills.detailDescription", { + name: activeSkill.name, + defaultValue: "Details for {{name}}.", + })} + +
+ {sourceLabel} + {statusLabel} +
+
+
+ + {loading ? ( +
+ + {t("settings.skills.loadingDetail", { defaultValue: "Loading skill details..." })} +
+ ) : loadFailed ? ( +
+ {t("settings.skills.loadFailed", { defaultValue: "Could not load skill details." })} +
+ ) : ( +
+ +

{activeSkill.description}

+
+ +
+ + +
+ + {!activeSkill.available && activeSkill.unavailable_reason ? ( + +

+ {activeSkill.unavailable_reason} +

+
+ ) : null} + + {detail ? : null} + + {detail ? : null} +
+ )} +
+
+
+ ); +} + +function RawInstructionsBlock({ markdown }: { markdown: string }) { + const { t } = useTranslation(); + const content = + markdown || + t("settings.skills.rawInstructionsEmpty", { + defaultValue: "No raw instructions.", + }); + + return ( +
+ + {t("settings.skills.rawInstructions", { defaultValue: "Raw SKILL.md" })} + +
+
+          {content}
+        
+
+
+ ); +} + +function MetaItem({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function RequirementsSection({ detail }: { detail: SkillDetail }) { + const { t } = useTranslation(); + const { bins, env, missing_bins, missing_env } = detail.requirements; + const hasRequirements = bins.length > 0 || env.length > 0; + + return ( + + {hasRequirements ? ( +
+ {missing_bins.length ? ( + } + /> + ) : null} + {missing_env.length ? ( + } + /> + ) : null} + {bins.length ? ( + } + /> + ) : null} + {env.length ? ( + } + /> + ) : null} +
+ ) : ( +

+ {t("settings.skills.noRequirements", { defaultValue: "No explicit requirements." })} +

+ )} +
+ ); +} + +function DetailSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function RequirementLine({ + title, + items, + icon, + tone = "muted", +}: { + title: string; + items: string[]; + icon: ReactNode; + tone?: "muted" | "danger"; +}) { + return ( +
+
+ {icon} + {title} +
+
+ {items.map((item) => ( + {item} + ))} +
+
+ ); +} + +function Pill({ + children, + tone = "muted", +}: { + children: ReactNode; + tone?: "muted" | "success"; +}) { + return ( + + {children} + + ); +} + +function skillSourceLabel(source: string, t: TFunction): string { + if (source === "workspace") { + return t("settings.skills.sourceWorkspace", { defaultValue: "Custom" }); + } + if (source === "builtin") { + return t("settings.skills.sourceBuiltin", { defaultValue: "Built-in" }); + } + return source; +} diff --git a/webui/src/components/thread/SessionInfoPopover.tsx b/webui/src/components/thread/SessionInfoPopover.tsx new file mode 100644 index 000000000..31df132bb --- /dev/null +++ b/webui/src/components/thread/SessionInfoPopover.tsx @@ -0,0 +1,224 @@ +import { useState } from "react"; +import { + CalendarClock, + CircleAlert, + ListTodo, + RefreshCcw, +} from "lucide-react"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useSessionAutomationJobs } from "@/hooks/useSessionAutomationJobs"; +import { currentLocale } from "@/i18n"; +import { fmtDateTime } from "@/lib/format"; +import type { SessionAutomationJob } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +const RELATIVE_THRESHOLDS: [number, Intl.RelativeTimeFormatUnit][] = [ + [60, "second"], + [60, "minute"], + [24, "hour"], + [7, "day"], + [4.345, "week"], + [12, "month"], + [Number.POSITIVE_INFINITY, "year"], +]; + +interface SessionInfoPopoverProps { + sessionKey: string; + token: string; + title: string; +} + +export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopoverProps) { + const { t } = useTranslation("common"); + const [open, setOpen] = useState(false); + const { jobs, loading, loadFailed, now } = useSessionAutomationJobs(open, token, sessionKey); + const automationContent = loading ? ( +
+ + {t("thread.sessionInfo.loading")} +
+ ) : loadFailed ? ( +
+ + {t("thread.sessionInfo.loadFailed")} +
+ ) : jobs.length ? ( +
+ {jobs.map((job) => ( + + ))} +
+ ) : ( +
+ {t("thread.sessionInfo.empty")} +
+ ); + + return ( + + + + + +
+
+
+ {t("thread.sessionInfo.title")} +
+
+ {title || t("thread.sessionInfo.untitled")} +
+
+ +
+ +
+
+ + + {t("thread.sessionInfo.automations")} + +
+ + {t("thread.sessionInfo.count", { count: jobs.length })} + +
+ + {automationContent} +
+ + + ); +} + +function AutomationRow({ job, now }: { job: SessionAutomationJob; now: number }) { + const { t } = useTranslation("common"); + const schedule = formatSchedule(job, t); + const nextRun = formatNextRun(job, t, now); + const statusClass = job.enabled + ? job.state.last_status === "error" + ? "bg-destructive" + : "bg-emerald-500" + : "bg-muted-foreground/35"; + + return ( +
+
+ +
+
+ {job.name} + {!job.enabled ? ( + + {t("thread.sessionInfo.disabled")} + + ) : null} +
+
+ {job.payload.message} +
+
+ {schedule} + · + {nextRun.label} +
+
+
+
+ ); +} + +function formatSchedule(job: SessionAutomationJob, t: TFunction) { + const locale = currentLocale(); + if (job.schedule.kind === "at" && job.schedule.at_ms) { + return t("thread.sessionInfo.schedule.at", { time: fmtDateTime(job.schedule.at_ms, locale) }); + } + if (job.schedule.kind === "every" && job.schedule.every_ms) { + return t("thread.sessionInfo.schedule.every", { + duration: formatDuration(job.schedule.every_ms, locale), + }); + } + if (job.schedule.kind === "cron" && job.schedule.expr) { + return job.schedule.tz + ? t("thread.sessionInfo.schedule.cronWithTz", { + expr: job.schedule.expr, + tz: job.schedule.tz, + }) + : t("thread.sessionInfo.schedule.cron", { expr: job.schedule.expr }); + } + return t("thread.sessionInfo.schedule.unknown"); +} + +function formatNextRun(job: SessionAutomationJob, t: TFunction, now: number) { + const locale = currentLocale(); + if (!job.enabled) { + return { label: t("thread.sessionInfo.next.disabled"), title: "" }; + } + const next = job.state.next_run_at_ms; + if (!next) { + return { label: t("thread.sessionInfo.next.none"), title: "" }; + } + return { + label: t("thread.sessionInfo.next.label", { time: relativeTimeFrom(next, now, locale) }), + title: fmtDateTime(next, locale), + }; +} + +function relativeTimeFrom(value: number, now: number, locale: string): string { + let delta = (value - now) / 1000; + const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); + for (const [step, unit] of RELATIVE_THRESHOLDS) { + if (Math.abs(delta) < step) { + return formatter.format(Math.round(delta), unit); + } + delta /= step; + } + return formatter.format(Math.round(delta), "year"); +} + +function formatDuration(ms: number, locale: string): string { + const units: Array<[Intl.NumberFormatOptions["unit"], number]> = [ + ["day", 86_400_000], + ["hour", 3_600_000], + ["minute", 60_000], + ["second", 1000], + ]; + for (const [unit, size] of units) { + if (ms >= size && ms % size === 0) { + return new Intl.NumberFormat(locale, { + style: "unit", + unit, + unitDisplay: "long", + maximumFractionDigits: 0, + }).format(ms / size); + } + } + return new Intl.NumberFormat(locale, { + style: "unit", + unit: "minute", + unitDisplay: "long", + maximumFractionDigits: 1, + }).format(ms / 60_000); +} diff --git a/webui/src/components/thread/ThreadHeader.tsx b/webui/src/components/thread/ThreadHeader.tsx index 698344dc3..c3a8a1806 100644 --- a/webui/src/components/thread/ThreadHeader.tsx +++ b/webui/src/components/thread/ThreadHeader.tsx @@ -1,4 +1,5 @@ import { Menu, Moon, Sun } from "lucide-react"; +import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; @@ -13,6 +14,7 @@ interface ThreadHeaderProps { hostChromeTitleInset?: boolean; hideThemeButton?: boolean; minimal?: boolean; + sessionInfoAction?: ReactNode; } export function ThreadHeader({ @@ -24,40 +26,16 @@ export function ThreadHeader({ hostChromeTitleInset = false, hideThemeButton = false, minimal = false, + sessionInfoAction, }: ThreadHeaderProps) { const { t } = useTranslation(); - if (minimal) { - return ( -
- - {!hideThemeButton ? ( - - ) : null} -
- ); - } return (
@@ -73,21 +51,27 @@ export function ThreadHeader({ > -
- {title} -
+ {!minimal ? ( +
+ {title} +
+ ) : null}
- {!hideThemeButton ? ( - - ) : null} +
+ {sessionInfoAction} + {!hideThemeButton ? ( + + ) : null} +
-
+ {!minimal ? ( +
+ ) : null}
); } diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index c361151e7..22dac883d 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -3,6 +3,7 @@ import type { PointerEvent as ReactPointerEvent } from "react"; import { useTranslation } from "react-i18next"; import { FilePreviewPanel } from "@/components/FilePreviewPanel"; +import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover"; import { ThreadComposer } from "@/components/thread/ThreadComposer"; import { ThreadHeader } from "@/components/thread/ThreadHeader"; import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice"; @@ -713,6 +714,9 @@ export function ThreadShell({
); + const sessionInfoAction = historyKey ? ( + + ) : undefined; return (
@@ -727,6 +731,7 @@ export function ThreadShell({ hostChromeTitleInset={hostChromeTitleInset} hideThemeButton={hideThemeButton} minimal={!session && !loading} + sessionInfoAction={sessionInfoAction} /> ) : null} , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetDescription.displayName = DialogPrimitive.Description.displayName; + +export { Sheet, SheetContent, SheetDescription, SheetTitle }; diff --git a/webui/src/globals.css b/webui/src/globals.css index f25f62d13..a069ebb2d 100644 --- a/webui/src/globals.css +++ b/webui/src/globals.css @@ -5,6 +5,7 @@ /* Design tokens — HSL form, sourced from shadcn/ui's "neutral" palette. */ @layer base { :root { + color-scheme: light; --background: 0 0% 100%; --foreground: 240 3% 12%; --card: 0 0% 100%; @@ -30,9 +31,12 @@ --sidebar-accent: 0 0% 95.8%; --sidebar-accent-foreground: 0 0% 9%; --sidebar-border: 0 0% 89.8%; + --scrollbar-thumb: hsl(var(--muted-foreground) / 0.26); + --scrollbar-thumb-hover: hsl(var(--muted-foreground) / 0.42); } .dark { + color-scheme: dark; --background: 0 0% 10%; --foreground: 240 4% 96%; --card: 0 0% 12%; @@ -57,6 +61,8 @@ --sidebar-accent: 0 0% 15.5%; --sidebar-accent-foreground: 0 0% 98%; --sidebar-border: 0 0% 18%; + --scrollbar-thumb: hsl(var(--muted-foreground) / 0.28); + --scrollbar-thumb-hover: hsl(var(--muted-foreground) / 0.44); } } @@ -75,6 +81,33 @@ @apply bg-background text-foreground font-sans antialiased; } + * { + scrollbar-color: var(--scrollbar-thumb) transparent; + scrollbar-width: thin; + } + + *::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + *::-webkit-scrollbar-track { + background: transparent; + } + + *::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb); + border-radius: 9999px; + } + + *::-webkit-scrollbar-thumb:hover { + background-color: var(--scrollbar-thumb-hover); + } + + *::-webkit-scrollbar-corner { + background: transparent; + } + ::selection { @apply bg-primary/15; } diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index 182dddbd5..60f5e0c65 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -945,6 +945,7 @@ export function useNanobotStream( content, ...(hasMedia ? { media } : {}), ...(lat !== undefined ? { latencyMs: lat } : {}), + ...(ev.source ? { source: ev.source } : {}), ...turnFieldsFromEvent(ev, "answer"), }); }); diff --git a/webui/src/hooks/useSessionAutomationJobs.ts b/webui/src/hooks/useSessionAutomationJobs.ts new file mode 100644 index 000000000..175f1fa5f --- /dev/null +++ b/webui/src/hooks/useSessionAutomationJobs.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from "react"; + +import { fetchSessionAutomations } from "@/lib/api"; +import type { SessionAutomationJob } from "@/lib/types"; + +const AUTOMATIONS_REFRESH_MS = 3000; + +export function useSessionAutomationJobs(open: boolean, token: string, sessionKey: string) { + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(false); + const [loadFailed, setLoadFailed] = useState(false); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (!open) return; + let cancelled = false; + let loadedOnce = false; + + const refresh = async (showLoading = false) => { + if (showLoading) { + setLoading(true); + setLoadFailed(false); + setJobs([]); + } + try { + const next = await fetchSessionAutomations(token, sessionKey); + if (cancelled) return; + setJobs(next.jobs); + setLoadFailed(false); + loadedOnce = true; + } catch { + if (!cancelled && !loadedOnce) setLoadFailed(true); + } finally { + if (!cancelled && showLoading) setLoading(false); + } + }; + + void refresh(true); + const refreshId = window.setInterval(() => void refresh(false), AUTOMATIONS_REFRESH_MS); + const refreshOnFocus = () => { + if (document.visibilityState !== "hidden") void refresh(false); + }; + window.addEventListener("focus", refreshOnFocus); + document.addEventListener("visibilitychange", refreshOnFocus); + return () => { + cancelled = true; + window.clearInterval(refreshId); + window.removeEventListener("focus", refreshOnFocus); + document.removeEventListener("visibilitychange", refreshOnFocus); + }; + }, [open, sessionKey, token]); + + useEffect(() => { + if (!open) return; + setNow(Date.now()); + const tickId = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(tickId); + }, [open]); + + return { jobs, loading, loadFailed, now }; +} diff --git a/webui/src/hooks/useSkills.ts b/webui/src/hooks/useSkills.ts new file mode 100644 index 000000000..9144b61cb --- /dev/null +++ b/webui/src/hooks/useSkills.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; + +import { fetchSkills } from "@/lib/api"; +import type { SkillSummary } from "@/lib/types"; + +export function useSkills(token: string): SkillSummary[] { + const [skills, setSkills] = useState([]); + + useEffect(() => { + let cancelled = false; + fetchSkills(token) + .then(({ skills: nextSkills }) => !cancelled && setSkills(nextSkills)) + .catch(() => !cancelled && setSkills([])); + return () => { + cancelled = true; + }; + }, [token]); + + return skills; +} diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index bc823c583..f8f31ca1e 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -54,7 +54,10 @@ "label": "Language", "ariaLabel": "Change language" }, - "apps": "Apps" + "apps": "Apps", + "skills": { + "title": "Skills" + } }, "settings": { "backToChat": "Back to chat", @@ -75,7 +78,8 @@ "mcp": "MCP", "runtime": "System", "advanced": "Security", - "apps": "Apps" + "apps": "Apps", + "skills": "Skills" }, "sections": { "interface": "Interface", @@ -455,6 +459,33 @@ "signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.", "signedIn": "Signed in", "notSignedIn": "Not signed in" + }, + "skills": { + "description": "Review the instruction skills this agent can load during a conversation.", + "caption": "{{available}} available · {{total}} total", + "featured": "Agent skills", + "empty": "No skills are available.", + "sourceWorkspace": "Custom", + "sourceBuiltin": "Built-in", + "statusAvailable": "Available", + "statusUnavailable": "Unavailable", + "unavailableReason": "Missing: {{reason}}", + "openDetails": "Open details for {{name}}", + "loadingDetail": "Loading skill details...", + "loadFailed": "Could not load skill details.", + "descriptionTitle": "Description", + "source": "Source", + "status": "Status", + "requirements": "Requirements", + "noRequirements": "No explicit requirements.", + "commands": "Commands", + "environment": "Environment variables", + "missingCommands": "Missing CLI", + "missingEnvironment": "Missing ENV", + "unavailableReasonLabel": "Unavailable reason", + "rawInstructions": "Raw SKILL.md", + "rawInstructionsEmpty": "No raw instructions.", + "detailDescription": "Details for {{name}}." } }, "chat": { @@ -576,7 +607,30 @@ "toggleSidebar": "Toggle sidebar", "newChat": "Start a new chat", "toggleTheme": "Toggle theme from header", - "settings": "Open settings" + "settings": "Open settings", + "sessionInfo": "Session details" + }, + "sessionInfo": { + "title": "Session", + "untitled": "Untitled chat", + "automations": "Automations", + "count": "{{count}}", + "loading": "Loading automations...", + "loadFailed": "Could not load automations.", + "empty": "No automations in this session yet.", + "disabled": "Off", + "schedule": { + "at": "{{time}}", + "every": "Every {{duration}}", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "unknown": "Custom schedule" + }, + "next": { + "label": "{{time}}", + "disabled": "Paused", + "none": "No next run" + } }, "composer": { "placeholderThread": "Type your message…", @@ -752,6 +806,8 @@ "cliRunRan": "Used", "cliRunFailed": "Failed", "imageAttachment": "Image attachment", + "automationSourceFallback": "Automation", + "automationTriggered": "Triggered automatically", "copyReply": "Copy reply", "copiedReply": "Copied reply", "turnLatencyTitle": "Response time (end-to-end)" diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 949fad825..a6938e3ea 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -54,7 +54,10 @@ "label": "Idioma", "ariaLabel": "Cambiar idioma" }, - "apps": "Apps" + "apps": "Apps", + "skills": { + "title": "Habilidades" + } }, "settings": { "backToChat": "Volver al chat", @@ -75,7 +78,8 @@ "advanced": "Seguridad", "cliApps": "Apps CLI", "mcp": "MCP", - "apps": "Aplicaciones" + "apps": "Aplicaciones", + "skills": "Habilidades" }, "sections": { "interface": "Interfaz", @@ -455,6 +459,33 @@ "signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.", "signedIn": "Sesión iniciada", "notSignedIn": "Sin sesión" + }, + "skills": { + "description": "Revisa las habilidades de instrucciones que este agente puede cargar durante una conversación.", + "caption": "{{available}} disponibles · {{total}} en total", + "featured": "Habilidades del agente", + "empty": "No hay habilidades disponibles.", + "sourceWorkspace": "Personalizada", + "sourceBuiltin": "Integradas", + "statusAvailable": "Disponible", + "statusUnavailable": "No disponible", + "unavailableReason": "Falta: {{reason}}", + "openDetails": "Abrir detalles de {{name}}", + "loadingDetail": "Cargando detalles de la habilidad...", + "loadFailed": "No se pudieron cargar los detalles.", + "descriptionTitle": "Descripción", + "source": "Origen", + "status": "Estado", + "requirements": "Requisitos", + "noRequirements": "Sin requisitos explícitos.", + "commands": "Comandos", + "environment": "Variables de entorno", + "missingCommands": "Falta CLI", + "missingEnvironment": "Falta ENV", + "unavailableReasonLabel": "Motivo de indisponibilidad", + "rawInstructions": "SKILL.md original", + "rawInstructionsEmpty": "No hay instrucciones originales.", + "detailDescription": "Detalles de {{name}}." } }, "chat": { @@ -576,7 +607,30 @@ "toggleSidebar": "Mostrar u ocultar la barra lateral", "newChat": "Iniciar un chat nuevo", "toggleTheme": "Cambiar tema desde el encabezado", - "settings": "Abrir configuración" + "settings": "Abrir configuración", + "sessionInfo": "Detalles de la sesión" + }, + "sessionInfo": { + "title": "Sesión", + "untitled": "Chat sin título", + "automations": "Automatizaciones", + "count": "{{count}}", + "loading": "Cargando automatizaciones...", + "loadFailed": "No se pudieron cargar las automatizaciones.", + "empty": "Esta sesión aún no tiene automatizaciones.", + "disabled": "Desactivado", + "schedule": { + "at": "A las {{time}}", + "every": "Cada {{duration}}", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "unknown": "Programación personalizada" + }, + "next": { + "label": "Siguiente {{time}}", + "disabled": "En pausa", + "none": "Sin próxima ejecución" + } }, "composer": { "placeholderThread": "Escribe tu mensaje…", @@ -754,7 +808,9 @@ "cliActivityFailedMany": "Fallaron {{count}} apps CLI", "cliRunRunning": "Usando", "cliRunRan": "Usado", - "cliRunFailed": "Falló" + "cliRunFailed": "Falló", + "automationSourceFallback": "Automatización", + "automationTriggered": "Activada automáticamente" }, "lightbox": { "title": "Vista previa de imagen", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 591cbd21b..c362afe9c 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -54,7 +54,10 @@ "label": "Langue", "ariaLabel": "Changer de langue" }, - "apps": "Apps" + "apps": "Apps", + "skills": { + "title": "Compétences" + } }, "settings": { "backToChat": "Retour au chat", @@ -75,7 +78,8 @@ "advanced": "Sécurité", "cliApps": "Apps CLI", "mcp": "MCP", - "apps": "Applications" + "apps": "Applications", + "skills": "Compétences" }, "sections": { "interface": "Interface utilisateur", @@ -455,6 +459,33 @@ "signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.", "signedIn": "Connecté", "notSignedIn": "Non connecté" + }, + "skills": { + "description": "Consultez les compétences d’instruction que cet agent peut charger pendant une conversation.", + "caption": "{{available}} disponibles · {{total}} au total", + "featured": "Compétences agent", + "empty": "Aucune compétence disponible.", + "sourceWorkspace": "Personnalisée", + "sourceBuiltin": "Intégrée", + "statusAvailable": "Disponible", + "statusUnavailable": "Indisponible", + "unavailableReason": "Manquant : {{reason}}", + "openDetails": "Ouvrir les détails de {{name}}", + "loadingDetail": "Chargement des détails...", + "loadFailed": "Impossible de charger les détails.", + "descriptionTitle": "Description", + "source": "Source", + "status": "Statut", + "requirements": "Prérequis", + "noRequirements": "Aucun prérequis explicite.", + "commands": "Commandes", + "environment": "Variables d’environnement", + "missingCommands": "CLI manquant", + "missingEnvironment": "ENV manquant", + "unavailableReasonLabel": "Raison d’indisponibilité", + "rawInstructions": "SKILL.md brut", + "rawInstructionsEmpty": "Aucune instruction brute.", + "detailDescription": "Détails de {{name}}." } }, "chat": { @@ -576,7 +607,30 @@ "toggleSidebar": "Afficher ou masquer la barre latérale", "newChat": "Démarrer un nouveau chat", "toggleTheme": "Changer le thème depuis l’en-tête", - "settings": "Ouvrir les paramètres" + "settings": "Ouvrir les paramètres", + "sessionInfo": "Détails de la session" + }, + "sessionInfo": { + "title": "Session", + "untitled": "Chat sans titre", + "automations": "Automatisations", + "count": "{{count}}", + "loading": "Chargement des automatisations...", + "loadFailed": "Impossible de charger les automatisations.", + "empty": "Aucune automatisation dans cette session pour le moment.", + "disabled": "Désactivé", + "schedule": { + "at": "À {{time}}", + "every": "Toutes les {{duration}}", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "unknown": "Planification personnalisée" + }, + "next": { + "label": "Prochaine {{time}}", + "disabled": "En pause", + "none": "Aucune prochaine exécution" + } }, "composer": { "placeholderThread": "Saisissez votre message…", @@ -754,7 +808,9 @@ "cliActivityFailedMany": "Échec de {{count}} apps CLI", "cliRunRunning": "Utilisation", "cliRunRan": "Utilisé", - "cliRunFailed": "Échec" + "cliRunFailed": "Échec", + "automationSourceFallback": "Automatisation", + "automationTriggered": "Déclenché automatiquement" }, "lightbox": { "title": "Aperçu de l’image", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index d5b976dfb..01da1f3d9 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -54,7 +54,10 @@ "label": "Bahasa", "ariaLabel": "Ganti bahasa" }, - "apps": "Aplikasi" + "apps": "Aplikasi", + "skills": { + "title": "Skill" + } }, "settings": { "backToChat": "Kembali ke chat", @@ -75,7 +78,8 @@ "advanced": "Keamanan", "cliApps": "Aplikasi CLI", "mcp": "MCP", - "apps": "Aplikasi" + "apps": "Aplikasi", + "skills": "Skill" }, "sections": { "interface": "Antarmuka", @@ -455,6 +459,33 @@ "signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.", "signedIn": "Sudah masuk", "notSignedIn": "Belum masuk" + }, + "skills": { + "description": "Tinjau skill instruksi yang dapat dimuat agent ini selama percakapan.", + "caption": "{{available}} tersedia · {{total}} total", + "featured": "Skill agent", + "empty": "Tidak ada skill yang tersedia.", + "sourceWorkspace": "Kustom", + "sourceBuiltin": "Bawaan", + "statusAvailable": "Tersedia", + "statusUnavailable": "Tidak tersedia", + "unavailableReason": "Kurang: {{reason}}", + "openDetails": "Buka detail {{name}}", + "loadingDetail": "Memuat detail skill...", + "loadFailed": "Tidak dapat memuat detail skill.", + "descriptionTitle": "Deskripsi", + "source": "Sumber", + "status": "Status", + "requirements": "Kebutuhan", + "noRequirements": "Tidak ada kebutuhan eksplisit.", + "commands": "Perintah", + "environment": "Variabel lingkungan", + "missingCommands": "CLI hilang", + "missingEnvironment": "ENV hilang", + "unavailableReasonLabel": "Alasan tidak tersedia", + "rawInstructions": "SKILL.md mentah", + "rawInstructionsEmpty": "Tidak ada instruksi mentah.", + "detailDescription": "Detail untuk {{name}}." } }, "chat": { @@ -576,7 +607,30 @@ "toggleSidebar": "Tampilkan atau sembunyikan sidebar", "newChat": "Mulai chat baru", "toggleTheme": "Alihkan tema dari header", - "settings": "Buka pengaturan" + "settings": "Buka pengaturan", + "sessionInfo": "Detail sesi" + }, + "sessionInfo": { + "title": "Sesi", + "untitled": "Chat tanpa judul", + "automations": "Otomasi", + "count": "{{count}}", + "loading": "Memuat otomasi...", + "loadFailed": "Tidak dapat memuat otomasi.", + "empty": "Belum ada otomasi dalam sesi ini.", + "disabled": "Mati", + "schedule": { + "at": "Pada {{time}}", + "every": "Setiap {{duration}}", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "unknown": "Jadwal khusus" + }, + "next": { + "label": "Berikutnya {{time}}", + "disabled": "Dijeda", + "none": "Tidak ada jadwal berikutnya" + } }, "composer": { "placeholderThread": "Ketik pesan Anda…", @@ -754,7 +808,9 @@ "cliActivityFailedMany": "{{count}} aplikasi CLI gagal", "cliRunRunning": "Menggunakan", "cliRunRan": "Digunakan", - "cliRunFailed": "Gagal" + "cliRunFailed": "Gagal", + "automationSourceFallback": "Otomatisasi", + "automationTriggered": "Dipicu otomatis" }, "lightbox": { "title": "Pratinjau gambar", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index eee60f59b..4dfac1b78 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -54,7 +54,10 @@ "label": "言語", "ariaLabel": "言語を変更" }, - "apps": "アプリ" + "apps": "アプリ", + "skills": { + "title": "スキル" + } }, "settings": { "backToChat": "チャットに戻る", @@ -75,7 +78,8 @@ "advanced": "セキュリティ", "cliApps": "CLI アプリ", "mcp": "MCP", - "apps": "アプリ" + "apps": "アプリ", + "skills": "スキル" }, "sections": { "interface": "インターフェース", @@ -455,6 +459,33 @@ "signInBeforeSaving": "この OAuth プロバイダーをアクティブなモデルプロバイダーとして保存する前にサインインしてください。", "signedIn": "サインイン済み", "notSignedIn": "未サインイン" + }, + "skills": { + "description": "このエージェントが会話中に読み込める指示スキルを確認します。", + "caption": "{{available}} 利用可能 · 合計 {{total}}", + "featured": "エージェントスキル", + "empty": "利用可能なスキルはありません。", + "sourceWorkspace": "カスタム", + "sourceBuiltin": "組み込み", + "statusAvailable": "利用可能", + "statusUnavailable": "利用不可", + "unavailableReason": "不足: {{reason}}", + "openDetails": "{{name}} の詳細を開く", + "loadingDetail": "スキル詳細を読み込み中...", + "loadFailed": "スキル詳細を読み込めませんでした。", + "descriptionTitle": "説明", + "source": "ソース", + "status": "状態", + "requirements": "要件", + "noRequirements": "明示的な要件はありません。", + "commands": "コマンド", + "environment": "環境変数", + "missingCommands": "CLI 不足", + "missingEnvironment": "ENV 不足", + "unavailableReasonLabel": "利用不可の理由", + "rawInstructions": "元の SKILL.md", + "rawInstructionsEmpty": "元の説明はありません。", + "detailDescription": "{{name}} の詳細。" } }, "chat": { @@ -576,7 +607,30 @@ "toggleSidebar": "サイドバーを切り替える", "newChat": "新しいチャットを開始", "toggleTheme": "ヘッダーからテーマを切り替える", - "settings": "設定を開く" + "settings": "設定を開く", + "sessionInfo": "セッション詳細" + }, + "sessionInfo": { + "title": "セッション", + "untitled": "無題のチャット", + "automations": "自動タスク", + "count": "{{count}}", + "loading": "自動タスクを読み込み中...", + "loadFailed": "自動タスクを読み込めませんでした。", + "empty": "このセッションにはまだ自動タスクがありません。", + "disabled": "オフ", + "schedule": { + "at": "{{time}}", + "every": "{{duration}}ごと", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "unknown": "カスタムスケジュール" + }, + "next": { + "label": "次回 {{time}}", + "disabled": "一時停止", + "none": "次回実行なし" + } }, "composer": { "placeholderThread": "メッセージを入力…", @@ -754,7 +808,9 @@ "cliActivityFailedMany": "{{count}} 個の CLI アプリが失敗しました", "cliRunRunning": "使用中", "cliRunRan": "使用済み", - "cliRunFailed": "失敗" + "cliRunFailed": "失敗", + "automationSourceFallback": "自動化", + "automationTriggered": "自動実行" }, "lightbox": { "title": "画像プレビュー", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index db8ce076f..4acf8a1d4 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -54,7 +54,10 @@ "label": "언어", "ariaLabel": "언어 변경" }, - "apps": "앱" + "apps": "앱", + "skills": { + "title": "스킬" + } }, "settings": { "backToChat": "채팅으로 돌아가기", @@ -75,7 +78,8 @@ "advanced": "보안", "cliApps": "CLI 앱", "mcp": "MCP", - "apps": "앱" + "apps": "앱", + "skills": "스킬" }, "sections": { "interface": "인터페이스", @@ -455,6 +459,33 @@ "signInBeforeSaving": "이 OAuth 제공자를 활성 모델 제공자로 저장하기 전에 로그인하세요.", "signedIn": "로그인됨", "notSignedIn": "로그인 안 됨" + }, + "skills": { + "description": "이 에이전트가 대화 중에 불러올 수 있는 지시 스킬을 확인합니다.", + "caption": "{{available}}개 사용 가능 · 총 {{total}}개", + "featured": "에이전트 스킬", + "empty": "사용 가능한 스킬이 없습니다.", + "sourceWorkspace": "사용자 지정", + "sourceBuiltin": "내장", + "statusAvailable": "사용 가능", + "statusUnavailable": "사용 불가", + "unavailableReason": "누락: {{reason}}", + "openDetails": "{{name}} 상세 열기", + "loadingDetail": "스킬 상세를 불러오는 중...", + "loadFailed": "스킬 상세를 불러올 수 없습니다.", + "descriptionTitle": "설명", + "source": "출처", + "status": "상태", + "requirements": "요구 사항", + "noRequirements": "명시된 요구 사항이 없습니다.", + "commands": "명령", + "environment": "환경 변수", + "missingCommands": "CLI 누락", + "missingEnvironment": "ENV 누락", + "unavailableReasonLabel": "사용 불가 이유", + "rawInstructions": "원본 SKILL.md", + "rawInstructionsEmpty": "원본 지침이 없습니다.", + "detailDescription": "{{name}} 세부 정보." } }, "chat": { @@ -576,7 +607,30 @@ "toggleSidebar": "사이드바 전환", "newChat": "새 채팅 시작", "toggleTheme": "헤더에서 테마 전환", - "settings": "설정 열기" + "settings": "설정 열기", + "sessionInfo": "세션 세부 정보" + }, + "sessionInfo": { + "title": "세션", + "untitled": "제목 없는 채팅", + "automations": "자동화", + "count": "{{count}}", + "loading": "자동화를 불러오는 중...", + "loadFailed": "자동화를 불러오지 못했습니다.", + "empty": "이 세션에는 아직 자동화가 없습니다.", + "disabled": "꺼짐", + "schedule": { + "at": "{{time}}", + "every": "{{duration}}마다", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "unknown": "사용자 지정 일정" + }, + "next": { + "label": "다음 {{time}}", + "disabled": "일시 중지됨", + "none": "다음 실행 없음" + } }, "composer": { "placeholderThread": "메시지를 입력하세요…", @@ -754,7 +808,9 @@ "cliActivityFailedMany": "CLI 앱 {{count}}개 실패", "cliRunRunning": "사용 중", "cliRunRan": "사용함", - "cliRunFailed": "실패" + "cliRunFailed": "실패", + "automationSourceFallback": "자동화", + "automationTriggered": "자동 실행됨" }, "lightbox": { "title": "이미지 미리보기", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 889482e04..23bbf4c9c 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -54,7 +54,10 @@ "label": "Ngôn ngữ", "ariaLabel": "Đổi ngôn ngữ" }, - "apps": "Ứng dụng" + "apps": "Ứng dụng", + "skills": { + "title": "Kỹ năng" + } }, "settings": { "backToChat": "Quay lại chat", @@ -75,7 +78,8 @@ "advanced": "Bảo mật", "cliApps": "Ứng dụng CLI", "mcp": "MCP", - "apps": "Ứng dụng" + "apps": "Ứng dụng", + "skills": "Kỹ năng" }, "sections": { "interface": "Giao diện", @@ -455,6 +459,33 @@ "signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.", "signedIn": "Đã đăng nhập", "notSignedIn": "Chưa đăng nhập" + }, + "skills": { + "description": "Xem các kỹ năng chỉ dẫn mà agent này có thể tải trong cuộc trò chuyện.", + "caption": "{{available}} khả dụng · tổng {{total}}", + "featured": "Kỹ năng agent", + "empty": "Không có kỹ năng nào khả dụng.", + "sourceWorkspace": "Tùy chỉnh", + "sourceBuiltin": "Tích hợp", + "statusAvailable": "Khả dụng", + "statusUnavailable": "Không khả dụng", + "unavailableReason": "Thiếu: {{reason}}", + "openDetails": "Mở chi tiết {{name}}", + "loadingDetail": "Đang tải chi tiết kỹ năng...", + "loadFailed": "Không tải được chi tiết kỹ năng.", + "descriptionTitle": "Mô tả", + "source": "Nguồn", + "status": "Trạng thái", + "requirements": "Yêu cầu", + "noRequirements": "Không có yêu cầu rõ ràng.", + "commands": "Lệnh", + "environment": "Biến môi trường", + "missingCommands": "Thiếu CLI", + "missingEnvironment": "Thiếu ENV", + "unavailableReasonLabel": "Lý do không khả dụng", + "rawInstructions": "SKILL.md gốc", + "rawInstructionsEmpty": "Không có hướng dẫn gốc.", + "detailDescription": "Chi tiết cho {{name}}." } }, "chat": { @@ -576,7 +607,30 @@ "toggleSidebar": "Bật/tắt thanh bên", "newChat": "Bắt đầu chat mới", "toggleTheme": "Chuyển chủ đề từ header", - "settings": "Mở cài đặt" + "settings": "Mở cài đặt", + "sessionInfo": "Chi tiết phiên" + }, + "sessionInfo": { + "title": "Phiên", + "untitled": "Chat chưa đặt tên", + "automations": "Tự động hóa", + "count": "{{count}}", + "loading": "Đang tải tự động hóa...", + "loadFailed": "Không thể tải tự động hóa.", + "empty": "Phiên này chưa có tự động hóa.", + "disabled": "Tắt", + "schedule": { + "at": "Lúc {{time}}", + "every": "Mỗi {{duration}}", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "unknown": "Lịch tùy chỉnh" + }, + "next": { + "label": "Tiếp theo {{time}}", + "disabled": "Đã tạm dừng", + "none": "Không có lần chạy tiếp theo" + } }, "composer": { "placeholderThread": "Nhập tin nhắn…", @@ -754,7 +808,9 @@ "cliActivityFailedMany": "{{count}} ứng dụng CLI thất bại", "cliRunRunning": "Đang dùng", "cliRunRan": "Đã dùng", - "cliRunFailed": "Thất bại" + "cliRunFailed": "Thất bại", + "automationSourceFallback": "Tự động hóa", + "automationTriggered": "Tự động kích hoạt" }, "lightbox": { "title": "Xem trước ảnh", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 03b8b4ec1..446a7a02a 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -54,7 +54,10 @@ "label": "语言", "ariaLabel": "切换语言" }, - "apps": "应用" + "apps": "应用", + "skills": { + "title": "技能" + } }, "settings": { "backToChat": "返回聊天", @@ -75,7 +78,8 @@ "mcp": "MCP", "runtime": "系统", "advanced": "安全", - "apps": "应用" + "apps": "应用", + "skills": "技能" }, "sections": { "interface": "界面", @@ -455,6 +459,33 @@ "signInBeforeSaving": "将此 OAuth 提供商设为当前模型提供商前,请先登录。", "signedIn": "已登录", "notSignedIn": "未登录" + }, + "skills": { + "description": "查看此 agent 在对话中可以加载的指令技能。", + "caption": "{{available}} 个可用 · 共 {{total}} 个", + "featured": "Agent 技能", + "empty": "暂无可用技能。", + "sourceWorkspace": "自定义", + "sourceBuiltin": "内置", + "statusAvailable": "可用", + "statusUnavailable": "不可用", + "unavailableReason": "缺少:{{reason}}", + "openDetails": "查看 {{name}} 详情", + "loadingDetail": "正在加载技能详情...", + "loadFailed": "无法加载技能详情。", + "descriptionTitle": "完整描述", + "source": "来源", + "status": "状态", + "requirements": "需求", + "noRequirements": "没有显式需求。", + "commands": "命令", + "environment": "环境变量", + "missingCommands": "缺 CLI", + "missingEnvironment": "缺 ENV", + "unavailableReasonLabel": "不可用原因", + "rawInstructions": "原始 SKILL.md", + "rawInstructionsEmpty": "没有原始说明。", + "detailDescription": "{{name}} 的详情。" } }, "chat": { @@ -576,7 +607,30 @@ "toggleSidebar": "切换侧边栏", "newChat": "从顶部新建对话", "toggleTheme": "从顶部切换主题", - "settings": "打开设置" + "settings": "打开设置", + "sessionInfo": "会话详情" + }, + "sessionInfo": { + "title": "会话", + "untitled": "未命名对话", + "automations": "自动任务", + "count": "{{count}}", + "loading": "正在加载自动任务...", + "loadFailed": "无法加载自动任务。", + "empty": "这个会话暂时没有自动任务。", + "disabled": "已关闭", + "schedule": { + "at": "{{time}}", + "every": "每 {{duration}}", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "unknown": "自定义计划" + }, + "next": { + "label": "下次 {{time}}", + "disabled": "已暂停", + "none": "没有下次运行" + } }, "composer": { "placeholderThread": "输入消息…", @@ -752,6 +806,8 @@ "cliRunRan": "已使用", "cliRunFailed": "失败", "imageAttachment": "图片附件", + "automationSourceFallback": "自动化", + "automationTriggered": "自动触发", "copyReply": "复制回复", "copiedReply": "已复制回复", "turnLatencyTitle": "本轮耗时(端到端)" diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 4ea3ce95f..12597c722 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -54,7 +54,10 @@ "label": "語言", "ariaLabel": "切換語言" }, - "apps": "應用" + "apps": "應用", + "skills": { + "title": "技能" + } }, "settings": { "backToChat": "返回聊天", @@ -75,7 +78,8 @@ "advanced": "安全", "cliApps": "CLI 應用", "mcp": "MCP", - "apps": "應用" + "apps": "應用", + "skills": "技能" }, "sections": { "interface": "介面", @@ -455,6 +459,33 @@ "signInBeforeSaving": "將此 OAuth 供應商設為目前模型供應商前,請先登入。", "signedIn": "已登入", "notSignedIn": "未登入" + }, + "skills": { + "description": "查看此 agent 在對話中可以載入的指令技能。", + "caption": "{{available}} 個可用 · 共 {{total}} 個", + "featured": "Agent 技能", + "empty": "暫無可用技能。", + "sourceWorkspace": "自訂", + "sourceBuiltin": "內建", + "statusAvailable": "可用", + "statusUnavailable": "不可用", + "unavailableReason": "缺少:{{reason}}", + "openDetails": "查看 {{name}} 詳情", + "loadingDetail": "正在載入技能詳情...", + "loadFailed": "無法載入技能詳情。", + "descriptionTitle": "完整描述", + "source": "來源", + "status": "狀態", + "requirements": "需求", + "noRequirements": "沒有明確需求。", + "commands": "命令", + "environment": "環境變數", + "missingCommands": "缺 CLI", + "missingEnvironment": "缺 ENV", + "unavailableReasonLabel": "不可用原因", + "rawInstructions": "原始 SKILL.md", + "rawInstructionsEmpty": "沒有原始說明。", + "detailDescription": "{{name}} 的詳細資訊。" } }, "chat": { @@ -576,7 +607,30 @@ "toggleSidebar": "切換側邊欄", "newChat": "開始新對話", "toggleTheme": "從頂部切換主題", - "settings": "開啟設定" + "settings": "開啟設定", + "sessionInfo": "會話詳情" + }, + "sessionInfo": { + "title": "會話", + "untitled": "未命名對話", + "automations": "自動任務", + "count": "{{count}}", + "loading": "正在載入自動任務...", + "loadFailed": "無法載入自動任務。", + "empty": "這個會話暫時沒有自動任務。", + "disabled": "已關閉", + "schedule": { + "at": "{{time}}", + "every": "每 {{duration}}", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "unknown": "自訂計畫" + }, + "next": { + "label": "下次 {{time}}", + "disabled": "已暫停", + "none": "沒有下次執行" + } }, "composer": { "placeholderThread": "輸入訊息…", @@ -754,7 +808,9 @@ "cliActivityFailedMany": "{{count}} 個 CLI 應用失敗", "cliRunRunning": "使用中", "cliRunRan": "已使用", - "cliRunFailed": "失敗" + "cliRunFailed": "失敗", + "automationSourceFallback": "自動化", + "automationTriggered": "自動觸發" }, "lightbox": { "title": "圖片預覽", diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index f1355be98..c0e5618c1 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -9,9 +9,12 @@ import type { NetworkSafetySettingsUpdate, ProviderModelsPayload, ProviderSettingsUpdate, + SessionAutomationsPayload, SettingsPayload, SettingsUpdate, SidebarStatePayload, + SkillDetail, + SkillsPayload, SlashCommand, WebSearchSettingsUpdate, WorkspacesPayload, @@ -151,6 +154,44 @@ export async function fetchFilePreview( ); } +export async function fetchSessionAutomations( + token: string, + key: string, + base: string = "", +): Promise { + return request( + `${base}/api/sessions/${encodeURIComponent(key)}/automations`, + token, + undefined, + API_READ_TIMEOUT_MS, + ); +} + +export async function fetchSkills( + token: string, + base: string = "", +): Promise { + return request( + `${base}/api/webui/skills`, + token, + undefined, + API_READ_TIMEOUT_MS, + ); +} + +export async function fetchSkillDetail( + token: string, + name: string, + base: string = "", +): Promise { + return request( + `${base}/api/webui/skills/${encodeURIComponent(name)}`, + token, + undefined, + API_READ_TIMEOUT_MS, + ); +} + export async function deleteSession( token: string, key: string, diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 34cba38a8..dec90f0ea 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -32,6 +32,8 @@ export interface UIMediaAttachment { name?: string; } +export interface UIMessageSource { kind: "cron"; label?: string; } + export interface UIMessage { id: string; role: Role; @@ -66,6 +68,8 @@ export interface UIMessage { reasoningStreaming?: boolean; /** End-to-end wall time for this assistant turn (persisted ``latency_ms`` / ``turn_end``). */ latencyMs?: number; + /** Lightweight provenance for proactive assistant messages. */ + source?: UIMessageSource; /** Stable protocol metadata for grouping all activity emitted by one user turn. */ turnId?: string; turnPhase?: UITurnPhase; @@ -92,6 +96,50 @@ export interface UIMcpPresetAttachment { brand_color?: string | null; } +export interface SessionAutomationJob { + id: string; + name: string; + enabled: boolean; + schedule: { + kind: "at" | "every" | "cron" | string; + at_ms?: number | null; + every_ms?: number | null; + expr?: string | null; + tz?: string | null; + }; + payload: { + message: string; + }; + state: { + next_run_at_ms?: number | null; + last_status?: "ok" | "error" | "skipped" | string | null; + }; +} + +export interface SessionAutomationsPayload { jobs: SessionAutomationJob[]; } + +export interface SkillSummary { + name: string; + description: string; + source: "workspace" | "builtin" | string; + available: boolean; + unavailable_reason?: string; +} + +export interface SkillRequirements { + bins: string[]; + env: string[]; + missing_bins: string[]; + missing_env: string[]; +} + +export interface SkillDetail extends SkillSummary { + requirements: SkillRequirements; + raw_markdown: string; +} + +export interface SkillsPayload { skills: SkillSummary[]; } + /** Structured UI blob on ``progress`` WS frames; channels may add more ``kind`` values later. */ export interface AgentUIBlob { kind: string; @@ -670,6 +718,8 @@ export type InboundEvent = kind?: "tool_hint" | "progress" | "reasoning"; /** Server-measured turn wall time when this frame finishes an assistant reply. */ latency_ms?: number; + /** Lightweight provenance for proactive assistant messages. */ + source?: UIMessageSource; /** Optional structured payload on progress frames (channel-specific). */ agent_ui?: AgentUIBlob; } & InboundTurnMetadata) diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index c8630c11f..d48483615 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -7,8 +7,11 @@ import { fetchCliApps, fetchMcpPresets, fetchProviderModels, + fetchSessionAutomations, fetchSettingsUsage, fetchSidebarState, + fetchSkillDetail, + fetchSkills, fetchWebuiThread, fetchWorkspaces, importMcpConfig, @@ -69,6 +72,39 @@ describe("webui API helpers", () => { ); }); + it("percent-encodes websocket keys when fetching session automations", async () => { + await fetchSessionAutomations("tok", "websocket:chat-1"); + + expect(fetch).toHaveBeenCalledWith( + "/api/sessions/websocket%3Achat-1/automations", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ); + }); + + it("fetches the WebUI skill summary", async () => { + await fetchSkills("tok"); + + expect(fetch).toHaveBeenCalledWith( + "/api/webui/skills", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ); + }); + + it("percent-encodes skill names when fetching skill details", async () => { + await fetchSkillDetail("tok", "current web"); + + expect(fetch).toHaveBeenCalledWith( + "/api/webui/skills/current%20web", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ); + }); + it("percent-encodes websocket keys when deleting a session", async () => { await deleteSession("tok", "websocket:chat-1"); diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index a7b8260d6..754cb0f8e 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -30,6 +30,18 @@ function jsonResponse(body: unknown): Response { } as Response; } +function mockFetchRoutes(routes: Record): void { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const body = routes[String(input)]; + return body === undefined + ? ({ ok: false, status: 404, json: async () => ({}) } as Response) + : jsonResponse(body); + }), + ); +} + function baseSettingsPayload() { return { agent: { @@ -244,6 +256,75 @@ describe("App layout", () => { expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true); }); + it("opens Skills from the main sidebar", async () => { + mockFetchRoutes({ + "/api/settings": baseSettingsPayload(), + "/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" }, + "/api/settings/mcp-presets": { presets: [], installed_count: 0 }, + "/api/webui/skills": { + skills: [ + { name: "cron", description: "Schedule reminders.", source: "builtin", available: true }, + { + name: "github", + description: "Work with GitHub.", + source: "builtin", + available: false, + unavailable_reason: "CLI: gh", + }, + ], + }, + "/api/webui/skills/github": { + name: "github", + description: "Work with GitHub.", + source: "builtin", + available: false, + unavailable_reason: "CLI: gh", + requirements: { + bins: ["gh"], + env: [], + missing_bins: ["gh"], + missing_env: [], + }, + raw_markdown: "---\nname: github\n---\nUse GitHub CLI.", + }, + }); + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + const skillsButton = within(sidebar).getByRole("button", { name: "Skills" }); + + fireEvent.click(skillsButton); + + expect(await screen.findByRole("heading", { name: "Skills" })).toBeInTheDocument(); + expect(screen.getByText("cron")).toBeInTheDocument(); + expect(screen.getByText("github")).toBeInTheDocument(); + expect(screen.getByText("Missing: CLI: gh")).toBeInTheDocument(); + expect(screen.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument(); + expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument(); + expect(within(sidebar).getByRole("button", { name: "Skills" })).toHaveAttribute( + "aria-current", + "page", + ); + expect(document.title).toBe("Skills · nanobot"); + + fireEvent.click(screen.getByRole("button", { name: "Back to chat" })); + expect(await screen.findByText(HERO_GREETING_PATTERN)).toBeInTheDocument(); + + fireEvent.click(within(sidebar).getByRole("button", { name: "Skills" })); + expect(await screen.findByRole("heading", { name: "Skills" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Open details for github" })); + + expect(await screen.findByRole("heading", { name: "github" })).toBeInTheDocument(); + expect(screen.getByText("Unavailable reason")).toBeInTheDocument(); + expect(screen.getAllByText("CLI: gh").length).toBeGreaterThan(0); + expect(screen.getByText("Missing CLI")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Raw SKILL.md")); + expect(screen.getByText(/Use GitHub CLI/)).toBeInTheDocument(); + }); + it("fully collapses the native host sidebar and previews it on hover", async () => { mockSessions = [ { @@ -1090,15 +1171,7 @@ describe("App layout", () => { }); it("restores the settings section from the URL hash after a page reload", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async (input: RequestInfo | URL) => { - if (String(input) === "/api/settings") { - return jsonResponse(baseSettingsPayload()); - } - return { ok: false, status: 404, json: async () => ({}) } as Response; - }), - ); + mockFetchRoutes({ "/api/settings": baseSettingsPayload() }); window.history.replaceState(null, "", "/#/settings?section=models"); render(); @@ -1109,15 +1182,7 @@ describe("App layout", () => { }); it("updates the URL hash when switching settings sections", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async (input: RequestInfo | URL) => { - if (String(input) === "/api/settings") { - return jsonResponse(baseSettingsPayload()); - } - return { ok: false, status: 404, json: async () => ({}) } as Response; - }), - ); + mockFetchRoutes({ "/api/settings": baseSettingsPayload() }); render(); @@ -1135,22 +1200,11 @@ describe("App layout", () => { }); it("opens Apps from the main sidebar without replacing the sidebar", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async (input: RequestInfo | URL) => { - const href = String(input); - if (href === "/api/settings") { - return jsonResponse(baseSettingsPayload()); - } - if (href === "/api/settings/cli-apps") { - return jsonResponse({ apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" }); - } - if (href === "/api/settings/mcp-presets") { - return jsonResponse({ presets: [], installed_count: 0 }); - } - return { ok: false, status: 404, json: async () => ({}) } as Response; - }), - ); + mockFetchRoutes({ + "/api/settings": baseSettingsPayload(), + "/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" }, + "/api/settings/mcp-presets": { presets: [], installed_count: 0 }, + }); render(); diff --git a/webui/src/tests/markdown-text-renderer.test.tsx b/webui/src/tests/markdown-text-renderer.test.tsx index 3af72566e..2748fbdcc 100644 --- a/webui/src/tests/markdown-text-renderer.test.tsx +++ b/webui/src/tests/markdown-text-renderer.test.tsx @@ -154,6 +154,42 @@ describe("MarkdownTextRenderer", () => { ).toHaveAttribute("href", "https://polymarket.com/event/when-will-gpt-5pt6-be-released"); }); + it("falls back through favicon sources before showing a globe for compact link rows", () => { + const { container } = render( + + { + "Useful links:\n\n- Savills Hong Kong Corporate Relocation — Corporate relocation services\n https://www.savills.com.hk/services/corporate-relocation.aspx" + } + , + ); + const link = screen.getByRole("link", { + name: "Open link: Savills Hong Kong Corporate Relocation — Corporate relocation services", + }); + const favicon = () => link.querySelector("img"); + + expect(favicon()).toHaveAttribute( + "src", + "https://www.savills.com.hk/favicon.ico", + ); + + fireEvent.error(favicon()!); + expect(favicon()).toHaveAttribute( + "src", + "https://icons.duckduckgo.com/ip3/www.savills.com.hk.ico", + ); + + fireEvent.error(favicon()!); + expect(favicon()).toHaveAttribute( + "src", + "https://www.google.com/s2/favicons?domain=www.savills.com.hk&sz=64", + ); + + fireEvent.error(favicon()!); + expect(favicon()).not.toBeInTheDocument(); + expect(link.querySelector("svg")).toBeInTheDocument(); + expect(container).not.toHaveTextContent("SC"); + }); + it("renders media attachments without an extra preview/code wrapper", () => { render(![Diagram](/api/media/sig/payload)); diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx index 060f0374a..b306cdbbe 100644 --- a/webui/src/tests/message-bubble.test.tsx +++ b/webui/src/tests/message-bubble.test.tsx @@ -101,6 +101,22 @@ describe("MessageBubble", () => { expect(screen.getByText(/not @krita/)).toBeInTheDocument(); }); + it("renders a lightweight automation source label for cron replies", () => { + const message: UIMessage = { + id: "a-cron", + role: "assistant", + content: "Time to drink water.", + source: { kind: "cron", label: "drink water" }, + createdAt: Date.now(), + }; + + render(); + + expect(screen.getByText("drink water")).toBeInTheDocument(); + expect(screen.getByText("Triggered automatically")).toBeInTheDocument(); + expect(screen.getByText("Time to drink water.")).toBeInTheDocument(); + }); + it("renders structured CLI app attachments even without the installed catalog", () => { const message: UIMessage = { id: "u-cli-attached", diff --git a/webui/src/tests/session-info-popover.test.tsx b/webui/src/tests/session-info-popover.test.tsx new file mode 100644 index 000000000..15da6986d --- /dev/null +++ b/webui/src/tests/session-info-popover.test.tsx @@ -0,0 +1,117 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover"; +import { setAppLanguage } from "@/i18n"; + +function automationJob(nextRunAt = Date.now() + 3_600_000) { + return { + id: "job-1", + name: "Morning check", + enabled: true, + schedule: { kind: "every", every_ms: 3_600_000 }, + payload: { message: "Check the project status" }, + state: { next_run_at_ms: nextRunAt }, + }; +} + +function automationsResponse(jobs: unknown[]) { + return { + ok: true, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ + jobs, + }), + } as Response; +} + +describe("SessionInfoPopover", () => { + beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(automationsResponse([automationJob()])), + ); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("loads and displays session automations when opened", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Session details" })); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + "/api/sessions/websocket%3Achat-1/automations", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ); + }); + expect(await screen.findByText("Morning check")).toBeInTheDocument(); + expect(screen.getByText("Check the project status")).toBeInTheDocument(); + }); + + it("localizes the panel chrome in Simplified Chinese", async () => { + await setAppLanguage("zh-CN"); + const user = userEvent.setup(); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "会话详情" })); + + expect(await screen.findByText("会话")).toBeInTheDocument(); + expect(screen.getByText("自动任务")).toBeInTheDocument(); + expect(screen.getByText("Morning check")).toBeInTheDocument(); + expect(screen.getByText(/下次/)).toBeInTheDocument(); + expect(screen.queryByText("Session")).not.toBeInTheDocument(); + expect(screen.queryByText("Automations")).not.toBeInTheDocument(); + }); + + it("refreshes while open so completed one-shot automations disappear", async () => { + vi.stubGlobal( + "fetch", + vi.fn() + .mockResolvedValueOnce(automationsResponse([automationJob(Date.now() + 1000)])) + .mockResolvedValue(automationsResponse([])), + ); + const user = userEvent.setup(); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Session details" })); + expect(await screen.findByText("Morning check")).toBeInTheDocument(); + + await waitFor( + () => { + expect(screen.queryByText("Morning check")).not.toBeInTheDocument(); + }, + { timeout: 4500 }, + ); + expect(screen.getByText("No automations in this session yet.")).toBeInTheDocument(); + }, 8000); +}); diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 21de70fa7..88c5b3ba2 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -157,6 +157,28 @@ describe("useNanobotStream", () => { expect(result.current.isStreaming).toBe(false); }); + it("preserves proactive automation source metadata on complete assistant messages", () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-cron", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-cron", { + event: "message", + chat_id: "chat-cron", + text: "Time to drink water.", + source: { kind: "cron", label: "drink water" }, + }); + }); + + expect(result.current.messages[0]).toMatchObject({ + role: "assistant", + content: "Time to drink water.", + source: { kind: "cron", label: "drink water" }, + }); + }); + it("drops pending stream work when switching chats", async () => { const fake = fakeClient(); const { result, rerender } = renderHook(