feat(desktop): polish shell and shared surfaces

This commit is contained in:
Xubin Ren
2026-06-06 00:19:31 +08:00
parent 62c43b621a
commit c789416e40
50 changed files with 2689 additions and 292 deletions
+1 -1
View File
@@ -31,7 +31,7 @@
</p> </p>
</div> </div>
🐈 **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 ## 📢 News
+1 -1
View File
@@ -25,7 +25,7 @@
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
"build": { "build": {
"appId": "ai.nanobot.desktop", "appId": "wiki.nanobot.desktop",
"productName": "nanobot", "productName": "nanobot",
"asar": true, "asar": true,
"files": [ "files": [
+2 -36
View File
@@ -658,31 +658,6 @@ class AgentLoop:
budget = self.context_window_tokens - max(1, reserved_output) - 1024 budget = self.context_window_tokens - max(1, reserved_output) - 1024
return budget if budget > 0 else max(128, self.context_window_tokens // 2) 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( async def _run_agent_loop(
self, self,
initial_messages: list[dict], initial_messages: list[dict],
@@ -726,12 +701,8 @@ class AgentLoop:
on_iteration=lambda iteration: setattr(self, "_current_iteration", iteration), on_iteration=lambda iteration: setattr(self, "_current_iteration", iteration),
) )
hook: AgentHook = loop_hook hook: AgentHook = loop_hook
extra_hooks = [ if not ephemeral and self._extra_hooks:
h for h in self._extra_hooks hook = CompositeHook([loop_hook] + self._extra_hooks)
if not ephemeral or self._hook_includes_ephemeral(h)
]
if extra_hooks:
hook = CompositeHook([loop_hook] + extra_hooks)
async def _checkpoint(payload: dict[str, Any]) -> None: async def _checkpoint(payload: dict[str, Any]) -> None:
if session is 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_active_predicate=lambda: sustained_goal_active(session.metadata) if session is not None else False,
goal_continue_message=_goal_continue, goal_continue_message=_goal_continue,
usage_source=self._usage_source_for_turn(
channel=channel,
session_key=active_session_key,
ephemeral=ephemeral,
),
)) ))
finally: finally:
reset_workspace_scope(workspace_token) reset_workspace_scope(workspace_token)
+18
View File
@@ -151,6 +151,24 @@ class SkillsLoader:
+ [f"ENV: {env_name}" for env_name in required_env_vars if not os.environ.get(env_name)] + [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: def _get_skill_description(self, name: str) -> str:
"""Get the description of a skill from its frontmatter.""" """Get the description of a skill from its frontmatter."""
meta = self.get_skill_metadata(name) meta = self.get_skill_metadata(name)
+4
View File
@@ -56,6 +56,7 @@ class ChannelManager:
bus: MessageBus, bus: MessageBus,
*, *,
session_manager: "SessionManager | None" = None, session_manager: "SessionManager | None" = None,
cron_service: Any | None = None,
webui_runtime_model_name: Callable[[], str | None] | None = None, webui_runtime_model_name: Callable[[], str | None] | None = None,
webui_static_dist: bool = True, webui_static_dist: bool = True,
webui_runtime_surface: str = "browser", webui_runtime_surface: str = "browser",
@@ -64,6 +65,7 @@ class ChannelManager:
self.config = config self.config = config
self.bus = bus self.bus = bus
self._session_manager = session_manager self._session_manager = session_manager
self._cron_service = cron_service
self._webui_runtime_model_name = webui_runtime_model_name self._webui_runtime_model_name = webui_runtime_model_name
self._webui_static_dist = webui_static_dist self._webui_static_dist = webui_static_dist
self._webui_runtime_surface = webui_runtime_surface self._webui_runtime_surface = webui_runtime_surface
@@ -124,9 +126,11 @@ class ChannelManager:
static_dist_path=static_path, static_dist_path=static_path,
workspace_path=workspace, workspace_path=workspace,
default_restrict_to_workspace=self.config.tools.restrict_to_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_model_name=self._webui_runtime_model_name,
runtime_surface=self._webui_runtime_surface, runtime_surface=self._webui_runtime_surface,
runtime_capabilities_overrides=self._webui_runtime_capabilities, runtime_capabilities_overrides=self._webui_runtime_capabilities,
cron_service=self._cron_service,
logger=logger, logger=logger,
) )
kwargs["gateway"] = gateway kwargs["gateway"] = gateway
+44 -87
View File
@@ -45,7 +45,6 @@ from nanobot.webui.http_utils import (
query_first as _query_first, query_first as _query_first,
) )
from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions 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 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 _UPLOAD_MIME_ALLOWED: frozenset[str] = _IMAGE_MIME_ALLOWED | _VIDEO_MIME_ALLOWED
_DATA_URL_MIME_RE = re.compile(r"^data:([^;]+);base64,", re.DOTALL) _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: def _extract_data_url_mime(url: str) -> str | None:
@@ -262,14 +259,6 @@ def _is_websocket_upgrade(request: WsRequest) -> bool:
return True 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): class WebSocketChannel(BaseChannel):
"""Run a local WebSocket server; forward text/JSON messages to the message bus.""" """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._http_router = gateway.http
self._tokens = gateway.tokens self._tokens = gateway.tokens
self._media = gateway.media self._media = gateway.media
self._transcripts = gateway.transcripts
self._workspaces = gateway.workspaces self._workspaces = gateway.workspaces
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {} self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
self._webui_turn_sequences: dict[tuple[str, str], int] = {}
# -- Subscription bookkeeping ------------------------------------------- # -- Subscription bookkeeping -------------------------------------------
@@ -762,9 +751,9 @@ class WebSocketChannel(BaseChannel):
self._attach(connection, cid) self._attach(connection, cid)
await self._hydrate_after_subscribe(cid) await self._hydrate_after_subscribe(cid)
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)} 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: if envelope.get("webui") is True:
metadata["webui"] = 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")) cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps"))
if cli_apps: if cli_apps:
metadata["cli_apps"] = 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, "aspect_ratio": aspect_ratio if isinstance(aspect_ratio, str) else None,
} }
if metadata.get("webui") is True and self.is_allowed(client_id): if metadata.get("webui") is True and self.is_allowed(client_id):
self._try_append_webui_user_transcript( self._transcripts.append_user_message(
cid, cid,
content, content,
metadata=metadata, metadata=metadata,
media_paths=media_paths, media_paths=media_paths or None,
cli_apps=cli_apps, cli_apps=cli_apps or None,
mcp_presets=mcp_presets, mcp_presets=mcp_presets or None,
) )
await self._handle_message( await self._handle_message(
sender_id=client_id, sender_id=client_id,
@@ -849,59 +838,6 @@ class WebSocketChannel(BaseChannel):
self.logger.exception("send failed{}", label) self.logger.exception("send failed{}", label)
raise 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: async def send(self, msg: OutboundMessage) -> None:
if msg.metadata.get("_runtime_model_updated"): if msg.metadata.get("_runtime_model_updated"):
await self.send_runtime_model_updated( await self.send_runtime_model_updated(
@@ -1001,10 +937,14 @@ class WebSocketChannel(BaseChannel):
elif msg.metadata.get("_progress"): elif msg.metadata.get("_progress"):
payload["kind"] = "progress" payload["kind"] = "progress"
phase = "activity" if payload.get("kind") in ("tool_hint", "progress") else "answer" phase = "activity" if payload.get("kind") in ("tool_hint", "progress") else "answer"
self._annotate_webui_turn(payload, msg.chat_id, msg.metadata, phase) self._transcripts.prepare_and_append(
transcript_payload = dict(payload) msg.chat_id,
transcript_payload["text"] = text payload,
self._try_append_webui_transcript(msg.chat_id, transcript_payload) metadata=msg.metadata,
phase=phase,
include_source=True,
transcript_overrides={"text": text},
)
raw = json.dumps(payload, ensure_ascii=False) raw = json.dumps(payload, ensure_ascii=False)
for connection in conns: for connection in conns:
await self._safe_send_to(connection, raw, label=" ") await self._safe_send_to(connection, raw, label=" ")
@@ -1032,8 +972,12 @@ class WebSocketChannel(BaseChannel):
stream_id = meta.get("_stream_id") stream_id = meta.get("_stream_id")
if stream_id is not None: if stream_id is not None:
body["stream_id"] = stream_id body["stream_id"] = stream_id
self._annotate_webui_turn(body, chat_id, meta, "reasoning") self._transcripts.prepare_and_append(
self._try_append_webui_transcript(chat_id, body) chat_id,
body,
metadata=meta,
phase="reasoning",
)
raw = json.dumps(body, ensure_ascii=False) raw = json.dumps(body, ensure_ascii=False)
for connection in conns: for connection in conns:
await self._safe_send_to(connection, raw, label=" reasoning ") await self._safe_send_to(connection, raw, label=" reasoning ")
@@ -1055,8 +999,12 @@ class WebSocketChannel(BaseChannel):
stream_id = meta.get("_stream_id") stream_id = meta.get("_stream_id")
if stream_id is not None: if stream_id is not None:
body["stream_id"] = stream_id body["stream_id"] = stream_id
self._annotate_webui_turn(body, chat_id, meta, "reasoning") self._transcripts.prepare_and_append(
self._try_append_webui_transcript(chat_id, body) chat_id,
body,
metadata=meta,
phase="reasoning",
)
raw = json.dumps(body, ensure_ascii=False) raw = json.dumps(body, ensure_ascii=False)
for connection in conns: for connection in conns:
await self._safe_send_to(connection, raw, label=" reasoning_end ") await self._safe_send_to(connection, raw, label=" reasoning_end ")
@@ -1075,8 +1023,12 @@ class WebSocketChannel(BaseChannel):
"chat_id": chat_id, "chat_id": chat_id,
"edits": edits, "edits": edits,
} }
self._annotate_webui_turn(payload, chat_id, metadata, "activity") self._transcripts.prepare_and_append(
self._try_append_webui_transcript(chat_id, payload) chat_id,
payload,
metadata=metadata,
phase="activity",
)
raw = json.dumps(payload, ensure_ascii=False) raw = json.dumps(payload, ensure_ascii=False)
for connection in conns: for connection in conns:
await self._safe_send_to(connection, raw, label=" file_edit ") 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) self._stream_text_buffers.setdefault(stream_key, []).append(delta)
if meta.get("_stream_id") is not None: if meta.get("_stream_id") is not None:
body["stream_id"] = meta["_stream_id"] body["stream_id"] = meta["_stream_id"]
self._annotate_webui_turn(body, chat_id, meta, "answer") self._transcripts.prepare_and_append(
self._try_append_webui_transcript(chat_id, body) chat_id,
body,
metadata=meta,
phase="answer",
)
raw = json.dumps(body, ensure_ascii=False) raw = json.dumps(body, ensure_ascii=False)
for connection in conns: for connection in conns:
await self._safe_send_to(connection, raw, label=" stream ") await self._safe_send_to(connection, raw, label=" stream ")
@@ -1133,14 +1089,15 @@ class WebSocketChannel(BaseChannel):
body["latency_ms"] = int(latency_ms) body["latency_ms"] = int(latency_ms)
if goal_state is not None: if goal_state is not None:
body["goal_state"] = goal_state body["goal_state"] = goal_state
self._annotate_webui_turn(body, chat_id, metadata, "complete") self._transcripts.prepare_and_append(
self._try_append_webui_transcript(chat_id, body) chat_id,
body,
metadata=metadata,
phase="complete",
)
raw = json.dumps(body, ensure_ascii=False) raw = json.dumps(body, ensure_ascii=False)
for connection in conns: for connection in conns:
await self._safe_send_to(connection, raw, label=" turn_end ") 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: 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).""" """Push persisted goal-state snapshot for *chat_id* (multi-chat isolation)."""
+57 -1
View File
@@ -5,8 +5,10 @@ import os
import select import select
import signal import signal
import sys import sys
import uuid
from collections.abc import Callable from collections.abc import Callable
from contextlib import nullcontext, suppress from contextlib import nullcontext, suppress
from contextvars import ContextVar
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -83,6 +85,34 @@ class SafeFileHistory(FileHistory):
def store_string(self, string: str) -> None: def store_string(self, string: str) -> None:
super().store_string(_sanitize_surrogates(string)) 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( app = typer.Typer(
name="nanobot", name="nanobot",
context_settings={"help_option_names": ["-h", "--help"]}, 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.""" """Publish a user-visible message and mirror it into that channel's session."""
metadata = dict(msg.metadata or {}) metadata = dict(msg.metadata or {})
record = record or bool(metadata.pop("_record_channel_delivery", False)) 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 {}): if metadata != (msg.metadata or {}):
msg = OutboundMessage( msg = OutboundMessage(
channel=msg.channel, channel=msg.channel,
@@ -1081,6 +1114,13 @@ def _run_gateway(
except Exception: except Exception:
logger.exception("Dream cron job failed") logger.exception("Dream cron job failed")
finally: 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(): if store.git.is_initialized():
msg = build_dream_commit_message( msg = build_dream_commit_message(
"dream: periodic memory consolidation", resp, "dream: periodic memory consolidation", resp,
@@ -1171,6 +1211,14 @@ def _run_gateway(
if isinstance(message_tool, MessageTool): if isinstance(message_tool, MessageTool):
message_record_token = message_tool.set_record_channel_delivery(True) 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: try:
resp = await agent.process_direct( resp = await agent.process_direct(
reminder_note, reminder_note,
@@ -1180,6 +1228,7 @@ def _run_gateway(
on_progress=_silent, on_progress=_silent,
) )
finally: finally:
_PROACTIVE_WEBUI_METADATA.reset(proactive_token)
if isinstance(cron_tool, CronTool) and cron_token is not None: if isinstance(cron_tool, CronTool) and cron_token is not None:
cron_tool.reset_cron_context(cron_token) cron_tool.reset_cron_context(cron_token)
if isinstance(message_tool, MessageTool) and message_record_token is not None: 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, response, reminder_note, agent.provider, agent.model,
) )
if should_notify: 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( await _deliver_to_channel(
OutboundMessage( OutboundMessage(
channel=job.payload.channel or "cli", channel=job.payload.channel or "cli",
chat_id=job.payload.to, chat_id=job.payload.to,
content=response, content=response,
metadata=dict(job.payload.channel_meta), metadata=proactive_metadata,
), ),
record=True, record=True,
session_key=job.payload.session_key, session_key=job.payload.session_key,
@@ -1222,6 +1277,7 @@ def _run_gateway(
config, config,
bus, bus,
session_manager=session_manager, session_manager=session_manager,
cron_service=cron,
webui_runtime_model_name=_webui_runtime_model_name, webui_runtime_model_name=_webui_runtime_model_name,
webui_static_dist=webui_static_dist, webui_static_dist=webui_static_dist,
webui_runtime_surface=webui_runtime_surface, webui_runtime_surface=webui_runtime_surface,
+7
View File
@@ -350,6 +350,13 @@ async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
elapsed = time.monotonic() - t0 elapsed = time.monotonic() - t0
content = f"Dream failed after {elapsed:.1f}s: {e}" content = f"Dream failed after {elapsed:.1f}s: {e}"
finally: 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(): if store.git.is_initialized():
commit_msg = build_dream_commit_message("dream: manual run", resp) commit_msg = build_dream_commit_message("dream: manual run", resp)
sha = store.git.auto_commit(commit_msg) sha = store.git.auto_commit(commit_msg)
+11
View File
@@ -10,6 +10,7 @@ from loguru import logger as default_logger
from nanobot.webui.gateway_tokens import GatewayTokenStore from nanobot.webui.gateway_tokens import GatewayTokenStore
from nanobot.webui.media_gateway import WebUIMediaGateway from nanobot.webui.media_gateway import WebUIMediaGateway
from nanobot.webui.transcript import WebUITranscriptRecorder
from nanobot.webui.workspaces import WebUIWorkspaceController from nanobot.webui.workspaces import WebUIWorkspaceController
from nanobot.webui.ws_http import GatewayHTTPHandler from nanobot.webui.ws_http import GatewayHTTPHandler
@@ -21,8 +22,10 @@ class GatewayServices:
http: GatewayHTTPHandler http: GatewayHTTPHandler
tokens: GatewayTokenStore tokens: GatewayTokenStore
media: WebUIMediaGateway media: WebUIMediaGateway
transcripts: WebUITranscriptRecorder
workspaces: WebUIWorkspaceController workspaces: WebUIWorkspaceController
session_manager: Any | None session_manager: Any | None
cron_service: Any | None
def build_gateway_services( def build_gateway_services(
@@ -36,6 +39,8 @@ def build_gateway_services(
runtime_model_name: Any | None, runtime_model_name: Any | None,
runtime_surface: str, runtime_surface: str,
runtime_capabilities_overrides: dict[str, Any] | None, runtime_capabilities_overrides: dict[str, Any] | None,
disabled_skills: set[str] | None = None,
cron_service: Any | None = None,
logger: Any = default_logger, logger: Any = default_logger,
) -> GatewayServices: ) -> GatewayServices:
tokens = GatewayTokenStore() tokens = GatewayTokenStore()
@@ -43,6 +48,7 @@ def build_gateway_services(
workspace_path=workspace_path, workspace_path=workspace_path,
logger=logger, logger=logger,
) )
transcripts = WebUITranscriptRecorder(log=logger)
workspaces = WebUIWorkspaceController( workspaces = WebUIWorkspaceController(
session_manager=session_manager, session_manager=session_manager,
default_workspace=workspace_path, default_workspace=workspace_path,
@@ -59,12 +65,17 @@ def build_gateway_services(
tokens=tokens, tokens=tokens,
media=media, media=media,
workspaces=workspaces, workspaces=workspaces,
skills_workspace_path=workspace_path,
disabled_skills=disabled_skills,
cron_service=cron_service,
log=logger, log=logger,
) )
return GatewayServices( return GatewayServices(
http=http, http=http,
tokens=tokens, tokens=tokens,
media=media, media=media,
transcripts=transcripts,
workspaces=workspaces, workspaces=workspaces,
session_manager=session_manager, session_manager=session_manager,
cron_service=cron_service,
) )
+56
View File
@@ -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,
},
}
+61
View File
@@ -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
+30 -4
View File
@@ -75,6 +75,19 @@ def _clean_source(value: str | None) -> str:
return value if value in _SOURCE_KEYS else "system" 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]: def _normalize_usage(raw: dict[str, Any] | None) -> dict[str, int]:
if not isinstance(raw, dict): if not isinstance(raw, dict):
return {} return {}
@@ -249,6 +262,22 @@ def record_token_usage(
return write_token_usage_state(state) 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( def token_usage_payload(
*, *,
days: int = 371, days: int = 371,
@@ -317,14 +346,11 @@ class TokenUsageHook(AgentHook):
super().__init__() super().__init__()
self._timezone_name = timezone_name self._timezone_name = timezone_name
def include_ephemeral(self) -> bool:
return True
async def after_iteration(self, context: AgentHookContext) -> None: async def after_iteration(self, context: AgentHookContext) -> None:
try: try:
record_token_usage( record_token_usage(
context.usage, context.usage,
source=context.usage_source, source=_source_from_session_key(context.session_key),
timezone_name=self._timezone_name, timezone_name=self._timezone_name,
) )
except Exception: except Exception:
+147
View File
@@ -18,6 +18,9 @@ from nanobot.session.manager import SessionManager
WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3 WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3
_MAX_TRANSCRIPT_FILE_BYTES = 8 * 1024 * 1024 _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( _MARKDOWN_LOCAL_IMAGE_RE = re.compile(
r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)" r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)"
) )
@@ -152,6 +155,125 @@ def append_transcript_object(session_key: str, obj: dict[str, Any]) -> None:
os.fsync(f.fileno()) 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: def delete_webui_transcript(session_key: str) -> bool:
path = webui_transcript_path(session_key) path = webui_transcript_path(session_key)
if not path.is_file(): if not path.is_file():
@@ -560,6 +682,8 @@ def replay_transcript_to_ui_messages(
active_file_edit_segment_id: str | None = None active_file_edit_segment_id: str | None = None
activity_segment_counter = 0 activity_segment_counter = 0
_ts_base = int(time.time() * 1000) _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: def _new_id(prefix: str, idx: int) -> str:
return f"{prefix}-{idx}-{uuid.uuid4().hex[:8]}" return f"{prefix}-{idx}-{uuid.uuid4().hex[:8]}"
@@ -576,6 +700,12 @@ def replay_transcript_to_ui_messages(
fields: dict[str, Any] = {} fields: dict[str, Any] = {}
turn_id = rec.get("turn_id") turn_id = rec.get("turn_id")
if isinstance(turn_id, str) and turn_id: if isinstance(turn_id, str) and 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 fields["turnId"] = turn_id
phase = rec.get("turn_phase") phase = rec.get("turn_phase")
if isinstance(phase, str) and phase: if isinstance(phase, str) and phase:
@@ -587,6 +717,16 @@ def replay_transcript_to_ui_messages(
fields["turnSeq"] = int(seq) fields["turnSeq"] = int(seq)
return fields 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: def _same_turn(message: dict[str, Any], turn_fields: dict[str, Any]) -> bool:
turn_id = turn_fields.get("turnId") turn_id = turn_fields.get("turnId")
message_turn_id = message.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: if isinstance(lat, (int, float)) and lat >= 0:
extra["latencyMs"] = int(lat) extra["latencyMs"] = int(lat)
extra.update(_turn_fields(rec, "answer")) extra.update(_turn_fields(rec, "answer"))
extra.update(_source_fields(rec))
absorb_complete(extra, idx) absorb_complete(extra, idx)
if media: if media:
suppress_until_turn_end = True suppress_until_turn_end = True
@@ -1107,6 +1248,12 @@ def replay_transcript_to_ui_messages(
suppress_until_turn_end = False suppress_until_turn_end = False
active_activity_segment_id = None active_activity_segment_id = None
active_file_edit_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): for i, m in enumerate(messages):
if m.get("isStreaming"): if m.get("isStreaming"):
messages[i] = {**m, "isStreaming": False} messages[i] = {**m, "isStreaming": False}
+59 -2
View File
@@ -61,16 +61,19 @@ from nanobot.webui.http_utils import (
safe_host_header as _safe_host_header, safe_host_header as _safe_host_header,
) )
from nanobot.webui.media_gateway import WebUIMediaGateway from nanobot.webui.media_gateway import WebUIMediaGateway
from nanobot.webui.session_automations import session_automations_payload
from nanobot.webui.sidebar_state import ( from nanobot.webui.sidebar_state import (
read_webui_sidebar_state, read_webui_sidebar_state,
write_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.thread_disk import delete_webui_thread
from nanobot.webui.transcript import build_webui_thread_response from nanobot.webui.transcript import build_webui_thread_response
from nanobot.webui.workspaces import WebUIWorkspaceController from nanobot.webui.workspaces import WebUIWorkspaceController
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.cron.service import CronService
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
@@ -96,7 +99,7 @@ def _default_model_name_from_config() -> str | None:
def _resolve_bootstrap_model_name( def _resolve_bootstrap_model_name(
runtime_name: Callable[[], str | None] | None, runtime_name: Callable[[], str | None] | None,
) -> str | None: ) -> str:
if runtime_name is not None: if runtime_name is not None:
try: try:
raw = runtime_name() raw = runtime_name()
@@ -107,7 +110,7 @@ def _resolve_bootstrap_model_name(
stripped = raw.strip() stripped = raw.strip()
if stripped: if stripped:
return stripped return stripped
return _default_model_name_from_config() return _default_model_name_from_config() or ""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -135,6 +138,9 @@ class GatewayHTTPHandler:
tokens: GatewayTokenStore, tokens: GatewayTokenStore,
media: WebUIMediaGateway, media: WebUIMediaGateway,
workspaces: WebUIWorkspaceController, workspaces: WebUIWorkspaceController,
skills_workspace_path: Path,
disabled_skills: set[str] | None = None,
cron_service: CronService | None = None,
log: Any = logger, log: Any = logger,
) -> None: ) -> None:
self.config = config self.config = config
@@ -145,6 +151,9 @@ class GatewayHTTPHandler:
self.tokens = tokens self.tokens = tokens
self.media = media self.media = media
self.workspaces = workspaces 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._log = log
self._runtime_surface = runtime_surface self._runtime_surface = runtime_surface
@@ -299,6 +308,10 @@ class GatewayHTTPHandler:
if m: if m:
return self._handle_file_preview(request, m.group(1)) 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) m = re.match(r"^/api/sessions/([^/]+)/delete$", got)
if m: if m:
return self._handle_session_delete(request, m.group(1)) return self._handle_session_delete(request, m.group(1))
@@ -395,6 +408,18 @@ class GatewayHTTPHandler:
return _http_error(e.status, e.message) return _http_error(e.status, e.message)
return _http_json_response(payload) 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: def _handle_session_delete(self, request: WsRequest, key: str) -> Response:
if not self.check_api_token(request): if not self.check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
@@ -437,6 +462,11 @@ class GatewayHTTPHandler:
return self._handle_commands(request) return self._handle_commands(request)
if got == "/api/workspaces": if got == "/api/workspaces":
return self._handle_workspaces(connection, request) 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": if got == "/api/webui/sidebar-state":
return self._handle_webui_sidebar_state(request) return self._handle_webui_sidebar_state(request)
if got == "/api/webui/sidebar-state/update": 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: def _handle_webui_sidebar_state(self, request: WsRequest) -> Response:
if not self.check_api_token(request): if not self.check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
-12
View File
@@ -356,18 +356,6 @@ class TestEphemeralHooks:
await loop.process_direct("test", session_key="cli:normal") await loop.process_direct("test", session_key="cli:normal")
spy.before_iteration.assert_called() 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: class TestDreamCommitMessage:
async def test_commit_includes_response_summary(self, tmp_path): async def test_commit_includes_response_summary(self, tmp_path):
"""Git auto-commit after Dream should include the LLM response in the body.""" """Git auto-commit after Dream should include the LLM response in the body."""
+160 -1
View File
@@ -12,6 +12,8 @@ import httpx
import pytest import pytest
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig 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.session.manager import Session, SessionManager
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
@@ -24,10 +26,12 @@ def _make_handler(
*, *,
session_manager: SessionManager | None = None, session_manager: SessionManager | None = None,
static_dist_path: Path | None = None, static_dist_path: Path | None = None,
workspace_path: Path | None = None,
runtime_model_name: Any | None = None, runtime_model_name: Any | None = None,
cron_service: CronService | None = None,
) -> GatewayServices: ) -> GatewayServices:
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
workspace = Path.cwd() workspace = workspace_path or Path.cwd()
return build_gateway_services( return build_gateway_services(
config=config, config=config,
bus=bus, bus=bus,
@@ -38,6 +42,7 @@ def _make_handler(
runtime_model_name=runtime_model_name, runtime_model_name=runtime_model_name,
runtime_surface="browser", runtime_surface="browser",
runtime_capabilities_overrides=None, runtime_capabilities_overrides=None,
cron_service=cron_service,
) )
@@ -46,8 +51,10 @@ def _ch(
*, *,
session_manager: SessionManager | None = None, session_manager: SessionManager | None = None,
static_dist_path: Path | None = None, static_dist_path: Path | None = None,
workspace_path: Path | None = None,
port: int = _PORT, port: int = _PORT,
runtime_model_name: Any | None = None, runtime_model_name: Any | None = None,
cron_service: CronService | None = None,
**extra: Any, **extra: Any,
) -> WebSocketChannel: ) -> WebSocketChannel:
cfg: dict[str, Any] = { cfg: dict[str, Any] = {
@@ -63,7 +70,9 @@ def _ch(
cfg, bus, cfg, bus,
session_manager=session_manager, session_manager=session_manager,
static_dist_path=static_dist_path, static_dist_path=static_dist_path,
workspace_path=workspace_path,
runtime_model_name=runtime_model_name, runtime_model_name=runtime_model_name,
cron_service=cron_service,
) )
return WebSocketChannel(cfg, bus, gateway=gateway) return WebSocketChannel(cfg, bus, gateway=gateway)
@@ -161,6 +170,156 @@ async def test_sessions_routes_require_bearer_token(
await server_task 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 @pytest.mark.asyncio
async def test_cli_apps_routes_require_token_and_return_payload( async def test_cli_apps_routes_require_token_and_return_payload(
bus: MagicMock, bus: MagicMock,
+57 -1
View File
@@ -9,7 +9,7 @@ import pytest
from typer.testing import CliRunner from typer.testing import CliRunner
from nanobot.bus.events import OutboundMessage 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.config.schema import Config
from nanobot.cron.types import CronJob, CronPayload from nanobot.cron.types import CronJob, CronPayload
from nanobot.providers.factory import ProviderSnapshot, make_provider from nanobot.providers.factory import ProviderSnapshot, make_provider
@@ -19,6 +19,27 @@ from nanobot.providers.registry import find_by_name
runner = CliRunner() 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(): def _fake_provider():
"""Return a minimal fake provider that satisfies AgentLoop.__init__.""" """Return a minimal fake provider that satisfies AgentLoop.__init__."""
p = MagicMock() 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( def test_gateway_cron_job_suppresses_intermediate_progress(
monkeypatch, tmp_path: Path monkeypatch, tmp_path: Path
+49
View File
@@ -92,6 +92,55 @@ def test_replay_preserves_turn_metadata(tmp_path, monkeypatch) -> None:
assert msgs[1]["turnSeq"] == 3 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( def test_build_response_restores_session_users_for_legacy_transcript(
tmp_path, tmp_path,
monkeypatch, monkeypatch,
+39
View File
@@ -1,8 +1,14 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from types import SimpleNamespace
import pytest
from nanobot.agent.hook import AgentHookContext
from nanobot.webui.token_usage import ( from nanobot.webui.token_usage import (
TokenUsageHook,
record_response_token_usage,
record_token_usage, record_token_usage,
token_usage_payload, 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"]["user"]["requests"] == 1
assert row["sources"]["dream"]["total_tokens"] == 25 assert row["sources"]["dream"]["total_tokens"] == 25
assert row["sources"]["dream"]["requests"] == 1 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
+33 -4
View File
@@ -12,6 +12,7 @@ import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { useSessions } from "@/hooks/useSessions"; import { useSessions } from "@/hooks/useSessions";
import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh"; import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh";
import { useSidebarState } from "@/hooks/useSidebarState"; import { useSidebarState } from "@/hooks/useSidebarState";
import { useSkills } from "@/hooks/useSkills";
import { ThemeProvider, useTheme } from "@/hooks/useTheme"; import { ThemeProvider, useTheme } from "@/hooks/useTheme";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@@ -60,7 +61,7 @@ const SIDEBAR_WIDTH = 272;
const SIDEBAR_RAIL_WIDTH = 56; const SIDEBAR_RAIL_WIDTH = 56;
const TOKEN_REFRESH_MARGIN_MS = 30_000; const TOKEN_REFRESH_MARGIN_MS = 30_000;
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000; const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
type ShellView = "chat" | "settings" | "apps"; type ShellView = "chat" | "settings" | "apps" | "skills";
type ShellRoute = { type ShellRoute = {
view: ShellView; view: ShellView;
activeKey: string | null; activeKey: string | null;
@@ -74,6 +75,7 @@ const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [
"image", "image",
"browser", "browser",
"apps", "apps",
"skills",
"runtime", "runtime",
"advanced", "advanced",
]; ];
@@ -86,6 +88,11 @@ function defaultShellRoute(): ShellRoute {
return { view: "chat", activeKey: null, settingsSection: "overview" }; return { view: "chat", activeKey: null, settingsSection: "overview" };
} }
function shellViewForSettingsSection(section: SettingsSectionKey): ShellView {
if (section === "apps" || section === "skills") return section;
return "settings";
}
function readShellRoute(): ShellRoute { function readShellRoute(): ShellRoute {
if (typeof window === "undefined") return defaultShellRoute(); if (typeof window === "undefined") return defaultShellRoute();
const hash = window.location.hash.startsWith("#") const hash = window.location.hash.startsWith("#")
@@ -102,11 +109,18 @@ function readShellRoute(): ShellRoute {
const activeKey = params.get("chat")?.trim() || null; const activeKey = params.get("chat")?.trim() || null;
if (path === "/settings") { if (path === "/settings") {
return { view: "settings", activeKey, settingsSection }; return {
view: shellViewForSettingsSection(settingsSection),
activeKey,
settingsSection,
};
} }
if (path === "/apps") { if (path === "/apps") {
return { view: "apps", activeKey, settingsSection: "apps" }; return { view: "apps", activeKey, settingsSection: "apps" };
} }
if (path === "/skills") {
return { view: "skills", activeKey, settingsSection: "skills" };
}
if (path.startsWith("/chat/")) { if (path.startsWith("/chat/")) {
const encoded = path.slice("/chat/".length); const encoded = path.slice("/chat/".length);
try { try {
@@ -562,6 +576,7 @@ function Shell({
const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set()); const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set());
const [completedChatIds, setCompletedChatIds] = useState<Set<string>>(readCompletedRunChatIds); const [completedChatIds, setCompletedChatIds] = useState<Set<string>>(readCompletedRunChatIds);
const [workspaces, setWorkspaces] = useState<WorkspacesPayload | null>(null); const [workspaces, setWorkspaces] = useState<WorkspacesPayload | null>(null);
const skills = useSkills(token);
const [settingsSnapshot, setSettingsSnapshot] = useState<SettingsPayload | null>(null); const [settingsSnapshot, setSettingsSnapshot] = useState<SettingsPayload | null>(null);
const [workspaceError, setWorkspaceError] = useState<string | null>(null); const [workspaceError, setWorkspaceError] = useState<string | null>(null);
const [draftWorkspaceScope, setDraftWorkspaceScope] = const [draftWorkspaceScope, setDraftWorkspaceScope] =
@@ -1128,10 +1143,16 @@ function Shell({
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
}, [activeKey, navigate]); }, [activeKey, navigate]);
const onOpenSkills = useCallback(() => {
setSessionSearchOpen(false);
navigate({ view: "skills", activeKey, settingsSection: "skills" });
setMobileSidebarOpen(false);
}, [activeKey, navigate]);
const onSettingsSectionChange = useCallback( const onSettingsSectionChange = useCallback(
(section: SettingsSectionKey) => { (section: SettingsSectionKey) => {
navigate({ navigate({
view: section === "apps" ? "apps" : "settings", view: shellViewForSettingsSection(section),
activeKey, activeKey,
settingsSection: section, settingsSection: section,
}); });
@@ -1283,6 +1304,12 @@ function Shell({
}); });
return; return;
} }
if (view === "skills") {
document.title = t("app.documentTitle.chat", {
title: t("settings.nav.skills", { defaultValue: "Skills" }),
});
return;
}
document.title = activeSession document.title = activeSession
? t("app.documentTitle.chat", { title: headerTitle }) ? t("app.documentTitle.chat", { title: headerTitle })
: t("app.documentTitle.base"); : t("app.documentTitle.base");
@@ -1304,8 +1331,9 @@ function Shell({
onNewChatInProject, onNewChatInProject,
onOpenSettings, onOpenSettings,
onOpenApps, onOpenApps,
onOpenSkills,
onOpenSearch: onOpenSessionSearch, onOpenSearch: onOpenSessionSearch,
activeUtility: view === "apps" ? "apps" as const : null, activeUtility: view === "apps" || view === "skills" ? view : null,
onToggleArchived, onToggleArchived,
pinnedKeys: sidebarState.pinned_keys, pinnedKeys: sidebarState.pinned_keys,
archivedKeys: sidebarState.archived_keys, archivedKeys: sidebarState.archived_keys,
@@ -1486,6 +1514,7 @@ function Shell({
onBackToChat={onBackToChat} onBackToChat={onBackToChat}
onModelNameChange={onModelNameChange} onModelNameChange={onModelNameChange}
onSettingsChange={setSettingsSnapshot} onSettingsChange={setSettingsSnapshot}
skills={skills}
onWorkspaceSettingsChange={refreshWorkspaces} onWorkspaceSettingsChange={refreshWorkspaces}
onSectionChange={onSettingsSectionChange} onSectionChange={onSettingsSectionChange}
onLogout={onLogout} onLogout={onLogout}
+41 -23
View File
@@ -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 type { Components, Options as ReactMarkdownOptions } from "react-markdown";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex"; import rehypeKatex from "rehype-katex";
import { Check } from "lucide-react"; import { Check, Globe2 } from "lucide-react";
import remarkBreaks from "remark-breaks"; import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import remarkMath from "remark-math"; import remarkMath from "remark-math";
@@ -11,6 +19,7 @@ import { AttachmentTile } from "@/components/AttachmentTile";
import { CodeBlock } from "@/components/CodeBlock"; import { CodeBlock } from "@/components/CodeBlock";
import { FileReferenceChip, isLikelyFilePath } from "@/components/FileReferenceChip"; import { FileReferenceChip, isLikelyFilePath } from "@/components/FileReferenceChip";
import { inferMediaKind } from "@/lib/media"; import { inferMediaKind } from "@/lib/media";
import { faviconUrls } from "@/lib/provider-brand";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import "katex/dist/katex.min.css"; import "katex/dist/katex.min.css";
@@ -33,10 +42,9 @@ type MarkdownAstNode = {
type InlineLinkPreview = { type InlineLinkPreview = {
href: string; href: string;
origin: string; host: string;
prefix?: string; prefix?: string;
title: string; title: string;
initials: string;
}; };
const SAFE_INLINE_HTML_TAGS = new Set(["mark", "sub", "sup"]); const SAFE_INLINE_HTML_TAGS = new Set(["mark", "sub", "sup"]);
@@ -249,16 +257,6 @@ function cleanLinkPreviewText(value: string): string {
.trim(); .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 { function inlineLinkPreviewFromChildren(children: ReactNode): InlineLinkPreview | null {
const { text: rawText, href } = linkPreviewParts(children); const { text: rawText, href } = linkPreviewParts(children);
if (!href) return null; if (!href) return null;
@@ -286,17 +284,18 @@ function inlineLinkPreviewFromChildren(children: ReactNode): InlineLinkPreview |
return { return {
href, href,
origin: url.origin, host: url.hostname,
prefix, prefix,
title, title,
initials: linkPreviewInitials(prefix || url.hostname),
}; };
} }
function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) { function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
const { favicon, onFaviconError } = useFaviconFallback(link.host);
const label = link.prefix const label = link.prefix
? `${link.prefix}${link.title}` ? `${link.prefix}${link.title}`
: link.title; : link.title;
return ( return (
<a <a
href={link.href} href={link.href}
@@ -311,20 +310,21 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
<span <span
className={cn( className={cn(
"relative grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px]", "relative grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px]",
"border border-border/65 bg-background text-[0.5rem] font-semibold text-muted-foreground", "border border-border/65 bg-background text-muted-foreground",
)} )}
aria-hidden aria-hidden
> >
{link.initials} {favicon ? (
<img <img
src={`${link.origin}/favicon.ico`} src={favicon}
alt="" alt=""
className="absolute h-3 w-3 rounded-[2px] object-contain" className="h-3 w-3 rounded-[2px] object-contain"
loading="lazy" loading="lazy"
onError={(event) => { onError={onFaviconError}
event.currentTarget.style.display = "none";
}}
/> />
) : (
<Globe2 className="h-3 w-3" />
)}
</span> </span>
<span className="min-w-0 truncate leading-normal"> <span className="min-w-0 truncate leading-normal">
{label} {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 { function isRenderedCodeBlock(value: ReactNode): boolean {
if (!isValidElement(value)) return false; if (!isValidElement(value)) return false;
const props = value.props as { code?: unknown }; const props = value.props as { code?: unknown };
+30 -1
View File
@@ -6,7 +6,7 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } 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 { useTranslation } from "react-i18next";
import { AttachmentTile } from "@/components/AttachmentTile"; import { AttachmentTile } from "@/components/AttachmentTile";
@@ -131,6 +131,10 @@ export function MessageBubble({
const reasoning = message.role === "assistant" ? message.reasoning ?? "" : ""; const reasoning = message.role === "assistant" ? message.reasoning ?? "" : "";
const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming); const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming);
const hasReasoning = reasoning.length > 0 || 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 showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
const showCopyButton = showAssistantCopyAction && showAssistantActions; const showCopyButton = showAssistantCopyAction && showAssistantActions;
@@ -155,6 +159,12 @@ export function MessageBubble({
<TypingDots /> <TypingDots />
) : empty && message.isStreaming ? null : ( ) : empty && message.isStreaming ? null : (
<> <>
{automationSourceLabel ? (
<AutomationSourceBadge
label={automationSourceLabel}
triggerLabel={automationTriggeredLabel}
/>
) : null}
<MarkdownText <MarkdownText
streaming={!!message.isStreaming} streaming={!!message.isStreaming}
onOpenFilePreview={onOpenFilePreview} onOpenFilePreview={onOpenFilePreview}
@@ -199,6 +209,25 @@ export function MessageBubble({
); );
} }
function AutomationSourceBadge({ label, triggerLabel }: { label: string; triggerLabel: string }) {
return (
<div
className={cn(
"mb-2 inline-flex max-w-full items-center gap-1.5 rounded-full px-2 py-1",
"border border-sky-500/15 bg-sky-500/[0.06]",
"text-[11px] font-medium leading-none text-sky-700",
"dark:border-sky-300/15 dark:bg-sky-300/[0.08] dark:text-sky-200/80",
)}
title={triggerLabel}
>
<Clock3 className="h-3 w-3 shrink-0" aria-hidden />
<span className="min-w-0 truncate">{label}</span>
<span className="text-current/45" aria-hidden>·</span>
<span className="shrink-0">{triggerLabel}</span>
</div>
);
}
function mergeMcpMentionPresets( function mergeMcpMentionPresets(
presets: McpPresetInfo[], presets: McpPresetInfo[],
attachments: UIMcpPresetAttachment[] | undefined, attachments: UIMcpPresetAttachment[] | undefined,
+10 -1
View File
@@ -1,6 +1,7 @@
import { useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { import {
Archive, Archive,
Brain,
Menu, Menu,
Search, Search,
Settings, Settings,
@@ -34,8 +35,9 @@ interface SidebarProps {
onNewChatInProject: (projectPath: string, projectName: string) => void; onNewChatInProject: (projectPath: string, projectName: string) => void;
onOpenSettings: () => void; onOpenSettings: () => void;
onOpenApps: () => void; onOpenApps: () => void;
onOpenSkills: () => void;
onOpenSearch: () => void; onOpenSearch: () => void;
activeUtility?: "apps" | null; activeUtility?: "apps" | "skills" | null;
onToggleArchived: () => void; onToggleArchived: () => void;
onCollapse: () => void; onCollapse: () => void;
onExpand?: () => void; onExpand?: () => void;
@@ -157,6 +159,13 @@ export function Sidebar(props: SidebarProps) {
active={props.activeUtility === "apps"} active={props.activeUtility === "apps"}
icon={<Blocks className="h-4 w-4" />} icon={<Blocks className="h-4 w-4" />}
/> />
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.skills.title")}
onClick={props.onOpenSkills}
active={props.activeUtility === "skills"}
icon={<Brain className="h-4 w-4" />}
/>
{props.archivedCount ? ( {props.archivedCount ? (
<SidebarActionButton <SidebarActionButton
collapsed={collapsed} collapsed={collapsed}
+26 -4
View File
@@ -53,6 +53,7 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@@ -118,6 +119,7 @@ import type {
NetworkSafetySettingsUpdate, NetworkSafetySettingsUpdate,
ProviderModelsPayload, ProviderModelsPayload,
SettingsPayload, SettingsPayload,
SkillSummary,
WebSearchSettingsUpdate, WebSearchSettingsUpdate,
WebuiDefaultAccessMode, WebuiDefaultAccessMode,
} from "@/lib/types"; } from "@/lib/types";
@@ -129,6 +131,7 @@ export type SettingsSectionKey =
| "image" | "image"
| "browser" | "browser"
| "apps" | "apps"
| "skills"
| "runtime" | "runtime"
| "advanced"; | "advanced";
@@ -279,6 +282,7 @@ interface SettingsViewProps {
onBackToChat: () => void; onBackToChat: () => void;
onModelNameChange: (modelName: string | null) => void; onModelNameChange: (modelName: string | null) => void;
onSettingsChange?: (payload: SettingsPayload) => void; onSettingsChange?: (payload: SettingsPayload) => void;
skills?: SkillSummary[];
onWorkspaceSettingsChange?: () => void | Promise<void>; onWorkspaceSettingsChange?: () => void | Promise<void>;
onSectionChange?: (section: SettingsSectionKey) => void; onSectionChange?: (section: SettingsSectionKey) => void;
onLogout?: () => void; onLogout?: () => void;
@@ -449,6 +453,7 @@ export function SettingsView({
onBackToChat, onBackToChat,
onModelNameChange, onModelNameChange,
onSettingsChange, onSettingsChange,
skills = [],
onWorkspaceSettingsChange, onWorkspaceSettingsChange,
onSectionChange, onSectionChange,
onLogout, onLogout,
@@ -1398,6 +1403,8 @@ export function SettingsView({
isRestarting={isRestarting || hostEngineApplying} isRestarting={isRestarting || hostEngineApplying}
/> />
); );
case "skills":
return <SkillsCatalogSettings skills={skills} />;
case "runtime": case "runtime":
return ( return (
<RuntimeSettings <RuntimeSettings
@@ -1462,6 +1469,16 @@ export function SettingsView({
)} )}
> >
<div className="mb-7"> <div className="mb-7">
{!showSidebar ? (
<button
type="button"
onClick={onBackToChat}
className="mb-4 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:hidden"
>
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
{t("settings.backToChat")}
</button>
) : null}
<p className="mb-2 text-[12px] font-normal text-muted-foreground"> <p className="mb-2 text-[12px] font-normal text-muted-foreground">
{t("settings.sidebar.title")} {t("settings.sidebar.title")}
</p> </p>
@@ -3170,9 +3187,11 @@ function AppsCatalogSettings({
const loading = (cliAppsLoading || mcpPresetsLoading) && !cliApps && !mcpPresets; const loading = (cliAppsLoading || mcpPresetsLoading) && !cliApps && !mcpPresets;
const statusMessage = cliError || mcpError || (!focusedApp ? cliMessage || mcpMessage : null); const statusMessage = cliError || mcpError || (!focusedApp ? cliMessage || mcpMessage : null);
const statusIsError = Boolean(cliError || mcpError); const statusIsError = Boolean(cliError || mcpError);
const caption = tx("settings.apps.caption", "{{cli}} CLI · {{mcp}} MCP") const caption = t("settings.apps.caption", {
.replace("{{cli}}", String(cliApps?.installed_count ?? 0)) cli: cliApps?.installed_count ?? 0,
.replace("{{mcp}}", String(mcpPresets?.installed_count ?? 0)); mcp: mcpPresets?.installed_count ?? 0,
defaultValue: "{{cli}} CLI · {{mcp}} MCP",
});
return ( return (
<div className="space-y-7"> <div className="space-y-7">
@@ -3554,7 +3573,10 @@ function McpAppsCatalogRow({
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-[12.5px] font-semibold text-foreground"> <div className="truncate text-[12.5px] font-semibold text-foreground">
{tx("settings.mcp.connectTitle", "Connect {{name}}").replace("{{name}}", preset.display_name)} {t("settings.mcp.connectTitle", {
name: preset.display_name,
defaultValue: "Connect {{name}}",
})}
</div> </div>
<p className="mt-0.5 text-[11.5px] text-muted-foreground"> <p className="mt-0.5 text-[11.5px] text-muted-foreground">
{tx("settings.mcp.connectHint", "Add the key from your account settings.")} {tx("settings.mcp.connectHint", "Add the key from your account settings.")}
@@ -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<SkillSummary | null>(null);
return (
<div className="space-y-7">
<section className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground">
{t("settings.skills.description", {
defaultValue: "Review the instruction skills this agent can load during a conversation.",
})}
</p>
<span className="text-[12px] font-medium text-muted-foreground">
{t("settings.skills.caption", {
available: availableCount,
total: skills.length,
defaultValue: "{{available}} available · {{total}} total",
})}
</span>
</section>
<section>
<div className="flex items-center justify-between border-b border-border/45 pb-3">
<h2 className="mb-2 px-1 text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
{t("settings.skills.featured", { defaultValue: "Agent skills" })}
</h2>
<span className="rounded-full bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
{skills.length}
</span>
</div>
{skills.length ? (
<div className="grid gap-x-10 gap-y-1 py-3 md:grid-cols-2">
{skills.map((skill) => (
<SkillCatalogRow
key={`${skill.source}:${skill.name}`}
skill={skill}
onSelect={setSelectedSkill}
/>
))}
</div>
) : (
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
{t("settings.skills.empty", { defaultValue: "No skills are available." })}
</div>
)}
</section>
<SkillDetailSheet
skill={selectedSkill}
open={selectedSkill !== null}
onOpenChange={(open) => {
if (!open) setSelectedSkill(null);
}}
/>
</div>
);
}
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 (
<button
type="button"
aria-label={t("settings.skills.openDetails", {
name: skill.name,
defaultValue: "Open details for {{name}}",
})}
onClick={() => onSelect(skill)}
className={cn(
"group flex min-w-0 items-center gap-3 rounded-[16px] px-3 py-3 text-left transition-colors",
"hover:bg-muted/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
!skill.available && "opacity-65",
)}
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[14px] bg-muted/70 text-muted-foreground">
<Brain className="h-5 w-5" strokeWidth={1.8} aria-hidden />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate text-[15px] font-semibold leading-5 text-foreground">
{skill.name}
</h3>
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold leading-none text-muted-foreground">
{sourceLabel}
</span>
</div>
<p className="mt-1 line-clamp-2 text-[13px] leading-5 text-muted-foreground">
{skill.description}
</p>
{!skill.available && skill.unavailable_reason ? (
<p className="mt-1 truncate text-[12px] leading-4 text-muted-foreground/80">
{t("settings.skills.unavailableReason", {
reason: skill.unavailable_reason,
defaultValue: "Missing: {{reason}}",
})}
</p>
) : null}
</div>
<span
title={!skill.available && skill.unavailable_reason ? skill.unavailable_reason : undefined}
className={cn(
"hidden shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-[12px] font-medium sm:inline-flex",
skill.available
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "bg-muted text-muted-foreground",
)}
>
<StatusIcon className="h-3.5 w-3.5" aria-hidden />
{statusLabel}
</span>
</button>
);
}
function SkillDetailSheet({
skill,
open,
onOpenChange,
}: {
skill: SkillSummary | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { token } = useClient();
const { t } = useTranslation();
const [detail, setDetail] = useState<SkillDetail | null>(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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-[min(34rem,calc(100vw-1rem))] max-w-none gap-0 overflow-hidden p-0 sm:max-w-none"
>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<div className="flex items-start gap-3 pr-8">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[15px] bg-muted/70 text-muted-foreground">
<Brain className="h-5 w-5" strokeWidth={1.8} aria-hidden />
</div>
<div className="min-w-0">
<SheetTitle className="truncate text-[20px] font-semibold">
{activeSkill.name}
</SheetTitle>
<SheetDescription className="sr-only">
{t("settings.skills.detailDescription", {
name: activeSkill.name,
defaultValue: "Details for {{name}}.",
})}
</SheetDescription>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
<Pill>{sourceLabel}</Pill>
<Pill tone={activeSkill.available ? "success" : "muted"}>{statusLabel}</Pill>
</div>
</div>
</div>
{loading ? (
<div className="mt-8 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
{t("settings.skills.loadingDetail", { defaultValue: "Loading skill details..." })}
</div>
) : loadFailed ? (
<div className="mt-8 rounded-[16px] bg-destructive/10 px-3 py-3 text-sm text-destructive">
{t("settings.skills.loadFailed", { defaultValue: "Could not load skill details." })}
</div>
) : (
<div className="mt-7 space-y-6">
<DetailSection title={t("settings.skills.descriptionTitle", { defaultValue: "Description" })}>
<p className="text-[14px] leading-6 text-muted-foreground">{activeSkill.description}</p>
</DetailSection>
<div className="grid grid-cols-2 gap-2">
<MetaItem
label={t("settings.skills.source", { defaultValue: "Source" })}
value={sourceLabel}
/>
<MetaItem
label={t("settings.skills.status", { defaultValue: "Status" })}
value={statusLabel}
/>
</div>
{!activeSkill.available && activeSkill.unavailable_reason ? (
<DetailSection
title={t("settings.skills.unavailableReasonLabel", {
defaultValue: "Unavailable reason",
})}
>
<p className="text-[13px] leading-5 text-destructive/85">
{activeSkill.unavailable_reason}
</p>
</DetailSection>
) : null}
{detail ? <RequirementsSection detail={detail} /> : null}
{detail ? <RawInstructionsBlock markdown={detail.raw_markdown} /> : null}
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}
function RawInstructionsBlock({ markdown }: { markdown: string }) {
const { t } = useTranslation();
const content =
markdown ||
t("settings.skills.rawInstructionsEmpty", {
defaultValue: "No raw instructions.",
});
return (
<details className="group rounded-[18px] border border-border/45 bg-muted/20 px-3 py-3">
<summary className="cursor-pointer select-none text-[13px] font-medium text-foreground/90 transition-colors hover:text-foreground">
{t("settings.skills.rawInstructions", { defaultValue: "Raw SKILL.md" })}
</summary>
<div className="mt-3 overflow-hidden rounded-[14px] border border-border/35 bg-background/70">
<pre
className={cn(
"max-h-[min(42vh,32rem)] overflow-auto overscroll-contain px-3.5 py-3 pr-4",
"whitespace-pre-wrap break-words font-mono text-[12px] leading-[1.7] text-foreground/62",
"scrollbar-thin scrollbar-track-transparent",
"[&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar]:w-1.5",
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/25",
)}
>
{content}
</pre>
</div>
</details>
);
}
function MetaItem({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-[16px] bg-muted/35 px-3 py-2.5">
<div className="text-[11px] text-muted-foreground">{label}</div>
<div className="mt-0.5 truncate text-[13px] font-medium text-foreground">{value}</div>
</div>
);
}
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 (
<DetailSection title={t("settings.skills.requirements", { defaultValue: "Requirements" })}>
{hasRequirements ? (
<div className="space-y-3">
{missing_bins.length ? (
<RequirementLine
title={t("settings.skills.missingCommands", { defaultValue: "Missing CLI" })}
items={missing_bins}
tone="danger"
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
{missing_env.length ? (
<RequirementLine
title={t("settings.skills.missingEnvironment", { defaultValue: "Missing ENV" })}
items={missing_env}
tone="danger"
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
{bins.length ? (
<RequirementLine
title={t("settings.skills.commands", { defaultValue: "Commands" })}
items={bins}
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
{env.length ? (
<RequirementLine
title={t("settings.skills.environment", { defaultValue: "Environment variables" })}
items={env}
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
</div>
) : (
<p className="text-[13px] text-muted-foreground">
{t("settings.skills.noRequirements", { defaultValue: "No explicit requirements." })}
</p>
)}
</DetailSection>
);
}
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
return (
<section>
<h3 className="mb-2 text-[12px] font-medium text-muted-foreground">{title}</h3>
{children}
</section>
);
}
function RequirementLine({
title,
items,
icon,
tone = "muted",
}: {
title: string;
items: string[];
icon: ReactNode;
tone?: "muted" | "danger";
}) {
return (
<div className="space-y-1.5">
<div
className={cn(
"flex items-center gap-1.5 text-[12px]",
tone === "danger" ? "text-destructive" : "text-muted-foreground",
)}
>
{icon}
{title}
</div>
<div className="flex flex-wrap gap-1.5">
{items.map((item) => (
<Pill key={item}>{item}</Pill>
))}
</div>
</div>
);
}
function Pill({
children,
tone = "muted",
}: {
children: ReactNode;
tone?: "muted" | "success";
}) {
return (
<span
className={cn(
"inline-flex max-w-full items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
tone === "success"
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "bg-muted text-muted-foreground",
)}
>
{children}
</span>
);
}
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;
}
@@ -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 ? (
<div className="flex items-center gap-2 rounded-[16px] bg-muted/45 px-3 py-3 text-[12.5px] text-muted-foreground">
<RefreshCcw className="h-3.5 w-3.5 animate-spin" />
{t("thread.sessionInfo.loading")}
</div>
) : loadFailed ? (
<div className="flex items-center gap-2 rounded-[16px] bg-destructive/10 px-3 py-3 text-[12.5px] text-destructive">
<CircleAlert className="h-3.5 w-3.5" />
{t("thread.sessionInfo.loadFailed")}
</div>
) : jobs.length ? (
<div className="space-y-1.5">
{jobs.map((job) => (
<AutomationRow key={job.id} job={job} now={now} />
))}
</div>
) : (
<div className="rounded-[16px] bg-muted/35 px-3 py-3 text-[12.5px] leading-relaxed text-muted-foreground">
{t("thread.sessionInfo.empty")}
</div>
);
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.sessionInfo")}
className={cn(
"host-no-drag h-8 w-8 rounded-full text-muted-foreground/85",
"hover:bg-accent/40 hover:text-foreground",
)}
>
<ListTodo className="h-4 w-4 stroke-[1.75]" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={8}
className="w-[min(23rem,calc(100vw-1.5rem))] rounded-[24px] p-0"
>
<div className="space-y-3 px-4 py-3.5">
<div className="min-w-0">
<div className="text-[12px] font-normal text-muted-foreground/75">
{t("thread.sessionInfo.title")}
</div>
<div className="mt-0.5 truncate text-[14px] font-medium text-foreground">
{title || t("thread.sessionInfo.untitled")}
</div>
</div>
<div className="h-px bg-border/45" />
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<CalendarClock className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" />
<span className="truncate text-[13px] font-medium text-foreground">
{t("thread.sessionInfo.automations")}
</span>
</div>
<span className="rounded-full bg-muted/70 px-2 py-0.5 text-[11px] text-muted-foreground">
{t("thread.sessionInfo.count", { count: jobs.length })}
</span>
</div>
{automationContent}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
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 (
<div className="rounded-[16px] px-3 py-2.5 transition-colors hover:bg-muted/40">
<div className="flex items-start gap-2.5">
<span className={cn("mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full", statusClass)} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-[13px] font-medium text-foreground">{job.name}</span>
{!job.enabled ? (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10.5px] text-muted-foreground">
{t("thread.sessionInfo.disabled")}
</span>
) : null}
</div>
<div className="mt-1 line-clamp-2 text-[12px] leading-snug text-muted-foreground">
{job.payload.message}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11.5px] text-muted-foreground/80">
<span>{schedule}</span>
<span aria-hidden>·</span>
<span title={nextRun.title}>{nextRun.label}</span>
</div>
</div>
</div>
</div>
);
}
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);
}
+12 -28
View File
@@ -1,4 +1,5 @@
import { Menu, Moon, Sun } from "lucide-react"; import { Menu, Moon, Sun } from "lucide-react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -13,6 +14,7 @@ interface ThreadHeaderProps {
hostChromeTitleInset?: boolean; hostChromeTitleInset?: boolean;
hideThemeButton?: boolean; hideThemeButton?: boolean;
minimal?: boolean; minimal?: boolean;
sessionInfoAction?: ReactNode;
} }
export function ThreadHeader({ export function ThreadHeader({
@@ -24,40 +26,16 @@ export function ThreadHeader({
hostChromeTitleInset = false, hostChromeTitleInset = false,
hideThemeButton = false, hideThemeButton = false,
minimal = false, minimal = false,
sessionInfoAction,
}: ThreadHeaderProps) { }: ThreadHeaderProps) {
const { t } = useTranslation(); const { t } = useTranslation();
if (minimal) {
return (
<div className="relative z-10 flex h-11 items-center justify-between gap-3 px-3 py-2">
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleSidebar")}
onClick={onToggleSidebar}
className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
hideSidebarToggleForHostChrome && "lg:hidden",
)}
>
<Menu className="h-3.5 w-3.5" />
</Button>
{!hideThemeButton ? (
<ThemeButton
theme={theme}
onToggleTheme={onToggleTheme}
label={t("thread.header.toggleTheme")}
className="ml-auto"
/>
) : null}
</div>
);
}
return ( return (
<div <div
className={cn( className={cn(
"relative z-10 flex items-center justify-between gap-3 px-3 py-2", "relative z-10 flex items-center justify-between gap-3 px-3 py-2",
hostChromeTitleInset && "lg:pl-[128px]", minimal && "h-11",
!minimal && hostChromeTitleInset && "lg:pl-[128px]",
)} )}
> >
<div className="relative flex min-w-0 items-center gap-2"> <div className="relative flex min-w-0 items-center gap-2">
@@ -73,21 +51,27 @@ export function ThreadHeader({
> >
<Menu className="h-3.5 w-3.5" /> <Menu className="h-3.5 w-3.5" />
</Button> </Button>
{!minimal ? (
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground"> <div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span> <span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
</div> </div>
) : null}
</div> </div>
<div className="ml-auto flex shrink-0 items-center gap-1">
{sessionInfoAction}
{!hideThemeButton ? ( {!hideThemeButton ? (
<ThemeButton <ThemeButton
theme={theme} theme={theme}
onToggleTheme={onToggleTheme} onToggleTheme={onToggleTheme}
label={t("thread.header.toggleTheme")} label={t("thread.header.toggleTheme")}
className="ml-auto shrink-0"
/> />
) : null} ) : null}
</div>
{!minimal ? (
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" /> <div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
) : null}
</div> </div>
); );
} }
@@ -3,6 +3,7 @@ import type { PointerEvent as ReactPointerEvent } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FilePreviewPanel } from "@/components/FilePreviewPanel"; import { FilePreviewPanel } from "@/components/FilePreviewPanel";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
import { ThreadComposer } from "@/components/thread/ThreadComposer"; import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { ThreadHeader } from "@/components/thread/ThreadHeader"; import { ThreadHeader } from "@/components/thread/ThreadHeader";
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice"; import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
@@ -713,6 +714,9 @@ export function ThreadShell({
</h1> </h1>
</div> </div>
); );
const sessionInfoAction = historyKey ? (
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
) : undefined;
return ( return (
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden"> <section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
@@ -727,6 +731,7 @@ export function ThreadShell({
hostChromeTitleInset={hostChromeTitleInset} hostChromeTitleInset={hostChromeTitleInset}
hideThemeButton={hideThemeButton} hideThemeButton={hideThemeButton}
minimal={!session && !loading} minimal={!session && !loading}
sessionInfoAction={sessionInfoAction}
/> />
) : null} ) : null}
<ThreadViewport <ThreadViewport
+13 -1
View File
@@ -100,4 +100,16 @@ const SheetTitle = React.forwardRef<
)); ));
SheetTitle.displayName = DialogPrimitive.Title.displayName; SheetTitle.displayName = DialogPrimitive.Title.displayName;
export { Sheet, SheetContent, SheetTitle }; const SheetDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
SheetDescription.displayName = DialogPrimitive.Description.displayName;
export { Sheet, SheetContent, SheetDescription, SheetTitle };
+33
View File
@@ -5,6 +5,7 @@
/* Design tokens — HSL form, sourced from shadcn/ui's "neutral" palette. */ /* Design tokens — HSL form, sourced from shadcn/ui's "neutral" palette. */
@layer base { @layer base {
:root { :root {
color-scheme: light;
--background: 0 0% 100%; --background: 0 0% 100%;
--foreground: 240 3% 12%; --foreground: 240 3% 12%;
--card: 0 0% 100%; --card: 0 0% 100%;
@@ -30,9 +31,12 @@
--sidebar-accent: 0 0% 95.8%; --sidebar-accent: 0 0% 95.8%;
--sidebar-accent-foreground: 0 0% 9%; --sidebar-accent-foreground: 0 0% 9%;
--sidebar-border: 0 0% 89.8%; --sidebar-border: 0 0% 89.8%;
--scrollbar-thumb: hsl(var(--muted-foreground) / 0.26);
--scrollbar-thumb-hover: hsl(var(--muted-foreground) / 0.42);
} }
.dark { .dark {
color-scheme: dark;
--background: 0 0% 10%; --background: 0 0% 10%;
--foreground: 240 4% 96%; --foreground: 240 4% 96%;
--card: 0 0% 12%; --card: 0 0% 12%;
@@ -57,6 +61,8 @@
--sidebar-accent: 0 0% 15.5%; --sidebar-accent: 0 0% 15.5%;
--sidebar-accent-foreground: 0 0% 98%; --sidebar-accent-foreground: 0 0% 98%;
--sidebar-border: 0 0% 18%; --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; @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 { ::selection {
@apply bg-primary/15; @apply bg-primary/15;
} }
+1
View File
@@ -945,6 +945,7 @@ export function useNanobotStream(
content, content,
...(hasMedia ? { media } : {}), ...(hasMedia ? { media } : {}),
...(lat !== undefined ? { latencyMs: lat } : {}), ...(lat !== undefined ? { latencyMs: lat } : {}),
...(ev.source ? { source: ev.source } : {}),
...turnFieldsFromEvent(ev, "answer"), ...turnFieldsFromEvent(ev, "answer"),
}); });
}); });
@@ -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<SessionAutomationJob[]>([]);
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 };
}
+20
View File
@@ -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<SkillSummary[]>([]);
useEffect(() => {
let cancelled = false;
fetchSkills(token)
.then(({ skills: nextSkills }) => !cancelled && setSkills(nextSkills))
.catch(() => !cancelled && setSkills([]));
return () => {
cancelled = true;
};
}, [token]);
return skills;
}
+59 -3
View File
@@ -54,7 +54,10 @@
"label": "Language", "label": "Language",
"ariaLabel": "Change language" "ariaLabel": "Change language"
}, },
"apps": "Apps" "apps": "Apps",
"skills": {
"title": "Skills"
}
}, },
"settings": { "settings": {
"backToChat": "Back to chat", "backToChat": "Back to chat",
@@ -75,7 +78,8 @@
"mcp": "MCP", "mcp": "MCP",
"runtime": "System", "runtime": "System",
"advanced": "Security", "advanced": "Security",
"apps": "Apps" "apps": "Apps",
"skills": "Skills"
}, },
"sections": { "sections": {
"interface": "Interface", "interface": "Interface",
@@ -455,6 +459,33 @@
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.", "signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in", "signedIn": "Signed in",
"notSignedIn": "Not 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": { "chat": {
@@ -576,7 +607,30 @@
"toggleSidebar": "Toggle sidebar", "toggleSidebar": "Toggle sidebar",
"newChat": "Start a new chat", "newChat": "Start a new chat",
"toggleTheme": "Toggle theme from header", "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": { "composer": {
"placeholderThread": "Type your message…", "placeholderThread": "Type your message…",
@@ -752,6 +806,8 @@
"cliRunRan": "Used", "cliRunRan": "Used",
"cliRunFailed": "Failed", "cliRunFailed": "Failed",
"imageAttachment": "Image attachment", "imageAttachment": "Image attachment",
"automationSourceFallback": "Automation",
"automationTriggered": "Triggered automatically",
"copyReply": "Copy reply", "copyReply": "Copy reply",
"copiedReply": "Copied reply", "copiedReply": "Copied reply",
"turnLatencyTitle": "Response time (end-to-end)" "turnLatencyTitle": "Response time (end-to-end)"
+60 -4
View File
@@ -54,7 +54,10 @@
"label": "Idioma", "label": "Idioma",
"ariaLabel": "Cambiar idioma" "ariaLabel": "Cambiar idioma"
}, },
"apps": "Apps" "apps": "Apps",
"skills": {
"title": "Habilidades"
}
}, },
"settings": { "settings": {
"backToChat": "Volver al chat", "backToChat": "Volver al chat",
@@ -75,7 +78,8 @@
"advanced": "Seguridad", "advanced": "Seguridad",
"cliApps": "Apps CLI", "cliApps": "Apps CLI",
"mcp": "MCP", "mcp": "MCP",
"apps": "Aplicaciones" "apps": "Aplicaciones",
"skills": "Habilidades"
}, },
"sections": { "sections": {
"interface": "Interfaz", "interface": "Interfaz",
@@ -455,6 +459,33 @@
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.", "signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Sesión iniciada", "signedIn": "Sesión iniciada",
"notSignedIn": "Sin sesión" "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": { "chat": {
@@ -576,7 +607,30 @@
"toggleSidebar": "Mostrar u ocultar la barra lateral", "toggleSidebar": "Mostrar u ocultar la barra lateral",
"newChat": "Iniciar un chat nuevo", "newChat": "Iniciar un chat nuevo",
"toggleTheme": "Cambiar tema desde el encabezado", "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": { "composer": {
"placeholderThread": "Escribe tu mensaje…", "placeholderThread": "Escribe tu mensaje…",
@@ -754,7 +808,9 @@
"cliActivityFailedMany": "Fallaron {{count}} apps CLI", "cliActivityFailedMany": "Fallaron {{count}} apps CLI",
"cliRunRunning": "Usando", "cliRunRunning": "Usando",
"cliRunRan": "Usado", "cliRunRan": "Usado",
"cliRunFailed": "Falló" "cliRunFailed": "Falló",
"automationSourceFallback": "Automatización",
"automationTriggered": "Activada automáticamente"
}, },
"lightbox": { "lightbox": {
"title": "Vista previa de imagen", "title": "Vista previa de imagen",
+60 -4
View File
@@ -54,7 +54,10 @@
"label": "Langue", "label": "Langue",
"ariaLabel": "Changer de langue" "ariaLabel": "Changer de langue"
}, },
"apps": "Apps" "apps": "Apps",
"skills": {
"title": "Compétences"
}
}, },
"settings": { "settings": {
"backToChat": "Retour au chat", "backToChat": "Retour au chat",
@@ -75,7 +78,8 @@
"advanced": "Sécurité", "advanced": "Sécurité",
"cliApps": "Apps CLI", "cliApps": "Apps CLI",
"mcp": "MCP", "mcp": "MCP",
"apps": "Applications" "apps": "Applications",
"skills": "Compétences"
}, },
"sections": { "sections": {
"interface": "Interface utilisateur", "interface": "Interface utilisateur",
@@ -455,6 +459,33 @@
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.", "signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Connecté", "signedIn": "Connecté",
"notSignedIn": "Non connecté" "notSignedIn": "Non connecté"
},
"skills": {
"description": "Consultez les compétences dinstruction 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 denvironnement",
"missingCommands": "CLI manquant",
"missingEnvironment": "ENV manquant",
"unavailableReasonLabel": "Raison dindisponibilité",
"rawInstructions": "SKILL.md brut",
"rawInstructionsEmpty": "Aucune instruction brute.",
"detailDescription": "Détails de {{name}}."
} }
}, },
"chat": { "chat": {
@@ -576,7 +607,30 @@
"toggleSidebar": "Afficher ou masquer la barre latérale", "toggleSidebar": "Afficher ou masquer la barre latérale",
"newChat": "Démarrer un nouveau chat", "newChat": "Démarrer un nouveau chat",
"toggleTheme": "Changer le thème depuis len-tête", "toggleTheme": "Changer le thème depuis len-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": { "composer": {
"placeholderThread": "Saisissez votre message…", "placeholderThread": "Saisissez votre message…",
@@ -754,7 +808,9 @@
"cliActivityFailedMany": "Échec de {{count}} apps CLI", "cliActivityFailedMany": "Échec de {{count}} apps CLI",
"cliRunRunning": "Utilisation", "cliRunRunning": "Utilisation",
"cliRunRan": "Utilisé", "cliRunRan": "Utilisé",
"cliRunFailed": "Échec" "cliRunFailed": "Échec",
"automationSourceFallback": "Automatisation",
"automationTriggered": "Déclenché automatiquement"
}, },
"lightbox": { "lightbox": {
"title": "Aperçu de limage", "title": "Aperçu de limage",
+60 -4
View File
@@ -54,7 +54,10 @@
"label": "Bahasa", "label": "Bahasa",
"ariaLabel": "Ganti bahasa" "ariaLabel": "Ganti bahasa"
}, },
"apps": "Aplikasi" "apps": "Aplikasi",
"skills": {
"title": "Skill"
}
}, },
"settings": { "settings": {
"backToChat": "Kembali ke chat", "backToChat": "Kembali ke chat",
@@ -75,7 +78,8 @@
"advanced": "Keamanan", "advanced": "Keamanan",
"cliApps": "Aplikasi CLI", "cliApps": "Aplikasi CLI",
"mcp": "MCP", "mcp": "MCP",
"apps": "Aplikasi" "apps": "Aplikasi",
"skills": "Skill"
}, },
"sections": { "sections": {
"interface": "Antarmuka", "interface": "Antarmuka",
@@ -455,6 +459,33 @@
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.", "signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Sudah masuk", "signedIn": "Sudah masuk",
"notSignedIn": "Belum 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": { "chat": {
@@ -576,7 +607,30 @@
"toggleSidebar": "Tampilkan atau sembunyikan sidebar", "toggleSidebar": "Tampilkan atau sembunyikan sidebar",
"newChat": "Mulai chat baru", "newChat": "Mulai chat baru",
"toggleTheme": "Alihkan tema dari header", "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": { "composer": {
"placeholderThread": "Ketik pesan Anda…", "placeholderThread": "Ketik pesan Anda…",
@@ -754,7 +808,9 @@
"cliActivityFailedMany": "{{count}} aplikasi CLI gagal", "cliActivityFailedMany": "{{count}} aplikasi CLI gagal",
"cliRunRunning": "Menggunakan", "cliRunRunning": "Menggunakan",
"cliRunRan": "Digunakan", "cliRunRan": "Digunakan",
"cliRunFailed": "Gagal" "cliRunFailed": "Gagal",
"automationSourceFallback": "Otomatisasi",
"automationTriggered": "Dipicu otomatis"
}, },
"lightbox": { "lightbox": {
"title": "Pratinjau gambar", "title": "Pratinjau gambar",
+60 -4
View File
@@ -54,7 +54,10 @@
"label": "言語", "label": "言語",
"ariaLabel": "言語を変更" "ariaLabel": "言語を変更"
}, },
"apps": "アプリ" "apps": "アプリ",
"skills": {
"title": "スキル"
}
}, },
"settings": { "settings": {
"backToChat": "チャットに戻る", "backToChat": "チャットに戻る",
@@ -75,7 +78,8 @@
"advanced": "セキュリティ", "advanced": "セキュリティ",
"cliApps": "CLI アプリ", "cliApps": "CLI アプリ",
"mcp": "MCP", "mcp": "MCP",
"apps": "アプリ" "apps": "アプリ",
"skills": "スキル"
}, },
"sections": { "sections": {
"interface": "インターフェース", "interface": "インターフェース",
@@ -455,6 +459,33 @@
"signInBeforeSaving": "この OAuth プロバイダーをアクティブなモデルプロバイダーとして保存する前にサインインしてください。", "signInBeforeSaving": "この OAuth プロバイダーをアクティブなモデルプロバイダーとして保存する前にサインインしてください。",
"signedIn": "サインイン済み", "signedIn": "サインイン済み",
"notSignedIn": "未サインイン" "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": { "chat": {
@@ -576,7 +607,30 @@
"toggleSidebar": "サイドバーを切り替える", "toggleSidebar": "サイドバーを切り替える",
"newChat": "新しいチャットを開始", "newChat": "新しいチャットを開始",
"toggleTheme": "ヘッダーからテーマを切り替える", "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": { "composer": {
"placeholderThread": "メッセージを入力…", "placeholderThread": "メッセージを入力…",
@@ -754,7 +808,9 @@
"cliActivityFailedMany": "{{count}} 個の CLI アプリが失敗しました", "cliActivityFailedMany": "{{count}} 個の CLI アプリが失敗しました",
"cliRunRunning": "使用中", "cliRunRunning": "使用中",
"cliRunRan": "使用済み", "cliRunRan": "使用済み",
"cliRunFailed": "失敗" "cliRunFailed": "失敗",
"automationSourceFallback": "自動化",
"automationTriggered": "自動実行"
}, },
"lightbox": { "lightbox": {
"title": "画像プレビュー", "title": "画像プレビュー",
+60 -4
View File
@@ -54,7 +54,10 @@
"label": "언어", "label": "언어",
"ariaLabel": "언어 변경" "ariaLabel": "언어 변경"
}, },
"apps": "앱" "apps": "앱",
"skills": {
"title": "스킬"
}
}, },
"settings": { "settings": {
"backToChat": "채팅으로 돌아가기", "backToChat": "채팅으로 돌아가기",
@@ -75,7 +78,8 @@
"advanced": "보안", "advanced": "보안",
"cliApps": "CLI 앱", "cliApps": "CLI 앱",
"mcp": "MCP", "mcp": "MCP",
"apps": "앱" "apps": "앱",
"skills": "스킬"
}, },
"sections": { "sections": {
"interface": "인터페이스", "interface": "인터페이스",
@@ -455,6 +459,33 @@
"signInBeforeSaving": "이 OAuth 제공자를 활성 모델 제공자로 저장하기 전에 로그인하세요.", "signInBeforeSaving": "이 OAuth 제공자를 활성 모델 제공자로 저장하기 전에 로그인하세요.",
"signedIn": "로그인됨", "signedIn": "로그인됨",
"notSignedIn": "로그인 안 됨" "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": { "chat": {
@@ -576,7 +607,30 @@
"toggleSidebar": "사이드바 전환", "toggleSidebar": "사이드바 전환",
"newChat": "새 채팅 시작", "newChat": "새 채팅 시작",
"toggleTheme": "헤더에서 테마 전환", "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": { "composer": {
"placeholderThread": "메시지를 입력하세요…", "placeholderThread": "메시지를 입력하세요…",
@@ -754,7 +808,9 @@
"cliActivityFailedMany": "CLI 앱 {{count}}개 실패", "cliActivityFailedMany": "CLI 앱 {{count}}개 실패",
"cliRunRunning": "사용 중", "cliRunRunning": "사용 중",
"cliRunRan": "사용함", "cliRunRan": "사용함",
"cliRunFailed": "실패" "cliRunFailed": "실패",
"automationSourceFallback": "자동화",
"automationTriggered": "자동 실행됨"
}, },
"lightbox": { "lightbox": {
"title": "이미지 미리보기", "title": "이미지 미리보기",
+60 -4
View File
@@ -54,7 +54,10 @@
"label": "Ngôn ngữ", "label": "Ngôn ngữ",
"ariaLabel": "Đổi ngôn ngữ" "ariaLabel": "Đổi ngôn ngữ"
}, },
"apps": "Ứng dụng" "apps": "Ứng dụng",
"skills": {
"title": "Kỹ năng"
}
}, },
"settings": { "settings": {
"backToChat": "Quay lại chat", "backToChat": "Quay lại chat",
@@ -75,7 +78,8 @@
"advanced": "Bảo mật", "advanced": "Bảo mật",
"cliApps": "Ứng dụng CLI", "cliApps": "Ứng dụng CLI",
"mcp": "MCP", "mcp": "MCP",
"apps": "Ứng dụng" "apps": "Ứng dụng",
"skills": "Kỹ năng"
}, },
"sections": { "sections": {
"interface": "Giao diện", "interface": "Giao diện",
@@ -455,6 +459,33 @@
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.", "signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Đã đăng nhập", "signedIn": "Đã đăng nhập",
"notSignedIn": "Chưa đă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": { "chat": {
@@ -576,7 +607,30 @@
"toggleSidebar": "Bật/tắt thanh bên", "toggleSidebar": "Bật/tắt thanh bên",
"newChat": "Bắt đầu chat mới", "newChat": "Bắt đầu chat mới",
"toggleTheme": "Chuyển chủ đề từ header", "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": { "composer": {
"placeholderThread": "Nhập tin nhắn…", "placeholderThread": "Nhập tin nhắn…",
@@ -754,7 +808,9 @@
"cliActivityFailedMany": "{{count}} ứng dụng CLI thất bại", "cliActivityFailedMany": "{{count}} ứng dụng CLI thất bại",
"cliRunRunning": "Đang dùng", "cliRunRunning": "Đang dùng",
"cliRunRan": "Đã 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": { "lightbox": {
"title": "Xem trước ảnh", "title": "Xem trước ảnh",
+59 -3
View File
@@ -54,7 +54,10 @@
"label": "语言", "label": "语言",
"ariaLabel": "切换语言" "ariaLabel": "切换语言"
}, },
"apps": "应用" "apps": "应用",
"skills": {
"title": "技能"
}
}, },
"settings": { "settings": {
"backToChat": "返回聊天", "backToChat": "返回聊天",
@@ -75,7 +78,8 @@
"mcp": "MCP", "mcp": "MCP",
"runtime": "系统", "runtime": "系统",
"advanced": "安全", "advanced": "安全",
"apps": "应用" "apps": "应用",
"skills": "技能"
}, },
"sections": { "sections": {
"interface": "界面", "interface": "界面",
@@ -455,6 +459,33 @@
"signInBeforeSaving": "将此 OAuth 提供商设为当前模型提供商前,请先登录。", "signInBeforeSaving": "将此 OAuth 提供商设为当前模型提供商前,请先登录。",
"signedIn": "已登录", "signedIn": "已登录",
"notSignedIn": "未登录" "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": { "chat": {
@@ -576,7 +607,30 @@
"toggleSidebar": "切换侧边栏", "toggleSidebar": "切换侧边栏",
"newChat": "从顶部新建对话", "newChat": "从顶部新建对话",
"toggleTheme": "从顶部切换主题", "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": { "composer": {
"placeholderThread": "输入消息…", "placeholderThread": "输入消息…",
@@ -752,6 +806,8 @@
"cliRunRan": "已使用", "cliRunRan": "已使用",
"cliRunFailed": "失败", "cliRunFailed": "失败",
"imageAttachment": "图片附件", "imageAttachment": "图片附件",
"automationSourceFallback": "自动化",
"automationTriggered": "自动触发",
"copyReply": "复制回复", "copyReply": "复制回复",
"copiedReply": "已复制回复", "copiedReply": "已复制回复",
"turnLatencyTitle": "本轮耗时(端到端)" "turnLatencyTitle": "本轮耗时(端到端)"
+60 -4
View File
@@ -54,7 +54,10 @@
"label": "語言", "label": "語言",
"ariaLabel": "切換語言" "ariaLabel": "切換語言"
}, },
"apps": "應用" "apps": "應用",
"skills": {
"title": "技能"
}
}, },
"settings": { "settings": {
"backToChat": "返回聊天", "backToChat": "返回聊天",
@@ -75,7 +78,8 @@
"advanced": "安全", "advanced": "安全",
"cliApps": "CLI 應用", "cliApps": "CLI 應用",
"mcp": "MCP", "mcp": "MCP",
"apps": "應用" "apps": "應用",
"skills": "技能"
}, },
"sections": { "sections": {
"interface": "介面", "interface": "介面",
@@ -455,6 +459,33 @@
"signInBeforeSaving": "將此 OAuth 供應商設為目前模型供應商前,請先登入。", "signInBeforeSaving": "將此 OAuth 供應商設為目前模型供應商前,請先登入。",
"signedIn": "已登入", "signedIn": "已登入",
"notSignedIn": "未登入" "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": { "chat": {
@@ -576,7 +607,30 @@
"toggleSidebar": "切換側邊欄", "toggleSidebar": "切換側邊欄",
"newChat": "開始新對話", "newChat": "開始新對話",
"toggleTheme": "從頂部切換主題", "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": { "composer": {
"placeholderThread": "輸入訊息…", "placeholderThread": "輸入訊息…",
@@ -754,7 +808,9 @@
"cliActivityFailedMany": "{{count}} 個 CLI 應用失敗", "cliActivityFailedMany": "{{count}} 個 CLI 應用失敗",
"cliRunRunning": "使用中", "cliRunRunning": "使用中",
"cliRunRan": "已使用", "cliRunRan": "已使用",
"cliRunFailed": "失敗" "cliRunFailed": "失敗",
"automationSourceFallback": "自動化",
"automationTriggered": "自動觸發"
}, },
"lightbox": { "lightbox": {
"title": "圖片預覽", "title": "圖片預覽",
+41
View File
@@ -9,9 +9,12 @@ import type {
NetworkSafetySettingsUpdate, NetworkSafetySettingsUpdate,
ProviderModelsPayload, ProviderModelsPayload,
ProviderSettingsUpdate, ProviderSettingsUpdate,
SessionAutomationsPayload,
SettingsPayload, SettingsPayload,
SettingsUpdate, SettingsUpdate,
SidebarStatePayload, SidebarStatePayload,
SkillDetail,
SkillsPayload,
SlashCommand, SlashCommand,
WebSearchSettingsUpdate, WebSearchSettingsUpdate,
WorkspacesPayload, WorkspacesPayload,
@@ -151,6 +154,44 @@ export async function fetchFilePreview(
); );
} }
export async function fetchSessionAutomations(
token: string,
key: string,
base: string = "",
): Promise<SessionAutomationsPayload> {
return request<SessionAutomationsPayload>(
`${base}/api/sessions/${encodeURIComponent(key)}/automations`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchSkills(
token: string,
base: string = "",
): Promise<SkillsPayload> {
return request<SkillsPayload>(
`${base}/api/webui/skills`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchSkillDetail(
token: string,
name: string,
base: string = "",
): Promise<SkillDetail> {
return request<SkillDetail>(
`${base}/api/webui/skills/${encodeURIComponent(name)}`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function deleteSession( export async function deleteSession(
token: string, token: string,
key: string, key: string,
+50
View File
@@ -32,6 +32,8 @@ export interface UIMediaAttachment {
name?: string; name?: string;
} }
export interface UIMessageSource { kind: "cron"; label?: string; }
export interface UIMessage { export interface UIMessage {
id: string; id: string;
role: Role; role: Role;
@@ -66,6 +68,8 @@ export interface UIMessage {
reasoningStreaming?: boolean; reasoningStreaming?: boolean;
/** End-to-end wall time for this assistant turn (persisted ``latency_ms`` / ``turn_end``). */ /** End-to-end wall time for this assistant turn (persisted ``latency_ms`` / ``turn_end``). */
latencyMs?: number; latencyMs?: number;
/** Lightweight provenance for proactive assistant messages. */
source?: UIMessageSource;
/** Stable protocol metadata for grouping all activity emitted by one user turn. */ /** Stable protocol metadata for grouping all activity emitted by one user turn. */
turnId?: string; turnId?: string;
turnPhase?: UITurnPhase; turnPhase?: UITurnPhase;
@@ -92,6 +96,50 @@ export interface UIMcpPresetAttachment {
brand_color?: string | null; 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. */ /** Structured UI blob on ``progress`` WS frames; channels may add more ``kind`` values later. */
export interface AgentUIBlob { export interface AgentUIBlob {
kind: string; kind: string;
@@ -670,6 +718,8 @@ export type InboundEvent =
kind?: "tool_hint" | "progress" | "reasoning"; kind?: "tool_hint" | "progress" | "reasoning";
/** Server-measured turn wall time when this frame finishes an assistant reply. */ /** Server-measured turn wall time when this frame finishes an assistant reply. */
latency_ms?: number; latency_ms?: number;
/** Lightweight provenance for proactive assistant messages. */
source?: UIMessageSource;
/** Optional structured payload on progress frames (channel-specific). */ /** Optional structured payload on progress frames (channel-specific). */
agent_ui?: AgentUIBlob; agent_ui?: AgentUIBlob;
} & InboundTurnMetadata) } & InboundTurnMetadata)
+36
View File
@@ -7,8 +7,11 @@ import {
fetchCliApps, fetchCliApps,
fetchMcpPresets, fetchMcpPresets,
fetchProviderModels, fetchProviderModels,
fetchSessionAutomations,
fetchSettingsUsage, fetchSettingsUsage,
fetchSidebarState, fetchSidebarState,
fetchSkillDetail,
fetchSkills,
fetchWebuiThread, fetchWebuiThread,
fetchWorkspaces, fetchWorkspaces,
importMcpConfig, 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 () => { it("percent-encodes websocket keys when deleting a session", async () => {
await deleteSession("tok", "websocket:chat-1"); await deleteSession("tok", "websocket:chat-1");
+88 -34
View File
@@ -30,6 +30,18 @@ function jsonResponse(body: unknown): Response {
} as Response; } as Response;
} }
function mockFetchRoutes(routes: Record<string, unknown>): 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() { function baseSettingsPayload() {
return { return {
agent: { agent: {
@@ -244,6 +256,75 @@ describe("App layout", () => {
expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true); 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(<App />);
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 () => { it("fully collapses the native host sidebar and previews it on hover", async () => {
mockSessions = [ mockSessions = [
{ {
@@ -1090,15 +1171,7 @@ describe("App layout", () => {
}); });
it("restores the settings section from the URL hash after a page reload", async () => { it("restores the settings section from the URL hash after a page reload", async () => {
vi.stubGlobal( mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
if (String(input) === "/api/settings") {
return jsonResponse(baseSettingsPayload());
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
window.history.replaceState(null, "", "/#/settings?section=models"); window.history.replaceState(null, "", "/#/settings?section=models");
render(<App />); render(<App />);
@@ -1109,15 +1182,7 @@ describe("App layout", () => {
}); });
it("updates the URL hash when switching settings sections", async () => { it("updates the URL hash when switching settings sections", async () => {
vi.stubGlobal( mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
if (String(input) === "/api/settings") {
return jsonResponse(baseSettingsPayload());
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
render(<App />); render(<App />);
@@ -1135,22 +1200,11 @@ describe("App layout", () => {
}); });
it("opens Apps from the main sidebar without replacing the sidebar", async () => { it("opens Apps from the main sidebar without replacing the sidebar", async () => {
vi.stubGlobal( mockFetchRoutes({
"fetch", "/api/settings": baseSettingsPayload(),
vi.fn(async (input: RequestInfo | URL) => { "/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
const href = String(input); "/api/settings/mcp-presets": { presets: [], installed_count: 0 },
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;
}),
);
render(<App />); render(<App />);
@@ -154,6 +154,42 @@ describe("MarkdownTextRenderer", () => {
).toHaveAttribute("href", "https://polymarket.com/event/when-will-gpt-5pt6-be-released"); ).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(
<MarkdownTextRenderer>
{
"Useful links:\n\n- Savills Hong Kong Corporate Relocation — Corporate relocation services\n https://www.savills.com.hk/services/corporate-relocation.aspx"
}
</MarkdownTextRenderer>,
);
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", () => { it("renders media attachments without an extra preview/code wrapper", () => {
render(<MarkdownTextRenderer>![Diagram](/api/media/sig/payload)</MarkdownTextRenderer>); render(<MarkdownTextRenderer>![Diagram](/api/media/sig/payload)</MarkdownTextRenderer>);
+16
View File
@@ -101,6 +101,22 @@ describe("MessageBubble", () => {
expect(screen.getByText(/not @krita/)).toBeInTheDocument(); 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(<MessageBubble message={message} />);
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", () => { it("renders structured CLI app attachments even without the installed catalog", () => {
const message: UIMessage = { const message: UIMessage = {
id: "u-cli-attached", id: "u-cli-attached",
@@ -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(
<SessionInfoPopover
sessionKey="websocket:chat-1"
token="tok"
title="Release work"
/>,
);
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(
<SessionInfoPopover
sessionKey="websocket:chat-1"
token="tok"
title="@hyperframes 使用指南"
/>,
);
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(
<SessionInfoPopover
sessionKey="websocket:chat-1"
token="tok"
title="Release work"
/>,
);
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);
});
+22
View File
@@ -157,6 +157,28 @@ describe("useNanobotStream", () => {
expect(result.current.isStreaming).toBe(false); 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 () => { it("drops pending stream work when switching chats", async () => {
const fake = fakeClient(); const fake = fakeClient();
const { result, rerender } = renderHook( const { result, rerender } = renderHook(