mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 21:08:34 +03:00
feat(desktop): polish shell and shared surfaces
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
+148
-1
@@ -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,7 +700,13 @@ 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:
|
||||||
fields["turnId"] = turn_id
|
if turn_id in closed_turn_ids:
|
||||||
|
fields["turnId"] = replay_turn_aliases.setdefault(
|
||||||
|
turn_id,
|
||||||
|
f"{turn_id}:replay:{idx}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
fields["turnId"] = turn_id
|
||||||
phase = rec.get("turn_phase")
|
phase = rec.get("turn_phase")
|
||||||
if isinstance(phase, str) and phase:
|
if isinstance(phase, str) and phase:
|
||||||
fields["turnPhase"] = phase
|
fields["turnPhase"] = 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}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
|
{!minimal ? (
|
||||||
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
|
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
|
||||||
</div>
|
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hideThemeButton ? (
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||||
<ThemeButton
|
{sessionInfoAction}
|
||||||
theme={theme}
|
{!hideThemeButton ? (
|
||||||
onToggleTheme={onToggleTheme}
|
<ThemeButton
|
||||||
label={t("thread.header.toggleTheme")}
|
theme={theme}
|
||||||
className="ml-auto shrink-0"
|
onToggleTheme={onToggleTheme}
|
||||||
/>
|
label={t("thread.header.toggleTheme")}
|
||||||
) : null}
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
|
{!minimal ? (
|
||||||
|
<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
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 d’instruction que cet agent peut charger pendant une conversation.",
|
||||||
|
"caption": "{{available}} disponibles · {{total}} au total",
|
||||||
|
"featured": "Compétences agent",
|
||||||
|
"empty": "Aucune compétence disponible.",
|
||||||
|
"sourceWorkspace": "Personnalisée",
|
||||||
|
"sourceBuiltin": "Intégrée",
|
||||||
|
"statusAvailable": "Disponible",
|
||||||
|
"statusUnavailable": "Indisponible",
|
||||||
|
"unavailableReason": "Manquant : {{reason}}",
|
||||||
|
"openDetails": "Ouvrir les détails de {{name}}",
|
||||||
|
"loadingDetail": "Chargement des détails...",
|
||||||
|
"loadFailed": "Impossible de charger les détails.",
|
||||||
|
"descriptionTitle": "Description",
|
||||||
|
"source": "Source",
|
||||||
|
"status": "Statut",
|
||||||
|
"requirements": "Prérequis",
|
||||||
|
"noRequirements": "Aucun prérequis explicite.",
|
||||||
|
"commands": "Commandes",
|
||||||
|
"environment": "Variables d’environnement",
|
||||||
|
"missingCommands": "CLI manquant",
|
||||||
|
"missingEnvironment": "ENV manquant",
|
||||||
|
"unavailableReasonLabel": "Raison d’indisponibilité",
|
||||||
|
"rawInstructions": "SKILL.md brut",
|
||||||
|
"rawInstructionsEmpty": "Aucune instruction brute.",
|
||||||
|
"detailDescription": "Détails de {{name}}."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"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 l’en-tête",
|
"toggleTheme": "Changer le thème depuis l’en-tête",
|
||||||
"settings": "Ouvrir les paramètres"
|
"settings": "Ouvrir les paramètres",
|
||||||
|
"sessionInfo": "Détails de la session"
|
||||||
|
},
|
||||||
|
"sessionInfo": {
|
||||||
|
"title": "Session",
|
||||||
|
"untitled": "Chat sans titre",
|
||||||
|
"automations": "Automatisations",
|
||||||
|
"count": "{{count}}",
|
||||||
|
"loading": "Chargement des automatisations...",
|
||||||
|
"loadFailed": "Impossible de charger les automatisations.",
|
||||||
|
"empty": "Aucune automatisation dans cette session pour le moment.",
|
||||||
|
"disabled": "Désactivé",
|
||||||
|
"schedule": {
|
||||||
|
"at": "À {{time}}",
|
||||||
|
"every": "Toutes les {{duration}}",
|
||||||
|
"cron": "Cron {{expr}}",
|
||||||
|
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||||
|
"unknown": "Planification personnalisée"
|
||||||
|
},
|
||||||
|
"next": {
|
||||||
|
"label": "Prochaine {{time}}",
|
||||||
|
"disabled": "En pause",
|
||||||
|
"none": "Aucune prochaine exécution"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"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 l’image",
|
"title": "Aperçu de l’image",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "画像プレビュー",
|
||||||
|
|||||||
@@ -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": "이미지 미리보기",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "本轮耗时(端到端)"
|
||||||
|
|||||||
@@ -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": "圖片預覽",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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></MarkdownTextRenderer>);
|
render(<MarkdownTextRenderer></MarkdownTextRenderer>);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user