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
+222 -10
View File
@@ -18,6 +18,9 @@ from nanobot.session.manager import SessionManager
WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3
_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(
r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)"
)
@@ -152,6 +155,125 @@ def append_transcript_object(session_key: str, obj: dict[str, Any]) -> None:
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 (OSError, 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:
path = webui_transcript_path(session_key)
if not path.is_file():
@@ -560,6 +682,8 @@ def replay_transcript_to_ui_messages(
active_file_edit_segment_id: str | None = None
activity_segment_counter = 0
_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:
return f"{prefix}-{idx}-{uuid.uuid4().hex[:8]}"
@@ -572,6 +696,42 @@ def replay_transcript_to_ui_messages(
active_activity_segment_id = segment_id
return segment_id
def _turn_fields(rec: dict[str, Any], fallback_phase: str | None = None) -> dict[str, Any]:
fields: dict[str, Any] = {}
turn_id = rec.get("turn_id")
if isinstance(turn_id, str) and turn_id:
if turn_id in closed_turn_ids:
fields["turnId"] = replay_turn_aliases.setdefault(
turn_id,
f"{turn_id}:replay:{idx}",
)
else:
fields["turnId"] = turn_id
phase = rec.get("turn_phase")
if isinstance(phase, str) and phase:
fields["turnPhase"] = phase
elif fallback_phase:
fields["turnPhase"] = fallback_phase
seq = rec.get("turn_seq")
if isinstance(seq, (int, float)):
fields["turnSeq"] = int(seq)
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:
turn_id = turn_fields.get("turnId")
message_turn_id = message.get("turnId")
return not turn_id or not message_turn_id or turn_id == message_turn_id
def _ensure_activity_segment() -> str:
return active_activity_segment_id or _new_activity_segment()
@@ -586,7 +746,13 @@ def replay_transcript_to_ui_messages(
active_activity_segment_id = None
active_file_edit_segment_id = None
def attach_reasoning_chunk(prev: list[dict[str, Any]], chunk: str, idx: int) -> None:
def attach_reasoning_chunk(
prev: list[dict[str, Any]],
chunk: str,
idx: int,
turn_fields: dict[str, Any] | None = None,
) -> None:
turn_fields = turn_fields or {}
for i in range(len(prev) - 1, -1, -1):
candidate = prev[i]
if candidate.get("role") == "user":
@@ -595,6 +761,8 @@ def replay_transcript_to_ui_messages(
break
if candidate.get("role") != "assistant":
continue
if not _same_turn(candidate, turn_fields):
break
content = str(candidate.get("content") or "")
has_answer = len(content) > 0
if (
@@ -608,6 +776,7 @@ def replay_transcript_to_ui_messages(
"reasoning": (str(candidate.get("reasoning") or "")) + chunk,
"reasoningStreaming": True,
"activitySegmentId": candidate.get("activitySegmentId") or _ensure_activity_segment(),
**turn_fields,
}
return
if not has_answer and candidate.get("isStreaming"):
@@ -616,6 +785,7 @@ def replay_transcript_to_ui_messages(
"reasoning": chunk,
"reasoningStreaming": True,
"activitySegmentId": candidate.get("activitySegmentId") or _ensure_activity_segment(),
**turn_fields,
}
return
break
@@ -629,11 +799,16 @@ def replay_transcript_to_ui_messages(
"reasoning": chunk,
"reasoningStreaming": True,
"activitySegmentId": segment,
**turn_fields,
"createdAt": _ts_base + idx,
},
)
def find_active_placeholder(prev: list[dict[str, Any]]) -> str | None:
def find_active_placeholder(
prev: list[dict[str, Any]],
turn_fields: dict[str, Any] | None = None,
) -> str | None:
turn_fields = turn_fields or {}
last = prev[-1] if prev else None
if not last:
return None
@@ -643,6 +818,8 @@ def replay_transcript_to_ui_messages(
return None
if not last.get("isStreaming"):
return None
if not _same_turn(last, turn_fields):
return None
return str(last.get("id"))
def demote_interrupted_assistant(segment: str) -> None:
@@ -721,7 +898,7 @@ def replay_transcript_to_ui_messages(
def absorb_complete(extra: dict[str, Any], idx: int) -> None:
nonlocal active_activity_segment_id, active_file_edit_segment_id
last = messages[-1] if messages else None
if last and is_reasoning_only_placeholder(last):
if last and is_reasoning_only_placeholder(last) and _same_turn(last, extra):
messages[-1] = {
**last,
**extra,
@@ -768,8 +945,13 @@ def replay_transcript_to_ui_messages(
return i
return None
def upsert_file_edits(edits: list[dict[str, Any]], idx: int) -> None:
def upsert_file_edits(
edits: list[dict[str, Any]],
idx: int,
turn_fields: dict[str, Any] | None = None,
) -> None:
nonlocal active_file_edit_segment_id
turn_fields = turn_fields or {}
if not edits:
return
segment = active_file_edit_segment_id
@@ -796,6 +978,7 @@ def replay_transcript_to_ui_messages(
"traces": [],
"fileEdits": [],
"activitySegmentId": segment,
**turn_fields,
"createdAt": _ts_base + idx,
},
)
@@ -827,6 +1010,7 @@ def replay_transcript_to_ui_messages(
**last,
"fileEdits": existing,
"activitySegmentId": last.get("activitySegmentId") or segment,
**turn_fields,
}
for idx, rec in enumerate(lines):
@@ -847,6 +1031,7 @@ def replay_transcript_to_ui_messages(
"id": _new_id("u", idx),
"role": "user",
"content": text_s,
**_turn_fields(rec, "user"),
"createdAt": _ts_base + idx,
}
if media_att:
@@ -867,7 +1052,11 @@ def replay_transcript_to_ui_messages(
if ev == "file_edit":
raw_edits = rec.get("edits")
if isinstance(raw_edits, list):
upsert_file_edits([e for e in raw_edits if isinstance(e, dict)], idx)
upsert_file_edits(
[e for e in raw_edits if isinstance(e, dict)],
idx,
_turn_fields(rec, "activity"),
)
continue
if ev == "delta":
@@ -877,7 +1066,8 @@ def replay_transcript_to_ui_messages(
if not isinstance(chunk, str):
continue
close_activity_for_answer()
adopted = find_active_placeholder(messages) if buffer_message_id is None else None
turn_fields = _turn_fields(rec, "answer")
adopted = find_active_placeholder(messages, turn_fields) if buffer_message_id is None else None
if buffer_message_id is None:
if adopted:
buffer_message_id = adopted
@@ -889,6 +1079,7 @@ def replay_transcript_to_ui_messages(
"role": "assistant",
"content": "",
"isStreaming": True,
**_turn_fields(rec, "answer"),
"createdAt": _ts_base + idx,
},
)
@@ -896,7 +1087,12 @@ def replay_transcript_to_ui_messages(
combined = "".join(buffer_parts)
for i, m in enumerate(messages):
if m.get("id") == buffer_message_id:
messages[i] = {**m, "content": combined, "isStreaming": True}
messages[i] = {
**m,
"content": combined,
"isStreaming": True,
**_turn_fields(rec, "answer"),
}
break
continue
@@ -915,13 +1111,19 @@ def replay_transcript_to_ui_messages(
"role": "assistant",
"content": final_text,
"isStreaming": True,
**_turn_fields(rec, "answer"),
"createdAt": _ts_base + idx,
},
)
else:
for i, m in enumerate(messages):
if m.get("id") == buffer_message_id:
messages[i] = {**m, "content": final_text, "isStreaming": True}
messages[i] = {
**m,
"content": final_text,
"isStreaming": True,
**_turn_fields(rec, "answer"),
}
break
buffer_message_id = None
buffer_parts = []
@@ -934,7 +1136,7 @@ def replay_transcript_to_ui_messages(
if not isinstance(chunk, str) or not chunk:
continue
close_file_edit_phase_before_activity()
attach_reasoning_chunk(messages, chunk, idx)
attach_reasoning_chunk(messages, chunk, idx, _turn_fields(rec, "reasoning"))
continue
if ev == "reasoning_end":
@@ -956,7 +1158,7 @@ def replay_transcript_to_ui_messages(
if not isinstance(line, str) or not line:
continue
close_file_edit_phase_before_activity()
attach_reasoning_chunk(messages, line, idx)
attach_reasoning_chunk(messages, line, idx, _turn_fields(rec, "reasoning"))
close_reasoning(messages)
continue
if kind in ("tool_hint", "progress"):
@@ -998,6 +1200,7 @@ def replay_transcript_to_ui_messages(
if visible_structured_events
else last.get("toolEvents"),
"activitySegmentId": last.get("activitySegmentId") or segment,
**_turn_fields(rec, "activity"),
}
messages[-1] = merged
else:
@@ -1010,6 +1213,7 @@ def replay_transcript_to_ui_messages(
"traces": trace_lines,
**({"toolEvents": visible_structured_events} if visible_structured_events else {}),
"activitySegmentId": segment,
**_turn_fields(rec, "activity"),
"createdAt": _ts_base + idx,
},
)
@@ -1033,6 +1237,8 @@ def replay_transcript_to_ui_messages(
lat = rec.get("latency_ms")
if isinstance(lat, (int, float)) and lat >= 0:
extra["latencyMs"] = int(lat)
extra.update(_turn_fields(rec, "answer"))
extra.update(_source_fields(rec))
absorb_complete(extra, idx)
if media:
suppress_until_turn_end = True
@@ -1042,6 +1248,12 @@ def replay_transcript_to_ui_messages(
suppress_until_turn_end = False
active_activity_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):
if m.get("isStreaming"):
messages[i] = {**m, "isStreaming": False}