Merge PR #3877: feat(webui+agent): optimize streaming, activity rendering, and runtime sync
feat(webui+agent): optimize streaming, activity rendering, and runtime sync
This commit is contained in:
+30
-64
@@ -33,7 +33,6 @@ from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.providers.factory import ProviderSnapshot
|
||||
from nanobot.session.goal_state import (
|
||||
goal_state_ws_blob,
|
||||
runner_wall_llm_timeout_s,
|
||||
)
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
@@ -42,10 +41,14 @@ from nanobot.utils.document import extract_documents
|
||||
from nanobot.utils.helpers import image_placeholder_text
|
||||
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
||||
from nanobot.utils.image_generation_intent import image_generation_prompt
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
||||
from nanobot.utils.session_attachments import merge_turn_media_into_last_assistant
|
||||
from nanobot.utils.webui_titles import mark_webui_session, maybe_generate_webui_title_after_turn
|
||||
from nanobot.utils.webui_turn_helpers import publish_turn_run_status
|
||||
from nanobot.utils.webui_turn_helpers import (
|
||||
WebuiTurnCoordinator,
|
||||
build_bus_progress_callback,
|
||||
mark_webui_session,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.config.schema import (
|
||||
@@ -136,6 +139,11 @@ class AgentLoop:
|
||||
def tool_names(self) -> list[str]:
|
||||
return self.tools.tool_names
|
||||
|
||||
def llm_runtime(self) -> LLMRuntime:
|
||||
"""Return the current provider/model pair owned by this loop."""
|
||||
self._refresh_provider_snapshot()
|
||||
return LLMRuntime(self.provider, self.model)
|
||||
|
||||
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
||||
_PENDING_USER_TURN_KEY = "pending_user_turn"
|
||||
|
||||
@@ -237,6 +245,11 @@ class AgentLoop:
|
||||
|
||||
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
|
||||
self.sessions = session_manager or SessionManager(workspace)
|
||||
self._webui_turns = WebuiTurnCoordinator(
|
||||
bus=self.bus,
|
||||
sessions=self.sessions,
|
||||
schedule_background=lambda coro: self._schedule_background(coro),
|
||||
)
|
||||
self.tools = ToolRegistry()
|
||||
# One file-read/write tracker per logical session. The tool registry is
|
||||
# shared by this loop, so tools resolve the active state via contextvars.
|
||||
@@ -524,34 +537,7 @@ class AgentLoop:
|
||||
self, msg: InboundMessage
|
||||
) -> Callable[..., Awaitable[None]]:
|
||||
"""Build a progress callback that publishes to the message bus."""
|
||||
|
||||
async def _bus_progress(
|
||||
content: str,
|
||||
*,
|
||||
tool_hint: bool = False,
|
||||
tool_events: list[dict[str, Any]] | None = None,
|
||||
reasoning: bool = False,
|
||||
reasoning_end: bool = False,
|
||||
) -> None:
|
||||
meta = dict(msg.metadata or {})
|
||||
meta["_progress"] = True
|
||||
meta["_tool_hint"] = tool_hint
|
||||
if reasoning:
|
||||
meta["_reasoning_delta"] = True
|
||||
if reasoning_end:
|
||||
meta["_reasoning_end"] = True
|
||||
if tool_events:
|
||||
meta["_tool_events"] = tool_events
|
||||
await self.bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=content,
|
||||
metadata=meta,
|
||||
)
|
||||
)
|
||||
|
||||
return _bus_progress
|
||||
return build_bus_progress_callback(self.bus, msg)
|
||||
|
||||
async def _build_retry_wait_callback(
|
||||
self, msg: InboundMessage
|
||||
@@ -938,38 +924,12 @@ class AgentLoop:
|
||||
content="", metadata=msg.metadata or {},
|
||||
))
|
||||
if msg.channel == "websocket":
|
||||
# Signal that the turn is fully complete (all tools executed,
|
||||
# final text streamed). This lets WS clients know when to
|
||||
# definitively stop the loading indicator.
|
||||
turn_lat = self._pending_turn_latency_ms.pop(session_key, None)
|
||||
turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True}
|
||||
if turn_lat is not None:
|
||||
turn_metadata["latency_ms"] = int(turn_lat)
|
||||
sess_turn = self.sessions.get_or_create(session_key)
|
||||
turn_metadata["goal_state"] = goal_state_ws_blob(sess_turn.metadata)
|
||||
await self.bus.publish_outbound(OutboundMessage(
|
||||
channel=msg.channel, chat_id=msg.chat_id,
|
||||
content="", metadata=turn_metadata,
|
||||
))
|
||||
if msg.metadata.get("webui") is True:
|
||||
async def _generate_title_and_notify() -> None:
|
||||
generated = await maybe_generate_webui_title_after_turn(
|
||||
channel=msg.channel,
|
||||
metadata=msg.metadata,
|
||||
sessions=self.sessions,
|
||||
session_key=session_key,
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
)
|
||||
if generated:
|
||||
await self.bus.publish_outbound(OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content="",
|
||||
metadata={**msg.metadata, "_session_updated": True},
|
||||
))
|
||||
|
||||
self._schedule_background(_generate_title_and_notify())
|
||||
await self._webui_turns.handle_turn_end(
|
||||
msg,
|
||||
session_key=session_key,
|
||||
latency_ms=turn_lat,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Task cancelled for session {}", session_key)
|
||||
# Preserve partial context from the interrupted turn so
|
||||
@@ -1021,8 +981,9 @@ class AgentLoop:
|
||||
"Re-published {} leftover message(s) to bus for session {}",
|
||||
leftover, session_key,
|
||||
)
|
||||
await publish_turn_run_status(self.bus, msg, "idle")
|
||||
await self._webui_turns.publish_run_status(msg, "idle")
|
||||
self._pending_turn_latency_ms.pop(session_key, None)
|
||||
self._webui_turns.discard(session_key)
|
||||
|
||||
async def close_mcp(self) -> None:
|
||||
"""Drain pending background archives, then close MCP connections."""
|
||||
@@ -1338,6 +1299,11 @@ class AgentLoop:
|
||||
"include_timestamps": True,
|
||||
}
|
||||
ctx.history = ctx.session.get_history(**_hist_kwargs)
|
||||
self._webui_turns.capture_title_context(
|
||||
ctx.session_key,
|
||||
ctx.msg,
|
||||
self.llm_runtime(),
|
||||
)
|
||||
|
||||
ctx.initial_messages = self._build_initial_messages(
|
||||
ctx.msg, ctx.session, ctx.history, ctx.pending_summary
|
||||
@@ -1354,7 +1320,7 @@ class AgentLoop:
|
||||
return "ok"
|
||||
|
||||
async def _state_run(self, ctx: TurnContext) -> str:
|
||||
await publish_turn_run_status(self.bus, ctx.msg, "running")
|
||||
await self._webui_turns.publish_run_status(ctx.msg, "running")
|
||||
result = await self._run_agent_loop(
|
||||
ctx.initial_messages,
|
||||
on_progress=ctx.on_progress,
|
||||
|
||||
@@ -15,6 +15,12 @@ from loguru import logger
|
||||
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
from nanobot.utils.file_edit_events import (
|
||||
build_file_edit_end_event,
|
||||
build_file_edit_error_event,
|
||||
build_file_edit_start_event,
|
||||
prepare_file_edit_tracker,
|
||||
)
|
||||
from nanobot.utils.helpers import (
|
||||
IncrementalThinkExtractor,
|
||||
build_assistant_message,
|
||||
@@ -26,6 +32,10 @@ from nanobot.utils.helpers import (
|
||||
strip_think,
|
||||
truncate_text,
|
||||
)
|
||||
from nanobot.utils.progress_events import (
|
||||
invoke_file_edit_progress,
|
||||
on_progress_accepts_file_edit_events,
|
||||
)
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
from nanobot.utils.runtime import (
|
||||
EMPTY_FINAL_RESPONSE_MESSAGE,
|
||||
@@ -813,6 +823,30 @@ class AgentRunner:
|
||||
return prep_error + hint, event, (
|
||||
RuntimeError(prep_error) if spec.fail_on_tool_error else None
|
||||
)
|
||||
emit_file_edit_events = (
|
||||
spec.progress_callback is not None
|
||||
and on_progress_accepts_file_edit_events(spec.progress_callback)
|
||||
)
|
||||
progress_callback = spec.progress_callback if emit_file_edit_events else None
|
||||
file_edit_tracker = (
|
||||
prepare_file_edit_tracker(
|
||||
call_id=tool_call.id,
|
||||
tool_name=tool_call.name,
|
||||
tool=tool,
|
||||
workspace=spec.workspace,
|
||||
params=params if isinstance(params, dict) else None,
|
||||
)
|
||||
if progress_callback is not None
|
||||
else None
|
||||
)
|
||||
if file_edit_tracker is not None and progress_callback is not None:
|
||||
await invoke_file_edit_progress(
|
||||
progress_callback,
|
||||
[build_file_edit_start_event(
|
||||
file_edit_tracker,
|
||||
params if isinstance(params, dict) else None,
|
||||
)],
|
||||
)
|
||||
try:
|
||||
if tool is not None:
|
||||
result = await tool.execute(**params)
|
||||
@@ -821,6 +855,11 @@ class AgentRunner:
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except BaseException as exc:
|
||||
if file_edit_tracker is not None and progress_callback is not None:
|
||||
await invoke_file_edit_progress(
|
||||
progress_callback,
|
||||
[build_file_edit_error_event(file_edit_tracker, str(exc))],
|
||||
)
|
||||
event = {
|
||||
"name": tool_call.name,
|
||||
"status": "error",
|
||||
@@ -842,6 +881,11 @@ class AgentRunner:
|
||||
return payload, event, None
|
||||
|
||||
if isinstance(result, str) and result.startswith("Error"):
|
||||
if file_edit_tracker is not None and progress_callback is not None:
|
||||
await invoke_file_edit_progress(
|
||||
progress_callback,
|
||||
[build_file_edit_error_event(file_edit_tracker, result)],
|
||||
)
|
||||
event = {
|
||||
"name": tool_call.name,
|
||||
"status": "error",
|
||||
@@ -860,6 +904,12 @@ class AgentRunner:
|
||||
return result + hint, event, RuntimeError(result)
|
||||
return result + hint, event, None
|
||||
|
||||
if file_edit_tracker is not None and progress_callback is not None:
|
||||
await invoke_file_edit_progress(
|
||||
progress_callback,
|
||||
[build_file_edit_end_event(file_edit_tracker)],
|
||||
)
|
||||
|
||||
detail = "" if result is None else str(result)
|
||||
detail = detail.replace("\n", " ").strip()
|
||||
if not detail:
|
||||
|
||||
@@ -230,6 +230,25 @@ def _mask_secret_hint(secret: str | None) -> str | None:
|
||||
return f"{secret[:4]}••••{secret[-4:]}"
|
||||
|
||||
|
||||
def _provider_requires_api_key(spec: Any) -> bool:
|
||||
if spec.backend == "azure_openai":
|
||||
return True
|
||||
if spec.is_local or spec.is_direct:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
|
||||
if _provider_requires_api_key(spec):
|
||||
return bool(provider_config.api_key)
|
||||
return bool(
|
||||
provider_config.api_key
|
||||
or provider_config.api_base
|
||||
or getattr(provider_config, "region", None)
|
||||
or getattr(provider_config, "profile", None)
|
||||
)
|
||||
|
||||
|
||||
_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
|
||||
{"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
|
||||
{"name": "brave", "label": "Brave Search", "credential": "api_key"},
|
||||
@@ -786,13 +805,14 @@ class WebSocketChannel(BaseChannel):
|
||||
providers = []
|
||||
for spec in PROVIDERS:
|
||||
provider_config = getattr(config.providers, spec.name, None)
|
||||
if provider_config is None or spec.is_oauth or spec.is_local:
|
||||
if provider_config is None or spec.is_oauth:
|
||||
continue
|
||||
providers.append(
|
||||
{
|
||||
"name": spec.name,
|
||||
"label": spec.label,
|
||||
"configured": bool(provider_config.api_key),
|
||||
"configured": _provider_configured_for_settings(spec, provider_config),
|
||||
"api_key_required": _provider_requires_api_key(spec),
|
||||
"api_key_hint": _mask_secret_hint(provider_config.api_key),
|
||||
"api_base": provider_config.api_base,
|
||||
"default_api_base": spec.default_api_base or None,
|
||||
@@ -862,7 +882,12 @@ class WebSocketChannel(BaseChannel):
|
||||
if find_by_name(provider) is None:
|
||||
return _http_error(400, "unknown provider")
|
||||
provider_config = getattr(config.providers, provider, None)
|
||||
if provider_config is None or not provider_config.api_key:
|
||||
spec = find_by_name(provider)
|
||||
if (
|
||||
provider_config is None
|
||||
or spec is None
|
||||
or not _provider_configured_for_settings(spec, provider_config)
|
||||
):
|
||||
return _http_error(400, "provider is not configured")
|
||||
if defaults.provider != provider:
|
||||
defaults.provider = provider
|
||||
@@ -885,7 +910,7 @@ class WebSocketChannel(BaseChannel):
|
||||
if not provider_name:
|
||||
return _http_error(400, "provider is required")
|
||||
spec = find_by_name(provider_name)
|
||||
if spec is None or spec.is_oauth or spec.is_local:
|
||||
if spec is None or spec.is_oauth:
|
||||
return _http_error(400, "unknown provider")
|
||||
|
||||
config = load_config()
|
||||
@@ -1581,6 +1606,7 @@ class WebSocketChannel(BaseChannel):
|
||||
if not conns:
|
||||
if (
|
||||
msg.metadata.get("_progress")
|
||||
or msg.metadata.get("_file_edit_events")
|
||||
or msg.metadata.get("_turn_end")
|
||||
or msg.metadata.get("_session_updated")
|
||||
or msg.metadata.get("_goal_status")
|
||||
@@ -1613,7 +1639,22 @@ class WebSocketChannel(BaseChannel):
|
||||
await self.send_turn_end(msg.chat_id, latency_ms=lat_i, goal_state=gs_blob)
|
||||
return
|
||||
if msg.metadata.get("_session_updated"):
|
||||
await self.send_session_updated(msg.chat_id)
|
||||
scope = msg.metadata.get("_session_update_scope")
|
||||
await self.send_session_updated(
|
||||
msg.chat_id,
|
||||
scope=scope if isinstance(scope, str) else None,
|
||||
)
|
||||
return
|
||||
if msg.metadata.get("_file_edit_events"):
|
||||
payload: dict[str, Any] = {
|
||||
"event": "file_edit",
|
||||
"chat_id": msg.chat_id,
|
||||
"edits": msg.metadata["_file_edit_events"],
|
||||
}
|
||||
self._try_append_webui_transcript(msg.chat_id, payload)
|
||||
raw = json.dumps(payload, ensure_ascii=False)
|
||||
for connection in conns:
|
||||
await self._safe_send_to(connection, raw, label=" ")
|
||||
return
|
||||
text = msg.content
|
||||
payload: dict[str, Any] = {
|
||||
@@ -1780,12 +1821,14 @@ class WebSocketChannel(BaseChannel):
|
||||
for connection in conns:
|
||||
await self._safe_send_to(connection, raw, label=" goal_status ")
|
||||
|
||||
async def send_session_updated(self, chat_id: str) -> None:
|
||||
async def send_session_updated(self, chat_id: str, *, scope: str | None = None) -> None:
|
||||
"""Notify clients that session metadata changed outside the main turn."""
|
||||
conns = list(self._subs.get(chat_id, ()))
|
||||
if not conns:
|
||||
return
|
||||
body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id}
|
||||
if scope:
|
||||
body["scope"] = scope
|
||||
raw = json.dumps(body, ensure_ascii=False)
|
||||
for connection in conns:
|
||||
await self._safe_send_to(connection, raw, label=" session_updated ")
|
||||
|
||||
@@ -968,8 +968,7 @@ def _run_gateway(
|
||||
hb_cfg = config.gateway.heartbeat
|
||||
heartbeat = HeartbeatService(
|
||||
workspace=config.workspace_path,
|
||||
provider=agent.provider,
|
||||
model=agent.model,
|
||||
llm_runtime=agent.llm_runtime,
|
||||
on_execute=on_heartbeat_execute,
|
||||
on_notify=on_heartbeat_notify,
|
||||
interval_s=hb_cfg.interval_s,
|
||||
|
||||
@@ -4,12 +4,12 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Callable, Coroutine
|
||||
from typing import Any, Callable, Coroutine
|
||||
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.utils.llm_runtime import LLMRuntimeResolver, static_llm_runtime
|
||||
|
||||
_HEARTBEAT_TOOL = [
|
||||
{
|
||||
@@ -53,17 +53,21 @@ class HeartbeatService:
|
||||
def __init__(
|
||||
self,
|
||||
workspace: Path,
|
||||
provider: LLMProvider,
|
||||
model: str,
|
||||
provider: LLMProvider | None = None,
|
||||
model: str | None = None,
|
||||
on_execute: Callable[[str], Coroutine[Any, Any, str]] | None = None,
|
||||
on_notify: Callable[[str], Coroutine[Any, Any, None]] | None = None,
|
||||
interval_s: int = 30 * 60,
|
||||
enabled: bool = True,
|
||||
timezone: str | None = None,
|
||||
llm_runtime: LLMRuntimeResolver | None = None,
|
||||
):
|
||||
self.workspace = workspace
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
if llm_runtime is None:
|
||||
if provider is None or model is None:
|
||||
raise ValueError("HeartbeatService requires either llm_runtime or provider/model")
|
||||
llm_runtime = static_llm_runtime(provider, model)
|
||||
self._llm_runtime = llm_runtime
|
||||
self.on_execute = on_execute
|
||||
self.on_notify = on_notify
|
||||
self.interval_s = interval_s
|
||||
@@ -91,7 +95,9 @@ class HeartbeatService:
|
||||
"""
|
||||
from nanobot.utils.helpers import current_time_str
|
||||
|
||||
response = await self.provider.chat_with_retry(
|
||||
llm = self._llm_runtime()
|
||||
|
||||
response = await llm.provider.chat_with_retry(
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a heartbeat agent. Call the heartbeat tool to report your decision."},
|
||||
{"role": "user", "content": (
|
||||
@@ -101,7 +107,7 @@ class HeartbeatService:
|
||||
)},
|
||||
],
|
||||
tools=_HEARTBEAT_TOOL,
|
||||
model=self.model,
|
||||
model=llm.model,
|
||||
)
|
||||
|
||||
if not response.should_execute_tools:
|
||||
@@ -214,8 +220,9 @@ class HeartbeatService:
|
||||
)
|
||||
return
|
||||
|
||||
llm = self._llm_runtime()
|
||||
should_notify = await evaluate_response(
|
||||
response, tasks, self.provider, self.model,
|
||||
response, tasks, llm.provider, llm.model,
|
||||
)
|
||||
if should_notify and self.on_notify:
|
||||
logger.info("Heartbeat: completed, delivering response")
|
||||
|
||||
@@ -396,7 +396,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
name="vllm",
|
||||
keywords=("vllm",),
|
||||
env_key="HOSTED_VLLM_API_KEY",
|
||||
display_name="vLLM/Local",
|
||||
display_name="vLLM",
|
||||
backend="openai_compat",
|
||||
is_local=True,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"""File-edit activity helpers for WebUI progress events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
TRACKED_FILE_EDIT_TOOLS = frozenset({"write_file", "edit_file", "notebook_edit"})
|
||||
_MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FileSnapshot:
|
||||
path: Path
|
||||
exists: bool
|
||||
text: str | None
|
||||
unreadable: bool = False
|
||||
binary: bool = False
|
||||
oversized: bool = False
|
||||
|
||||
@property
|
||||
def countable(self) -> bool:
|
||||
return (
|
||||
self.text is not None
|
||||
and not self.binary
|
||||
and not self.oversized
|
||||
and not self.unreadable
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FileEditTracker:
|
||||
call_id: str
|
||||
tool: str
|
||||
path: Path
|
||||
display_path: str
|
||||
before: FileSnapshot
|
||||
|
||||
|
||||
def is_file_edit_tool(tool_name: str | None) -> bool:
|
||||
return bool(tool_name) and tool_name in TRACKED_FILE_EDIT_TOOLS
|
||||
|
||||
|
||||
def resolve_file_edit_path(
|
||||
tool: Any,
|
||||
workspace: Path | None,
|
||||
params: dict[str, Any] | None,
|
||||
) -> Path | None:
|
||||
"""Resolve the target file path after tool argument preparation."""
|
||||
if not isinstance(params, dict):
|
||||
return None
|
||||
raw_path = params.get("path")
|
||||
if not isinstance(raw_path, str) or not raw_path.strip():
|
||||
return None
|
||||
resolver = getattr(tool, "_resolve", None)
|
||||
if callable(resolver):
|
||||
try:
|
||||
resolved = resolver(raw_path)
|
||||
if isinstance(resolved, Path):
|
||||
return resolved
|
||||
if resolved:
|
||||
return Path(resolved)
|
||||
except Exception:
|
||||
return None
|
||||
if workspace is None:
|
||||
return Path(raw_path).expanduser().resolve()
|
||||
return (workspace / raw_path).expanduser().resolve()
|
||||
|
||||
|
||||
def display_file_edit_path(path: Path, workspace: Path | None) -> str:
|
||||
if workspace is not None:
|
||||
try:
|
||||
return path.resolve().relative_to(workspace.resolve()).as_posix()
|
||||
except Exception:
|
||||
pass
|
||||
return path.as_posix()
|
||||
|
||||
|
||||
def read_file_snapshot(path: Path, *, max_bytes: int = _MAX_SNAPSHOT_BYTES) -> FileSnapshot:
|
||||
try:
|
||||
if not path.exists() or not path.is_file():
|
||||
return FileSnapshot(path=path, exists=False, text="")
|
||||
size = path.stat().st_size
|
||||
if size > max_bytes:
|
||||
return FileSnapshot(path=path, exists=True, text=None, oversized=True)
|
||||
raw = path.read_bytes()
|
||||
except OSError:
|
||||
return FileSnapshot(path=path, exists=path.exists(), text=None, unreadable=True)
|
||||
if b"\x00" in raw:
|
||||
return FileSnapshot(path=path, exists=True, text=None, binary=True)
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return FileSnapshot(path=path, exists=True, text=None, binary=True)
|
||||
return FileSnapshot(path=path, exists=True, text=text.replace("\r\n", "\n"))
|
||||
|
||||
|
||||
def line_diff_stats(before: str | None, after: str | None) -> tuple[int, int]:
|
||||
"""Return ``(added, deleted)`` for a UTF-8 text line-level diff."""
|
||||
if before is None or after is None:
|
||||
return 0, 0
|
||||
before_lines = before.replace("\r\n", "\n").splitlines()
|
||||
after_lines = after.replace("\r\n", "\n").splitlines()
|
||||
added = 0
|
||||
deleted = 0
|
||||
matcher = difflib.SequenceMatcher(a=before_lines, b=after_lines, autojunk=False)
|
||||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||
if tag == "equal":
|
||||
continue
|
||||
if tag in ("replace", "delete"):
|
||||
deleted += i2 - i1
|
||||
if tag in ("replace", "insert"):
|
||||
added += j2 - j1
|
||||
return added, deleted
|
||||
|
||||
|
||||
def prepare_file_edit_tracker(
|
||||
*,
|
||||
call_id: str,
|
||||
tool_name: str,
|
||||
tool: Any,
|
||||
workspace: Path | None,
|
||||
params: dict[str, Any] | None,
|
||||
) -> FileEditTracker | None:
|
||||
if not is_file_edit_tool(tool_name):
|
||||
return None
|
||||
path = resolve_file_edit_path(tool, workspace, params)
|
||||
if path is None:
|
||||
return None
|
||||
before = read_file_snapshot(path)
|
||||
return FileEditTracker(
|
||||
call_id=str(call_id or ""),
|
||||
tool=tool_name,
|
||||
path=path,
|
||||
display_path=display_file_edit_path(path, workspace),
|
||||
before=before,
|
||||
)
|
||||
|
||||
|
||||
def build_file_edit_start_event(
|
||||
tracker: FileEditTracker,
|
||||
params: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
predicted_after = _predict_after_text(tracker.tool, params or {}, tracker.before)
|
||||
if tracker.before.countable and predicted_after is not None:
|
||||
added, deleted = line_diff_stats(tracker.before.text, predicted_after)
|
||||
else:
|
||||
added, deleted = 0, 0
|
||||
return _event_payload(
|
||||
tracker,
|
||||
phase="start",
|
||||
status="editing",
|
||||
added=added,
|
||||
deleted=deleted,
|
||||
approximate=True,
|
||||
)
|
||||
|
||||
|
||||
def build_file_edit_end_event(tracker: FileEditTracker) -> dict[str, Any]:
|
||||
after = read_file_snapshot(tracker.path)
|
||||
if tracker.before.countable and after.countable:
|
||||
added, deleted = line_diff_stats(tracker.before.text, after.text)
|
||||
else:
|
||||
added, deleted = 0, 0
|
||||
return _event_payload(
|
||||
tracker,
|
||||
phase="end",
|
||||
status="done",
|
||||
added=added,
|
||||
deleted=deleted,
|
||||
approximate=False,
|
||||
binary=after.binary or after.oversized or after.unreadable,
|
||||
)
|
||||
|
||||
|
||||
def build_file_edit_error_event(tracker: FileEditTracker, error: str | None = None) -> dict[str, Any]:
|
||||
payload = _event_payload(
|
||||
tracker,
|
||||
phase="error",
|
||||
status="error",
|
||||
added=0,
|
||||
deleted=0,
|
||||
approximate=False,
|
||||
)
|
||||
if error:
|
||||
payload["error"] = error.strip()[:240]
|
||||
return payload
|
||||
|
||||
|
||||
def _event_payload(
|
||||
tracker: FileEditTracker,
|
||||
*,
|
||||
phase: str,
|
||||
status: str,
|
||||
added: int,
|
||||
deleted: int,
|
||||
approximate: bool,
|
||||
binary: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"version": 1,
|
||||
"call_id": tracker.call_id,
|
||||
"tool": tracker.tool,
|
||||
"path": tracker.display_path,
|
||||
"phase": phase,
|
||||
"added": max(0, int(added)),
|
||||
"deleted": max(0, int(deleted)),
|
||||
"approximate": bool(approximate),
|
||||
"status": status,
|
||||
}
|
||||
if binary:
|
||||
payload["binary"] = True
|
||||
return payload
|
||||
|
||||
|
||||
def _predict_after_text(
|
||||
tool_name: str,
|
||||
params: dict[str, Any],
|
||||
before: FileSnapshot,
|
||||
) -> str | None:
|
||||
if not before.countable:
|
||||
return None
|
||||
before_text = before.text or ""
|
||||
if tool_name == "write_file":
|
||||
content = params.get("content")
|
||||
return content if isinstance(content, str) else ""
|
||||
if tool_name == "edit_file":
|
||||
old_text = params.get("old_text")
|
||||
new_text = params.get("new_text")
|
||||
if not isinstance(old_text, str) or not isinstance(new_text, str):
|
||||
return None
|
||||
replace_all = bool(params.get("replace_all"))
|
||||
if old_text == "":
|
||||
return new_text if not before.exists else before_text
|
||||
if old_text in before_text:
|
||||
if replace_all:
|
||||
return before_text.replace(old_text, new_text)
|
||||
return before_text.replace(old_text, new_text, 1)
|
||||
return None
|
||||
if tool_name == "notebook_edit":
|
||||
return _predict_notebook_after_text(params, before_text)
|
||||
return None
|
||||
|
||||
|
||||
def _predict_notebook_after_text(params: dict[str, Any], before_text: str) -> str | None:
|
||||
try:
|
||||
nb = json.loads(before_text) if before_text.strip() else _empty_notebook()
|
||||
except Exception:
|
||||
return None
|
||||
cells = nb.get("cells")
|
||||
if not isinstance(cells, list):
|
||||
return None
|
||||
try:
|
||||
cell_index = int(params.get("cell_index", 0))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
new_source = params.get("new_source")
|
||||
source = new_source if isinstance(new_source, str) else ""
|
||||
cell_type = params.get("cell_type") if params.get("cell_type") in ("code", "markdown") else "code"
|
||||
mode = params.get("edit_mode") if params.get("edit_mode") in ("replace", "insert", "delete") else "replace"
|
||||
if mode == "delete":
|
||||
if 0 <= cell_index < len(cells):
|
||||
cells.pop(cell_index)
|
||||
else:
|
||||
return None
|
||||
elif mode == "insert":
|
||||
insert_at = min(max(cell_index + 1, 0), len(cells))
|
||||
cells.insert(insert_at, _new_notebook_cell(source, str(cell_type)))
|
||||
else:
|
||||
if not (0 <= cell_index < len(cells)):
|
||||
return None
|
||||
cell = cells[cell_index]
|
||||
if not isinstance(cell, dict):
|
||||
return None
|
||||
cell["source"] = source
|
||||
cell["cell_type"] = cell_type
|
||||
if cell_type == "code":
|
||||
cell.setdefault("outputs", [])
|
||||
cell.setdefault("execution_count", None)
|
||||
else:
|
||||
cell.pop("outputs", None)
|
||||
cell.pop("execution_count", None)
|
||||
nb["cells"] = cells
|
||||
try:
|
||||
return json.dumps(nb, indent=1, ensure_ascii=False)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _empty_notebook() -> dict[str, Any]:
|
||||
return {
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5,
|
||||
"metadata": {
|
||||
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
|
||||
"language_info": {"name": "python"},
|
||||
},
|
||||
"cells": [],
|
||||
}
|
||||
|
||||
|
||||
def _new_notebook_cell(source: str, cell_type: str) -> dict[str, Any]:
|
||||
cell: dict[str, Any] = {"cell_type": cell_type, "source": source, "metadata": {}}
|
||||
if cell_type == "code":
|
||||
cell["outputs"] = []
|
||||
cell["execution_count"] = None
|
||||
return cell
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Small helpers for passing the active LLM provider/model together."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LLMRuntime:
|
||||
provider: LLMProvider
|
||||
model: str
|
||||
|
||||
|
||||
LLMRuntimeResolver = Callable[[], LLMRuntime]
|
||||
|
||||
|
||||
def static_llm_runtime(provider: LLMProvider, model: str) -> LLMRuntimeResolver:
|
||||
runtime = LLMRuntime(provider=provider, model=model)
|
||||
return lambda: runtime
|
||||
@@ -10,13 +10,21 @@ from nanobot.agent.hook import AgentHookContext
|
||||
|
||||
|
||||
def on_progress_accepts_tool_events(cb: Callable[..., Any]) -> bool:
|
||||
return _on_progress_accepts(cb, "tool_events")
|
||||
|
||||
|
||||
def on_progress_accepts_file_edit_events(cb: Callable[..., Any]) -> bool:
|
||||
return _on_progress_accepts(cb, "file_edit_events")
|
||||
|
||||
|
||||
def _on_progress_accepts(cb: Callable[..., Any], name: str) -> bool:
|
||||
try:
|
||||
sig = inspect.signature(cb)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
|
||||
return True
|
||||
return "tool_events" in sig.parameters
|
||||
return name in sig.parameters
|
||||
|
||||
|
||||
async def invoke_on_progress(
|
||||
@@ -32,6 +40,15 @@ async def invoke_on_progress(
|
||||
await on_progress(content, tool_hint=tool_hint)
|
||||
|
||||
|
||||
async def invoke_file_edit_progress(
|
||||
on_progress: Callable[..., Awaitable[None]],
|
||||
file_edit_events: list[dict[str, Any]],
|
||||
) -> None:
|
||||
if not file_edit_events or not on_progress_accepts_file_edit_events(on_progress):
|
||||
return
|
||||
await on_progress("", file_edit_events=file_edit_events)
|
||||
|
||||
|
||||
def build_tool_event_start_payload(tool_call: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"version": 1,
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
"""Helpers for WebUI chat title generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.utils.helpers import truncate_text
|
||||
|
||||
WEBUI_SESSION_METADATA_KEY = "webui"
|
||||
WEBUI_TITLE_METADATA_KEY = "title"
|
||||
WEBUI_TITLE_USER_EDITED_METADATA_KEY = "title_user_edited"
|
||||
TITLE_MAX_CHARS = 60
|
||||
|
||||
|
||||
def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool:
|
||||
"""Persist a WebUI marker only when the inbound websocket frame opted in."""
|
||||
if metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
|
||||
return False
|
||||
session.metadata[WEBUI_SESSION_METADATA_KEY] = True
|
||||
return True
|
||||
|
||||
|
||||
def clean_generated_title(raw: str | None) -> str:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r"^\s*(title|标题)\s*[::]\s*", "", text, flags=re.IGNORECASE)
|
||||
text = text.strip().strip("\"'`“”‘’")
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
text = text.rstrip("。.!!??,,;;:")
|
||||
if len(text) > TITLE_MAX_CHARS:
|
||||
text = text[: TITLE_MAX_CHARS - 1].rstrip() + "…"
|
||||
return text
|
||||
|
||||
|
||||
def _title_inputs(session: Session) -> tuple[str, str]:
|
||||
user_text = ""
|
||||
assistant_text = ""
|
||||
for message in session.messages:
|
||||
role = message.get("role")
|
||||
content = message.get("content")
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
continue
|
||||
if role == "user" and not user_text:
|
||||
user_text = content.strip()
|
||||
elif role == "assistant" and not assistant_text:
|
||||
assistant_text = content.strip()
|
||||
if user_text and assistant_text:
|
||||
break
|
||||
return user_text, assistant_text
|
||||
|
||||
|
||||
async def maybe_generate_webui_title(
|
||||
*,
|
||||
sessions: SessionManager,
|
||||
session_key: str,
|
||||
provider: LLMProvider,
|
||||
model: str,
|
||||
) -> bool:
|
||||
"""Generate and persist a short title for WebUI-owned sessions only."""
|
||||
session = sessions.get_or_create(session_key)
|
||||
if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
|
||||
return False
|
||||
if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True:
|
||||
return False
|
||||
current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY)
|
||||
if isinstance(current_title, str) and current_title.strip():
|
||||
return False
|
||||
|
||||
user_text, assistant_text = _title_inputs(session)
|
||||
if not user_text:
|
||||
return False
|
||||
|
||||
prompt = (
|
||||
"Generate a concise title for this chat.\n"
|
||||
"Rules:\n"
|
||||
"- Use the same language as the user when practical.\n"
|
||||
"- 3 to 8 words.\n"
|
||||
"- No quotes.\n"
|
||||
"- No punctuation at the end.\n"
|
||||
"- Return only the title.\n\n"
|
||||
f"User: {truncate_text(user_text, 1_000)}"
|
||||
)
|
||||
if assistant_text:
|
||||
prompt += f"\nAssistant: {truncate_text(assistant_text, 1_000)}"
|
||||
|
||||
try:
|
||||
response = await provider.chat_with_retry(
|
||||
[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You write short, neutral chat titles. "
|
||||
"Return only the title text."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
tools=None,
|
||||
model=model,
|
||||
max_tokens=32,
|
||||
temperature=0.2,
|
||||
retry_mode="standard",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to generate webui session title for {}", session_key, exc_info=True)
|
||||
return False
|
||||
|
||||
title = clean_generated_title(response.content)
|
||||
if not title or title.lower().startswith("error"):
|
||||
return False
|
||||
session.metadata[WEBUI_TITLE_METADATA_KEY] = title
|
||||
sessions.save(session)
|
||||
return True
|
||||
|
||||
|
||||
async def maybe_generate_webui_title_after_turn(
|
||||
*,
|
||||
channel: str,
|
||||
metadata: dict[str, Any],
|
||||
sessions: SessionManager,
|
||||
session_key: str,
|
||||
provider: LLMProvider,
|
||||
model: str,
|
||||
) -> bool:
|
||||
if channel != "websocket" or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
|
||||
return False
|
||||
return await maybe_generate_webui_title(
|
||||
sessions=sessions,
|
||||
session_key=session_key,
|
||||
provider=provider,
|
||||
model=model,
|
||||
)
|
||||
@@ -125,11 +125,25 @@ def replay_transcript_to_ui_messages(
|
||||
buffer_message_id: str | None = None
|
||||
buffer_parts: list[str] = []
|
||||
suppress_until_turn_end = False
|
||||
active_activity_segment_id: str | None = None
|
||||
active_file_edit_segment_id: str | None = None
|
||||
activity_segment_counter = 0
|
||||
_ts_base = int(time.time() * 1000)
|
||||
|
||||
def _new_id(prefix: str, idx: int) -> str:
|
||||
return f"{prefix}-{idx}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
def _new_activity_segment(*, activate: bool = True) -> str:
|
||||
nonlocal active_activity_segment_id, activity_segment_counter
|
||||
activity_segment_counter += 1
|
||||
segment_id = f"activity-{activity_segment_counter}"
|
||||
if activate:
|
||||
active_activity_segment_id = segment_id
|
||||
return segment_id
|
||||
|
||||
def _ensure_activity_segment() -> str:
|
||||
return active_activity_segment_id or _new_activity_segment()
|
||||
|
||||
def attach_reasoning_chunk(prev: list[dict[str, Any]], chunk: str, idx: int) -> None:
|
||||
for i in range(len(prev) - 1, -1, -1):
|
||||
candidate = prev[i]
|
||||
@@ -151,12 +165,19 @@ def replay_transcript_to_ui_messages(
|
||||
**candidate,
|
||||
"reasoning": (str(candidate.get("reasoning") or "")) + chunk,
|
||||
"reasoningStreaming": True,
|
||||
"activitySegmentId": candidate.get("activitySegmentId") or _ensure_activity_segment(),
|
||||
}
|
||||
return
|
||||
if not has_answer and candidate.get("isStreaming"):
|
||||
prev[i] = {**candidate, "reasoning": chunk, "reasoningStreaming": True}
|
||||
prev[i] = {
|
||||
**candidate,
|
||||
"reasoning": chunk,
|
||||
"reasoningStreaming": True,
|
||||
"activitySegmentId": candidate.get("activitySegmentId") or _ensure_activity_segment(),
|
||||
}
|
||||
return
|
||||
break
|
||||
segment = _ensure_activity_segment()
|
||||
prev.append(
|
||||
{
|
||||
"id": _new_id("as", idx),
|
||||
@@ -165,6 +186,7 @@ def replay_transcript_to_ui_messages(
|
||||
"isStreaming": True,
|
||||
"reasoning": chunk,
|
||||
"reasoningStreaming": True,
|
||||
"activitySegmentId": segment,
|
||||
"createdAt": _ts_base + idx,
|
||||
},
|
||||
)
|
||||
@@ -221,6 +243,7 @@ def replay_transcript_to_ui_messages(
|
||||
return
|
||||
|
||||
def absorb_complete(extra: dict[str, Any], idx: int) -> None:
|
||||
nonlocal active_activity_segment_id
|
||||
last = messages[-1] if messages else None
|
||||
if last and is_reasoning_only_placeholder(last):
|
||||
messages[-1] = {
|
||||
@@ -238,10 +261,76 @@ def replay_transcript_to_ui_messages(
|
||||
**extra,
|
||||
},
|
||||
)
|
||||
active_activity_segment_id = None
|
||||
|
||||
def _file_edit_key(edit: dict[str, Any]) -> str:
|
||||
return "|".join(
|
||||
str(edit.get(k) or "")
|
||||
for k in ("call_id", "tool", "path")
|
||||
)
|
||||
|
||||
def upsert_file_edits(edits: list[dict[str, Any]], idx: int) -> None:
|
||||
nonlocal active_file_edit_segment_id
|
||||
if not edits:
|
||||
return
|
||||
last = messages[-1] if messages else None
|
||||
if (
|
||||
active_file_edit_segment_id
|
||||
and last
|
||||
and last.get("kind") == "trace"
|
||||
and last.get("fileEdits")
|
||||
):
|
||||
segment = active_file_edit_segment_id
|
||||
else:
|
||||
segment = _new_activity_segment(activate=False)
|
||||
active_file_edit_segment_id = segment
|
||||
if not (
|
||||
last
|
||||
and last.get("kind") == "trace"
|
||||
and not last.get("isStreaming")
|
||||
and last.get("fileEdits")
|
||||
and last.get("activitySegmentId") == segment
|
||||
):
|
||||
messages.append(
|
||||
{
|
||||
"id": _new_id("tr", idx),
|
||||
"role": "tool",
|
||||
"kind": "trace",
|
||||
"content": "",
|
||||
"traces": [],
|
||||
"fileEdits": [],
|
||||
"activitySegmentId": segment,
|
||||
"createdAt": _ts_base + idx,
|
||||
},
|
||||
)
|
||||
last = messages[-1]
|
||||
existing = list(last.get("fileEdits") or [])
|
||||
index_by_key = {
|
||||
_file_edit_key(edit): pos
|
||||
for pos, edit in enumerate(existing)
|
||||
if isinstance(edit, dict)
|
||||
}
|
||||
for edit in edits:
|
||||
if not isinstance(edit, dict):
|
||||
continue
|
||||
key = _file_edit_key(edit)
|
||||
if key in index_by_key:
|
||||
pos = index_by_key[key]
|
||||
existing[pos] = {**existing[pos], **edit}
|
||||
else:
|
||||
index_by_key[key] = len(existing)
|
||||
existing.append(dict(edit))
|
||||
messages[-1] = {
|
||||
**last,
|
||||
"fileEdits": existing,
|
||||
"activitySegmentId": last.get("activitySegmentId") or segment,
|
||||
}
|
||||
|
||||
for idx, rec in enumerate(lines):
|
||||
ev = rec.get("event")
|
||||
if ev == "user":
|
||||
active_activity_segment_id = None
|
||||
active_file_edit_segment_id = None
|
||||
text = rec.get("text")
|
||||
text_s = text if isinstance(text, str) else ""
|
||||
media_paths = rec.get("media_paths")
|
||||
@@ -264,6 +353,12 @@ def replay_transcript_to_ui_messages(
|
||||
messages.append(row)
|
||||
continue
|
||||
|
||||
if ev == "file_edit":
|
||||
raw_edits = rec.get("edits")
|
||||
if isinstance(raw_edits, list):
|
||||
upsert_file_edits([e for e in raw_edits if isinstance(e, dict)], idx)
|
||||
continue
|
||||
|
||||
if ev == "delta":
|
||||
if suppress_until_turn_end:
|
||||
continue
|
||||
@@ -338,14 +433,21 @@ def replay_transcript_to_ui_messages(
|
||||
trace_lines = structured if structured else ([text] if isinstance(text, str) and text else [])
|
||||
if not trace_lines:
|
||||
continue
|
||||
segment = _ensure_activity_segment()
|
||||
last = messages[-1] if messages else None
|
||||
if last and last.get("kind") == "trace" and not last.get("isStreaming"):
|
||||
if (
|
||||
last
|
||||
and last.get("kind") == "trace"
|
||||
and not last.get("isStreaming")
|
||||
and (last.get("activitySegmentId") in (None, segment))
|
||||
):
|
||||
prev_traces = list(last.get("traces") or [last.get("content")])
|
||||
merged_traces = prev_traces + trace_lines
|
||||
messages[-1] = {
|
||||
**last,
|
||||
"traces": merged_traces,
|
||||
"content": trace_lines[-1],
|
||||
"activitySegmentId": last.get("activitySegmentId") or segment,
|
||||
}
|
||||
else:
|
||||
messages.append(
|
||||
@@ -355,6 +457,7 @@ def replay_transcript_to_ui_messages(
|
||||
"kind": "trace",
|
||||
"content": trace_lines[-1],
|
||||
"traces": trace_lines,
|
||||
"activitySegmentId": segment,
|
||||
"createdAt": _ts_base + idx,
|
||||
},
|
||||
)
|
||||
@@ -389,6 +492,8 @@ def replay_transcript_to_ui_messages(
|
||||
|
||||
if ev == "turn_end":
|
||||
suppress_until_turn_end = False
|
||||
active_activity_segment_id = None
|
||||
active_file_edit_segment_id = None
|
||||
for i, m in enumerate(messages):
|
||||
if m.get("isStreaming"):
|
||||
messages[i] = {**m, "isStreaming": False}
|
||||
|
||||
@@ -6,17 +6,163 @@ AgentLoop uses these without importing a concrete channel plugin; only
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.session.goal_state import goal_state_ws_blob
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.utils.helpers import truncate_text
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
|
||||
WEBUI_SESSION_METADATA_KEY = "webui"
|
||||
WEBUI_TITLE_METADATA_KEY = "title"
|
||||
WEBUI_TITLE_USER_EDITED_METADATA_KEY = "title_user_edited"
|
||||
TITLE_MAX_CHARS = 60
|
||||
TITLE_GENERATION_MAX_TOKENS = 96
|
||||
TITLE_GENERATION_REASONING_EFFORT = "none"
|
||||
|
||||
# Wall-clock turn start per ``chat_id`` (websocket only). Survives browser refresh while the
|
||||
# gateway process stays up; cleared on idle/stop and implicitly dropped on restart.
|
||||
_WEBSOCKET_TURN_WALL_STARTED_AT: dict[str, float] = {}
|
||||
|
||||
|
||||
def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool:
|
||||
"""Persist a WebUI marker only when the inbound websocket frame opted in."""
|
||||
if metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
|
||||
return False
|
||||
session.metadata[WEBUI_SESSION_METADATA_KEY] = True
|
||||
return True
|
||||
|
||||
|
||||
def clean_generated_title(raw: str | None) -> str:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r"^\s*(title|标题)\s*[::]\s*", "", text, flags=re.IGNORECASE)
|
||||
text = text.strip().strip("\"'`“”‘’")
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
text = text.rstrip("。.!!??,,;;:")
|
||||
if len(text) > TITLE_MAX_CHARS:
|
||||
text = text[: TITLE_MAX_CHARS - 1].rstrip() + "…"
|
||||
return text
|
||||
|
||||
|
||||
def _title_inputs(session: Session) -> tuple[str, str]:
|
||||
user_text = ""
|
||||
assistant_text = ""
|
||||
for message in session.messages:
|
||||
if message.get("_command") is True:
|
||||
continue
|
||||
role = message.get("role")
|
||||
content = message.get("content")
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
continue
|
||||
if role == "user" and not user_text:
|
||||
user_text = content.strip()
|
||||
elif role == "assistant" and not assistant_text:
|
||||
assistant_text = content.strip()
|
||||
if user_text and assistant_text:
|
||||
break
|
||||
return user_text, assistant_text
|
||||
|
||||
|
||||
async def maybe_generate_webui_title(
|
||||
*,
|
||||
sessions: SessionManager,
|
||||
session_key: str,
|
||||
provider: LLMProvider,
|
||||
model: str,
|
||||
) -> bool:
|
||||
"""Generate and persist a short title for WebUI-owned sessions only."""
|
||||
session = sessions.get_or_create(session_key)
|
||||
if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
|
||||
return False
|
||||
if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True:
|
||||
return False
|
||||
current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY)
|
||||
if isinstance(current_title, str) and current_title.strip():
|
||||
return False
|
||||
|
||||
user_text, assistant_text = _title_inputs(session)
|
||||
if not user_text:
|
||||
return False
|
||||
|
||||
prompt = (
|
||||
"Generate a concise title for this chat.\n"
|
||||
"Rules:\n"
|
||||
"- Use the same language as the user when practical.\n"
|
||||
"- 3 to 8 words.\n"
|
||||
"- No quotes.\n"
|
||||
"- No punctuation at the end.\n"
|
||||
"- Return only the title.\n\n"
|
||||
f"User: {truncate_text(user_text, 1_000)}"
|
||||
)
|
||||
if assistant_text:
|
||||
prompt += f"\nAssistant: {truncate_text(assistant_text, 1_000)}"
|
||||
|
||||
try:
|
||||
response = await provider.chat_with_retry(
|
||||
[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You write short, neutral chat titles. "
|
||||
"Return only the title text."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
tools=None,
|
||||
model=model,
|
||||
max_tokens=TITLE_GENERATION_MAX_TOKENS,
|
||||
temperature=0.2,
|
||||
reasoning_effort=TITLE_GENERATION_REASONING_EFFORT,
|
||||
retry_mode="standard",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to generate webui session title for {}", session_key, exc_info=True)
|
||||
return False
|
||||
|
||||
title = clean_generated_title(response.content)
|
||||
if not title or title.lower().startswith("error"):
|
||||
logger.debug(
|
||||
"WebUI title generation returned no usable title for {} (finish_reason={})",
|
||||
session_key,
|
||||
response.finish_reason,
|
||||
)
|
||||
return False
|
||||
session.metadata[WEBUI_TITLE_METADATA_KEY] = title
|
||||
sessions.save(session)
|
||||
return True
|
||||
|
||||
|
||||
async def maybe_generate_webui_title_after_turn(
|
||||
*,
|
||||
channel: str,
|
||||
metadata: dict[str, Any],
|
||||
sessions: SessionManager,
|
||||
session_key: str,
|
||||
provider: LLMProvider,
|
||||
model: str,
|
||||
) -> bool:
|
||||
if channel != "websocket" or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
|
||||
return False
|
||||
return await maybe_generate_webui_title(
|
||||
sessions=sessions,
|
||||
session_key=session_key,
|
||||
provider=provider,
|
||||
model=model,
|
||||
)
|
||||
|
||||
|
||||
def websocket_turn_wall_started_at(chat_id: str) -> float | None:
|
||||
"""Return ``time.time()`` when the active user turn began, if still running."""
|
||||
return _WEBSOCKET_TURN_WALL_STARTED_AT.get(chat_id)
|
||||
@@ -46,3 +192,156 @@ async def publish_turn_run_status(bus: MessageBus, msg: InboundMessage, status:
|
||||
metadata=meta,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_bus_progress_callback(
|
||||
bus: MessageBus,
|
||||
msg: InboundMessage,
|
||||
) -> Callable[..., Awaitable[None]]:
|
||||
"""Return the bus progress callback for agent runtime events."""
|
||||
|
||||
async def _publish_progress(
|
||||
content: str,
|
||||
*,
|
||||
tool_hint: bool = False,
|
||||
tool_events: list[dict[str, Any]] | None = None,
|
||||
file_edit_events: list[dict[str, Any]] | None = None,
|
||||
reasoning: bool = False,
|
||||
reasoning_end: bool = False,
|
||||
) -> None:
|
||||
meta = dict(msg.metadata or {})
|
||||
meta["_progress"] = True
|
||||
meta["_tool_hint"] = tool_hint
|
||||
if reasoning:
|
||||
meta["_reasoning_delta"] = True
|
||||
if reasoning_end:
|
||||
meta["_reasoning_end"] = True
|
||||
if tool_events:
|
||||
meta["_tool_events"] = tool_events
|
||||
if file_edit_events:
|
||||
meta["_file_edit_events"] = file_edit_events
|
||||
await bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=content,
|
||||
metadata=meta,
|
||||
)
|
||||
)
|
||||
|
||||
if msg.channel == "websocket":
|
||||
async def _websocket_progress(
|
||||
content: str,
|
||||
*,
|
||||
tool_hint: bool = False,
|
||||
tool_events: list[dict[str, Any]] | None = None,
|
||||
file_edit_events: list[dict[str, Any]] | None = None,
|
||||
reasoning: bool = False,
|
||||
reasoning_end: bool = False,
|
||||
) -> None:
|
||||
await _publish_progress(
|
||||
content,
|
||||
tool_hint=tool_hint,
|
||||
tool_events=tool_events,
|
||||
file_edit_events=file_edit_events,
|
||||
reasoning=reasoning,
|
||||
reasoning_end=reasoning_end,
|
||||
)
|
||||
|
||||
return _websocket_progress
|
||||
|
||||
async def _bus_progress(
|
||||
content: str,
|
||||
*,
|
||||
tool_hint: bool = False,
|
||||
tool_events: list[dict[str, Any]] | None = None,
|
||||
reasoning: bool = False,
|
||||
reasoning_end: bool = False,
|
||||
) -> None:
|
||||
await _publish_progress(
|
||||
content,
|
||||
tool_hint=tool_hint,
|
||||
tool_events=tool_events,
|
||||
reasoning=reasoning,
|
||||
reasoning_end=reasoning_end,
|
||||
)
|
||||
|
||||
return _bus_progress
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebuiTurnCoordinator:
|
||||
"""Own the WebUI/WebSocket wire details that hang off AgentLoop turns."""
|
||||
|
||||
bus: MessageBus
|
||||
sessions: SessionManager
|
||||
schedule_background: Callable[[Awaitable[None]], None]
|
||||
_title_contexts: dict[str, LLMRuntime] = field(default_factory=dict)
|
||||
|
||||
def capture_title_context(
|
||||
self,
|
||||
session_key: str,
|
||||
msg: InboundMessage,
|
||||
llm: LLMRuntime,
|
||||
) -> None:
|
||||
if msg.channel == "websocket" and msg.metadata.get("webui") is True:
|
||||
self._title_contexts[session_key] = llm
|
||||
|
||||
def discard(self, session_key: str) -> None:
|
||||
self._title_contexts.pop(session_key, None)
|
||||
|
||||
async def publish_run_status(self, msg: InboundMessage, status: str) -> None:
|
||||
await publish_turn_run_status(self.bus, msg, status)
|
||||
|
||||
async def handle_turn_end(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
*,
|
||||
session_key: str,
|
||||
latency_ms: int | None,
|
||||
) -> None:
|
||||
if msg.channel != "websocket":
|
||||
return
|
||||
|
||||
turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True}
|
||||
if latency_ms is not None:
|
||||
turn_metadata["latency_ms"] = int(latency_ms)
|
||||
session = self.sessions.get_or_create(session_key)
|
||||
turn_metadata["goal_state"] = goal_state_ws_blob(session.metadata)
|
||||
await self.bus.publish_outbound(OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content="",
|
||||
metadata=turn_metadata,
|
||||
))
|
||||
self._schedule_title_update(msg, session_key=session_key)
|
||||
|
||||
def _schedule_title_update(self, msg: InboundMessage, *, session_key: str) -> None:
|
||||
title_context = self._title_contexts.pop(session_key, None)
|
||||
if msg.metadata.get("webui") is not True or title_context is None:
|
||||
return
|
||||
|
||||
async def _generate_title_and_notify(
|
||||
title_llm: LLMRuntime = title_context,
|
||||
) -> None:
|
||||
generated = await maybe_generate_webui_title_after_turn(
|
||||
channel=msg.channel,
|
||||
metadata=msg.metadata,
|
||||
sessions=self.sessions,
|
||||
session_key=session_key,
|
||||
provider=title_llm.provider,
|
||||
model=title_llm.model,
|
||||
)
|
||||
if generated:
|
||||
await self.bus.publish_outbound(OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content="",
|
||||
metadata={
|
||||
**msg.metadata,
|
||||
"_session_updated": True,
|
||||
"_session_update_scope": "metadata",
|
||||
},
|
||||
))
|
||||
|
||||
self.schedule_background(_generate_title_and_notify())
|
||||
|
||||
@@ -4,6 +4,7 @@ import pytest
|
||||
|
||||
from nanobot.heartbeat.service import HeartbeatService
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
|
||||
|
||||
class DummyProvider(LLMProvider):
|
||||
@@ -11,9 +12,11 @@ class DummyProvider(LLMProvider):
|
||||
super().__init__()
|
||||
self._responses = list(responses)
|
||||
self.calls = 0
|
||||
self.models: list[str | None] = []
|
||||
|
||||
async def chat(self, *args, **kwargs) -> LLMResponse:
|
||||
self.calls += 1
|
||||
self.models.append(kwargs.get("model"))
|
||||
if self._responses:
|
||||
return self._responses.pop(0)
|
||||
return LLMResponse(content="", tool_calls=[])
|
||||
@@ -215,6 +218,51 @@ async def test_tick_suppresses_when_evaluator_says_no(tmp_path, monkeypatch) ->
|
||||
assert notified == []
|
||||
|
||||
|
||||
def test_tick_uses_runtime_provider_and_model(tmp_path, monkeypatch) -> None:
|
||||
"""Preset changes must apply to heartbeat decision and post-run evaluation."""
|
||||
(tmp_path / "HEARTBEAT.md").write_text("- [ ] check runtime model", encoding="utf-8")
|
||||
|
||||
runtime_provider = DummyProvider([
|
||||
LLMResponse(
|
||||
content="",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="hb_1",
|
||||
name="heartbeat",
|
||||
arguments={"action": "run", "tasks": "check runtime model"},
|
||||
)
|
||||
],
|
||||
),
|
||||
])
|
||||
runtime_model = "openai/gpt-4.1"
|
||||
|
||||
executed: list[str] = []
|
||||
evaluated: list[tuple[LLMProvider, str]] = []
|
||||
|
||||
async def _on_execute(tasks: str) -> str:
|
||||
executed.append(tasks)
|
||||
return "runtime model produced a user-facing update"
|
||||
|
||||
async def _eval_capture(response, tasks, provider, model):
|
||||
evaluated.append((provider, model))
|
||||
return False
|
||||
|
||||
service = HeartbeatService(
|
||||
workspace=tmp_path,
|
||||
llm_runtime=lambda: LLMRuntime(runtime_provider, runtime_model),
|
||||
on_execute=_on_execute,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("nanobot.utils.evaluator.evaluate_response", _eval_capture)
|
||||
|
||||
asyncio.run(service._tick())
|
||||
|
||||
assert runtime_provider.calls == 1
|
||||
assert runtime_provider.models == [runtime_model]
|
||||
assert executed == ["check runtime model"]
|
||||
assert evaluated == [(runtime_provider, runtime_model)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decide_retries_transient_error_then_succeeds(tmp_path, monkeypatch) -> None:
|
||||
provider = DummyProvider([
|
||||
@@ -286,4 +334,3 @@ async def test_decide_prompt_includes_current_time(tmp_path) -> None:
|
||||
user_msg = captured_messages[1]
|
||||
assert user_msg["role"] == "user"
|
||||
assert "Current Time:" in user_msg["content"]
|
||||
|
||||
|
||||
@@ -6,10 +6,15 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import nanobot.agent.runner as runner_module
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
from nanobot.utils.progress_events import (
|
||||
invoke_file_edit_progress,
|
||||
on_progress_accepts_file_edit_events,
|
||||
)
|
||||
|
||||
|
||||
def _make_loop(tmp_path: Path) -> AgentLoop:
|
||||
@@ -82,6 +87,142 @@ class TestToolEventProgress:
|
||||
),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_file_emits_file_edit_progress(self, tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
target = tmp_path / "foo.txt"
|
||||
target.write_text("old\n", encoding="utf-8")
|
||||
tool_call = ToolCallRequest(
|
||||
id="call-write",
|
||||
name="write_file",
|
||||
arguments={"path": "foo.txt", "content": "new\nextra\n"},
|
||||
)
|
||||
calls = iter([
|
||||
LLMResponse(content="", tool_calls=[tool_call]),
|
||||
LLMResponse(content="Done", tool_calls=[]),
|
||||
])
|
||||
loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.tools.prepare_call = MagicMock(
|
||||
return_value=(None, {"path": "foo.txt", "content": "new\nextra\n"}, None),
|
||||
)
|
||||
|
||||
async def execute(name: str, params: dict) -> str:
|
||||
target.write_text(params["content"], encoding="utf-8")
|
||||
return "ok"
|
||||
|
||||
loop.tools.execute = AsyncMock(side_effect=execute)
|
||||
file_events: list[dict] = []
|
||||
|
||||
async def on_progress(
|
||||
content: str,
|
||||
*,
|
||||
tool_hint: bool = False,
|
||||
tool_events: list[dict] | None = None,
|
||||
file_edit_events: list[dict] | None = None,
|
||||
) -> None:
|
||||
if file_edit_events:
|
||||
file_events.extend(file_edit_events)
|
||||
|
||||
final_content, _, _, _, _ = await loop._run_agent_loop([], on_progress=on_progress)
|
||||
|
||||
assert final_content == "Done"
|
||||
assert [event["phase"] for event in file_events] == ["start", "end"]
|
||||
assert file_events[0] == {
|
||||
"version": 1,
|
||||
"call_id": "call-write",
|
||||
"tool": "write_file",
|
||||
"path": "foo.txt",
|
||||
"phase": "start",
|
||||
"added": 2,
|
||||
"deleted": 1,
|
||||
"approximate": True,
|
||||
"status": "editing",
|
||||
}
|
||||
assert file_events[1]["status"] == "done"
|
||||
assert file_events[1]["approximate"] is False
|
||||
assert (file_events[1]["added"], file_events[1]["deleted"]) == (2, 1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_edit_snapshot_skipped_when_progress_callback_cannot_emit_file_edits(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
target = tmp_path / "foo.txt"
|
||||
target.write_text("old\n", encoding="utf-8")
|
||||
tool_call = ToolCallRequest(
|
||||
id="call-write",
|
||||
name="write_file",
|
||||
arguments={"path": "foo.txt", "content": "new\n"},
|
||||
)
|
||||
calls = iter([
|
||||
LLMResponse(content="", tool_calls=[tool_call]),
|
||||
LLMResponse(content="Done", tool_calls=[]),
|
||||
])
|
||||
loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.tools.prepare_call = MagicMock(
|
||||
return_value=(None, {"path": "foo.txt", "content": "new\n"}, None),
|
||||
)
|
||||
|
||||
async def execute(name: str, params: dict) -> str:
|
||||
target.write_text(params["content"], encoding="utf-8")
|
||||
return "ok"
|
||||
|
||||
loop.tools.execute = AsyncMock(side_effect=execute)
|
||||
prepare_tracker = MagicMock(side_effect=AssertionError("unexpected file snapshot"))
|
||||
monkeypatch.setattr(runner_module, "prepare_file_edit_tracker", prepare_tracker)
|
||||
|
||||
async def on_progress(
|
||||
content: str,
|
||||
*,
|
||||
tool_hint: bool = False,
|
||||
tool_events: list[dict] | None = None,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
final_content, _, _, _, _ = await loop._run_agent_loop([], on_progress=on_progress)
|
||||
|
||||
assert final_content == "Done"
|
||||
assert target.read_text(encoding="utf-8") == "new\n"
|
||||
prepare_tracker.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_does_not_emit_file_edit_progress(self, tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
tool_call = ToolCallRequest(
|
||||
id="call-exec",
|
||||
name="exec",
|
||||
arguments={"command": "printf hi > foo.txt"},
|
||||
)
|
||||
calls = iter([
|
||||
LLMResponse(content="", tool_calls=[tool_call]),
|
||||
LLMResponse(content="Done", tool_calls=[]),
|
||||
])
|
||||
loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.tools.prepare_call = MagicMock(
|
||||
return_value=(None, {"command": "printf hi > foo.txt"}, None),
|
||||
)
|
||||
loop.tools.execute = AsyncMock(return_value="ok")
|
||||
file_events: list[dict] = []
|
||||
|
||||
async def on_progress(
|
||||
content: str,
|
||||
*,
|
||||
tool_hint: bool = False,
|
||||
tool_events: list[dict] | None = None,
|
||||
file_edit_events: list[dict] | None = None,
|
||||
) -> None:
|
||||
if file_edit_events:
|
||||
file_events.extend(file_edit_events)
|
||||
|
||||
await loop._run_agent_loop([], on_progress=on_progress)
|
||||
|
||||
assert file_events == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bus_progress_forwards_tool_events_to_outbound_metadata(self, tmp_path: Path) -> None:
|
||||
"""When run() handles a bus message, _tool_events lands in OutboundMessage metadata."""
|
||||
@@ -130,6 +271,44 @@ class TestToolEventProgress:
|
||||
assert finish["phase"] == "end"
|
||||
assert finish["result"] == "file.txt"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bus_progress_forwards_file_edit_events_for_websocket_only(self, tmp_path: Path) -> None:
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||
edit_events = [{
|
||||
"call_id": "call-write",
|
||||
"tool": "write_file",
|
||||
"path": "foo.txt",
|
||||
"phase": "start",
|
||||
"added": 1,
|
||||
"deleted": 0,
|
||||
"approximate": True,
|
||||
"status": "editing",
|
||||
}]
|
||||
|
||||
websocket_progress = await loop._build_bus_progress_callback(InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u1",
|
||||
chat_id="chat1",
|
||||
content="edit",
|
||||
))
|
||||
assert on_progress_accepts_file_edit_events(websocket_progress) is True
|
||||
await websocket_progress("", file_edit_events=edit_events)
|
||||
outbound = await bus.consume_outbound()
|
||||
assert outbound.metadata["_file_edit_events"] == edit_events
|
||||
|
||||
telegram_progress = await loop._build_bus_progress_callback(InboundMessage(
|
||||
channel="telegram",
|
||||
sender_id="u1",
|
||||
chat_id="chat2",
|
||||
content="edit",
|
||||
))
|
||||
assert on_progress_accepts_file_edit_events(telegram_progress) is False
|
||||
await invoke_file_edit_progress(telegram_progress, edit_events)
|
||||
assert bus.outbound_size == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_streaming_channel_does_not_publish_codex_progress_deltas(
|
||||
self,
|
||||
@@ -353,8 +532,93 @@ class TestToolEventProgress:
|
||||
assert session_updated is not None
|
||||
|
||||
assert (session_updated.metadata or {}).get("_session_updated") is True
|
||||
assert (session_updated.metadata or {}).get("_session_update_scope") == "metadata"
|
||||
assert provider.chat_with_retry.await_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_title_generation_uses_turn_model_snapshot(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[]))
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_title_after_turn(**kwargs: object) -> bool:
|
||||
captured.update(kwargs)
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn",
|
||||
fake_title_after_turn,
|
||||
)
|
||||
scheduled_title: list[object] = []
|
||||
|
||||
def schedule_background(coro: object) -> None:
|
||||
name = getattr(coro, "__qualname__", "")
|
||||
if "_generate_title_and_notify" in name:
|
||||
scheduled_title.append(coro)
|
||||
elif hasattr(coro, "close"):
|
||||
coro.close()
|
||||
|
||||
loop._schedule_background = schedule_background # type: ignore[method-assign]
|
||||
|
||||
await loop._dispatch(InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u1",
|
||||
chat_id="chat1",
|
||||
content="say hello",
|
||||
metadata={"webui": True},
|
||||
))
|
||||
|
||||
assert len(scheduled_title) == 1
|
||||
loop.provider = MagicMock()
|
||||
loop.model = "switched-after-turn"
|
||||
|
||||
await scheduled_title[0] # type: ignore[misc]
|
||||
|
||||
assert captured["provider"] is provider
|
||||
assert captured["model"] == "test-model"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_command_turn_does_not_schedule_title_generation(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[]))
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||
|
||||
async def fake_title_after_turn(**_kwargs: object) -> bool:
|
||||
raise AssertionError("command-only turns should not generate titles")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn",
|
||||
fake_title_after_turn,
|
||||
)
|
||||
scheduled: list[object] = []
|
||||
loop._schedule_background = scheduled.append # type: ignore[method-assign]
|
||||
|
||||
await loop._dispatch(InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u1",
|
||||
chat_id="chat1",
|
||||
content="/model",
|
||||
metadata={"webui": True},
|
||||
))
|
||||
|
||||
assert scheduled == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_websocket_dispatch_does_not_publish_turn_end_marker(self, tmp_path: Path) -> None:
|
||||
bus = MessageBus()
|
||||
|
||||
@@ -10,12 +10,16 @@ from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse
|
||||
from nanobot.session.goal_state import GOAL_STATE_KEY
|
||||
from nanobot.session.manager import Session
|
||||
from nanobot.utils.webui_titles import (
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.utils.webui_turn_helpers import (
|
||||
TITLE_GENERATION_MAX_TOKENS,
|
||||
TITLE_GENERATION_REASONING_EFFORT,
|
||||
WEBUI_SESSION_METADATA_KEY,
|
||||
WEBUI_TITLE_METADATA_KEY,
|
||||
WebuiTurnCoordinator,
|
||||
maybe_generate_webui_title,
|
||||
)
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
|
||||
|
||||
def _mk_loop() -> AgentLoop:
|
||||
@@ -33,6 +37,22 @@ def _make_full_loop(tmp_path: Path) -> AgentLoop:
|
||||
return AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
||||
|
||||
|
||||
def test_agent_loop_llm_runtime_reflects_current_provider_and_model(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
runtime = loop.llm_runtime()
|
||||
|
||||
assert runtime.provider is loop.provider
|
||||
assert runtime.model == "test-model"
|
||||
|
||||
next_provider = MagicMock()
|
||||
loop.provider = next_provider
|
||||
loop.model = "next-model"
|
||||
runtime = loop.llm_runtime()
|
||||
|
||||
assert runtime.provider is next_provider
|
||||
assert runtime.model == "next-model"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
@@ -55,6 +75,11 @@ async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Pat
|
||||
assert generated is True
|
||||
assert session.metadata[WEBUI_TITLE_METADATA_KEY] == "优化 WebUI 侧边栏"
|
||||
loop.provider.chat_with_retry.assert_awaited_once()
|
||||
assert loop.provider.chat_with_retry.await_args.kwargs["max_tokens"] == TITLE_GENERATION_MAX_TOKENS
|
||||
assert (
|
||||
loop.provider.chat_with_retry.await_args.kwargs["reasoning_effort"]
|
||||
== TITLE_GENERATION_REASONING_EFFORT
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -79,6 +104,80 @@ async def test_generate_webui_title_skips_plain_websocket_sessions(tmp_path: Pat
|
||||
loop.provider.chat_with_retry.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_webui_title_ignores_command_only_sessions(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
session = loop.sessions.get_or_create("websocket:command-title")
|
||||
session.metadata[WEBUI_SESSION_METADATA_KEY] = True
|
||||
session.add_message("user", "/model deep", _command=True)
|
||||
session.add_message(
|
||||
"assistant",
|
||||
"Switched model preset to `deep`.\n- Model: `deepseek-v4-pro`",
|
||||
_command=True,
|
||||
)
|
||||
loop.sessions.save(session)
|
||||
|
||||
generated = await maybe_generate_webui_title(
|
||||
sessions=loop.sessions,
|
||||
session_key="websocket:command-title",
|
||||
provider=loop.provider,
|
||||
model=loop.model,
|
||||
)
|
||||
|
||||
assert generated is False
|
||||
assert WEBUI_TITLE_METADATA_KEY not in session.metadata
|
||||
loop.provider.chat_with_retry.assert_not_awaited()
|
||||
|
||||
|
||||
def test_webui_title_update_uses_captured_llm_runtime(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
bus = MessageBus()
|
||||
sessions = SessionManager(tmp_path)
|
||||
scheduled: list[object] = []
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_title_after_turn(**kwargs: object) -> bool:
|
||||
captured.update(kwargs)
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn",
|
||||
fake_title_after_turn,
|
||||
)
|
||||
coordinator = WebuiTurnCoordinator(
|
||||
bus=bus,
|
||||
sessions=sessions,
|
||||
schedule_background=lambda coro: scheduled.append(coro),
|
||||
)
|
||||
provider = MagicMock()
|
||||
msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u1",
|
||||
chat_id="chat1",
|
||||
content="say hello",
|
||||
metadata={"webui": True},
|
||||
)
|
||||
|
||||
coordinator.capture_title_context(
|
||||
"websocket:chat1",
|
||||
msg,
|
||||
LLMRuntime(provider, "turn-model"),
|
||||
)
|
||||
asyncio.run(coordinator.handle_turn_end(
|
||||
msg,
|
||||
session_key="websocket:chat1",
|
||||
latency_ms=None,
|
||||
))
|
||||
|
||||
assert len(scheduled) == 1
|
||||
asyncio.run(scheduled[0]) # type: ignore[arg-type]
|
||||
|
||||
assert captured["provider"] is provider
|
||||
assert captured["model"] == "turn-model"
|
||||
|
||||
|
||||
def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None:
|
||||
loop = _mk_loop()
|
||||
session = Session(key="test:runtime-only")
|
||||
|
||||
@@ -47,3 +47,28 @@ def test_provider_refresh_updates_all_model_dependents(tmp_path: Path) -> None:
|
||||
assert loop.dream.provider is new_provider
|
||||
assert loop.dream.model == "new-model"
|
||||
assert loop.dream._runner.provider is new_provider
|
||||
|
||||
|
||||
def test_llm_runtime_refreshes_provider_snapshot(tmp_path: Path) -> None:
|
||||
old_provider = _provider("old-model")
|
||||
new_provider = _provider("new-model", max_tokens=456)
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=old_provider,
|
||||
workspace=tmp_path,
|
||||
model="old-model",
|
||||
context_window_tokens=1000,
|
||||
provider_snapshot_loader=lambda: ProviderSnapshot(
|
||||
provider=new_provider,
|
||||
model="new-model",
|
||||
context_window_tokens=2000,
|
||||
signature=("new-model",),
|
||||
),
|
||||
)
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
|
||||
assert runtime.provider is new_provider
|
||||
assert runtime.model == "new-model"
|
||||
assert loop.provider is new_provider
|
||||
assert loop.runner.provider is new_provider
|
||||
|
||||
@@ -370,6 +370,55 @@ async def test_send_progress_includes_structured_tool_events() -> None:
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_file_edit_progress_uses_file_edit_event() -> None:
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
content="",
|
||||
metadata={
|
||||
"_progress": True,
|
||||
"_file_edit_events": [
|
||||
{
|
||||
"version": 1,
|
||||
"phase": "start",
|
||||
"call_id": "call-1",
|
||||
"tool": "write_file",
|
||||
"path": "src/app.py",
|
||||
"added": 12,
|
||||
"deleted": 2,
|
||||
"approximate": True,
|
||||
"status": "editing",
|
||||
}
|
||||
],
|
||||
},
|
||||
))
|
||||
|
||||
payload = json.loads(mock_ws.send.await_args.args[0])
|
||||
assert payload == {
|
||||
"event": "file_edit",
|
||||
"chat_id": "chat-1",
|
||||
"edits": [
|
||||
{
|
||||
"version": 1,
|
||||
"phase": "start",
|
||||
"call_id": "call-1",
|
||||
"tool": "write_file",
|
||||
"path": "src/app.py",
|
||||
"added": 12,
|
||||
"deleted": 2,
|
||||
"approximate": True,
|
||||
"status": "editing",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_progress_includes_agent_ui_blob() -> None:
|
||||
bus = MagicMock()
|
||||
@@ -758,6 +807,25 @@ async def test_send_session_updated_emits_session_updated_event() -> None:
|
||||
assert body == {"event": "session_updated", "chat_id": "chat-1"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_session_updated_includes_scope_when_present() -> None:
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
content="",
|
||||
metadata={"_session_updated": True, "_session_update_scope": "metadata"},
|
||||
))
|
||||
|
||||
mock_ws.send.assert_awaited_once()
|
||||
body = json.loads(mock_ws.send.await_args.args[0])
|
||||
assert body == {"event": "session_updated", "chat_id": "chat-1", "scope": "metadata"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_non_connection_closed_exception_is_raised() -> None:
|
||||
bus = MagicMock()
|
||||
@@ -946,7 +1014,12 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
providers = {provider["name"]: provider for provider in body["providers"]}
|
||||
assert providers["openai"]["configured"] is True
|
||||
assert providers["openai"]["api_key_hint"] == "secr••••-key"
|
||||
assert providers["azure_openai"]["api_key_required"] is True
|
||||
assert providers["openrouter"]["configured"] is False
|
||||
assert providers["openrouter"]["api_key_required"] is True
|
||||
assert providers["atomic_chat"]["configured"] is False
|
||||
assert providers["atomic_chat"]["api_key_required"] is False
|
||||
assert providers["atomic_chat"]["default_api_base"] == "http://localhost:1337/v1"
|
||||
assert body["agent"]["has_api_key"] is True
|
||||
assert body["web_search"]["provider"] == "brave"
|
||||
assert body["web_search"]["api_key_hint"] == "brav••••cret"
|
||||
@@ -969,10 +1042,24 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert provider_rows["openrouter"]["configured"] is True
|
||||
assert "sk-or-test" not in provider_updated.text
|
||||
|
||||
local_provider_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/provider/update?provider=atomic_chat"
|
||||
"&api_base=http%3A%2F%2Flocalhost%3A1337%2Fv1",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert local_provider_updated.status_code == 200
|
||||
local_provider_body = local_provider_updated.json()
|
||||
local_provider_rows = {
|
||||
provider["name"]: provider for provider in local_provider_body["providers"]
|
||||
}
|
||||
assert local_provider_rows["atomic_chat"]["configured"] is True
|
||||
assert "localhost:1337" in local_provider_updated.text
|
||||
|
||||
updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/update?model=openrouter/test"
|
||||
"&provider=openrouter",
|
||||
f"{port}/api/settings/update?model=atomic_chat/test"
|
||||
"&provider=atomic_chat",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
@@ -992,10 +1079,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert search_body["web_search"]["base_url"] == "https://search.example.com"
|
||||
|
||||
saved = load_config(config_path)
|
||||
assert saved.agents.defaults.model == "openrouter/test"
|
||||
assert saved.agents.defaults.provider == "openrouter"
|
||||
assert saved.agents.defaults.model == "atomic_chat/test"
|
||||
assert saved.agents.defaults.provider == "atomic_chat"
|
||||
assert saved.providers.openrouter.api_key == "sk-or-test"
|
||||
assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1"
|
||||
assert saved.providers.atomic_chat.api_base == "http://localhost:1337/v1"
|
||||
assert saved.tools.web.search.provider == "searxng"
|
||||
assert saved.tools.web.search.api_key == ""
|
||||
assert saved.tools.web.search.base_url == "https://search.example.com"
|
||||
|
||||
@@ -1170,6 +1170,7 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
||||
self.model = "test-model"
|
||||
self.provider = kwargs.get("provider", object())
|
||||
self.tools = {}
|
||||
seen["agent"] = self
|
||||
|
||||
async def process_direct(self, *_args, **_kwargs):
|
||||
return OutboundMessage(
|
||||
@@ -1218,6 +1219,11 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
||||
assert isinstance(cron, _FakeCron)
|
||||
assert cron.on_job is not None
|
||||
|
||||
runtime_provider = object()
|
||||
agent = seen["agent"]
|
||||
agent.provider = runtime_provider
|
||||
agent.model = "runtime-model"
|
||||
|
||||
job = CronJob(
|
||||
id="cron-1",
|
||||
name="stretch",
|
||||
@@ -1233,8 +1239,8 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
||||
|
||||
assert response == "Time to stretch."
|
||||
assert seen["response"] == "Time to stretch."
|
||||
assert seen["provider"] is provider
|
||||
assert seen["model"] == "test-model"
|
||||
assert seen["provider"] is runtime_provider
|
||||
assert seen["model"] == "runtime-model"
|
||||
assert seen["task_context"] == (
|
||||
"The scheduled time has arrived. Deliver this reminder to the user now, "
|
||||
"as a brief and natural message in their language. Speak directly to them — "
|
||||
@@ -1543,6 +1549,9 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
||||
self.dream = _FakeDream()
|
||||
self.sessions = _FakeSessionManager()
|
||||
|
||||
def llm_runtime(self) -> None:
|
||||
return None
|
||||
|
||||
async def run(self) -> None:
|
||||
await asyncio.Event().wait()
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.utils.file_edit_events import (
|
||||
build_file_edit_end_event,
|
||||
build_file_edit_start_event,
|
||||
line_diff_stats,
|
||||
prepare_file_edit_tracker,
|
||||
read_file_snapshot,
|
||||
)
|
||||
|
||||
|
||||
def test_line_diff_stats_counts_replacements_insertions_and_deletions() -> None:
|
||||
added, deleted = line_diff_stats("a\nb\nc\n", "a\nB\nc\nd\n")
|
||||
assert (added, deleted) == (2, 1)
|
||||
|
||||
|
||||
def test_line_diff_stats_normalizes_crlf() -> None:
|
||||
assert line_diff_stats("a\r\nb\r\n", "a\nb\nc\n") == (1, 0)
|
||||
|
||||
|
||||
def test_write_file_start_predicts_and_end_calibrates_exact_diff(tmp_path: Path) -> None:
|
||||
target = tmp_path / "notes.txt"
|
||||
target.write_text("old\nkeep\n", encoding="utf-8")
|
||||
params = {"path": "notes.txt", "content": "new\nkeep\nextra\n"}
|
||||
tracker = prepare_file_edit_tracker(
|
||||
call_id="call-write",
|
||||
tool_name="write_file",
|
||||
tool=None,
|
||||
workspace=tmp_path,
|
||||
params=params,
|
||||
)
|
||||
|
||||
assert tracker is not None
|
||||
start = build_file_edit_start_event(tracker, params)
|
||||
assert start == {
|
||||
"version": 1,
|
||||
"call_id": "call-write",
|
||||
"tool": "write_file",
|
||||
"path": "notes.txt",
|
||||
"phase": "start",
|
||||
"added": 2,
|
||||
"deleted": 1,
|
||||
"approximate": True,
|
||||
"status": "editing",
|
||||
}
|
||||
|
||||
target.write_text("new\nkeep\nextra\n", encoding="utf-8")
|
||||
end = build_file_edit_end_event(tracker)
|
||||
assert end["phase"] == "end"
|
||||
assert end["status"] == "done"
|
||||
assert end["approximate"] is False
|
||||
assert (end["added"], end["deleted"]) == (2, 1)
|
||||
|
||||
|
||||
def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
|
||||
target = tmp_path / "data.bin"
|
||||
target.write_bytes(b"\x00\x01before")
|
||||
tracker = prepare_file_edit_tracker(
|
||||
call_id="call-bin",
|
||||
tool_name="edit_file",
|
||||
tool=None,
|
||||
workspace=tmp_path,
|
||||
params={"path": "data.bin", "old_text": "before", "new_text": "after"},
|
||||
)
|
||||
|
||||
assert tracker is not None
|
||||
assert not read_file_snapshot(target).countable
|
||||
target.write_bytes(b"\x00\x01after")
|
||||
event = build_file_edit_end_event(tracker)
|
||||
assert event["binary"] is True
|
||||
assert (event["added"], event["deleted"]) == (0, 0)
|
||||
|
||||
|
||||
def test_untracked_tools_do_not_prepare_file_edit_tracker(tmp_path: Path) -> None:
|
||||
assert prepare_file_edit_tracker(
|
||||
call_id="call-exec",
|
||||
tool_name="exec",
|
||||
tool=None,
|
||||
workspace=tmp_path,
|
||||
params={"path": "created-by-shell.txt"},
|
||||
) is None
|
||||
@@ -42,6 +42,62 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
|
||||
assert msgs[1]["latencyMs"] == 42
|
||||
|
||||
|
||||
def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
key = "websocket:t-file"
|
||||
for ev in (
|
||||
{"event": "user", "chat_id": "t-file", "text": "edit"},
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "t-file",
|
||||
"text": 'write_file({"path":"foo.txt"})',
|
||||
"kind": "tool_hint",
|
||||
},
|
||||
{
|
||||
"event": "file_edit",
|
||||
"chat_id": "t-file",
|
||||
"edits": [
|
||||
{
|
||||
"version": 1,
|
||||
"call_id": "call-write",
|
||||
"tool": "write_file",
|
||||
"path": "foo.txt",
|
||||
"phase": "end",
|
||||
"added": 2,
|
||||
"deleted": 1,
|
||||
"approximate": False,
|
||||
"status": "done",
|
||||
},
|
||||
],
|
||||
},
|
||||
):
|
||||
append_transcript_object(key, ev)
|
||||
|
||||
msgs = replay_transcript_to_ui_messages(read_transcript_lines(key))
|
||||
|
||||
assert len(msgs) == 3
|
||||
assert msgs[1]["kind"] == "trace"
|
||||
assert msgs[1]["traces"] == ['write_file({"path":"foo.txt"})']
|
||||
assert "fileEdits" not in msgs[1]
|
||||
assert msgs[2]["kind"] == "trace"
|
||||
assert msgs[2]["traces"] == []
|
||||
assert msgs[2]["fileEdits"] == [
|
||||
{
|
||||
"version": 1,
|
||||
"call_id": "call-write",
|
||||
"tool": "write_file",
|
||||
"path": "foo.txt",
|
||||
"phase": "end",
|
||||
"added": 2,
|
||||
"deleted": 1,
|
||||
"approximate": False,
|
||||
"status": "done",
|
||||
},
|
||||
]
|
||||
assert msgs[2]["activitySegmentId"]
|
||||
assert msgs[2]["activitySegmentId"] != msgs[1]["activitySegmentId"]
|
||||
|
||||
|
||||
def test_build_response_schema(monkeypatch, tmp_path) -> None:
|
||||
from nanobot.utils.webui_transcript import build_webui_thread_response
|
||||
|
||||
|
||||
+157
-89
@@ -7,7 +7,8 @@ import { ThreadShell } from "@/components/thread/ThreadShell";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
|
||||
import { useSessions } from "@/hooks/useSessions";
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh";
|
||||
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
clearSavedSecret,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
loadSavedSecret,
|
||||
saveSecret,
|
||||
} from "@/lib/bootstrap";
|
||||
import { deriveTitle } from "@/lib/format";
|
||||
import { NanobotClient } from "@/lib/nanobot-client";
|
||||
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
@@ -30,14 +32,30 @@ type BootState =
|
||||
status: "ready";
|
||||
client: NanobotClient;
|
||||
token: string;
|
||||
tokenExpiresAt: number;
|
||||
modelName: string | null;
|
||||
};
|
||||
|
||||
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
|
||||
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
|
||||
const SIDEBAR_WIDTH = 272;
|
||||
const TOKEN_REFRESH_MARGIN_MS = 30_000;
|
||||
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
|
||||
type ShellView = "chat" | "settings";
|
||||
|
||||
function bootstrapTokenExpiresAt(expiresInSeconds: number): number {
|
||||
return Date.now() + Math.max(0, expiresInSeconds) * 1000;
|
||||
}
|
||||
|
||||
function tokenRefreshDelayMs(expiresAt: number): number {
|
||||
const remaining = Math.max(0, expiresAt - Date.now());
|
||||
const margin = Math.min(
|
||||
TOKEN_REFRESH_MARGIN_MS,
|
||||
Math.max(1_000, remaining / 2),
|
||||
);
|
||||
return Math.max(TOKEN_REFRESH_MIN_DELAY_MS, remaining - margin);
|
||||
}
|
||||
|
||||
function AuthForm({
|
||||
failed,
|
||||
onSecret,
|
||||
@@ -106,6 +124,7 @@ function readSidebarOpen(): boolean {
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<BootState>({ status: "loading" });
|
||||
const bootstrapSecretRef = useRef("");
|
||||
|
||||
const bootstrapWithSecret = useCallback(
|
||||
(secret: string) => {
|
||||
@@ -117,22 +136,37 @@ export default function App() {
|
||||
if (cancelled) return;
|
||||
if (secret) saveSecret(secret);
|
||||
const url = deriveWsUrl(boot.ws_path, boot.token);
|
||||
const client = new NanobotClient({
|
||||
let client: NanobotClient;
|
||||
client = new NanobotClient({
|
||||
url,
|
||||
onReauth: async () => {
|
||||
try {
|
||||
const refreshed = await fetchBootstrap("", secret);
|
||||
return deriveWsUrl(refreshed.ws_path, refreshed.token);
|
||||
const refreshed = await fetchBootstrap("", bootstrapSecretRef.current);
|
||||
const refreshedUrl = deriveWsUrl(refreshed.ws_path, refreshed.token);
|
||||
const tokenExpiresAt = bootstrapTokenExpiresAt(refreshed.expires_in);
|
||||
setState((current) =>
|
||||
current.status === "ready" && current.client === client
|
||||
? {
|
||||
...current,
|
||||
token: refreshed.token,
|
||||
tokenExpiresAt,
|
||||
modelName: refreshed.model_name ?? current.modelName,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
return refreshedUrl;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
bootstrapSecretRef.current = secret;
|
||||
client.connect();
|
||||
setState({
|
||||
status: "ready",
|
||||
client,
|
||||
token: boot.token,
|
||||
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
|
||||
modelName: boot.model_name ?? null,
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -152,6 +186,35 @@ export default function App() {
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status !== "ready") return;
|
||||
const client = state.client;
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const boot = await fetchBootstrap("", bootstrapSecretRef.current);
|
||||
const url = deriveWsUrl(boot.ws_path, boot.token);
|
||||
const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in);
|
||||
client.updateUrl(url);
|
||||
setState((current) =>
|
||||
current.status === "ready" && current.client === client
|
||||
? {
|
||||
...current,
|
||||
token: boot.token,
|
||||
tokenExpiresAt,
|
||||
modelName: boot.model_name ?? current.modelName,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) {
|
||||
setState({ status: "auth", failed: true });
|
||||
}
|
||||
}
|
||||
}, tokenRefreshDelayMs(state.tokenExpiresAt));
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = loadSavedSecret();
|
||||
return bootstrapWithSecret(saved);
|
||||
@@ -219,7 +282,13 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: string | null) => void; onLogout: () => void }) {
|
||||
function Shell({
|
||||
onModelNameChange,
|
||||
onLogout,
|
||||
}: {
|
||||
onModelNameChange: (modelName: string | null) => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { client } = useClient();
|
||||
const { theme, toggle } = useTheme();
|
||||
@@ -362,9 +431,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
||||
});
|
||||
}, [client, t]);
|
||||
|
||||
const onTurnEnd = useCallback(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh);
|
||||
|
||||
const onConfirmDelete = useCallback(async () => {
|
||||
if (!pendingDelete) return;
|
||||
@@ -386,8 +453,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
||||
|
||||
const headerTitle = activeSession
|
||||
? activeSession.title ||
|
||||
activeSession.preview ||
|
||||
t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) })
|
||||
deriveTitle(activeSession.preview, t("chat.newChat"))
|
||||
: t("app.brand");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -415,93 +481,95 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
||||
const showMainSidebar = view !== "settings";
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full overflow-hidden">
|
||||
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
|
||||
{showMainSidebar ? (
|
||||
<aside
|
||||
className={cn(
|
||||
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
|
||||
"transition-[width] duration-300 ease-out",
|
||||
)}
|
||||
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
|
||||
>
|
||||
<ThemeProvider theme={theme}>
|
||||
<div className="relative flex h-full w-full overflow-hidden">
|
||||
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
|
||||
{showMainSidebar ? (
|
||||
<aside
|
||||
className={cn(
|
||||
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
|
||||
"transition-[width] duration-300 ease-out",
|
||||
)}
|
||||
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
|
||||
"transition-transform duration-300 ease-out",
|
||||
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
|
||||
)}
|
||||
style={{ width: SIDEBAR_WIDTH }}
|
||||
>
|
||||
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
{showMainSidebar ? (
|
||||
<Sheet
|
||||
open={mobileSidebarOpen}
|
||||
onOpenChange={(open) => setMobileSidebarOpen(open)}
|
||||
>
|
||||
<SheetContent
|
||||
side="left"
|
||||
showCloseButton={false}
|
||||
className="p-0 lg:hidden"
|
||||
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
|
||||
>
|
||||
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
) : null}
|
||||
|
||||
<main className="relative flex h-full min-w-0 flex-1 flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
|
||||
"transition-transform duration-300 ease-out",
|
||||
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
|
||||
"absolute inset-0 flex flex-col",
|
||||
view === "settings" && "invisible pointer-events-none",
|
||||
)}
|
||||
style={{ width: SIDEBAR_WIDTH }}
|
||||
>
|
||||
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
{showMainSidebar ? (
|
||||
<Sheet
|
||||
open={mobileSidebarOpen}
|
||||
onOpenChange={(open) => setMobileSidebarOpen(open)}
|
||||
>
|
||||
<SheetContent
|
||||
side="left"
|
||||
showCloseButton={false}
|
||||
className="p-0 lg:hidden"
|
||||
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
|
||||
>
|
||||
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
) : null}
|
||||
|
||||
<main className="relative flex h-full min-w-0 flex-1 flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex flex-col",
|
||||
view === "settings" && "invisible pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<ThreadShell
|
||||
session={activeSession}
|
||||
title={headerTitle}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onNewChat={onNewChat}
|
||||
onCreateChat={onCreateChat}
|
||||
onTurnEnd={onTurnEnd}
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
hideSidebarToggleOnDesktop={desktopSidebarOpen}
|
||||
/>
|
||||
</div>
|
||||
{view === "settings" && (
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<SettingsView
|
||||
<ThreadShell
|
||||
session={activeSession}
|
||||
title={headerTitle}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onNewChat={onNewChat}
|
||||
onCreateChat={onCreateChat}
|
||||
onTurnEnd={onTurnEnd}
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
onBackToChat={onBackToChat}
|
||||
onModelNameChange={onModelNameChange}
|
||||
onLogout={onLogout}
|
||||
onRestart={onRestart}
|
||||
isRestarting={isRestarting}
|
||||
hideSidebarToggleOnDesktop={desktopSidebarOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
{view === "settings" && (
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<SettingsView
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
onBackToChat={onBackToChat}
|
||||
onModelNameChange={onModelNameChange}
|
||||
onLogout={onLogout}
|
||||
onRestart={onRestart}
|
||||
isRestarting={isRestarting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<DeleteConfirm
|
||||
open={!!pendingDelete}
|
||||
title={pendingDelete?.label ?? ""}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={onConfirmDelete}
|
||||
/>
|
||||
{restartToast ? (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed left-1/2 top-4 z-50 -translate-x-1/2 rounded-full border border-border/70 bg-popover px-4 py-2 text-sm font-medium text-popover-foreground shadow-lg"
|
||||
>
|
||||
{restartToast}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DeleteConfirm
|
||||
open={!!pendingDelete}
|
||||
title={pendingDelete?.label ?? ""}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={onConfirmDelete}
|
||||
/>
|
||||
{restartToast ? (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed left-1/2 top-4 z-50 -translate-x-1/2 rounded-full border border-border/70 bg-popover px-4 py-2 text-sm font-medium text-popover-foreground shadow-lg"
|
||||
>
|
||||
{restartToast}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { deriveTitle } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
@@ -64,8 +65,11 @@ export function ChatList({
|
||||
const fallbackTitle = t("chat.fallbackTitle", {
|
||||
id: s.chatId.slice(0, 6),
|
||||
});
|
||||
const rawLabel = (s.title || s.preview)?.trim();
|
||||
const title = rawLabel || fallbackTitle;
|
||||
const generatedTitle = s.title?.trim() || "";
|
||||
const title =
|
||||
generatedTitle || deriveTitle(s.preview, t("chat.newChat"));
|
||||
const tooltipTitle =
|
||||
generatedTitle || deriveTitle(s.preview, fallbackTitle);
|
||||
return (
|
||||
<li key={s.key} className="min-w-0">
|
||||
<div
|
||||
@@ -79,7 +83,7 @@ export function ChatList({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(s.key)}
|
||||
title={rawLabel || fallbackTitle}
|
||||
title={tooltipTitle}
|
||||
className="min-w-0 flex-1 overflow-hidden py-1.5 text-left"
|
||||
>
|
||||
<span className="block w-full truncate font-medium leading-5">{title}</span>
|
||||
|
||||
@@ -1,44 +1,75 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Suspense, lazy, useCallback, useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import {
|
||||
oneDark,
|
||||
oneLight,
|
||||
} from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
|
||||
import { useThemeValue } from "@/hooks/useTheme";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CodeBlockProps {
|
||||
language?: string;
|
||||
code: string;
|
||||
className?: string;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
/** Read dark mode straight from the DOM — stays in sync with Tailwind's `dark:`. */
|
||||
function useIsDark() {
|
||||
const [isDark, setIsDark] = useState(() =>
|
||||
typeof document !== "undefined"
|
||||
? document.documentElement.classList.contains("dark")
|
||||
: true,
|
||||
interface HighlightedCodeProps {
|
||||
language?: string;
|
||||
code: string;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
const LazyHighlightedCode = lazy(async () => {
|
||||
const [
|
||||
{ default: SyntaxHighlighter },
|
||||
{ default: oneDark },
|
||||
{ default: oneLight },
|
||||
] = await Promise.all([
|
||||
import("react-syntax-highlighter/dist/esm/prism-async-light"),
|
||||
import("react-syntax-highlighter/dist/esm/styles/prism/one-dark"),
|
||||
import("react-syntax-highlighter/dist/esm/styles/prism/one-light"),
|
||||
]);
|
||||
|
||||
return {
|
||||
default({ language, code, isDark }: HighlightedCodeProps) {
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={isDark ? oneDark : oneLight}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
PreTag="pre"
|
||||
wrapLongLines
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function PlainCodeFallback({ code }: { code: string }) {
|
||||
return (
|
||||
<pre
|
||||
className="m-0 overflow-x-auto whitespace-pre-wrap p-4 font-mono text-sm leading-[1.6]"
|
||||
>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el = document.documentElement;
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDark(el.classList.contains("dark"));
|
||||
});
|
||||
observer.observe(el, { attributeFilter: ["class"] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return isDark;
|
||||
}
|
||||
|
||||
export function CodeBlock({ language, code, className }: CodeBlockProps) {
|
||||
export function CodeBlock({
|
||||
language,
|
||||
code,
|
||||
className,
|
||||
highlight = true,
|
||||
}: CodeBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const isDark = useIsDark();
|
||||
const isDark = useThemeValue() === "dark";
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
if (!navigator.clipboard) return;
|
||||
@@ -86,20 +117,13 @@ export function CodeBlock({ language, code, className }: CodeBlockProps) {
|
||||
<span>{copied ? t("code.copied") : t("code.copy")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={isDark ? oneDark : oneLight}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
PreTag="pre"
|
||||
wrapLongLines
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
{highlight ? (
|
||||
<Suspense fallback={<PlainCodeFallback code={code} />}>
|
||||
<LazyHighlightedCode language={language} code={code} isDark={isDark} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<PlainCodeFallback code={code} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,21 +36,25 @@ export function ConnectionBadge() {
|
||||
status === "connecting" ||
|
||||
status === "reconnecting" ||
|
||||
status === "error";
|
||||
const label = t(`connection.${status}`);
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 text-[11px] font-medium transition-colors",
|
||||
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors",
|
||||
"text-muted-foreground/70 hover:bg-sidebar-accent/65",
|
||||
meta.color,
|
||||
)}
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
title={label}
|
||||
>
|
||||
<span className="relative flex h-1.5 w-1.5" aria-hidden>
|
||||
<span className="relative flex h-2 w-2" aria-hidden>
|
||||
{pulsing && (
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-75" />
|
||||
)}
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-current" />
|
||||
</span>
|
||||
{t(`connection.${status}`)}
|
||||
<span className="sr-only">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FileReferenceKind =
|
||||
| "default"
|
||||
| "css"
|
||||
| "html"
|
||||
| "json"
|
||||
| "markdown"
|
||||
| "notebook"
|
||||
| "python"
|
||||
| "react"
|
||||
| "typescript";
|
||||
|
||||
interface FileReferenceChipProps {
|
||||
path: string;
|
||||
display?: "name" | "path";
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function FileReferenceChip({
|
||||
path,
|
||||
display = "name",
|
||||
active = false,
|
||||
className,
|
||||
textClassName,
|
||||
testId = "inline-file-path",
|
||||
}: FileReferenceChipProps) {
|
||||
const { name } = splitFilePath(path);
|
||||
const kind = fileKindForPath(path);
|
||||
const displayText = display === "path" ? path.replace(/\\/g, "/") : name;
|
||||
return (
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn("not-prose inline-flex max-w-full align-[0.14em]", className)}
|
||||
>
|
||||
<span
|
||||
data-testid={testId}
|
||||
aria-label={path}
|
||||
className={cn(
|
||||
"inline-flex max-w-full items-center gap-1 font-medium leading-[1.1]",
|
||||
"text-sky-600 transition-colors hover:text-sky-700",
|
||||
"dark:text-sky-300 dark:hover:text-sky-200",
|
||||
)}
|
||||
>
|
||||
<FileReferenceIcon kind={kind} />
|
||||
<span
|
||||
data-sheen-text={active ? displayText : undefined}
|
||||
className={cn(
|
||||
"min-w-0 truncate",
|
||||
active && "streaming-text-sheen",
|
||||
textClassName,
|
||||
)}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
className={cn(
|
||||
"max-w-[min(38rem,calc(100vw-2rem))] rounded-[10px]",
|
||||
"border-border/60 bg-popover/95 px-2.5 py-1.5",
|
||||
"break-all font-mono text-[11px] leading-snug text-popover-foreground",
|
||||
"shadow-lg backdrop-blur",
|
||||
)}
|
||||
>
|
||||
{path}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function isLikelyFilePath(value: string): boolean {
|
||||
const raw = value.trim();
|
||||
if (!raw || raw.includes("\n")) return false;
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) return false;
|
||||
if (!/[\\/]/.test(raw) && !/^(dockerfile|makefile|readme|package-lock\.json)$/i.test(raw)) {
|
||||
return false;
|
||||
}
|
||||
const normalized = raw.replace(/\\/g, "/");
|
||||
const name = normalized.split("/").filter(Boolean).pop() ?? normalized;
|
||||
if (!name || name === "." || name === "..") return false;
|
||||
if (/^(dockerfile|makefile|readme|package-lock\.json)$/i.test(name)) return true;
|
||||
return /\.[a-z0-9][a-z0-9_-]{0,12}$/i.test(name);
|
||||
}
|
||||
|
||||
function splitFilePath(path: string): { directory: string; name: string } {
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
const slash = normalized.lastIndexOf("/");
|
||||
if (slash < 0) return { directory: "", name: path };
|
||||
return {
|
||||
directory: normalized.slice(0, slash + 1),
|
||||
name: normalized.slice(slash + 1) || normalized,
|
||||
};
|
||||
}
|
||||
|
||||
function fileKindForPath(path: string): FileReferenceKind {
|
||||
const normalized = path.toLowerCase();
|
||||
const name = normalized.split(/[\\/]/).pop() ?? normalized;
|
||||
const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
|
||||
if (name === "dockerfile") {
|
||||
return "default";
|
||||
}
|
||||
switch (ext) {
|
||||
case "py":
|
||||
case "pyi":
|
||||
return "python";
|
||||
case "jsx":
|
||||
case "tsx":
|
||||
return "react";
|
||||
case "ts":
|
||||
return "typescript";
|
||||
case "html":
|
||||
case "htm":
|
||||
return "html";
|
||||
case "css":
|
||||
case "scss":
|
||||
case "sass":
|
||||
return "css";
|
||||
case "json":
|
||||
case "jsonl":
|
||||
return "json";
|
||||
case "md":
|
||||
case "mdx":
|
||||
return "markdown";
|
||||
case "ipynb":
|
||||
return "notebook";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
|
||||
if (kind === "react") {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
className="h-[0.98em] w-[0.98em] shrink-0 text-sky-500 dark:text-sky-300"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="1.9" fill="currentColor" stroke="none" />
|
||||
<ellipse cx="12" cy="12" rx="9" ry="3.7" />
|
||||
<ellipse cx="12" cy="12" rx="9" ry="3.7" transform="rotate(60 12 12)" />
|
||||
<ellipse cx="12" cy="12" rx="9" ry="3.7" transform="rotate(120 12 12)" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (kind === "default") {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
className="h-[0.98em] w-[0.98em] shrink-0 text-sky-500 dark:text-sky-300"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.9"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7z" />
|
||||
<path d="M14 2v5h5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const label = fileKindLabel(kind);
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"inline-flex h-[1.05em] min-w-[1.05em] shrink-0 items-center justify-center",
|
||||
"rounded-[4px] bg-sky-500/12 px-[0.22em] text-[0.58em] font-bold uppercase leading-none",
|
||||
"text-sky-600 dark:bg-sky-400/15 dark:text-sky-300",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function fileKindLabel(kind: FileReferenceKind): string {
|
||||
switch (kind) {
|
||||
case "css":
|
||||
return "#";
|
||||
case "html":
|
||||
return "H";
|
||||
case "json":
|
||||
return "{}";
|
||||
case "markdown":
|
||||
return "M";
|
||||
case "notebook":
|
||||
return "N";
|
||||
case "python":
|
||||
return "PY";
|
||||
case "typescript":
|
||||
return "TS";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,46 @@
|
||||
import { Suspense, lazy } from "react";
|
||||
import {
|
||||
Suspense,
|
||||
lazy,
|
||||
memo,
|
||||
startTransition,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MarkdownTextProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer");
|
||||
const LazyMarkdownRenderer = lazy(loadMarkdownRenderer);
|
||||
|
||||
const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({
|
||||
source,
|
||||
className,
|
||||
highlightCode,
|
||||
}: {
|
||||
source: string;
|
||||
className?: string;
|
||||
highlightCode: boolean;
|
||||
}) {
|
||||
return (
|
||||
<LazyMarkdownRenderer className={className} highlightCode={highlightCode}>
|
||||
{source}
|
||||
</LazyMarkdownRenderer>
|
||||
);
|
||||
});
|
||||
|
||||
const SHORT_STREAM_COMMIT_MS = 80;
|
||||
const MEDIUM_STREAM_COMMIT_MS = 140;
|
||||
const LONG_STREAM_COMMIT_MS = 220;
|
||||
|
||||
export function preloadMarkdownText(): void {
|
||||
void loadMarkdownRenderer();
|
||||
}
|
||||
@@ -19,7 +50,18 @@ export function preloadMarkdownText(): void {
|
||||
* ``remark-math`` / ``rehype-katex``, and fenced code blocks delegated to
|
||||
* ``CodeBlock`` for copy-to-clipboard and syntax highlighting.
|
||||
*/
|
||||
export function MarkdownText({ children, className }: MarkdownTextProps) {
|
||||
export function MarkdownText({
|
||||
children,
|
||||
className,
|
||||
streaming = false,
|
||||
}: MarkdownTextProps) {
|
||||
const renderedSource = useStreamingMarkdownSource(children, streaming);
|
||||
const highlightCode = !streaming && renderedSource === children;
|
||||
|
||||
useEffect(() => {
|
||||
if (streaming) preloadMarkdownText();
|
||||
}, [streaming]);
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
@@ -29,11 +71,73 @@ export function MarkdownText({ children, className }: MarkdownTextProps) {
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{renderedSource}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyMarkdownRenderer className={className}>{children}</LazyMarkdownRenderer>
|
||||
<MemoizedMarkdownRenderer
|
||||
source={renderedSource}
|
||||
className={className}
|
||||
highlightCode={highlightCode}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function useStreamingMarkdownSource(source: string, streaming: boolean): string {
|
||||
const [renderedSource, setRenderedSource] = useState(source);
|
||||
const latestSourceRef = useRef(source);
|
||||
const renderedSourceRef = useRef(source);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
const clearPendingCommit = useCallback(() => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const commitSource = useCallback((next: string, urgent: boolean) => {
|
||||
if (renderedSourceRef.current === next) return;
|
||||
renderedSourceRef.current = next;
|
||||
if (urgent) {
|
||||
setRenderedSource(next);
|
||||
return;
|
||||
}
|
||||
startTransition(() => setRenderedSource(next));
|
||||
}, []);
|
||||
|
||||
const scheduleCommit = useCallback(() => {
|
||||
if (timerRef.current !== null) return;
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
commitSource(latestSourceRef.current, false);
|
||||
}, streamingCommitDelay(latestSourceRef.current.length));
|
||||
}, [commitSource]);
|
||||
|
||||
latestSourceRef.current = source;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
latestSourceRef.current = source;
|
||||
if (!streaming) {
|
||||
clearPendingCommit();
|
||||
commitSource(source, true);
|
||||
}
|
||||
}, [clearPendingCommit, commitSource, source, streaming]);
|
||||
|
||||
useEffect(() => {
|
||||
latestSourceRef.current = source;
|
||||
if (!streaming) return;
|
||||
scheduleCommit();
|
||||
}, [scheduleCommit, source, streaming]);
|
||||
|
||||
useEffect(() => clearPendingCommit, [clearPendingCommit]);
|
||||
|
||||
return renderedSource;
|
||||
}
|
||||
|
||||
function streamingCommitDelay(length: number): number {
|
||||
if (length > 24_000) return LONG_STREAM_COMMIT_MS;
|
||||
if (length > 8_000) return MEDIUM_STREAM_COMMIT_MS;
|
||||
return SHORT_STREAM_COMMIT_MS;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Children, isValidElement } from "react";
|
||||
import { Children, isValidElement, useMemo } from "react";
|
||||
import type { Components } from "react-markdown";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
|
||||
import { CodeBlock } from "@/components/CodeBlock";
|
||||
import { FileReferenceChip, isLikelyFilePath } from "@/components/FileReferenceChip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import "katex/dist/katex.min.css";
|
||||
@@ -12,8 +14,12 @@ import "katex/dist/katex.min.css";
|
||||
interface MarkdownTextRendererProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
highlightCode?: boolean;
|
||||
}
|
||||
|
||||
const remarkPlugins = [remarkGfm, remarkMath];
|
||||
const rehypePlugins = [rehypeKatex];
|
||||
|
||||
/**
|
||||
* Heavy markdown stack (GFM, math, KaTeX, syntax highlighting) kept in a
|
||||
* separate chunk so the app shell can paint sooner on refresh.
|
||||
@@ -21,7 +27,91 @@ interface MarkdownTextRendererProps {
|
||||
export default function MarkdownTextRenderer({
|
||||
children,
|
||||
className,
|
||||
highlightCode = true,
|
||||
}: MarkdownTextRendererProps) {
|
||||
const components = useMemo<Components>(
|
||||
() => ({
|
||||
code({ className: cls, children: kids, ...props }) {
|
||||
const match = /language-(\w+)/.exec(cls || "");
|
||||
if (match) {
|
||||
const code = String(kids).replace(/\n$/, "");
|
||||
return (
|
||||
<CodeBlock
|
||||
language={match[1]}
|
||||
code={code}
|
||||
className="my-3"
|
||||
highlight={highlightCode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const raw = String(kids).replace(/\n$/, "");
|
||||
if (isLikelyFilePath(raw)) {
|
||||
return <FileReferenceChip path={raw} />;
|
||||
}
|
||||
/** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
|
||||
const widePlainBlock = raw.includes("\n") || raw.length > 120;
|
||||
if (widePlainBlock) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
"block min-w-0 whitespace-pre bg-transparent p-0 font-mono text-[0.8125rem]",
|
||||
"leading-snug text-inherit",
|
||||
cls,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{kids}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
"rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
|
||||
cls,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{kids}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre({ children: markdownChildren }) {
|
||||
const kids = Children.toArray(markdownChildren);
|
||||
const lone = kids.length === 1 ? kids[0] : null;
|
||||
/** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``<pre><div>``. */
|
||||
if (lone != null && isValidElement(lone) && lone.type === CodeBlock) {
|
||||
return <>{markdownChildren}</>;
|
||||
}
|
||||
return (
|
||||
<pre
|
||||
className={cn(
|
||||
"my-3 overflow-x-auto rounded-lg border border-border/60 bg-muted/35",
|
||||
"p-3 font-mono text-[0.8125rem] leading-snug text-foreground/90",
|
||||
"whitespace-pre [overflow-wrap:normal]",
|
||||
)}
|
||||
>
|
||||
{markdownChildren}
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
a({ href, children: markdownChildren, ...props }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-primary underline underline-offset-2 hover:opacity-80"
|
||||
{...props}
|
||||
>
|
||||
{markdownChildren}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[highlightCode],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -42,77 +132,9 @@ export default function MarkdownTextRenderer({
|
||||
style={{ lineHeight: "var(--cjk-line-height)" }}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
code({ className: cls, children: kids, ...props }) {
|
||||
const match = /language-(\w+)/.exec(cls || "");
|
||||
if (match) {
|
||||
const code = String(kids).replace(/\n$/, "");
|
||||
return <CodeBlock language={match[1]} code={code} className="my-3" />;
|
||||
}
|
||||
const raw = String(kids).replace(/\n$/, "");
|
||||
/** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
|
||||
const widePlainBlock = raw.includes("\n") || raw.length > 120;
|
||||
if (widePlainBlock) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
"block min-w-0 whitespace-pre bg-transparent p-0 font-mono text-[0.8125rem]",
|
||||
"leading-snug text-inherit",
|
||||
cls,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{kids}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
"rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
|
||||
cls,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{kids}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre({ children: markdownChildren }) {
|
||||
const kids = Children.toArray(markdownChildren);
|
||||
const lone = kids.length === 1 ? kids[0] : null;
|
||||
/** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``<pre><div>``. */
|
||||
if (lone != null && isValidElement(lone) && lone.type === CodeBlock) {
|
||||
return <>{markdownChildren}</>;
|
||||
}
|
||||
return (
|
||||
<pre
|
||||
className={cn(
|
||||
"my-3 overflow-x-auto rounded-lg border border-border/60 bg-muted/35",
|
||||
"p-3 font-mono text-[0.8125rem] leading-snug text-foreground/90",
|
||||
"whitespace-pre [overflow-wrap:normal]",
|
||||
)}
|
||||
>
|
||||
{markdownChildren}
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
a({ href, children: markdownChildren, ...props }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-primary underline underline-offset-2 hover:opacity-80"
|
||||
{...props}
|
||||
>
|
||||
{markdownChildren}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -120,7 +119,7 @@ export function MessageBubble({
|
||||
<TypingDots />
|
||||
) : empty && message.isStreaming ? null : (
|
||||
<>
|
||||
<MarkdownText>{message.content}</MarkdownText>
|
||||
<MarkdownText streaming={!!message.isStreaming}>{message.content}</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">
|
||||
@@ -167,10 +166,15 @@ function MessageMedia({
|
||||
align: "left" | "right";
|
||||
}) {
|
||||
if (media.length === 0) return null;
|
||||
const images = media
|
||||
.filter((item) => item.kind === "image")
|
||||
.map(({ url, name }) => ({ url, name }));
|
||||
const nonImages = media.filter((item) => item.kind !== "image");
|
||||
const images: UIImage[] = [];
|
||||
const nonImages: UIMediaAttachment[] = [];
|
||||
for (const item of media) {
|
||||
if (item.kind === "image") {
|
||||
images.push({ url: item.url, name: item.name });
|
||||
} else {
|
||||
nonImages.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -276,13 +280,14 @@ function UserImages({
|
||||
const { t } = useTranslation();
|
||||
// Only real-URL images can open in the lightbox; historical-replay
|
||||
// placeholders (no URL) have nothing to zoom into.
|
||||
const viewable = images
|
||||
.map((img, i) => ({ img, i }))
|
||||
.filter(({ img }) => typeof img.url === "string" && img.url.length > 0);
|
||||
const viewableImages = viewable.map(({ img }) => img);
|
||||
const originalToViewable = new Map<number, number>(
|
||||
viewable.map(({ i }, v) => [i, v]),
|
||||
);
|
||||
const viewableImages: UIImage[] = [];
|
||||
const originalToViewable = new Map<number, number>();
|
||||
for (let i = 0; i < images.length; i += 1) {
|
||||
const img = images[i];
|
||||
if (typeof img.url !== "string" || img.url.length === 0) continue;
|
||||
originalToViewable.set(i, viewableImages.length);
|
||||
viewableImages.push(img);
|
||||
}
|
||||
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
@@ -416,7 +421,7 @@ function Dot({ delay }: { delay: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** L→R sheen overlay on label text; base copy stays solid ``text-muted-foreground``. */
|
||||
/** L→R sheen on the glyphs themselves; inactive labels stay solid muted text. */
|
||||
export function StreamingLabelSheen({
|
||||
children,
|
||||
active,
|
||||
@@ -426,21 +431,21 @@ export function StreamingLabelSheen({
|
||||
active: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const sheenText =
|
||||
typeof children === "string" || typeof children === "number"
|
||||
? String(children)
|
||||
: undefined;
|
||||
return (
|
||||
<span className={cn("relative block min-w-0 py-px", className)}>
|
||||
<span className={cn("block min-w-0 overflow-hidden py-px", className)}>
|
||||
<span
|
||||
data-sheen-text={active ? sheenText : undefined}
|
||||
className={cn(
|
||||
"relative z-0 block font-medium leading-normal text-muted-foreground",
|
||||
!active && "truncate",
|
||||
"block w-fit max-w-full truncate font-medium leading-normal",
|
||||
active ? "streaming-text-sheen" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{active ? (
|
||||
<span className="reasoning-sheen-track" aria-hidden dir="ltr">
|
||||
<span className="reasoning-sheen-stripe" />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -474,8 +479,6 @@ export function ReasoningBubble({
|
||||
embeddedInCluster = false,
|
||||
}: ReasoningBubbleProps) {
|
||||
const { t } = useTranslation();
|
||||
const deferredText = useDeferredValue(text);
|
||||
const markdownSource = streaming ? deferredText : text;
|
||||
const [userToggled, setUserToggled] = useState(false);
|
||||
const [openLocal, setOpenLocal] = useState(true);
|
||||
const open = userToggled ? openLocal : streaming;
|
||||
@@ -531,6 +534,7 @@ export function ReasoningBubble({
|
||||
)}
|
||||
>
|
||||
<MarkdownText
|
||||
streaming={streaming}
|
||||
className={cn(
|
||||
"text-[12.5px] italic text-muted-foreground/88",
|
||||
"prose-p:my-1.5 prose-li:my-0.5",
|
||||
@@ -541,7 +545,7 @@ export function ReasoningBubble({
|
||||
"prose-code:text-[0.92em]",
|
||||
)}
|
||||
>
|
||||
{markdownSource}
|
||||
{text}
|
||||
</MarkdownText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -117,12 +117,12 @@ export function Sidebar(props: SidebarProps) {
|
||||
/>
|
||||
</div>
|
||||
<Separator className="bg-sidebar-border/50" />
|
||||
<div className="space-y-1 px-2.5 py-2.5 text-xs">
|
||||
<div className="flex items-center gap-1 px-2.5 py-2.5 text-xs">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={props.onOpenSettings}
|
||||
className="h-8 w-full justify-start gap-2 rounded-full px-2.5 text-[12.5px] font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
|
||||
className="h-8 min-w-0 flex-1 justify-start gap-2 rounded-full px-2.5 text-[12.5px] font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("sidebar.settings")}
|
||||
|
||||
@@ -52,6 +52,13 @@ import type { SettingsPayload, WebSearchSettingsUpdate } from "@/lib/types";
|
||||
type SettingsSectionKey = "general" | "byok";
|
||||
type ByokPaneKey = "llm" | "web-search";
|
||||
|
||||
const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map(
|
||||
["vllm", "ollama", "lm_studio", "atomic_chat", "ovms"].map((name, index) => [
|
||||
name,
|
||||
index,
|
||||
]),
|
||||
);
|
||||
|
||||
interface SettingsViewProps {
|
||||
theme: "light" | "dark";
|
||||
onToggleTheme: () => void;
|
||||
@@ -176,7 +183,8 @@ export function SettingsView({
|
||||
if (!provider) return;
|
||||
const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "" };
|
||||
const apiKey = providerForm.apiKey.trim();
|
||||
if (!provider.configured && !apiKey) {
|
||||
const apiKeyRequired = provider.api_key_required ?? true;
|
||||
if (!provider.configured && apiKeyRequired && !apiKey) {
|
||||
setError(t("settings.byok.apiKeyRequired"));
|
||||
return;
|
||||
}
|
||||
@@ -917,7 +925,10 @@ function ByokSettings({
|
||||
const [activePane, setActivePane] = useState<ByokPaneKey>("llm");
|
||||
const [showAllUnconfigured, setShowAllUnconfigured] = useState(false);
|
||||
const configuredProviders = settings.providers.filter((provider) => provider.configured);
|
||||
const unconfiguredProviders = settings.providers.filter((provider) => !provider.configured);
|
||||
const unconfiguredProviders = useMemo(
|
||||
() => orderUnconfiguredProviders(settings.providers.filter((provider) => !provider.configured)),
|
||||
[settings.providers],
|
||||
);
|
||||
const initialUnconfiguredCount = 6;
|
||||
const visibleUnconfiguredProviders = showAllUnconfigured
|
||||
? unconfiguredProviders
|
||||
@@ -935,6 +946,12 @@ function ByokSettings({
|
||||
const saving = providerSaving === provider.name;
|
||||
const keyVisible = !!visibleProviderKeys[provider.name];
|
||||
const editingKey = !provider.configured || !!editingProviderKeys[provider.name];
|
||||
const apiKeyRequired = provider.api_key_required ?? true;
|
||||
const apiKey = form.apiKey.trim();
|
||||
const apiBase = form.apiBase.trim();
|
||||
const missingRequiredApiKey = apiKeyRequired && !provider.configured && !apiKey;
|
||||
const missingOptionalCredential =
|
||||
!apiKeyRequired && !provider.configured && !apiKey && !apiBase;
|
||||
return (
|
||||
<div
|
||||
key={provider.name}
|
||||
@@ -1045,7 +1062,7 @@ function ByokSettings({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onSaveProvider(provider.name)}
|
||||
disabled={saving || (!provider.configured && !form.apiKey.trim())}
|
||||
disabled={saving || missingRequiredApiKey || missingOptionalCredential}
|
||||
className="rounded-full"
|
||||
>
|
||||
{saving ? t("settings.actions.saving") : t("settings.actions.save")}
|
||||
@@ -1188,6 +1205,25 @@ function ByokEmptyState({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function orderUnconfiguredProviders(
|
||||
providers: SettingsPayload["providers"],
|
||||
): SettingsPayload["providers"] {
|
||||
return providers
|
||||
.map((provider, index) => ({ provider, index }))
|
||||
.sort((left, right) => {
|
||||
const rank = providerVisibilityRank(left.provider) - providerVisibilityRank(right.provider);
|
||||
return rank || left.index - right.index;
|
||||
})
|
||||
.map(({ provider }) => provider);
|
||||
}
|
||||
|
||||
function providerVisibilityRank(provider: SettingsPayload["providers"][number]): number {
|
||||
const localRank = LOCAL_UNCONFIGURED_PROVIDER_ORDER.get(provider.name);
|
||||
if (localRank !== undefined) return localRank;
|
||||
if ((provider.api_key_required ?? true) === false) return 100;
|
||||
return 200;
|
||||
}
|
||||
|
||||
const PROVIDER_ICONS: Record<string, LucideIcon> = {
|
||||
custom: Hexagon,
|
||||
openrouter: Sparkles,
|
||||
@@ -1212,6 +1248,12 @@ const PROVIDER_ICONS: Record<string, LucideIcon> = {
|
||||
qianfan: Database,
|
||||
azure_openai: Cloud,
|
||||
bedrock: Database,
|
||||
vllm: Cpu,
|
||||
ollama: Cpu,
|
||||
lm_studio: Cpu,
|
||||
atomic_chat: Cpu,
|
||||
ovms: Cpu,
|
||||
nvidia: Zap,
|
||||
};
|
||||
|
||||
function ProviderIcon({ provider }: { provider: string }) {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronRight, Layers } from "lucide-react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertCircle, ChevronRight, Layers } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FileReferenceChip } from "@/components/FileReferenceChip";
|
||||
import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
import type { UIFileEdit, UIMessage } from "@/lib/types";
|
||||
|
||||
/** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */
|
||||
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
|
||||
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
|
||||
|
||||
export function isReasoningOnlyAssistant(m: UIMessage): boolean {
|
||||
if (m.role !== "assistant" || m.kind === "trace") return false;
|
||||
@@ -19,14 +21,70 @@ export function isAgentActivityMember(m: UIMessage): boolean {
|
||||
return isReasoningOnlyAssistant(m) || m.kind === "trace";
|
||||
}
|
||||
|
||||
function countToolCalls(messages: UIMessage[]): number {
|
||||
let n = 0;
|
||||
interface ActivityCounts {
|
||||
reasoningSteps: number;
|
||||
toolCalls: number;
|
||||
fileCount: number;
|
||||
added: number;
|
||||
deleted: number;
|
||||
hasEditingFiles: boolean;
|
||||
hasFailedFiles: boolean;
|
||||
primaryFilePath?: string;
|
||||
}
|
||||
|
||||
interface FileEditSummary {
|
||||
key: string;
|
||||
path: string;
|
||||
added: number;
|
||||
deleted: number;
|
||||
approximate: boolean;
|
||||
binary: boolean;
|
||||
status: UIFileEdit["status"];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): ActivityCounts {
|
||||
let reasoningSteps = 0;
|
||||
let toolCalls = 0;
|
||||
for (const m of messages) {
|
||||
if (m.kind !== "trace") continue;
|
||||
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
|
||||
n += Math.max(lines, 1);
|
||||
if (isReasoningOnlyAssistant(m)) {
|
||||
reasoningSteps += 1;
|
||||
continue;
|
||||
}
|
||||
if (m.kind === "trace") {
|
||||
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
|
||||
toolCalls += lines;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
let added = 0;
|
||||
let deleted = 0;
|
||||
let hasEditingFiles = false;
|
||||
let failedFileCount = 0;
|
||||
let primaryFilePath: string | undefined;
|
||||
for (const edit of fileEdits) {
|
||||
primaryFilePath = edit.path;
|
||||
if (edit.status === "editing") {
|
||||
hasEditingFiles = true;
|
||||
}
|
||||
if (edit.status === "error") {
|
||||
failedFileCount += 1;
|
||||
}
|
||||
if (edit.status === "error" || edit.binary) {
|
||||
continue;
|
||||
}
|
||||
added += edit.added;
|
||||
deleted += edit.deleted;
|
||||
}
|
||||
return {
|
||||
reasoningSteps,
|
||||
toolCalls,
|
||||
fileCount: fileEdits.length,
|
||||
added,
|
||||
deleted,
|
||||
hasEditingFiles,
|
||||
hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
|
||||
primaryFilePath,
|
||||
};
|
||||
}
|
||||
|
||||
interface AgentActivityClusterProps {
|
||||
@@ -46,24 +104,56 @@ export function AgentActivityCluster({
|
||||
hasBodyBelow,
|
||||
}: AgentActivityClusterProps) {
|
||||
const { t } = useTranslation();
|
||||
const reasoningSteps = messages.filter(isReasoningOnlyAssistant).length;
|
||||
const toolCalls = countToolCalls(messages);
|
||||
const fileEdits = useMemo(
|
||||
() => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming),
|
||||
[messages, isTurnStreaming],
|
||||
);
|
||||
const {
|
||||
reasoningSteps,
|
||||
toolCalls,
|
||||
fileCount,
|
||||
added,
|
||||
deleted,
|
||||
hasEditingFiles,
|
||||
hasFailedFiles,
|
||||
primaryFilePath,
|
||||
} = countActivity(messages, fileEdits);
|
||||
|
||||
const [userToggledOuter, setUserToggledOuter] = useState(false);
|
||||
const [outerOpenLocal, setOuterOpenLocal] = useState(false);
|
||||
const activityScrollRef = useRef<HTMLDivElement>(null);
|
||||
const activityContentRef = useRef<HTMLDivElement>(null);
|
||||
const autoFollowActivityRef = useRef(true);
|
||||
const scrollFrameRef = useRef<number | null>(null);
|
||||
/** Collapsed by default during “Working…” and after the turn; user expands to inspect traces. */
|
||||
const outerExpanded = userToggledOuter ? outerOpenLocal : false;
|
||||
|
||||
const headerBusy = isTurnStreaming;
|
||||
const hasLiveEditingFiles = isTurnStreaming && hasEditingFiles;
|
||||
const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming;
|
||||
|
||||
const summary =
|
||||
isTurnStreaming
|
||||
const fileActivitySummary = fileCount > 0
|
||||
? fileCount === 1 && primaryFilePath
|
||||
? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
|
||||
file: shortFileName(primaryFilePath),
|
||||
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{file}}`,
|
||||
})
|
||||
: t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
|
||||
count: fileCount,
|
||||
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{count}} files`,
|
||||
})
|
||||
: "";
|
||||
|
||||
const summary = fileCount > 0
|
||||
? fileActivitySummary
|
||||
: isTurnStreaming
|
||||
? reasoningSteps > 0
|
||||
? t("message.agentActivityLiveSummary", {
|
||||
reasoning: reasoningSteps,
|
||||
tools: toolCalls,
|
||||
defaultValue: "Working… · {{reasoning}} steps · {{tools}} tool calls",
|
||||
})
|
||||
: toolCalls === 0 && fileCount > 0
|
||||
? t("message.agentActivityLiveFilesOnly", { defaultValue: "Working…" })
|
||||
: t("message.agentActivityLiveToolsOnly", {
|
||||
tools: toolCalls,
|
||||
defaultValue: "Working… · {{tools}} tool calls",
|
||||
@@ -74,16 +164,73 @@ export function AgentActivityCluster({
|
||||
tools: toolCalls,
|
||||
defaultValue: "{{reasoning}} steps · {{tools}} tool calls",
|
||||
})
|
||||
: toolCalls === 0 && fileCount > 0
|
||||
? t("message.agentActivityFilesOnly", { defaultValue: "File changes" })
|
||||
: t("message.agentActivityToolsOnly", {
|
||||
tools: toolCalls,
|
||||
defaultValue: "{{tools}} tool calls",
|
||||
});
|
||||
|
||||
const cancelActivityScrollFrame = useCallback(() => {
|
||||
if (scrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(scrollFrameRef.current);
|
||||
scrollFrameRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scrollActivityToBottom = useCallback(() => {
|
||||
const el = activityScrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
|
||||
}, []);
|
||||
|
||||
const scheduleActivityScrollToBottom = useCallback(() => {
|
||||
cancelActivityScrollFrame();
|
||||
scrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
scrollFrameRef.current = null;
|
||||
scrollActivityToBottom();
|
||||
});
|
||||
}, [cancelActivityScrollFrame, scrollActivityToBottom]);
|
||||
|
||||
const toggleOuter = () => {
|
||||
const nextOpen = userToggledOuter ? !outerOpenLocal : !outerExpanded;
|
||||
if (nextOpen) {
|
||||
autoFollowActivityRef.current = true;
|
||||
}
|
||||
setUserToggledOuter(true);
|
||||
setOuterOpenLocal((v) => (userToggledOuter ? !v : !outerExpanded));
|
||||
setOuterOpenLocal(nextOpen);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!outerExpanded || !autoFollowActivityRef.current) return;
|
||||
scheduleActivityScrollToBottom();
|
||||
}, [outerExpanded, messages, isTurnStreaming, scheduleActivityScrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!outerExpanded) {
|
||||
autoFollowActivityRef.current = true;
|
||||
return;
|
||||
}
|
||||
const target = activityContentRef.current;
|
||||
if (!target || typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (autoFollowActivityRef.current) {
|
||||
scheduleActivityScrollToBottom();
|
||||
}
|
||||
});
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [outerExpanded, scheduleActivityScrollToBottom]);
|
||||
|
||||
useEffect(() => cancelActivityScrollFrame, [cancelActivityScrollFrame]);
|
||||
|
||||
const onActivityScroll = useCallback(() => {
|
||||
const el = activityScrollRef.current;
|
||||
if (!el) return;
|
||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
autoFollowActivityRef.current = distance < ACTIVITY_SCROLL_NEAR_BOTTOM_PX;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
|
||||
<button
|
||||
@@ -96,12 +243,19 @@ export function AgentActivityCluster({
|
||||
aria-expanded={outerExpanded}
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<StreamingLabelSheen
|
||||
active={headerBusy}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
{summary}
|
||||
</StreamingLabelSheen>
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-left">
|
||||
<StreamingLabelSheen
|
||||
active={headerBusy}
|
||||
className="min-w-0"
|
||||
>
|
||||
{summary}
|
||||
</StreamingLabelSheen>
|
||||
{fileCount > 0 && (
|
||||
<span className="inline-flex min-w-0 items-center gap-1 text-muted-foreground/85">
|
||||
<DiffPair added={added} deleted={deleted} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className={cn(
|
||||
@@ -118,29 +272,38 @@ export function AgentActivityCluster({
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={activityScrollRef}
|
||||
data-testid="agent-activity-scroll"
|
||||
onScroll={onActivityScroll}
|
||||
className={cn(
|
||||
CLUSTER_SCROLL_MAX_CLASS,
|
||||
"overflow-y-auto px-2 py-1.5 scrollbar-thin scrollbar-track-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div ref={activityContentRef} className="flex flex-col gap-2">
|
||||
{messages.map((m) => {
|
||||
if (isReasoningOnlyAssistant(m)) {
|
||||
return (
|
||||
<ReasoningBubble
|
||||
key={m.id}
|
||||
text={m.reasoning ?? ""}
|
||||
streaming={!!m.reasoningStreaming}
|
||||
streaming={isTurnStreaming && !!m.reasoningStreaming}
|
||||
hasBodyBelow={false}
|
||||
embeddedInCluster
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (m.kind === "trace") {
|
||||
return <TraceGroup key={m.id} message={m} animClass="" />;
|
||||
const hasTraceLines = (m.traces?.length ?? 0) > 0 || m.content.trim().length > 0;
|
||||
return hasTraceLines ? (
|
||||
<div key={m.id} className="flex flex-col gap-1">
|
||||
<TraceGroup message={m} animClass="" />
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
{fileEdits.length ? <FileEditGroup edits={fileEdits} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,3 +311,231 @@ export function AgentActivityCluster({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function shortFileName(path: string): string {
|
||||
return path.split(/[\\/]/).pop() || path;
|
||||
}
|
||||
|
||||
function fileActivityVerb(editing: boolean, failed: boolean): string {
|
||||
if (failed) return "Failed";
|
||||
return editing ? "Editing" : "Edited";
|
||||
}
|
||||
|
||||
function fileActivitySummaryKey(editing: boolean, failed: boolean): string {
|
||||
if (failed) return "message.fileActivityFailedOne";
|
||||
return editing ? "message.fileActivityEditingOne" : "message.fileActivityEditedOne";
|
||||
}
|
||||
|
||||
function fileActivityManySummaryKey(editing: boolean, failed: boolean): string {
|
||||
if (failed) return "message.fileActivityFailedMany";
|
||||
return editing ? "message.fileActivityEditingMany" : "message.fileActivityEditedMany";
|
||||
}
|
||||
|
||||
function fileEditCallKey(edit: UIFileEdit): string {
|
||||
return `${edit.call_id}|${edit.tool}|${edit.path}`;
|
||||
}
|
||||
|
||||
function collectFileEdits(messages: UIMessage[]): UIFileEdit[] {
|
||||
const edits: UIFileEdit[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.kind === "trace" && message.fileEdits?.length) {
|
||||
edits.push(...message.fileEdits);
|
||||
}
|
||||
}
|
||||
return edits;
|
||||
}
|
||||
|
||||
function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] {
|
||||
const order: string[] = [];
|
||||
const byKey = new Map<string, UIFileEdit>();
|
||||
for (const edit of edits) {
|
||||
const key = fileEditCallKey(edit);
|
||||
if (!byKey.has(key)) order.push(key);
|
||||
byKey.set(key, edit);
|
||||
}
|
||||
return order.map((key) => byKey.get(key)).filter(Boolean) as UIFileEdit[];
|
||||
}
|
||||
|
||||
function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSummary[] {
|
||||
interface MutableSummary {
|
||||
key: string;
|
||||
path: string;
|
||||
added: number;
|
||||
deleted: number;
|
||||
approximate: boolean;
|
||||
binary: boolean;
|
||||
hasSuccessfulChange: boolean;
|
||||
hasActiveEditing: boolean;
|
||||
hasFailed: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const order: string[] = [];
|
||||
const byPath = new Map<string, MutableSummary>();
|
||||
for (const edit of latestFileEditEvents(edits)) {
|
||||
const key = edit.path;
|
||||
let summary = byPath.get(key);
|
||||
if (!summary) {
|
||||
summary = {
|
||||
key,
|
||||
path: edit.path,
|
||||
added: 0,
|
||||
deleted: 0,
|
||||
approximate: false,
|
||||
binary: false,
|
||||
hasSuccessfulChange: false,
|
||||
hasActiveEditing: false,
|
||||
hasFailed: false,
|
||||
};
|
||||
byPath.set(key, summary);
|
||||
order.push(key);
|
||||
}
|
||||
|
||||
if (active && edit.status === "editing") {
|
||||
summary.hasActiveEditing = true;
|
||||
summary.binary = summary.binary || !!edit.binary;
|
||||
summary.approximate = summary.approximate || !!edit.approximate;
|
||||
if (!edit.binary) {
|
||||
summary.added += edit.added;
|
||||
summary.deleted += edit.deleted;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (edit.status === "error") {
|
||||
summary.hasFailed = true;
|
||||
summary.error = edit.error ?? summary.error;
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.hasSuccessfulChange = true;
|
||||
summary.binary = summary.binary || !!edit.binary;
|
||||
summary.approximate = active && (summary.approximate || !!edit.approximate);
|
||||
if (!edit.binary) {
|
||||
summary.added += edit.added;
|
||||
summary.deleted += edit.deleted;
|
||||
}
|
||||
}
|
||||
|
||||
return order.map((key) => {
|
||||
const summary = byPath.get(key)!;
|
||||
const status: UIFileEdit["status"] = summary.hasActiveEditing
|
||||
? "editing"
|
||||
: summary.hasSuccessfulChange
|
||||
? "done"
|
||||
: summary.hasFailed
|
||||
? "error"
|
||||
: "done";
|
||||
return {
|
||||
key: summary.key,
|
||||
path: summary.path,
|
||||
added: summary.added,
|
||||
deleted: summary.deleted,
|
||||
approximate: summary.approximate,
|
||||
binary: summary.binary,
|
||||
status,
|
||||
error: summary.error,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
|
||||
if (edits.length === 0) return null;
|
||||
return (
|
||||
<ul className="space-y-1 border-l border-muted-foreground/15 pl-3">
|
||||
{edits.map((edit) => (
|
||||
<FileEditRow key={edit.key} edit={edit} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function FileEditRow({ edit }: { edit: FileEditSummary }) {
|
||||
const { t } = useTranslation();
|
||||
const editing = edit.status === "editing";
|
||||
const failed = edit.status === "error";
|
||||
const hasCountedDiff = !failed && !edit.binary;
|
||||
return (
|
||||
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md px-2 py-1.5 text-xs">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FileReferenceChip
|
||||
path={edit.path}
|
||||
display="path"
|
||||
active={editing}
|
||||
className="min-w-0"
|
||||
textClassName="text-[12px]"
|
||||
testId="activity-file-reference"
|
||||
/>
|
||||
{failed ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-[10.5px] font-medium text-destructive/75">
|
||||
<AlertCircle className="h-3 w-3" aria-hidden />
|
||||
{t("message.fileEditFailed", { defaultValue: "Failed" })}
|
||||
</span>
|
||||
) : null}
|
||||
{edit.approximate && !failed ? (
|
||||
<span className="shrink-0 text-[10.5px] font-medium text-muted-foreground/55">
|
||||
{t("message.fileEditApproximate", { defaultValue: "estimated" })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{hasCountedDiff ? (
|
||||
<DiffPair added={edit.added} deleted={edit.deleted} />
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffPair({ added, deleted }: { added: number; deleted: number }) {
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 tabular-nums">
|
||||
<span className="text-emerald-600/75 dark:text-emerald-300/75">
|
||||
+<AnimatedNumber value={added} />
|
||||
</span>
|
||||
<span className="text-rose-600/70 dark:text-rose-300/75">
|
||||
-<AnimatedNumber value={deleted} />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedNumber({ value }: { value: number }) {
|
||||
const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
|
||||
const [display, setDisplay] = useState(0);
|
||||
const displayRef = useRef(0);
|
||||
|
||||
const setAnimatedDisplay = useCallback((next: number) => {
|
||||
displayRef.current = next;
|
||||
setDisplay(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reduceMotion) {
|
||||
setAnimatedDisplay(safeValue);
|
||||
return;
|
||||
}
|
||||
const start = displayRef.current;
|
||||
const delta = safeValue - start;
|
||||
if (delta === 0) {
|
||||
setAnimatedDisplay(safeValue);
|
||||
return;
|
||||
}
|
||||
const duration = 260;
|
||||
const startedAt = performance.now();
|
||||
let frame = 0;
|
||||
const tick = (now: number) => {
|
||||
const progress = Math.min(1, (now - startedAt) / duration);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setAnimatedDisplay(Math.round(start + delta * eased));
|
||||
if (progress < 1) {
|
||||
frame = window.requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
displayRef.current = safeValue;
|
||||
};
|
||||
frame = window.requestAnimationFrame(tick);
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [safeValue, setAnimatedDisplay]);
|
||||
|
||||
return <>{display}</>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MessageBubble } from "@/components/MessageBubble";
|
||||
import {
|
||||
AgentActivityCluster,
|
||||
@@ -9,6 +12,8 @@ interface ThreadMessagesProps {
|
||||
messages: UIMessage[];
|
||||
/** When true, agent turn still in flight — keeps activity cluster expanded. */
|
||||
isStreaming?: boolean;
|
||||
hiddenMessageCount?: number;
|
||||
onLoadEarlier?: () => void;
|
||||
}
|
||||
|
||||
export type DisplayUnit =
|
||||
@@ -30,31 +35,160 @@ export function isFinalAssistantSliceBeforeNextUser(
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
|
||||
export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
|
||||
const out: DisplayUnit[] = [];
|
||||
let i = 0;
|
||||
while (i < messages.length) {
|
||||
const m = messages[i];
|
||||
if (isAgentActivityMember(m)) {
|
||||
const cluster: UIMessage[] = [];
|
||||
while (i < messages.length && isAgentActivityMember(messages[i])) {
|
||||
cluster.push(messages[i]);
|
||||
let segmentId: string | undefined = m.activitySegmentId;
|
||||
let clusterHasFileEdits = hasFileEdits(m);
|
||||
while (
|
||||
i < messages.length
|
||||
&& isAgentActivityMember(messages[i])
|
||||
&& canJoinActivityCluster(segmentId, clusterHasFileEdits, messages[i])
|
||||
) {
|
||||
const current = messages[i];
|
||||
if (!segmentId && current.activitySegmentId) {
|
||||
segmentId = current.activitySegmentId;
|
||||
}
|
||||
clusterHasFileEdits = clusterHasFileEdits || hasFileEdits(current);
|
||||
cluster.push(current);
|
||||
i += 1;
|
||||
}
|
||||
out.push({ type: "cluster", messages: cluster });
|
||||
continue;
|
||||
}
|
||||
const previous = out[out.length - 1];
|
||||
if (
|
||||
previous?.type === "cluster"
|
||||
&& assistantHasInlineReasoning(m)
|
||||
&& canFoldInlineReasoning(previous.messages, m)
|
||||
) {
|
||||
previous.messages.push(reasoningOnlyMessageFromAnswer(m));
|
||||
out.push({ type: "single", message: stripInlineReasoning(m) });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (assistantHasInlineReasoning(m)) {
|
||||
out.push({ type: "cluster", messages: [reasoningOnlyMessageFromAnswer(m)] });
|
||||
out.push({ type: "single", message: stripInlineReasoning(m) });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
out.push({ type: "single", message: m });
|
||||
i += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function ThreadMessages({ messages, isStreaming = false }: ThreadMessagesProps) {
|
||||
const units = buildDisplayUnits(messages);
|
||||
function clusterSegmentId(messages: UIMessage[]): string | undefined {
|
||||
return messages.find((message) => message.activitySegmentId)?.activitySegmentId;
|
||||
}
|
||||
|
||||
function hasFileEdits(message: UIMessage): boolean {
|
||||
return !!message.fileEdits?.length;
|
||||
}
|
||||
|
||||
function clusterHasFileEdits(messages: UIMessage[]): boolean {
|
||||
return messages.some(hasFileEdits);
|
||||
}
|
||||
|
||||
function canJoinActivityCluster(
|
||||
clusterSegmentId: string | undefined,
|
||||
clusterIncludesFileEdits: boolean,
|
||||
message: UIMessage,
|
||||
): boolean {
|
||||
const messageHasFileEdits = hasFileEdits(message);
|
||||
if (!clusterIncludesFileEdits && !messageHasFileEdits) return true;
|
||||
if (!clusterSegmentId || !message.activitySegmentId) return true;
|
||||
return clusterSegmentId === message.activitySegmentId;
|
||||
}
|
||||
|
||||
function canFoldInlineReasoning(cluster: UIMessage[], message: UIMessage): boolean {
|
||||
if (!clusterHasFileEdits(cluster) && !hasFileEdits(message)) return true;
|
||||
const segmentId = clusterSegmentId(cluster);
|
||||
if (!segmentId || !message.activitySegmentId) return true;
|
||||
return segmentId === message.activitySegmentId;
|
||||
}
|
||||
|
||||
function assistantHasInlineReasoning(message: UIMessage): boolean {
|
||||
return (
|
||||
message.role === "assistant"
|
||||
&& message.kind !== "trace"
|
||||
&& message.content.trim().length > 0
|
||||
&& (!!message.reasoning?.trim() || !!message.reasoningStreaming)
|
||||
);
|
||||
}
|
||||
|
||||
function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
|
||||
return {
|
||||
id: `${message.id}-reasoning`,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
createdAt: message.createdAt,
|
||||
reasoning: message.reasoning,
|
||||
reasoningStreaming: message.reasoningStreaming,
|
||||
isStreaming: message.reasoningStreaming,
|
||||
activitySegmentId: message.activitySegmentId,
|
||||
};
|
||||
}
|
||||
|
||||
function stripInlineReasoning(message: UIMessage): UIMessage {
|
||||
const next = { ...message };
|
||||
delete next.reasoning;
|
||||
delete next.reasoningStreaming;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
|
||||
const flags = new Array<boolean>(units.length).fill(true);
|
||||
let hasLaterUnitBeforeUser = false;
|
||||
for (let i = units.length - 1; i >= 0; i -= 1) {
|
||||
const unit = units[i];
|
||||
if (unit.type === "single" && unit.message.role === "user") {
|
||||
hasLaterUnitBeforeUser = false;
|
||||
continue;
|
||||
}
|
||||
if (unit.type === "single" && unit.message.role === "assistant") {
|
||||
flags[i] = !hasLaterUnitBeforeUser;
|
||||
}
|
||||
hasLaterUnitBeforeUser = true;
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
export function ThreadMessages({
|
||||
messages,
|
||||
isStreaming = false,
|
||||
hiddenMessageCount = 0,
|
||||
onLoadEarlier,
|
||||
}: ThreadMessagesProps) {
|
||||
const { t } = useTranslation();
|
||||
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
|
||||
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
|
||||
const liveActivityClusterIndex = useMemo(
|
||||
() => isStreaming ? currentActivityClusterIndex(units) : -1,
|
||||
[isStreaming, units],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
{hiddenMessageCount > 0 && onLoadEarlier ? (
|
||||
<div className="mb-4 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoadEarlier}
|
||||
className="rounded-full border border-border/60 bg-background/85 px-3 py-1.5 text-xs font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted/55 hover:text-foreground"
|
||||
>
|
||||
{t("thread.loadEarlier", {
|
||||
count: hiddenMessageCount,
|
||||
defaultValue: "Load earlier messages",
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{units.map((unit, index) => {
|
||||
const prev = units[index - 1];
|
||||
const marginTop =
|
||||
@@ -72,7 +206,7 @@ export function ThreadMessages({ messages, isStreaming = false }: ThreadMessages
|
||||
{unit.type === "cluster" ? (
|
||||
<AgentActivityCluster
|
||||
messages={unit.messages}
|
||||
isTurnStreaming={isStreaming}
|
||||
isTurnStreaming={index === liveActivityClusterIndex}
|
||||
hasBodyBelow={hasBodyBelow}
|
||||
/>
|
||||
) : (
|
||||
@@ -80,7 +214,7 @@ export function ThreadMessages({ messages, isStreaming = false }: ThreadMessages
|
||||
message={unit.message}
|
||||
showAssistantCopyAction={
|
||||
unit.message.role === "assistant"
|
||||
? isFinalAssistantSliceBeforeNextUser(units, index)
|
||||
? copyFlags[index]
|
||||
: true
|
||||
}
|
||||
/>
|
||||
@@ -92,6 +226,11 @@ export function ThreadMessages({ messages, isStreaming = false }: ThreadMessages
|
||||
);
|
||||
}
|
||||
|
||||
function currentActivityClusterIndex(units: DisplayUnit[]): number {
|
||||
const last = units.length - 1;
|
||||
return units[last]?.type === "cluster" ? last : -1;
|
||||
}
|
||||
|
||||
function unitKey(unit: DisplayUnit, index: number): string {
|
||||
if (unit.type === "cluster") {
|
||||
const anchor = unit.messages[0]?.id;
|
||||
|
||||
@@ -167,8 +167,9 @@ export function ThreadShell({
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId) return;
|
||||
return client.onSessionUpdate((updatedChatId) => {
|
||||
return client.onSessionUpdate((updatedChatId, scope) => {
|
||||
if (updatedChatId !== chatId) return;
|
||||
if (scope === "metadata") return;
|
||||
pendingCanonicalHydrateRef.current.add(chatId);
|
||||
refreshHistory();
|
||||
});
|
||||
@@ -389,6 +390,7 @@ export function ThreadShell({
|
||||
composer={composer}
|
||||
scrollToBottomSignal={scrollToBottomSignal}
|
||||
conversationKey={historyKey}
|
||||
showScrollToBottomButton={!!session}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
||||
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
@@ -14,9 +23,27 @@ interface ThreadViewportProps {
|
||||
emptyState?: ReactNode;
|
||||
scrollToBottomSignal?: number;
|
||||
conversationKey?: string | null;
|
||||
showScrollToBottomButton?: boolean;
|
||||
}
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
||||
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
||||
export const INITIAL_HISTORY_WINDOW = 160;
|
||||
export const HISTORY_WINDOW_INCREMENT = 120;
|
||||
|
||||
export function windowMessages(messages: UIMessage[], visibleCount: number): UIMessage[] {
|
||||
if (messages.length <= visibleCount) return messages;
|
||||
let start = Math.max(0, messages.length - visibleCount);
|
||||
while (
|
||||
start > 0
|
||||
&& isAgentActivityMember(messages[start])
|
||||
&& isAgentActivityMember(messages[start - 1])
|
||||
) {
|
||||
start -= 1;
|
||||
}
|
||||
return messages.slice(start);
|
||||
}
|
||||
|
||||
export function ThreadViewport({
|
||||
messages,
|
||||
@@ -25,18 +52,33 @@ export function ThreadViewport({
|
||||
emptyState,
|
||||
scrollToBottomSignal = 0,
|
||||
conversationKey = null,
|
||||
showScrollToBottomButton = true,
|
||||
}: ThreadViewportProps) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const composerDockRef = useRef<HTMLDivElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const lastConversationKeyRef = useRef<string | null>(conversationKey);
|
||||
const pendingConversationScrollRef = useRef(true);
|
||||
const scrollFrameIdsRef = useRef<number[]>([]);
|
||||
const restoreScrollAfterPrependRef =
|
||||
useRef<{ height: number; top: number } | null>(null);
|
||||
/** User scrolled away from the bottom; do not auto-yank until they return or we reset (new chat / send). */
|
||||
const userReadingHistoryRef = useRef(false);
|
||||
const [atBottom, setAtBottom] = useState(true);
|
||||
const [composerDockHeight, setComposerDockHeight] = useState(0);
|
||||
const [visibleMessageCount, setVisibleMessageCount] =
|
||||
useState(INITIAL_HISTORY_WINDOW);
|
||||
const hasMessages = messages.length > 0;
|
||||
const visibleMessages = useMemo(
|
||||
() => windowMessages(messages, visibleMessageCount),
|
||||
[messages, visibleMessageCount],
|
||||
);
|
||||
const hiddenMessageCount = messages.length - visibleMessages.length;
|
||||
const scrollButtonBottom = composerDockHeight > 0
|
||||
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
|
||||
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX;
|
||||
|
||||
const cancelScheduledBottomScroll = useCallback(() => {
|
||||
for (const id of scrollFrameIdsRef.current) {
|
||||
@@ -77,6 +119,30 @@ export function ThreadViewport({
|
||||
[cancelScheduledBottomScroll, scrollToBottomNow],
|
||||
);
|
||||
|
||||
const loadEarlierMessages = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) {
|
||||
restoreScrollAfterPrependRef.current = {
|
||||
height: el.scrollHeight,
|
||||
top: el.scrollTop,
|
||||
};
|
||||
}
|
||||
userReadingHistoryRef.current = true;
|
||||
setAtBottom(false);
|
||||
setVisibleMessageCount((count) =>
|
||||
Math.min(messages.length, count + HISTORY_WINDOW_INCREMENT),
|
||||
);
|
||||
}, [messages.length]);
|
||||
|
||||
const measureComposerDock = useCallback(() => {
|
||||
const el = composerDockRef.current;
|
||||
if (!el) return;
|
||||
const height = el.getBoundingClientRect().height || el.offsetHeight;
|
||||
setComposerDockHeight((current) =>
|
||||
Math.abs(current - height) < 1 ? current : height,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!atBottom) return;
|
||||
// Instant jump: CSS scroll-smooth + behavior "auto" still animates in some
|
||||
@@ -96,8 +162,19 @@ export function ThreadViewport({
|
||||
pendingConversationScrollRef.current = true;
|
||||
userReadingHistoryRef.current = false;
|
||||
setAtBottom(true);
|
||||
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
|
||||
}, [conversationKey]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const pending = restoreScrollAfterPrependRef.current;
|
||||
if (!pending) return;
|
||||
const el = scrollRef.current;
|
||||
restoreScrollAfterPrependRef.current = null;
|
||||
if (!el) return;
|
||||
const delta = el.scrollHeight - pending.height;
|
||||
el.scrollTop = pending.top + delta;
|
||||
}, [visibleMessages.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!pendingConversationScrollRef.current) return;
|
||||
if (!conversationKey) {
|
||||
@@ -110,6 +187,10 @@ export function ThreadViewport({
|
||||
pendingConversationScrollRef.current = false;
|
||||
}, [conversationKey, hasMessages, messages, scrollToBottom]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measureComposerDock();
|
||||
}, [composer, hasMessages, measureComposerDock]);
|
||||
|
||||
useEffect(() => cancelScheduledBottomScroll, [cancelScheduledBottomScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -123,6 +204,14 @@ export function ThreadViewport({
|
||||
return () => observer.disconnect();
|
||||
}, [hasMessages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = composerDockRef.current;
|
||||
if (!target || typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => measureComposerDock());
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMessages, measureComposerDock]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
@@ -155,11 +244,20 @@ export function ThreadViewport({
|
||||
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
|
||||
<div className="flex-1 px-4 pb-20 pt-4">
|
||||
<div className="mx-auto w-full max-w-[49.5rem]">
|
||||
<ThreadMessages messages={messages} isStreaming={isStreaming} />
|
||||
<ThreadMessages
|
||||
messages={visibleMessages}
|
||||
isStreaming={isStreaming}
|
||||
hiddenMessageCount={hiddenMessageCount}
|
||||
onLoadEarlier={loadEarlierMessages}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 z-10 mt-auto bg-background">
|
||||
<div
|
||||
ref={composerDockRef}
|
||||
data-testid="thread-composer-dock"
|
||||
className="sticky bottom-0 z-10 mt-auto bg-background"
|
||||
>
|
||||
<div className="px-4 pb-3">
|
||||
{composer}
|
||||
</div>
|
||||
@@ -183,17 +281,18 @@ export function ThreadViewport({
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
|
||||
/>
|
||||
|
||||
{!atBottom && (
|
||||
{showScrollToBottomButton && !atBottom && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => scrollToBottom(true, 1, { force: true })}
|
||||
className={cn(
|
||||
/* Keep clear of sticky composer (textarea + toolbar + optional goal strip). */
|
||||
"absolute bottom-48 left-1/2 z-20 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
|
||||
"absolute left-1/2 z-20 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
|
||||
"bg-background/90 backdrop-blur",
|
||||
"animate-in fade-in-0 zoom-in-95",
|
||||
)}
|
||||
style={{ bottom: scrollButtonBottom }}
|
||||
aria-label={t("thread.scrollToBottom")}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
|
||||
@@ -11,15 +11,17 @@ const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
|
||||
+36
-29
@@ -117,53 +117,60 @@
|
||||
--cjk-line-height: 1.625;
|
||||
}
|
||||
|
||||
/* L→R sheen over solid label text (overlay stripe). Avoids ``background-clip:
|
||||
text`` loop seams that read as RTL “erase” or one-frame transparent glyphs. */
|
||||
@keyframes reasoning-sheen-ltr {
|
||||
/* L→R sheen clipped to live activity labels. The highlight lives inside
|
||||
the glyphs, not in the row background, so dark mode stays quiet. */
|
||||
@keyframes streaming-text-sheen-ltr {
|
||||
0% {
|
||||
left: -44%;
|
||||
background-position: 140% 50%;
|
||||
}
|
||||
100% {
|
||||
left: 118%;
|
||||
background-position: -40% 50%;
|
||||
}
|
||||
}
|
||||
.reasoning-sheen-track {
|
||||
.streaming-text-sheen {
|
||||
position: relative;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.streaming-text-sheen::after {
|
||||
content: attr(data-sheen-text);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border-radius: 2px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
}
|
||||
.reasoning-sheen-stripe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 44%;
|
||||
min-width: 3.25rem;
|
||||
left: -44%;
|
||||
border-radius: inherit;
|
||||
color: transparent;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
hsl(0 0% 100% / 0.07) 34%,
|
||||
hsl(0 0% 100% / 0.76) 50%,
|
||||
hsl(0 0% 100% / 0.07) 66%,
|
||||
transparent 38%,
|
||||
hsl(var(--foreground) / 0.98) 50%,
|
||||
transparent 62%,
|
||||
transparent 100%
|
||||
);
|
||||
mix-blend-mode: soft-light;
|
||||
opacity: 0.95;
|
||||
animation: reasoning-sheen-ltr 5.2s linear infinite;
|
||||
background-size: 260% 100%;
|
||||
background-position: 140% 50%;
|
||||
background-repeat: no-repeat;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: streaming-text-sheen-ltr 2.8s ease-in-out infinite;
|
||||
}
|
||||
.dark .reasoning-sheen-stripe {
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 1;
|
||||
.dark .streaming-text-sheen::after {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
transparent 38%,
|
||||
hsl(var(--foreground) / 0.98) 50%,
|
||||
transparent 62%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reasoning-sheen-stripe {
|
||||
.streaming-text-sheen::after {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
const TITLE_REFRESH_RETRY_DELAYS_MS = [1_000, 3_000, 7_000] as const;
|
||||
|
||||
function hasGeneratedTitle(session: ChatSummary | null): boolean {
|
||||
return !!session?.title?.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* The server generates WebUI titles after the main turn has already ended.
|
||||
* Refresh once immediately, then retry lightly for untitled sessions so the
|
||||
* async title appears even if the websocket metadata notification is delayed.
|
||||
*/
|
||||
export function useDeferredTitleRefresh(
|
||||
activeSession: ChatSummary | null,
|
||||
refresh: () => Promise<void>,
|
||||
retryDelaysMs: readonly number[] = TITLE_REFRESH_RETRY_DELAYS_MS,
|
||||
): () => void {
|
||||
const activeSessionRef = useRef(activeSession);
|
||||
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
activeSessionRef.current = activeSession;
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
for (const timer of timersRef.current) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timersRef.current = [];
|
||||
}, []);
|
||||
|
||||
useEffect(() => clearTimers, [clearTimers]);
|
||||
|
||||
useEffect(() => {
|
||||
clearTimers();
|
||||
}, [activeSession?.key, clearTimers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasGeneratedTitle(activeSession)) {
|
||||
clearTimers();
|
||||
}
|
||||
}, [activeSession, clearTimers]);
|
||||
|
||||
return useCallback(() => {
|
||||
void refresh();
|
||||
|
||||
const sessionAtTurnEnd = activeSessionRef.current;
|
||||
if (!sessionAtTurnEnd || hasGeneratedTitle(sessionAtTurnEnd)) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimers();
|
||||
for (const delayMs of retryDelaysMs) {
|
||||
const timer = setTimeout(() => {
|
||||
const latest = activeSessionRef.current;
|
||||
if (
|
||||
!latest ||
|
||||
latest.key !== sessionAtTurnEnd.key ||
|
||||
hasGeneratedTitle(latest)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
}, delayMs);
|
||||
timersRef.current.push(timer);
|
||||
}
|
||||
}, [clearTimers, refresh, retryDelaysMs]);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
OutboundMedia,
|
||||
GoalStateWsPayload,
|
||||
UIImage,
|
||||
UIFileEdit,
|
||||
UIMessage,
|
||||
} from "@/lib/types";
|
||||
|
||||
@@ -18,12 +19,26 @@ interface StreamBuffer {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
/** Scan upward from the bottom skipping trace rows so tool breadcrumbs don't steal the stream target. */
|
||||
function findStreamingAssistantId(prev: UIMessage[]): string | null {
|
||||
interface ActiveAssistantCursor {
|
||||
id: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
type PendingStreamEvent =
|
||||
| { kind: "delta"; text: string }
|
||||
| { kind: "reasoning"; text: string };
|
||||
|
||||
/** Find a still-open streamed assistant turn. Closed stream segments stay visible
|
||||
* as streaming until ``turn_end`` for visual continuity, but they must not
|
||||
* receive later delta segments. */
|
||||
function findStreamingAssistantIndex(
|
||||
prev: UIMessage[],
|
||||
closedStreamIds: ReadonlySet<string>,
|
||||
): number | null {
|
||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||
const m = prev[i];
|
||||
if (m.kind === "trace") continue;
|
||||
if (m.role === "assistant" && m.isStreaming) return m.id;
|
||||
if (m.role === "assistant" && m.isStreaming && !closedStreamIds.has(m.id)) return i;
|
||||
if (m.role === "user") break;
|
||||
}
|
||||
return null;
|
||||
@@ -38,7 +53,13 @@ function findStreamingAssistantId(prev: UIMessage[]): string | null {
|
||||
* case the reasoning still belongs to the same assistant turn and must render
|
||||
* above the answer, not as a new row below it.
|
||||
*/
|
||||
function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
|
||||
function attachReasoningChunk(
|
||||
prev: UIMessage[],
|
||||
chunk: string,
|
||||
segments?: {
|
||||
ensure: () => string;
|
||||
},
|
||||
): UIMessage[] {
|
||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||
const candidate = prev[i];
|
||||
// A user turn is a hard boundary: reasoning after it belongs to the new
|
||||
@@ -49,6 +70,7 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
|
||||
// that produced those tool calls.
|
||||
if (candidate.kind === "trace") break;
|
||||
if (candidate.role !== "assistant") continue;
|
||||
const activitySegmentId = candidate.activitySegmentId ?? segments?.ensure();
|
||||
const hasAnswer = candidate.content.length > 0;
|
||||
if (
|
||||
candidate.reasoningStreaming
|
||||
@@ -60,6 +82,7 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
|
||||
...candidate,
|
||||
reasoning: (candidate.reasoning ?? "") + chunk,
|
||||
reasoningStreaming: true,
|
||||
...(activitySegmentId ? { activitySegmentId } : {}),
|
||||
};
|
||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
||||
}
|
||||
@@ -68,11 +91,13 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
|
||||
...candidate,
|
||||
reasoning: chunk,
|
||||
reasoningStreaming: true,
|
||||
...(activitySegmentId ? { activitySegmentId } : {}),
|
||||
};
|
||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
||||
}
|
||||
break;
|
||||
}
|
||||
const activitySegmentId = segments?.ensure();
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
@@ -82,6 +107,7 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
|
||||
isStreaming: true,
|
||||
reasoning: chunk,
|
||||
reasoningStreaming: true,
|
||||
...(activitySegmentId ? { activitySegmentId } : {}),
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
@@ -95,13 +121,19 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
|
||||
* the model already produced an answer in a previous turn, so the new
|
||||
* delta belongs in a fresh row.
|
||||
*/
|
||||
function findActiveAssistantPlaceholder(prev: UIMessage[]): string | null {
|
||||
function findActiveAssistantPlaceholderIndex(prev: UIMessage[]): number | null {
|
||||
const last = prev[prev.length - 1];
|
||||
if (!last) return null;
|
||||
if (last.role !== "assistant" || last.kind === "trace") return null;
|
||||
if (last.content.length > 0) return null;
|
||||
if (!last.isStreaming) return null;
|
||||
return last.id;
|
||||
return prev.length - 1;
|
||||
}
|
||||
|
||||
function replaceMessageAt(prev: UIMessage[], index: number, message: UIMessage): UIMessage[] {
|
||||
const next = prev.slice();
|
||||
next[index] = message;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,6 +214,47 @@ function absorbCompleteAssistantMessage(
|
||||
];
|
||||
}
|
||||
|
||||
function fileEditKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): string {
|
||||
return `${edit.call_id}|${edit.tool}|${edit.path}`;
|
||||
}
|
||||
|
||||
function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
|
||||
if (!edit || !edit.path || !edit.tool) return null;
|
||||
const inferredStatus =
|
||||
edit.phase === "error"
|
||||
? "error"
|
||||
: edit.phase === "end"
|
||||
? "done"
|
||||
: "editing";
|
||||
return {
|
||||
...edit,
|
||||
call_id: edit.call_id || `${edit.tool}:${edit.path}`,
|
||||
added: Number.isFinite(edit.added) ? Math.max(0, Math.round(edit.added)) : 0,
|
||||
deleted: Number.isFinite(edit.deleted) ? Math.max(0, Math.round(edit.deleted)) : 0,
|
||||
status: edit.status === "error" || edit.status === "done" || edit.status === "editing"
|
||||
? edit.status
|
||||
: inferredStatus,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit[]): UIFileEdit[] {
|
||||
const next = [...(existing ?? [])];
|
||||
const indexByKey = new Map(next.map((edit, index) => [fileEditKey(edit), index]));
|
||||
for (const raw of incoming) {
|
||||
const edit = normalizeFileEdit(raw);
|
||||
if (!edit) continue;
|
||||
const key = fileEditKey(edit);
|
||||
const existingIndex = indexByKey.get(key);
|
||||
if (existingIndex === undefined) {
|
||||
indexByKey.set(key, next.length);
|
||||
next.push(edit);
|
||||
continue;
|
||||
}
|
||||
next[existingIndex] = { ...next[existingIndex], ...edit };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a chat by ID. Returns the in-memory message list for the chat,
|
||||
* a streaming flag, and a ``send`` function. Initial history must be seeded
|
||||
@@ -239,6 +312,13 @@ export function useNanobotStream(
|
||||
const [goalState, setGoalState] = useState<GoalStateWsPayload | undefined>(undefined);
|
||||
const [streamError, setStreamError] = useState<StreamError | null>(null);
|
||||
const buffer = useRef<StreamBuffer | null>(null);
|
||||
const activeAssistantRef = useRef<ActiveAssistantCursor | null>(null);
|
||||
const closedAssistantStreamIdsRef = useRef<Set<string>>(new Set());
|
||||
const activitySegmentRef = useRef<string | null>(null);
|
||||
const fileEditSegmentRef = useRef<string | null>(null);
|
||||
const activitySegmentCounterRef = useRef(0);
|
||||
const pendingStreamEventsRef = useRef<PendingStreamEvent[]>([]);
|
||||
const streamFrameRef = useRef<number | null>(null);
|
||||
const suppressStreamUntilTurnEndRef = useRef(false);
|
||||
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
|
||||
*
|
||||
@@ -255,6 +335,159 @@ export function useNanobotStream(
|
||||
|
||||
const dismissStreamError = useCallback(() => setStreamError(null), []);
|
||||
|
||||
const clearPendingStreamWork = useCallback(() => {
|
||||
if (streamFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(streamFrameRef.current);
|
||||
streamFrameRef.current = null;
|
||||
}
|
||||
pendingStreamEventsRef.current = [];
|
||||
}, []);
|
||||
|
||||
const createActivitySegmentId = useCallback((activate = true) => {
|
||||
activitySegmentCounterRef.current += 1;
|
||||
const id = `activity-${activitySegmentCounterRef.current}`;
|
||||
if (activate) activitySegmentRef.current = id;
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
const freshActivitySegmentId = useCallback(
|
||||
() => createActivitySegmentId(true),
|
||||
[createActivitySegmentId],
|
||||
);
|
||||
|
||||
const detachedActivitySegmentId = useCallback(
|
||||
() => createActivitySegmentId(false),
|
||||
[createActivitySegmentId],
|
||||
);
|
||||
|
||||
const ensureActivitySegmentId = useCallback(() => {
|
||||
if (activitySegmentRef.current) return activitySegmentRef.current;
|
||||
return freshActivitySegmentId();
|
||||
}, [freshActivitySegmentId]);
|
||||
|
||||
const clearActivitySegment = useCallback(() => {
|
||||
activitySegmentRef.current = null;
|
||||
fileEditSegmentRef.current = null;
|
||||
}, []);
|
||||
|
||||
const closeActiveAssistantStream = useCallback(() => {
|
||||
const closedStreamId = buffer.current?.messageId ?? activeAssistantRef.current?.id;
|
||||
if (closedStreamId) closedAssistantStreamIdsRef.current.add(closedStreamId);
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
}, []);
|
||||
|
||||
const resolveActiveAssistantIndex = useCallback((prev: UIMessage[]): number | null => {
|
||||
const cursor = activeAssistantRef.current;
|
||||
if (!cursor) return null;
|
||||
const indexed = prev[cursor.index];
|
||||
if (indexed?.id === cursor.id && indexed.role === "assistant" && indexed.kind !== "trace") {
|
||||
return cursor.index;
|
||||
}
|
||||
const idx = prev.findIndex((m) => m.id === cursor.id);
|
||||
if (idx === -1) {
|
||||
activeAssistantRef.current = null;
|
||||
return null;
|
||||
}
|
||||
const found = prev[idx];
|
||||
if (found.role !== "assistant" || found.kind === "trace") {
|
||||
activeAssistantRef.current = null;
|
||||
return null;
|
||||
}
|
||||
activeAssistantRef.current = { id: cursor.id, index: idx };
|
||||
return idx;
|
||||
}, []);
|
||||
|
||||
const appendAnswerChunk = useCallback(
|
||||
(prev: UIMessage[], chunk: string): UIMessage[] => {
|
||||
let next = prev;
|
||||
let targetIndex = resolveActiveAssistantIndex(next);
|
||||
|
||||
if (targetIndex === null) {
|
||||
targetIndex = findActiveAssistantPlaceholderIndex(next);
|
||||
}
|
||||
if (targetIndex === null) {
|
||||
targetIndex = findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current);
|
||||
}
|
||||
if (targetIndex === null) {
|
||||
const id = crypto.randomUUID();
|
||||
next = [
|
||||
...next,
|
||||
{
|
||||
id,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
isStreaming: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
targetIndex = next.length - 1;
|
||||
}
|
||||
|
||||
const target = next[targetIndex];
|
||||
const merged: UIMessage = {
|
||||
...target,
|
||||
content: target.content + chunk,
|
||||
isStreaming: true,
|
||||
};
|
||||
closedAssistantStreamIdsRef.current.delete(merged.id);
|
||||
activeAssistantRef.current = { id: merged.id, index: targetIndex };
|
||||
buffer.current = { messageId: merged.id };
|
||||
return replaceMessageAt(next, targetIndex, merged);
|
||||
},
|
||||
[resolveActiveAssistantIndex],
|
||||
);
|
||||
|
||||
const applyPendingStreamEvents = useCallback(
|
||||
(prev: UIMessage[], events: PendingStreamEvent[]): UIMessage[] => {
|
||||
let next = prev;
|
||||
for (let i = 0; i < events.length;) {
|
||||
const kind = events[i].kind;
|
||||
let text = "";
|
||||
while (i < events.length && events[i].kind === kind) {
|
||||
text += events[i].text;
|
||||
i += 1;
|
||||
}
|
||||
next = kind === "delta"
|
||||
? appendAnswerChunk(next, text)
|
||||
: attachReasoningChunk(next, text, {
|
||||
ensure: ensureActivitySegmentId,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
},
|
||||
[appendAnswerChunk, ensureActivitySegmentId],
|
||||
);
|
||||
|
||||
const flushPendingStreamEvents = useCallback((options?: { closeAnswerSegment?: boolean }) => {
|
||||
if (streamFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(streamFrameRef.current);
|
||||
streamFrameRef.current = null;
|
||||
}
|
||||
const events = pendingStreamEventsRef.current;
|
||||
if (events.length === 0) {
|
||||
if (options?.closeAnswerSegment) closeActiveAssistantStream();
|
||||
return;
|
||||
}
|
||||
pendingStreamEventsRef.current = [];
|
||||
setMessages((prev) => {
|
||||
const next = applyPendingStreamEvents(prev, events);
|
||||
if (options?.closeAnswerSegment) closeActiveAssistantStream();
|
||||
return next;
|
||||
});
|
||||
}, [applyPendingStreamEvents, closeActiveAssistantStream]);
|
||||
|
||||
const schedulePendingStreamFlush = useCallback(() => {
|
||||
if (streamFrameRef.current !== null) return;
|
||||
streamFrameRef.current = window.requestAnimationFrame(() => {
|
||||
streamFrameRef.current = null;
|
||||
const events = pendingStreamEventsRef.current;
|
||||
if (events.length === 0) return;
|
||||
pendingStreamEventsRef.current = [];
|
||||
setMessages((prev) => applyPendingStreamEvents(prev, events));
|
||||
});
|
||||
}, [applyPendingStreamEvents]);
|
||||
|
||||
// Reset local state when switching chats. Do not reset on every
|
||||
// ``initialMessages`` update: a brand-new chat can receive an empty/404
|
||||
// history response after the optimistic first message has already rendered.
|
||||
@@ -269,13 +502,17 @@ export function useNanobotStream(
|
||||
setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null);
|
||||
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
closedAssistantStreamIdsRef.current.clear();
|
||||
clearActivitySegment();
|
||||
clearPendingStreamWork();
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
if (streamEndTimerRef.current !== null) {
|
||||
clearTimeout(streamEndTimerRef.current);
|
||||
streamEndTimerRef.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatId, client]);
|
||||
}, [chatId, client, clearActivitySegment, clearPendingStreamWork]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPendingToolCalls) setIsStreaming(true);
|
||||
@@ -296,54 +533,10 @@ export function useNanobotStream(
|
||||
if (ev.event === "delta") {
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
const chunk = typeof ev.text === "string" ? ev.text : "";
|
||||
if (!chunk) return;
|
||||
setIsStreaming(true);
|
||||
setMessages((prev) => {
|
||||
const adopted = findActiveAssistantPlaceholder(prev);
|
||||
const streamingAssistId = findStreamingAssistantId(prev);
|
||||
let targetId: string;
|
||||
let next: UIMessage[];
|
||||
|
||||
if (adopted) {
|
||||
targetId = adopted;
|
||||
next = prev;
|
||||
} else if (streamingAssistId) {
|
||||
targetId = streamingAssistId;
|
||||
next = prev;
|
||||
} else {
|
||||
targetId = crypto.randomUUID();
|
||||
next = [
|
||||
...prev,
|
||||
{
|
||||
id: targetId,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
isStreaming: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
buffer.current = { messageId: targetId };
|
||||
|
||||
const priorContent = next.find((m) => m.id === targetId)?.content ?? "";
|
||||
const combined = priorContent + chunk;
|
||||
return next.map((m) =>
|
||||
m.id === targetId ? { ...m, content: combined, isStreaming: true } : m,
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.event === "stream_end") {
|
||||
if (suppressStreamUntilTurnEndRef.current) {
|
||||
buffer.current = null;
|
||||
return;
|
||||
}
|
||||
// stream_end only means the text segment finished — the model may
|
||||
// still be executing tools. Do NOT reset isStreaming here; the
|
||||
// definitive "turn is complete" signal is ``turn_end``.
|
||||
if (!buffer.current) return;
|
||||
buffer.current = null;
|
||||
pendingStreamEventsRef.current.push({ kind: "delta", text: chunk });
|
||||
schedulePendingStreamFlush();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -351,11 +544,23 @@ export function useNanobotStream(
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
const chunk = ev.text;
|
||||
if (!chunk) return;
|
||||
setMessages((prev) => attachReasoningChunk(prev, chunk));
|
||||
setIsStreaming(true);
|
||||
pendingStreamEventsRef.current.push({ kind: "reasoning", text: chunk });
|
||||
schedulePendingStreamFlush();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.event === "stream_end") {
|
||||
flushPendingStreamEvents({ closeAnswerSegment: true });
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
// stream_end only means the text segment finished — the model may
|
||||
// still be executing tools. Do NOT reset isStreaming here; the
|
||||
// definitive "turn is complete" signal is ``turn_end``.
|
||||
return;
|
||||
}
|
||||
|
||||
flushPendingStreamEvents();
|
||||
|
||||
if (ev.event === "reasoning_end") {
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
setMessages((prev) => closeReasoningStream(prev));
|
||||
@@ -393,6 +598,10 @@ export function useNanobotStream(
|
||||
if (typeof ev.latency_ms === "number" && ev.latency_ms >= 0) {
|
||||
finalized = stampLastAssistantLatency(finalized, Math.round(ev.latency_ms));
|
||||
}
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
clearActivitySegment();
|
||||
closedAssistantStreamIdsRef.current.clear();
|
||||
return finalized;
|
||||
});
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
@@ -413,7 +622,9 @@ export function useNanobotStream(
|
||||
if (ev.kind === "reasoning") {
|
||||
const line = ev.text;
|
||||
if (!line) return;
|
||||
setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line)));
|
||||
setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line, {
|
||||
ensure: ensureActivitySegmentId,
|
||||
})));
|
||||
return;
|
||||
}
|
||||
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
|
||||
@@ -428,12 +639,24 @@ export function useNanobotStream(
|
||||
: [];
|
||||
if (lines.length === 0) return;
|
||||
setMessages((prev) => {
|
||||
const segmentId = ensureActivitySegmentId();
|
||||
const last = prev[prev.length - 1];
|
||||
if (last && last.kind === "trace" && !last.isStreaming) {
|
||||
if (
|
||||
last
|
||||
&& last.kind === "trace"
|
||||
&& !last.isStreaming
|
||||
&& (!last.activitySegmentId || last.activitySegmentId === segmentId)
|
||||
) {
|
||||
const previousTraces = last.traces?.length
|
||||
? last.traces
|
||||
: last.content
|
||||
? [last.content]
|
||||
: [];
|
||||
const merged: UIMessage = {
|
||||
...last,
|
||||
traces: [...(last.traces ?? [last.content]), ...lines],
|
||||
traces: [...previousTraces, ...lines],
|
||||
content: lines[lines.length - 1],
|
||||
activitySegmentId: last.activitySegmentId ?? segmentId,
|
||||
};
|
||||
return [...prev.slice(0, -1), merged];
|
||||
}
|
||||
@@ -445,6 +668,7 @@ export function useNanobotStream(
|
||||
kind: "trace",
|
||||
content: lines[lines.length - 1],
|
||||
traces: lines,
|
||||
activitySegmentId: segmentId,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
@@ -459,11 +683,12 @@ export function useNanobotStream(
|
||||
|
||||
// A complete (non-streamed) assistant message. If a stream was in
|
||||
// flight, drop the placeholder so we don't render the text twice.
|
||||
const activeId = buffer.current?.messageId;
|
||||
buffer.current = null;
|
||||
// Do NOT reset isStreaming here — only ``turn_end`` signals that
|
||||
// the full turn (all tool calls + final text) is complete.
|
||||
setMessages((prev) => {
|
||||
const activeId = buffer.current?.messageId;
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
|
||||
const content = ev.text;
|
||||
const lat =
|
||||
@@ -481,6 +706,46 @@ export function useNanobotStream(
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ev.event === "file_edit") {
|
||||
const edits = Array.isArray(ev.edits) ? ev.edits : [];
|
||||
if (edits.length === 0) return;
|
||||
setMessages((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
let segmentId = fileEditSegmentRef.current;
|
||||
if (!segmentId || !(last?.kind === "trace" && last.fileEdits?.length)) {
|
||||
segmentId = detachedActivitySegmentId();
|
||||
fileEditSegmentRef.current = segmentId;
|
||||
}
|
||||
if (
|
||||
last
|
||||
&& last.kind === "trace"
|
||||
&& !last.isStreaming
|
||||
&& !!last.fileEdits?.length
|
||||
&& last.activitySegmentId === segmentId
|
||||
) {
|
||||
const merged: UIMessage = {
|
||||
...last,
|
||||
fileEdits: mergeFileEdits(last.fileEdits, edits),
|
||||
activitySegmentId: last.activitySegmentId ?? segmentId,
|
||||
};
|
||||
return [...prev.slice(0, -1), merged];
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "",
|
||||
traces: [],
|
||||
fileEdits: mergeFileEdits(undefined, edits),
|
||||
activitySegmentId: segmentId,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
});
|
||||
return;
|
||||
}
|
||||
// ``attached`` / ``error`` frames aren't actionable here; the client
|
||||
// shell handles them separately.
|
||||
};
|
||||
@@ -489,12 +754,26 @@ export function useNanobotStream(
|
||||
return () => {
|
||||
unsub();
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
closedAssistantStreamIdsRef.current.clear();
|
||||
clearActivitySegment();
|
||||
clearPendingStreamWork();
|
||||
if (streamEndTimerRef.current !== null) {
|
||||
clearTimeout(streamEndTimerRef.current);
|
||||
streamEndTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [chatId, client, onTurnEnd]);
|
||||
}, [
|
||||
chatId,
|
||||
client,
|
||||
clearActivitySegment,
|
||||
clearPendingStreamWork,
|
||||
detachedActivitySegmentId,
|
||||
ensureActivitySegmentId,
|
||||
flushPendingStreamEvents,
|
||||
onTurnEnd,
|
||||
schedulePendingStreamFlush,
|
||||
]);
|
||||
|
||||
const send = useCallback(
|
||||
(content: string, images?: SendImage[], options?: SendOptions) => {
|
||||
@@ -504,17 +783,24 @@ export function useNanobotStream(
|
||||
// the image blocks via ``media`` paths.
|
||||
if (!hasImages && !content.trim()) return;
|
||||
|
||||
flushPendingStreamEvents();
|
||||
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
|
||||
setMessages((prev) => [
|
||||
...pruneReasoningOnlyPlaceholders(prev),
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
content,
|
||||
createdAt: Date.now(),
|
||||
...(previews ? { images: previews } : {}),
|
||||
},
|
||||
]);
|
||||
setMessages((prev) => {
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
closedAssistantStreamIdsRef.current.clear();
|
||||
clearActivitySegment();
|
||||
return [
|
||||
...pruneReasoningOnlyPlaceholders(prev),
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
content,
|
||||
createdAt: Date.now(),
|
||||
...(previews ? { images: previews } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
// Mark streaming immediately so the UI shows the loading indicator
|
||||
// right away, before the first delta arrives from the server.
|
||||
setIsStreaming(true);
|
||||
@@ -525,18 +811,23 @@ export function useNanobotStream(
|
||||
client.sendMessage(chatId, content, wireMedia);
|
||||
}
|
||||
},
|
||||
[chatId, client],
|
||||
[chatId, clearActivitySegment, client, flushPendingStreamEvents],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (!chatId) return;
|
||||
flushPendingStreamEvents();
|
||||
setIsStreaming(false);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
|
||||
);
|
||||
setMessages((prev) => {
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
closedAssistantStreamIdsRef.current.clear();
|
||||
clearActivitySegment();
|
||||
return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
|
||||
});
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
client.sendMessage(chatId, "/stop");
|
||||
}, [chatId, client]);
|
||||
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
createContext,
|
||||
createElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
const STORAGE_KEY = "nanobot-webui.theme";
|
||||
const ThemeContext = createContext<Theme>("light");
|
||||
|
||||
function readStored(): Theme | null {
|
||||
try {
|
||||
@@ -18,7 +27,11 @@ function applyTheme(theme: Theme): void {
|
||||
else root.classList.remove("dark");
|
||||
}
|
||||
|
||||
export function useTheme(): { theme: Theme; toggle: () => void; setTheme: (t: Theme) => void } {
|
||||
export function useTheme(): {
|
||||
theme: Theme;
|
||||
toggle: () => void;
|
||||
setTheme: (t: Theme) => void;
|
||||
} {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
const stored = readStored();
|
||||
if (stored) return stored;
|
||||
@@ -46,3 +59,11 @@ export function useTheme(): { theme: Theme; toggle: () => void; setTheme: (t: Th
|
||||
);
|
||||
return { theme, toggle, setTheme };
|
||||
}
|
||||
|
||||
export function ThemeProvider({ theme, children }: { theme: Theme; children: ReactNode }) {
|
||||
return createElement(ThemeContext.Provider, { value: theme }, children);
|
||||
}
|
||||
|
||||
export function useThemeValue(): Theme {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
|
||||
@@ -335,7 +335,8 @@
|
||||
"io": "Couldn't read this file"
|
||||
}
|
||||
},
|
||||
"scrollToBottom": "Scroll to bottom"
|
||||
"scrollToBottom": "Scroll to bottom",
|
||||
"loadEarlier": "Load earlier messages"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "streaming",
|
||||
|
||||
@@ -303,7 +303,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "Cerrar objetivo"
|
||||
},
|
||||
"scrollToBottom": "Desplazarse al final"
|
||||
"scrollToBottom": "Desplazarse al final",
|
||||
"loadEarlier": "Cargar mensajes anteriores"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "transmitiendo",
|
||||
|
||||
@@ -303,7 +303,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "Fermer l’objectif"
|
||||
},
|
||||
"scrollToBottom": "Faire défiler vers le bas"
|
||||
"scrollToBottom": "Faire défiler vers le bas",
|
||||
"loadEarlier": "Charger les messages précédents"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "en cours de génération",
|
||||
|
||||
@@ -303,7 +303,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "Tutup tujuan"
|
||||
},
|
||||
"scrollToBottom": "Gulir ke bawah"
|
||||
"scrollToBottom": "Gulir ke bawah",
|
||||
"loadEarlier": "Muat pesan sebelumnya"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "sedang mengalir",
|
||||
|
||||
@@ -303,7 +303,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "目標を閉じる"
|
||||
},
|
||||
"scrollToBottom": "一番下へスクロール"
|
||||
"scrollToBottom": "一番下へスクロール",
|
||||
"loadEarlier": "以前のメッセージを読み込む"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "生成中",
|
||||
|
||||
@@ -303,7 +303,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "목표 닫기"
|
||||
},
|
||||
"scrollToBottom": "맨 아래로 스크롤"
|
||||
"scrollToBottom": "맨 아래로 스크롤",
|
||||
"loadEarlier": "이전 메시지 불러오기"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "생성 중",
|
||||
|
||||
@@ -303,7 +303,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "Đóng mục tiêu"
|
||||
},
|
||||
"scrollToBottom": "Cuộn xuống cuối"
|
||||
"scrollToBottom": "Cuộn xuống cuối",
|
||||
"loadEarlier": "Tải tin nhắn trước đó"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "đang truyền",
|
||||
|
||||
@@ -323,7 +323,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "关闭目标"
|
||||
},
|
||||
"scrollToBottom": "滚动到底部"
|
||||
"scrollToBottom": "滚动到底部",
|
||||
"loadEarlier": "加载更早消息"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "流式输出中",
|
||||
|
||||
@@ -303,7 +303,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "關閉目標"
|
||||
},
|
||||
"scrollToBottom": "捲動到底部"
|
||||
"scrollToBottom": "捲動到底部",
|
||||
"loadEarlier": "載入更早訊息"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "串流輸出中",
|
||||
|
||||
@@ -1,10 +1,35 @@
|
||||
import i18n, { currentLocale } from "@/i18n";
|
||||
|
||||
const LOW_INFORMATION_TITLE_PREVIEWS = new Set([
|
||||
"hi",
|
||||
"hello",
|
||||
"hey",
|
||||
"hello nano",
|
||||
"hello nanobot",
|
||||
"hi nano",
|
||||
"hi nanobot",
|
||||
"你好",
|
||||
"您好",
|
||||
"嗨",
|
||||
"哈喽",
|
||||
"哈啰",
|
||||
"在吗",
|
||||
]);
|
||||
|
||||
function isLowInformationTitlePreview(text: string): boolean {
|
||||
const normalized = text.toLowerCase().replace(/[.!?。!?~~\s]+$/g, "").trim();
|
||||
return (
|
||||
normalized.startsWith("/") ||
|
||||
LOW_INFORMATION_TITLE_PREVIEWS.has(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
/** Truncate the first user message into a chat title. */
|
||||
export function deriveTitle(preview: string | undefined, fallback: string): string {
|
||||
if (!preview) return fallback;
|
||||
const oneLine = preview.replace(/\s+/g, " ").trim();
|
||||
if (!oneLine) return fallback;
|
||||
if (isLowInformationTitlePreview(oneLine)) return fallback;
|
||||
return oneLine.length > 60 ? `${oneLine.slice(0, 57)}…` : oneLine;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ type Unsubscribe = () => void;
|
||||
type EventHandler = (ev: InboundEvent) => void;
|
||||
type StatusHandler = (status: ConnectionStatus) => void;
|
||||
type RuntimeModelHandler = (modelName: string | null, modelPreset?: string | null) => void;
|
||||
type SessionUpdateHandler = (chatId: string) => void;
|
||||
type SessionUpdateScope = "metadata" | "thread" | string;
|
||||
type SessionUpdateHandler = (chatId: string, scope?: SessionUpdateScope) => void;
|
||||
|
||||
/** Structured connection-level errors surfaced to the UI.
|
||||
*
|
||||
@@ -364,7 +365,7 @@ export class NanobotClient {
|
||||
}
|
||||
|
||||
if (parsed.event === "session_updated") {
|
||||
this.emitSessionUpdate(parsed.chat_id);
|
||||
this.emitSessionUpdate(parsed.chat_id, parsed.scope);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -382,9 +383,9 @@ export class NanobotClient {
|
||||
}
|
||||
}
|
||||
|
||||
private emitSessionUpdate(chatId: string): void {
|
||||
private emitSessionUpdate(chatId: string, scope?: SessionUpdateScope): void {
|
||||
for (const handler of this.sessionUpdateHandlers) {
|
||||
handler(chatId);
|
||||
handler(chatId, scope);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-1
@@ -40,6 +40,10 @@ export interface UIMessage {
|
||||
/** For trace rows: each individual hint line, so consecutive hints can
|
||||
* render as a single collapsible group. */
|
||||
traces?: string[];
|
||||
/** Activity rows: explicit file edits emitted by edit tools. */
|
||||
fileEdits?: UIFileEdit[];
|
||||
/** Activity rows created during the same agent phase share one collapsible block. */
|
||||
activitySegmentId?: string;
|
||||
/** User turn: optimistic blob URLs for preview. Replay: placeholder chips. */
|
||||
images?: UIImage[];
|
||||
/** Signed or local UI-renderable media attachments. */
|
||||
@@ -80,6 +84,20 @@ export interface ToolProgressEvent {
|
||||
embeds?: unknown[];
|
||||
}
|
||||
|
||||
export interface UIFileEdit {
|
||||
version?: number;
|
||||
call_id: string;
|
||||
tool: string;
|
||||
path: string;
|
||||
phase?: "start" | "end" | "error" | string;
|
||||
added: number;
|
||||
deleted: number;
|
||||
approximate?: boolean;
|
||||
status: "editing" | "done" | "error";
|
||||
binary?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ChatSummary {
|
||||
/** Server-side session key, e.g. ``websocket:abcd-...``. */
|
||||
key: string;
|
||||
@@ -110,6 +128,7 @@ export interface SettingsPayload {
|
||||
name: string;
|
||||
label: string;
|
||||
configured: boolean;
|
||||
api_key_required?: boolean;
|
||||
api_key_hint?: string | null;
|
||||
api_base?: string | null;
|
||||
default_api_base?: string | null;
|
||||
@@ -182,6 +201,11 @@ export type InboundEvent =
|
||||
/** Optional structured payload on progress frames (channel-specific). */
|
||||
agent_ui?: AgentUIBlob;
|
||||
}
|
||||
| {
|
||||
event: "file_edit";
|
||||
chat_id: string;
|
||||
edits: UIFileEdit[];
|
||||
}
|
||||
| {
|
||||
event: "delta";
|
||||
chat_id: string;
|
||||
@@ -229,7 +253,7 @@ export type InboundEvent =
|
||||
chat_id: string;
|
||||
goal_state: GoalStateWsPayload;
|
||||
}
|
||||
| { event: "session_updated"; chat_id: string }
|
||||
| { event: "session_updated"; chat_id: string; scope?: "metadata" | "thread" | string }
|
||||
| { event: "error"; chat_id?: string; detail?: string };
|
||||
|
||||
/** Base64-encoded image attached to an outbound ``message`` envelope.
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
function activityMessages(extraReasoning = "", extraTool?: UIMessage): UIMessage[] {
|
||||
const rows: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: `thinking${extraReasoning}`,
|
||||
reasoningStreaming: true,
|
||||
isStreaming: true,
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "search()",
|
||||
traces: ["search()"],
|
||||
createdAt: 2,
|
||||
},
|
||||
];
|
||||
if (extraTool) rows.push(extraTool);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function installAnimationFrameQueue() {
|
||||
const originalRequest = window.requestAnimationFrame;
|
||||
const originalCancel = window.cancelAnimationFrame;
|
||||
const callbacks = new Map<number, FrameRequestCallback>();
|
||||
let nextId = 1;
|
||||
|
||||
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||
const id = nextId;
|
||||
nextId += 1;
|
||||
callbacks.set(id, callback);
|
||||
return id;
|
||||
}) as typeof window.requestAnimationFrame;
|
||||
window.cancelAnimationFrame = ((id: number) => {
|
||||
callbacks.delete(id);
|
||||
}) as typeof window.cancelAnimationFrame;
|
||||
|
||||
return {
|
||||
flush() {
|
||||
const pending = Array.from(callbacks.entries());
|
||||
callbacks.clear();
|
||||
for (const [, callback] of pending) callback(0);
|
||||
},
|
||||
restore() {
|
||||
window.requestAnimationFrame = originalRequest;
|
||||
window.cancelAnimationFrame = originalCancel;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setScrollGeometry(
|
||||
element: HTMLElement,
|
||||
geometry: { scrollHeight: number; clientHeight: number; scrollTop?: number },
|
||||
) {
|
||||
Object.defineProperties(element, {
|
||||
scrollHeight: { configurable: true, value: geometry.scrollHeight },
|
||||
clientHeight: { configurable: true, value: geometry.clientHeight },
|
||||
scrollTop: {
|
||||
configurable: true,
|
||||
value: geometry.scrollTop ?? element.scrollTop,
|
||||
writable: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function installReducedMotion() {
|
||||
const original = window.matchMedia;
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
matches: true,
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}),
|
||||
});
|
||||
return () => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: original,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentActivityCluster", () => {
|
||||
it("jumps to the latest activity when opened", () => {
|
||||
const raf = installAnimationFrameQueue();
|
||||
try {
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages()}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /working/i }));
|
||||
const scrollport = screen.getByTestId("agent-activity-scroll");
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 120,
|
||||
scrollTop: 0,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
raf.flush();
|
||||
});
|
||||
|
||||
expect(scrollport.scrollTop).toBe(880);
|
||||
} finally {
|
||||
raf.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("follows new reasoning and tool activity while the user is at the bottom", () => {
|
||||
const raf = installAnimationFrameQueue();
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages()}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /working/i }));
|
||||
const scrollport = screen.getByTestId("agent-activity-scroll");
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 120,
|
||||
scrollTop: 0,
|
||||
});
|
||||
act(() => {
|
||||
raf.flush();
|
||||
});
|
||||
|
||||
rerender(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages(" with more detail", {
|
||||
id: "t2",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "open_browser()",
|
||||
traces: ["open_browser()"],
|
||||
createdAt: 3,
|
||||
})}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1500,
|
||||
clientHeight: 120,
|
||||
scrollTop: scrollport.scrollTop,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
raf.flush();
|
||||
});
|
||||
|
||||
expect(scrollport.scrollTop).toBe(1380);
|
||||
} finally {
|
||||
raf.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not pull the user down after they scroll up inside the activity pane", () => {
|
||||
const raf = installAnimationFrameQueue();
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages()}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /working/i }));
|
||||
const scrollport = screen.getByTestId("agent-activity-scroll");
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 120,
|
||||
scrollTop: 0,
|
||||
});
|
||||
act(() => {
|
||||
raf.flush();
|
||||
});
|
||||
|
||||
scrollport.scrollTop = 100;
|
||||
fireEvent.scroll(scrollport);
|
||||
|
||||
rerender(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages(" still streaming")}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1500,
|
||||
clientHeight: 120,
|
||||
scrollTop: scrollport.scrollTop,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
raf.flush();
|
||||
});
|
||||
|
||||
expect(scrollport.scrollTop).toBe(100);
|
||||
} finally {
|
||||
raf.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders file edit totals and a compact expanded file list", async () => {
|
||||
const restoreMotion = installReducedMotion();
|
||||
try {
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages("", {
|
||||
id: "t2",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "edit_file()",
|
||||
traces: ["edit_file()"],
|
||||
fileEdits: [{
|
||||
call_id: "call-edit",
|
||||
tool: "edit_file",
|
||||
path: "src/app.tsx",
|
||||
phase: "end",
|
||||
added: 12,
|
||||
deleted: 3,
|
||||
approximate: false,
|
||||
status: "done",
|
||||
}],
|
||||
createdAt: 3,
|
||||
})}
|
||||
isTurnStreaming={false}
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /edited app\.tsx/i })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edited app\.tsx/i }));
|
||||
|
||||
expect(screen.queryByText("Edited files")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Edited")).not.toBeInTheDocument();
|
||||
const fileRef = screen.getByTestId("activity-file-reference");
|
||||
expect(fileRef).toHaveTextContent("src/app.tsx");
|
||||
expect(fileRef).toHaveAttribute("aria-label", "src/app.tsx");
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("+12").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("-3").length).toBeGreaterThan(0);
|
||||
});
|
||||
} finally {
|
||||
restoreMotion();
|
||||
}
|
||||
});
|
||||
|
||||
it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
|
||||
const restoreMotion = installReducedMotion();
|
||||
try {
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages("", {
|
||||
id: "t2",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "edit_file()",
|
||||
traces: ["edit_file()"],
|
||||
fileEdits: [
|
||||
{
|
||||
call_id: "call-edit-1",
|
||||
tool: "edit_file",
|
||||
path: "minecraft-fps/index.html",
|
||||
phase: "end",
|
||||
added: 2,
|
||||
deleted: 1,
|
||||
approximate: false,
|
||||
status: "done",
|
||||
},
|
||||
{
|
||||
call_id: "call-edit-2",
|
||||
tool: "edit_file",
|
||||
path: "minecraft-fps/index.html",
|
||||
phase: "error",
|
||||
added: 0,
|
||||
deleted: 0,
|
||||
approximate: false,
|
||||
status: "error",
|
||||
error: "patch failed",
|
||||
},
|
||||
{
|
||||
call_id: "call-edit-3",
|
||||
tool: "edit_file",
|
||||
path: "minecraft-fps/index.html",
|
||||
phase: "end",
|
||||
added: 6,
|
||||
deleted: 6,
|
||||
approximate: false,
|
||||
status: "done",
|
||||
},
|
||||
],
|
||||
createdAt: 3,
|
||||
})}
|
||||
isTurnStreaming={false}
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /edited index\.html/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /failed index\.html/i })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edited index\.html/i }));
|
||||
|
||||
const fileRefs = screen.getAllByTestId("activity-file-reference");
|
||||
expect(fileRefs).toHaveLength(1);
|
||||
expect(fileRefs[0]).toHaveTextContent("minecraft-fps/index.html");
|
||||
expect(screen.queryByText("Failed")).not.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("+8").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("-7").length).toBeGreaterThan(0);
|
||||
});
|
||||
} finally {
|
||||
restoreMotion();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
@@ -8,6 +8,7 @@ const refreshSpy = vi.fn();
|
||||
const createChatSpy = vi.fn().mockResolvedValue("chat-1");
|
||||
const deleteChatSpy = vi.fn();
|
||||
const toggleThemeSpy = vi.fn();
|
||||
const updateUrlSpy = vi.fn();
|
||||
let mockSessions: ChatSummary[] = [];
|
||||
|
||||
vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
||||
@@ -32,12 +33,18 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/useTheme", () => ({
|
||||
useTheme: () => ({
|
||||
theme: "light" as const,
|
||||
toggle: toggleThemeSpy,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/useTheme", async () => {
|
||||
const React = await import("react");
|
||||
return {
|
||||
ThemeProvider: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
useTheme: () => ({
|
||||
theme: "light" as const,
|
||||
toggle: toggleThemeSpy,
|
||||
}),
|
||||
useThemeValue: () => "light" as const,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/bootstrap", () => ({
|
||||
fetchBootstrap: vi.fn().mockResolvedValue({
|
||||
@@ -64,22 +71,30 @@ vi.mock("@/lib/nanobot-client", () => {
|
||||
newChat = vi.fn();
|
||||
attach = vi.fn();
|
||||
close = vi.fn();
|
||||
updateUrl = vi.fn();
|
||||
updateUrl = updateUrlSpy;
|
||||
}
|
||||
|
||||
return { NanobotClient: MockClient };
|
||||
});
|
||||
|
||||
import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
|
||||
import App from "@/App";
|
||||
|
||||
describe("App layout", () => {
|
||||
beforeEach(() => {
|
||||
mockSessions = [];
|
||||
connectSpy.mockClear();
|
||||
updateUrlSpy.mockClear();
|
||||
refreshSpy.mockReset();
|
||||
createChatSpy.mockClear();
|
||||
deleteChatSpy.mockReset();
|
||||
toggleThemeSpy.mockReset();
|
||||
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
||||
token: "tok",
|
||||
ws_path: "/",
|
||||
expires_in: 300,
|
||||
});
|
||||
vi.mocked(deriveWsUrl).mockReset().mockReturnValue("ws://test");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
@@ -89,6 +104,10 @@ describe("App layout", () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps sidebar layout out of the main thread width contract", async () => {
|
||||
const { container } = render(<App />);
|
||||
|
||||
@@ -192,8 +211,52 @@ describe("App layout", () => {
|
||||
name: "openrouter",
|
||||
label: "OpenRouter",
|
||||
configured: false,
|
||||
api_key_required: true,
|
||||
default_api_base: "https://openrouter.ai/api/v1",
|
||||
},
|
||||
{
|
||||
name: "azure_openai",
|
||||
label: "Azure OpenAI",
|
||||
configured: false,
|
||||
api_key_required: true,
|
||||
},
|
||||
{
|
||||
name: "huggingface",
|
||||
label: "Hugging Face",
|
||||
configured: false,
|
||||
api_key_required: true,
|
||||
},
|
||||
{
|
||||
name: "siliconflow",
|
||||
label: "SiliconFlow",
|
||||
configured: false,
|
||||
api_key_required: true,
|
||||
},
|
||||
{
|
||||
name: "volcengine",
|
||||
label: "VolcEngine",
|
||||
configured: false,
|
||||
api_key_required: true,
|
||||
},
|
||||
{
|
||||
name: "byteplus",
|
||||
label: "BytePlus",
|
||||
configured: false,
|
||||
api_key_required: true,
|
||||
},
|
||||
{
|
||||
name: "qianfan",
|
||||
label: "Qianfan",
|
||||
configured: false,
|
||||
api_key_required: true,
|
||||
},
|
||||
{
|
||||
name: "atomic_chat",
|
||||
label: "Atomic Chat",
|
||||
configured: false,
|
||||
api_key_required: false,
|
||||
default_api_base: "http://localhost:1337/v1",
|
||||
},
|
||||
],
|
||||
web_search: {
|
||||
provider: "brave",
|
||||
@@ -248,6 +311,9 @@ describe("App layout", () => {
|
||||
fireEvent.click(screen.getByText("OpenAI"));
|
||||
expect(screen.getByText("open••••-key")).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("Atomic Chat"));
|
||||
expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Web Search" }));
|
||||
expect(screen.getByText("Search provider")).toBeInTheDocument();
|
||||
@@ -426,4 +492,36 @@ describe("App layout", () => {
|
||||
|
||||
expect(within(sidebar).getByText("Existing chat")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refreshes the bootstrap token before REST settings auth expires", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(fetchBootstrap)
|
||||
.mockResolvedValueOnce({
|
||||
token: "tok-1",
|
||||
ws_path: "/",
|
||||
expires_in: 30,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
token: "tok-2",
|
||||
ws_path: "/",
|
||||
expires_in: 300,
|
||||
});
|
||||
vi.mocked(deriveWsUrl).mockImplementation(
|
||||
(_wsPath: string, token: string) => `ws://test?token=${token}`,
|
||||
);
|
||||
|
||||
const { unmount } = render(<App />);
|
||||
await act(async () => {});
|
||||
|
||||
expect(connectSpy).toHaveBeenCalled();
|
||||
expect(fetchBootstrap).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
});
|
||||
|
||||
expect(fetchBootstrap).toHaveBeenCalledTimes(2);
|
||||
expect(updateUrlSpy).toHaveBeenCalledWith("ws://test?token=tok-2");
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CodeBlock } from "@/components/CodeBlock";
|
||||
import { ThemeProvider } from "@/hooks/useTheme";
|
||||
|
||||
const mockedStyles = vi.hoisted(() => ({
|
||||
dark: { pre: { background: "#111" } },
|
||||
light: { pre: { background: "#fff" } },
|
||||
}));
|
||||
|
||||
vi.mock("react-syntax-highlighter/dist/esm/prism-async-light", () => ({
|
||||
default: ({
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
children: string;
|
||||
style: Record<string, unknown>;
|
||||
}) => (
|
||||
<pre
|
||||
data-testid="highlighted-code"
|
||||
data-theme={style === mockedStyles.dark ? "dark" : "light"}
|
||||
>
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("react-syntax-highlighter/dist/esm/styles/prism/one-dark", () => ({
|
||||
default: mockedStyles.dark,
|
||||
}));
|
||||
|
||||
vi.mock("react-syntax-highlighter/dist/esm/styles/prism/one-light", () => ({
|
||||
default: mockedStyles.light,
|
||||
}));
|
||||
|
||||
describe("CodeBlock", () => {
|
||||
it("renders plain code without mounting the highlighter when highlighting is disabled", () => {
|
||||
render(
|
||||
<ThemeProvider theme="dark">
|
||||
<CodeBlock language="ts" code="const value = 1;" highlight={false} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("highlighted-code")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("const value = 1;")).toBeInTheDocument();
|
||||
expect(screen.getByText("ts")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reads theme from context without creating per-block observers", async () => {
|
||||
const originalMutationObserver = globalThis.MutationObserver;
|
||||
const observer = vi.fn();
|
||||
class MockMutationObserver {
|
||||
constructor(callback: MutationCallback) {
|
||||
observer(callback);
|
||||
}
|
||||
|
||||
observe = vi.fn();
|
||||
|
||||
disconnect = vi.fn();
|
||||
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("MutationObserver", MockMutationObserver);
|
||||
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<ThemeProvider theme="dark">
|
||||
<CodeBlock language="ts" code="const value = 1;" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("highlighted-code")).toHaveAttribute(
|
||||
"data-theme",
|
||||
"dark",
|
||||
);
|
||||
|
||||
rerender(
|
||||
<ThemeProvider theme="light">
|
||||
<CodeBlock language="ts" code="const value = 1;" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("highlighted-code")).toHaveAttribute(
|
||||
"data-theme",
|
||||
"light",
|
||||
);
|
||||
expect(observer).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.stubGlobal("MutationObserver", originalMutationObserver);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { MarkdownText } from "@/components/MarkdownText";
|
||||
|
||||
const rendererSpy = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/components/MarkdownTextRenderer", () => ({
|
||||
default: ({
|
||||
children,
|
||||
highlightCode,
|
||||
}: {
|
||||
children: string;
|
||||
highlightCode?: boolean;
|
||||
}) => {
|
||||
rendererSpy({ children, highlightCode });
|
||||
return (
|
||||
<div
|
||||
data-testid="markdown-renderer"
|
||||
data-highlight-code={String(highlightCode)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
describe("MarkdownText", () => {
|
||||
it("throttles streaming markdown commits and flushes before final highlighting", async () => {
|
||||
rendererSpy.mockClear();
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<MarkdownText streaming>hello</MarkdownText>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
|
||||
"data-highlight-code",
|
||||
"false",
|
||||
);
|
||||
expect(rendererSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<MarkdownText streaming>hello world</MarkdownText>);
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
|
||||
expect(rendererSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(79);
|
||||
});
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
|
||||
expect(rendererSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world");
|
||||
expect(rendererSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
rerender(<MarkdownText streaming>hello world!!!</MarkdownText>);
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world");
|
||||
|
||||
rerender(<MarkdownText>hello world!!!</MarkdownText>);
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world!!!");
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
|
||||
"data-highlight-code",
|
||||
"true",
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { MessageBubble } from "@/components/MessageBubble";
|
||||
@@ -131,7 +131,9 @@ describe("MessageBubble", () => {
|
||||
|
||||
expect(screen.getByText("Thinking…")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Step 1: parse intent\./)).toBeInTheDocument();
|
||||
expect(container.querySelector(".reasoning-sheen-stripe")).toBeInTheDocument();
|
||||
expect(container.querySelector(".reasoning-sheen-stripe")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Thinking…")).toHaveClass("streaming-text-sheen");
|
||||
expect(screen.getByText("Thinking…")).toHaveAttribute("data-sheen-text", "Thinking…");
|
||||
expect(screen.getByRole("button", { name: /thinking/i }).parentElement).not.toHaveClass("mb-2");
|
||||
});
|
||||
|
||||
@@ -177,6 +179,47 @@ describe("MessageBubble", () => {
|
||||
expect(screen.getByText("Body line.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders inline file paths as compact file references", async () => {
|
||||
await import("@/components/MarkdownTextRenderer");
|
||||
const message: UIMessage = {
|
||||
id: "a-file-path",
|
||||
role: "assistant",
|
||||
content:
|
||||
"改动在 `webui/src/components/MarkdownTextRenderer.tsx` 和 `/Users/renxubin/.nanobot/workspace/minecraft-fps/index.html`。",
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
render(<MessageBubble message={message} />);
|
||||
|
||||
const references = await screen.findAllByTestId("inline-file-path");
|
||||
expect(references).toHaveLength(2);
|
||||
expect(references[0].parentElement).not.toHaveClass("translate-y-[0.08em]");
|
||||
expect(references[0].parentElement).toHaveClass("align-[0.14em]");
|
||||
expect(references[0]).toHaveTextContent("MarkdownTextRenderer.tsx");
|
||||
expect(references[0]).not.toHaveTextContent("webui/src/components");
|
||||
expect(screen.getByText("index.html")).toBeInTheDocument();
|
||||
expect(references[1]).not.toHaveTextContent("/Users/renxubin");
|
||||
expect(references[1]).not.toHaveAttribute("title");
|
||||
expect(references[1]).toHaveAttribute(
|
||||
"aria-label",
|
||||
"/Users/renxubin/.nanobot/workspace/minecraft-fps/index.html",
|
||||
);
|
||||
|
||||
vi.useFakeTimers();
|
||||
fireEvent.pointerMove(references[1].parentElement!);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
const tooltip = screen.getByRole("tooltip");
|
||||
expect(tooltip).toHaveTextContent(
|
||||
"/Users/renxubin/.nanobot/workspace/minecraft-fps/index.html",
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders assistant image media as a larger generated result", () => {
|
||||
const message: UIMessage = {
|
||||
id: "a-image",
|
||||
|
||||
@@ -233,9 +233,13 @@ describe("NanobotClient", () => {
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
|
||||
lastSocket().fakeMessage({ event: "session_updated", chat_id: "chat-title" });
|
||||
lastSocket().fakeMessage({
|
||||
event: "session_updated",
|
||||
chat_id: "chat-title",
|
||||
scope: "metadata",
|
||||
});
|
||||
|
||||
expect(globalHandler).toHaveBeenCalledWith("chat-title");
|
||||
expect(globalHandler).toHaveBeenCalledWith("chat-title", "metadata");
|
||||
expect(chatHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
||||
import {
|
||||
assistantCopyFlags,
|
||||
buildDisplayUnits,
|
||||
ThreadMessages,
|
||||
} from "@/components/thread/ThreadMessages";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
describe("ThreadMessages", () => {
|
||||
@@ -51,6 +55,206 @@ describe("ThreadMessages", () => {
|
||||
expect(rows[1]).toHaveClass("mt-4");
|
||||
});
|
||||
|
||||
it("starts a new activity cluster when the activity segment changes", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "first pass",
|
||||
activitySegmentId: "seg-1",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "edit_file()",
|
||||
traces: ["edit_file()"],
|
||||
fileEdits: [{
|
||||
call_id: "call-edit",
|
||||
tool: "edit_file",
|
||||
path: "foo.txt",
|
||||
phase: "end",
|
||||
added: 2,
|
||||
deleted: 1,
|
||||
status: "done",
|
||||
}],
|
||||
activitySegmentId: "seg-1",
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "second pass",
|
||||
activitySegmentId: "seg-2",
|
||||
createdAt: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const units = buildDisplayUnits(messages);
|
||||
|
||||
expect(units).toHaveLength(2);
|
||||
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"r1",
|
||||
"t1",
|
||||
]);
|
||||
expect(units[1].type === "cluster" ? units[1].messages.map((m) => m.id) : []).toEqual([
|
||||
"r2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not split ordinary tool activity just because segment ids changed", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "first pass",
|
||||
activitySegmentId: "seg-1",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "read_file()",
|
||||
traces: ["read_file()"],
|
||||
activitySegmentId: "seg-1",
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "second pass",
|
||||
activitySegmentId: "seg-2",
|
||||
createdAt: 3,
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "grep()",
|
||||
traces: ["grep()"],
|
||||
activitySegmentId: "seg-2",
|
||||
createdAt: 4,
|
||||
},
|
||||
];
|
||||
|
||||
const units = buildDisplayUnits(messages);
|
||||
|
||||
expect(units).toHaveLength(1);
|
||||
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"r1",
|
||||
"t1",
|
||||
"r2",
|
||||
"t2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("only marks the current activity cluster as live while streaming", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "first pass",
|
||||
reasoningStreaming: true,
|
||||
activitySegmentId: "seg-1",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "edit_file()",
|
||||
traces: ["edit_file()"],
|
||||
fileEdits: [{
|
||||
call_id: "call-edit",
|
||||
tool: "edit_file",
|
||||
path: "foo.txt",
|
||||
phase: "start",
|
||||
added: 4,
|
||||
deleted: 1,
|
||||
approximate: true,
|
||||
status: "editing",
|
||||
}],
|
||||
activitySegmentId: "seg-1",
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "second pass",
|
||||
reasoningStreaming: true,
|
||||
activitySegmentId: "seg-2",
|
||||
createdAt: 3,
|
||||
},
|
||||
];
|
||||
|
||||
render(<ThreadMessages messages={messages} isStreaming />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /edited foo\.txt/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /editing foo\.txt/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /working/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("folds final answer reasoning into the preceding activity cluster", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "search plan",
|
||||
reasoningStreaming: false,
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "web_search()",
|
||||
traces: ["web_search()"],
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: "final answer",
|
||||
reasoning: "summarize results",
|
||||
reasoningStreaming: false,
|
||||
createdAt: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const units = buildDisplayUnits(messages);
|
||||
|
||||
expect(units).toHaveLength(2);
|
||||
expect(units[0]).toMatchObject({ type: "cluster" });
|
||||
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"r1",
|
||||
"t1",
|
||||
"a1-reasoning",
|
||||
]);
|
||||
expect(units[1]).toMatchObject({
|
||||
type: "single",
|
||||
message: {
|
||||
id: "a1",
|
||||
content: "final answer",
|
||||
},
|
||||
});
|
||||
if (units[1].type === "single") {
|
||||
expect(units[1].message).not.toHaveProperty("reasoning");
|
||||
}
|
||||
|
||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
expect(screen.queryByRole("button", { name: /^thinking$/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("final answer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows copy only on the last assistant slice before the next user turn", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
@@ -89,4 +293,37 @@ describe("ThreadMessages", () => {
|
||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("computes final assistant copy flags with user-boundary semantics", () => {
|
||||
const units = buildDisplayUnits([
|
||||
{ id: "u1", role: "user", content: "one", createdAt: 1 },
|
||||
{ id: "a1", role: "assistant", content: "draft", createdAt: 2 },
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "tool()",
|
||||
traces: ["tool()"],
|
||||
createdAt: 3,
|
||||
},
|
||||
{ id: "a2", role: "assistant", content: "final", createdAt: 4 },
|
||||
{ id: "u2", role: "user", content: "two", createdAt: 5 },
|
||||
{ id: "a3", role: "assistant", content: "next", createdAt: 6 },
|
||||
]);
|
||||
|
||||
const flags = assistantCopyFlags(units);
|
||||
const assistantFlags = units
|
||||
.map((unit, index) =>
|
||||
unit.type === "single" && unit.message.role === "assistant"
|
||||
? [unit.message.id, flags[index]]
|
||||
: null,
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
expect(assistantFlags).toEqual([
|
||||
["a1", false],
|
||||
["a2", true],
|
||||
["a3", true],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { UIMessage } from "@/lib/types";
|
||||
function makeClient() {
|
||||
const errorHandlers = new Set<(err: { kind: string }) => void>();
|
||||
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>();
|
||||
const sessionUpdateHandlers = new Set<(chatId: string) => void>();
|
||||
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||
const goalStateByChatId = new Map<string, import("@/lib/types").GoalStateWsPayload>();
|
||||
return {
|
||||
status: "open" as const,
|
||||
@@ -34,7 +34,7 @@ function makeClient() {
|
||||
errorHandlers.delete(handler);
|
||||
};
|
||||
},
|
||||
onSessionUpdate: (handler: (chatId: string) => void) => {
|
||||
onSessionUpdate: (handler: (chatId: string, scope?: string) => void) => {
|
||||
sessionUpdateHandlers.add(handler);
|
||||
return () => {
|
||||
sessionUpdateHandlers.delete(handler);
|
||||
@@ -49,8 +49,8 @@ function makeClient() {
|
||||
}
|
||||
for (const h of chatHandlers.get(chatId) ?? []) h(ev);
|
||||
},
|
||||
_emitSessionUpdate(chatId: string) {
|
||||
for (const h of sessionUpdateHandlers) h(chatId);
|
||||
_emitSessionUpdate(chatId: string, scope?: string) {
|
||||
for (const h of sessionUpdateHandlers) h(chatId, scope);
|
||||
},
|
||||
sendMessage: vi.fn(),
|
||||
newChat: vi.fn(),
|
||||
@@ -651,6 +651,52 @@ describe("ThreadShell", () => {
|
||||
expect(historyCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("does not refetch thread history for metadata-only session updates", async () => {
|
||||
const client = makeClient();
|
||||
let historyCalls = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("websocket%3Achat-a/webui-thread")) {
|
||||
historyCalls += 1;
|
||||
return httpJson(
|
||||
transcriptFromSimpleMessages([
|
||||
{ role: "user", content: "question" },
|
||||
{ role: "assistant", content: "answer" },
|
||||
]),
|
||||
);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-a")}
|
||||
title="Chat chat-a"
|
||||
onToggleSidebar={() => {}}
|
||||
onNewChat={() => {}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("answer")).toBeInTheDocument());
|
||||
expect(historyCalls).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
client._emitSessionUpdate("chat-a", "metadata");
|
||||
});
|
||||
|
||||
expect(historyCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("scrolls to the bottom after loading a session from the blank new-chat page", async () => {
|
||||
const client = makeClient();
|
||||
const scrollIntoView = vi.fn();
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ThreadViewport } from "@/components/thread/ThreadViewport";
|
||||
import {
|
||||
HISTORY_WINDOW_INCREMENT,
|
||||
INITIAL_HISTORY_WINDOW,
|
||||
ThreadViewport,
|
||||
windowMessages,
|
||||
} from "@/components/thread/ThreadViewport";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
const messages: UIMessage[] = [
|
||||
@@ -15,7 +20,191 @@ const messages: UIMessage[] = [
|
||||
|
||||
const emptyMessages: UIMessage[] = [];
|
||||
|
||||
interface ResizeObserverInstance {
|
||||
element?: Element;
|
||||
callback: ResizeObserverCallback;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function makeLongMessages(count: number): UIMessage[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `m${index}`,
|
||||
role: "user" as const,
|
||||
content: `message ${index}`,
|
||||
createdAt: index,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("ThreadViewport", () => {
|
||||
it("keeps the scroll-to-bottom button above a growing composer", () => {
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
const resizeObservers: ResizeObserverInstance[] = [];
|
||||
class MockResizeObserver {
|
||||
element?: Element;
|
||||
callback: ResizeObserverCallback;
|
||||
disconnect = vi.fn();
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.callback = callback;
|
||||
resizeObservers.push(this);
|
||||
}
|
||||
|
||||
observe(element: Element) {
|
||||
this.element = element;
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
||||
|
||||
try {
|
||||
const { container } = render(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<div>composer</div>}
|
||||
/>,
|
||||
);
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 2400 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, value: 0 },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
const button = screen.getByRole("button", { name: "Scroll to bottom" });
|
||||
expect(button).toHaveStyle({ bottom: "192px" });
|
||||
|
||||
const composerDock = screen.getByTestId("thread-composer-dock");
|
||||
composerDock.getBoundingClientRect = () =>
|
||||
({
|
||||
height: 240,
|
||||
width: 800,
|
||||
top: 0,
|
||||
right: 800,
|
||||
bottom: 240,
|
||||
left: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
|
||||
const composerObserver = resizeObservers.find(
|
||||
(observer) => observer.element === composerDock,
|
||||
);
|
||||
expect(composerObserver).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
composerObserver!.callback([], composerObserver as unknown as ResizeObserver);
|
||||
});
|
||||
|
||||
expect(button).toHaveStyle({ bottom: "256px" });
|
||||
} finally {
|
||||
vi.stubGlobal("ResizeObserver", originalResizeObserver);
|
||||
}
|
||||
});
|
||||
|
||||
it("hides the scroll-to-bottom button when disabled for the welcome view", () => {
|
||||
const { container } = render(
|
||||
<ThreadViewport
|
||||
messages={emptyMessages}
|
||||
isStreaming={false}
|
||||
composer={<div>composer</div>}
|
||||
emptyState={<div>welcome</div>}
|
||||
showScrollToBottomButton={false}
|
||||
/>,
|
||||
);
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 2400 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, value: 0 },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Scroll to bottom" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders only the tail window for long history by default", () => {
|
||||
const longMessages = makeLongMessages(300);
|
||||
|
||||
render(
|
||||
<ThreadViewport
|
||||
messages={longMessages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("message 139")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("message 140")).toBeInTheDocument();
|
||||
expect(screen.getByText("message 299")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Load earlier messages" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads earlier history in fixed increments without rendering the whole transcript", () => {
|
||||
const longMessages = makeLongMessages(300);
|
||||
|
||||
render(
|
||||
<ThreadViewport
|
||||
messages={longMessages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load earlier messages" }));
|
||||
|
||||
const firstVisible =
|
||||
300 - INITIAL_HISTORY_WINDOW - HISTORY_WINDOW_INCREMENT;
|
||||
|
||||
expect(
|
||||
screen.queryByText(`message ${firstVisible - 1}`),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText(`message ${firstVisible}`)).toBeInTheDocument();
|
||||
expect(screen.getByText("message 299")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands the window start to avoid cutting an agent activity cluster", () => {
|
||||
const clustered = makeLongMessages(200);
|
||||
clustered.splice(
|
||||
38,
|
||||
3,
|
||||
{
|
||||
id: "r0",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "first reasoning",
|
||||
createdAt: 38,
|
||||
},
|
||||
{
|
||||
id: "t0",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "tool()",
|
||||
traces: ["tool()"],
|
||||
createdAt: 39,
|
||||
},
|
||||
{
|
||||
id: "r1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "second reasoning",
|
||||
createdAt: 40,
|
||||
},
|
||||
);
|
||||
|
||||
const visible = windowMessages(clustered, INITIAL_HISTORY_WINDOW);
|
||||
|
||||
expect(visible[0].id).toBe("r0");
|
||||
expect(visible).toHaveLength(INITIAL_HISTORY_WINDOW + 2);
|
||||
});
|
||||
|
||||
it("resets to the bottom when opening a different conversation", async () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
function session(overrides: Partial<ChatSummary> = {}): ChatSummary {
|
||||
return {
|
||||
key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chatId: "chat-a",
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
title: "",
|
||||
preview: "First user message",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useDeferredTitleRefresh", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("retries refreshing untitled sessions after turn_end", () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() =>
|
||||
useDeferredTitleRefresh(session(), refresh, [100, 300]),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(refresh).toHaveBeenCalledTimes(2);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
expect(refresh).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("stops pending retries once a generated title arrives", () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined);
|
||||
const { result, rerender } = renderHook(
|
||||
({ activeSession }) =>
|
||||
useDeferredTitleRefresh(activeSession, refresh, [100, 300]),
|
||||
{ initialProps: { activeSession: session() } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
rerender({ activeSession: session({ title: "Generated title" }) });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry when the active session already has a title", () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() =>
|
||||
useDeferredTitleRefresh(session({ title: "Existing title" }), refresh, [100]),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears pending retries when the active chat changes", () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined);
|
||||
const { result, rerender } = renderHook(
|
||||
({ activeSession }) =>
|
||||
useDeferredTitleRefresh(activeSession, refresh, [100]),
|
||||
{ initialProps: { activeSession: session() } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
rerender({
|
||||
activeSession: session({
|
||||
key: "websocket:chat-b",
|
||||
chatId: "chat-b",
|
||||
}),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -83,7 +83,112 @@ function wrap(client: ReturnType<typeof fakeClient>["client"]) {
|
||||
};
|
||||
}
|
||||
|
||||
async function flushStreamFrame() {
|
||||
await act(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("useNanobotStream", () => {
|
||||
it("batches answer deltas into one animation-frame update", async () => {
|
||||
const fake = fakeClient();
|
||||
const requestFrame = vi.spyOn(window, "requestAnimationFrame");
|
||||
const { result } = renderHook(() => useNanobotStream("chat-batch", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-batch", {
|
||||
event: "delta",
|
||||
chat_id: "chat-batch",
|
||||
text: "Hello",
|
||||
});
|
||||
fake.emit("chat-batch", {
|
||||
event: "delta",
|
||||
chat_id: "chat-batch",
|
||||
text: " world",
|
||||
});
|
||||
});
|
||||
|
||||
expect(requestFrame).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.messages).toHaveLength(0);
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "Hello world",
|
||||
isStreaming: true,
|
||||
});
|
||||
requestFrame.mockRestore();
|
||||
});
|
||||
|
||||
it("flushes pending delta text before turn_end finalizes the turn", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-flush", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-flush", {
|
||||
event: "delta",
|
||||
chat_id: "chat-flush",
|
||||
text: "final chunk",
|
||||
});
|
||||
fake.emit("chat-flush", {
|
||||
event: "turn_end",
|
||||
chat_id: "chat-flush",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "final chunk",
|
||||
isStreaming: false,
|
||||
});
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("drops pending stream work when switching chats", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result, rerender } = renderHook(
|
||||
({ chatId }: { chatId: string }) => useNanobotStream(chatId, EMPTY_MESSAGES),
|
||||
{
|
||||
wrapper: wrap(fake.client),
|
||||
initialProps: { chatId: "chat-old" },
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-old", {
|
||||
event: "delta",
|
||||
chat_id: "chat-old",
|
||||
text: "stale",
|
||||
});
|
||||
});
|
||||
|
||||
rerender({ chatId: "chat-new" });
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-new", {
|
||||
event: "delta",
|
||||
chat_id: "chat-new",
|
||||
text: "fresh",
|
||||
});
|
||||
});
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "fresh",
|
||||
});
|
||||
});
|
||||
|
||||
it("starts in streaming mode when history shows pending tool calls", () => {
|
||||
const fake = fakeClient();
|
||||
const initialMessages = [{
|
||||
@@ -203,7 +308,174 @@ describe("useNanobotStream", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", () => {
|
||||
it("renders live file_edit events as their own activity trace", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-file-edit", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-file-edit", {
|
||||
event: "message",
|
||||
chat_id: "chat-file-edit",
|
||||
text: 'write_file({"path":"foo.txt"})',
|
||||
kind: "tool_hint",
|
||||
});
|
||||
fake.emit("chat-file-edit", {
|
||||
event: "file_edit",
|
||||
chat_id: "chat-file-edit",
|
||||
edits: [{
|
||||
call_id: "call-write",
|
||||
tool: "write_file",
|
||||
path: "foo.txt",
|
||||
phase: "start",
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
approximate: true,
|
||||
status: "editing",
|
||||
}],
|
||||
});
|
||||
fake.emit("chat-file-edit", {
|
||||
event: "file_edit",
|
||||
chat_id: "chat-file-edit",
|
||||
edits: [{
|
||||
call_id: "call-write",
|
||||
tool: "write_file",
|
||||
path: "foo.txt",
|
||||
phase: "end",
|
||||
added: 3,
|
||||
deleted: 1,
|
||||
approximate: false,
|
||||
status: "done",
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(2);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
traces: ['write_file({"path":"foo.txt"})'],
|
||||
});
|
||||
expect(result.current.messages[1]).toMatchObject({
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
fileEdits: [{
|
||||
call_id: "call-write",
|
||||
status: "done",
|
||||
added: 3,
|
||||
deleted: 1,
|
||||
approximate: false,
|
||||
}],
|
||||
});
|
||||
expect(result.current.messages[1].activitySegmentId).toBeTruthy();
|
||||
expect(result.current.messages[1].activitySegmentId).not.toBe(
|
||||
result.current.messages[0].activitySegmentId,
|
||||
);
|
||||
});
|
||||
|
||||
it("starts a new assistant bubble for deltas after stream_end and activity", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-stream-segments", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-stream-segments", {
|
||||
event: "delta",
|
||||
chat_id: "chat-stream-segments",
|
||||
text: "I created the files.",
|
||||
});
|
||||
fake.emit("chat-stream-segments", {
|
||||
event: "stream_end",
|
||||
chat_id: "chat-stream-segments",
|
||||
});
|
||||
fake.emit("chat-stream-segments", {
|
||||
event: "message",
|
||||
chat_id: "chat-stream-segments",
|
||||
text: 'write_file({"path":"minecraft-fps/options.txt"})',
|
||||
kind: "tool_hint",
|
||||
});
|
||||
fake.emit("chat-stream-segments", {
|
||||
event: "delta",
|
||||
chat_id: "chat-stream-segments",
|
||||
text: "Now I will summarize the edits.",
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "I created the files.",
|
||||
});
|
||||
expect(result.current.messages[1]).toMatchObject({
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
traces: ['write_file({"path":"minecraft-fps/options.txt"})'],
|
||||
});
|
||||
expect(result.current.messages[2]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "Now I will summarize the edits.",
|
||||
});
|
||||
});
|
||||
|
||||
it("opens a new activity segment for reasoning after file edit activity", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-file-segments", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-file-segments", {
|
||||
event: "reasoning_delta",
|
||||
chat_id: "chat-file-segments",
|
||||
text: "Plan.",
|
||||
});
|
||||
fake.emit("chat-file-segments", {
|
||||
event: "reasoning_end",
|
||||
chat_id: "chat-file-segments",
|
||||
});
|
||||
fake.emit("chat-file-segments", {
|
||||
event: "message",
|
||||
chat_id: "chat-file-segments",
|
||||
text: 'edit_file({"path":"foo.txt"})',
|
||||
kind: "tool_hint",
|
||||
});
|
||||
fake.emit("chat-file-segments", {
|
||||
event: "file_edit",
|
||||
chat_id: "chat-file-segments",
|
||||
edits: [{
|
||||
call_id: "call-edit",
|
||||
tool: "edit_file",
|
||||
path: "foo.txt",
|
||||
phase: "start",
|
||||
added: 1,
|
||||
deleted: 1,
|
||||
approximate: true,
|
||||
status: "editing",
|
||||
}],
|
||||
});
|
||||
fake.emit("chat-file-segments", {
|
||||
event: "reasoning_delta",
|
||||
chat_id: "chat-file-segments",
|
||||
text: "Review result.",
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(4);
|
||||
const firstSegment = result.current.messages[0].activitySegmentId;
|
||||
expect(firstSegment).toBeTruthy();
|
||||
expect(result.current.messages[1].activitySegmentId).toBe(firstSegment);
|
||||
expect(result.current.messages[2].activitySegmentId).toBeTruthy();
|
||||
expect(result.current.messages[2].activitySegmentId).not.toBe(firstSegment);
|
||||
expect(result.current.messages[3].activitySegmentId).toBe(firstSegment);
|
||||
});
|
||||
|
||||
it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
@@ -222,6 +494,8 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].role).toBe("assistant");
|
||||
expect(result.current.messages[0].reasoning).toBe("Let me think step by step.");
|
||||
@@ -328,7 +602,7 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages[0].reasoningStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("does not attach a new turn's reasoning across the latest user boundary", () => {
|
||||
it("does not attach a new turn's reasoning across the latest user boundary", async () => {
|
||||
const fake = fakeClient();
|
||||
const initialMessages = [
|
||||
{
|
||||
@@ -358,6 +632,8 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.messages[0].reasoning).toBe("Previous thought.");
|
||||
expect(result.current.messages[2].role).toBe("assistant");
|
||||
@@ -366,7 +642,7 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages[2].reasoningStreaming).toBe(true);
|
||||
});
|
||||
|
||||
it("does not attach reasoning across a tool trace boundary", () => {
|
||||
it("does not attach reasoning across a tool trace boundary", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-r7", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
@@ -392,6 +668,8 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.messages.map((m) => m.kind ?? "message")).toEqual([
|
||||
"message",
|
||||
@@ -651,7 +929,7 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages[0].content).toBe("long task");
|
||||
});
|
||||
|
||||
it("keeps streaming alive across stream_end and completes on turn_end", () => {
|
||||
it("keeps streaming alive across stream_end and completes on turn_end", async () => {
|
||||
const fake = fakeClient();
|
||||
const onTurnEnd = vi.fn();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES, false, onTurnEnd), {
|
||||
@@ -666,6 +944,8 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useSessionHistory, useSessions } from "@/hooks/useSessions";
|
||||
import { sessionTitle, useSessionHistory, useSessions } from "@/hooks/useSessions";
|
||||
import * as api from "@/lib/api";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
|
||||
@@ -17,7 +17,7 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
||||
});
|
||||
|
||||
function fakeClient() {
|
||||
const sessionUpdateHandlers = new Set<(chatId: string) => void>();
|
||||
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||
return {
|
||||
status: "open" as const,
|
||||
defaultChatId: null as string | null,
|
||||
@@ -25,12 +25,12 @@ function fakeClient() {
|
||||
onError: () => () => {},
|
||||
onChat: () => () => {},
|
||||
getRunStartedAt: () => null,
|
||||
onSessionUpdate: (handler: (chatId: string) => void) => {
|
||||
onSessionUpdate: (handler: (chatId: string, scope?: string) => void) => {
|
||||
sessionUpdateHandlers.add(handler);
|
||||
return () => sessionUpdateHandlers.delete(handler);
|
||||
},
|
||||
emitSessionUpdate: (chatId: string) => {
|
||||
for (const handler of sessionUpdateHandlers) handler(chatId);
|
||||
emitSessionUpdate: (chatId: string, scope?: string) => {
|
||||
for (const handler of sessionUpdateHandlers) handler(chatId, scope);
|
||||
},
|
||||
sendMessage: vi.fn(),
|
||||
newChat: vi.fn(),
|
||||
@@ -61,6 +61,28 @@ describe("useSessions", () => {
|
||||
vi.mocked(api.fetchWebuiThread).mockReset();
|
||||
});
|
||||
|
||||
it("does not use low-information greetings as fallback session titles", () => {
|
||||
expect(sessionTitle({
|
||||
key: "websocket:chat-hi",
|
||||
channel: "websocket",
|
||||
chatId: "chat-hi",
|
||||
createdAt: "2026-04-16T10:00:00Z",
|
||||
updatedAt: "2026-04-16T10:00:00Z",
|
||||
title: "",
|
||||
preview: "hi",
|
||||
})).toBe("New chat");
|
||||
|
||||
expect(sessionTitle({
|
||||
key: "websocket:chat-work",
|
||||
channel: "websocket",
|
||||
chatId: "chat-work",
|
||||
createdAt: "2026-04-16T10:00:00Z",
|
||||
updatedAt: "2026-04-16T10:00:00Z",
|
||||
title: "",
|
||||
preview: "帮我优化 WebUI 性能",
|
||||
})).toBe("帮我优化 WebUI 性能");
|
||||
});
|
||||
|
||||
it("removes a session from the local list after delete succeeds", async () => {
|
||||
vi.mocked(api.listSessions).mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
declare module "react-syntax-highlighter/dist/esm/prism-async-light" {
|
||||
import * as React from "react";
|
||||
import type { SyntaxHighlighterProps } from "react-syntax-highlighter";
|
||||
|
||||
export default class SyntaxHighlighter extends React.Component<SyntaxHighlighterProps> {
|
||||
static registerLanguage(name: string, func: unknown): void;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "react-syntax-highlighter/dist/esm/styles/prism/one-dark" {
|
||||
import type * as React from "react";
|
||||
|
||||
const style: { [key: string]: React.CSSProperties };
|
||||
export default style;
|
||||
}
|
||||
|
||||
declare module "react-syntax-highlighter/dist/esm/styles/prism/one-light" {
|
||||
import type * as React from "react";
|
||||
|
||||
const style: { [key: string]: React.CSSProperties };
|
||||
export default style;
|
||||
}
|
||||
@@ -25,6 +25,36 @@ export default defineConfig(({ mode }) => {
|
||||
outDir: path.resolve(__dirname, "../nanobot/web/dist"),
|
||||
emptyOutDir: true,
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes("node_modules/refractor/lang/")) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
id.includes("node_modules/react-syntax-highlighter")
|
||||
|| id.includes("node_modules/refractor/core")
|
||||
) {
|
||||
return "syntax-highlight";
|
||||
}
|
||||
if (
|
||||
id.includes("node_modules/react-markdown")
|
||||
|| id.includes("node_modules/remark-")
|
||||
|| id.includes("node_modules/rehype-")
|
||||
|| id.includes("node_modules/unified")
|
||||
|| id.includes("node_modules/mdast-")
|
||||
|| id.includes("node_modules/hast-")
|
||||
|| id.includes("node_modules/micromark")
|
||||
|| id.includes("node_modules/unist-")
|
||||
) {
|
||||
return "markdown-vendor";
|
||||
}
|
||||
if (id.includes("node_modules/katex")) {
|
||||
return "katex";
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
|
||||
Reference in New Issue
Block a user