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:
Xubin Ren
2026-06-06 19:49:33 +08:00
committed by GitHub
co-authored by chengyongru
parent a1b9577224
commit ab9f49970d
103 changed files with 10483 additions and 1003 deletions
+126 -6
View File
@@ -5,8 +5,10 @@ import os
import select
import signal
import sys
import uuid
from collections.abc import Callable
from contextlib import nullcontext, suppress
from contextvars import ContextVar
from pathlib import Path
from typing import Any
@@ -83,6 +85,34 @@ class SafeFileHistory(FileHistory):
def store_string(self, string: str) -> None:
super().store_string(_sanitize_surrogates(string))
_WEBUI_TURN_META_KEY = "webui_turn_id"
_WEBUI_MESSAGE_SOURCE_META_KEY = "_webui_message_source"
_PROACTIVE_WEBUI_METADATA: ContextVar[dict[str, Any] | None] = ContextVar(
"proactive_webui_metadata",
default=None,
)
def _proactive_delivery_metadata(
channel: str,
metadata: dict[str, Any] | None,
*,
turn_seed: str,
source_label: str | None = None,
) -> dict[str, Any]:
"""Return channel metadata for a fresh proactive delivery turn."""
out = dict(metadata or {})
out.pop(_WEBUI_TURN_META_KEY, None)
if channel == "websocket":
out[_WEBUI_TURN_META_KEY] = f"{turn_seed}:{uuid.uuid4().hex}"
source: dict[str, str] = {"kind": "cron"}
if source_label:
source["label"] = source_label
out[_WEBUI_MESSAGE_SOURCE_META_KEY] = source
return out
app = typer.Typer(
name="nanobot",
context_settings={"help_option_names": ["-h", "--help"]},
@@ -738,6 +768,59 @@ def gateway(
_run_gateway(cfg, port=port)
DESKTOP_BOOTSTRAP_PROVIDER = "openai_codex"
DESKTOP_BOOTSTRAP_MODEL = "openai-codex/gpt-5.1-codex"
def _desktop_provider_error_is_recoverable(error: ValueError) -> bool:
message = str(error)
return "No API key configured" in message or "requires api_key and api_base" in message
def _desktop_provider_needs_bootstrap(config: Config) -> bool:
from nanobot.providers.factory import make_provider
try:
make_provider(config)
return False
except ValueError as e:
if not _desktop_provider_error_is_recoverable(e):
raise
return True
def _reset_desktop_config_to_unconfigured(config: Config) -> bool:
defaults = config.agents.defaults
changed = False
if defaults.model_preset is not None:
defaults.model_preset = None
changed = True
if defaults.provider:
defaults.provider = ""
changed = True
if defaults.model:
defaults.model = ""
changed = True
return changed
def _is_persisted_desktop_bootstrap(config: Config) -> bool:
defaults = config.agents.defaults
return (
defaults.model_preset is None
and defaults.provider == DESKTOP_BOOTSTRAP_PROVIDER
and defaults.model == DESKTOP_BOOTSTRAP_MODEL
and not config.model_presets
)
def _apply_desktop_runtime_bootstrap(config: Config) -> None:
defaults = config.agents.defaults
config.agents.defaults.model_preset = None
defaults.provider = DESKTOP_BOOTSTRAP_PROVIDER
defaults.model = DESKTOP_BOOTSTRAP_MODEL
def _load_or_create_desktop_config(config: str | None, workspace: str | None) -> Config:
"""Load the desktop-owned config, creating it on first launch."""
from nanobot.config.loader import (
@@ -751,7 +834,7 @@ def _load_or_create_desktop_config(config: str | None, workspace: str | None) ->
config_path = Path(config).expanduser().resolve() if config else get_config_path()
set_config_path(config_path)
created = False
changed = False
if config_path.exists():
try:
loaded = resolve_config_env_vars(load_config(config_path))
@@ -760,16 +843,25 @@ def _load_or_create_desktop_config(config: str | None, workspace: str | None) ->
raise typer.Exit(1)
else:
loaded = NanobotConfig()
created = True
changed = True
if workspace:
workspace_path = Path(workspace).expanduser()
loaded.agents.defaults.workspace = str(workspace_path)
created = True
changed = True
if created:
if _is_persisted_desktop_bootstrap(loaded):
changed = _reset_desktop_config_to_unconfigured(loaded) or changed
elif _desktop_provider_needs_bootstrap(loaded):
changed = _reset_desktop_config_to_unconfigured(loaded) or changed
if changed:
save_config(loaded, config_path)
return loaded
runtime_config = loaded.model_copy(deep=True)
if _desktop_provider_needs_bootstrap(runtime_config):
_apply_desktop_runtime_bootstrap(runtime_config)
return runtime_config
def _configure_desktop_gateway(
@@ -889,6 +981,7 @@ def _run_gateway(
from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.session.manager import SessionManager
from nanobot.session.webui_turns import WebuiTurnCoordinator
from nanobot.webui.token_usage import TokenUsageHook
port = port if port is not None else config.gateway.port
@@ -923,6 +1016,7 @@ def _run_gateway(
provider_snapshot_loader=load_provider_snapshot,
runtime_events=runtime_events,
provider_signature=provider_snapshot.signature,
hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
)
WebuiTurnCoordinator(
bus=bus,
@@ -946,6 +1040,9 @@ def _run_gateway(
"""Publish a user-visible message and mirror it into that channel's session."""
metadata = dict(msg.metadata or {})
record = record or bool(metadata.pop("_record_channel_delivery", False))
proactive_webui_metadata = _PROACTIVE_WEBUI_METADATA.get()
if record and msg.channel == "websocket" and proactive_webui_metadata:
metadata = {**metadata, **proactive_webui_metadata}
if metadata != (msg.metadata or {}):
msg = OutboundMessage(
channel=msg.channel,
@@ -1017,6 +1114,13 @@ def _run_gateway(
except Exception:
logger.exception("Dream cron job failed")
finally:
from nanobot.webui.token_usage import record_response_token_usage
record_response_token_usage(
resp,
source="dream",
timezone_name=config.agents.defaults.timezone,
)
if store.git.is_initialized():
msg = build_dream_commit_message(
"dream: periodic memory consolidation", resp,
@@ -1107,6 +1211,14 @@ def _run_gateway(
if isinstance(message_tool, MessageTool):
message_record_token = message_tool.set_record_channel_delivery(True)
proactive_webui_metadata = _proactive_delivery_metadata(
"websocket",
None,
turn_seed=f"cron:{job.id}",
source_label=job.name,
)
proactive_token = _PROACTIVE_WEBUI_METADATA.set(proactive_webui_metadata)
try:
resp = await agent.process_direct(
reminder_note,
@@ -1116,6 +1228,7 @@ def _run_gateway(
on_progress=_silent,
)
finally:
_PROACTIVE_WEBUI_METADATA.reset(proactive_token)
if isinstance(cron_tool, CronTool) and cron_token is not None:
cron_tool.reset_cron_context(cron_token)
if isinstance(message_tool, MessageTool) and message_record_token is not None:
@@ -1131,12 +1244,18 @@ def _run_gateway(
response, reminder_note, agent.provider, agent.model,
)
if should_notify:
proactive_metadata = _proactive_delivery_metadata(
job.payload.channel or "cli",
job.payload.channel_meta,
turn_seed=f"cron:{job.id}",
source_label=job.name,
)
await _deliver_to_channel(
OutboundMessage(
channel=job.payload.channel or "cli",
chat_id=job.payload.to,
content=response,
metadata=dict(job.payload.channel_meta),
metadata=proactive_metadata,
),
record=True,
session_key=job.payload.session_key,
@@ -1158,6 +1277,7 @@ def _run_gateway(
config,
bus,
session_manager=session_manager,
cron_service=cron,
webui_runtime_model_name=_webui_runtime_model_name,
webui_static_dist=webui_static_dist,
webui_runtime_surface=webui_runtime_surface,