mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-15 07:14:08 +00:00
feat(webui): add assistant reply fork-from-here
This commit is contained in:
parent
4a58b83acc
commit
03bca4c0a9
@ -5,6 +5,37 @@ nanobot Python distribution (`pip install nanobot-ai`).
|
||||
|
||||
---
|
||||
|
||||
## Tabler Icons — WebUI fork action icon (MIT)
|
||||
|
||||
- **Source**: https://github.com/tabler/tabler-icons
|
||||
- **Bundled**: inline SVG path for `arrow-fork` in `nanobot/web/dist/assets/index-*.js`
|
||||
|
||||
```
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2020-2026 Paweł Kuna
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## KaTeX — math rendering (MIT)
|
||||
|
||||
- **Source**: https://github.com/KaTeX/KaTeX
|
||||
|
||||
@ -45,6 +45,11 @@ from nanobot.webui.http_utils import (
|
||||
query_first as _query_first,
|
||||
)
|
||||
from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions
|
||||
from nanobot.webui.transcript import (
|
||||
delete_webui_transcript,
|
||||
fork_transcript_before_user_index,
|
||||
write_session_messages_as_transcript,
|
||||
)
|
||||
from nanobot.webui.transcription_ws import webui_transcription_event
|
||||
from nanobot.webui.websocket_logging import websockets_server_logger
|
||||
|
||||
@ -668,6 +673,61 @@ class WebSocketChannel(BaseChannel):
|
||||
)
|
||||
await self._hydrate_after_subscribe(new_id)
|
||||
return
|
||||
if t == "fork_chat":
|
||||
source_chat_id = envelope.get("source_chat_id")
|
||||
raw_index = envelope.get("before_user_index")
|
||||
if not _is_valid_chat_id(source_chat_id):
|
||||
await self._send_event(connection, "error", detail="invalid source_chat_id")
|
||||
return
|
||||
if (
|
||||
isinstance(raw_index, bool)
|
||||
or not isinstance(raw_index, int)
|
||||
or raw_index < 0
|
||||
):
|
||||
await self._send_event(connection, "error", detail="invalid before_user_index")
|
||||
return
|
||||
if self.gateway.session_manager is None:
|
||||
await self._send_event(connection, "error", detail="session_manager_unavailable")
|
||||
return
|
||||
|
||||
new_id = str(uuid.uuid4())
|
||||
source_key = f"websocket:{source_chat_id}"
|
||||
target_key = f"websocket:{new_id}"
|
||||
try:
|
||||
forked = self.gateway.session_manager.fork_session_before_user_index(
|
||||
source_key,
|
||||
target_key,
|
||||
raw_index,
|
||||
)
|
||||
if forked is None:
|
||||
await self._send_event(connection, "error", detail="invalid fork source or index")
|
||||
return
|
||||
transcript_ok = fork_transcript_before_user_index(
|
||||
source_key,
|
||||
target_key,
|
||||
raw_index,
|
||||
)
|
||||
if not transcript_ok:
|
||||
write_session_messages_as_transcript(target_key, forked.messages)
|
||||
except Exception as exc:
|
||||
delete_webui_transcript(target_key)
|
||||
self.gateway.session_manager.delete_session(target_key)
|
||||
self.logger.warning("fork_chat failed: {}", exc)
|
||||
await self._send_event(connection, "error", detail="fork_chat_failed")
|
||||
return
|
||||
|
||||
scope = self._workspaces.scope_for_session_key(target_key)
|
||||
self._attach(connection, new_id)
|
||||
await self._send_event(connection, "attached", chat_id=new_id)
|
||||
await self._send_event(
|
||||
connection,
|
||||
"session_updated",
|
||||
chat_id=new_id,
|
||||
scope="metadata",
|
||||
workspace_scope=scope.payload(),
|
||||
)
|
||||
await self._hydrate_after_subscribe(new_id)
|
||||
return
|
||||
if t == "attach":
|
||||
cid = envelope.get("chat_id")
|
||||
if not _is_valid_chat_id(cid):
|
||||
|
||||
@ -5,6 +5,7 @@ import os
|
||||
import re
|
||||
import shutil
|
||||
from contextlib import suppress
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@ -30,6 +31,14 @@ _TOOL_CALL_ECHO_RE = re.compile(r'^\s*(?:generate_image|message)\([^)]*\)\s*$')
|
||||
_SESSION_PREVIEW_MAX_CHARS = 120
|
||||
_SESSION_LIST_PREVIEW_MAX_RECORDS = 200
|
||||
_SESSION_LIST_PREVIEW_MAX_CHARS = 1_000_000
|
||||
_FORK_VOLATILE_METADATA_KEYS = {
|
||||
"goal_state",
|
||||
"pending_user_turn",
|
||||
"runtime_checkpoint",
|
||||
"thread_goal",
|
||||
"title",
|
||||
"title_user_edited",
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_assistant_replay_text(content: str) -> str:
|
||||
@ -628,6 +637,62 @@ class SessionManager:
|
||||
logger.warning("Failed to delete session file {}: {}", path, e)
|
||||
return False
|
||||
|
||||
def fork_session_before_user_index(
|
||||
self,
|
||||
source_key: str,
|
||||
target_key: str,
|
||||
before_user_index: int,
|
||||
) -> Session | None:
|
||||
"""Create *target_key* from *source_key* before a global user-message index.
|
||||
|
||||
``before_user_index`` is zero-based over user messages in the full session:
|
||||
``0`` means "before the first user message", ``1`` means "before the
|
||||
second user message", and so on. A value equal to the total user-message
|
||||
count copies the full session prefix. The target user message itself is
|
||||
not copied; the WebUI pre-fills it in the composer for editing and resend.
|
||||
"""
|
||||
if before_user_index < 0:
|
||||
return None
|
||||
source = self._cache.get(source_key) or self._load(source_key)
|
||||
if source is None:
|
||||
return None
|
||||
|
||||
copied: list[dict[str, Any]] = []
|
||||
user_index = 0
|
||||
found_target = False
|
||||
for message in source.messages:
|
||||
if message.get("role") == "user":
|
||||
if user_index == before_user_index:
|
||||
found_target = True
|
||||
break
|
||||
user_index += 1
|
||||
copied.append(deepcopy(message))
|
||||
if user_index == before_user_index:
|
||||
found_target = True
|
||||
if not found_target:
|
||||
return None
|
||||
|
||||
metadata = deepcopy(source.metadata)
|
||||
for key in _FORK_VOLATILE_METADATA_KEYS:
|
||||
metadata.pop(key, None)
|
||||
|
||||
last_consolidated = min(source.last_consolidated, len(copied))
|
||||
if source.last_consolidated > len(copied):
|
||||
metadata.pop("_last_summary", None)
|
||||
last_consolidated = 0
|
||||
|
||||
now = datetime.now()
|
||||
target = Session(
|
||||
key=target_key,
|
||||
messages=copied,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
metadata=metadata,
|
||||
last_consolidated=last_consolidated,
|
||||
)
|
||||
self.save(target, fsync=True)
|
||||
return target
|
||||
|
||||
def read_session_file(self, key: str) -> dict[str, Any] | None:
|
||||
"""Load a session from disk without caching; intended for read-only HTTP endpoints.
|
||||
|
||||
|
||||
@ -274,6 +274,125 @@ class WebUITranscriptRecorder:
|
||||
self._turn_sequences.pop((chat_id, turn_id), None)
|
||||
|
||||
|
||||
def _chat_id_from_session_key(session_key: str) -> str | None:
|
||||
if not session_key.startswith("websocket:"):
|
||||
return None
|
||||
chat_id = session_key.split(":", 1)[1].strip()
|
||||
return chat_id or None
|
||||
|
||||
|
||||
def _is_user_transcript_row(row: dict[str, Any]) -> bool:
|
||||
return row.get("event") == "user" or row.get("role") == "user"
|
||||
|
||||
|
||||
def fork_transcript_before_user_index(
|
||||
source_key: str,
|
||||
target_key: str,
|
||||
before_user_index: int,
|
||||
) -> bool:
|
||||
"""Copy transcript rows before a zero-based global user-message index.
|
||||
|
||||
``before_user_index == user_count`` copies the full transcript prefix. WebUI
|
||||
uses that when forking from an assistant reply at the end of a chat.
|
||||
"""
|
||||
if before_user_index < 0:
|
||||
return False
|
||||
lines = read_transcript_lines(source_key)
|
||||
if not lines:
|
||||
return False
|
||||
|
||||
target_chat_id = _chat_id_from_session_key(target_key)
|
||||
copied: list[dict[str, Any]] = []
|
||||
user_index = 0
|
||||
found_target = False
|
||||
for row in lines:
|
||||
if _is_user_transcript_row(row):
|
||||
if user_index == before_user_index:
|
||||
found_target = True
|
||||
break
|
||||
user_index += 1
|
||||
dup = json.loads(json.dumps(row, ensure_ascii=False))
|
||||
if target_chat_id is not None:
|
||||
dup["chat_id"] = target_chat_id
|
||||
copied.append(dup)
|
||||
if user_index == before_user_index:
|
||||
found_target = True
|
||||
|
||||
if not found_target:
|
||||
return False
|
||||
|
||||
path = webui_transcript_path(target_key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(".jsonl.tmp")
|
||||
try:
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
for row in copied:
|
||||
raw = json.dumps(row, ensure_ascii=False, separators=(",", ":"))
|
||||
if len(raw.encode("utf-8")) > _MAX_TRANSCRIPT_FILE_BYTES:
|
||||
raise ValueError("webui transcript line too large")
|
||||
f.write(raw + "\n")
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, path)
|
||||
except BaseException:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
return True
|
||||
|
||||
|
||||
def write_session_messages_as_transcript(
|
||||
target_key: str,
|
||||
messages: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Write a minimal WebUI transcript from already-truncated session messages."""
|
||||
target_chat_id = _chat_id_from_session_key(target_key)
|
||||
path = webui_transcript_path(target_key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(".jsonl.tmp")
|
||||
try:
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
for msg in messages:
|
||||
role = msg.get("role")
|
||||
content = msg.get("content")
|
||||
text = content if isinstance(content, str) else ""
|
||||
if role == "user":
|
||||
row: dict[str, Any] = {
|
||||
"event": "user",
|
||||
"chat_id": target_chat_id,
|
||||
"text": text,
|
||||
}
|
||||
media = msg.get("media")
|
||||
if isinstance(media, list) and media:
|
||||
row["media_paths"] = [str(p) for p in media if isinstance(p, str) and p]
|
||||
for key in ("cli_apps", "mcp_presets"):
|
||||
value = msg.get(key)
|
||||
if isinstance(value, list) and value:
|
||||
row[key] = json.loads(json.dumps(value, ensure_ascii=False))
|
||||
elif role == "assistant":
|
||||
if not text.strip():
|
||||
continue
|
||||
row = {
|
||||
"event": "message",
|
||||
"chat_id": target_chat_id,
|
||||
"text": text,
|
||||
}
|
||||
media = msg.get("media")
|
||||
if isinstance(media, list) and media:
|
||||
row["media"] = [str(p) for p in media if isinstance(p, str) and p]
|
||||
else:
|
||||
continue
|
||||
raw = json.dumps(row, ensure_ascii=False, separators=(",", ":"))
|
||||
if len(raw.encode("utf-8")) > _MAX_TRANSCRIPT_FILE_BYTES:
|
||||
raise ValueError("webui transcript line too large")
|
||||
f.write(raw + "\n")
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, path)
|
||||
except BaseException:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def delete_webui_transcript(session_key: str) -> bool:
|
||||
path = webui_transcript_path(session_key)
|
||||
if not path.is_file():
|
||||
|
||||
@ -426,6 +426,87 @@ def test_get_history_synthesizes_cli_app_attachment_breadcrumb():
|
||||
}]
|
||||
|
||||
|
||||
def test_fork_session_before_user_index_copies_only_prefix(tmp_path):
|
||||
manager = SessionManager(tmp_path)
|
||||
source = manager.get_or_create("websocket:source")
|
||||
source.metadata["webui"] = True
|
||||
source.metadata["title"] = "Old title"
|
||||
source.metadata["goal_state"] = {"status": "active", "objective": "do not inherit"}
|
||||
source.add_message("user", "round1")
|
||||
source.add_message("assistant", "answer1")
|
||||
source.add_message("user", "round2 fork me")
|
||||
source.add_message("assistant", "answer2")
|
||||
source.add_message("user", "round3 must not appear")
|
||||
manager.save(source)
|
||||
|
||||
forked = manager.fork_session_before_user_index(
|
||||
"websocket:source",
|
||||
"websocket:fork",
|
||||
1,
|
||||
)
|
||||
|
||||
assert forked is not None
|
||||
assert [m["content"] for m in forked.messages] == ["round1", "answer1"]
|
||||
assert forked.metadata["webui"] is True
|
||||
assert "title" not in forked.metadata
|
||||
assert "goal_state" not in forked.metadata
|
||||
saved = manager.read_session_file("websocket:fork")
|
||||
assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"]
|
||||
|
||||
|
||||
def test_fork_session_rejects_negative_missing_and_out_of_range(tmp_path):
|
||||
manager = SessionManager(tmp_path)
|
||||
source = manager.get_or_create("websocket:source")
|
||||
source.add_message("user", "round1")
|
||||
manager.save(source)
|
||||
|
||||
assert manager.fork_session_before_user_index("websocket:source", "websocket:x", -1) is None
|
||||
assert manager.fork_session_before_user_index("websocket:missing", "websocket:x", 0) is None
|
||||
assert manager.fork_session_before_user_index("websocket:source", "websocket:x", 2) is None
|
||||
|
||||
|
||||
def test_fork_session_allows_index_equal_to_user_count(tmp_path):
|
||||
manager = SessionManager(tmp_path)
|
||||
source = manager.get_or_create("websocket:source")
|
||||
source.add_message("user", "round1")
|
||||
source.add_message("assistant", "answer1")
|
||||
manager.save(source)
|
||||
|
||||
forked = manager.fork_session_before_user_index(
|
||||
"websocket:source",
|
||||
"websocket:fork",
|
||||
1,
|
||||
)
|
||||
|
||||
assert forked is not None
|
||||
assert [m["content"] for m in forked.messages] == ["round1", "answer1"]
|
||||
|
||||
|
||||
def test_fork_session_drops_summary_when_fork_point_is_inside_consolidated_prefix(tmp_path):
|
||||
manager = SessionManager(tmp_path)
|
||||
source = manager.get_or_create("websocket:source")
|
||||
source.messages = [
|
||||
{"role": "user", "content": "round1"},
|
||||
{"role": "assistant", "content": "answer1"},
|
||||
{"role": "user", "content": "round2 fork me"},
|
||||
{"role": "assistant", "content": "answer2"},
|
||||
]
|
||||
source.last_consolidated = 4
|
||||
source.metadata["_last_summary"] = {"text": "round2 fork me and answer2"}
|
||||
manager.save(source)
|
||||
|
||||
forked = manager.fork_session_before_user_index(
|
||||
"websocket:source",
|
||||
"websocket:fork",
|
||||
1,
|
||||
)
|
||||
|
||||
assert forked is not None
|
||||
assert [m["content"] for m in forked.messages] == ["round1", "answer1"]
|
||||
assert forked.last_consolidated == 0
|
||||
assert "_last_summary" not in forked.metadata
|
||||
|
||||
|
||||
def test_get_history_ignores_media_kwarg_on_non_user_rows():
|
||||
"""``media`` only ever appears on user entries in practice, but the
|
||||
synthesizer must be defensive: assistants / tools with list content
|
||||
|
||||
@ -45,6 +45,7 @@ from nanobot.webui.http_utils import (
|
||||
parse_request_path as _parse_request_path,
|
||||
)
|
||||
from nanobot.webui.settings_api import settings_payload, update_provider_settings
|
||||
from nanobot.webui.transcript import append_transcript_object, read_transcript_lines
|
||||
|
||||
# -- Shared helpers (aligned with test_websocket_integration.py) ---------------
|
||||
|
||||
@ -2385,6 +2386,216 @@ async def test_multiplex_new_chat_roundtrip(bus: MagicMock) -> None:
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fork_chat_copies_only_prefix_session_and_transcript(
|
||||
bus: MagicMock,
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
sessions = SessionManager(tmp_path / "sessions")
|
||||
source = sessions.get_or_create("websocket:source")
|
||||
source.metadata["webui"] = True
|
||||
source.add_message("user", "round1")
|
||||
source.add_message("assistant", "answer1")
|
||||
source.add_message("user", "round2 fork me")
|
||||
source.add_message("assistant", "answer2")
|
||||
source.add_message("user", "round3 must not appear")
|
||||
sessions.save(source)
|
||||
for ev in (
|
||||
{"event": "user", "chat_id": "source", "text": "round1"},
|
||||
{"event": "message", "chat_id": "source", "text": "answer1"},
|
||||
{"event": "turn_end", "chat_id": "source"},
|
||||
{"event": "user", "chat_id": "source", "text": "round2 fork me"},
|
||||
{"event": "message", "chat_id": "source", "text": "answer2"},
|
||||
{"event": "user", "chat_id": "source", "text": "round3 must not appear"},
|
||||
):
|
||||
append_transcript_object("websocket:source", ev)
|
||||
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
conn = AsyncMock()
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{"type": "fork_chat", "source_chat_id": "source", "before_user_index": 1},
|
||||
)
|
||||
|
||||
sent = [json.loads(call.args[0]) for call in conn.send.await_args_list]
|
||||
attached = next(item for item in sent if item["event"] == "attached")
|
||||
fork_id = attached["chat_id"]
|
||||
saved = sessions.read_session_file(f"websocket:{fork_id}")
|
||||
assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"]
|
||||
fork_lines = read_transcript_lines(f"websocket:{fork_id}")
|
||||
assert [line.get("text") for line in fork_lines] == ["round1", "answer1", None]
|
||||
assert all(line.get("chat_id") == fork_id for line in fork_lines)
|
||||
assert "round3 must not appear" not in json.dumps(saved, ensure_ascii=False)
|
||||
bus.publish_inbound.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fork_chat_falls_back_to_session_prefix_when_transcript_lacks_user_rows(
|
||||
bus: MagicMock,
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
sessions = SessionManager(tmp_path / "sessions")
|
||||
source = sessions.get_or_create("websocket:source")
|
||||
source.metadata["webui"] = True
|
||||
source.add_message("user", "round1")
|
||||
source.add_message("assistant", "answer1")
|
||||
source.add_message("user", "round2 fork me")
|
||||
source.add_message("assistant", "answer2")
|
||||
source.add_message("user", "round3 must not appear")
|
||||
sessions.save(source)
|
||||
append_transcript_object(
|
||||
"websocket:source",
|
||||
{"event": "message", "chat_id": "source", "text": "answer1"},
|
||||
)
|
||||
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
conn = AsyncMock()
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{"type": "fork_chat", "source_chat_id": "source", "before_user_index": 1},
|
||||
)
|
||||
|
||||
sent = [json.loads(call.args[0]) for call in conn.send.await_args_list]
|
||||
attached = next(item for item in sent if item["event"] == "attached")
|
||||
fork_id = attached["chat_id"]
|
||||
saved = sessions.read_session_file(f"websocket:{fork_id}")
|
||||
assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"]
|
||||
fork_lines = read_transcript_lines(f"websocket:{fork_id}")
|
||||
assert [line.get("text") for line in fork_lines] == ["round1", "answer1"]
|
||||
assert "round3 must not appear" not in json.dumps(fork_lines, ensure_ascii=False)
|
||||
bus.publish_inbound.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fork_chat_allows_index_equal_to_user_count(
|
||||
bus: MagicMock,
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
sessions = SessionManager(tmp_path / "sessions")
|
||||
source = sessions.get_or_create("websocket:source")
|
||||
source.metadata["webui"] = True
|
||||
source.add_message("user", "round1")
|
||||
source.add_message("assistant", "answer1")
|
||||
sessions.save(source)
|
||||
append_transcript_object("websocket:source", {"event": "user", "chat_id": "source", "text": "round1"})
|
||||
append_transcript_object(
|
||||
"websocket:source",
|
||||
{"event": "message", "chat_id": "source", "text": "answer1"},
|
||||
)
|
||||
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
conn = AsyncMock()
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{"type": "fork_chat", "source_chat_id": "source", "before_user_index": 1},
|
||||
)
|
||||
|
||||
sent = [json.loads(call.args[0]) for call in conn.send.await_args_list]
|
||||
attached = next(item for item in sent if item["event"] == "attached")
|
||||
fork_id = attached["chat_id"]
|
||||
saved = sessions.read_session_file(f"websocket:{fork_id}")
|
||||
assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"]
|
||||
fork_lines = read_transcript_lines(f"websocket:{fork_id}")
|
||||
assert [line.get("text") for line in fork_lines] == ["round1", "answer1"]
|
||||
bus.publish_inbound.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fork_chat_rejects_invalid_source_and_index(bus: MagicMock, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path / "sessions")
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
conn = AsyncMock()
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{"type": "fork_chat", "source_chat_id": "bad/source", "before_user_index": 0},
|
||||
)
|
||||
payload = json.loads(conn.send.await_args.args[0])
|
||||
assert payload["event"] == "error"
|
||||
assert payload["detail"] == "invalid source_chat_id"
|
||||
|
||||
conn.reset_mock()
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{"type": "fork_chat", "source_chat_id": "missing", "before_user_index": -1},
|
||||
)
|
||||
payload = json.loads(conn.send.await_args.args[0])
|
||||
assert payload["event"] == "error"
|
||||
assert payload["detail"] == "invalid before_user_index"
|
||||
bus.publish_inbound.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_message_envelope_appends_user_transcript(
|
||||
bus: MagicMock,
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
sessions = SessionManager(tmp_path / "sessions")
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
conn = AsyncMock()
|
||||
conn.remote_address = ("127.0.0.1", 50123)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": "source",
|
||||
"content": "round1",
|
||||
"webui": True,
|
||||
},
|
||||
)
|
||||
|
||||
[line] = read_transcript_lines("websocket:source")
|
||||
assert {
|
||||
"event": line.get("event"),
|
||||
"chat_id": line.get("chat_id"),
|
||||
"text": line.get("text"),
|
||||
} == {"event": "user", "chat_id": "source", "text": "round1"}
|
||||
assert isinstance(line.get("turn_id"), str)
|
||||
assert line.get("turn_phase") == "user"
|
||||
assert line.get("turn_seq") == 1
|
||||
inbound = bus.publish_inbound.await_args.args[0]
|
||||
assert inbound.chat_id == "source"
|
||||
assert inbound.content == "round1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiplex_two_chats_isolated(bus: MagicMock) -> None:
|
||||
port = 29932
|
||||
|
||||
@ -6,8 +6,10 @@ from nanobot.webui.transcript import (
|
||||
WEBUI_TRANSCRIPT_SCHEMA_VERSION,
|
||||
append_transcript_object,
|
||||
build_webui_thread_response,
|
||||
fork_transcript_before_user_index,
|
||||
read_transcript_lines,
|
||||
replay_transcript_to_ui_messages,
|
||||
write_session_messages_as_transcript,
|
||||
)
|
||||
|
||||
|
||||
@ -20,6 +22,79 @@ def test_append_and_read_roundtrip(tmp_path, monkeypatch) -> None:
|
||||
assert lines[0]["text"] == "hello"
|
||||
|
||||
|
||||
def test_fork_transcript_before_user_index_copies_only_prefix(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
source = "websocket:source"
|
||||
for ev in (
|
||||
{"event": "user", "chat_id": "source", "text": "round1"},
|
||||
{"event": "message", "chat_id": "source", "text": "answer1"},
|
||||
{"event": "turn_end", "chat_id": "source"},
|
||||
{"event": "user", "chat_id": "source", "text": "round2 fork me"},
|
||||
{"event": "message", "chat_id": "source", "text": "answer2"},
|
||||
{"event": "user", "chat_id": "source", "text": "round3 must not appear"},
|
||||
):
|
||||
append_transcript_object(source, ev)
|
||||
|
||||
ok = fork_transcript_before_user_index(source, "websocket:fork", 1)
|
||||
|
||||
assert ok is True
|
||||
lines = read_transcript_lines("websocket:fork")
|
||||
assert [line.get("text") for line in lines] == ["round1", "answer1", None]
|
||||
assert all(line.get("chat_id") == "fork" for line in lines)
|
||||
assert "round2 fork me" not in "\n".join(str(line.get("text")) for line in lines)
|
||||
assert "round3 must not appear" not in "\n".join(str(line.get("text")) for line in lines)
|
||||
|
||||
|
||||
def test_fork_transcript_rejects_out_of_range_user_index(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
source = "websocket:source"
|
||||
append_transcript_object(source, {"event": "user", "chat_id": "source", "text": "round1"})
|
||||
|
||||
assert fork_transcript_before_user_index(source, "websocket:fork", 2) is False
|
||||
assert read_transcript_lines("websocket:fork") == []
|
||||
|
||||
|
||||
def test_fork_transcript_allows_index_equal_to_user_count(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
source = "websocket:source"
|
||||
for ev in (
|
||||
{"event": "user", "chat_id": "source", "text": "round1"},
|
||||
{"event": "message", "chat_id": "source", "text": "answer1"},
|
||||
):
|
||||
append_transcript_object(source, ev)
|
||||
|
||||
ok = fork_transcript_before_user_index(source, "websocket:fork", 1)
|
||||
|
||||
assert ok is True
|
||||
assert [line.get("text") for line in read_transcript_lines("websocket:fork")] == [
|
||||
"round1",
|
||||
"answer1",
|
||||
]
|
||||
|
||||
|
||||
def test_write_session_messages_as_transcript_builds_canonical_prefix(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
|
||||
write_session_messages_as_transcript(
|
||||
"websocket:fork",
|
||||
[
|
||||
{"role": "user", "content": "round1"},
|
||||
{"role": "assistant", "content": "answer1"},
|
||||
],
|
||||
)
|
||||
|
||||
lines = read_transcript_lines("websocket:fork")
|
||||
assert lines == [
|
||||
{"event": "user", "chat_id": "fork", "text": "round1"},
|
||||
{"event": "message", "chat_id": "fork", "text": "answer1"},
|
||||
]
|
||||
msgs = replay_transcript_to_ui_messages(lines)
|
||||
assert [m["content"] for m in msgs] == ["round1", "answer1"]
|
||||
|
||||
|
||||
def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
key = "websocket:t2"
|
||||
|
||||
@ -526,7 +526,7 @@ function Shell({
|
||||
const { t, i18n } = useTranslation();
|
||||
const { client, token } = useClient();
|
||||
const { theme, toggle } = useTheme();
|
||||
const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
|
||||
const { sessions, loading, refresh, createChat, forkChat, deleteChat } = useSessions();
|
||||
const { state: sidebarState, update: updateSidebarState } =
|
||||
useSidebarState(sessions, !loading);
|
||||
const initialRouteRef = useRef<ShellRoute | null>(null);
|
||||
@ -885,6 +885,25 @@ function Shell({
|
||||
}
|
||||
}, [activeWorkspaceScope, createChat, navigate, t]);
|
||||
|
||||
const onForkChat = useCallback(async (
|
||||
sourceChatId: string,
|
||||
beforeUserIndex: number,
|
||||
) => {
|
||||
try {
|
||||
const chatId = await forkChat(sourceChatId, beforeUserIndex);
|
||||
navigate({
|
||||
view: "chat",
|
||||
activeKey: `websocket:${chatId}`,
|
||||
settingsSection: "overview",
|
||||
});
|
||||
setMobileSidebarOpen(false);
|
||||
return chatId;
|
||||
} catch (e) {
|
||||
console.error("Failed to fork chat", e);
|
||||
return null;
|
||||
}
|
||||
}, [forkChat, navigate]);
|
||||
|
||||
const onNewChat = useCallback(() => {
|
||||
navigate(defaultShellRoute());
|
||||
setDraftWorkspaceScope(null);
|
||||
@ -1486,6 +1505,7 @@ function Shell({
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onNewChat={onNewChat}
|
||||
onCreateChat={onCreateChat}
|
||||
onForkChat={onForkChat}
|
||||
onTurnEnd={onTurnEnd}
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
|
||||
@ -5,14 +5,29 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type SVGProps,
|
||||
} from "react";
|
||||
import { Check, ChevronRight, Clock3, Copy, ImageIcon, Sparkles, Wrench } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Copy,
|
||||
ImageIcon,
|
||||
Sparkles,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AttachmentTile } from "@/components/AttachmentTile";
|
||||
import { CliAppMentionText } from "@/components/CliAppMentionText";
|
||||
import { ImageLightbox } from "@/components/ImageLightbox";
|
||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { formatTurnLatency } from "@/lib/format";
|
||||
@ -34,6 +49,7 @@ interface MessageBubbleProps {
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
onForkFromHere?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -51,6 +67,7 @@ export function MessageBubble({
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
onOpenFilePreview,
|
||||
onForkFromHere,
|
||||
}: MessageBubbleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
@ -73,7 +90,7 @@ export function MessageBubble({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onCopyAssistantReply = useCallback(() => {
|
||||
const onCopyMessage = useCallback(() => {
|
||||
void copyTextToClipboard(message.content).then((ok) => {
|
||||
if (!ok) return;
|
||||
setCopied(true);
|
||||
@ -97,6 +114,11 @@ export function MessageBubble({
|
||||
const hasImages = images.length > 0;
|
||||
const hasMedia = media.length > 0;
|
||||
const hasText = message.content.trim().length > 0;
|
||||
const showUserActions = hasText;
|
||||
const timeLabel = formatMessageClock(message.createdAt);
|
||||
const copyLabel = copied
|
||||
? t("message.copiedMessage", { defaultValue: "Copied message" })
|
||||
: t("message.copyMessage", { defaultValue: "Copy message" });
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@ -122,6 +144,43 @@ export function MessageBubble({
|
||||
/>
|
||||
</p>
|
||||
) : null}
|
||||
{showUserActions ? (
|
||||
<TooltipProvider delayDuration={180} skipDelayDuration={80}>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 flex h-8 items-center justify-end gap-1 self-end",
|
||||
"text-[13px] text-muted-foreground/65 opacity-0 transition-opacity duration-150",
|
||||
"group-focus-within:opacity-100 group-hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
{hasText ? (
|
||||
<MessageActionTooltip label={copyLabel}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopyMessage}
|
||||
aria-label={copyLabel}
|
||||
className={cn(
|
||||
"inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full",
|
||||
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" aria-hidden />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
</MessageActionTooltip>
|
||||
) : null}
|
||||
{timeLabel ? (
|
||||
<span className="ml-1 shrink-0 select-none tabular-nums" title={timeLabel}>
|
||||
{timeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -138,13 +197,16 @@ export function MessageBubble({
|
||||
|
||||
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
|
||||
const showCopyButton = showAssistantCopyAction && showAssistantActions;
|
||||
const showForkButton = showAssistantActions && !!onForkFromHere;
|
||||
const copyReplyLabel = copied ? t("message.copiedReply") : t("message.copyReply");
|
||||
const forkLabel = t("message.forkFromHere");
|
||||
const latencyMs = message.latencyMs;
|
||||
const showLatencyFooter =
|
||||
message.role === "assistant"
|
||||
&& latencyMs != null
|
||||
&& !message.isStreaming
|
||||
&& (!empty || hasReasoning || media.length > 0);
|
||||
const showAssistantFooterRow = showCopyButton || showLatencyFooter;
|
||||
const showAssistantFooterRow = showCopyButton || showForkButton || showLatencyFooter;
|
||||
return (
|
||||
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
|
||||
{hasReasoning ? (
|
||||
@ -173,35 +235,54 @@ export function MessageBubble({
|
||||
</MarkdownText>
|
||||
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
|
||||
{showAssistantFooterRow ? (
|
||||
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
|
||||
{showCopyButton ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopyAssistantReply}
|
||||
aria-label={copied ? t("message.copiedReply") : t("message.copyReply")}
|
||||
title={copied ? t("message.copiedReply") : t("message.copyReply")}
|
||||
className={cn(
|
||||
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
{showLatencyFooter ? (
|
||||
<span
|
||||
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
|
||||
title={t("message.turnLatencyTitle")}
|
||||
>
|
||||
{formatTurnLatency(latencyMs)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<TooltipProvider delayDuration={180} skipDelayDuration={80}>
|
||||
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
|
||||
{showCopyButton ? (
|
||||
<MessageActionTooltip label={copyReplyLabel}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopyMessage}
|
||||
aria-label={copyReplyLabel}
|
||||
className={cn(
|
||||
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
</MessageActionTooltip>
|
||||
) : null}
|
||||
{showForkButton ? (
|
||||
<MessageActionTooltip label={forkLabel}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onForkFromHere}
|
||||
aria-label={forkLabel}
|
||||
className={cn(
|
||||
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<ForkFromHereIcon className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
</MessageActionTooltip>
|
||||
) : null}
|
||||
{showLatencyFooter ? (
|
||||
<span
|
||||
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
|
||||
title={t("message.turnLatencyTitle")}
|
||||
>
|
||||
{formatTurnLatency(latencyMs)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
@ -209,6 +290,27 @@ export function MessageBubble({
|
||||
);
|
||||
}
|
||||
|
||||
function MessageActionTooltip({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="center"
|
||||
className="rounded-full border-border/70 bg-background px-2.5 py-1 text-[12px] font-medium text-foreground shadow-[0_8px_24px_rgba(15,23,42,0.13)] dark:border-white/10 dark:bg-neutral-900 dark:text-white"
|
||||
>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function AutomationSourceBadge({ label, triggerLabel }: { label: string; triggerLabel: string }) {
|
||||
return (
|
||||
<div
|
||||
@ -228,6 +330,39 @@ function AutomationSourceBadge({ label, triggerLabel }: { label: string; trigger
|
||||
);
|
||||
}
|
||||
|
||||
function formatMessageClock(createdAt: number): string {
|
||||
if (!Number.isFinite(createdAt) || createdAt <= 0) return "";
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(createdAt));
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function ForkFromHereIcon({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
// Tabler Icons "arrow-fork" (MIT, Copyright Paweł Kuna).
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M16 3h5v5" />
|
||||
<path d="M8 3h-5v5" />
|
||||
<path d="M21 3l-7.536 7.536a5 5 0 0 0 -1.464 3.534v6.93" />
|
||||
<path d="M3 3l7.536 7.536a5 5 0 0 1 1.464 3.534v.93" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function mergeMcpMentionPresets(
|
||||
presets: McpPresetInfo[],
|
||||
attachments: UIMcpPresetAttachment[] | undefined,
|
||||
|
||||
@ -172,6 +172,7 @@ interface ThreadComposerProps {
|
||||
workspaceError?: string | null;
|
||||
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
|
||||
pendingQueueKey?: string | null;
|
||||
externalError?: string | null;
|
||||
}
|
||||
|
||||
const COMMAND_ICONS: Record<string, LucideIcon> = {
|
||||
@ -765,6 +766,7 @@ export function ThreadComposer({
|
||||
workspaceError = null,
|
||||
onWorkspaceScopeChange,
|
||||
pendingQueueKey = null,
|
||||
externalError = null,
|
||||
}: ThreadComposerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState("");
|
||||
@ -782,6 +784,7 @@ export function ThreadComposer({
|
||||
const chipRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
const queuedPromptCounterRef = useRef(0);
|
||||
const draggedQueuedPromptIdRef = useRef<string | null>(null);
|
||||
const previousPendingQueueKeyRef = useRef(pendingQueueKey);
|
||||
const wasStreamingRef = useRef(isStreaming);
|
||||
const skipNextQueuedFlushRef = useRef(false);
|
||||
const skipQueuedPromptPersistRef = useRef(false);
|
||||
@ -1128,6 +1131,28 @@ export function ThreadComposer({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Runs before paint so switching sessions never flashes stale draft text.
|
||||
useLayoutEffect(() => {
|
||||
if (previousPendingQueueKeyRef.current === pendingQueueKey) return;
|
||||
previousPendingQueueKeyRef.current = pendingQueueKey;
|
||||
setValue("");
|
||||
setInlineError(null);
|
||||
setSlashMenuDismissed(false);
|
||||
setCliAppMenuDismissed(false);
|
||||
setCursorPosition(0);
|
||||
clear();
|
||||
requestAnimationFrame(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
|
||||
});
|
||||
}, [clear, pendingQueueKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (externalError) setInlineError(externalError);
|
||||
}, [externalError]);
|
||||
|
||||
const appendTranscription = useCallback((text: string) => {
|
||||
const transcript = text.trim();
|
||||
if (!transcript) return;
|
||||
|
||||
@ -8,6 +8,7 @@ import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
||||
|
||||
interface ThreadMessagesProps {
|
||||
messages: UIMessage[];
|
||||
allMessages?: UIMessage[];
|
||||
/** When true, agent turn still in flight — keeps activity timeline expanded. */
|
||||
isStreaming?: boolean;
|
||||
hiddenMessageCount?: number;
|
||||
@ -15,6 +16,7 @@ interface ThreadMessagesProps {
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
onForkFromMessage?: (beforeUserIndex: number) => void;
|
||||
}
|
||||
|
||||
export type DisplayUnit = TurnUnit;
|
||||
@ -62,15 +64,21 @@ export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
|
||||
|
||||
export function ThreadMessages({
|
||||
messages,
|
||||
allMessages,
|
||||
isStreaming = false,
|
||||
hiddenMessageCount = 0,
|
||||
onLoadEarlier,
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
onOpenFilePreview,
|
||||
onForkFromMessage,
|
||||
}: ThreadMessagesProps) {
|
||||
const { t } = useTranslation();
|
||||
const units = useMemo(() => buildDisplayUnits(messages, isStreaming), [isStreaming, messages]);
|
||||
const assistantForkIndexById = useMemo(
|
||||
() => assistantForkIndexByMessageId(allMessages ?? messages),
|
||||
[allMessages, messages],
|
||||
);
|
||||
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
|
||||
const liveActivityClusterIndices = useMemo(
|
||||
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
|
||||
@ -137,6 +145,16 @@ export function ThreadMessages({
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
onForkFromHere={
|
||||
onForkFromMessage
|
||||
? forkHandlerForAssistantMessage(
|
||||
unit.message,
|
||||
copyFlags[index],
|
||||
assistantForkIndexById,
|
||||
onForkFromMessage,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -146,6 +164,34 @@ export function ThreadMessages({
|
||||
);
|
||||
}
|
||||
|
||||
function assistantForkIndexByMessageId(messages: UIMessage[]): Map<string, number> {
|
||||
const out = new Map<string, number>();
|
||||
let nextUserIndex = 0;
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
nextUserIndex += 1;
|
||||
} else if (message.role === "assistant") {
|
||||
out.set(message.id, nextUserIndex);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function forkHandlerForAssistantMessage(
|
||||
message: UIMessage,
|
||||
canForkAssistant: boolean,
|
||||
assistantForkIndexById: Map<string, number>,
|
||||
onForkFromMessage: NonNullable<ThreadMessagesProps["onForkFromMessage"]>,
|
||||
): (() => void) | undefined {
|
||||
if (message.role === "assistant" && canForkAssistant) {
|
||||
const beforeUserIndex = assistantForkIndexById.get(message.id);
|
||||
return beforeUserIndex === undefined
|
||||
? undefined
|
||||
: () => onForkFromMessage(beforeUserIndex);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function currentActivityClusterIndices(units: DisplayUnit[]): Set<number> {
|
||||
const indices = new Set<number>();
|
||||
let markedCurrentActivity = false;
|
||||
|
||||
@ -77,6 +77,7 @@ interface ThreadShellProps {
|
||||
onGoHome?: () => void;
|
||||
onNewChat?: () => void;
|
||||
onCreateChat?: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string | null>;
|
||||
onForkChat?: (sourceChatId: string, beforeUserIndex: number) => Promise<string | null>;
|
||||
onTurnEnd?: () => void;
|
||||
theme?: "light" | "dark";
|
||||
onToggleTheme?: () => void;
|
||||
@ -226,6 +227,7 @@ export function ThreadShell({
|
||||
title,
|
||||
onToggleSidebar,
|
||||
onCreateChat,
|
||||
onForkChat,
|
||||
onTurnEnd,
|
||||
theme = "light",
|
||||
onToggleTheme = () => {},
|
||||
@ -275,6 +277,8 @@ export function ThreadShell({
|
||||
const [filePreviewPath, setFilePreviewPath] = useState<string | null>(null);
|
||||
const [filePreviewClosing, setFilePreviewClosing] = useState(false);
|
||||
const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH);
|
||||
const [forkError, setForkError] = useState<string | null>(null);
|
||||
const [forkHydratingChatId, setForkHydratingChatId] = useState<string | null>(null);
|
||||
const shellRef = useRef<HTMLElement | null>(null);
|
||||
const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH);
|
||||
const filePreviewCloseTimerRef = useRef<number | null>(null);
|
||||
@ -283,6 +287,7 @@ export function ThreadShell({
|
||||
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
||||
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
|
||||
const prevChatIdForCacheRef = useRef<string | null>(null);
|
||||
const prevChatIdForComposerRef = useRef<string | null>(chatId);
|
||||
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
|
||||
const skipLayoutCacheRef = useRef(false);
|
||||
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
|
||||
@ -334,6 +339,12 @@ export function ThreadShell({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevChatIdForComposerRef.current === chatId) return;
|
||||
prevChatIdForComposerRef.current = chatId;
|
||||
setForkError(null);
|
||||
}, [chatId]);
|
||||
|
||||
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
|
||||
|
||||
const showHeroComposer = messages.length === 0 && !loading;
|
||||
@ -443,6 +454,12 @@ export function ThreadShell({
|
||||
setMessages(projectWebuiThreadMessages(historical));
|
||||
}, [chatId, historical, setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId || loading || forkHydratingChatId !== chatId) return;
|
||||
setForkHydratingChatId(null);
|
||||
setScrollToBottomSignal((value) => value + 1);
|
||||
}, [chatId, forkHydratingChatId, loading]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (chatId) {
|
||||
const prev = prevChatIdForCacheRef.current;
|
||||
@ -521,6 +538,7 @@ export function ThreadShell({
|
||||
|
||||
const handleThreadSend = useCallback(
|
||||
(content: string, images?: SendImage[], options?: SendOptions) => {
|
||||
setForkError(null);
|
||||
setScrollToBottomSignal((value) => value + 1);
|
||||
send(content, images, withWorkspaceScope(options));
|
||||
},
|
||||
@ -615,6 +633,26 @@ export function ThreadShell({
|
||||
};
|
||||
}, [filePreviewPath]);
|
||||
|
||||
const handleForkFromMessage = useCallback(
|
||||
async (beforeUserIndex: number) => {
|
||||
if (!chatId || !onForkChat) return;
|
||||
setForkError(null);
|
||||
const forkedChatId = await onForkChat(chatId, beforeUserIndex);
|
||||
if (!forkedChatId) {
|
||||
setForkError(t("thread.fork.failed", {
|
||||
defaultValue: "Could not fork this chat. Try again.",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
messageCacheRef.current.delete(forkedChatId);
|
||||
appliedHistoryVersionRef.current.delete(forkedChatId);
|
||||
pendingCanonicalHydrateRef.current.add(forkedChatId);
|
||||
setForkHydratingChatId(forkedChatId);
|
||||
setForkError(null);
|
||||
},
|
||||
[chatId, onForkChat, t],
|
||||
);
|
||||
|
||||
const composer = (
|
||||
<>
|
||||
{streamError ? (
|
||||
@ -626,7 +664,7 @@ export function ThreadShell({
|
||||
{session ? (
|
||||
<ThreadComposer
|
||||
onSend={handleThreadSend}
|
||||
disabled={!chatId}
|
||||
disabled={!chatId || forkHydratingChatId === chatId}
|
||||
isStreaming={isStreaming}
|
||||
placeholder={
|
||||
showHeroComposer
|
||||
@ -653,6 +691,7 @@ export function ThreadShell({
|
||||
workspaceError={workspaceError}
|
||||
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||
pendingQueueKey={chatId}
|
||||
externalError={forkError}
|
||||
/>
|
||||
) : (
|
||||
<ThreadComposer
|
||||
@ -736,7 +775,9 @@ export function ThreadShell({
|
||||
showScrollToBottomButton={!!session}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
allMessages={displayMessages}
|
||||
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
|
||||
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}
|
||||
/>
|
||||
</div>
|
||||
{filePreviewPath && historyKey ? (
|
||||
|
||||
@ -29,6 +29,7 @@ export interface ThreadViewportHandle {
|
||||
|
||||
interface ThreadViewportProps {
|
||||
messages: UIMessage[];
|
||||
allMessages?: UIMessage[];
|
||||
isStreaming: boolean;
|
||||
composer: ReactNode;
|
||||
emptyState?: ReactNode;
|
||||
@ -38,6 +39,7 @@ interface ThreadViewportProps {
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
onForkFromMessage?: (beforeUserIndex: number) => void;
|
||||
}
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
@ -61,6 +63,7 @@ export function windowMessages(messages: UIMessage[], visibleCount: number): UIM
|
||||
|
||||
export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
|
||||
messages,
|
||||
allMessages,
|
||||
isStreaming,
|
||||
composer,
|
||||
emptyState,
|
||||
@ -70,6 +73,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
onOpenFilePreview,
|
||||
onForkFromMessage,
|
||||
}, ref) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
@ -289,12 +293,14 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
<div className="mx-auto w-full max-w-[49.5rem]">
|
||||
<ThreadMessages
|
||||
messages={visibleMessages}
|
||||
allMessages={allMessages ?? messages}
|
||||
isStreaming={isStreaming}
|
||||
hiddenMessageCount={hiddenMessageCount}
|
||||
onLoadEarlier={loadEarlierMessages}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
onForkFromMessage={onForkFromMessage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -20,6 +20,7 @@ export function useSessions(): {
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
createChat: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string>;
|
||||
forkChat: (sourceChatId: string, beforeUserIndex: number) => Promise<string>;
|
||||
deleteChat: (key: string) => Promise<void>;
|
||||
} {
|
||||
const { client, token } = useClient();
|
||||
@ -88,6 +89,29 @@ export function useSessions(): {
|
||||
return chatId;
|
||||
}, [client]);
|
||||
|
||||
const forkChat = useCallback(async (
|
||||
sourceChatId: string,
|
||||
beforeUserIndex: number,
|
||||
): Promise<string> => {
|
||||
const chatId = await client.forkChat(sourceChatId, beforeUserIndex);
|
||||
const key = `websocket:${chatId}`;
|
||||
optimisticKeysRef.current.add(key);
|
||||
setSessions((prev) => [
|
||||
{
|
||||
key,
|
||||
channel: "websocket",
|
||||
chatId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
title: "",
|
||||
preview: "",
|
||||
workspaceScope: null,
|
||||
},
|
||||
...prev.filter((s) => s.key !== key),
|
||||
]);
|
||||
return chatId;
|
||||
}, [client]);
|
||||
|
||||
const deleteChat = useCallback(
|
||||
async (key: string) => {
|
||||
await apiDeleteSession(tokenRef.current, key);
|
||||
@ -97,7 +121,7 @@ export function useSessions(): {
|
||||
[],
|
||||
);
|
||||
|
||||
return { sessions, loading, error, refresh, createChat, deleteChat };
|
||||
return { sessions, loading, error, refresh, createChat, forkChat, deleteChat };
|
||||
}
|
||||
|
||||
/** Lazy-load a session's on-disk messages the first time the UI displays it. */
|
||||
|
||||
@ -810,6 +810,9 @@
|
||||
},
|
||||
"scrollToBottom": "Scroll to bottom",
|
||||
"loadEarlier": "Load earlier messages",
|
||||
"fork": {
|
||||
"failed": "Could not fork this chat. Try again."
|
||||
},
|
||||
"promptNavigator": {
|
||||
"open": "Open prompt navigator",
|
||||
"title": "Prompts",
|
||||
@ -849,6 +852,9 @@
|
||||
"imageAttachment": "Image attachment",
|
||||
"automationSourceFallback": "Automation",
|
||||
"automationTriggered": "Triggered automatically",
|
||||
"copyMessage": "Copy message",
|
||||
"copiedMessage": "Copied message",
|
||||
"forkFromHere": "Fork from here",
|
||||
"copyReply": "Copy reply",
|
||||
"copiedReply": "Copied reply",
|
||||
"turnLatencyTitle": "Response time (end-to-end)"
|
||||
|
||||
@ -810,6 +810,9 @@
|
||||
},
|
||||
"scrollToBottom": "Desplazarse al final",
|
||||
"loadEarlier": "Cargar mensajes anteriores",
|
||||
"fork": {
|
||||
"failed": "No se pudo bifurcar este chat. Inténtalo de nuevo."
|
||||
},
|
||||
"promptNavigator": {
|
||||
"open": "Abrir navegador de prompts",
|
||||
"title": "Prompts",
|
||||
@ -835,6 +838,9 @@
|
||||
"agentActivityLiveSummary": "En curso… · {{reasoning}} pasos · {{tools}} llamadas a herramientas",
|
||||
"agentActivityLiveToolsOnly": "En curso… · {{tools}} llamadas a herramientas",
|
||||
"imageAttachment": "Imagen adjunta",
|
||||
"copyMessage": "Copiar mensaje",
|
||||
"copiedMessage": "Mensaje copiado",
|
||||
"forkFromHere": "Bifurcar desde aquí",
|
||||
"copyReply": "Copiar respuesta",
|
||||
"copiedReply": "Respuesta copiada",
|
||||
"turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)",
|
||||
|
||||
@ -810,6 +810,9 @@
|
||||
},
|
||||
"scrollToBottom": "Faire défiler vers le bas",
|
||||
"loadEarlier": "Charger les messages précédents",
|
||||
"fork": {
|
||||
"failed": "Impossible de bifurquer cette conversation. Réessayez."
|
||||
},
|
||||
"promptNavigator": {
|
||||
"open": "Ouvrir le navigateur de prompts",
|
||||
"title": "Prompts",
|
||||
@ -835,6 +838,9 @@
|
||||
"agentActivityLiveSummary": "En cours… · {{reasoning}} étapes · {{tools}} appels d’outils",
|
||||
"agentActivityLiveToolsOnly": "En cours… · {{tools}} appels d’outils",
|
||||
"imageAttachment": "Pièce jointe image",
|
||||
"copyMessage": "Copier le message",
|
||||
"copiedMessage": "Message copié",
|
||||
"forkFromHere": "Bifurquer depuis ici",
|
||||
"copyReply": "Copier la réponse",
|
||||
"copiedReply": "Réponse copiée",
|
||||
"turnLatencyTitle": "Temps de réponse (de bout en bout)",
|
||||
|
||||
@ -810,6 +810,9 @@
|
||||
},
|
||||
"scrollToBottom": "Gulir ke bawah",
|
||||
"loadEarlier": "Muat pesan sebelumnya",
|
||||
"fork": {
|
||||
"failed": "Tidak dapat mem-fork chat ini. Coba lagi."
|
||||
},
|
||||
"promptNavigator": {
|
||||
"open": "Buka navigator prompt",
|
||||
"title": "Prompt",
|
||||
@ -835,6 +838,9 @@
|
||||
"agentActivityLiveSummary": "Berjalan… · {{reasoning}} langkah · {{tools}} panggilan alat",
|
||||
"agentActivityLiveToolsOnly": "Berjalan… · {{tools}} panggilan alat",
|
||||
"imageAttachment": "Lampiran gambar",
|
||||
"copyMessage": "Salin pesan",
|
||||
"copiedMessage": "Pesan disalin",
|
||||
"forkFromHere": "Fork dari sini",
|
||||
"copyReply": "Salin balasan",
|
||||
"copiedReply": "Balasan disalin",
|
||||
"turnLatencyTitle": "Waktu respons (ujung ke ujung)",
|
||||
|
||||
@ -810,6 +810,9 @@
|
||||
},
|
||||
"scrollToBottom": "一番下へスクロール",
|
||||
"loadEarlier": "以前のメッセージを読み込む",
|
||||
"fork": {
|
||||
"failed": "このチャットを分岐できませんでした。もう一度お試しください。"
|
||||
},
|
||||
"promptNavigator": {
|
||||
"open": "プロンプトナビゲーターを開く",
|
||||
"title": "プロンプト",
|
||||
@ -835,6 +838,9 @@
|
||||
"agentActivityLiveSummary": "実行中… · {{reasoning}} ステップ · ツール呼び出し {{tools}} 回",
|
||||
"agentActivityLiveToolsOnly": "実行中… · ツール呼び出し {{tools}} 回",
|
||||
"imageAttachment": "画像の添付",
|
||||
"copyMessage": "メッセージをコピー",
|
||||
"copiedMessage": "メッセージをコピーしました",
|
||||
"forkFromHere": "ここから分岐",
|
||||
"copyReply": "返信をコピー",
|
||||
"copiedReply": "返信をコピーしました",
|
||||
"turnLatencyTitle": "応答時間(全行程)",
|
||||
|
||||
@ -810,6 +810,9 @@
|
||||
},
|
||||
"scrollToBottom": "맨 아래로 스크롤",
|
||||
"loadEarlier": "이전 메시지 불러오기",
|
||||
"fork": {
|
||||
"failed": "이 채팅을 분기할 수 없습니다. 다시 시도해 주세요."
|
||||
},
|
||||
"promptNavigator": {
|
||||
"open": "프롬프트 탐색기 열기",
|
||||
"title": "프롬프트",
|
||||
@ -835,6 +838,9 @@
|
||||
"agentActivityLiveSummary": "진행 중… · {{reasoning}}단계 · 도구 호출 {{tools}}회",
|
||||
"agentActivityLiveToolsOnly": "진행 중… · 도구 호출 {{tools}}회",
|
||||
"imageAttachment": "이미지 첨부",
|
||||
"copyMessage": "메시지 복사",
|
||||
"copiedMessage": "메시지가 복사됨",
|
||||
"forkFromHere": "여기서 분기",
|
||||
"copyReply": "답변 복사",
|
||||
"copiedReply": "답변이 복사됨",
|
||||
"turnLatencyTitle": "응답 시간(엔드투엔드)",
|
||||
|
||||
@ -810,6 +810,9 @@
|
||||
},
|
||||
"scrollToBottom": "Cuộn xuống cuối",
|
||||
"loadEarlier": "Tải tin nhắn trước đó",
|
||||
"fork": {
|
||||
"failed": "Không thể rẽ nhánh cuộc trò chuyện này. Hãy thử lại."
|
||||
},
|
||||
"promptNavigator": {
|
||||
"open": "Mở trình điều hướng prompt",
|
||||
"title": "Prompt",
|
||||
@ -835,6 +838,9 @@
|
||||
"agentActivityLiveSummary": "Đang chạy… · {{reasoning}} bước · {{tools}} lần gọi công cụ",
|
||||
"agentActivityLiveToolsOnly": "Đang chạy… · {{tools}} lần gọi công cụ",
|
||||
"imageAttachment": "Tệp hình ảnh đính kèm",
|
||||
"copyMessage": "Sao chép tin nhắn",
|
||||
"copiedMessage": "Đã sao chép tin nhắn",
|
||||
"forkFromHere": "Rẽ nhánh từ đây",
|
||||
"copyReply": "Sao chép trả lời",
|
||||
"copiedReply": "Đã sao chép trả lời",
|
||||
"turnLatencyTitle": "Thời gian phản hồi (end-to-end)",
|
||||
|
||||
@ -810,6 +810,9 @@
|
||||
},
|
||||
"scrollToBottom": "滚动到底部",
|
||||
"loadEarlier": "加载更早消息",
|
||||
"fork": {
|
||||
"failed": "无法分叉这个对话,请重试。"
|
||||
},
|
||||
"promptNavigator": {
|
||||
"open": "打开输入导航",
|
||||
"title": "输入列表",
|
||||
@ -849,6 +852,9 @@
|
||||
"imageAttachment": "图片附件",
|
||||
"automationSourceFallback": "自动化",
|
||||
"automationTriggered": "自动触发",
|
||||
"copyMessage": "复制消息",
|
||||
"copiedMessage": "已复制消息",
|
||||
"forkFromHere": "从这里分叉",
|
||||
"copyReply": "复制回复",
|
||||
"copiedReply": "已复制回复",
|
||||
"turnLatencyTitle": "本轮耗时(端到端)"
|
||||
|
||||
@ -810,6 +810,9 @@
|
||||
},
|
||||
"scrollToBottom": "捲動到底部",
|
||||
"loadEarlier": "載入更早訊息",
|
||||
"fork": {
|
||||
"failed": "無法分叉這個對話,請重試。"
|
||||
},
|
||||
"promptNavigator": {
|
||||
"open": "開啟輸入導覽",
|
||||
"title": "輸入列表",
|
||||
@ -835,6 +838,9 @@
|
||||
"agentActivityLiveSummary": "進行中… · {{reasoning}} 步 · {{tools}} 次工具呼叫",
|
||||
"agentActivityLiveToolsOnly": "進行中… · {{tools}} 次工具呼叫",
|
||||
"imageAttachment": "圖片附件",
|
||||
"copyMessage": "複製訊息",
|
||||
"copiedMessage": "已複製訊息",
|
||||
"forkFromHere": "從這裡分叉",
|
||||
"copyReply": "複製回覆",
|
||||
"copiedReply": "已複製回覆",
|
||||
"turnLatencyTitle": "本輪耗時(端到端)",
|
||||
|
||||
@ -348,6 +348,29 @@ export class NanobotClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** Ask the server to create a non-destructive fork before a user-message index. */
|
||||
forkChat(
|
||||
sourceChatId: string,
|
||||
beforeUserIndex: number,
|
||||
timeoutMs: number = 5_000,
|
||||
): Promise<string> {
|
||||
if (this.pendingNewChat) {
|
||||
return Promise.reject(new Error("newChat already in flight"));
|
||||
}
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingNewChat = null;
|
||||
reject(new Error("forkChat timed out"));
|
||||
}, timeoutMs);
|
||||
this.pendingNewChat = { resolve, reject, timer };
|
||||
this.queueSend({
|
||||
type: "fork_chat",
|
||||
source_chat_id: sourceChatId,
|
||||
before_user_index: beforeUserIndex,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
attach(chatId: string): void {
|
||||
this.knownChats.add(chatId);
|
||||
if (this.socket?.readyState === WS_OPEN) {
|
||||
@ -481,6 +504,14 @@ export class NanobotClient {
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.event === "error" && this.pendingNewChat) {
|
||||
clearTimeout(this.pendingNewChat.timer);
|
||||
const detail = typeof parsed.detail === "string" ? parsed.detail : "server error";
|
||||
const reason = typeof parsed.reason === "string" && parsed.reason ? `:${parsed.reason}` : "";
|
||||
this.pendingNewChat.reject(new Error(`${detail}${reason}`));
|
||||
this.pendingNewChat = null;
|
||||
}
|
||||
|
||||
const chatId = (parsed as { chat_id?: string }).chat_id;
|
||||
if (chatId) {
|
||||
this.recordGoalStatusForRunStrip(chatId, parsed);
|
||||
|
||||
@ -877,6 +877,7 @@ export interface FilePreviewPayload {
|
||||
|
||||
export type Outbound =
|
||||
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
|
||||
| { type: "fork_chat"; source_chat_id: string; before_user_index: number }
|
||||
| { type: "attach"; chat_id: string }
|
||||
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
|
||||
| { type: "transcribe_audio"; request_id: string; data_url: string; duration_ms?: number }
|
||||
|
||||
@ -144,6 +144,7 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
||||
error: null,
|
||||
refresh: refreshSpy,
|
||||
createChat: createChatSpy,
|
||||
forkChat: async () => "fork-chat",
|
||||
deleteChat: async (key: string) => {
|
||||
await deleteChatSpy(key);
|
||||
setSessions((prev: ChatSummary[]) => prev.filter((s) => s.key !== key));
|
||||
|
||||
@ -76,9 +76,41 @@ describe("MessageBubble", () => {
|
||||
|
||||
expect(row).toHaveClass("ml-auto", "flex");
|
||||
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]");
|
||||
expect(screen.getByRole("button", { name: "Copy message" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render fork control for user messages", () => {
|
||||
const onForkFromHere = vi.fn();
|
||||
const message: UIMessage = {
|
||||
id: "u-fork",
|
||||
role: "user",
|
||||
content: "continue from here",
|
||||
createdAt: new Date("2026-06-06T09:04:00Z").getTime(),
|
||||
};
|
||||
|
||||
render(<MessageBubble message={message} onForkFromHere={onForkFromHere} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Copy message" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Fork from here" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders fork control in completed assistant action rows", () => {
|
||||
const onForkFromHere = vi.fn();
|
||||
const message: UIMessage = {
|
||||
id: "a-fork",
|
||||
role: "assistant",
|
||||
content: "branch after this answer",
|
||||
latencyMs: 1_200,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
render(<MessageBubble message={message} onForkFromHere={onForkFromHere} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Fork from here" }));
|
||||
expect(onForkFromHere).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders installed CLI app mentions inside sent user messages", () => {
|
||||
const message: UIMessage = {
|
||||
id: "u-cli",
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@ -59,6 +59,7 @@ function makeClient() {
|
||||
},
|
||||
sendMessage: vi.fn(),
|
||||
newChat: vi.fn(),
|
||||
forkChat: vi.fn(),
|
||||
attach: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
close: vi.fn(),
|
||||
@ -721,6 +722,267 @@ describe("ThreadShell", () => {
|
||||
expect(screen.queryByText("old answer")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("forks assistant replies using the global user message index rather than the visible window index", async () => {
|
||||
const client = makeClient();
|
||||
const onForkChat = vi.fn().mockResolvedValue("chat-fork");
|
||||
const rows = Array.from({ length: 165 }, (_, index) => [
|
||||
{ role: "user" as const, content: `question ${index}` },
|
||||
{ role: "assistant" as const, content: `answer ${index}` },
|
||||
]).flat();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("websocket%3Along-chat/webui-thread")) {
|
||||
return httpJson(transcriptFromSimpleMessages(rows));
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("long-chat")}
|
||||
title="Long chat"
|
||||
onToggleSidebar={() => {}}
|
||||
onForkChat={onForkChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
const targetText = await screen.findByText("answer 100");
|
||||
fireEvent.click(within(targetText.closest(".w-full") as HTMLElement).getByRole("button", {
|
||||
name: "Fork from here",
|
||||
}));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onForkChat).toHaveBeenCalledWith("long-chat", 101),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows an error without changing the draft when assistant fork fails", async () => {
|
||||
const client = makeClient();
|
||||
const onForkChat = vi.fn().mockResolvedValue(null);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("websocket%3Achat-a/webui-thread")) {
|
||||
return httpJson(transcriptFromSimpleMessages([
|
||||
{ role: "user", content: "fork me" },
|
||||
{ role: "assistant", content: "answer" },
|
||||
]));
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-a")}
|
||||
title="Chat chat-a"
|
||||
onToggleSidebar={() => {}}
|
||||
onForkChat={onForkChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
const targetText = await screen.findByText("answer");
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "keep my current draft" },
|
||||
});
|
||||
fireEvent.click(within(targetText.closest(".w-full") as HTMLElement).getByRole("button", {
|
||||
name: "Fork from here",
|
||||
}));
|
||||
|
||||
await waitFor(() => expect(onForkChat).toHaveBeenCalledWith("chat-a", 1));
|
||||
expect(screen.getByLabelText("Message input")).toHaveValue("keep my current draft");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Could not fork this chat");
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hydrates a successful fork from canonical history without later source messages", async () => {
|
||||
const client = makeClient();
|
||||
const onForkChat = vi.fn().mockResolvedValue("chat-fork");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("websocket%3Achat-a/webui-thread")) {
|
||||
return httpJson(transcriptFromSimpleMessages([
|
||||
{ role: "user", content: "round1" },
|
||||
{ role: "assistant", content: "answer1" },
|
||||
{ role: "user", content: "round2 fork me" },
|
||||
{ role: "assistant", content: "answer2" },
|
||||
{ role: "user", content: "round3 must not appear" },
|
||||
]));
|
||||
}
|
||||
if (url.includes("websocket%3Achat-fork/webui-thread")) {
|
||||
return httpJson(transcriptFromSimpleMessages([
|
||||
{ role: "user", content: "round1" },
|
||||
{ role: "assistant", content: "answer1" },
|
||||
{ role: "user", content: "round2 fork me" },
|
||||
{ role: "assistant", content: "answer2" },
|
||||
]));
|
||||
}
|
||||
if (url.includes("websocket%3Achat-other/webui-thread")) {
|
||||
return httpJson(transcriptFromSimpleMessages([
|
||||
{ role: "user", content: "other chat" },
|
||||
]));
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-a")}
|
||||
title="Chat chat-a"
|
||||
onToggleSidebar={() => {}}
|
||||
onForkChat={onForkChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
const targetText = await screen.findByText("answer2");
|
||||
fireEvent.click(within(targetText.closest(".w-full") as HTMLElement).getByRole("button", {
|
||||
name: "Fork from here",
|
||||
}));
|
||||
|
||||
await waitFor(() => expect(onForkChat).toHaveBeenCalledWith("chat-a", 2));
|
||||
await act(async () => {
|
||||
rerender(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-fork")}
|
||||
title="Chat chat-fork"
|
||||
onToggleSidebar={() => {}}
|
||||
onForkChat={onForkChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText("answer1")).toBeInTheDocument());
|
||||
expect(screen.getByText("answer2")).toBeInTheDocument();
|
||||
expect(screen.queryByText("round3 must not appear")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Message input")).toHaveValue("");
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-other")}
|
||||
title="Chat chat-other"
|
||||
onToggleSidebar={() => {}}
|
||||
onForkChat={onForkChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("Message input")).toHaveValue(""),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={null}
|
||||
title="New chat"
|
||||
onToggleSidebar={() => {}}
|
||||
onForkChat={onForkChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText("Message input")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("forks from completed assistant replies without pre-filling the assistant text", async () => {
|
||||
const client = makeClient();
|
||||
const onForkChat = vi.fn().mockResolvedValue("chat-fork");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("websocket%3Achat-a/webui-thread")) {
|
||||
return httpJson(transcriptFromSimpleMessages([
|
||||
{ role: "user", content: "round1" },
|
||||
{ role: "assistant", content: "answer1" },
|
||||
]));
|
||||
}
|
||||
if (url.includes("websocket%3Achat-fork/webui-thread")) {
|
||||
return httpJson(transcriptFromSimpleMessages([
|
||||
{ role: "user", content: "round1" },
|
||||
{ role: "assistant", content: "answer1" },
|
||||
]));
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-a")}
|
||||
title="Chat chat-a"
|
||||
onToggleSidebar={() => {}}
|
||||
onForkChat={onForkChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await screen.findByText("answer1");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Fork from here" }).at(-1)!);
|
||||
|
||||
await waitFor(() => expect(onForkChat).toHaveBeenCalledWith("chat-a", 1));
|
||||
await act(async () => {
|
||||
rerender(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-fork")}
|
||||
title="Chat chat-fork"
|
||||
onToggleSidebar={() => {}}
|
||||
onForkChat={onForkChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText("answer1")).toBeInTheDocument());
|
||||
expect(screen.getByLabelText("Message input")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("does not cache optimistic messages under the next chat during a session switch", async () => {
|
||||
const client = makeClient();
|
||||
const onNewChat = vi.fn().mockResolvedValue("chat-b");
|
||||
|
||||
@ -60,6 +60,7 @@ function fakeClient() {
|
||||
},
|
||||
sendMessage: vi.fn(),
|
||||
newChat: vi.fn(),
|
||||
forkChat: vi.fn(),
|
||||
attach: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
close: vi.fn(),
|
||||
|
||||
@ -34,6 +34,7 @@ function fakeClient() {
|
||||
},
|
||||
sendMessage: vi.fn(),
|
||||
newChat: vi.fn(),
|
||||
forkChat: vi.fn(),
|
||||
attach: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
close: vi.fn(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user