fix(webui): prevent redundant thread and media reloads (#5164)
This commit is contained in:
@@ -67,6 +67,7 @@ from nanobot.webui.http_utils import (
|
||||
from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions
|
||||
from nanobot.webui.metadata import (
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
||||
WEBUI_SYSTEM_COMMAND_TURN_PREFIX,
|
||||
WEBUI_TURN_METADATA_KEY,
|
||||
)
|
||||
from nanobot.webui.transcript import WEBUI_TRANSCRIPT_INCOMPLETE_KEY
|
||||
@@ -1003,6 +1004,13 @@ class WebSocketChannel(BaseChannel):
|
||||
return
|
||||
# Signal that the agent has fully finished processing the current turn.
|
||||
if isinstance(event, TurnEndEvent):
|
||||
turn_id = (msg.metadata or {}).get(WEBUI_TURN_METADATA_KEY)
|
||||
session_update_scope = (
|
||||
"metadata"
|
||||
if isinstance(turn_id, str)
|
||||
and turn_id.startswith(WEBUI_SYSTEM_COMMAND_TURN_PREFIX)
|
||||
else "thread"
|
||||
)
|
||||
turn_owner = (msg.metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
|
||||
await self.send_turn_end(
|
||||
msg.chat_id,
|
||||
@@ -1011,7 +1019,7 @@ class WebSocketChannel(BaseChannel):
|
||||
metadata=msg.metadata,
|
||||
turn_owner=turn_owner if isinstance(turn_owner, str) else None,
|
||||
)
|
||||
await self.send_session_updated(msg.chat_id, scope="thread")
|
||||
await self.send_session_updated(msg.chat_id, scope=session_update_scope)
|
||||
return
|
||||
if isinstance(event, SessionUpdatedEvent):
|
||||
if conns:
|
||||
|
||||
@@ -49,7 +49,11 @@ from nanobot.webui.http_utils import (
|
||||
from nanobot.webui.http_utils import (
|
||||
parse_request_path as _parse_request_path,
|
||||
)
|
||||
from nanobot.webui.metadata import WEBSOCKET_TURN_OWNER_METADATA_KEY
|
||||
from nanobot.webui.metadata import (
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
||||
WEBUI_SYSTEM_COMMAND_TURN_PREFIX,
|
||||
WEBUI_TURN_METADATA_KEY,
|
||||
)
|
||||
from nanobot.webui.settings_api import settings_payload, update_provider_settings
|
||||
from nanobot.webui.transcript import (
|
||||
append_transcript_object,
|
||||
@@ -1618,6 +1622,43 @@ async def test_send_turn_end_emits_turn_end_event() -> None:
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_command_turn_end_only_refreshes_session_metadata() -> None:
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus),
|
||||
)
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-model")
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="chat-model",
|
||||
content="",
|
||||
metadata={
|
||||
WEBUI_TURN_METADATA_KEY: f"{WEBUI_SYSTEM_COMMAND_TURN_PREFIX}model-switch",
|
||||
},
|
||||
event=TurnEndEvent(),
|
||||
))
|
||||
|
||||
assert _sent_ws_payloads(mock_ws) == [
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "chat-model",
|
||||
"turn_id": f"{WEBUI_SYSTEM_COMMAND_TURN_PREFIX}model-switch",
|
||||
"turn_phase": "complete",
|
||||
"turn_seq": 1,
|
||||
},
|
||||
{
|
||||
"event": "session_updated",
|
||||
"chat_id": "chat-model",
|
||||
"scope": "metadata",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("active_owner", "event_owner", "expected_cleared"),
|
||||
|
||||
@@ -2937,6 +2937,17 @@ async def test_webui_thread_resigns_assistant_media_urls(
|
||||
assert media[0]["url"].startswith("/api/media/")
|
||||
assert media[0]["url"] != "/api/media/old-sig/old-payload"
|
||||
|
||||
repeated = await _http_get(
|
||||
"http://127.0.0.1:29914/api/sessions/websocket:video-replay/webui-thread",
|
||||
headers=auth,
|
||||
)
|
||||
repeated_assistant = next(
|
||||
m for m in repeated.json()["messages"] if m["role"] == "assistant"
|
||||
)
|
||||
assert repeated_assistant["id"] == assistant["id"]
|
||||
assert repeated_assistant["media"][0]["url"] == media[0]["url"]
|
||||
assert len(list(websocket_media.iterdir())) == 1
|
||||
|
||||
fetched = await _http_get(f"http://127.0.0.1:29914{media[0]['url']}")
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.content == b"video"
|
||||
|
||||
@@ -146,16 +146,41 @@ def test_local_markdown_image_is_staged_and_rewritten(
|
||||
channel = _ch(bus, workspace_path=workspace, port=0)
|
||||
|
||||
with patch("nanobot.webui.media_gateway.get_media_dir", side_effect=_fake_media_dir(media)):
|
||||
rewritten = channel.gateway.media.rewrite_local_markdown_images(
|
||||
first = channel.gateway.media.rewrite_local_markdown_images(
|
||||
"The result:\n"
|
||||
)
|
||||
second = channel.gateway.media.rewrite_local_markdown_images(
|
||||
"The result:\n"
|
||||
)
|
||||
|
||||
assert ".iterdir())
|
||||
assert len(staged) == 1
|
||||
assert staged[0].read_bytes() == _PNG_BYTES
|
||||
|
||||
|
||||
def test_modified_local_markdown_image_gets_a_new_immutable_url(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
source = workspace / "demo_arch.png"
|
||||
source.write_bytes(_PNG_BYTES)
|
||||
media = tmp_path / "media"
|
||||
channel = _ch(bus, workspace_path=workspace, port=0)
|
||||
markdown = ""
|
||||
|
||||
with patch("nanobot.webui.media_gateway.get_media_dir", side_effect=_fake_media_dir(media)):
|
||||
first = channel.gateway.media.rewrite_local_markdown_images(markdown)
|
||||
source.write_bytes(_PNG_BYTES + b"updated")
|
||||
second = channel.gateway.media.rewrite_local_markdown_images(markdown)
|
||||
|
||||
assert second != first
|
||||
assert len(list((media / "websocket").iterdir())) == 2
|
||||
|
||||
|
||||
def test_local_markdown_video_is_staged_and_rewritten(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
|
||||
@@ -7,6 +7,7 @@ import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
@@ -126,17 +127,33 @@ def sign_or_stage_media_path(
|
||||
signed = sign_media_path(path, secret=secret, media_dir=media_dir)
|
||||
if signed is not None:
|
||||
return {"url": signed, "name": path.name}
|
||||
staged_tmp: Path | None = None
|
||||
try:
|
||||
if not path.is_file():
|
||||
resolved = path.resolve(strict=True)
|
||||
if not resolved.is_file():
|
||||
return None
|
||||
source_stat = resolved.stat()
|
||||
target_dir = media_dir("websocket")
|
||||
safe_name = safe_filename(path.name) or "attachment"
|
||||
staged = target_dir / f"{uuid.uuid4().hex[:12]}-{safe_name}"
|
||||
shutil.copyfile(path, staged)
|
||||
source_version = "\0".join((
|
||||
os.path.normcase(str(resolved)),
|
||||
str(source_stat.st_size),
|
||||
str(source_stat.st_mtime_ns),
|
||||
str(source_stat.st_ctime_ns),
|
||||
))
|
||||
source_digest = hashlib.sha256(source_version.encode("utf-8")).hexdigest()[:20]
|
||||
staged = target_dir / f"{source_digest}-{safe_name}"
|
||||
if not staged.is_file() or staged.stat().st_size != source_stat.st_size:
|
||||
staged_tmp = target_dir / f".{source_digest}-{uuid.uuid4().hex}.tmp"
|
||||
shutil.copyfile(resolved, staged_tmp)
|
||||
staged_tmp.replace(staged)
|
||||
except OSError as exc:
|
||||
if logger is not None:
|
||||
logger.warning("failed to stage outbound media {}: {}", path, exc)
|
||||
return None
|
||||
finally:
|
||||
if staged_tmp is not None:
|
||||
staged_tmp.unlink(missing_ok=True)
|
||||
signed = sign_media_path(staged, secret=secret, media_dir=media_dir)
|
||||
if signed is None:
|
||||
return None
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Shared WebUI metadata keys."""
|
||||
|
||||
WEBUI_TURN_METADATA_KEY = "webui_turn_id"
|
||||
WEBUI_SYSTEM_COMMAND_TURN_PREFIX = "webui-system:"
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY = "_websocket_turn_owner"
|
||||
WEBUI_MESSAGE_SOURCE_METADATA_KEY = "_webui_message_source"
|
||||
|
||||
+104
-3
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -34,6 +35,7 @@ _TRANSCRIPT_SEGMENT_RE = re.compile(r"^\d{6}\.jsonl$")
|
||||
_DEFAULT_TRANSCRIPT_PAGE_LIMIT = 160
|
||||
_MAX_TRANSCRIPT_PAGE_LIMIT = 1000
|
||||
_WEBUI_TURN_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
||||
_WEBUI_REPLAY_IDENTITY_KEY = "_webui_replay_identity"
|
||||
_MARKDOWN_LOCAL_IMAGE_RE = re.compile(
|
||||
r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)"
|
||||
)
|
||||
@@ -194,6 +196,20 @@ def _flatten_turns(turns: list[list[dict[str, Any]]]) -> list[dict[str, Any]]:
|
||||
return [record for turn in turns for record in turn]
|
||||
|
||||
|
||||
def _records_with_replay_identity(
|
||||
records: list[dict[str, Any]],
|
||||
*,
|
||||
turn_ordinal: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
**record,
|
||||
_WEBUI_REPLAY_IDENTITY_KEY: f"turn:{turn_ordinal}:record:{record_index}",
|
||||
}
|
||||
for record_index, record in enumerate(records)
|
||||
]
|
||||
|
||||
|
||||
def _write_records_to_path(path: Path, rows: list[dict[str, Any]]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
||||
@@ -543,7 +559,14 @@ def _select_transcript_page(
|
||||
break
|
||||
|
||||
selected_chronological = list(reversed(selected))
|
||||
lines = [record for ref in selected_chronological for record in ref.records]
|
||||
lines = [
|
||||
record
|
||||
for ref in selected_chronological
|
||||
for record in _records_with_replay_identity(
|
||||
ref.records,
|
||||
turn_ordinal=ref.ordinal,
|
||||
)
|
||||
]
|
||||
if not selected_chronological:
|
||||
return [], {
|
||||
"before_cursor": None,
|
||||
@@ -1030,6 +1053,74 @@ def _split_transcript_turns(lines: list[dict[str, Any]]) -> list[list[dict[str,
|
||||
return turns
|
||||
|
||||
|
||||
def _annotate_replay_identities(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
record
|
||||
for turn_ordinal, turn in enumerate(_split_transcript_turns(lines))
|
||||
for record in _records_with_replay_identity(
|
||||
turn,
|
||||
turn_ordinal=turn_ordinal,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _stable_record_digest(record: dict[str, Any]) -> str:
|
||||
persisted = {
|
||||
key: value
|
||||
for key, value in record.items()
|
||||
if key != _WEBUI_REPLAY_IDENTITY_KEY
|
||||
}
|
||||
raw = json.dumps(
|
||||
persisted,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
default=str,
|
||||
)
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _ensure_replay_identities(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Give backfilled/recovered rows a stable identity beside persisted rows."""
|
||||
annotated: list[dict[str, Any]] = []
|
||||
for fallback_turn_index, turn in enumerate(_split_transcript_turns(lines)):
|
||||
anchor = next(
|
||||
(
|
||||
value
|
||||
for record in turn
|
||||
if isinstance(
|
||||
value := record.get(_WEBUI_REPLAY_IDENTITY_KEY),
|
||||
str,
|
||||
)
|
||||
and value
|
||||
),
|
||||
None,
|
||||
)
|
||||
if anchor and ":record:" in anchor:
|
||||
turn_identity = anchor.rsplit(":record:", 1)[0]
|
||||
else:
|
||||
turn_digest = hashlib.sha256(
|
||||
"\n".join(_stable_record_digest(record) for record in turn).encode("ascii")
|
||||
).hexdigest()[:16]
|
||||
turn_identity = f"legacy:{fallback_turn_index}:{turn_digest}"
|
||||
synthetic_occurrences: dict[str, int] = {}
|
||||
for record in turn:
|
||||
identity = record.get(_WEBUI_REPLAY_IDENTITY_KEY)
|
||||
if isinstance(identity, str) and identity:
|
||||
annotated.append(record)
|
||||
continue
|
||||
digest = _stable_record_digest(record)
|
||||
occurrence = synthetic_occurrences.get(digest, 0)
|
||||
synthetic_occurrences[digest] = occurrence + 1
|
||||
annotated.append({
|
||||
**record,
|
||||
_WEBUI_REPLAY_IDENTITY_KEY: (
|
||||
f"{turn_identity}:synthetic:{digest}:{occurrence}"
|
||||
),
|
||||
})
|
||||
return annotated
|
||||
|
||||
|
||||
def _transcript_turn_signature(records: list[dict[str, Any]]) -> tuple[str, ...]:
|
||||
texts: list[str] = []
|
||||
for message in replay_transcript_to_ui_messages(records):
|
||||
@@ -1464,9 +1555,18 @@ def replay_transcript_to_ui_messages(
|
||||
_ts_base = _now_ms()
|
||||
closed_turn_ids: set[str] = set()
|
||||
replay_turn_aliases: dict[str, str] = {}
|
||||
generated_id_occurrences: dict[str, int] = {}
|
||||
|
||||
def _new_id(prefix: str, idx: int) -> str:
|
||||
return f"{prefix}-{idx}-{uuid.uuid4().hex[:8]}"
|
||||
record = lines[idx] if 0 <= idx < len(lines) else {}
|
||||
identity = record.get(_WEBUI_REPLAY_IDENTITY_KEY)
|
||||
if not isinstance(identity, str) or not identity:
|
||||
identity = f"direct:{idx}:{_stable_record_digest(record)}"
|
||||
digest = hashlib.sha256(f"{prefix}\0{identity}".encode("utf-8")).hexdigest()[:16]
|
||||
base = f"{prefix}-{digest}"
|
||||
occurrence = generated_id_occurrences.get(base, 0)
|
||||
generated_id_occurrences[base] = occurrence + 1
|
||||
return base if occurrence == 0 else f"{base}-{occurrence}"
|
||||
|
||||
def _created_at_ms(rec: dict[str, Any], idx: int) -> int:
|
||||
created_at_ms = _valid_created_at_ms(rec.get("created_at_ms"))
|
||||
@@ -2255,7 +2355,7 @@ def build_webui_thread_response(
|
||||
if paginated:
|
||||
lines, page = _select_transcript_page(session_key, limit=limit, before=before)
|
||||
else:
|
||||
lines = read_transcript_lines(session_key)
|
||||
lines = _annotate_replay_identities(read_transcript_lines(session_key))
|
||||
if not lines and active_turn_started_at is None:
|
||||
return None
|
||||
lines = inject_missing_user_events_from_session(session_key, lines, session_messages)
|
||||
@@ -2264,6 +2364,7 @@ def build_webui_thread_response(
|
||||
session_messages,
|
||||
session_key=session_key,
|
||||
)
|
||||
lines = _ensure_replay_identities(lines)
|
||||
fork_boundary = fork_boundary_message_count(lines)
|
||||
msgs = replay_transcript_to_ui_messages(
|
||||
lines,
|
||||
|
||||
Reference in New Issue
Block a user