feat(webui): upgrade settings and sidebar controls (#3906)

* feat(settings): expand settings api payload

* feat(webui): build app-style settings center

* feat(webui): add centered chat search dialog

* fix(webui): shorten chat search label

* fix(webui): center dialog entrance animation

* fix(webui): simplify chat search results

* fix(webui): tighten mobile settings navigation

* feat(webui): persist sidebar state

* feat(webui): add sidebar organization controls

* refactor(webui): organize backend helpers

* refactor(webui): remove utils compatibility shims

* refactor(session): move shared webui helpers out of webui package

* feat(webui): add image generation settings

* style(webui): refine settings overview layout

* fix(webui): localize settings zh-CN copy

* style(webui): add settings status indicators

* feat(webui): show sidebar run indicators

* fix(webui): persist sidebar run indicators

* fix(webui): highlight settings pending status

* fix(webui): align settings test with provider update

* fix(utils): preserve legacy webui helper imports
This commit is contained in:
Xubin Ren
2026-05-19 22:42:38 +08:00
committed by GitHub
parent 30fc05c746
commit 57d5276da1
45 changed files with 6306 additions and 1012 deletions
+36
View File
@@ -1,6 +1,42 @@
"""Utility functions for nanobot."""
from __future__ import annotations
import sys
from importlib import import_module
from types import ModuleType
from nanobot.utils.helpers import ensure_dir
from nanobot.utils.path import abbreviate_path
__all__ = ["ensure_dir", "abbreviate_path"]
class _LazyModuleAlias(ModuleType):
def __init__(self, name: str, target: str) -> None:
super().__init__(name)
self.__dict__["_target"] = target
def _load(self) -> ModuleType:
module = import_module(self.__dict__["_target"])
sys.modules[self.__name__] = module
return module
def __getattr__(self, name: str) -> object:
return getattr(self._load(), name)
def __dir__(self) -> list[str]:
return sorted(set(super().__dir__()) | set(dir(self._load())))
_LEGACY_MODULE_ALIASES = {
"webui_thread_disk": "nanobot.webui.thread_disk",
"webui_transcript": "nanobot.webui.transcript",
"webui_turn_helpers": "nanobot.session.webui_turns",
}
for _legacy_name, _target_name in _LEGACY_MODULE_ALIASES.items():
sys.modules.setdefault(
f"{__name__}.{_legacy_name}",
_LazyModuleAlias(f"{__name__}.{_legacy_name}", _target_name),
)
-2
View File
@@ -120,5 +120,3 @@ def generated_image_tool_result(artifacts: list[dict[str, Any]]) -> str:
},
ensure_ascii=False,
)
-31
View File
@@ -1,31 +0,0 @@
"""Legacy WebUI JSON snapshot path helpers (JSON file); transcripts use webui_transcript."""
from __future__ import annotations
from pathlib import Path
from loguru import logger
from nanobot.config.paths import get_webui_dir
from nanobot.session.manager import SessionManager
from nanobot.utils.webui_transcript import delete_webui_transcript
def webui_thread_file_path(session_key: str) -> Path:
stem = SessionManager.safe_key(session_key)
return get_webui_dir() / f"{stem}.json"
def delete_webui_thread(session_key: str) -> bool:
"""Remove legacy WebUI JSON snapshot and append-only transcript for *session_key*."""
removed = False
path = webui_thread_file_path(session_key)
if path.is_file():
try:
path.unlink()
removed = True
except OSError as e:
logger.warning("Failed to delete webui thread file {}: {}", path, e)
if delete_webui_transcript(session_key):
removed = True
return removed
-564
View File
@@ -1,564 +0,0 @@
"""Append-only WebUI display transcript (JSONL), separate from agent session."""
from __future__ import annotations
import json
import os
import time
import uuid
from pathlib import Path
from typing import Any, Callable
from loguru import logger
from nanobot.config.paths import get_webui_dir
from nanobot.session.manager import SessionManager
WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3
_MAX_TRANSCRIPT_FILE_BYTES = 8 * 1024 * 1024
def webui_transcript_path(session_key: str) -> Path:
stem = SessionManager.safe_key(session_key)
return get_webui_dir() / f"{stem}.jsonl"
def read_transcript_lines(session_key: str) -> list[dict[str, Any]]:
path = webui_transcript_path(session_key)
if not path.is_file():
return []
size = path.stat().st_size
if size > _MAX_TRANSCRIPT_FILE_BYTES:
logger.warning("webui transcript too large, skipping: {}", path)
return []
lines_out: list[dict[str, Any]] = []
try:
with open(path, encoding="utf-8") as f:
for line_no, line in enumerate(f, start=1):
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
logger.warning("bad jsonl at {} line {}", path, line_no)
continue
if isinstance(obj, dict):
lines_out.append(obj)
except OSError as e:
logger.warning("read transcript failed {}: {}", path, e)
return []
return lines_out
def append_transcript_object(session_key: str, obj: dict[str, Any]) -> None:
raw = json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
if len(raw.encode("utf-8")) > _MAX_TRANSCRIPT_FILE_BYTES:
msg = "webui transcript line too large"
raise ValueError(msg)
path = webui_transcript_path(session_key)
path.parent.mkdir(parents=True, exist_ok=True)
line = raw + "\n"
with open(path, "a", encoding="utf-8") as f:
f.write(line)
f.flush()
os.fsync(f.fileno())
def delete_webui_transcript(session_key: str) -> bool:
path = webui_transcript_path(session_key)
if not path.is_file():
return False
try:
path.unlink()
return True
except OSError as e:
logger.warning("Failed to delete webui transcript {}: {}", path, e)
return False
def _format_tool_call_trace(call: Any) -> str | None:
if not call or not isinstance(call, dict):
return None
fn = call.get("function")
name = fn.get("name") if isinstance(fn, dict) else None
if not isinstance(name, str) or not name:
raw_name = call.get("name")
name = raw_name if isinstance(raw_name, str) else ""
if not name:
return None
args = (fn.get("arguments") if isinstance(fn, dict) else None) or call.get("arguments")
if isinstance(args, str) and args.strip():
return f"{name}({args})"
if args and isinstance(args, dict):
return f"{name}({json.dumps(args, ensure_ascii=False)})"
return f"{name}()"
def tool_trace_lines_from_events(events: Any) -> list[str]:
if not isinstance(events, list):
return []
lines: list[str] = []
for event in events:
if not event or not isinstance(event, dict):
continue
if event.get("phase") != "start":
continue
t = _format_tool_call_trace(event)
if t:
lines.append(t)
return lines
def replay_transcript_to_ui_messages(
lines: list[dict[str, Any]],
*,
augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
) -> list[dict[str, Any]]:
"""Fold JSONL records into ``UIMessage``-shaped dicts for the WebUI.
Mirrors the core fold in ``useNanobotStream.ts`` (delta, reasoning,
message+kind, turn_end). ``augment_user_media`` maps persisted filesystem
paths to ``{url, name?}`` / attachment dicts the client expects.
"""
messages: list[dict[str, Any]] = []
buffer_message_id: str | None = None
buffer_parts: list[str] = []
suppress_until_turn_end = False
active_activity_segment_id: str | None = None
active_file_edit_segment_id: str | None = None
activity_segment_counter = 0
_ts_base = int(time.time() * 1000)
def _new_id(prefix: str, idx: int) -> str:
return f"{prefix}-{idx}-{uuid.uuid4().hex[:8]}"
def _new_activity_segment(*, activate: bool = True) -> str:
nonlocal active_activity_segment_id, activity_segment_counter
activity_segment_counter += 1
segment_id = f"activity-{activity_segment_counter}"
if activate:
active_activity_segment_id = segment_id
return segment_id
def _ensure_activity_segment() -> str:
return active_activity_segment_id or _new_activity_segment()
def close_activity_for_answer() -> None:
nonlocal active_activity_segment_id, active_file_edit_segment_id
active_activity_segment_id = None
active_file_edit_segment_id = None
def close_file_edit_phase_before_activity() -> None:
nonlocal active_activity_segment_id, active_file_edit_segment_id
if active_file_edit_segment_id:
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:
for i in range(len(prev) - 1, -1, -1):
candidate = prev[i]
if candidate.get("role") == "user":
break
if candidate.get("kind") == "trace":
break
if candidate.get("role") != "assistant":
continue
content = str(candidate.get("content") or "")
has_answer = len(content) > 0
if (
candidate.get("reasoningStreaming")
or candidate.get("reasoning") is not None
or has_answer
or candidate.get("isStreaming")
):
prev[i] = {
**candidate,
"reasoning": (str(candidate.get("reasoning") or "")) + chunk,
"reasoningStreaming": True,
"activitySegmentId": candidate.get("activitySegmentId") or _ensure_activity_segment(),
}
return
if not has_answer and candidate.get("isStreaming"):
prev[i] = {
**candidate,
"reasoning": chunk,
"reasoningStreaming": True,
"activitySegmentId": candidate.get("activitySegmentId") or _ensure_activity_segment(),
}
return
break
segment = _ensure_activity_segment()
prev.append(
{
"id": _new_id("as", idx),
"role": "assistant",
"content": "",
"isStreaming": True,
"reasoning": chunk,
"reasoningStreaming": True,
"activitySegmentId": segment,
"createdAt": _ts_base + idx,
},
)
def find_active_placeholder(prev: list[dict[str, Any]]) -> str | None:
last = prev[-1] if prev else None
if not last:
return None
if last.get("role") != "assistant" or last.get("kind") == "trace":
return None
if str(last.get("content") or ""):
return None
if not last.get("isStreaming"):
return None
return str(last.get("id"))
def close_reasoning(prev: list[dict[str, Any]]) -> None:
for i in range(len(prev) - 1, -1, -1):
if prev[i].get("reasoningStreaming"):
prev[i] = {**prev[i], "reasoningStreaming": False}
return
def is_reasoning_only_placeholder(m: dict[str, Any]) -> bool:
return (
m.get("role") == "assistant"
and m.get("kind") != "trace"
and not str(m.get("content") or "").strip()
and bool(m.get("reasoning"))
and not m.get("reasoningStreaming")
and not m.get("media")
)
def is_tool_trace_at(index: int) -> bool:
m = messages[index] if 0 <= index < len(messages) else None
return bool(m and m.get("kind") == "trace")
def prune_reasoning_only() -> None:
nonlocal messages
kept: list[dict[str, Any]] = []
for i, m in enumerate(messages):
if is_reasoning_only_placeholder(m) and not is_tool_trace_at(i + 1):
continue
kept.append(m)
messages = kept
def stamp_latency(latency_ms: int) -> None:
for i in range(len(messages) - 1, -1, -1):
if messages[i].get("role") == "assistant" and messages[i].get("kind") != "trace":
messages[i] = {
**messages[i],
"latencyMs": latency_ms,
"isStreaming": False,
}
return
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):
messages[-1] = {
**last,
**extra,
"isStreaming": False,
"reasoningStreaming": False,
}
else:
messages.append(
{
"id": _new_id("as", idx),
"role": "assistant",
"createdAt": _ts_base + idx,
**extra,
},
)
active_activity_segment_id = None
active_file_edit_segment_id = None
def _file_edit_key(edit: dict[str, Any]) -> str:
call_id = str(edit.get("call_id") or "")
tool = str(edit.get("tool") or "")
if call_id:
return f"{call_id}|{tool}"
return f"{tool}|{edit.get('path') or ''}"
def find_file_edit_trace_index(
segment: str | None,
edits: list[dict[str, Any]],
) -> int | None:
incoming_keys = {_file_edit_key(edit) for edit in edits if isinstance(edit, dict)}
for i in range(len(messages) - 1, -1, -1):
candidate = messages[i]
if candidate.get("role") == "user":
break
if candidate.get("kind") != "trace" or not candidate.get("fileEdits"):
continue
if segment and candidate.get("activitySegmentId") == segment:
return i
existing_edits = candidate.get("fileEdits")
if not isinstance(existing_edits, list):
continue
for existing in existing_edits:
if isinstance(existing, dict) and _file_edit_key(existing) in incoming_keys:
return i
return None
def upsert_file_edits(edits: list[dict[str, Any]], idx: int) -> None:
nonlocal active_file_edit_segment_id
if not edits:
return
segment = active_file_edit_segment_id
target_index = find_file_edit_trace_index(segment, edits)
if target_index is not None:
last = messages[target_index]
segment = str(last.get("activitySegmentId") or segment or _new_activity_segment(activate=False))
active_file_edit_segment_id = segment
else:
if not segment:
segment = _new_activity_segment(activate=False)
active_file_edit_segment_id = segment
messages.append(
{
"id": _new_id("tr", idx),
"role": "tool",
"kind": "trace",
"content": "",
"traces": [],
"fileEdits": [],
"activitySegmentId": segment,
"createdAt": _ts_base + idx,
},
)
target_index = len(messages) - 1
last = messages[target_index]
if not segment:
segment = _new_activity_segment(activate=False)
active_file_edit_segment_id = segment
existing = list(last.get("fileEdits") or [])
index_by_key = {
_file_edit_key(edit): pos
for pos, edit in enumerate(existing)
if isinstance(edit, dict)
}
for edit in edits:
if not isinstance(edit, dict):
continue
key = _file_edit_key(edit)
if key in index_by_key:
pos = index_by_key[key]
merged = {**existing[pos], **edit}
if edit.get("path") and not edit.get("pending"):
merged.pop("pending", None)
existing[pos] = merged
else:
index_by_key[key] = len(existing)
existing.append(dict(edit))
messages[target_index] = {
**last,
"fileEdits": existing,
"activitySegmentId": last.get("activitySegmentId") or segment,
}
for idx, rec in enumerate(lines):
ev = rec.get("event")
if ev == "user":
active_activity_segment_id = None
active_file_edit_segment_id = None
text = rec.get("text")
text_s = text if isinstance(text, str) else ""
media_paths = rec.get("media_paths")
paths: list[str] = []
if isinstance(media_paths, list):
paths = [str(p) for p in media_paths if p]
media_att: list[dict[str, Any]] | None = None
if paths and augment_user_media is not None:
media_att = augment_user_media(paths)
row: dict[str, Any] = {
"id": _new_id("u", idx),
"role": "user",
"content": text_s,
"createdAt": _ts_base + idx,
}
if media_att:
row["media"] = media_att
if all(m.get("kind") == "image" for m in media_att):
row["images"] = [{"url": m.get("url"), "name": m.get("name")} for m in media_att]
messages.append(row)
continue
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)
continue
if ev == "delta":
if suppress_until_turn_end:
continue
chunk = rec.get("text")
if not isinstance(chunk, str):
continue
close_activity_for_answer()
adopted = find_active_placeholder(messages) if buffer_message_id is None else None
if buffer_message_id is None:
if adopted:
buffer_message_id = adopted
else:
buffer_message_id = _new_id("buf", idx)
messages.append(
{
"id": buffer_message_id,
"role": "assistant",
"content": "",
"isStreaming": True,
"createdAt": _ts_base + idx,
},
)
buffer_parts.append(chunk)
combined = "".join(buffer_parts)
for i, m in enumerate(messages):
if m.get("id") == buffer_message_id:
messages[i] = {**m, "content": combined, "isStreaming": True}
break
continue
if ev == "stream_end":
if suppress_until_turn_end:
buffer_message_id = None
buffer_parts = []
continue
buffer_message_id = None
buffer_parts = []
continue
if ev == "reasoning_delta":
if suppress_until_turn_end:
continue
chunk = rec.get("text")
if not isinstance(chunk, str) or not chunk:
continue
close_file_edit_phase_before_activity()
attach_reasoning_chunk(messages, chunk, idx)
continue
if ev == "reasoning_end":
if suppress_until_turn_end:
continue
close_reasoning(messages)
continue
if ev == "message":
if suppress_until_turn_end and rec.get("kind") in (
"tool_hint",
"progress",
"reasoning",
):
continue
kind = rec.get("kind")
if kind == "reasoning":
line = rec.get("text")
if not isinstance(line, str) or not line:
continue
close_file_edit_phase_before_activity()
attach_reasoning_chunk(messages, line, idx)
close_reasoning(messages)
continue
if kind in ("tool_hint", "progress"):
structured = tool_trace_lines_from_events(rec.get("tool_events"))
text = rec.get("text")
trace_lines = structured if structured else ([text] if isinstance(text, str) and text else [])
if not trace_lines:
continue
segment = _ensure_activity_segment()
last = messages[-1] if messages else None
if (
last
and last.get("kind") == "trace"
and not last.get("isStreaming")
and (last.get("activitySegmentId") in (None, segment))
):
prev_traces = list(last.get("traces") or [last.get("content")])
merged_traces = prev_traces + trace_lines
messages[-1] = {
**last,
"traces": merged_traces,
"content": trace_lines[-1],
"activitySegmentId": last.get("activitySegmentId") or segment,
}
else:
messages.append(
{
"id": _new_id("tr", idx),
"role": "tool",
"kind": "trace",
"content": trace_lines[-1],
"traces": trace_lines,
"activitySegmentId": segment,
"createdAt": _ts_base + idx,
},
)
continue
buffer_message_id = None
buffer_parts = []
text = rec.get("text")
content_s = text if isinstance(text, str) else ""
media_urls = rec.get("media_urls")
media: list[dict[str, Any]] = []
if isinstance(media_urls, list):
for m in media_urls:
if isinstance(m, dict) and m.get("url"):
media.append(
{
"kind": "image",
"url": str(m["url"]),
"name": str(m.get("name") or ""),
},
)
extra: dict[str, Any] = {"content": content_s}
if media:
extra["media"] = media
lat = rec.get("latency_ms")
if isinstance(lat, (int, float)) and lat >= 0:
extra["latencyMs"] = int(lat)
absorb_complete(extra, idx)
if media:
suppress_until_turn_end = True
continue
if ev == "turn_end":
suppress_until_turn_end = False
active_activity_segment_id = None
active_file_edit_segment_id = None
for i, m in enumerate(messages):
if m.get("isStreaming"):
messages[i] = {**m, "isStreaming": False}
prune_reasoning_only()
lat = rec.get("latency_ms")
if isinstance(lat, (int, float)) and lat >= 0:
stamp_latency(int(lat))
buffer_message_id = None
buffer_parts = []
continue
for m in messages:
m.pop("isStreaming", None)
m.pop("reasoningStreaming", None)
return messages
def build_webui_thread_response(
session_key: str,
*,
augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
) -> dict[str, Any] | None:
"""Return a payload compatible with ``WebuiThreadPersistedPayload``."""
lines = read_transcript_lines(session_key)
if not lines:
return None
msgs = replay_transcript_to_ui_messages(lines, augment_user_media=augment_user_media)
return {
"schemaVersion": WEBUI_TRANSCRIPT_SCHEMA_VERSION,
"sessionKey": session_key,
"messages": msgs,
}
-347
View File
@@ -1,347 +0,0 @@
"""Outbound helpers for the WebSocket/WebUI wire contract.
AgentLoop uses these without importing a concrete channel plugin; only
``channel == "websocket"`` messages are affected.
"""
from __future__ import annotations
import re
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from loguru import logger
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider
from nanobot.session.goal_state import goal_state_ws_blob
from nanobot.session.manager import Session, SessionManager
from nanobot.utils.helpers import truncate_text
from nanobot.utils.llm_runtime import LLMRuntime
WEBUI_SESSION_METADATA_KEY = "webui"
WEBUI_TITLE_METADATA_KEY = "title"
WEBUI_TITLE_USER_EDITED_METADATA_KEY = "title_user_edited"
TITLE_MAX_CHARS = 60
TITLE_GENERATION_MAX_TOKENS = 96
TITLE_GENERATION_REASONING_EFFORT = "none"
# Wall-clock turn start per ``chat_id`` (websocket only). Survives browser refresh while the
# gateway process stays up; cleared on idle/stop and implicitly dropped on restart.
_WEBSOCKET_TURN_WALL_STARTED_AT: dict[str, float] = {}
def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool:
"""Persist a WebUI marker only when the inbound websocket frame opted in."""
if metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return False
session.metadata[WEBUI_SESSION_METADATA_KEY] = True
return True
def clean_generated_title(raw: str | None) -> str:
text = (raw or "").strip()
if not text:
return ""
text = re.sub(r"^\s*(title|标题)\s*[:]\s*", "", text, flags=re.IGNORECASE)
text = text.strip().strip("\"'`“”‘’")
text = re.sub(r"\s+", " ", text).strip()
text = text.rstrip("。.!?,;:")
if len(text) > TITLE_MAX_CHARS:
text = text[: TITLE_MAX_CHARS - 1].rstrip() + ""
return text
def _title_inputs(session: Session) -> tuple[str, str]:
user_text = ""
assistant_text = ""
for message in session.messages:
if message.get("_command") is True:
continue
role = message.get("role")
content = message.get("content")
if not isinstance(content, str) or not content.strip():
continue
if role == "user" and not user_text:
user_text = content.strip()
elif role == "assistant" and not assistant_text:
assistant_text = content.strip()
if user_text and assistant_text:
break
return user_text, assistant_text
async def maybe_generate_webui_title(
*,
sessions: SessionManager,
session_key: str,
provider: LLMProvider,
model: str,
) -> bool:
"""Generate and persist a short title for WebUI-owned sessions only."""
session = sessions.get_or_create(session_key)
if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return False
if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True:
return False
current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY)
if isinstance(current_title, str) and current_title.strip():
return False
user_text, assistant_text = _title_inputs(session)
if not user_text:
return False
prompt = (
"Generate a concise title for this chat.\n"
"Rules:\n"
"- Use the same language as the user when practical.\n"
"- 3 to 8 words.\n"
"- No quotes.\n"
"- No punctuation at the end.\n"
"- Return only the title.\n\n"
f"User: {truncate_text(user_text, 1_000)}"
)
if assistant_text:
prompt += f"\nAssistant: {truncate_text(assistant_text, 1_000)}"
try:
response = await provider.chat_with_retry(
[
{
"role": "system",
"content": (
"You write short, neutral chat titles. "
"Return only the title text."
),
},
{"role": "user", "content": prompt},
],
tools=None,
model=model,
max_tokens=TITLE_GENERATION_MAX_TOKENS,
temperature=0.2,
reasoning_effort=TITLE_GENERATION_REASONING_EFFORT,
retry_mode="standard",
)
except Exception:
logger.debug("Failed to generate webui session title for {}", session_key, exc_info=True)
return False
title = clean_generated_title(response.content)
if not title or title.lower().startswith("error"):
logger.debug(
"WebUI title generation returned no usable title for {} (finish_reason={})",
session_key,
response.finish_reason,
)
return False
session.metadata[WEBUI_TITLE_METADATA_KEY] = title
sessions.save(session)
return True
async def maybe_generate_webui_title_after_turn(
*,
channel: str,
metadata: dict[str, Any],
sessions: SessionManager,
session_key: str,
provider: LLMProvider,
model: str,
) -> bool:
if channel != "websocket" or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return False
return await maybe_generate_webui_title(
sessions=sessions,
session_key=session_key,
provider=provider,
model=model,
)
def websocket_turn_wall_started_at(chat_id: str) -> float | None:
"""Return ``time.time()`` when the active user turn began, if still running."""
return _WEBSOCKET_TURN_WALL_STARTED_AT.get(chat_id)
async def publish_turn_run_status(bus: MessageBus, msg: InboundMessage, status: str) -> None:
"""Notify WebSocket clients while a user turn is executing (timing strip)."""
if msg.channel != "websocket":
return
cid = str(msg.chat_id)
meta: dict[str, Any] = {
**dict(msg.metadata or {}),
"_goal_status": True,
"goal_status": status,
}
if status == "running":
t0 = time.time()
meta["started_at"] = t0
_WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0
else:
_WEBSOCKET_TURN_WALL_STARTED_AT.pop(cid, None)
await bus.publish_outbound(
OutboundMessage(
channel=msg.channel,
chat_id=cid,
content="",
metadata=meta,
),
)
def build_bus_progress_callback(
bus: MessageBus,
msg: InboundMessage,
) -> Callable[..., Awaitable[None]]:
"""Return the bus progress callback for agent runtime events."""
async def _publish_progress(
content: str,
*,
tool_hint: bool = False,
tool_events: list[dict[str, Any]] | None = None,
file_edit_events: list[dict[str, Any]] | None = None,
reasoning: bool = False,
reasoning_end: bool = False,
) -> None:
meta = dict(msg.metadata or {})
meta["_progress"] = True
meta["_tool_hint"] = tool_hint
if reasoning:
meta["_reasoning_delta"] = True
if reasoning_end:
meta["_reasoning_end"] = True
if tool_events:
meta["_tool_events"] = tool_events
if file_edit_events:
meta["_file_edit_events"] = file_edit_events
await bus.publish_outbound(
OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=content,
metadata=meta,
)
)
if msg.channel == "websocket":
async def _websocket_progress(
content: str,
*,
tool_hint: bool = False,
tool_events: list[dict[str, Any]] | None = None,
file_edit_events: list[dict[str, Any]] | None = None,
reasoning: bool = False,
reasoning_end: bool = False,
) -> None:
await _publish_progress(
content,
tool_hint=tool_hint,
tool_events=tool_events,
file_edit_events=file_edit_events,
reasoning=reasoning,
reasoning_end=reasoning_end,
)
return _websocket_progress
async def _bus_progress(
content: str,
*,
tool_hint: bool = False,
tool_events: list[dict[str, Any]] | None = None,
reasoning: bool = False,
reasoning_end: bool = False,
) -> None:
await _publish_progress(
content,
tool_hint=tool_hint,
tool_events=tool_events,
reasoning=reasoning,
reasoning_end=reasoning_end,
)
return _bus_progress
@dataclass
class WebuiTurnCoordinator:
"""Own the WebUI/WebSocket wire details that hang off AgentLoop turns."""
bus: MessageBus
sessions: SessionManager
schedule_background: Callable[[Awaitable[None]], None]
_title_contexts: dict[str, LLMRuntime] = field(default_factory=dict)
def capture_title_context(
self,
session_key: str,
msg: InboundMessage,
llm: LLMRuntime,
) -> None:
if msg.channel == "websocket" and msg.metadata.get("webui") is True:
self._title_contexts[session_key] = llm
def discard(self, session_key: str) -> None:
self._title_contexts.pop(session_key, None)
async def publish_run_status(self, msg: InboundMessage, status: str) -> None:
await publish_turn_run_status(self.bus, msg, status)
async def handle_turn_end(
self,
msg: InboundMessage,
*,
session_key: str,
latency_ms: int | None,
) -> None:
if msg.channel != "websocket":
return
turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True}
if latency_ms is not None:
turn_metadata["latency_ms"] = int(latency_ms)
session = self.sessions.get_or_create(session_key)
turn_metadata["goal_state"] = goal_state_ws_blob(session.metadata)
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content="",
metadata=turn_metadata,
))
self._schedule_title_update(msg, session_key=session_key)
def _schedule_title_update(self, msg: InboundMessage, *, session_key: str) -> None:
title_context = self._title_contexts.pop(session_key, None)
if msg.metadata.get("webui") is not True or title_context is None:
return
async def _generate_title_and_notify(
title_llm: LLMRuntime = title_context,
) -> None:
generated = await maybe_generate_webui_title_after_turn(
channel=msg.channel,
metadata=msg.metadata,
sessions=self.sessions,
session_key=session_key,
provider=title_llm.provider,
model=title_llm.model,
)
if generated:
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content="",
metadata={
**msg.metadata,
"_session_updated": True,
"_session_update_scope": "metadata",
},
))
self.schedule_background(_generate_title_and_notify())