feat(desktop): polish desktop shell and shared WebUI surfaces (#4195)
* feat(desktop): add native host scaffold * feat(webui): track turns and usage in gateway * feat(webui): polish desktop chat experience * feat(apps): add ArcGIS and Joplin logos * feat(desktop): polish shell and shared surfaces * fix(webui): avoid preview chips for glob references * test: align CI expectations for token fallback * feat(webui): preview prompt rail entries * feat(webui): add prompt navigator drawer * style(webui): refine prompt navigator placement * style(webui): align prompt navigator with header actions * style(webui): simplify prompt navigator header * refactor(webui): clean thread resource refresh * feat(desktop): add native reply notifications * fix(webui): preserve desktop restart and replay state * fix(desktop): harden gateway proxy startup * fix(web): fall back when readability is unavailable * fix(desktop): hide window instead of closing on macos * fix(webui): unify desktop header actions * fix(webui): simplify prompt history rows * fix(desktop): log notification delivery failures * chore(desktop): clean source package artifacts * fix(cron): support one-time relative reminders * fix(webui): reveal scroll button in place * Revert "fix(cron): support one-time relative reminders" This reverts commit 4c4661da120a3c7283e0768412bae48604e7390b. * refactor(webui): extract token usage heatmap * docs(desktop): clarify contributor guides --------- Co-authored-by: chengyongru <2755839590@qq.com>
This commit is contained in:
@@ -104,6 +104,7 @@ def bus() -> MagicMock:
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.workspaces.get_webui_dir",
|
||||
lambda: tmp_path / "webui",
|
||||
@@ -277,6 +278,8 @@ async def test_token_issue_route_requires_secret_when_static_token_configured(bu
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_message_envelope_marks_inbound_metadata(bus: MagicMock) -> None:
|
||||
from nanobot.webui.transcript import read_transcript_lines
|
||||
|
||||
channel = _ch(bus)
|
||||
conn = MagicMock()
|
||||
conn.remote_address = ("127.0.0.1", 50123)
|
||||
@@ -284,14 +287,30 @@ async def test_webui_message_envelope_marks_inbound_metadata(bus: MagicMock) ->
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{"type": "message", "chat_id": "chat-1", "content": "hello", "webui": True},
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": "chat-1",
|
||||
"content": "hello",
|
||||
"webui": True,
|
||||
"turn_id": "turn-1",
|
||||
},
|
||||
)
|
||||
|
||||
msg = bus.publish_inbound.await_args.args[0]
|
||||
assert msg.channel == "websocket"
|
||||
assert msg.chat_id == "chat-1"
|
||||
assert msg.metadata["webui"] is True
|
||||
assert msg.metadata["webui_turn_id"] == "turn-1"
|
||||
assert msg.metadata["_wants_stream"] is True
|
||||
lines = read_transcript_lines("websocket:chat-1")
|
||||
assert lines == [{
|
||||
"event": "user",
|
||||
"chat_id": "chat-1",
|
||||
"text": "hello",
|
||||
"turn_id": "turn-1",
|
||||
"turn_phase": "user",
|
||||
"turn_seq": 1,
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -359,7 +378,7 @@ async def test_webui_user_transcript_append_failure_does_not_block_inbound(
|
||||
def fail_append(_session_key: str, _obj: dict[str, Any]) -> None:
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.websocket.append_transcript_object", fail_append)
|
||||
monkeypatch.setattr("nanobot.webui.transcript.append_transcript_object", fail_append)
|
||||
channel = _ch(bus)
|
||||
conn = AsyncMock()
|
||||
conn.remote_address = ("127.0.0.1", 50123)
|
||||
@@ -664,6 +683,58 @@ async def test_webui_scope_rejects_non_loopback_custom_scope(bus: MagicMock, tmp
|
||||
assert sessions.read_session_file("websocket:chat-remote") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_webui_scope_allows_custom_scope_without_loopback(
|
||||
bus: MagicMock,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
default_workspace = tmp_path / "default"
|
||||
project = tmp_path / "project"
|
||||
default_workspace.mkdir()
|
||||
project.mkdir()
|
||||
sessions = SessionManager(tmp_path / "sessions")
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
|
||||
bus,
|
||||
gateway=_basic_handler(
|
||||
bus,
|
||||
session_manager=sessions,
|
||||
workspace_path=default_workspace,
|
||||
runtime_surface="native",
|
||||
),
|
||||
)
|
||||
conn = AsyncMock()
|
||||
conn.remote_address = None
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"native-client",
|
||||
{
|
||||
"type": "set_workspace_scope",
|
||||
"chat_id": "chat-native",
|
||||
"workspace_scope": {
|
||||
"project_path": str(project),
|
||||
"access_mode": "full",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
payload = json.loads(conn.send.await_args.args[0])
|
||||
assert payload["event"] == "session_updated"
|
||||
assert payload["chat_id"] == "chat-native"
|
||||
assert payload["workspace_scope"]["project_path"] == str(project.resolve())
|
||||
assert payload["workspace_scope"]["project_name"] == "project"
|
||||
assert payload["workspace_scope"]["access_mode"] == "full"
|
||||
assert payload["workspace_scope"]["restrict_to_workspace"] is False
|
||||
assert payload["workspace_scope"]["sandbox_status"]["restrict_to_workspace"] is False
|
||||
assert payload["workspace_scope"]["sandbox_status"]["workspace_root"] == str(project.resolve())
|
||||
saved = sessions.read_session_file("websocket:chat-native")
|
||||
assert saved["metadata"]["workspace_scope"] == {
|
||||
"project_path": str(project.resolve()),
|
||||
"access_mode": "full",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delivers_json_message_with_media_and_reply() -> None:
|
||||
bus = MagicMock()
|
||||
@@ -799,6 +870,7 @@ async def test_send_progress_includes_structured_tool_events() -> None:
|
||||
metadata={
|
||||
"_progress": True,
|
||||
"_tool_hint": True,
|
||||
"webui_turn_id": "turn-1",
|
||||
"_tool_events": [
|
||||
{
|
||||
"version": 1,
|
||||
@@ -818,6 +890,9 @@ async def test_send_progress_includes_structured_tool_events() -> None:
|
||||
payload = json.loads(mock_ws.send.await_args.args[0])
|
||||
assert payload["event"] == "message"
|
||||
assert payload["kind"] == "tool_hint"
|
||||
assert payload["turn_id"] == "turn-1"
|
||||
assert payload["turn_phase"] == "activity"
|
||||
assert payload["turn_seq"] == 1
|
||||
assert payload["tool_events"] == [
|
||||
{
|
||||
"version": 1,
|
||||
@@ -1091,6 +1166,37 @@ async def test_send_reasoning_without_subscribers_is_noop() -> None:
|
||||
assert channel._subs == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_transcript_persists_without_subscribers() -> None:
|
||||
from nanobot.webui.transcript import build_webui_thread_response, read_transcript_lines
|
||||
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "streaming": True},
|
||||
bus,
|
||||
gateway=_basic_handler(bus),
|
||||
)
|
||||
|
||||
await channel.send_delta("chat-1", "hello", {"_stream_delta": True, "_stream_id": "s1"})
|
||||
await channel.send_delta("chat-1", " world", {"_stream_delta": True, "_stream_id": "s1"})
|
||||
await channel.send_delta("chat-1", "", {"_stream_end": True, "_stream_id": "s1"})
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
content="",
|
||||
metadata={"_turn_end": True, "latency_ms": 42},
|
||||
))
|
||||
|
||||
assert channel._subs == {}
|
||||
lines = read_transcript_lines("websocket:chat-1")
|
||||
assert [line["event"] for line in lines] == ["delta", "delta", "stream_end", "turn_end"]
|
||||
body = build_webui_thread_response("websocket:chat-1")
|
||||
assert body is not None
|
||||
assert body["messages"][-1]["role"] == "assistant"
|
||||
assert body["messages"][-1]["content"] == "hello world"
|
||||
assert body["messages"][-1]["latencyMs"] == 42
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_turn_end_emits_turn_end_event() -> None:
|
||||
bus = MagicMock()
|
||||
@@ -2494,6 +2600,71 @@ def test_handle_webui_thread_get_returns_json(tmp_path, monkeypatch) -> None:
|
||||
assert body["messages"][0]["content"] == "hi"
|
||||
|
||||
|
||||
def test_handle_file_preview_returns_workspace_file(tmp_path) -> None:
|
||||
from urllib.parse import quote
|
||||
|
||||
from websockets.datastructures import Headers
|
||||
from websockets.http11 import Request
|
||||
|
||||
workspace = tmp_path / "workspace"
|
||||
source = workspace / "nanobot" / "agent" / "hook.py"
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text("print('hello')\n", encoding="utf-8")
|
||||
|
||||
gateway = _basic_handler(MagicMock(), workspace_path=workspace)
|
||||
gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
|
||||
key = "websocket:file-preview"
|
||||
enc = quote(key, safe="")
|
||||
path = quote("nanobot/agent/hook.py:12", safe="")
|
||||
req = Request(
|
||||
f"/api/sessions/{enc}/file-preview?path={path}",
|
||||
Headers([("Authorization", "Bearer tok")]),
|
||||
)
|
||||
|
||||
resp = gateway.http._handle_file_preview(req, enc)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = json.loads(resp.body.decode())
|
||||
assert body["display_path"] == "nanobot/agent/hook.py"
|
||||
assert body["language"] == "python"
|
||||
assert body["content"].splitlines() == ["print('hello')"]
|
||||
assert body["truncated"] is False
|
||||
|
||||
|
||||
def test_file_preview_normalizes_windows_file_url() -> None:
|
||||
from nanobot.webui.file_preview import _clean_preview_path
|
||||
|
||||
assert _clean_preview_path("file:///C:/Users/me/project/app.py") == (
|
||||
"C:/Users/me/project/app.py"
|
||||
)
|
||||
assert _clean_preview_path("file:///tmp/project/app.py") == "/tmp/project/app.py"
|
||||
|
||||
|
||||
def test_handle_file_preview_rejects_paths_outside_workspace(tmp_path) -> None:
|
||||
from urllib.parse import quote
|
||||
|
||||
from websockets.datastructures import Headers
|
||||
from websockets.http11 import Request
|
||||
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
outside = tmp_path / "secret.py"
|
||||
outside.write_text("secret = True\n", encoding="utf-8")
|
||||
|
||||
gateway = _basic_handler(MagicMock(), workspace_path=workspace)
|
||||
gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
|
||||
key = "websocket:file-preview"
|
||||
enc = quote(key, safe="")
|
||||
req = Request(
|
||||
f"/api/sessions/{enc}/file-preview?path={quote(str(outside), safe='')}",
|
||||
Headers([("Authorization", "Bearer tok")]),
|
||||
)
|
||||
|
||||
resp = gateway.http._handle_file_preview(req, enc)
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_handle_webui_thread_get_backfills_legacy_missing_user_rows(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
|
||||
@@ -12,6 +12,8 @@ import httpx
|
||||
import pytest
|
||||
|
||||
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
|
||||
|
||||
@@ -24,10 +26,12 @@ def _make_handler(
|
||||
*,
|
||||
session_manager: SessionManager | None = None,
|
||||
static_dist_path: Path | None = None,
|
||||
workspace_path: Path | None = None,
|
||||
runtime_model_name: Any | None = None,
|
||||
cron_service: CronService | None = None,
|
||||
) -> GatewayServices:
|
||||
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
|
||||
workspace = Path.cwd()
|
||||
workspace = workspace_path or Path.cwd()
|
||||
return build_gateway_services(
|
||||
config=config,
|
||||
bus=bus,
|
||||
@@ -38,6 +42,7 @@ def _make_handler(
|
||||
runtime_model_name=runtime_model_name,
|
||||
runtime_surface="browser",
|
||||
runtime_capabilities_overrides=None,
|
||||
cron_service=cron_service,
|
||||
)
|
||||
|
||||
|
||||
@@ -46,8 +51,10 @@ def _ch(
|
||||
*,
|
||||
session_manager: SessionManager | None = None,
|
||||
static_dist_path: Path | None = None,
|
||||
workspace_path: Path | None = None,
|
||||
port: int = _PORT,
|
||||
runtime_model_name: Any | None = None,
|
||||
cron_service: CronService | None = None,
|
||||
**extra: Any,
|
||||
) -> WebSocketChannel:
|
||||
cfg: dict[str, Any] = {
|
||||
@@ -63,7 +70,9 @@ def _ch(
|
||||
cfg, bus,
|
||||
session_manager=session_manager,
|
||||
static_dist_path=static_dist_path,
|
||||
workspace_path=workspace_path,
|
||||
runtime_model_name=runtime_model_name,
|
||||
cron_service=cron_service,
|
||||
)
|
||||
return WebSocketChannel(cfg, bus, gateway=gateway)
|
||||
|
||||
@@ -161,6 +170,156 @@ async def test_sessions_routes_require_bearer_token(
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_automations_route_filters_by_webui_session(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
cron = CronService(tmp_path / "cron" / "jobs.json")
|
||||
hourly = CronSchedule(kind="every", every_ms=3_600_000)
|
||||
for name, message, to in (
|
||||
("Morning check", "Check the project status", "abc"),
|
||||
("Other session", "Do not show", "other"),
|
||||
):
|
||||
cron.add_job(
|
||||
name=name,
|
||||
schedule=hourly,
|
||||
message=message,
|
||||
channel="websocket",
|
||||
to=to,
|
||||
session_key=f"websocket:{to}",
|
||||
)
|
||||
cron.register_system_job(
|
||||
CronJob(
|
||||
id="heartbeat",
|
||||
name="heartbeat",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
payload=CronPayload(kind="system_event"),
|
||||
)
|
||||
)
|
||||
channel = _ch(
|
||||
bus,
|
||||
session_manager=_seed_session(tmp_path, key="websocket:abc"),
|
||||
cron_service=cron,
|
||||
port=29914,
|
||||
)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
deny = await _http_get(
|
||||
"http://127.0.0.1:29914/api/sessions/websocket:abc/automations"
|
||||
)
|
||||
assert deny.status_code == 401
|
||||
|
||||
boot = await _http_get("http://127.0.0.1:29914/webui/bootstrap")
|
||||
token = boot.json()["token"]
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
resp = await _http_get(
|
||||
"http://127.0.0.1:29914/api/sessions/websocket%3Aabc/automations",
|
||||
headers=auth,
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert [job["name"] for job in body["jobs"]] == ["Morning check"]
|
||||
job = body["jobs"][0]
|
||||
assert job["schedule"]["kind"] == "every"
|
||||
assert job["schedule"]["every_ms"] == 3_600_000
|
||||
assert job["payload"]["message"] == "Check the project status"
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_skills_route_requires_token_and_hides_paths(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
workspace_skill = tmp_path / "skills" / "workspace-skill"
|
||||
workspace_skill.mkdir(parents=True)
|
||||
(workspace_skill / "SKILL.md").write_text(
|
||||
"---\nname: workspace-skill\ndescription: Workspace skill.\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
unavailable_skill = tmp_path / "skills" / "zz-unavailable-skill"
|
||||
unavailable_skill.mkdir(parents=True)
|
||||
(unavailable_skill / "SKILL.md").write_text(
|
||||
"\n".join([
|
||||
"---",
|
||||
"name: zz-unavailable-skill",
|
||||
"description: Missing CLI skill.",
|
||||
"metadata:",
|
||||
" nanobot:",
|
||||
" requires:",
|
||||
" bins:",
|
||||
" - definitely-missing-nanobot-skill-cli",
|
||||
" env:",
|
||||
" - DEFINITELY_MISSING_NANOBOT_SKILL_ENV",
|
||||
"---",
|
||||
"Use the missing CLI and env var.",
|
||||
]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
channel = _ch(
|
||||
bus,
|
||||
session_manager=_seed_session(tmp_path),
|
||||
workspace_path=tmp_path,
|
||||
port=29920,
|
||||
)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
deny = await _http_get("http://127.0.0.1:29920/api/webui/skills")
|
||||
assert deny.status_code == 401
|
||||
deny_detail = await _http_get("http://127.0.0.1:29920/api/webui/skills/workspace-skill")
|
||||
assert deny_detail.status_code == 401
|
||||
|
||||
boot = await _http_get("http://127.0.0.1:29920/webui/bootstrap")
|
||||
token = boot.json()["token"]
|
||||
resp = await _http_get(
|
||||
"http://127.0.0.1:29920/api/webui/skills",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
names = [skill["name"] for skill in body["skills"]]
|
||||
assert names[0] == "workspace-skill"
|
||||
assert "cron" in names
|
||||
assert all("path" not in skill for skill in body["skills"])
|
||||
workspace = body["skills"][0]
|
||||
assert workspace == {
|
||||
"name": "workspace-skill",
|
||||
"description": "Workspace skill.",
|
||||
"source": "workspace",
|
||||
"available": True,
|
||||
"unavailable_reason": "",
|
||||
}
|
||||
unavailable = next(skill for skill in body["skills"] if skill["name"] == "zz-unavailable-skill")
|
||||
assert unavailable["available"] is False
|
||||
assert unavailable["unavailable_reason"] == (
|
||||
"CLI: definitely-missing-nanobot-skill-cli, "
|
||||
"ENV: DEFINITELY_MISSING_NANOBOT_SKILL_ENV"
|
||||
)
|
||||
|
||||
detail = await _http_get(
|
||||
"http://127.0.0.1:29920/api/webui/skills/zz-unavailable-skill",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert detail.status_code == 200
|
||||
detail_body = detail.json()
|
||||
assert "path" not in detail_body
|
||||
assert detail_body["requirements"] == {
|
||||
"bins": ["definitely-missing-nanobot-skill-cli"],
|
||||
"env": ["DEFINITELY_MISSING_NANOBOT_SKILL_ENV"],
|
||||
"missing_bins": ["definitely-missing-nanobot-skill-cli"],
|
||||
"missing_env": ["DEFINITELY_MISSING_NANOBOT_SKILL_ENV"],
|
||||
}
|
||||
assert "Use the missing CLI and env var." in detail_body["raw_markdown"]
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_apps_routes_require_token_and_return_payload(
|
||||
bus: MagicMock,
|
||||
|
||||
Reference in New Issue
Block a user