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:
+222
-10
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user