mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-15 15:24:06 +00:00
refactor(webui): shrink fork implementation
This commit is contained in:
parent
1f926e3769
commit
916525f94a
@ -5,37 +5,6 @@ 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)
|
## KaTeX — math rendering (MIT)
|
||||||
|
|
||||||
- **Source**: https://github.com/KaTeX/KaTeX
|
- **Source**: https://github.com/KaTeX/KaTeX
|
||||||
|
|||||||
@ -696,22 +696,23 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if forked is None:
|
if forked is None:
|
||||||
await self._send_event(connection, "error", detail="invalid fork source or index")
|
await self._send_event(connection, "error", detail="invalid fork source or index")
|
||||||
return
|
return
|
||||||
|
fork_id, fork_key = forked
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.logger.warning("fork_chat failed: {}", exc)
|
self.logger.warning("fork_chat failed: {}", exc)
|
||||||
await self._send_event(connection, "error", detail="fork_chat_failed")
|
await self._send_event(connection, "error", detail="fork_chat_failed")
|
||||||
return
|
return
|
||||||
|
|
||||||
scope = self._workspaces.scope_for_session_key(forked.session_key)
|
scope = self._workspaces.scope_for_session_key(fork_key)
|
||||||
self._attach(connection, forked.chat_id)
|
self._attach(connection, fork_id)
|
||||||
await self._send_event(connection, "attached", chat_id=forked.chat_id)
|
await self._send_event(connection, "attached", chat_id=fork_id)
|
||||||
await self._send_event(
|
await self._send_event(
|
||||||
connection,
|
connection,
|
||||||
"session_updated",
|
"session_updated",
|
||||||
chat_id=forked.chat_id,
|
chat_id=fork_id,
|
||||||
scope="metadata",
|
scope="metadata",
|
||||||
workspace_scope=scope.payload(),
|
workspace_scope=scope.payload(),
|
||||||
)
|
)
|
||||||
await self._hydrate_after_subscribe(forked.chat_id)
|
await self._hydrate_after_subscribe(fork_id)
|
||||||
return
|
return
|
||||||
if t == "attach":
|
if t == "attach":
|
||||||
cid = envelope.get("chat_id")
|
cid = envelope.get("chat_id")
|
||||||
|
|||||||
@ -1,14 +1,8 @@
|
|||||||
"""Helpers for WebUI chat forking.
|
"""WebUI chat fork orchestration."""
|
||||||
|
|
||||||
The WebSocket channel owns transport concerns only. This module owns the
|
|
||||||
WebUI-specific session/transcript work needed to make a fork look like a normal
|
|
||||||
chat in both browser WebUI and desktop.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.session.webui_turns import WEBUI_TITLE_METADATA_KEY, clean_generated_title
|
from nanobot.session.webui_turns import WEBUI_TITLE_METADATA_KEY, clean_generated_title
|
||||||
@ -20,25 +14,14 @@ from nanobot.webui.transcript import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class WebuiForkResult:
|
|
||||||
chat_id: str
|
|
||||||
session_key: str
|
|
||||||
|
|
||||||
|
|
||||||
def create_webui_chat_fork(
|
def create_webui_chat_fork(
|
||||||
session_manager: SessionManager,
|
session_manager: SessionManager,
|
||||||
*,
|
*,
|
||||||
source_chat_id: str,
|
source_chat_id: str,
|
||||||
before_user_index: int,
|
before_user_index: int,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
) -> WebuiForkResult | None:
|
) -> tuple[str, str] | None:
|
||||||
"""Create a WebUI chat fork from a completed assistant-turn boundary.
|
"""Return ``(chat_id, session_key)`` for a new fork, or ``None`` for bad input."""
|
||||||
|
|
||||||
Returns ``None`` when the source/index is invalid. Exceptions are reserved
|
|
||||||
for unexpected I/O or persistence failures and are rolled back before being
|
|
||||||
re-raised.
|
|
||||||
"""
|
|
||||||
new_id = str(uuid.uuid4())
|
new_id = str(uuid.uuid4())
|
||||||
source_key = f"websocket:{source_chat_id}"
|
source_key = f"websocket:{source_chat_id}"
|
||||||
target_key = f"websocket:{new_id}"
|
target_key = f"websocket:{new_id}"
|
||||||
@ -68,4 +51,4 @@ def create_webui_chat_fork(
|
|||||||
delete_webui_transcript(target_key)
|
delete_webui_transcript(target_key)
|
||||||
session_manager.delete_session(target_key)
|
session_manager.delete_session(target_key)
|
||||||
raise
|
raise
|
||||||
return WebuiForkResult(chat_id=new_id, session_key=target_key)
|
return new_id, target_key
|
||||||
|
|||||||
@ -286,6 +286,25 @@ def _is_user_transcript_row(row: dict[str, Any]) -> bool:
|
|||||||
return row.get("event") == "user" or row.get("role") == "user"
|
return row.get("event") == "user" or row.get("role") == "user"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_transcript_lines(session_key: str, rows: list[dict[str, Any]]) -> None:
|
||||||
|
path = webui_transcript_path(session_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 rows:
|
||||||
|
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 fork_transcript_before_user_index(
|
def fork_transcript_before_user_index(
|
||||||
source_key: str,
|
source_key: str,
|
||||||
target_key: str,
|
target_key: str,
|
||||||
@ -324,22 +343,7 @@ def fork_transcript_before_user_index(
|
|||||||
if not found_target:
|
if not found_target:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
path = webui_transcript_path(target_key)
|
_write_transcript_lines(target_key, copied)
|
||||||
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
@ -360,51 +364,29 @@ def write_session_messages_as_transcript(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Write a minimal WebUI transcript from already-truncated session messages."""
|
"""Write a minimal WebUI transcript from already-truncated session messages."""
|
||||||
target_chat_id = _chat_id_from_session_key(target_key)
|
target_chat_id = _chat_id_from_session_key(target_key)
|
||||||
path = webui_transcript_path(target_key)
|
rows: list[dict[str, Any]] = []
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
for msg in messages:
|
||||||
tmp_path = path.with_suffix(".jsonl.tmp")
|
role = msg.get("role")
|
||||||
try:
|
content = msg.get("content")
|
||||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
text = content if isinstance(content, str) else ""
|
||||||
for msg in messages:
|
if role == "user":
|
||||||
role = msg.get("role")
|
row: dict[str, Any] = {"event": "user", "chat_id": target_chat_id, "text": text}
|
||||||
content = msg.get("content")
|
media = msg.get("media")
|
||||||
text = content if isinstance(content, str) else ""
|
if isinstance(media, list) and media:
|
||||||
if role == "user":
|
row["media_paths"] = [str(p) for p in media if isinstance(p, str) and p]
|
||||||
row: dict[str, Any] = {
|
for key in ("cli_apps", "mcp_presets"):
|
||||||
"event": "user",
|
value = msg.get(key)
|
||||||
"chat_id": target_chat_id,
|
if isinstance(value, list) and value:
|
||||||
"text": text,
|
row[key] = json.loads(json.dumps(value, ensure_ascii=False))
|
||||||
}
|
elif role == "assistant" and text.strip():
|
||||||
media = msg.get("media")
|
row = {"event": "message", "chat_id": target_chat_id, "text": text}
|
||||||
if isinstance(media, list) and media:
|
media = msg.get("media")
|
||||||
row["media_paths"] = [str(p) for p in media if isinstance(p, str) and p]
|
if isinstance(media, list) and media:
|
||||||
for key in ("cli_apps", "mcp_presets"):
|
row["media"] = [str(p) for p in media if isinstance(p, str) and p]
|
||||||
value = msg.get(key)
|
else:
|
||||||
if isinstance(value, list) and value:
|
continue
|
||||||
row[key] = json.loads(json.dumps(value, ensure_ascii=False))
|
rows.append(row)
|
||||||
elif role == "assistant":
|
_write_transcript_lines(target_key, rows)
|
||||||
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:
|
def delete_webui_transcript(session_key: str) -> bool:
|
||||||
@ -1411,25 +1393,12 @@ def replay_transcript_to_ui_messages(
|
|||||||
return messages
|
return messages
|
||||||
|
|
||||||
|
|
||||||
def fork_boundary_message_count(
|
def fork_boundary_message_count(lines: list[dict[str, Any]]) -> int | None:
|
||||||
lines: list[dict[str, Any]],
|
|
||||||
*,
|
|
||||||
augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
|
|
||||||
augment_assistant_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
|
|
||||||
augment_assistant_text: Callable[[str], str] | None = None,
|
|
||||||
) -> int | None:
|
|
||||||
"""Return the replayed UI message count before the first fork marker, if any."""
|
"""Return the replayed UI message count before the first fork marker, if any."""
|
||||||
for idx, rec in enumerate(lines):
|
for idx, rec in enumerate(lines):
|
||||||
if rec.get("event") != WEBUI_FORK_MARKER_EVENT:
|
if rec.get("event") != WEBUI_FORK_MARKER_EVENT:
|
||||||
continue
|
continue
|
||||||
return len(
|
return len(replay_transcript_to_ui_messages(lines[:idx]))
|
||||||
replay_transcript_to_ui_messages(
|
|
||||||
lines[:idx],
|
|
||||||
augment_user_media=augment_user_media,
|
|
||||||
augment_assistant_media=augment_assistant_media,
|
|
||||||
augment_assistant_text=augment_assistant_text,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@ -1446,12 +1415,7 @@ def build_webui_thread_response(
|
|||||||
if not lines:
|
if not lines:
|
||||||
return None
|
return None
|
||||||
lines = inject_missing_user_events_from_session(session_key, lines, session_messages)
|
lines = inject_missing_user_events_from_session(session_key, lines, session_messages)
|
||||||
fork_boundary = fork_boundary_message_count(
|
fork_boundary = fork_boundary_message_count(lines)
|
||||||
lines,
|
|
||||||
augment_user_media=augment_user_media,
|
|
||||||
augment_assistant_media=augment_assistant_media,
|
|
||||||
augment_assistant_text=augment_assistant_text,
|
|
||||||
)
|
|
||||||
msgs = replay_transcript_to_ui_messages(
|
msgs = replay_transcript_to_ui_messages(
|
||||||
lines,
|
lines,
|
||||||
augment_user_media=augment_user_media,
|
augment_user_media=augment_user_media,
|
||||||
|
|||||||
@ -454,34 +454,6 @@ def test_fork_session_before_user_index_copies_only_prefix(tmp_path):
|
|||||||
assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"]
|
assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"]
|
||||||
|
|
||||||
|
|
||||||
def test_fork_session_from_middle_assistant_reply_keeps_selected_turn(tmp_path):
|
|
||||||
manager = SessionManager(tmp_path)
|
|
||||||
source = manager.get_or_create("websocket:source")
|
|
||||||
source.add_message("user", "round1")
|
|
||||||
source.add_message("assistant", "answer1")
|
|
||||||
source.add_message("user", "round2")
|
|
||||||
source.add_message("assistant", "answer2")
|
|
||||||
source.add_message("user", "round3 must not appear")
|
|
||||||
source.add_message("assistant", "answer3 must not appear")
|
|
||||||
manager.save(source)
|
|
||||||
|
|
||||||
forked = manager.fork_session_before_user_index(
|
|
||||||
"websocket:source",
|
|
||||||
"websocket:fork",
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert forked is not None
|
|
||||||
assert [m["content"] for m in forked.messages] == [
|
|
||||||
"round1",
|
|
||||||
"answer1",
|
|
||||||
"round2",
|
|
||||||
"answer2",
|
|
||||||
]
|
|
||||||
saved = manager.read_session_file("websocket:fork")
|
|
||||||
assert "round3 must not appear" not in str(saved)
|
|
||||||
|
|
||||||
|
|
||||||
def test_fork_session_rejects_negative_missing_and_out_of_range(tmp_path):
|
def test_fork_session_rejects_negative_missing_and_out_of_range(tmp_path):
|
||||||
manager = SessionManager(tmp_path)
|
manager = SessionManager(tmp_path)
|
||||||
source = manager.get_or_create("websocket:source")
|
source = manager.get_or_create("websocket:source")
|
||||||
|
|||||||
@ -2398,17 +2398,12 @@ async def test_fork_chat_copies_only_prefix_session_and_transcript(
|
|||||||
source.metadata["webui"] = True
|
source.metadata["webui"] = True
|
||||||
source.add_message("user", "round1")
|
source.add_message("user", "round1")
|
||||||
source.add_message("assistant", "answer1")
|
source.add_message("assistant", "answer1")
|
||||||
source.add_message("user", "round2 fork me")
|
source.add_message("user", "future")
|
||||||
source.add_message("assistant", "answer2")
|
|
||||||
source.add_message("user", "round3 must not appear")
|
|
||||||
sessions.save(source)
|
sessions.save(source)
|
||||||
for ev in (
|
for ev in (
|
||||||
{"event": "user", "chat_id": "source", "text": "round1"},
|
{"event": "user", "chat_id": "source", "text": "round1"},
|
||||||
{"event": "message", "chat_id": "source", "text": "answer1"},
|
{"event": "message", "chat_id": "source", "text": "answer1"},
|
||||||
{"event": "turn_end", "chat_id": "source"},
|
{"event": "user", "chat_id": "source", "text": "future"},
|
||||||
{"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)
|
append_transcript_object("websocket:source", ev)
|
||||||
|
|
||||||
@ -2437,133 +2432,12 @@ async def test_fork_chat_copies_only_prefix_session_and_transcript(
|
|||||||
assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"]
|
assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"]
|
||||||
assert saved["metadata"]["title"] == "Fork: Old title"
|
assert saved["metadata"]["title"] == "Fork: Old title"
|
||||||
fork_lines = read_transcript_lines(f"websocket:{fork_id}")
|
fork_lines = read_transcript_lines(f"websocket:{fork_id}")
|
||||||
assert [line.get("text") for line in fork_lines] == ["round1", "answer1", None, None]
|
assert [line.get("text") for line in fork_lines] == ["round1", "answer1", None]
|
||||||
assert fork_lines[-1]["event"] == "fork_marker"
|
assert fork_lines[-1]["event"] == "fork_marker"
|
||||||
assert all(line.get("chat_id") == fork_id for line in fork_lines)
|
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)
|
assert "future" not in json.dumps(saved, ensure_ascii=False)
|
||||||
bus.publish_inbound.assert_not_awaited()
|
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", None]
|
|
||||||
assert fork_lines[-1]["event"] == "fork_marker"
|
|
||||||
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", None]
|
|
||||||
assert fork_lines[-1]["event"] == "fork_marker"
|
|
||||||
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
|
@pytest.mark.asyncio
|
||||||
async def test_webui_message_envelope_appends_user_transcript(
|
async def test_webui_message_envelope_appends_user_transcript(
|
||||||
bus: MagicMock,
|
bus: MagicMock,
|
||||||
|
|||||||
@ -46,33 +46,6 @@ def test_fork_transcript_before_user_index_copies_only_prefix(tmp_path, monkeypa
|
|||||||
assert "round3 must not appear" 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_from_middle_assistant_reply_keeps_selected_turn(
|
|
||||||
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": "user", "chat_id": "source", "text": "round2"},
|
|
||||||
{"event": "message", "chat_id": "source", "text": "answer2"},
|
|
||||||
{"event": "user", "chat_id": "source", "text": "round3 must not appear"},
|
|
||||||
{"event": "message", "chat_id": "source", "text": "answer3 must not appear"},
|
|
||||||
):
|
|
||||||
append_transcript_object(source, ev)
|
|
||||||
|
|
||||||
ok = fork_transcript_before_user_index(source, "websocket:fork", 2)
|
|
||||||
|
|
||||||
assert ok is True
|
|
||||||
assert [line.get("text") for line in read_transcript_lines("websocket:fork")] == [
|
|
||||||
"round1",
|
|
||||||
"answer1",
|
|
||||||
"round2",
|
|
||||||
"answer2",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_fork_transcript_rejects_out_of_range_user_index(tmp_path, monkeypatch) -> None:
|
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)
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
source = "websocket:source"
|
source = "websocket:source"
|
||||||
@ -82,24 +55,6 @@ def test_fork_transcript_rejects_out_of_range_user_index(tmp_path, monkeypatch)
|
|||||||
assert read_transcript_lines("websocket:fork") == []
|
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_build_response_reports_fork_boundary_from_marker(tmp_path, monkeypatch) -> None:
|
def test_build_response_reports_fork_boundary_from_marker(tmp_path, monkeypatch) -> None:
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
key = "websocket:fork"
|
key = "websocket:fork"
|
||||||
|
|||||||
@ -5,13 +5,13 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
type SVGProps,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock3,
|
Clock3,
|
||||||
Copy,
|
Copy,
|
||||||
|
GitFork,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Wrench,
|
Wrench,
|
||||||
@ -22,12 +22,6 @@ import { AttachmentTile } from "@/components/AttachmentTile";
|
|||||||
import { CliAppMentionText } from "@/components/CliAppMentionText";
|
import { CliAppMentionText } from "@/components/CliAppMentionText";
|
||||||
import { ImageLightbox } from "@/components/ImageLightbox";
|
import { ImageLightbox } from "@/components/ImageLightbox";
|
||||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||||
import { formatTurnLatency } from "@/lib/format";
|
import { formatTurnLatency } from "@/lib/format";
|
||||||
@ -90,7 +84,7 @@ export function MessageBubble({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onCopyMessage = useCallback(() => {
|
const onCopyAssistantReply = useCallback(() => {
|
||||||
void copyTextToClipboard(message.content).then((ok) => {
|
void copyTextToClipboard(message.content).then((ok) => {
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
@ -114,11 +108,6 @@ export function MessageBubble({
|
|||||||
const hasImages = images.length > 0;
|
const hasImages = images.length > 0;
|
||||||
const hasMedia = media.length > 0;
|
const hasMedia = media.length > 0;
|
||||||
const hasText = message.content.trim().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" })
|
|
||||||
: t("message.copyMessage", { defaultValue: "Copy" });
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -144,43 +133,6 @@ export function MessageBubble({
|
|||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -235,54 +187,50 @@ export function MessageBubble({
|
|||||||
</MarkdownText>
|
</MarkdownText>
|
||||||
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
|
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
|
||||||
{showAssistantFooterRow ? (
|
{showAssistantFooterRow ? (
|
||||||
<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">
|
||||||
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
|
{showCopyButton ? (
|
||||||
{showCopyButton ? (
|
<button
|
||||||
<MessageActionTooltip label={copyReplyLabel}>
|
type="button"
|
||||||
<button
|
onClick={onCopyAssistantReply}
|
||||||
type="button"
|
aria-label={copyReplyLabel}
|
||||||
onClick={onCopyMessage}
|
title={copyReplyLabel}
|
||||||
aria-label={copyReplyLabel}
|
className={cn(
|
||||||
className={cn(
|
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||||
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||||
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
)}
|
||||||
)}
|
>
|
||||||
>
|
{copied ? (
|
||||||
{copied ? (
|
<Check className="h-4 w-4" aria-hidden />
|
||||||
<Check className="h-4 w-4" aria-hidden />
|
) : (
|
||||||
) : (
|
<Copy className="h-4 w-4" aria-hidden />
|
||||||
<Copy className="h-4 w-4" aria-hidden />
|
)}
|
||||||
)}
|
</button>
|
||||||
</button>
|
) : null}
|
||||||
</MessageActionTooltip>
|
{showForkButton ? (
|
||||||
) : null}
|
<button
|
||||||
{showForkButton ? (
|
type="button"
|
||||||
<MessageActionTooltip label={forkLabel}>
|
onClick={onForkFromHere}
|
||||||
<button
|
aria-label={forkLabel}
|
||||||
type="button"
|
title={forkLabel}
|
||||||
onClick={onForkFromHere}
|
className={cn(
|
||||||
aria-label={forkLabel}
|
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||||
className={cn(
|
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||||
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
)}
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
>
|
||||||
)}
|
<GitFork className="h-4 w-4" aria-hidden />
|
||||||
>
|
</button>
|
||||||
<ForkFromHereIcon className="h-4 w-4" aria-hidden />
|
) : null}
|
||||||
</button>
|
{showLatencyFooter ? (
|
||||||
</MessageActionTooltip>
|
<span
|
||||||
) : null}
|
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
|
||||||
{showLatencyFooter ? (
|
title={t("message.turnLatencyTitle")}
|
||||||
<span
|
>
|
||||||
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
|
{formatTurnLatency(latencyMs)}
|
||||||
title={t("message.turnLatencyTitle")}
|
</span>
|
||||||
>
|
) : null}
|
||||||
{formatTurnLatency(latencyMs)}
|
</div>
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -290,27 +238,6 @@ 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 }) {
|
function AutomationSourceBadge({ label, triggerLabel }: { label: string; triggerLabel: string }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -330,39 +257,6 @@ 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(
|
function mergeMcpMentionPresets(
|
||||||
presets: McpPresetInfo[],
|
presets: McpPresetInfo[],
|
||||||
attachments: UIMcpPresetAttachment[] | undefined,
|
attachments: UIMcpPresetAttachment[] | undefined,
|
||||||
|
|||||||
@ -172,7 +172,6 @@ interface ThreadComposerProps {
|
|||||||
workspaceError?: string | null;
|
workspaceError?: string | null;
|
||||||
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
|
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
|
||||||
pendingQueueKey?: string | null;
|
pendingQueueKey?: string | null;
|
||||||
externalError?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMMAND_ICONS: Record<string, LucideIcon> = {
|
const COMMAND_ICONS: Record<string, LucideIcon> = {
|
||||||
@ -766,7 +765,6 @@ export function ThreadComposer({
|
|||||||
workspaceError = null,
|
workspaceError = null,
|
||||||
onWorkspaceScopeChange,
|
onWorkspaceScopeChange,
|
||||||
pendingQueueKey = null,
|
pendingQueueKey = null,
|
||||||
externalError = null,
|
|
||||||
}: ThreadComposerProps) {
|
}: ThreadComposerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
@ -1149,10 +1147,6 @@ export function ThreadComposer({
|
|||||||
});
|
});
|
||||||
}, [clear, pendingQueueKey]);
|
}, [clear, pendingQueueKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (externalError) setInlineError(externalError);
|
|
||||||
}, [externalError]);
|
|
||||||
|
|
||||||
const appendTranscription = useCallback((text: string) => {
|
const appendTranscription = useCallback((text: string) => {
|
||||||
const transcript = text.trim();
|
const transcript = text.trim();
|
||||||
if (!transcript) return;
|
if (!transcript) return;
|
||||||
|
|||||||
@ -8,10 +8,10 @@ import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
|||||||
|
|
||||||
interface ThreadMessagesProps {
|
interface ThreadMessagesProps {
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
allMessages?: UIMessage[];
|
|
||||||
/** When true, agent turn still in flight — keeps activity timeline expanded. */
|
/** When true, agent turn still in flight — keeps activity timeline expanded. */
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
hiddenMessageCount?: number;
|
hiddenMessageCount?: number;
|
||||||
|
hiddenUserMessageCount?: number;
|
||||||
onLoadEarlier?: () => void;
|
onLoadEarlier?: () => void;
|
||||||
cliApps?: CliAppInfo[];
|
cliApps?: CliAppInfo[];
|
||||||
mcpPresets?: McpPresetInfo[];
|
mcpPresets?: McpPresetInfo[];
|
||||||
@ -65,9 +65,9 @@ export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
|
|||||||
|
|
||||||
export function ThreadMessages({
|
export function ThreadMessages({
|
||||||
messages,
|
messages,
|
||||||
allMessages,
|
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
hiddenMessageCount = 0,
|
hiddenMessageCount = 0,
|
||||||
|
hiddenUserMessageCount = 0,
|
||||||
onLoadEarlier,
|
onLoadEarlier,
|
||||||
cliApps = [],
|
cliApps = [],
|
||||||
mcpPresets = [],
|
mcpPresets = [],
|
||||||
@ -81,15 +81,12 @@ export function ThreadMessages({
|
|||||||
() => unitIndexAfterMessageCount(units, forkBoundaryMessageCount),
|
() => unitIndexAfterMessageCount(units, forkBoundaryMessageCount),
|
||||||
[forkBoundaryMessageCount, units],
|
[forkBoundaryMessageCount, units],
|
||||||
);
|
);
|
||||||
const assistantForkIndexById = useMemo(
|
|
||||||
() => assistantForkIndexByMessageId(allMessages ?? messages),
|
|
||||||
[allMessages, messages],
|
|
||||||
);
|
|
||||||
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
|
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
|
||||||
const liveActivityClusterIndices = useMemo(
|
const liveActivityClusterIndices = useMemo(
|
||||||
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
|
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
|
||||||
[isStreaming, units],
|
[isStreaming, units],
|
||||||
);
|
);
|
||||||
|
let nextUserIndex = hiddenUserMessageCount;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col">
|
||||||
@ -123,6 +120,11 @@ export function ThreadMessages({
|
|||||||
unit.type === "message" && unit.message.role === "user"
|
unit.type === "message" && unit.message.role === "user"
|
||||||
? unit.message.id
|
? unit.message.id
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const forkIndex =
|
||||||
|
unit.type === "message" && unit.message.role === "assistant" && copyFlags[index]
|
||||||
|
? nextUserIndex
|
||||||
|
: undefined;
|
||||||
|
if (unit.type === "message" && unit.message.role === "user") nextUserIndex += 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={unitKey(unit, index)}>
|
<Fragment key={unitKey(unit, index)}>
|
||||||
@ -149,20 +151,15 @@ export function ThreadMessages({
|
|||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
onOpenFilePreview={onOpenFilePreview}
|
onOpenFilePreview={onOpenFilePreview}
|
||||||
onForkFromHere={
|
onForkFromHere={
|
||||||
onForkFromMessage
|
onForkFromMessage && forkIndex !== undefined
|
||||||
? forkHandlerForAssistantMessage(
|
? () => onForkFromMessage(forkIndex)
|
||||||
unit.message,
|
|
||||||
copyFlags[index],
|
|
||||||
assistantForkIndexById,
|
|
||||||
onForkFromMessage,
|
|
||||||
)
|
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{index === forkBoundaryAfterUnitIndex ? (
|
{index === forkBoundaryAfterUnitIndex ? (
|
||||||
<ForkBoundaryDivider label={t("thread.fork.fromHistory")} />
|
<ForkBoundaryDivider label={t("thread.forkedFromHistory")} />
|
||||||
) : null}
|
) : null}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
@ -195,34 +192,6 @@ function ForkBoundaryDivider({ label }: { label: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
function currentActivityClusterIndices(units: DisplayUnit[]): Set<number> {
|
||||||
const indices = new Set<number>();
|
const indices = new Set<number>();
|
||||||
let markedCurrentActivity = false;
|
let markedCurrentActivity = false;
|
||||||
|
|||||||
@ -278,8 +278,6 @@ export function ThreadShell({
|
|||||||
const [filePreviewPath, setFilePreviewPath] = useState<string | null>(null);
|
const [filePreviewPath, setFilePreviewPath] = useState<string | null>(null);
|
||||||
const [filePreviewClosing, setFilePreviewClosing] = useState(false);
|
const [filePreviewClosing, setFilePreviewClosing] = useState(false);
|
||||||
const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH);
|
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 shellRef = useRef<HTMLElement | null>(null);
|
||||||
const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH);
|
const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH);
|
||||||
const filePreviewCloseTimerRef = useRef<number | null>(null);
|
const filePreviewCloseTimerRef = useRef<number | null>(null);
|
||||||
@ -288,7 +286,6 @@ export function ThreadShell({
|
|||||||
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
||||||
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
|
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
|
||||||
const prevChatIdForCacheRef = useRef<string | null>(null);
|
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). */
|
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
|
||||||
const skipLayoutCacheRef = useRef(false);
|
const skipLayoutCacheRef = useRef(false);
|
||||||
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
|
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
|
||||||
@ -340,12 +337,6 @@ export function ThreadShell({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (prevChatIdForComposerRef.current === chatId) return;
|
|
||||||
prevChatIdForComposerRef.current = chatId;
|
|
||||||
setForkError(null);
|
|
||||||
}, [chatId]);
|
|
||||||
|
|
||||||
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
|
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
|
||||||
|
|
||||||
const showHeroComposer = messages.length === 0 && !loading;
|
const showHeroComposer = messages.length === 0 && !loading;
|
||||||
@ -455,12 +446,6 @@ export function ThreadShell({
|
|||||||
setMessages(projectWebuiThreadMessages(historical));
|
setMessages(projectWebuiThreadMessages(historical));
|
||||||
}, [chatId, historical, setMessages]);
|
}, [chatId, historical, setMessages]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!chatId || loading || forkHydratingChatId !== chatId) return;
|
|
||||||
setForkHydratingChatId(null);
|
|
||||||
setScrollToBottomSignal((value) => value + 1);
|
|
||||||
}, [chatId, forkHydratingChatId, loading]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (chatId) {
|
if (chatId) {
|
||||||
const prev = prevChatIdForCacheRef.current;
|
const prev = prevChatIdForCacheRef.current;
|
||||||
@ -539,7 +524,6 @@ export function ThreadShell({
|
|||||||
|
|
||||||
const handleThreadSend = useCallback(
|
const handleThreadSend = useCallback(
|
||||||
(content: string, images?: SendImage[], options?: SendOptions) => {
|
(content: string, images?: SendImage[], options?: SendOptions) => {
|
||||||
setForkError(null);
|
|
||||||
setScrollToBottomSignal((value) => value + 1);
|
setScrollToBottomSignal((value) => value + 1);
|
||||||
send(content, images, withWorkspaceScope(options));
|
send(content, images, withWorkspaceScope(options));
|
||||||
},
|
},
|
||||||
@ -637,21 +621,13 @@ export function ThreadShell({
|
|||||||
const handleForkFromMessage = useCallback(
|
const handleForkFromMessage = useCallback(
|
||||||
async (beforeUserIndex: number) => {
|
async (beforeUserIndex: number) => {
|
||||||
if (!chatId || !onForkChat) return;
|
if (!chatId || !onForkChat) return;
|
||||||
setForkError(null);
|
|
||||||
const forkedChatId = await onForkChat(chatId, beforeUserIndex);
|
const forkedChatId = await onForkChat(chatId, beforeUserIndex);
|
||||||
if (!forkedChatId) {
|
if (!forkedChatId) return;
|
||||||
setForkError(t("thread.fork.failed", {
|
|
||||||
defaultValue: "Could not fork this chat. Try again.",
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
messageCacheRef.current.delete(forkedChatId);
|
messageCacheRef.current.delete(forkedChatId);
|
||||||
appliedHistoryVersionRef.current.delete(forkedChatId);
|
appliedHistoryVersionRef.current.delete(forkedChatId);
|
||||||
pendingCanonicalHydrateRef.current.add(forkedChatId);
|
pendingCanonicalHydrateRef.current.add(forkedChatId);
|
||||||
setForkHydratingChatId(forkedChatId);
|
|
||||||
setForkError(null);
|
|
||||||
},
|
},
|
||||||
[chatId, onForkChat, t],
|
[chatId, onForkChat],
|
||||||
);
|
);
|
||||||
|
|
||||||
const composer = (
|
const composer = (
|
||||||
@ -665,7 +641,7 @@ export function ThreadShell({
|
|||||||
{session ? (
|
{session ? (
|
||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
onSend={handleThreadSend}
|
onSend={handleThreadSend}
|
||||||
disabled={!chatId || forkHydratingChatId === chatId}
|
disabled={!chatId}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
placeholder={
|
placeholder={
|
||||||
showHeroComposer
|
showHeroComposer
|
||||||
@ -692,7 +668,6 @@ export function ThreadShell({
|
|||||||
workspaceError={workspaceError}
|
workspaceError={workspaceError}
|
||||||
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||||
pendingQueueKey={chatId}
|
pendingQueueKey={chatId}
|
||||||
externalError={forkError}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
@ -776,7 +751,6 @@ export function ThreadShell({
|
|||||||
showScrollToBottomButton={!!session}
|
showScrollToBottomButton={!!session}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
allMessages={displayMessages}
|
|
||||||
forkBoundaryMessageCount={forkBoundaryMessageCount}
|
forkBoundaryMessageCount={forkBoundaryMessageCount}
|
||||||
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
|
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
|
||||||
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}
|
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}
|
||||||
|
|||||||
@ -29,7 +29,6 @@ export interface ThreadViewportHandle {
|
|||||||
|
|
||||||
interface ThreadViewportProps {
|
interface ThreadViewportProps {
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
allMessages?: UIMessage[];
|
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
composer: ReactNode;
|
composer: ReactNode;
|
||||||
emptyState?: ReactNode;
|
emptyState?: ReactNode;
|
||||||
@ -64,7 +63,6 @@ export function windowMessages(messages: UIMessage[], visibleCount: number): UIM
|
|||||||
|
|
||||||
export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
|
export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
|
||||||
messages,
|
messages,
|
||||||
allMessages,
|
|
||||||
isStreaming,
|
isStreaming,
|
||||||
composer,
|
composer,
|
||||||
emptyState,
|
emptyState,
|
||||||
@ -100,6 +98,10 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
[messages, visibleMessageCount],
|
[messages, visibleMessageCount],
|
||||||
);
|
);
|
||||||
const hiddenMessageCount = messages.length - visibleMessages.length;
|
const hiddenMessageCount = messages.length - visibleMessages.length;
|
||||||
|
const hiddenUserMessageCount =
|
||||||
|
hiddenMessageCount > 0
|
||||||
|
? messages.slice(0, hiddenMessageCount).filter((message) => message.role === "user").length
|
||||||
|
: 0;
|
||||||
const visibleForkBoundaryMessageCount =
|
const visibleForkBoundaryMessageCount =
|
||||||
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
|
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
|
||||||
? forkBoundaryMessageCount - hiddenMessageCount
|
? forkBoundaryMessageCount - hiddenMessageCount
|
||||||
@ -299,9 +301,9 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
<div className="mx-auto w-full max-w-[49.5rem]">
|
<div className="mx-auto w-full max-w-[49.5rem]">
|
||||||
<ThreadMessages
|
<ThreadMessages
|
||||||
messages={visibleMessages}
|
messages={visibleMessages}
|
||||||
allMessages={allMessages ?? messages}
|
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
hiddenMessageCount={hiddenMessageCount}
|
hiddenMessageCount={hiddenMessageCount}
|
||||||
|
hiddenUserMessageCount={hiddenUserMessageCount}
|
||||||
onLoadEarlier={loadEarlierMessages}
|
onLoadEarlier={loadEarlierMessages}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
|
|||||||
@ -811,10 +811,7 @@
|
|||||||
},
|
},
|
||||||
"scrollToBottom": "Scroll to bottom",
|
"scrollToBottom": "Scroll to bottom",
|
||||||
"loadEarlier": "Load earlier messages",
|
"loadEarlier": "Load earlier messages",
|
||||||
"fork": {
|
"forkedFromHistory": "Forked from history",
|
||||||
"failed": "Could not fork this chat. Try again.",
|
|
||||||
"fromHistory": "Forked from history"
|
|
||||||
},
|
|
||||||
"promptNavigator": {
|
"promptNavigator": {
|
||||||
"open": "Open prompt navigator",
|
"open": "Open prompt navigator",
|
||||||
"title": "Prompts",
|
"title": "Prompts",
|
||||||
@ -854,8 +851,6 @@
|
|||||||
"imageAttachment": "Image attachment",
|
"imageAttachment": "Image attachment",
|
||||||
"automationSourceFallback": "Automation",
|
"automationSourceFallback": "Automation",
|
||||||
"automationTriggered": "Triggered automatically",
|
"automationTriggered": "Triggered automatically",
|
||||||
"copyMessage": "Copy",
|
|
||||||
"copiedMessage": "Copied",
|
|
||||||
"forkFromHere": "Fork",
|
"forkFromHere": "Fork",
|
||||||
"copyReply": "Copy",
|
"copyReply": "Copy",
|
||||||
"copiedReply": "Copied",
|
"copiedReply": "Copied",
|
||||||
|
|||||||
@ -811,10 +811,7 @@
|
|||||||
},
|
},
|
||||||
"scrollToBottom": "Desplazarse al final",
|
"scrollToBottom": "Desplazarse al final",
|
||||||
"loadEarlier": "Cargar mensajes anteriores",
|
"loadEarlier": "Cargar mensajes anteriores",
|
||||||
"fork": {
|
"forkedFromHistory": "Bifurcado desde el historial",
|
||||||
"failed": "No se pudo bifurcar este chat. Inténtalo de nuevo.",
|
|
||||||
"fromHistory": "Bifurcado desde el historial"
|
|
||||||
},
|
|
||||||
"promptNavigator": {
|
"promptNavigator": {
|
||||||
"open": "Abrir navegador de prompts",
|
"open": "Abrir navegador de prompts",
|
||||||
"title": "Prompts",
|
"title": "Prompts",
|
||||||
@ -840,8 +837,6 @@
|
|||||||
"agentActivityLiveSummary": "En curso… · {{reasoning}} pasos · {{tools}} llamadas a herramientas",
|
"agentActivityLiveSummary": "En curso… · {{reasoning}} pasos · {{tools}} llamadas a herramientas",
|
||||||
"agentActivityLiveToolsOnly": "En curso… · {{tools}} llamadas a herramientas",
|
"agentActivityLiveToolsOnly": "En curso… · {{tools}} llamadas a herramientas",
|
||||||
"imageAttachment": "Imagen adjunta",
|
"imageAttachment": "Imagen adjunta",
|
||||||
"copyMessage": "Copiar",
|
|
||||||
"copiedMessage": "Copiado",
|
|
||||||
"forkFromHere": "Bifurcar",
|
"forkFromHere": "Bifurcar",
|
||||||
"copyReply": "Copiar",
|
"copyReply": "Copiar",
|
||||||
"copiedReply": "Copiado",
|
"copiedReply": "Copiado",
|
||||||
|
|||||||
@ -811,10 +811,7 @@
|
|||||||
},
|
},
|
||||||
"scrollToBottom": "Faire défiler vers le bas",
|
"scrollToBottom": "Faire défiler vers le bas",
|
||||||
"loadEarlier": "Charger les messages précédents",
|
"loadEarlier": "Charger les messages précédents",
|
||||||
"fork": {
|
"forkedFromHistory": "Bifurqué depuis l'historique",
|
||||||
"failed": "Impossible de bifurquer cette conversation. Réessayez.",
|
|
||||||
"fromHistory": "Bifurqué depuis l'historique"
|
|
||||||
},
|
|
||||||
"promptNavigator": {
|
"promptNavigator": {
|
||||||
"open": "Ouvrir le navigateur de prompts",
|
"open": "Ouvrir le navigateur de prompts",
|
||||||
"title": "Prompts",
|
"title": "Prompts",
|
||||||
@ -840,8 +837,6 @@
|
|||||||
"agentActivityLiveSummary": "En cours… · {{reasoning}} étapes · {{tools}} appels d’outils",
|
"agentActivityLiveSummary": "En cours… · {{reasoning}} étapes · {{tools}} appels d’outils",
|
||||||
"agentActivityLiveToolsOnly": "En cours… · {{tools}} appels d’outils",
|
"agentActivityLiveToolsOnly": "En cours… · {{tools}} appels d’outils",
|
||||||
"imageAttachment": "Pièce jointe image",
|
"imageAttachment": "Pièce jointe image",
|
||||||
"copyMessage": "Copier",
|
|
||||||
"copiedMessage": "Copié",
|
|
||||||
"forkFromHere": "Bifurquer",
|
"forkFromHere": "Bifurquer",
|
||||||
"copyReply": "Copier",
|
"copyReply": "Copier",
|
||||||
"copiedReply": "Copié",
|
"copiedReply": "Copié",
|
||||||
|
|||||||
@ -811,10 +811,7 @@
|
|||||||
},
|
},
|
||||||
"scrollToBottom": "Gulir ke bawah",
|
"scrollToBottom": "Gulir ke bawah",
|
||||||
"loadEarlier": "Muat pesan sebelumnya",
|
"loadEarlier": "Muat pesan sebelumnya",
|
||||||
"fork": {
|
"forkedFromHistory": "Fork dari riwayat",
|
||||||
"failed": "Tidak dapat mem-fork chat ini. Coba lagi.",
|
|
||||||
"fromHistory": "Fork dari riwayat"
|
|
||||||
},
|
|
||||||
"promptNavigator": {
|
"promptNavigator": {
|
||||||
"open": "Buka navigator prompt",
|
"open": "Buka navigator prompt",
|
||||||
"title": "Prompt",
|
"title": "Prompt",
|
||||||
@ -840,8 +837,6 @@
|
|||||||
"agentActivityLiveSummary": "Berjalan… · {{reasoning}} langkah · {{tools}} panggilan alat",
|
"agentActivityLiveSummary": "Berjalan… · {{reasoning}} langkah · {{tools}} panggilan alat",
|
||||||
"agentActivityLiveToolsOnly": "Berjalan… · {{tools}} panggilan alat",
|
"agentActivityLiveToolsOnly": "Berjalan… · {{tools}} panggilan alat",
|
||||||
"imageAttachment": "Lampiran gambar",
|
"imageAttachment": "Lampiran gambar",
|
||||||
"copyMessage": "Salin",
|
|
||||||
"copiedMessage": "Disalin",
|
|
||||||
"forkFromHere": "Fork",
|
"forkFromHere": "Fork",
|
||||||
"copyReply": "Salin",
|
"copyReply": "Salin",
|
||||||
"copiedReply": "Disalin",
|
"copiedReply": "Disalin",
|
||||||
|
|||||||
@ -811,10 +811,7 @@
|
|||||||
},
|
},
|
||||||
"scrollToBottom": "一番下へスクロール",
|
"scrollToBottom": "一番下へスクロール",
|
||||||
"loadEarlier": "以前のメッセージを読み込む",
|
"loadEarlier": "以前のメッセージを読み込む",
|
||||||
"fork": {
|
"forkedFromHistory": "履歴から分岐",
|
||||||
"failed": "このチャットを分岐できませんでした。もう一度お試しください。",
|
|
||||||
"fromHistory": "履歴から分岐"
|
|
||||||
},
|
|
||||||
"promptNavigator": {
|
"promptNavigator": {
|
||||||
"open": "プロンプトナビゲーターを開く",
|
"open": "プロンプトナビゲーターを開く",
|
||||||
"title": "プロンプト",
|
"title": "プロンプト",
|
||||||
@ -840,8 +837,6 @@
|
|||||||
"agentActivityLiveSummary": "実行中… · {{reasoning}} ステップ · ツール呼び出し {{tools}} 回",
|
"agentActivityLiveSummary": "実行中… · {{reasoning}} ステップ · ツール呼び出し {{tools}} 回",
|
||||||
"agentActivityLiveToolsOnly": "実行中… · ツール呼び出し {{tools}} 回",
|
"agentActivityLiveToolsOnly": "実行中… · ツール呼び出し {{tools}} 回",
|
||||||
"imageAttachment": "画像の添付",
|
"imageAttachment": "画像の添付",
|
||||||
"copyMessage": "コピー",
|
|
||||||
"copiedMessage": "コピー済み",
|
|
||||||
"forkFromHere": "分岐",
|
"forkFromHere": "分岐",
|
||||||
"copyReply": "コピー",
|
"copyReply": "コピー",
|
||||||
"copiedReply": "コピー済み",
|
"copiedReply": "コピー済み",
|
||||||
|
|||||||
@ -811,10 +811,7 @@
|
|||||||
},
|
},
|
||||||
"scrollToBottom": "맨 아래로 스크롤",
|
"scrollToBottom": "맨 아래로 스크롤",
|
||||||
"loadEarlier": "이전 메시지 불러오기",
|
"loadEarlier": "이전 메시지 불러오기",
|
||||||
"fork": {
|
"forkedFromHistory": "기록에서 분기됨",
|
||||||
"failed": "이 채팅을 분기할 수 없습니다. 다시 시도해 주세요.",
|
|
||||||
"fromHistory": "기록에서 분기됨"
|
|
||||||
},
|
|
||||||
"promptNavigator": {
|
"promptNavigator": {
|
||||||
"open": "프롬프트 탐색기 열기",
|
"open": "프롬프트 탐색기 열기",
|
||||||
"title": "프롬프트",
|
"title": "프롬프트",
|
||||||
@ -840,8 +837,6 @@
|
|||||||
"agentActivityLiveSummary": "진행 중… · {{reasoning}}단계 · 도구 호출 {{tools}}회",
|
"agentActivityLiveSummary": "진행 중… · {{reasoning}}단계 · 도구 호출 {{tools}}회",
|
||||||
"agentActivityLiveToolsOnly": "진행 중… · 도구 호출 {{tools}}회",
|
"agentActivityLiveToolsOnly": "진행 중… · 도구 호출 {{tools}}회",
|
||||||
"imageAttachment": "이미지 첨부",
|
"imageAttachment": "이미지 첨부",
|
||||||
"copyMessage": "복사",
|
|
||||||
"copiedMessage": "복사됨",
|
|
||||||
"forkFromHere": "분기",
|
"forkFromHere": "분기",
|
||||||
"copyReply": "복사",
|
"copyReply": "복사",
|
||||||
"copiedReply": "복사됨",
|
"copiedReply": "복사됨",
|
||||||
|
|||||||
@ -811,10 +811,7 @@
|
|||||||
},
|
},
|
||||||
"scrollToBottom": "Cuộn xuống cuối",
|
"scrollToBottom": "Cuộn xuống cuối",
|
||||||
"loadEarlier": "Tải tin nhắn trước đó",
|
"loadEarlier": "Tải tin nhắn trước đó",
|
||||||
"fork": {
|
"forkedFromHistory": "Tách nhánh từ lịch sử",
|
||||||
"failed": "Không thể rẽ nhánh cuộc trò chuyện này. Hãy thử lại.",
|
|
||||||
"fromHistory": "Tách nhánh từ lịch sử"
|
|
||||||
},
|
|
||||||
"promptNavigator": {
|
"promptNavigator": {
|
||||||
"open": "Mở trình điều hướng prompt",
|
"open": "Mở trình điều hướng prompt",
|
||||||
"title": "Prompt",
|
"title": "Prompt",
|
||||||
@ -840,8 +837,6 @@
|
|||||||
"agentActivityLiveSummary": "Đang chạy… · {{reasoning}} bước · {{tools}} lần gọi công cụ",
|
"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ụ",
|
"agentActivityLiveToolsOnly": "Đang chạy… · {{tools}} lần gọi công cụ",
|
||||||
"imageAttachment": "Tệp hình ảnh đính kèm",
|
"imageAttachment": "Tệp hình ảnh đính kèm",
|
||||||
"copyMessage": "Sao chép",
|
|
||||||
"copiedMessage": "Đã sao chép",
|
|
||||||
"forkFromHere": "Tách nhánh",
|
"forkFromHere": "Tách nhánh",
|
||||||
"copyReply": "Sao chép",
|
"copyReply": "Sao chép",
|
||||||
"copiedReply": "Đã sao chép",
|
"copiedReply": "Đã sao chép",
|
||||||
|
|||||||
@ -811,10 +811,7 @@
|
|||||||
},
|
},
|
||||||
"scrollToBottom": "滚动到底部",
|
"scrollToBottom": "滚动到底部",
|
||||||
"loadEarlier": "加载更早消息",
|
"loadEarlier": "加载更早消息",
|
||||||
"fork": {
|
"forkedFromHistory": "从历史消息分叉",
|
||||||
"failed": "无法分叉这个对话,请重试。",
|
|
||||||
"fromHistory": "从历史消息分叉"
|
|
||||||
},
|
|
||||||
"promptNavigator": {
|
"promptNavigator": {
|
||||||
"open": "打开输入导航",
|
"open": "打开输入导航",
|
||||||
"title": "输入列表",
|
"title": "输入列表",
|
||||||
@ -854,8 +851,6 @@
|
|||||||
"imageAttachment": "图片附件",
|
"imageAttachment": "图片附件",
|
||||||
"automationSourceFallback": "自动化",
|
"automationSourceFallback": "自动化",
|
||||||
"automationTriggered": "自动触发",
|
"automationTriggered": "自动触发",
|
||||||
"copyMessage": "复制",
|
|
||||||
"copiedMessage": "已复制",
|
|
||||||
"forkFromHere": "分叉",
|
"forkFromHere": "分叉",
|
||||||
"copyReply": "复制",
|
"copyReply": "复制",
|
||||||
"copiedReply": "已复制",
|
"copiedReply": "已复制",
|
||||||
|
|||||||
@ -811,10 +811,7 @@
|
|||||||
},
|
},
|
||||||
"scrollToBottom": "捲動到底部",
|
"scrollToBottom": "捲動到底部",
|
||||||
"loadEarlier": "載入更早訊息",
|
"loadEarlier": "載入更早訊息",
|
||||||
"fork": {
|
"forkedFromHistory": "從歷史訊息分叉",
|
||||||
"failed": "無法分叉這個對話,請重試。",
|
|
||||||
"fromHistory": "從歷史訊息分叉"
|
|
||||||
},
|
|
||||||
"promptNavigator": {
|
"promptNavigator": {
|
||||||
"open": "開啟輸入導覽",
|
"open": "開啟輸入導覽",
|
||||||
"title": "輸入列表",
|
"title": "輸入列表",
|
||||||
@ -840,8 +837,6 @@
|
|||||||
"agentActivityLiveSummary": "進行中… · {{reasoning}} 步 · {{tools}} 次工具呼叫",
|
"agentActivityLiveSummary": "進行中… · {{reasoning}} 步 · {{tools}} 次工具呼叫",
|
||||||
"agentActivityLiveToolsOnly": "進行中… · {{tools}} 次工具呼叫",
|
"agentActivityLiveToolsOnly": "進行中… · {{tools}} 次工具呼叫",
|
||||||
"imageAttachment": "圖片附件",
|
"imageAttachment": "圖片附件",
|
||||||
"copyMessage": "複製",
|
|
||||||
"copiedMessage": "已複製",
|
|
||||||
"forkFromHere": "分叉",
|
"forkFromHere": "分叉",
|
||||||
"copyReply": "複製",
|
"copyReply": "複製",
|
||||||
"copiedReply": "已複製",
|
"copiedReply": "已複製",
|
||||||
|
|||||||
@ -76,22 +76,6 @@ describe("MessageBubble", () => {
|
|||||||
|
|
||||||
expect(row).toHaveClass("ml-auto", "flex");
|
expect(row).toHaveClass("ml-auto", "flex");
|
||||||
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]");
|
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]");
|
||||||
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole("button", { name: "Fork" })).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" })).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -766,223 +766,6 @@ describe("ThreadShell", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
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",
|
|
||||||
}));
|
|
||||||
|
|
||||||
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",
|
|
||||||
}));
|
|
||||||
|
|
||||||
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" }).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 () => {
|
it("does not cache optimistic messages under the next chat during a session switch", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
const onNewChat = vi.fn().mockResolvedValue("chat-b");
|
const onNewChat = vi.fn().mockResolvedValue("chat-b");
|
||||||
|
|||||||
@ -230,24 +230,6 @@ describe("useSessions", () => {
|
|||||||
expect(result.current.sessions[0]?.workspaceScope).toEqual(workspaceScope);
|
expect(result.current.sessions[0]?.workspaceScope).toEqual(workspaceScope);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a fork title visible while the server session list catches up", async () => {
|
|
||||||
vi.mocked(api.listSessions).mockResolvedValue([]);
|
|
||||||
const client = fakeClient();
|
|
||||||
client.forkChat.mockResolvedValue("chat-fork");
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useSessions(), {
|
|
||||||
wrapper: wrap(client),
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.forkChat("source", 2, "Fork: Original title");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(client.forkChat).toHaveBeenCalledWith("source", 2, "Fork: Original title");
|
|
||||||
expect(result.current.sessions[0]?.title).toBe("Fork: Original title");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes through WebUI transcript user media as images and media", async () => {
|
it("passes through WebUI transcript user media as images and media", async () => {
|
||||||
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
||||||
schemaVersion: 3,
|
schemaVersion: 3,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user