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:
+126
-6
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user