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:
@@ -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),
|
||||
)
|
||||
|
||||
@@ -120,5 +120,3 @@ def generated_image_tool_result(artifacts: list[dict[str, Any]]) -> str:
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user