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:
+28
-62
@@ -33,7 +33,6 @@ from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
|||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
from nanobot.providers.factory import ProviderSnapshot
|
from nanobot.providers.factory import ProviderSnapshot
|
||||||
from nanobot.session.goal_state import (
|
from nanobot.session.goal_state import (
|
||||||
goal_state_ws_blob,
|
|
||||||
runner_wall_llm_timeout_s,
|
runner_wall_llm_timeout_s,
|
||||||
)
|
)
|
||||||
from nanobot.session.manager import Session, SessionManager
|
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 image_placeholder_text
|
||||||
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
||||||
from nanobot.utils.image_generation_intent import image_generation_prompt
|
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.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
||||||
from nanobot.utils.session_attachments import merge_turn_media_into_last_assistant
|
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 (
|
||||||
from nanobot.utils.webui_turn_helpers import publish_turn_run_status
|
WebuiTurnCoordinator,
|
||||||
|
build_bus_progress_callback,
|
||||||
|
mark_webui_session,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.config.schema import (
|
from nanobot.config.schema import (
|
||||||
@@ -136,6 +139,11 @@ class AgentLoop:
|
|||||||
def tool_names(self) -> list[str]:
|
def tool_names(self) -> list[str]:
|
||||||
return self.tools.tool_names
|
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"
|
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
||||||
_PENDING_USER_TURN_KEY = "pending_user_turn"
|
_PENDING_USER_TURN_KEY = "pending_user_turn"
|
||||||
|
|
||||||
@@ -237,6 +245,11 @@ class AgentLoop:
|
|||||||
|
|
||||||
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
|
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
|
||||||
self.sessions = session_manager or SessionManager(workspace)
|
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()
|
self.tools = ToolRegistry()
|
||||||
# One file-read/write tracker per logical session. The tool registry is
|
# One file-read/write tracker per logical session. The tool registry is
|
||||||
# shared by this loop, so tools resolve the active state via contextvars.
|
# shared by this loop, so tools resolve the active state via contextvars.
|
||||||
@@ -524,34 +537,7 @@ class AgentLoop:
|
|||||||
self, msg: InboundMessage
|
self, msg: InboundMessage
|
||||||
) -> Callable[..., Awaitable[None]]:
|
) -> Callable[..., Awaitable[None]]:
|
||||||
"""Build a progress callback that publishes to the message bus."""
|
"""Build a progress callback that publishes to the message bus."""
|
||||||
|
return build_bus_progress_callback(self.bus, msg)
|
||||||
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
|
|
||||||
|
|
||||||
async def _build_retry_wait_callback(
|
async def _build_retry_wait_callback(
|
||||||
self, msg: InboundMessage
|
self, msg: InboundMessage
|
||||||
@@ -938,38 +924,12 @@ class AgentLoop:
|
|||||||
content="", metadata=msg.metadata or {},
|
content="", metadata=msg.metadata or {},
|
||||||
))
|
))
|
||||||
if msg.channel == "websocket":
|
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_lat = self._pending_turn_latency_ms.pop(session_key, None)
|
||||||
turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True}
|
await self._webui_turns.handle_turn_end(
|
||||||
if turn_lat is not None:
|
msg,
|
||||||
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,
|
session_key=session_key,
|
||||||
provider=self.provider,
|
latency_ms=turn_lat,
|
||||||
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())
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.info("Task cancelled for session {}", session_key)
|
logger.info("Task cancelled for session {}", session_key)
|
||||||
# Preserve partial context from the interrupted turn so
|
# Preserve partial context from the interrupted turn so
|
||||||
@@ -1021,8 +981,9 @@ class AgentLoop:
|
|||||||
"Re-published {} leftover message(s) to bus for session {}",
|
"Re-published {} leftover message(s) to bus for session {}",
|
||||||
leftover, session_key,
|
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._pending_turn_latency_ms.pop(session_key, None)
|
||||||
|
self._webui_turns.discard(session_key)
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
"""Drain pending background archives, then close MCP connections."""
|
"""Drain pending background archives, then close MCP connections."""
|
||||||
@@ -1338,6 +1299,11 @@ class AgentLoop:
|
|||||||
"include_timestamps": True,
|
"include_timestamps": True,
|
||||||
}
|
}
|
||||||
ctx.history = ctx.session.get_history(**_hist_kwargs)
|
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.initial_messages = self._build_initial_messages(
|
||||||
ctx.msg, ctx.session, ctx.history, ctx.pending_summary
|
ctx.msg, ctx.session, ctx.history, ctx.pending_summary
|
||||||
@@ -1354,7 +1320,7 @@ class AgentLoop:
|
|||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
async def _state_run(self, ctx: TurnContext) -> str:
|
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(
|
result = await self._run_agent_loop(
|
||||||
ctx.initial_messages,
|
ctx.initial_messages,
|
||||||
on_progress=ctx.on_progress,
|
on_progress=ctx.on_progress,
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ from loguru import logger
|
|||||||
from nanobot.agent.hook import AgentHook, AgentHookContext
|
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
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 (
|
from nanobot.utils.helpers import (
|
||||||
IncrementalThinkExtractor,
|
IncrementalThinkExtractor,
|
||||||
build_assistant_message,
|
build_assistant_message,
|
||||||
@@ -26,6 +32,10 @@ from nanobot.utils.helpers import (
|
|||||||
strip_think,
|
strip_think,
|
||||||
truncate_text,
|
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.prompt_templates import render_template
|
||||||
from nanobot.utils.runtime import (
|
from nanobot.utils.runtime import (
|
||||||
EMPTY_FINAL_RESPONSE_MESSAGE,
|
EMPTY_FINAL_RESPONSE_MESSAGE,
|
||||||
@@ -813,6 +823,30 @@ class AgentRunner:
|
|||||||
return prep_error + hint, event, (
|
return prep_error + hint, event, (
|
||||||
RuntimeError(prep_error) if spec.fail_on_tool_error else None
|
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:
|
try:
|
||||||
if tool is not None:
|
if tool is not None:
|
||||||
result = await tool.execute(**params)
|
result = await tool.execute(**params)
|
||||||
@@ -821,6 +855,11 @@ class AgentRunner:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except BaseException as exc:
|
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 = {
|
event = {
|
||||||
"name": tool_call.name,
|
"name": tool_call.name,
|
||||||
"status": "error",
|
"status": "error",
|
||||||
@@ -842,6 +881,11 @@ class AgentRunner:
|
|||||||
return payload, event, None
|
return payload, event, None
|
||||||
|
|
||||||
if isinstance(result, str) and result.startswith("Error"):
|
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 = {
|
event = {
|
||||||
"name": tool_call.name,
|
"name": tool_call.name,
|
||||||
"status": "error",
|
"status": "error",
|
||||||
@@ -860,6 +904,12 @@ class AgentRunner:
|
|||||||
return result + hint, event, RuntimeError(result)
|
return result + hint, event, RuntimeError(result)
|
||||||
return result + hint, event, None
|
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 = "" if result is None else str(result)
|
||||||
detail = detail.replace("\n", " ").strip()
|
detail = detail.replace("\n", " ").strip()
|
||||||
if not detail:
|
if not detail:
|
||||||
|
|||||||
@@ -230,6 +230,25 @@ def _mask_secret_hint(secret: str | None) -> str | None:
|
|||||||
return f"{secret[:4]}••••{secret[-4:]}"
|
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], ...] = (
|
_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
|
||||||
{"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
|
{"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
|
||||||
{"name": "brave", "label": "Brave Search", "credential": "api_key"},
|
{"name": "brave", "label": "Brave Search", "credential": "api_key"},
|
||||||
@@ -786,13 +805,14 @@ class WebSocketChannel(BaseChannel):
|
|||||||
providers = []
|
providers = []
|
||||||
for spec in PROVIDERS:
|
for spec in PROVIDERS:
|
||||||
provider_config = getattr(config.providers, spec.name, None)
|
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
|
continue
|
||||||
providers.append(
|
providers.append(
|
||||||
{
|
{
|
||||||
"name": spec.name,
|
"name": spec.name,
|
||||||
"label": spec.label,
|
"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_key_hint": _mask_secret_hint(provider_config.api_key),
|
||||||
"api_base": provider_config.api_base,
|
"api_base": provider_config.api_base,
|
||||||
"default_api_base": spec.default_api_base or None,
|
"default_api_base": spec.default_api_base or None,
|
||||||
@@ -862,7 +882,12 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if find_by_name(provider) is None:
|
if find_by_name(provider) is None:
|
||||||
return _http_error(400, "unknown provider")
|
return _http_error(400, "unknown provider")
|
||||||
provider_config = getattr(config.providers, provider, None)
|
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")
|
return _http_error(400, "provider is not configured")
|
||||||
if defaults.provider != provider:
|
if defaults.provider != provider:
|
||||||
defaults.provider = provider
|
defaults.provider = provider
|
||||||
@@ -885,7 +910,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if not provider_name:
|
if not provider_name:
|
||||||
return _http_error(400, "provider is required")
|
return _http_error(400, "provider is required")
|
||||||
spec = find_by_name(provider_name)
|
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")
|
return _http_error(400, "unknown provider")
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@@ -1581,6 +1606,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if not conns:
|
if not conns:
|
||||||
if (
|
if (
|
||||||
msg.metadata.get("_progress")
|
msg.metadata.get("_progress")
|
||||||
|
or msg.metadata.get("_file_edit_events")
|
||||||
or msg.metadata.get("_turn_end")
|
or msg.metadata.get("_turn_end")
|
||||||
or msg.metadata.get("_session_updated")
|
or msg.metadata.get("_session_updated")
|
||||||
or msg.metadata.get("_goal_status")
|
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)
|
await self.send_turn_end(msg.chat_id, latency_ms=lat_i, goal_state=gs_blob)
|
||||||
return
|
return
|
||||||
if msg.metadata.get("_session_updated"):
|
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
|
return
|
||||||
text = msg.content
|
text = msg.content
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
@@ -1780,12 +1821,14 @@ class WebSocketChannel(BaseChannel):
|
|||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" goal_status ")
|
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."""
|
"""Notify clients that session metadata changed outside the main turn."""
|
||||||
conns = list(self._subs.get(chat_id, ()))
|
conns = list(self._subs.get(chat_id, ()))
|
||||||
if not conns:
|
if not conns:
|
||||||
return
|
return
|
||||||
body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id}
|
body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id}
|
||||||
|
if scope:
|
||||||
|
body["scope"] = scope
|
||||||
raw = json.dumps(body, ensure_ascii=False)
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" session_updated ")
|
await self._safe_send_to(connection, raw, label=" session_updated ")
|
||||||
|
|||||||
@@ -968,8 +968,7 @@ def _run_gateway(
|
|||||||
hb_cfg = config.gateway.heartbeat
|
hb_cfg = config.gateway.heartbeat
|
||||||
heartbeat = HeartbeatService(
|
heartbeat = HeartbeatService(
|
||||||
workspace=config.workspace_path,
|
workspace=config.workspace_path,
|
||||||
provider=agent.provider,
|
llm_runtime=agent.llm_runtime,
|
||||||
model=agent.model,
|
|
||||||
on_execute=on_heartbeat_execute,
|
on_execute=on_heartbeat_execute,
|
||||||
on_notify=on_heartbeat_notify,
|
on_notify=on_heartbeat_notify,
|
||||||
interval_s=hb_cfg.interval_s,
|
interval_s=hb_cfg.interval_s,
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Callable, Coroutine
|
from typing import Any, Callable, Coroutine
|
||||||
|
|
||||||
from loguru import logger
|
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 = [
|
_HEARTBEAT_TOOL = [
|
||||||
{
|
{
|
||||||
@@ -53,17 +53,21 @@ class HeartbeatService:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
workspace: Path,
|
workspace: Path,
|
||||||
provider: LLMProvider,
|
provider: LLMProvider | None = None,
|
||||||
model: str,
|
model: str | None = None,
|
||||||
on_execute: Callable[[str], Coroutine[Any, Any, str]] | None = None,
|
on_execute: Callable[[str], Coroutine[Any, Any, str]] | None = None,
|
||||||
on_notify: Callable[[str], Coroutine[Any, Any, None]] | None = None,
|
on_notify: Callable[[str], Coroutine[Any, Any, None]] | None = None,
|
||||||
interval_s: int = 30 * 60,
|
interval_s: int = 30 * 60,
|
||||||
enabled: bool = True,
|
enabled: bool = True,
|
||||||
timezone: str | None = None,
|
timezone: str | None = None,
|
||||||
|
llm_runtime: LLMRuntimeResolver | None = None,
|
||||||
):
|
):
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
self.provider = provider
|
if llm_runtime is None:
|
||||||
self.model = model
|
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_execute = on_execute
|
||||||
self.on_notify = on_notify
|
self.on_notify = on_notify
|
||||||
self.interval_s = interval_s
|
self.interval_s = interval_s
|
||||||
@@ -91,7 +95,9 @@ class HeartbeatService:
|
|||||||
"""
|
"""
|
||||||
from nanobot.utils.helpers import current_time_str
|
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=[
|
messages=[
|
||||||
{"role": "system", "content": "You are a heartbeat agent. Call the heartbeat tool to report your decision."},
|
{"role": "system", "content": "You are a heartbeat agent. Call the heartbeat tool to report your decision."},
|
||||||
{"role": "user", "content": (
|
{"role": "user", "content": (
|
||||||
@@ -101,7 +107,7 @@ class HeartbeatService:
|
|||||||
)},
|
)},
|
||||||
],
|
],
|
||||||
tools=_HEARTBEAT_TOOL,
|
tools=_HEARTBEAT_TOOL,
|
||||||
model=self.model,
|
model=llm.model,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not response.should_execute_tools:
|
if not response.should_execute_tools:
|
||||||
@@ -214,8 +220,9 @@ class HeartbeatService:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
llm = self._llm_runtime()
|
||||||
should_notify = await evaluate_response(
|
should_notify = await evaluate_response(
|
||||||
response, tasks, self.provider, self.model,
|
response, tasks, llm.provider, llm.model,
|
||||||
)
|
)
|
||||||
if should_notify and self.on_notify:
|
if should_notify and self.on_notify:
|
||||||
logger.info("Heartbeat: completed, delivering response")
|
logger.info("Heartbeat: completed, delivering response")
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
name="vllm",
|
name="vllm",
|
||||||
keywords=("vllm",),
|
keywords=("vllm",),
|
||||||
env_key="HOSTED_VLLM_API_KEY",
|
env_key="HOSTED_VLLM_API_KEY",
|
||||||
display_name="vLLM/Local",
|
display_name="vLLM",
|
||||||
backend="openai_compat",
|
backend="openai_compat",
|
||||||
is_local=True,
|
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:
|
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:
|
try:
|
||||||
sig = inspect.signature(cb)
|
sig = inspect.signature(cb)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return False
|
return False
|
||||||
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
|
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
|
||||||
return True
|
return True
|
||||||
return "tool_events" in sig.parameters
|
return name in sig.parameters
|
||||||
|
|
||||||
|
|
||||||
async def invoke_on_progress(
|
async def invoke_on_progress(
|
||||||
@@ -32,6 +40,15 @@ async def invoke_on_progress(
|
|||||||
await on_progress(content, tool_hint=tool_hint)
|
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]:
|
def build_tool_event_start_payload(tool_call: Any) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"version": 1,
|
"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_message_id: str | None = None
|
||||||
buffer_parts: list[str] = []
|
buffer_parts: list[str] = []
|
||||||
suppress_until_turn_end = False
|
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)
|
_ts_base = int(time.time() * 1000)
|
||||||
|
|
||||||
def _new_id(prefix: str, idx: int) -> str:
|
def _new_id(prefix: str, idx: int) -> str:
|
||||||
return f"{prefix}-{idx}-{uuid.uuid4().hex[:8]}"
|
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:
|
def attach_reasoning_chunk(prev: list[dict[str, Any]], chunk: str, idx: int) -> None:
|
||||||
for i in range(len(prev) - 1, -1, -1):
|
for i in range(len(prev) - 1, -1, -1):
|
||||||
candidate = prev[i]
|
candidate = prev[i]
|
||||||
@@ -151,12 +165,19 @@ def replay_transcript_to_ui_messages(
|
|||||||
**candidate,
|
**candidate,
|
||||||
"reasoning": (str(candidate.get("reasoning") or "")) + chunk,
|
"reasoning": (str(candidate.get("reasoning") or "")) + chunk,
|
||||||
"reasoningStreaming": True,
|
"reasoningStreaming": True,
|
||||||
|
"activitySegmentId": candidate.get("activitySegmentId") or _ensure_activity_segment(),
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
if not has_answer and candidate.get("isStreaming"):
|
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
|
return
|
||||||
break
|
break
|
||||||
|
segment = _ensure_activity_segment()
|
||||||
prev.append(
|
prev.append(
|
||||||
{
|
{
|
||||||
"id": _new_id("as", idx),
|
"id": _new_id("as", idx),
|
||||||
@@ -165,6 +186,7 @@ def replay_transcript_to_ui_messages(
|
|||||||
"isStreaming": True,
|
"isStreaming": True,
|
||||||
"reasoning": chunk,
|
"reasoning": chunk,
|
||||||
"reasoningStreaming": True,
|
"reasoningStreaming": True,
|
||||||
|
"activitySegmentId": segment,
|
||||||
"createdAt": _ts_base + idx,
|
"createdAt": _ts_base + idx,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -221,6 +243,7 @@ def replay_transcript_to_ui_messages(
|
|||||||
return
|
return
|
||||||
|
|
||||||
def absorb_complete(extra: dict[str, Any], idx: int) -> None:
|
def absorb_complete(extra: dict[str, Any], idx: int) -> None:
|
||||||
|
nonlocal active_activity_segment_id
|
||||||
last = messages[-1] if messages else None
|
last = messages[-1] if messages else None
|
||||||
if last and is_reasoning_only_placeholder(last):
|
if last and is_reasoning_only_placeholder(last):
|
||||||
messages[-1] = {
|
messages[-1] = {
|
||||||
@@ -238,10 +261,76 @@ def replay_transcript_to_ui_messages(
|
|||||||
**extra,
|
**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):
|
for idx, rec in enumerate(lines):
|
||||||
ev = rec.get("event")
|
ev = rec.get("event")
|
||||||
if ev == "user":
|
if ev == "user":
|
||||||
|
active_activity_segment_id = None
|
||||||
|
active_file_edit_segment_id = None
|
||||||
text = rec.get("text")
|
text = rec.get("text")
|
||||||
text_s = text if isinstance(text, str) else ""
|
text_s = text if isinstance(text, str) else ""
|
||||||
media_paths = rec.get("media_paths")
|
media_paths = rec.get("media_paths")
|
||||||
@@ -264,6 +353,12 @@ def replay_transcript_to_ui_messages(
|
|||||||
messages.append(row)
|
messages.append(row)
|
||||||
continue
|
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 ev == "delta":
|
||||||
if suppress_until_turn_end:
|
if suppress_until_turn_end:
|
||||||
continue
|
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 [])
|
trace_lines = structured if structured else ([text] if isinstance(text, str) and text else [])
|
||||||
if not trace_lines:
|
if not trace_lines:
|
||||||
continue
|
continue
|
||||||
|
segment = _ensure_activity_segment()
|
||||||
last = messages[-1] if messages else None
|
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")])
|
prev_traces = list(last.get("traces") or [last.get("content")])
|
||||||
merged_traces = prev_traces + trace_lines
|
merged_traces = prev_traces + trace_lines
|
||||||
messages[-1] = {
|
messages[-1] = {
|
||||||
**last,
|
**last,
|
||||||
"traces": merged_traces,
|
"traces": merged_traces,
|
||||||
"content": trace_lines[-1],
|
"content": trace_lines[-1],
|
||||||
|
"activitySegmentId": last.get("activitySegmentId") or segment,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
messages.append(
|
messages.append(
|
||||||
@@ -355,6 +457,7 @@ def replay_transcript_to_ui_messages(
|
|||||||
"kind": "trace",
|
"kind": "trace",
|
||||||
"content": trace_lines[-1],
|
"content": trace_lines[-1],
|
||||||
"traces": trace_lines,
|
"traces": trace_lines,
|
||||||
|
"activitySegmentId": segment,
|
||||||
"createdAt": _ts_base + idx,
|
"createdAt": _ts_base + idx,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -389,6 +492,8 @@ def replay_transcript_to_ui_messages(
|
|||||||
|
|
||||||
if ev == "turn_end":
|
if ev == "turn_end":
|
||||||
suppress_until_turn_end = False
|
suppress_until_turn_end = False
|
||||||
|
active_activity_segment_id = None
|
||||||
|
active_file_edit_segment_id = None
|
||||||
for i, m in enumerate(messages):
|
for i, m in enumerate(messages):
|
||||||
if m.get("isStreaming"):
|
if m.get("isStreaming"):
|
||||||
messages[i] = {**m, "isStreaming": False}
|
messages[i] = {**m, "isStreaming": False}
|
||||||
|
|||||||
@@ -6,17 +6,163 @@ AgentLoop uses these without importing a concrete channel plugin; only
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
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
|
# 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.
|
# gateway process stays up; cleared on idle/stop and implicitly dropped on restart.
|
||||||
_WEBSOCKET_TURN_WALL_STARTED_AT: dict[str, float] = {}
|
_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:
|
def websocket_turn_wall_started_at(chat_id: str) -> float | None:
|
||||||
"""Return ``time.time()`` when the active user turn began, if still running."""
|
"""Return ``time.time()`` when the active user turn began, if still running."""
|
||||||
return _WEBSOCKET_TURN_WALL_STARTED_AT.get(chat_id)
|
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,
|
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.heartbeat.service import HeartbeatService
|
||||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||||
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
|
|
||||||
|
|
||||||
class DummyProvider(LLMProvider):
|
class DummyProvider(LLMProvider):
|
||||||
@@ -11,9 +12,11 @@ class DummyProvider(LLMProvider):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self._responses = list(responses)
|
self._responses = list(responses)
|
||||||
self.calls = 0
|
self.calls = 0
|
||||||
|
self.models: list[str | None] = []
|
||||||
|
|
||||||
async def chat(self, *args, **kwargs) -> LLMResponse:
|
async def chat(self, *args, **kwargs) -> LLMResponse:
|
||||||
self.calls += 1
|
self.calls += 1
|
||||||
|
self.models.append(kwargs.get("model"))
|
||||||
if self._responses:
|
if self._responses:
|
||||||
return self._responses.pop(0)
|
return self._responses.pop(0)
|
||||||
return LLMResponse(content="", tool_calls=[])
|
return LLMResponse(content="", tool_calls=[])
|
||||||
@@ -215,6 +218,51 @@ async def test_tick_suppresses_when_evaluator_says_no(tmp_path, monkeypatch) ->
|
|||||||
assert notified == []
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_decide_retries_transient_error_then_succeeds(tmp_path, monkeypatch) -> None:
|
async def test_decide_retries_transient_error_then_succeeds(tmp_path, monkeypatch) -> None:
|
||||||
provider = DummyProvider([
|
provider = DummyProvider([
|
||||||
@@ -286,4 +334,3 @@ async def test_decide_prompt_includes_current_time(tmp_path) -> None:
|
|||||||
user_msg = captured_messages[1]
|
user_msg = captured_messages[1]
|
||||||
assert user_msg["role"] == "user"
|
assert user_msg["role"] == "user"
|
||||||
assert "Current Time:" in user_msg["content"]
|
assert "Current Time:" in user_msg["content"]
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,15 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import nanobot.agent.runner as runner_module
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
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:
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_bus_progress_forwards_tool_events_to_outbound_metadata(self, tmp_path: Path) -> None:
|
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."""
|
"""When run() handles a bus message, _tool_events lands in OutboundMessage metadata."""
|
||||||
@@ -130,6 +271,44 @@ class TestToolEventProgress:
|
|||||||
assert finish["phase"] == "end"
|
assert finish["phase"] == "end"
|
||||||
assert finish["result"] == "file.txt"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_non_streaming_channel_does_not_publish_codex_progress_deltas(
|
async def test_non_streaming_channel_does_not_publish_codex_progress_deltas(
|
||||||
self,
|
self,
|
||||||
@@ -353,8 +532,93 @@ class TestToolEventProgress:
|
|||||||
assert session_updated is not None
|
assert session_updated is not None
|
||||||
|
|
||||||
assert (session_updated.metadata or {}).get("_session_updated") is True
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_non_websocket_dispatch_does_not_publish_turn_end_marker(self, tmp_path: Path) -> None:
|
async def test_non_websocket_dispatch_does_not_publish_turn_end_marker(self, tmp_path: Path) -> None:
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
|
|||||||
@@ -10,12 +10,16 @@ from nanobot.bus.events import InboundMessage
|
|||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.providers.base import LLMResponse
|
from nanobot.providers.base import LLMResponse
|
||||||
from nanobot.session.goal_state import GOAL_STATE_KEY
|
from nanobot.session.goal_state import GOAL_STATE_KEY
|
||||||
from nanobot.session.manager import Session
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.utils.webui_titles import (
|
from nanobot.utils.webui_turn_helpers import (
|
||||||
|
TITLE_GENERATION_MAX_TOKENS,
|
||||||
|
TITLE_GENERATION_REASONING_EFFORT,
|
||||||
WEBUI_SESSION_METADATA_KEY,
|
WEBUI_SESSION_METADATA_KEY,
|
||||||
WEBUI_TITLE_METADATA_KEY,
|
WEBUI_TITLE_METADATA_KEY,
|
||||||
|
WebuiTurnCoordinator,
|
||||||
maybe_generate_webui_title,
|
maybe_generate_webui_title,
|
||||||
)
|
)
|
||||||
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
|
|
||||||
|
|
||||||
def _mk_loop() -> AgentLoop:
|
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")
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Path) -> None:
|
async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Path) -> None:
|
||||||
loop = _make_full_loop(tmp_path)
|
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 generated is True
|
||||||
assert session.metadata[WEBUI_TITLE_METADATA_KEY] == "优化 WebUI 侧边栏"
|
assert session.metadata[WEBUI_TITLE_METADATA_KEY] == "优化 WebUI 侧边栏"
|
||||||
loop.provider.chat_with_retry.assert_awaited_once()
|
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
|
@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()
|
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:
|
def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None:
|
||||||
loop = _mk_loop()
|
loop = _mk_loop()
|
||||||
session = Session(key="test:runtime-only")
|
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.provider is new_provider
|
||||||
assert loop.dream.model == "new-model"
|
assert loop.dream.model == "new-model"
|
||||||
assert loop.dream._runner.provider is new_provider
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_send_progress_includes_agent_ui_blob() -> None:
|
async def test_send_progress_includes_agent_ui_blob() -> None:
|
||||||
bus = MagicMock()
|
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"}
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_send_non_connection_closed_exception_is_raised() -> None:
|
async def test_send_non_connection_closed_exception_is_raised() -> None:
|
||||||
bus = MagicMock()
|
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"]}
|
providers = {provider["name"]: provider for provider in body["providers"]}
|
||||||
assert providers["openai"]["configured"] is True
|
assert providers["openai"]["configured"] is True
|
||||||
assert providers["openai"]["api_key_hint"] == "secr••••-key"
|
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"]["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["agent"]["has_api_key"] is True
|
||||||
assert body["web_search"]["provider"] == "brave"
|
assert body["web_search"]["provider"] == "brave"
|
||||||
assert body["web_search"]["api_key_hint"] == "brav••••cret"
|
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 provider_rows["openrouter"]["configured"] is True
|
||||||
assert "sk-or-test" not in provider_updated.text
|
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(
|
updated = await _http_get(
|
||||||
"http://127.0.0.1:"
|
"http://127.0.0.1:"
|
||||||
f"{port}/api/settings/update?model=openrouter/test"
|
f"{port}/api/settings/update?model=atomic_chat/test"
|
||||||
"&provider=openrouter",
|
"&provider=atomic_chat",
|
||||||
headers={"Authorization": "Bearer tok"},
|
headers={"Authorization": "Bearer tok"},
|
||||||
)
|
)
|
||||||
assert updated.status_code == 200
|
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"
|
assert search_body["web_search"]["base_url"] == "https://search.example.com"
|
||||||
|
|
||||||
saved = load_config(config_path)
|
saved = load_config(config_path)
|
||||||
assert saved.agents.defaults.model == "openrouter/test"
|
assert saved.agents.defaults.model == "atomic_chat/test"
|
||||||
assert saved.agents.defaults.provider == "openrouter"
|
assert saved.agents.defaults.provider == "atomic_chat"
|
||||||
assert saved.providers.openrouter.api_key == "sk-or-test"
|
assert saved.providers.openrouter.api_key == "sk-or-test"
|
||||||
assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1"
|
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.provider == "searxng"
|
||||||
assert saved.tools.web.search.api_key == ""
|
assert saved.tools.web.search.api_key == ""
|
||||||
assert saved.tools.web.search.base_url == "https://search.example.com"
|
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.model = "test-model"
|
||||||
self.provider = kwargs.get("provider", object())
|
self.provider = kwargs.get("provider", object())
|
||||||
self.tools = {}
|
self.tools = {}
|
||||||
|
seen["agent"] = self
|
||||||
|
|
||||||
async def process_direct(self, *_args, **_kwargs):
|
async def process_direct(self, *_args, **_kwargs):
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
@@ -1218,6 +1219,11 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
|||||||
assert isinstance(cron, _FakeCron)
|
assert isinstance(cron, _FakeCron)
|
||||||
assert cron.on_job is not None
|
assert cron.on_job is not None
|
||||||
|
|
||||||
|
runtime_provider = object()
|
||||||
|
agent = seen["agent"]
|
||||||
|
agent.provider = runtime_provider
|
||||||
|
agent.model = "runtime-model"
|
||||||
|
|
||||||
job = CronJob(
|
job = CronJob(
|
||||||
id="cron-1",
|
id="cron-1",
|
||||||
name="stretch",
|
name="stretch",
|
||||||
@@ -1233,8 +1239,8 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
|||||||
|
|
||||||
assert response == "Time to stretch."
|
assert response == "Time to stretch."
|
||||||
assert seen["response"] == "Time to stretch."
|
assert seen["response"] == "Time to stretch."
|
||||||
assert seen["provider"] is provider
|
assert seen["provider"] is runtime_provider
|
||||||
assert seen["model"] == "test-model"
|
assert seen["model"] == "runtime-model"
|
||||||
assert seen["task_context"] == (
|
assert seen["task_context"] == (
|
||||||
"The scheduled time has arrived. Deliver this reminder to the user now, "
|
"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 — "
|
"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.dream = _FakeDream()
|
||||||
self.sessions = _FakeSessionManager()
|
self.sessions = _FakeSessionManager()
|
||||||
|
|
||||||
|
def llm_runtime(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
await asyncio.Event().wait()
|
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
|
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:
|
def test_build_response_schema(monkeypatch, tmp_path) -> None:
|
||||||
from nanobot.utils.webui_transcript import build_webui_thread_response
|
from nanobot.utils.webui_transcript import build_webui_thread_response
|
||||||
|
|
||||||
|
|||||||
+78
-10
@@ -7,7 +7,8 @@ import { ThreadShell } from "@/components/thread/ThreadShell";
|
|||||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||||
|
|
||||||
import { useSessions } from "@/hooks/useSessions";
|
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 { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
clearSavedSecret,
|
clearSavedSecret,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
loadSavedSecret,
|
loadSavedSecret,
|
||||||
saveSecret,
|
saveSecret,
|
||||||
} from "@/lib/bootstrap";
|
} from "@/lib/bootstrap";
|
||||||
|
import { deriveTitle } from "@/lib/format";
|
||||||
import { NanobotClient } from "@/lib/nanobot-client";
|
import { NanobotClient } from "@/lib/nanobot-client";
|
||||||
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||||
import type { ChatSummary } from "@/lib/types";
|
import type { ChatSummary } from "@/lib/types";
|
||||||
@@ -30,14 +32,30 @@ type BootState =
|
|||||||
status: "ready";
|
status: "ready";
|
||||||
client: NanobotClient;
|
client: NanobotClient;
|
||||||
token: string;
|
token: string;
|
||||||
|
tokenExpiresAt: number;
|
||||||
modelName: string | null;
|
modelName: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
|
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
|
||||||
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
|
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
|
||||||
const SIDEBAR_WIDTH = 272;
|
const SIDEBAR_WIDTH = 272;
|
||||||
|
const TOKEN_REFRESH_MARGIN_MS = 30_000;
|
||||||
|
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
|
||||||
type ShellView = "chat" | "settings";
|
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({
|
function AuthForm({
|
||||||
failed,
|
failed,
|
||||||
onSecret,
|
onSecret,
|
||||||
@@ -106,6 +124,7 @@ function readSidebarOpen(): boolean {
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [state, setState] = useState<BootState>({ status: "loading" });
|
const [state, setState] = useState<BootState>({ status: "loading" });
|
||||||
|
const bootstrapSecretRef = useRef("");
|
||||||
|
|
||||||
const bootstrapWithSecret = useCallback(
|
const bootstrapWithSecret = useCallback(
|
||||||
(secret: string) => {
|
(secret: string) => {
|
||||||
@@ -117,22 +136,37 @@ export default function App() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (secret) saveSecret(secret);
|
if (secret) saveSecret(secret);
|
||||||
const url = deriveWsUrl(boot.ws_path, boot.token);
|
const url = deriveWsUrl(boot.ws_path, boot.token);
|
||||||
const client = new NanobotClient({
|
let client: NanobotClient;
|
||||||
|
client = new NanobotClient({
|
||||||
url,
|
url,
|
||||||
onReauth: async () => {
|
onReauth: async () => {
|
||||||
try {
|
try {
|
||||||
const refreshed = await fetchBootstrap("", secret);
|
const refreshed = await fetchBootstrap("", bootstrapSecretRef.current);
|
||||||
return deriveWsUrl(refreshed.ws_path, refreshed.token);
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
bootstrapSecretRef.current = secret;
|
||||||
client.connect();
|
client.connect();
|
||||||
setState({
|
setState({
|
||||||
status: "ready",
|
status: "ready",
|
||||||
client,
|
client,
|
||||||
token: boot.token,
|
token: boot.token,
|
||||||
|
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
|
||||||
modelName: boot.model_name ?? null,
|
modelName: boot.model_name ?? null,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} 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(() => {
|
useEffect(() => {
|
||||||
const saved = loadSavedSecret();
|
const saved = loadSavedSecret();
|
||||||
return bootstrapWithSecret(saved);
|
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 { t, i18n } = useTranslation();
|
||||||
const { client } = useClient();
|
const { client } = useClient();
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
@@ -362,9 +431,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
|||||||
});
|
});
|
||||||
}, [client, t]);
|
}, [client, t]);
|
||||||
|
|
||||||
const onTurnEnd = useCallback(() => {
|
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh);
|
||||||
void refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
const onConfirmDelete = useCallback(async () => {
|
const onConfirmDelete = useCallback(async () => {
|
||||||
if (!pendingDelete) return;
|
if (!pendingDelete) return;
|
||||||
@@ -386,8 +453,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
|||||||
|
|
||||||
const headerTitle = activeSession
|
const headerTitle = activeSession
|
||||||
? activeSession.title ||
|
? activeSession.title ||
|
||||||
activeSession.preview ||
|
deriveTitle(activeSession.preview, t("chat.newChat"))
|
||||||
t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) })
|
|
||||||
: t("app.brand");
|
: t("app.brand");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -415,6 +481,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
|||||||
const showMainSidebar = view !== "settings";
|
const showMainSidebar = view !== "settings";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
<div className="relative flex h-full w-full overflow-hidden">
|
<div className="relative flex h-full w-full overflow-hidden">
|
||||||
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
|
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
|
||||||
{showMainSidebar ? (
|
{showMainSidebar ? (
|
||||||
@@ -503,5 +570,6 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { deriveTitle } from "@/lib/format";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ChatSummary } from "@/lib/types";
|
import type { ChatSummary } from "@/lib/types";
|
||||||
|
|
||||||
@@ -64,8 +65,11 @@ export function ChatList({
|
|||||||
const fallbackTitle = t("chat.fallbackTitle", {
|
const fallbackTitle = t("chat.fallbackTitle", {
|
||||||
id: s.chatId.slice(0, 6),
|
id: s.chatId.slice(0, 6),
|
||||||
});
|
});
|
||||||
const rawLabel = (s.title || s.preview)?.trim();
|
const generatedTitle = s.title?.trim() || "";
|
||||||
const title = rawLabel || fallbackTitle;
|
const title =
|
||||||
|
generatedTitle || deriveTitle(s.preview, t("chat.newChat"));
|
||||||
|
const tooltipTitle =
|
||||||
|
generatedTitle || deriveTitle(s.preview, fallbackTitle);
|
||||||
return (
|
return (
|
||||||
<li key={s.key} className="min-w-0">
|
<li key={s.key} className="min-w-0">
|
||||||
<div
|
<div
|
||||||
@@ -79,7 +83,7 @@ export function ChatList({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(s.key)}
|
onClick={() => onSelect(s.key)}
|
||||||
title={rawLabel || fallbackTitle}
|
title={tooltipTitle}
|
||||||
className="min-w-0 flex-1 overflow-hidden py-1.5 text-left"
|
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>
|
<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 { Check, Copy } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface CodeBlockProps {
|
interface CodeBlockProps {
|
||||||
language?: string;
|
language?: string;
|
||||||
code: string;
|
code: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
highlight?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read dark mode straight from the DOM — stays in sync with Tailwind's `dark:`. */
|
interface HighlightedCodeProps {
|
||||||
function useIsDark() {
|
language?: string;
|
||||||
const [isDark, setIsDark] = useState(() =>
|
code: string;
|
||||||
typeof document !== "undefined"
|
isDark: boolean;
|
||||||
? document.documentElement.classList.contains("dark")
|
}
|
||||||
: true,
|
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
function PlainCodeFallback({ code }: { code: string }) {
|
||||||
const el = document.documentElement;
|
return (
|
||||||
const observer = new MutationObserver(() => {
|
<pre
|
||||||
setIsDark(el.classList.contains("dark"));
|
className="m-0 overflow-x-auto whitespace-pre-wrap p-4 font-mono text-sm leading-[1.6]"
|
||||||
});
|
>
|
||||||
observer.observe(el, { attributeFilter: ["class"] });
|
<code>{code}</code>
|
||||||
return () => observer.disconnect();
|
</pre>
|
||||||
}, []);
|
);
|
||||||
|
|
||||||
return isDark;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CodeBlock({ language, code, className }: CodeBlockProps) {
|
export function CodeBlock({
|
||||||
|
language,
|
||||||
|
code,
|
||||||
|
className,
|
||||||
|
highlight = true,
|
||||||
|
}: CodeBlockProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const isDark = useIsDark();
|
const isDark = useThemeValue() === "dark";
|
||||||
|
|
||||||
const onCopy = useCallback(() => {
|
const onCopy = useCallback(() => {
|
||||||
if (!navigator.clipboard) return;
|
if (!navigator.clipboard) return;
|
||||||
@@ -86,20 +117,13 @@ export function CodeBlock({ language, code, className }: CodeBlockProps) {
|
|||||||
<span>{copied ? t("code.copied") : t("code.copy")}</span>
|
<span>{copied ? t("code.copied") : t("code.copy")}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<SyntaxHighlighter
|
{highlight ? (
|
||||||
language={language}
|
<Suspense fallback={<PlainCodeFallback code={code} />}>
|
||||||
style={isDark ? oneDark : oneLight}
|
<LazyHighlightedCode language={language} code={code} isDark={isDark} />
|
||||||
customStyle={{
|
</Suspense>
|
||||||
margin: 0,
|
) : (
|
||||||
padding: "1rem",
|
<PlainCodeFallback code={code} />
|
||||||
fontSize: "0.875rem",
|
)}
|
||||||
lineHeight: 1.6,
|
|
||||||
}}
|
|
||||||
PreTag="pre"
|
|
||||||
wrapLongLines
|
|
||||||
>
|
|
||||||
{code}
|
|
||||||
</SyntaxHighlighter>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,21 +36,25 @@ export function ConnectionBadge() {
|
|||||||
status === "connecting" ||
|
status === "connecting" ||
|
||||||
status === "reconnecting" ||
|
status === "reconnecting" ||
|
||||||
status === "error";
|
status === "error";
|
||||||
|
const label = t(`connection.${status}`);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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,
|
meta.color,
|
||||||
)}
|
)}
|
||||||
aria-live="polite"
|
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 && (
|
{pulsing && (
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-75" />
|
<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>
|
</span>
|
||||||
{t(`connection.${status}`)}
|
<span className="sr-only">{label}</span>
|
||||||
</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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface MarkdownTextProps {
|
interface MarkdownTextProps {
|
||||||
children: string;
|
children: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
streaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer");
|
const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer");
|
||||||
const LazyMarkdownRenderer = lazy(loadMarkdownRenderer);
|
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 {
|
export function preloadMarkdownText(): void {
|
||||||
void loadMarkdownRenderer();
|
void loadMarkdownRenderer();
|
||||||
}
|
}
|
||||||
@@ -19,7 +50,18 @@ export function preloadMarkdownText(): void {
|
|||||||
* ``remark-math`` / ``rehype-katex``, and fenced code blocks delegated to
|
* ``remark-math`` / ``rehype-katex``, and fenced code blocks delegated to
|
||||||
* ``CodeBlock`` for copy-to-clipboard and syntax highlighting.
|
* ``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 (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
@@ -29,11 +71,73 @@ export function MarkdownText({ children, className }: MarkdownTextProps) {
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{renderedSource}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<LazyMarkdownRenderer className={className}>{children}</LazyMarkdownRenderer>
|
<MemoizedMarkdownRenderer
|
||||||
|
source={renderedSource}
|
||||||
|
className={className}
|
||||||
|
highlightCode={highlightCode}
|
||||||
|
/>
|
||||||
</Suspense>
|
</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 ReactMarkdown from "react-markdown";
|
||||||
import rehypeKatex from "rehype-katex";
|
import rehypeKatex from "rehype-katex";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import remarkMath from "remark-math";
|
import remarkMath from "remark-math";
|
||||||
|
|
||||||
import { CodeBlock } from "@/components/CodeBlock";
|
import { CodeBlock } from "@/components/CodeBlock";
|
||||||
|
import { FileReferenceChip, isLikelyFilePath } from "@/components/FileReferenceChip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import "katex/dist/katex.min.css";
|
import "katex/dist/katex.min.css";
|
||||||
@@ -12,8 +14,12 @@ import "katex/dist/katex.min.css";
|
|||||||
interface MarkdownTextRendererProps {
|
interface MarkdownTextRendererProps {
|
||||||
children: string;
|
children: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
highlightCode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const remarkPlugins = [remarkGfm, remarkMath];
|
||||||
|
const rehypePlugins = [rehypeKatex];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Heavy markdown stack (GFM, math, KaTeX, syntax highlighting) kept in a
|
* Heavy markdown stack (GFM, math, KaTeX, syntax highlighting) kept in a
|
||||||
* separate chunk so the app shell can paint sooner on refresh.
|
* separate chunk so the app shell can paint sooner on refresh.
|
||||||
@@ -21,37 +27,27 @@ interface MarkdownTextRendererProps {
|
|||||||
export default function MarkdownTextRenderer({
|
export default function MarkdownTextRenderer({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
highlightCode = true,
|
||||||
}: MarkdownTextRendererProps) {
|
}: MarkdownTextRendererProps) {
|
||||||
return (
|
const components = useMemo<Components>(
|
||||||
<div
|
() => ({
|
||||||
className={cn(
|
|
||||||
"markdown-content prose max-w-none dark:prose-invert",
|
|
||||||
"prose-headings:mt-4 prose-headings:mb-2 prose-headings:font-semibold prose-headings:tracking-tight",
|
|
||||||
"prose-h1:text-lg prose-h2:text-base prose-h3:text-sm prose-h4:text-[13px]",
|
|
||||||
"prose-p:my-2",
|
|
||||||
"prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5",
|
|
||||||
"prose-blockquote:my-3 prose-blockquote:border-l-2 prose-blockquote:font-normal",
|
|
||||||
"prose-blockquote:not-italic prose-blockquote:text-foreground/80",
|
|
||||||
"prose-a:text-primary prose-a:underline-offset-2 hover:prose-a:opacity-80",
|
|
||||||
"prose-hr:my-6",
|
|
||||||
"prose-pre:my-0 prose-pre:bg-transparent prose-pre:p-0",
|
|
||||||
"prose-code:before:content-none prose-code:after:content-none prose-code:font-normal",
|
|
||||||
"prose-table:my-3 prose-th:text-left prose-th:font-medium",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
style={{ lineHeight: "var(--cjk-line-height)" }}
|
|
||||||
>
|
|
||||||
<ReactMarkdown
|
|
||||||
remarkPlugins={[remarkGfm, remarkMath]}
|
|
||||||
rehypePlugins={[rehypeKatex]}
|
|
||||||
components={{
|
|
||||||
code({ className: cls, children: kids, ...props }) {
|
code({ className: cls, children: kids, ...props }) {
|
||||||
const match = /language-(\w+)/.exec(cls || "");
|
const match = /language-(\w+)/.exec(cls || "");
|
||||||
if (match) {
|
if (match) {
|
||||||
const code = String(kids).replace(/\n$/, "");
|
const code = String(kids).replace(/\n$/, "");
|
||||||
return <CodeBlock language={match[1]} code={code} className="my-3" />;
|
return (
|
||||||
|
<CodeBlock
|
||||||
|
language={match[1]}
|
||||||
|
code={code}
|
||||||
|
className="my-3"
|
||||||
|
highlight={highlightCode}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const raw = String(kids).replace(/\n$/, "");
|
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. */
|
/** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
|
||||||
const widePlainBlock = raw.includes("\n") || raw.length > 120;
|
const widePlainBlock = raw.includes("\n") || raw.length > 120;
|
||||||
if (widePlainBlock) {
|
if (widePlainBlock) {
|
||||||
@@ -112,7 +108,33 @@ export default function MarkdownTextRenderer({
|
|||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
}}
|
}),
|
||||||
|
[highlightCode],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"markdown-content prose max-w-none dark:prose-invert",
|
||||||
|
"prose-headings:mt-4 prose-headings:mb-2 prose-headings:font-semibold prose-headings:tracking-tight",
|
||||||
|
"prose-h1:text-lg prose-h2:text-base prose-h3:text-sm prose-h4:text-[13px]",
|
||||||
|
"prose-p:my-2",
|
||||||
|
"prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5",
|
||||||
|
"prose-blockquote:my-3 prose-blockquote:border-l-2 prose-blockquote:font-normal",
|
||||||
|
"prose-blockquote:not-italic prose-blockquote:text-foreground/80",
|
||||||
|
"prose-a:text-primary prose-a:underline-offset-2 hover:prose-a:opacity-80",
|
||||||
|
"prose-hr:my-6",
|
||||||
|
"prose-pre:my-0 prose-pre:bg-transparent prose-pre:p-0",
|
||||||
|
"prose-code:before:content-none prose-code:after:content-none prose-code:font-normal",
|
||||||
|
"prose-table:my-3 prose-th:text-left prose-th:font-medium",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={{ lineHeight: "var(--cjk-line-height)" }}
|
||||||
|
>
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={remarkPlugins}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
components={components}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useDeferredValue,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -120,7 +119,7 @@ export function MessageBubble({
|
|||||||
<TypingDots />
|
<TypingDots />
|
||||||
) : empty && message.isStreaming ? null : (
|
) : empty && message.isStreaming ? null : (
|
||||||
<>
|
<>
|
||||||
<MarkdownText>{message.content}</MarkdownText>
|
<MarkdownText streaming={!!message.isStreaming}>{message.content}</MarkdownText>
|
||||||
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
|
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
|
||||||
{showAssistantFooterRow ? (
|
{showAssistantFooterRow ? (
|
||||||
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
|
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
|
||||||
@@ -167,10 +166,15 @@ function MessageMedia({
|
|||||||
align: "left" | "right";
|
align: "left" | "right";
|
||||||
}) {
|
}) {
|
||||||
if (media.length === 0) return null;
|
if (media.length === 0) return null;
|
||||||
const images = media
|
const images: UIImage[] = [];
|
||||||
.filter((item) => item.kind === "image")
|
const nonImages: UIMediaAttachment[] = [];
|
||||||
.map(({ url, name }) => ({ url, name }));
|
for (const item of media) {
|
||||||
const nonImages = media.filter((item) => item.kind !== "image");
|
if (item.kind === "image") {
|
||||||
|
images.push({ url: item.url, name: item.name });
|
||||||
|
} else {
|
||||||
|
nonImages.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -276,13 +280,14 @@ function UserImages({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Only real-URL images can open in the lightbox; historical-replay
|
// Only real-URL images can open in the lightbox; historical-replay
|
||||||
// placeholders (no URL) have nothing to zoom into.
|
// placeholders (no URL) have nothing to zoom into.
|
||||||
const viewable = images
|
const viewableImages: UIImage[] = [];
|
||||||
.map((img, i) => ({ img, i }))
|
const originalToViewable = new Map<number, number>();
|
||||||
.filter(({ img }) => typeof img.url === "string" && img.url.length > 0);
|
for (let i = 0; i < images.length; i += 1) {
|
||||||
const viewableImages = viewable.map(({ img }) => img);
|
const img = images[i];
|
||||||
const originalToViewable = new Map<number, number>(
|
if (typeof img.url !== "string" || img.url.length === 0) continue;
|
||||||
viewable.map(({ i }, v) => [i, v]),
|
originalToViewable.set(i, viewableImages.length);
|
||||||
);
|
viewableImages.push(img);
|
||||||
|
}
|
||||||
|
|
||||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
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({
|
export function StreamingLabelSheen({
|
||||||
children,
|
children,
|
||||||
active,
|
active,
|
||||||
@@ -426,21 +431,21 @@ export function StreamingLabelSheen({
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const sheenText =
|
||||||
|
typeof children === "string" || typeof children === "number"
|
||||||
|
? String(children)
|
||||||
|
: undefined;
|
||||||
return (
|
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
|
<span
|
||||||
|
data-sheen-text={active ? sheenText : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-0 block font-medium leading-normal text-muted-foreground",
|
"block w-fit max-w-full truncate font-medium leading-normal",
|
||||||
!active && "truncate",
|
active ? "streaming-text-sheen" : "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
{active ? (
|
|
||||||
<span className="reasoning-sheen-track" aria-hidden dir="ltr">
|
|
||||||
<span className="reasoning-sheen-stripe" />
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -474,8 +479,6 @@ export function ReasoningBubble({
|
|||||||
embeddedInCluster = false,
|
embeddedInCluster = false,
|
||||||
}: ReasoningBubbleProps) {
|
}: ReasoningBubbleProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const deferredText = useDeferredValue(text);
|
|
||||||
const markdownSource = streaming ? deferredText : text;
|
|
||||||
const [userToggled, setUserToggled] = useState(false);
|
const [userToggled, setUserToggled] = useState(false);
|
||||||
const [openLocal, setOpenLocal] = useState(true);
|
const [openLocal, setOpenLocal] = useState(true);
|
||||||
const open = userToggled ? openLocal : streaming;
|
const open = userToggled ? openLocal : streaming;
|
||||||
@@ -531,6 +534,7 @@ export function ReasoningBubble({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MarkdownText
|
<MarkdownText
|
||||||
|
streaming={streaming}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-[12.5px] italic text-muted-foreground/88",
|
"text-[12.5px] italic text-muted-foreground/88",
|
||||||
"prose-p:my-1.5 prose-li:my-0.5",
|
"prose-p:my-1.5 prose-li:my-0.5",
|
||||||
@@ -541,7 +545,7 @@ export function ReasoningBubble({
|
|||||||
"prose-code:text-[0.92em]",
|
"prose-code:text-[0.92em]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{markdownSource}
|
{text}
|
||||||
</MarkdownText>
|
</MarkdownText>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -117,12 +117,12 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="bg-sidebar-border/50" />
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={props.onOpenSettings}
|
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 />
|
<Settings className="h-3.5 w-3.5" aria-hidden />
|
||||||
{t("sidebar.settings")}
|
{t("sidebar.settings")}
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ import type { SettingsPayload, WebSearchSettingsUpdate } from "@/lib/types";
|
|||||||
type SettingsSectionKey = "general" | "byok";
|
type SettingsSectionKey = "general" | "byok";
|
||||||
type ByokPaneKey = "llm" | "web-search";
|
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 {
|
interface SettingsViewProps {
|
||||||
theme: "light" | "dark";
|
theme: "light" | "dark";
|
||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
@@ -176,7 +183,8 @@ export function SettingsView({
|
|||||||
if (!provider) return;
|
if (!provider) return;
|
||||||
const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "" };
|
const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "" };
|
||||||
const apiKey = providerForm.apiKey.trim();
|
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"));
|
setError(t("settings.byok.apiKeyRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -917,7 +925,10 @@ function ByokSettings({
|
|||||||
const [activePane, setActivePane] = useState<ByokPaneKey>("llm");
|
const [activePane, setActivePane] = useState<ByokPaneKey>("llm");
|
||||||
const [showAllUnconfigured, setShowAllUnconfigured] = useState(false);
|
const [showAllUnconfigured, setShowAllUnconfigured] = useState(false);
|
||||||
const configuredProviders = settings.providers.filter((provider) => provider.configured);
|
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 initialUnconfiguredCount = 6;
|
||||||
const visibleUnconfiguredProviders = showAllUnconfigured
|
const visibleUnconfiguredProviders = showAllUnconfigured
|
||||||
? unconfiguredProviders
|
? unconfiguredProviders
|
||||||
@@ -935,6 +946,12 @@ function ByokSettings({
|
|||||||
const saving = providerSaving === provider.name;
|
const saving = providerSaving === provider.name;
|
||||||
const keyVisible = !!visibleProviderKeys[provider.name];
|
const keyVisible = !!visibleProviderKeys[provider.name];
|
||||||
const editingKey = !provider.configured || !!editingProviderKeys[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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={provider.name}
|
key={provider.name}
|
||||||
@@ -1045,7 +1062,7 @@ function ByokSettings({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onSaveProvider(provider.name)}
|
onClick={() => onSaveProvider(provider.name)}
|
||||||
disabled={saving || (!provider.configured && !form.apiKey.trim())}
|
disabled={saving || missingRequiredApiKey || missingOptionalCredential}
|
||||||
className="rounded-full"
|
className="rounded-full"
|
||||||
>
|
>
|
||||||
{saving ? t("settings.actions.saving") : t("settings.actions.save")}
|
{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> = {
|
const PROVIDER_ICONS: Record<string, LucideIcon> = {
|
||||||
custom: Hexagon,
|
custom: Hexagon,
|
||||||
openrouter: Sparkles,
|
openrouter: Sparkles,
|
||||||
@@ -1212,6 +1248,12 @@ const PROVIDER_ICONS: Record<string, LucideIcon> = {
|
|||||||
qianfan: Database,
|
qianfan: Database,
|
||||||
azure_openai: Cloud,
|
azure_openai: Cloud,
|
||||||
bedrock: Database,
|
bedrock: Database,
|
||||||
|
vllm: Cpu,
|
||||||
|
ollama: Cpu,
|
||||||
|
lm_studio: Cpu,
|
||||||
|
atomic_chat: Cpu,
|
||||||
|
ovms: Cpu,
|
||||||
|
nvidia: Zap,
|
||||||
};
|
};
|
||||||
|
|
||||||
function ProviderIcon({ provider }: { provider: string }) {
|
function ProviderIcon({ provider }: { provider: string }) {
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ChevronRight, Layers } from "lucide-react";
|
import { AlertCircle, ChevronRight, Layers } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { FileReferenceChip } from "@/components/FileReferenceChip";
|
||||||
import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble";
|
import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble";
|
||||||
import { cn } from "@/lib/utils";
|
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). */
|
/** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */
|
||||||
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
|
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
|
||||||
|
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
|
||||||
|
|
||||||
export function isReasoningOnlyAssistant(m: UIMessage): boolean {
|
export function isReasoningOnlyAssistant(m: UIMessage): boolean {
|
||||||
if (m.role !== "assistant" || m.kind === "trace") return false;
|
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";
|
return isReasoningOnlyAssistant(m) || m.kind === "trace";
|
||||||
}
|
}
|
||||||
|
|
||||||
function countToolCalls(messages: UIMessage[]): number {
|
interface ActivityCounts {
|
||||||
let n = 0;
|
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) {
|
for (const m of messages) {
|
||||||
if (m.kind !== "trace") continue;
|
if (isReasoningOnlyAssistant(m)) {
|
||||||
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
|
reasoningSteps += 1;
|
||||||
n += Math.max(lines, 1);
|
continue;
|
||||||
}
|
}
|
||||||
return n;
|
if (m.kind === "trace") {
|
||||||
|
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
|
||||||
|
toolCalls += lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
interface AgentActivityClusterProps {
|
||||||
@@ -46,24 +104,56 @@ export function AgentActivityCluster({
|
|||||||
hasBodyBelow,
|
hasBodyBelow,
|
||||||
}: AgentActivityClusterProps) {
|
}: AgentActivityClusterProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const reasoningSteps = messages.filter(isReasoningOnlyAssistant).length;
|
const fileEdits = useMemo(
|
||||||
const toolCalls = countToolCalls(messages);
|
() => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming),
|
||||||
|
[messages, isTurnStreaming],
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
reasoningSteps,
|
||||||
|
toolCalls,
|
||||||
|
fileCount,
|
||||||
|
added,
|
||||||
|
deleted,
|
||||||
|
hasEditingFiles,
|
||||||
|
hasFailedFiles,
|
||||||
|
primaryFilePath,
|
||||||
|
} = countActivity(messages, fileEdits);
|
||||||
|
|
||||||
const [userToggledOuter, setUserToggledOuter] = useState(false);
|
const [userToggledOuter, setUserToggledOuter] = useState(false);
|
||||||
const [outerOpenLocal, setOuterOpenLocal] = 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. */
|
/** Collapsed by default during “Working…” and after the turn; user expands to inspect traces. */
|
||||||
const outerExpanded = userToggledOuter ? outerOpenLocal : false;
|
const outerExpanded = userToggledOuter ? outerOpenLocal : false;
|
||||||
|
|
||||||
const headerBusy = isTurnStreaming;
|
const hasLiveEditingFiles = isTurnStreaming && hasEditingFiles;
|
||||||
|
const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming;
|
||||||
|
|
||||||
const summary =
|
const fileActivitySummary = fileCount > 0
|
||||||
isTurnStreaming
|
? 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
|
? reasoningSteps > 0
|
||||||
? t("message.agentActivityLiveSummary", {
|
? t("message.agentActivityLiveSummary", {
|
||||||
reasoning: reasoningSteps,
|
reasoning: reasoningSteps,
|
||||||
tools: toolCalls,
|
tools: toolCalls,
|
||||||
defaultValue: "Working… · {{reasoning}} steps · {{tools}} tool calls",
|
defaultValue: "Working… · {{reasoning}} steps · {{tools}} tool calls",
|
||||||
})
|
})
|
||||||
|
: toolCalls === 0 && fileCount > 0
|
||||||
|
? t("message.agentActivityLiveFilesOnly", { defaultValue: "Working…" })
|
||||||
: t("message.agentActivityLiveToolsOnly", {
|
: t("message.agentActivityLiveToolsOnly", {
|
||||||
tools: toolCalls,
|
tools: toolCalls,
|
||||||
defaultValue: "Working… · {{tools}} tool calls",
|
defaultValue: "Working… · {{tools}} tool calls",
|
||||||
@@ -74,16 +164,73 @@ export function AgentActivityCluster({
|
|||||||
tools: toolCalls,
|
tools: toolCalls,
|
||||||
defaultValue: "{{reasoning}} steps · {{tools}} tool calls",
|
defaultValue: "{{reasoning}} steps · {{tools}} tool calls",
|
||||||
})
|
})
|
||||||
|
: toolCalls === 0 && fileCount > 0
|
||||||
|
? t("message.agentActivityFilesOnly", { defaultValue: "File changes" })
|
||||||
: t("message.agentActivityToolsOnly", {
|
: t("message.agentActivityToolsOnly", {
|
||||||
tools: toolCalls,
|
tools: toolCalls,
|
||||||
defaultValue: "{{tools}} tool calls",
|
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 toggleOuter = () => {
|
||||||
|
const nextOpen = userToggledOuter ? !outerOpenLocal : !outerExpanded;
|
||||||
|
if (nextOpen) {
|
||||||
|
autoFollowActivityRef.current = true;
|
||||||
|
}
|
||||||
setUserToggledOuter(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 (
|
return (
|
||||||
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
|
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
|
||||||
<button
|
<button
|
||||||
@@ -96,12 +243,19 @@ export function AgentActivityCluster({
|
|||||||
aria-expanded={outerExpanded}
|
aria-expanded={outerExpanded}
|
||||||
>
|
>
|
||||||
<Layers className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
<Layers className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||||
|
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-left">
|
||||||
<StreamingLabelSheen
|
<StreamingLabelSheen
|
||||||
active={headerBusy}
|
active={headerBusy}
|
||||||
className="min-w-0 flex-1 text-left"
|
className="min-w-0"
|
||||||
>
|
>
|
||||||
{summary}
|
{summary}
|
||||||
</StreamingLabelSheen>
|
</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
|
<ChevronRight
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -118,29 +272,38 @@ export function AgentActivityCluster({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={activityScrollRef}
|
||||||
|
data-testid="agent-activity-scroll"
|
||||||
|
onScroll={onActivityScroll}
|
||||||
className={cn(
|
className={cn(
|
||||||
CLUSTER_SCROLL_MAX_CLASS,
|
CLUSTER_SCROLL_MAX_CLASS,
|
||||||
"overflow-y-auto px-2 py-1.5 scrollbar-thin scrollbar-track-transparent",
|
"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) => {
|
{messages.map((m) => {
|
||||||
if (isReasoningOnlyAssistant(m)) {
|
if (isReasoningOnlyAssistant(m)) {
|
||||||
return (
|
return (
|
||||||
<ReasoningBubble
|
<ReasoningBubble
|
||||||
key={m.id}
|
key={m.id}
|
||||||
text={m.reasoning ?? ""}
|
text={m.reasoning ?? ""}
|
||||||
streaming={!!m.reasoningStreaming}
|
streaming={isTurnStreaming && !!m.reasoningStreaming}
|
||||||
hasBodyBelow={false}
|
hasBodyBelow={false}
|
||||||
embeddedInCluster
|
embeddedInCluster
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (m.kind === "trace") {
|
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;
|
return null;
|
||||||
})}
|
})}
|
||||||
|
{fileEdits.length ? <FileEditGroup edits={fileEdits} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,3 +311,231 @@ export function AgentActivityCluster({
|
|||||||
</div>
|
</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 { MessageBubble } from "@/components/MessageBubble";
|
||||||
import {
|
import {
|
||||||
AgentActivityCluster,
|
AgentActivityCluster,
|
||||||
@@ -9,6 +12,8 @@ interface ThreadMessagesProps {
|
|||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
/** When true, agent turn still in flight — keeps activity cluster expanded. */
|
/** When true, agent turn still in flight — keeps activity cluster expanded. */
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
|
hiddenMessageCount?: number;
|
||||||
|
onLoadEarlier?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DisplayUnit =
|
export type DisplayUnit =
|
||||||
@@ -30,31 +35,160 @@ export function isFinalAssistantSliceBeforeNextUser(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
|
export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
|
||||||
const out: DisplayUnit[] = [];
|
const out: DisplayUnit[] = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
while (i < messages.length) {
|
while (i < messages.length) {
|
||||||
const m = messages[i];
|
const m = messages[i];
|
||||||
if (isAgentActivityMember(m)) {
|
if (isAgentActivityMember(m)) {
|
||||||
const cluster: UIMessage[] = [];
|
const cluster: UIMessage[] = [];
|
||||||
while (i < messages.length && isAgentActivityMember(messages[i])) {
|
let segmentId: string | undefined = m.activitySegmentId;
|
||||||
cluster.push(messages[i]);
|
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;
|
i += 1;
|
||||||
}
|
}
|
||||||
out.push({ type: "cluster", messages: cluster });
|
out.push({ type: "cluster", messages: cluster });
|
||||||
continue;
|
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 });
|
out.push({ type: "single", message: m });
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThreadMessages({ messages, isStreaming = false }: ThreadMessagesProps) {
|
function clusterSegmentId(messages: UIMessage[]): string | undefined {
|
||||||
const units = buildDisplayUnits(messages);
|
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 (
|
return (
|
||||||
<div className="flex w-full flex-col">
|
<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) => {
|
{units.map((unit, index) => {
|
||||||
const prev = units[index - 1];
|
const prev = units[index - 1];
|
||||||
const marginTop =
|
const marginTop =
|
||||||
@@ -72,7 +206,7 @@ export function ThreadMessages({ messages, isStreaming = false }: ThreadMessages
|
|||||||
{unit.type === "cluster" ? (
|
{unit.type === "cluster" ? (
|
||||||
<AgentActivityCluster
|
<AgentActivityCluster
|
||||||
messages={unit.messages}
|
messages={unit.messages}
|
||||||
isTurnStreaming={isStreaming}
|
isTurnStreaming={index === liveActivityClusterIndex}
|
||||||
hasBodyBelow={hasBodyBelow}
|
hasBodyBelow={hasBodyBelow}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -80,7 +214,7 @@ export function ThreadMessages({ messages, isStreaming = false }: ThreadMessages
|
|||||||
message={unit.message}
|
message={unit.message}
|
||||||
showAssistantCopyAction={
|
showAssistantCopyAction={
|
||||||
unit.message.role === "assistant"
|
unit.message.role === "assistant"
|
||||||
? isFinalAssistantSliceBeforeNextUser(units, index)
|
? copyFlags[index]
|
||||||
: true
|
: 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 {
|
function unitKey(unit: DisplayUnit, index: number): string {
|
||||||
if (unit.type === "cluster") {
|
if (unit.type === "cluster") {
|
||||||
const anchor = unit.messages[0]?.id;
|
const anchor = unit.messages[0]?.id;
|
||||||
|
|||||||
@@ -167,8 +167,9 @@ export function ThreadShell({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chatId) return;
|
if (!chatId) return;
|
||||||
return client.onSessionUpdate((updatedChatId) => {
|
return client.onSessionUpdate((updatedChatId, scope) => {
|
||||||
if (updatedChatId !== chatId) return;
|
if (updatedChatId !== chatId) return;
|
||||||
|
if (scope === "metadata") return;
|
||||||
pendingCanonicalHydrateRef.current.add(chatId);
|
pendingCanonicalHydrateRef.current.add(chatId);
|
||||||
refreshHistory();
|
refreshHistory();
|
||||||
});
|
});
|
||||||
@@ -389,6 +390,7 @@ export function ThreadShell({
|
|||||||
composer={composer}
|
composer={composer}
|
||||||
scrollToBottomSignal={scrollToBottomSignal}
|
scrollToBottomSignal={scrollToBottomSignal}
|
||||||
conversationKey={historyKey}
|
conversationKey={historyKey}
|
||||||
|
showScrollToBottomButton={!!session}
|
||||||
/>
|
/>
|
||||||
</section>
|
</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 { ArrowDown } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
||||||
|
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { UIMessage } from "@/lib/types";
|
import type { UIMessage } from "@/lib/types";
|
||||||
@@ -14,9 +23,27 @@ interface ThreadViewportProps {
|
|||||||
emptyState?: ReactNode;
|
emptyState?: ReactNode;
|
||||||
scrollToBottomSignal?: number;
|
scrollToBottomSignal?: number;
|
||||||
conversationKey?: string | null;
|
conversationKey?: string | null;
|
||||||
|
showScrollToBottomButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NEAR_BOTTOM_PX = 48;
|
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({
|
export function ThreadViewport({
|
||||||
messages,
|
messages,
|
||||||
@@ -25,18 +52,33 @@ export function ThreadViewport({
|
|||||||
emptyState,
|
emptyState,
|
||||||
scrollToBottomSignal = 0,
|
scrollToBottomSignal = 0,
|
||||||
conversationKey = null,
|
conversationKey = null,
|
||||||
|
showScrollToBottomButton = true,
|
||||||
}: ThreadViewportProps) {
|
}: ThreadViewportProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const composerDockRef = useRef<HTMLDivElement>(null);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const lastConversationKeyRef = useRef<string | null>(conversationKey);
|
const lastConversationKeyRef = useRef<string | null>(conversationKey);
|
||||||
const pendingConversationScrollRef = useRef(true);
|
const pendingConversationScrollRef = useRef(true);
|
||||||
const scrollFrameIdsRef = useRef<number[]>([]);
|
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). */
|
/** User scrolled away from the bottom; do not auto-yank until they return or we reset (new chat / send). */
|
||||||
const userReadingHistoryRef = useRef(false);
|
const userReadingHistoryRef = useRef(false);
|
||||||
const [atBottom, setAtBottom] = useState(true);
|
const [atBottom, setAtBottom] = useState(true);
|
||||||
|
const [composerDockHeight, setComposerDockHeight] = useState(0);
|
||||||
|
const [visibleMessageCount, setVisibleMessageCount] =
|
||||||
|
useState(INITIAL_HISTORY_WINDOW);
|
||||||
const hasMessages = messages.length > 0;
|
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(() => {
|
const cancelScheduledBottomScroll = useCallback(() => {
|
||||||
for (const id of scrollFrameIdsRef.current) {
|
for (const id of scrollFrameIdsRef.current) {
|
||||||
@@ -77,6 +119,30 @@ export function ThreadViewport({
|
|||||||
[cancelScheduledBottomScroll, scrollToBottomNow],
|
[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(() => {
|
useEffect(() => {
|
||||||
if (!atBottom) return;
|
if (!atBottom) return;
|
||||||
// Instant jump: CSS scroll-smooth + behavior "auto" still animates in some
|
// Instant jump: CSS scroll-smooth + behavior "auto" still animates in some
|
||||||
@@ -96,8 +162,19 @@ export function ThreadViewport({
|
|||||||
pendingConversationScrollRef.current = true;
|
pendingConversationScrollRef.current = true;
|
||||||
userReadingHistoryRef.current = false;
|
userReadingHistoryRef.current = false;
|
||||||
setAtBottom(true);
|
setAtBottom(true);
|
||||||
|
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
|
||||||
}, [conversationKey]);
|
}, [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(() => {
|
useLayoutEffect(() => {
|
||||||
if (!pendingConversationScrollRef.current) return;
|
if (!pendingConversationScrollRef.current) return;
|
||||||
if (!conversationKey) {
|
if (!conversationKey) {
|
||||||
@@ -110,6 +187,10 @@ export function ThreadViewport({
|
|||||||
pendingConversationScrollRef.current = false;
|
pendingConversationScrollRef.current = false;
|
||||||
}, [conversationKey, hasMessages, messages, scrollToBottom]);
|
}, [conversationKey, hasMessages, messages, scrollToBottom]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
measureComposerDock();
|
||||||
|
}, [composer, hasMessages, measureComposerDock]);
|
||||||
|
|
||||||
useEffect(() => cancelScheduledBottomScroll, [cancelScheduledBottomScroll]);
|
useEffect(() => cancelScheduledBottomScroll, [cancelScheduledBottomScroll]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -123,6 +204,14 @@ export function ThreadViewport({
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [hasMessages, scrollToBottom]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el) return;
|
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 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="flex-1 px-4 pb-20 pt-4">
|
||||||
<div className="mx-auto w-full max-w-[49.5rem]">
|
<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>
|
</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">
|
<div className="px-4 pb-3">
|
||||||
{composer}
|
{composer}
|
||||||
</div>
|
</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"
|
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!atBottom && (
|
{showScrollToBottomButton && !atBottom && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => scrollToBottom(true, 1, { force: true })}
|
onClick={() => scrollToBottom(true, 1, { force: true })}
|
||||||
className={cn(
|
className={cn(
|
||||||
/* Keep clear of sticky composer (textarea + toolbar + optional goal strip). */
|
/* 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",
|
"bg-background/90 backdrop-blur",
|
||||||
"animate-in fade-in-0 zoom-in-95",
|
"animate-in fade-in-0 zoom-in-95",
|
||||||
)}
|
)}
|
||||||
|
style={{ bottom: scrollButtonBottom }}
|
||||||
aria-label={t("thread.scrollToBottom")}
|
aria-label={t("thread.scrollToBottom")}
|
||||||
>
|
>
|
||||||
<ArrowDown className="h-4 w-4" />
|
<ArrowDown className="h-4 w-4" />
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const TooltipContent = React.forwardRef<
|
|||||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipPrimitive.Content
|
<TooltipPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
@@ -20,6 +21,7 @@ const TooltipContent = React.forwardRef<
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
));
|
));
|
||||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
|||||||
+36
-29
@@ -117,53 +117,60 @@
|
|||||||
--cjk-line-height: 1.625;
|
--cjk-line-height: 1.625;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* L→R sheen over solid label text (overlay stripe). Avoids ``background-clip:
|
/* L→R sheen clipped to live activity labels. The highlight lives inside
|
||||||
text`` loop seams that read as RTL “erase” or one-frame transparent glyphs. */
|
the glyphs, not in the row background, so dark mode stays quiet. */
|
||||||
@keyframes reasoning-sheen-ltr {
|
@keyframes streaming-text-sheen-ltr {
|
||||||
0% {
|
0% {
|
||||||
left: -44%;
|
background-position: 140% 50%;
|
||||||
}
|
}
|
||||||
100% {
|
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;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1;
|
display: block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 2px;
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
color: transparent;
|
||||||
.reasoning-sheen-stripe {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 44%;
|
|
||||||
min-width: 3.25rem;
|
|
||||||
left: -44%;
|
|
||||||
border-radius: inherit;
|
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
transparent 0%,
|
transparent 0%,
|
||||||
hsl(0 0% 100% / 0.07) 34%,
|
transparent 38%,
|
||||||
hsl(0 0% 100% / 0.76) 50%,
|
hsl(var(--foreground) / 0.98) 50%,
|
||||||
hsl(0 0% 100% / 0.07) 66%,
|
transparent 62%,
|
||||||
transparent 100%
|
transparent 100%
|
||||||
);
|
);
|
||||||
mix-blend-mode: soft-light;
|
background-size: 260% 100%;
|
||||||
opacity: 0.95;
|
background-position: 140% 50%;
|
||||||
animation: reasoning-sheen-ltr 5.2s linear infinite;
|
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 {
|
.dark .streaming-text-sheen::after {
|
||||||
mix-blend-mode: overlay;
|
background-image: linear-gradient(
|
||||||
opacity: 1;
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
transparent 38%,
|
||||||
|
hsl(var(--foreground) / 0.98) 50%,
|
||||||
|
transparent 62%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.reasoning-sheen-stripe {
|
.streaming-text-sheen::after {
|
||||||
animation: none;
|
animation: none;
|
||||||
opacity: 0;
|
content: "";
|
||||||
visibility: hidden;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
OutboundMedia,
|
||||||
GoalStateWsPayload,
|
GoalStateWsPayload,
|
||||||
UIImage,
|
UIImage,
|
||||||
|
UIFileEdit,
|
||||||
UIMessage,
|
UIMessage,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
@@ -18,12 +19,26 @@ interface StreamBuffer {
|
|||||||
messageId: string;
|
messageId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scan upward from the bottom skipping trace rows so tool breadcrumbs don't steal the stream target. */
|
interface ActiveAssistantCursor {
|
||||||
function findStreamingAssistantId(prev: UIMessage[]): string | null {
|
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) {
|
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||||
const m = prev[i];
|
const m = prev[i];
|
||||||
if (m.kind === "trace") continue;
|
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;
|
if (m.role === "user") break;
|
||||||
}
|
}
|
||||||
return null;
|
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
|
* case the reasoning still belongs to the same assistant turn and must render
|
||||||
* above the answer, not as a new row below it.
|
* 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) {
|
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||||
const candidate = prev[i];
|
const candidate = prev[i];
|
||||||
// A user turn is a hard boundary: reasoning after it belongs to the new
|
// 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.
|
// that produced those tool calls.
|
||||||
if (candidate.kind === "trace") break;
|
if (candidate.kind === "trace") break;
|
||||||
if (candidate.role !== "assistant") continue;
|
if (candidate.role !== "assistant") continue;
|
||||||
|
const activitySegmentId = candidate.activitySegmentId ?? segments?.ensure();
|
||||||
const hasAnswer = candidate.content.length > 0;
|
const hasAnswer = candidate.content.length > 0;
|
||||||
if (
|
if (
|
||||||
candidate.reasoningStreaming
|
candidate.reasoningStreaming
|
||||||
@@ -60,6 +82,7 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
|
|||||||
...candidate,
|
...candidate,
|
||||||
reasoning: (candidate.reasoning ?? "") + chunk,
|
reasoning: (candidate.reasoning ?? "") + chunk,
|
||||||
reasoningStreaming: true,
|
reasoningStreaming: true,
|
||||||
|
...(activitySegmentId ? { activitySegmentId } : {}),
|
||||||
};
|
};
|
||||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
||||||
}
|
}
|
||||||
@@ -68,11 +91,13 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
|
|||||||
...candidate,
|
...candidate,
|
||||||
reasoning: chunk,
|
reasoning: chunk,
|
||||||
reasoningStreaming: true,
|
reasoningStreaming: true,
|
||||||
|
...(activitySegmentId ? { activitySegmentId } : {}),
|
||||||
};
|
};
|
||||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
const activitySegmentId = segments?.ensure();
|
||||||
return [
|
return [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
@@ -82,6 +107,7 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
|
|||||||
isStreaming: true,
|
isStreaming: true,
|
||||||
reasoning: chunk,
|
reasoning: chunk,
|
||||||
reasoningStreaming: true,
|
reasoningStreaming: true,
|
||||||
|
...(activitySegmentId ? { activitySegmentId } : {}),
|
||||||
createdAt: Date.now(),
|
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
|
* the model already produced an answer in a previous turn, so the new
|
||||||
* delta belongs in a fresh row.
|
* delta belongs in a fresh row.
|
||||||
*/
|
*/
|
||||||
function findActiveAssistantPlaceholder(prev: UIMessage[]): string | null {
|
function findActiveAssistantPlaceholderIndex(prev: UIMessage[]): number | null {
|
||||||
const last = prev[prev.length - 1];
|
const last = prev[prev.length - 1];
|
||||||
if (!last) return null;
|
if (!last) return null;
|
||||||
if (last.role !== "assistant" || last.kind === "trace") return null;
|
if (last.role !== "assistant" || last.kind === "trace") return null;
|
||||||
if (last.content.length > 0) return null;
|
if (last.content.length > 0) return null;
|
||||||
if (!last.isStreaming) 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,
|
* 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
|
* 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 [goalState, setGoalState] = useState<GoalStateWsPayload | undefined>(undefined);
|
||||||
const [streamError, setStreamError] = useState<StreamError | null>(null);
|
const [streamError, setStreamError] = useState<StreamError | null>(null);
|
||||||
const buffer = useRef<StreamBuffer | 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);
|
const suppressStreamUntilTurnEndRef = useRef(false);
|
||||||
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
|
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
|
||||||
*
|
*
|
||||||
@@ -255,6 +335,159 @@ export function useNanobotStream(
|
|||||||
|
|
||||||
const dismissStreamError = useCallback(() => setStreamError(null), []);
|
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
|
// Reset local state when switching chats. Do not reset on every
|
||||||
// ``initialMessages`` update: a brand-new chat can receive an empty/404
|
// ``initialMessages`` update: a brand-new chat can receive an empty/404
|
||||||
// history response after the optimistic first message has already rendered.
|
// history response after the optimistic first message has already rendered.
|
||||||
@@ -269,13 +502,17 @@ export function useNanobotStream(
|
|||||||
setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null);
|
setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null);
|
||||||
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
|
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
|
||||||
buffer.current = null;
|
buffer.current = null;
|
||||||
|
activeAssistantRef.current = null;
|
||||||
|
closedAssistantStreamIdsRef.current.clear();
|
||||||
|
clearActivitySegment();
|
||||||
|
clearPendingStreamWork();
|
||||||
suppressStreamUntilTurnEndRef.current = false;
|
suppressStreamUntilTurnEndRef.current = false;
|
||||||
if (streamEndTimerRef.current !== null) {
|
if (streamEndTimerRef.current !== null) {
|
||||||
clearTimeout(streamEndTimerRef.current);
|
clearTimeout(streamEndTimerRef.current);
|
||||||
streamEndTimerRef.current = null;
|
streamEndTimerRef.current = null;
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [chatId, client]);
|
}, [chatId, client, clearActivitySegment, clearPendingStreamWork]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasPendingToolCalls) setIsStreaming(true);
|
if (hasPendingToolCalls) setIsStreaming(true);
|
||||||
@@ -296,54 +533,10 @@ export function useNanobotStream(
|
|||||||
if (ev.event === "delta") {
|
if (ev.event === "delta") {
|
||||||
if (suppressStreamUntilTurnEndRef.current) return;
|
if (suppressStreamUntilTurnEndRef.current) return;
|
||||||
const chunk = typeof ev.text === "string" ? ev.text : "";
|
const chunk = typeof ev.text === "string" ? ev.text : "";
|
||||||
|
if (!chunk) return;
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
setMessages((prev) => {
|
pendingStreamEventsRef.current.push({ kind: "delta", text: chunk });
|
||||||
const adopted = findActiveAssistantPlaceholder(prev);
|
schedulePendingStreamFlush();
|
||||||
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;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,11 +544,23 @@ export function useNanobotStream(
|
|||||||
if (suppressStreamUntilTurnEndRef.current) return;
|
if (suppressStreamUntilTurnEndRef.current) return;
|
||||||
const chunk = ev.text;
|
const chunk = ev.text;
|
||||||
if (!chunk) return;
|
if (!chunk) return;
|
||||||
setMessages((prev) => attachReasoningChunk(prev, chunk));
|
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
|
pendingStreamEventsRef.current.push({ kind: "reasoning", text: chunk });
|
||||||
|
schedulePendingStreamFlush();
|
||||||
return;
|
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 (ev.event === "reasoning_end") {
|
||||||
if (suppressStreamUntilTurnEndRef.current) return;
|
if (suppressStreamUntilTurnEndRef.current) return;
|
||||||
setMessages((prev) => closeReasoningStream(prev));
|
setMessages((prev) => closeReasoningStream(prev));
|
||||||
@@ -393,6 +598,10 @@ export function useNanobotStream(
|
|||||||
if (typeof ev.latency_ms === "number" && ev.latency_ms >= 0) {
|
if (typeof ev.latency_ms === "number" && ev.latency_ms >= 0) {
|
||||||
finalized = stampLastAssistantLatency(finalized, Math.round(ev.latency_ms));
|
finalized = stampLastAssistantLatency(finalized, Math.round(ev.latency_ms));
|
||||||
}
|
}
|
||||||
|
buffer.current = null;
|
||||||
|
activeAssistantRef.current = null;
|
||||||
|
clearActivitySegment();
|
||||||
|
closedAssistantStreamIdsRef.current.clear();
|
||||||
return finalized;
|
return finalized;
|
||||||
});
|
});
|
||||||
suppressStreamUntilTurnEndRef.current = false;
|
suppressStreamUntilTurnEndRef.current = false;
|
||||||
@@ -413,7 +622,9 @@ export function useNanobotStream(
|
|||||||
if (ev.kind === "reasoning") {
|
if (ev.kind === "reasoning") {
|
||||||
const line = ev.text;
|
const line = ev.text;
|
||||||
if (!line) return;
|
if (!line) return;
|
||||||
setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line)));
|
setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line, {
|
||||||
|
ensure: ensureActivitySegmentId,
|
||||||
|
})));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
|
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
|
||||||
@@ -428,12 +639,24 @@ export function useNanobotStream(
|
|||||||
: [];
|
: [];
|
||||||
if (lines.length === 0) return;
|
if (lines.length === 0) return;
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
|
const segmentId = ensureActivitySegmentId();
|
||||||
const last = prev[prev.length - 1];
|
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 = {
|
const merged: UIMessage = {
|
||||||
...last,
|
...last,
|
||||||
traces: [...(last.traces ?? [last.content]), ...lines],
|
traces: [...previousTraces, ...lines],
|
||||||
content: lines[lines.length - 1],
|
content: lines[lines.length - 1],
|
||||||
|
activitySegmentId: last.activitySegmentId ?? segmentId,
|
||||||
};
|
};
|
||||||
return [...prev.slice(0, -1), merged];
|
return [...prev.slice(0, -1), merged];
|
||||||
}
|
}
|
||||||
@@ -445,6 +668,7 @@ export function useNanobotStream(
|
|||||||
kind: "trace",
|
kind: "trace",
|
||||||
content: lines[lines.length - 1],
|
content: lines[lines.length - 1],
|
||||||
traces: lines,
|
traces: lines,
|
||||||
|
activitySegmentId: segmentId,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -459,11 +683,12 @@ export function useNanobotStream(
|
|||||||
|
|
||||||
// A complete (non-streamed) assistant message. If a stream was in
|
// A complete (non-streamed) assistant message. If a stream was in
|
||||||
// flight, drop the placeholder so we don't render the text twice.
|
// 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
|
// Do NOT reset isStreaming here — only ``turn_end`` signals that
|
||||||
// the full turn (all tool calls + final text) is complete.
|
// the full turn (all tool calls + final text) is complete.
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
|
const activeId = buffer.current?.messageId;
|
||||||
|
buffer.current = null;
|
||||||
|
activeAssistantRef.current = null;
|
||||||
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
|
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
|
||||||
const content = ev.text;
|
const content = ev.text;
|
||||||
const lat =
|
const lat =
|
||||||
@@ -481,6 +706,46 @@ export function useNanobotStream(
|
|||||||
}
|
}
|
||||||
return;
|
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
|
// ``attached`` / ``error`` frames aren't actionable here; the client
|
||||||
// shell handles them separately.
|
// shell handles them separately.
|
||||||
};
|
};
|
||||||
@@ -489,12 +754,26 @@ export function useNanobotStream(
|
|||||||
return () => {
|
return () => {
|
||||||
unsub();
|
unsub();
|
||||||
buffer.current = null;
|
buffer.current = null;
|
||||||
|
activeAssistantRef.current = null;
|
||||||
|
closedAssistantStreamIdsRef.current.clear();
|
||||||
|
clearActivitySegment();
|
||||||
|
clearPendingStreamWork();
|
||||||
if (streamEndTimerRef.current !== null) {
|
if (streamEndTimerRef.current !== null) {
|
||||||
clearTimeout(streamEndTimerRef.current);
|
clearTimeout(streamEndTimerRef.current);
|
||||||
streamEndTimerRef.current = null;
|
streamEndTimerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [chatId, client, onTurnEnd]);
|
}, [
|
||||||
|
chatId,
|
||||||
|
client,
|
||||||
|
clearActivitySegment,
|
||||||
|
clearPendingStreamWork,
|
||||||
|
detachedActivitySegmentId,
|
||||||
|
ensureActivitySegmentId,
|
||||||
|
flushPendingStreamEvents,
|
||||||
|
onTurnEnd,
|
||||||
|
schedulePendingStreamFlush,
|
||||||
|
]);
|
||||||
|
|
||||||
const send = useCallback(
|
const send = useCallback(
|
||||||
(content: string, images?: SendImage[], options?: SendOptions) => {
|
(content: string, images?: SendImage[], options?: SendOptions) => {
|
||||||
@@ -504,8 +783,14 @@ export function useNanobotStream(
|
|||||||
// the image blocks via ``media`` paths.
|
// the image blocks via ``media`` paths.
|
||||||
if (!hasImages && !content.trim()) return;
|
if (!hasImages && !content.trim()) return;
|
||||||
|
|
||||||
|
flushPendingStreamEvents();
|
||||||
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
|
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
|
||||||
setMessages((prev) => [
|
setMessages((prev) => {
|
||||||
|
buffer.current = null;
|
||||||
|
activeAssistantRef.current = null;
|
||||||
|
closedAssistantStreamIdsRef.current.clear();
|
||||||
|
clearActivitySegment();
|
||||||
|
return [
|
||||||
...pruneReasoningOnlyPlaceholders(prev),
|
...pruneReasoningOnlyPlaceholders(prev),
|
||||||
{
|
{
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -514,7 +799,8 @@ export function useNanobotStream(
|
|||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
...(previews ? { images: previews } : {}),
|
...(previews ? { images: previews } : {}),
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
});
|
||||||
// Mark streaming immediately so the UI shows the loading indicator
|
// Mark streaming immediately so the UI shows the loading indicator
|
||||||
// right away, before the first delta arrives from the server.
|
// right away, before the first delta arrives from the server.
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
@@ -525,18 +811,23 @@ export function useNanobotStream(
|
|||||||
client.sendMessage(chatId, content, wireMedia);
|
client.sendMessage(chatId, content, wireMedia);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[chatId, client],
|
[chatId, clearActivitySegment, client, flushPendingStreamEvents],
|
||||||
);
|
);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
if (!chatId) return;
|
if (!chatId) return;
|
||||||
|
flushPendingStreamEvents();
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
setMessages((prev) =>
|
setMessages((prev) => {
|
||||||
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
|
buffer.current = null;
|
||||||
);
|
activeAssistantRef.current = null;
|
||||||
|
closedAssistantStreamIdsRef.current.clear();
|
||||||
|
clearActivitySegment();
|
||||||
|
return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
|
||||||
|
});
|
||||||
suppressStreamUntilTurnEndRef.current = false;
|
suppressStreamUntilTurnEndRef.current = false;
|
||||||
client.sendMessage(chatId, "/stop");
|
client.sendMessage(chatId, "/stop");
|
||||||
}, [chatId, client]);
|
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
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";
|
type Theme = "light" | "dark";
|
||||||
const STORAGE_KEY = "nanobot-webui.theme";
|
const STORAGE_KEY = "nanobot-webui.theme";
|
||||||
|
const ThemeContext = createContext<Theme>("light");
|
||||||
|
|
||||||
function readStored(): Theme | null {
|
function readStored(): Theme | null {
|
||||||
try {
|
try {
|
||||||
@@ -18,7 +27,11 @@ function applyTheme(theme: Theme): void {
|
|||||||
else root.classList.remove("dark");
|
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 [theme, setThemeState] = useState<Theme>(() => {
|
||||||
const stored = readStored();
|
const stored = readStored();
|
||||||
if (stored) return stored;
|
if (stored) return stored;
|
||||||
@@ -46,3 +59,11 @@ export function useTheme(): { theme: Theme; toggle: () => void; setTheme: (t: Th
|
|||||||
);
|
);
|
||||||
return { theme, toggle, setTheme };
|
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"
|
"io": "Couldn't read this file"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scrollToBottom": "Scroll to bottom"
|
"scrollToBottom": "Scroll to bottom",
|
||||||
|
"loadEarlier": "Load earlier messages"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "streaming",
|
"streaming": "streaming",
|
||||||
|
|||||||
@@ -303,7 +303,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "Cerrar objetivo"
|
"goalStateCloseAria": "Cerrar objetivo"
|
||||||
},
|
},
|
||||||
"scrollToBottom": "Desplazarse al final"
|
"scrollToBottom": "Desplazarse al final",
|
||||||
|
"loadEarlier": "Cargar mensajes anteriores"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "transmitiendo",
|
"streaming": "transmitiendo",
|
||||||
|
|||||||
@@ -303,7 +303,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "Fermer l’objectif"
|
"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": {
|
"message": {
|
||||||
"streaming": "en cours de génération",
|
"streaming": "en cours de génération",
|
||||||
|
|||||||
@@ -303,7 +303,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "Tutup tujuan"
|
"goalStateCloseAria": "Tutup tujuan"
|
||||||
},
|
},
|
||||||
"scrollToBottom": "Gulir ke bawah"
|
"scrollToBottom": "Gulir ke bawah",
|
||||||
|
"loadEarlier": "Muat pesan sebelumnya"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "sedang mengalir",
|
"streaming": "sedang mengalir",
|
||||||
|
|||||||
@@ -303,7 +303,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "目標を閉じる"
|
"goalStateCloseAria": "目標を閉じる"
|
||||||
},
|
},
|
||||||
"scrollToBottom": "一番下へスクロール"
|
"scrollToBottom": "一番下へスクロール",
|
||||||
|
"loadEarlier": "以前のメッセージを読み込む"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "生成中",
|
"streaming": "生成中",
|
||||||
|
|||||||
@@ -303,7 +303,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "목표 닫기"
|
"goalStateCloseAria": "목표 닫기"
|
||||||
},
|
},
|
||||||
"scrollToBottom": "맨 아래로 스크롤"
|
"scrollToBottom": "맨 아래로 스크롤",
|
||||||
|
"loadEarlier": "이전 메시지 불러오기"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "생성 중",
|
"streaming": "생성 중",
|
||||||
|
|||||||
@@ -303,7 +303,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "Đóng mục tiêu"
|
"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": {
|
"message": {
|
||||||
"streaming": "đang truyền",
|
"streaming": "đang truyền",
|
||||||
|
|||||||
@@ -323,7 +323,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "关闭目标"
|
"goalStateCloseAria": "关闭目标"
|
||||||
},
|
},
|
||||||
"scrollToBottom": "滚动到底部"
|
"scrollToBottom": "滚动到底部",
|
||||||
|
"loadEarlier": "加载更早消息"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "流式输出中",
|
"streaming": "流式输出中",
|
||||||
|
|||||||
@@ -303,7 +303,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "關閉目標"
|
"goalStateCloseAria": "關閉目標"
|
||||||
},
|
},
|
||||||
"scrollToBottom": "捲動到底部"
|
"scrollToBottom": "捲動到底部",
|
||||||
|
"loadEarlier": "載入更早訊息"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "串流輸出中",
|
"streaming": "串流輸出中",
|
||||||
|
|||||||
@@ -1,10 +1,35 @@
|
|||||||
import i18n, { currentLocale } from "@/i18n";
|
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. */
|
/** Truncate the first user message into a chat title. */
|
||||||
export function deriveTitle(preview: string | undefined, fallback: string): string {
|
export function deriveTitle(preview: string | undefined, fallback: string): string {
|
||||||
if (!preview) return fallback;
|
if (!preview) return fallback;
|
||||||
const oneLine = preview.replace(/\s+/g, " ").trim();
|
const oneLine = preview.replace(/\s+/g, " ").trim();
|
||||||
if (!oneLine) return fallback;
|
if (!oneLine) return fallback;
|
||||||
|
if (isLowInformationTitlePreview(oneLine)) return fallback;
|
||||||
return oneLine.length > 60 ? `${oneLine.slice(0, 57)}…` : oneLine;
|
return oneLine.length > 60 ? `${oneLine.slice(0, 57)}…` : oneLine;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ type Unsubscribe = () => void;
|
|||||||
type EventHandler = (ev: InboundEvent) => void;
|
type EventHandler = (ev: InboundEvent) => void;
|
||||||
type StatusHandler = (status: ConnectionStatus) => void;
|
type StatusHandler = (status: ConnectionStatus) => void;
|
||||||
type RuntimeModelHandler = (modelName: string | null, modelPreset?: string | null) => 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.
|
/** Structured connection-level errors surfaced to the UI.
|
||||||
*
|
*
|
||||||
@@ -364,7 +365,7 @@ export class NanobotClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.event === "session_updated") {
|
if (parsed.event === "session_updated") {
|
||||||
this.emitSessionUpdate(parsed.chat_id);
|
this.emitSessionUpdate(parsed.chat_id, parsed.scope);
|
||||||
return;
|
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) {
|
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
|
/** For trace rows: each individual hint line, so consecutive hints can
|
||||||
* render as a single collapsible group. */
|
* render as a single collapsible group. */
|
||||||
traces?: string[];
|
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. */
|
/** User turn: optimistic blob URLs for preview. Replay: placeholder chips. */
|
||||||
images?: UIImage[];
|
images?: UIImage[];
|
||||||
/** Signed or local UI-renderable media attachments. */
|
/** Signed or local UI-renderable media attachments. */
|
||||||
@@ -80,6 +84,20 @@ export interface ToolProgressEvent {
|
|||||||
embeds?: unknown[];
|
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 {
|
export interface ChatSummary {
|
||||||
/** Server-side session key, e.g. ``websocket:abcd-...``. */
|
/** Server-side session key, e.g. ``websocket:abcd-...``. */
|
||||||
key: string;
|
key: string;
|
||||||
@@ -110,6 +128,7 @@ export interface SettingsPayload {
|
|||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
|
api_key_required?: boolean;
|
||||||
api_key_hint?: string | null;
|
api_key_hint?: string | null;
|
||||||
api_base?: string | null;
|
api_base?: string | null;
|
||||||
default_api_base?: string | null;
|
default_api_base?: string | null;
|
||||||
@@ -182,6 +201,11 @@ export type InboundEvent =
|
|||||||
/** Optional structured payload on progress frames (channel-specific). */
|
/** Optional structured payload on progress frames (channel-specific). */
|
||||||
agent_ui?: AgentUIBlob;
|
agent_ui?: AgentUIBlob;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
event: "file_edit";
|
||||||
|
chat_id: string;
|
||||||
|
edits: UIFileEdit[];
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
event: "delta";
|
event: "delta";
|
||||||
chat_id: string;
|
chat_id: string;
|
||||||
@@ -229,7 +253,7 @@ export type InboundEvent =
|
|||||||
chat_id: string;
|
chat_id: string;
|
||||||
goal_state: GoalStateWsPayload;
|
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 };
|
| { event: "error"; chat_id?: string; detail?: string };
|
||||||
|
|
||||||
/** Base64-encoded image attached to an outbound ``message`` envelope.
|
/** 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 { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { ChatSummary } from "@/lib/types";
|
import type { ChatSummary } from "@/lib/types";
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ const refreshSpy = vi.fn();
|
|||||||
const createChatSpy = vi.fn().mockResolvedValue("chat-1");
|
const createChatSpy = vi.fn().mockResolvedValue("chat-1");
|
||||||
const deleteChatSpy = vi.fn();
|
const deleteChatSpy = vi.fn();
|
||||||
const toggleThemeSpy = vi.fn();
|
const toggleThemeSpy = vi.fn();
|
||||||
|
const updateUrlSpy = vi.fn();
|
||||||
let mockSessions: ChatSummary[] = [];
|
let mockSessions: ChatSummary[] = [];
|
||||||
|
|
||||||
vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
||||||
@@ -32,12 +33,18 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("@/hooks/useTheme", () => ({
|
vi.mock("@/hooks/useTheme", async () => {
|
||||||
|
const React = await import("react");
|
||||||
|
return {
|
||||||
|
ThemeProvider: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement(React.Fragment, null, children),
|
||||||
useTheme: () => ({
|
useTheme: () => ({
|
||||||
theme: "light" as const,
|
theme: "light" as const,
|
||||||
toggle: toggleThemeSpy,
|
toggle: toggleThemeSpy,
|
||||||
}),
|
}),
|
||||||
}));
|
useThemeValue: () => "light" as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("@/lib/bootstrap", () => ({
|
vi.mock("@/lib/bootstrap", () => ({
|
||||||
fetchBootstrap: vi.fn().mockResolvedValue({
|
fetchBootstrap: vi.fn().mockResolvedValue({
|
||||||
@@ -64,22 +71,30 @@ vi.mock("@/lib/nanobot-client", () => {
|
|||||||
newChat = vi.fn();
|
newChat = vi.fn();
|
||||||
attach = vi.fn();
|
attach = vi.fn();
|
||||||
close = vi.fn();
|
close = vi.fn();
|
||||||
updateUrl = vi.fn();
|
updateUrl = updateUrlSpy;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { NanobotClient: MockClient };
|
return { NanobotClient: MockClient };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
|
||||||
import App from "@/App";
|
import App from "@/App";
|
||||||
|
|
||||||
describe("App layout", () => {
|
describe("App layout", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockSessions = [];
|
mockSessions = [];
|
||||||
connectSpy.mockClear();
|
connectSpy.mockClear();
|
||||||
|
updateUrlSpy.mockClear();
|
||||||
refreshSpy.mockReset();
|
refreshSpy.mockReset();
|
||||||
createChatSpy.mockClear();
|
createChatSpy.mockClear();
|
||||||
deleteChatSpy.mockReset();
|
deleteChatSpy.mockReset();
|
||||||
toggleThemeSpy.mockReset();
|
toggleThemeSpy.mockReset();
|
||||||
|
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
||||||
|
token: "tok",
|
||||||
|
ws_path: "/",
|
||||||
|
expires_in: 300,
|
||||||
|
});
|
||||||
|
vi.mocked(deriveWsUrl).mockReset().mockReturnValue("ws://test");
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn().mockResolvedValue({
|
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 () => {
|
it("keeps sidebar layout out of the main thread width contract", async () => {
|
||||||
const { container } = render(<App />);
|
const { container } = render(<App />);
|
||||||
|
|
||||||
@@ -192,8 +211,52 @@ describe("App layout", () => {
|
|||||||
name: "openrouter",
|
name: "openrouter",
|
||||||
label: "OpenRouter",
|
label: "OpenRouter",
|
||||||
configured: false,
|
configured: false,
|
||||||
|
api_key_required: true,
|
||||||
default_api_base: "https://openrouter.ai/api/v1",
|
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: {
|
web_search: {
|
||||||
provider: "brave",
|
provider: "brave",
|
||||||
@@ -248,6 +311,9 @@ describe("App layout", () => {
|
|||||||
fireEvent.click(screen.getByText("OpenAI"));
|
fireEvent.click(screen.getByText("OpenAI"));
|
||||||
expect(screen.getByText("open••••-key")).toBeInTheDocument();
|
expect(screen.getByText("open••••-key")).toBeInTheDocument();
|
||||||
expect(screen.queryByDisplayValue("unsaved-openai-key")).not.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" }));
|
fireEvent.click(screen.getByRole("tab", { name: "Web Search" }));
|
||||||
expect(screen.getByText("Search provider")).toBeInTheDocument();
|
expect(screen.getByText("Search provider")).toBeInTheDocument();
|
||||||
@@ -426,4 +492,36 @@ describe("App layout", () => {
|
|||||||
|
|
||||||
expect(within(sidebar).getByText("Existing chat")).toBeInTheDocument();
|
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 { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { MessageBubble } from "@/components/MessageBubble";
|
import { MessageBubble } from "@/components/MessageBubble";
|
||||||
@@ -131,7 +131,9 @@ describe("MessageBubble", () => {
|
|||||||
|
|
||||||
expect(screen.getByText("Thinking…")).toBeInTheDocument();
|
expect(screen.getByText("Thinking…")).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Step 1: parse intent\./)).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");
|
expect(screen.getByRole("button", { name: /thinking/i }).parentElement).not.toHaveClass("mb-2");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,6 +179,47 @@ describe("MessageBubble", () => {
|
|||||||
expect(screen.getByText("Body line.")).toBeInTheDocument();
|
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", () => {
|
it("renders assistant image media as a larger generated result", () => {
|
||||||
const message: UIMessage = {
|
const message: UIMessage = {
|
||||||
id: "a-image",
|
id: "a-image",
|
||||||
|
|||||||
@@ -233,9 +233,13 @@ describe("NanobotClient", () => {
|
|||||||
client.connect();
|
client.connect();
|
||||||
lastSocket().fakeOpen();
|
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();
|
expect(chatHandler).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { describe, expect, it } from "vitest";
|
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";
|
import type { UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
describe("ThreadMessages", () => {
|
describe("ThreadMessages", () => {
|
||||||
@@ -51,6 +55,206 @@ describe("ThreadMessages", () => {
|
|||||||
expect(rows[1]).toHaveClass("mt-4");
|
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", () => {
|
it("shows copy only on the last assistant slice before the next user turn", () => {
|
||||||
const messages: UIMessage[] = [
|
const messages: UIMessage[] = [
|
||||||
{
|
{
|
||||||
@@ -89,4 +293,37 @@ describe("ThreadMessages", () => {
|
|||||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||||
expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1);
|
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() {
|
function makeClient() {
|
||||||
const errorHandlers = new Set<(err: { kind: string }) => void>();
|
const errorHandlers = new Set<(err: { kind: string }) => void>();
|
||||||
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => 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>();
|
const goalStateByChatId = new Map<string, import("@/lib/types").GoalStateWsPayload>();
|
||||||
return {
|
return {
|
||||||
status: "open" as const,
|
status: "open" as const,
|
||||||
@@ -34,7 +34,7 @@ function makeClient() {
|
|||||||
errorHandlers.delete(handler);
|
errorHandlers.delete(handler);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onSessionUpdate: (handler: (chatId: string) => void) => {
|
onSessionUpdate: (handler: (chatId: string, scope?: string) => void) => {
|
||||||
sessionUpdateHandlers.add(handler);
|
sessionUpdateHandlers.add(handler);
|
||||||
return () => {
|
return () => {
|
||||||
sessionUpdateHandlers.delete(handler);
|
sessionUpdateHandlers.delete(handler);
|
||||||
@@ -49,8 +49,8 @@ function makeClient() {
|
|||||||
}
|
}
|
||||||
for (const h of chatHandlers.get(chatId) ?? []) h(ev);
|
for (const h of chatHandlers.get(chatId) ?? []) h(ev);
|
||||||
},
|
},
|
||||||
_emitSessionUpdate(chatId: string) {
|
_emitSessionUpdate(chatId: string, scope?: string) {
|
||||||
for (const h of sessionUpdateHandlers) h(chatId);
|
for (const h of sessionUpdateHandlers) h(chatId, scope);
|
||||||
},
|
},
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
newChat: vi.fn(),
|
newChat: vi.fn(),
|
||||||
@@ -651,6 +651,52 @@ describe("ThreadShell", () => {
|
|||||||
expect(historyCalls).toBe(1);
|
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 () => {
|
it("scrolls to the bottom after loading a session from the blank new-chat page", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
const scrollIntoView = vi.fn();
|
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 { 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";
|
import type { UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
const messages: UIMessage[] = [
|
const messages: UIMessage[] = [
|
||||||
@@ -15,7 +20,191 @@ const messages: UIMessage[] = [
|
|||||||
|
|
||||||
const emptyMessages: 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", () => {
|
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 () => {
|
it("resets to the bottom when opening a different conversation", async () => {
|
||||||
const scrollIntoView = vi.fn();
|
const scrollIntoView = vi.fn();
|
||||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
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", () => {
|
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", () => {
|
it("starts in streaming mode when history shows pending tool calls", () => {
|
||||||
const fake = fakeClient();
|
const fake = fakeClient();
|
||||||
const initialMessages = [{
|
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 fake = fakeClient();
|
||||||
const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), {
|
const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), {
|
||||||
wrapper: wrap(fake.client),
|
wrapper: wrap(fake.client),
|
||||||
@@ -222,6 +494,8 @@ describe("useNanobotStream", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await flushStreamFrame();
|
||||||
|
|
||||||
expect(result.current.messages).toHaveLength(1);
|
expect(result.current.messages).toHaveLength(1);
|
||||||
expect(result.current.messages[0].role).toBe("assistant");
|
expect(result.current.messages[0].role).toBe("assistant");
|
||||||
expect(result.current.messages[0].reasoning).toBe("Let me think step by step.");
|
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);
|
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 fake = fakeClient();
|
||||||
const initialMessages = [
|
const initialMessages = [
|
||||||
{
|
{
|
||||||
@@ -358,6 +632,8 @@ describe("useNanobotStream", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await flushStreamFrame();
|
||||||
|
|
||||||
expect(result.current.messages).toHaveLength(3);
|
expect(result.current.messages).toHaveLength(3);
|
||||||
expect(result.current.messages[0].reasoning).toBe("Previous thought.");
|
expect(result.current.messages[0].reasoning).toBe("Previous thought.");
|
||||||
expect(result.current.messages[2].role).toBe("assistant");
|
expect(result.current.messages[2].role).toBe("assistant");
|
||||||
@@ -366,7 +642,7 @@ describe("useNanobotStream", () => {
|
|||||||
expect(result.current.messages[2].reasoningStreaming).toBe(true);
|
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 fake = fakeClient();
|
||||||
const { result } = renderHook(() => useNanobotStream("chat-r7", EMPTY_MESSAGES), {
|
const { result } = renderHook(() => useNanobotStream("chat-r7", EMPTY_MESSAGES), {
|
||||||
wrapper: wrap(fake.client),
|
wrapper: wrap(fake.client),
|
||||||
@@ -392,6 +668,8 @@ describe("useNanobotStream", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await flushStreamFrame();
|
||||||
|
|
||||||
expect(result.current.messages).toHaveLength(3);
|
expect(result.current.messages).toHaveLength(3);
|
||||||
expect(result.current.messages.map((m) => m.kind ?? "message")).toEqual([
|
expect(result.current.messages.map((m) => m.kind ?? "message")).toEqual([
|
||||||
"message",
|
"message",
|
||||||
@@ -651,7 +929,7 @@ describe("useNanobotStream", () => {
|
|||||||
expect(result.current.messages[0].content).toBe("long task");
|
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 fake = fakeClient();
|
||||||
const onTurnEnd = vi.fn();
|
const onTurnEnd = vi.fn();
|
||||||
const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES, false, onTurnEnd), {
|
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.isStreaming).toBe(true);
|
||||||
expect(result.current.messages[0]).toMatchObject({
|
expect(result.current.messages[0]).toMatchObject({
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from "@testing-library/react";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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 * as api from "@/lib/api";
|
||||||
import { ClientProvider } from "@/providers/ClientProvider";
|
import { ClientProvider } from "@/providers/ClientProvider";
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function fakeClient() {
|
function fakeClient() {
|
||||||
const sessionUpdateHandlers = new Set<(chatId: string) => void>();
|
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||||
return {
|
return {
|
||||||
status: "open" as const,
|
status: "open" as const,
|
||||||
defaultChatId: null as string | null,
|
defaultChatId: null as string | null,
|
||||||
@@ -25,12 +25,12 @@ function fakeClient() {
|
|||||||
onError: () => () => {},
|
onError: () => () => {},
|
||||||
onChat: () => () => {},
|
onChat: () => () => {},
|
||||||
getRunStartedAt: () => null,
|
getRunStartedAt: () => null,
|
||||||
onSessionUpdate: (handler: (chatId: string) => void) => {
|
onSessionUpdate: (handler: (chatId: string, scope?: string) => void) => {
|
||||||
sessionUpdateHandlers.add(handler);
|
sessionUpdateHandlers.add(handler);
|
||||||
return () => sessionUpdateHandlers.delete(handler);
|
return () => sessionUpdateHandlers.delete(handler);
|
||||||
},
|
},
|
||||||
emitSessionUpdate: (chatId: string) => {
|
emitSessionUpdate: (chatId: string, scope?: string) => {
|
||||||
for (const handler of sessionUpdateHandlers) handler(chatId);
|
for (const handler of sessionUpdateHandlers) handler(chatId, scope);
|
||||||
},
|
},
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
newChat: vi.fn(),
|
newChat: vi.fn(),
|
||||||
@@ -61,6 +61,28 @@ describe("useSessions", () => {
|
|||||||
vi.mocked(api.fetchWebuiThread).mockReset();
|
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 () => {
|
it("removes a session from the local list after delete succeeds", async () => {
|
||||||
vi.mocked(api.listSessions).mockResolvedValue([
|
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"),
|
outDir: path.resolve(__dirname, "../nanobot/web/dist"),
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
sourcemap: false,
|
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: {
|
server: {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user