mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-15 15:24:06 +00:00
feat(webui): refine output timeline and model controls (#4108)
* feat(webui): refine output timeline and composer queue * feat(webui): add provider model picker * fix(webui): polish model settings and heartbeat checks * chore: keep heartbeat changes out of webui pr * refactor(webui): isolate settings routes * fix(providers): align minimax anthropic test * fix(providers): keep minimax anthropic base sdk-compatible * fix(providers): normalize anthropic base urls
This commit is contained in:
parent
b2e43955e3
commit
3dcf511c84
@ -27,7 +27,6 @@ from websockets.exceptions import ConnectionClosed
|
|||||||
from websockets.http11 import Request as WsRequest
|
from websockets.http11 import Request as WsRequest
|
||||||
from websockets.http11 import Response
|
from websockets.http11 import Response
|
||||||
|
|
||||||
from nanobot.agent.tools.mcp import request_mcp_reload
|
|
||||||
from nanobot.security.workspace_access import (
|
from nanobot.security.workspace_access import (
|
||||||
WORKSPACE_SCOPE_METADATA_KEY,
|
WORKSPACE_SCOPE_METADATA_KEY,
|
||||||
WorkspaceScopeError,
|
WorkspaceScopeError,
|
||||||
@ -45,35 +44,15 @@ from nanobot.utils.media_decode import (
|
|||||||
save_base64_data_url,
|
save_base64_data_url,
|
||||||
)
|
)
|
||||||
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
|
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
|
||||||
from nanobot.webui.settings_api import (
|
from nanobot.webui.settings_api import runtime_capabilities
|
||||||
WebUISettingsError,
|
from nanobot.webui.cli_apps_api import normalize_cli_app_mentions
|
||||||
create_model_configuration,
|
|
||||||
decorate_settings_payload,
|
|
||||||
login_oauth_provider,
|
|
||||||
logout_oauth_provider,
|
|
||||||
runtime_capabilities,
|
|
||||||
settings_payload,
|
|
||||||
update_agent_settings,
|
|
||||||
update_image_generation_settings,
|
|
||||||
update_model_configuration,
|
|
||||||
update_network_safety_settings,
|
|
||||||
update_provider_settings,
|
|
||||||
update_web_search_settings,
|
|
||||||
)
|
|
||||||
from nanobot.webui.cli_apps_api import (
|
|
||||||
cli_apps_action,
|
|
||||||
cli_apps_payload,
|
|
||||||
normalize_cli_app_mentions,
|
|
||||||
)
|
|
||||||
from nanobot.webui.media_api import (
|
from nanobot.webui.media_api import (
|
||||||
serve_signed_media,
|
serve_signed_media,
|
||||||
sign_media_path,
|
sign_media_path,
|
||||||
sign_or_stage_media_path,
|
sign_or_stage_media_path,
|
||||||
)
|
)
|
||||||
from nanobot.webui.mcp_presets_api import (
|
from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions
|
||||||
mcp_presets_settings_action,
|
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||||
normalize_mcp_preset_mentions,
|
|
||||||
)
|
|
||||||
from nanobot.webui.sidebar_state import (
|
from nanobot.webui.sidebar_state import (
|
||||||
read_webui_sidebar_state,
|
read_webui_sidebar_state,
|
||||||
write_webui_sidebar_state,
|
write_webui_sidebar_state,
|
||||||
@ -88,18 +67,6 @@ from nanobot.webui.workspaces import (
|
|||||||
WebUIWorkspaceController,
|
WebUIWorkspaceController,
|
||||||
)
|
)
|
||||||
|
|
||||||
_MCP_PRESET_ACTIONS_BY_PATH = {
|
|
||||||
"/api/settings/mcp-presets/enable": "enable",
|
|
||||||
"/api/settings/mcp-presets/remove": "remove",
|
|
||||||
"/api/settings/mcp-presets/test": "test",
|
|
||||||
"/api/settings/mcp-presets/custom": "custom",
|
|
||||||
"/api/settings/mcp-presets/import": "import",
|
|
||||||
"/api/settings/mcp-presets/import-cursor": "import-cursor",
|
|
||||||
"/api/settings/mcp-presets/tools": "tools",
|
|
||||||
}
|
|
||||||
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
|
|
||||||
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
|
|
||||||
@ -318,34 +285,6 @@ def _parse_query(path_with_query: str) -> dict[str, list[str]]:
|
|||||||
return _parse_request_path(path_with_query)[1]
|
return _parse_request_path(path_with_query)[1]
|
||||||
|
|
||||||
|
|
||||||
def _parse_mcp_settings_query(request: WsRequest) -> dict[str, list[str]]:
|
|
||||||
query = _parse_query(request.path)
|
|
||||||
raw = request.headers.get(_MCP_VALUES_HEADER)
|
|
||||||
if not raw:
|
|
||||||
return query
|
|
||||||
if len(raw.encode("utf-8")) > _MCP_VALUES_HEADER_MAX_BYTES:
|
|
||||||
raise WebUISettingsError("MCP settings payload is too large")
|
|
||||||
try:
|
|
||||||
payload = json.loads(raw)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise WebUISettingsError("invalid MCP settings payload") from exc
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise WebUISettingsError("MCP settings payload must be a JSON object")
|
|
||||||
merged = {key: list(values) for key, values in query.items()}
|
|
||||||
for key, value in payload.items():
|
|
||||||
if not isinstance(key, str) or not key:
|
|
||||||
raise WebUISettingsError("MCP settings payload contains an invalid key")
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
if isinstance(value, str):
|
|
||||||
text = value.strip()
|
|
||||||
else:
|
|
||||||
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
||||||
if text:
|
|
||||||
merged[key] = [text]
|
|
||||||
return merged
|
|
||||||
|
|
||||||
|
|
||||||
def _query_first(query: dict[str, list[str]], key: str) -> str | None:
|
def _query_first(query: dict[str, list[str]], key: str) -> str | None:
|
||||||
"""Return the first value for *key*, or None."""
|
"""Return the first value for *key*, or None."""
|
||||||
values = query.get(key)
|
values = query.get(key)
|
||||||
@ -586,7 +525,16 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._runtime_surface,
|
self._runtime_surface,
|
||||||
runtime_capabilities_overrides,
|
runtime_capabilities_overrides,
|
||||||
)
|
)
|
||||||
self._settings_restart_sections: set[str] = set()
|
self._settings_routes = WebUISettingsRouter(
|
||||||
|
bus=self.bus,
|
||||||
|
logger=self.logger,
|
||||||
|
check_api_token=self._check_api_token,
|
||||||
|
parse_query=_parse_query,
|
||||||
|
json_response=_http_json_response,
|
||||||
|
error_response=_http_error,
|
||||||
|
runtime_surface=self._runtime_surface,
|
||||||
|
runtime_capabilities=self._runtime_capabilities,
|
||||||
|
)
|
||||||
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
|
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
|
||||||
# Process-local secret used to HMAC-sign media URLs. The signed URL is
|
# Process-local secret used to HMAC-sign media URLs. The signed URL is
|
||||||
# the capability — anyone who holds a valid URL can fetch that one
|
# the capability — anyone who holds a valid URL can fetch that one
|
||||||
@ -808,59 +756,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
request: WsRequest,
|
request: WsRequest,
|
||||||
got: str,
|
got: str,
|
||||||
) -> Response | None:
|
) -> Response | None:
|
||||||
if got == "/api/settings":
|
return await self._settings_routes.dispatch(request, got)
|
||||||
return self._handle_settings(request)
|
|
||||||
|
|
||||||
if got == "/api/settings/update":
|
|
||||||
return self._handle_settings_update(request)
|
|
||||||
|
|
||||||
if got == "/api/settings/model-configurations/create":
|
|
||||||
return self._handle_settings_model_configuration_create(request)
|
|
||||||
|
|
||||||
if got == "/api/settings/model-configurations/update":
|
|
||||||
return self._handle_settings_model_configuration_update(request)
|
|
||||||
|
|
||||||
if got == "/api/settings/provider/update":
|
|
||||||
return self._handle_settings_provider_update(request)
|
|
||||||
|
|
||||||
if got == "/api/settings/provider/oauth-login":
|
|
||||||
return await self._handle_settings_provider_oauth(request, "login")
|
|
||||||
|
|
||||||
if got == "/api/settings/provider/oauth-logout":
|
|
||||||
return await self._handle_settings_provider_oauth(request, "logout")
|
|
||||||
|
|
||||||
if got == "/api/settings/web-search/update":
|
|
||||||
return self._handle_settings_web_search_update(request)
|
|
||||||
|
|
||||||
if got == "/api/settings/image-generation/update":
|
|
||||||
return self._handle_settings_image_generation_update(request)
|
|
||||||
|
|
||||||
if got == "/api/settings/network-safety/update":
|
|
||||||
return self._handle_settings_network_safety_update(request)
|
|
||||||
|
|
||||||
if got == "/api/settings/cli-apps":
|
|
||||||
return self._handle_settings_cli_apps(request)
|
|
||||||
|
|
||||||
if got == "/api/settings/cli-apps/install":
|
|
||||||
return await self._handle_settings_cli_apps_action(request, "install")
|
|
||||||
|
|
||||||
if got == "/api/settings/cli-apps/update":
|
|
||||||
return await self._handle_settings_cli_apps_action(request, "update")
|
|
||||||
|
|
||||||
if got == "/api/settings/cli-apps/uninstall":
|
|
||||||
return await self._handle_settings_cli_apps_action(request, "uninstall")
|
|
||||||
|
|
||||||
if got == "/api/settings/cli-apps/test":
|
|
||||||
return await self._handle_settings_cli_apps_action(request, "test")
|
|
||||||
|
|
||||||
if got == "/api/settings/mcp-presets":
|
|
||||||
return await self._handle_settings_mcp_presets(request)
|
|
||||||
|
|
||||||
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(got)
|
|
||||||
if mcp_action is not None:
|
|
||||||
return await self._handle_settings_mcp_presets(request, mcp_action)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _dispatch_session_api_route(
|
def _dispatch_session_api_route(
|
||||||
self,
|
self,
|
||||||
@ -1019,38 +915,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._webui_workspaces.payload(controls_available=_is_localhost(connection))
|
self._webui_workspaces.payload(controls_available=_is_localhost(connection))
|
||||||
)
|
)
|
||||||
|
|
||||||
def _handle_settings(self, request: WsRequest) -> Response:
|
|
||||||
if not self._check_api_token(request):
|
|
||||||
return _http_error(401, "Unauthorized")
|
|
||||||
return _http_json_response(
|
|
||||||
self._with_settings_restart_state(
|
|
||||||
settings_payload(
|
|
||||||
surface=self._runtime_surface,
|
|
||||||
runtime_capability_overrides=self._runtime_capabilities,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _with_settings_restart_state(
|
|
||||||
self,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
*,
|
|
||||||
section: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Keep restart-required state alive for this gateway process."""
|
|
||||||
if section and payload.get("requires_restart"):
|
|
||||||
self._settings_restart_sections.add(section)
|
|
||||||
sections = sorted(self._settings_restart_sections)
|
|
||||||
payload = dict(payload)
|
|
||||||
if sections:
|
|
||||||
payload["requires_restart"] = True
|
|
||||||
return decorate_settings_payload(
|
|
||||||
payload,
|
|
||||||
surface=self._runtime_surface,
|
|
||||||
runtime_capability_overrides=self._runtime_capabilities,
|
|
||||||
restart_required_sections=sections,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _handle_commands(self, request: WsRequest) -> Response:
|
def _handle_commands(self, request: WsRequest) -> Response:
|
||||||
if not self._check_api_token(request):
|
if not self._check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
@ -1083,142 +947,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return _http_error(500, "failed to write sidebar state")
|
return _http_error(500, "failed to write sidebar state")
|
||||||
return _http_json_response(state)
|
return _http_json_response(state)
|
||||||
|
|
||||||
def _handle_settings_update(self, request: WsRequest) -> Response:
|
|
||||||
if not self._check_api_token(request):
|
|
||||||
return _http_error(401, "Unauthorized")
|
|
||||||
query = _parse_query(request.path)
|
|
||||||
try:
|
|
||||||
payload = update_agent_settings(query)
|
|
||||||
except WebUISettingsError as e:
|
|
||||||
return _http_error(e.status, e.message)
|
|
||||||
return _http_json_response(
|
|
||||||
self._with_settings_restart_state(payload, section="runtime")
|
|
||||||
)
|
|
||||||
|
|
||||||
def _handle_settings_model_configuration_create(self, request: WsRequest) -> Response:
|
|
||||||
if not self._check_api_token(request):
|
|
||||||
return _http_error(401, "Unauthorized")
|
|
||||||
query = _parse_query(request.path)
|
|
||||||
try:
|
|
||||||
payload = create_model_configuration(query)
|
|
||||||
except WebUISettingsError as e:
|
|
||||||
return _http_error(e.status, e.message)
|
|
||||||
return _http_json_response(self._with_settings_restart_state(payload))
|
|
||||||
|
|
||||||
def _handle_settings_model_configuration_update(self, request: WsRequest) -> Response:
|
|
||||||
if not self._check_api_token(request):
|
|
||||||
return _http_error(401, "Unauthorized")
|
|
||||||
query = _parse_query(request.path)
|
|
||||||
try:
|
|
||||||
payload = update_model_configuration(query)
|
|
||||||
except WebUISettingsError as e:
|
|
||||||
return _http_error(e.status, e.message)
|
|
||||||
return _http_json_response(self._with_settings_restart_state(payload))
|
|
||||||
|
|
||||||
def _handle_settings_provider_update(self, request: WsRequest) -> Response:
|
|
||||||
if not self._check_api_token(request):
|
|
||||||
return _http_error(401, "Unauthorized")
|
|
||||||
query = _parse_query(request.path)
|
|
||||||
try:
|
|
||||||
payload = update_provider_settings(query)
|
|
||||||
except WebUISettingsError as e:
|
|
||||||
return _http_error(e.status, e.message)
|
|
||||||
return _http_json_response(self._with_settings_restart_state(payload, section="image"))
|
|
||||||
|
|
||||||
async def _handle_settings_provider_oauth(self, request: WsRequest, action: str) -> Response:
|
|
||||||
if not self._check_api_token(request):
|
|
||||||
return _http_error(401, "Unauthorized")
|
|
||||||
query = _parse_query(request.path)
|
|
||||||
try:
|
|
||||||
if action == "login":
|
|
||||||
payload = await asyncio.to_thread(login_oauth_provider, query)
|
|
||||||
else:
|
|
||||||
payload = await asyncio.to_thread(logout_oauth_provider, query)
|
|
||||||
except WebUISettingsError as e:
|
|
||||||
return _http_error(e.status, e.message)
|
|
||||||
return _http_json_response(self._with_settings_restart_state(payload))
|
|
||||||
|
|
||||||
def _handle_settings_web_search_update(self, request: WsRequest) -> Response:
|
|
||||||
if not self._check_api_token(request):
|
|
||||||
return _http_error(401, "Unauthorized")
|
|
||||||
query = _parse_query(request.path)
|
|
||||||
try:
|
|
||||||
payload = update_web_search_settings(query)
|
|
||||||
except WebUISettingsError as e:
|
|
||||||
return _http_error(e.status, e.message)
|
|
||||||
return _http_json_response(self._with_settings_restart_state(payload, section="browser"))
|
|
||||||
|
|
||||||
def _handle_settings_image_generation_update(self, request: WsRequest) -> Response:
|
|
||||||
if not self._check_api_token(request):
|
|
||||||
return _http_error(401, "Unauthorized")
|
|
||||||
query = _parse_query(request.path)
|
|
||||||
try:
|
|
||||||
payload = update_image_generation_settings(query)
|
|
||||||
except WebUISettingsError as e:
|
|
||||||
return _http_error(e.status, e.message)
|
|
||||||
return _http_json_response(self._with_settings_restart_state(payload, section="image"))
|
|
||||||
|
|
||||||
def _handle_settings_network_safety_update(self, request: WsRequest) -> Response:
|
|
||||||
if not self._check_api_token(request):
|
|
||||||
return _http_error(401, "Unauthorized")
|
|
||||||
query = _parse_query(request.path)
|
|
||||||
try:
|
|
||||||
payload = update_network_safety_settings(query)
|
|
||||||
except WebUISettingsError as e:
|
|
||||||
return _http_error(e.status, e.message)
|
|
||||||
return _http_json_response(self._with_settings_restart_state(payload, section="runtime"))
|
|
||||||
|
|
||||||
def _handle_settings_cli_apps(self, request: WsRequest) -> Response:
|
|
||||||
if not self._check_api_token(request):
|
|
||||||
return _http_error(401, "Unauthorized")
|
|
||||||
try:
|
|
||||||
payload = cli_apps_payload()
|
|
||||||
except Exception:
|
|
||||||
self.logger.exception("failed to load CLI Apps payload")
|
|
||||||
return _http_error(500, "failed to load CLI Apps")
|
|
||||||
return _http_json_response(payload)
|
|
||||||
|
|
||||||
async def _handle_settings_cli_apps_action(self, request: WsRequest, action: str) -> Response:
|
|
||||||
if not self._check_api_token(request):
|
|
||||||
return _http_error(401, "Unauthorized")
|
|
||||||
query = _parse_query(request.path)
|
|
||||||
try:
|
|
||||||
payload = await asyncio.to_thread(cli_apps_action, action, query)
|
|
||||||
except WebUISettingsError as e:
|
|
||||||
return _http_error(e.status, e.message)
|
|
||||||
except Exception as e:
|
|
||||||
status = getattr(e, "status", 500)
|
|
||||||
message = getattr(e, "message", str(e))
|
|
||||||
if status >= 500:
|
|
||||||
self.logger.exception("CLI Apps action '{}' failed", action)
|
|
||||||
return _http_error(status, message)
|
|
||||||
return _http_json_response(payload)
|
|
||||||
|
|
||||||
async def _handle_settings_mcp_presets(
|
|
||||||
self,
|
|
||||||
request: WsRequest,
|
|
||||||
action: str | None = None,
|
|
||||||
) -> Response:
|
|
||||||
if not self._check_api_token(request):
|
|
||||||
return _http_error(401, "Unauthorized")
|
|
||||||
try:
|
|
||||||
payload = await mcp_presets_settings_action(
|
|
||||||
action,
|
|
||||||
_parse_mcp_settings_query(request),
|
|
||||||
reload_mcp=lambda: request_mcp_reload(self.bus),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
status = getattr(e, "status", 500)
|
|
||||||
message = getattr(e, "message", str(e))
|
|
||||||
if status >= 500:
|
|
||||||
self.logger.exception("MCP preset action '{}' failed", action or "list")
|
|
||||||
return _http_error(status, message)
|
|
||||||
if action is None:
|
|
||||||
return _http_json_response(payload)
|
|
||||||
return _http_json_response(
|
|
||||||
self._with_settings_restart_state(payload, section="runtime")
|
|
||||||
)
|
|
||||||
|
|
||||||
# -- Session replay, transcript, and signed media ----------------------
|
# -- Session replay, transcript, and signed media ----------------------
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@ -45,13 +45,21 @@ class AnthropicProvider(LLMProvider):
|
|||||||
if api_key:
|
if api_key:
|
||||||
client_kw["api_key"] = api_key
|
client_kw["api_key"] = api_key
|
||||||
if api_base:
|
if api_base:
|
||||||
client_kw["base_url"] = api_base
|
client_kw["base_url"] = self._normalize_base_url(api_base)
|
||||||
if extra_headers:
|
if extra_headers:
|
||||||
client_kw["default_headers"] = extra_headers
|
client_kw["default_headers"] = extra_headers
|
||||||
# Keep retries centralized in LLMProvider._run_with_retry to avoid retry amplification.
|
# Keep retries centralized in LLMProvider._run_with_retry to avoid retry amplification.
|
||||||
client_kw["max_retries"] = 0
|
client_kw["max_retries"] = 0
|
||||||
self._client = AsyncAnthropic(**client_kw)
|
self._client = AsyncAnthropic(**client_kw)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_base_url(api_base: str) -> str:
|
||||||
|
"""Anthropic SDK appends /v1 to request paths internally."""
|
||||||
|
normalized = api_base.rstrip("/")
|
||||||
|
if normalized.endswith("/v1"):
|
||||||
|
return normalized[: -len("/v1")]
|
||||||
|
return normalized
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _handle_error(cls, e: Exception) -> LLMResponse:
|
def _handle_error(cls, e: Exception) -> LLMResponse:
|
||||||
response = getattr(e, "response", None)
|
response = getattr(e, "response", None)
|
||||||
|
|||||||
@ -19,6 +19,7 @@ from nanobot.utils.helpers import (
|
|||||||
find_legal_message_start,
|
find_legal_message_start,
|
||||||
image_placeholder_text,
|
image_placeholder_text,
|
||||||
safe_filename,
|
safe_filename,
|
||||||
|
strip_think,
|
||||||
)
|
)
|
||||||
from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body
|
from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body
|
||||||
|
|
||||||
@ -76,6 +77,17 @@ def _message_preview_text(message: dict[str, Any]) -> str:
|
|||||||
return _text_preview(content)
|
return _text_preview(content)
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_title(metadata: Any) -> str:
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
return ""
|
||||||
|
title = metadata.get("title")
|
||||||
|
if not isinstance(title, str):
|
||||||
|
return ""
|
||||||
|
if metadata.get("title_user_edited") is True:
|
||||||
|
return title
|
||||||
|
return strip_think(title)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Session:
|
class Session:
|
||||||
"""A conversation session."""
|
"""A conversation session."""
|
||||||
@ -642,7 +654,7 @@ class SessionManager:
|
|||||||
if data.get("_type") == "metadata":
|
if data.get("_type") == "metadata":
|
||||||
key = data.get("key") or path.stem.replace("_", ":", 1)
|
key = data.get("key") or path.stem.replace("_", ":", 1)
|
||||||
metadata = data.get("metadata", {})
|
metadata = data.get("metadata", {})
|
||||||
title = metadata.get("title") if isinstance(metadata, dict) else None
|
title = _metadata_title(metadata)
|
||||||
preview = ""
|
preview = ""
|
||||||
fallback_preview = ""
|
fallback_preview = ""
|
||||||
scanned_records = 0
|
scanned_records = 0
|
||||||
@ -673,7 +685,7 @@ class SessionManager:
|
|||||||
"key": key,
|
"key": key,
|
||||||
"created_at": data.get("created_at"),
|
"created_at": data.get("created_at"),
|
||||||
"updated_at": data.get("updated_at"),
|
"updated_at": data.get("updated_at"),
|
||||||
"title": title if isinstance(title, str) else "",
|
"title": title,
|
||||||
"preview": preview,
|
"preview": preview,
|
||||||
"path": str(path)
|
"path": str(path)
|
||||||
})
|
})
|
||||||
@ -684,11 +696,7 @@ class SessionManager:
|
|||||||
"key": repaired.key,
|
"key": repaired.key,
|
||||||
"created_at": repaired.created_at.isoformat(),
|
"created_at": repaired.created_at.isoformat(),
|
||||||
"updated_at": repaired.updated_at.isoformat(),
|
"updated_at": repaired.updated_at.isoformat(),
|
||||||
"title": (
|
"title": _metadata_title(repaired.metadata),
|
||||||
repaired.metadata.get("title")
|
|
||||||
if isinstance(repaired.metadata.get("title"), str)
|
|
||||||
else ""
|
|
||||||
),
|
|
||||||
"preview": next(
|
"preview": next(
|
||||||
(
|
(
|
||||||
text
|
text
|
||||||
|
|||||||
@ -19,7 +19,7 @@ from nanobot.bus.queue import MessageBus
|
|||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
from nanobot.session.goal_state import goal_state_ws_blob
|
from nanobot.session.goal_state import goal_state_ws_blob
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.utils.helpers import truncate_text
|
from nanobot.utils.helpers import strip_think, truncate_text
|
||||||
from nanobot.utils.llm_runtime import LLMRuntime
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
|
|
||||||
WEBUI_SESSION_METADATA_KEY = "webui"
|
WEBUI_SESSION_METADATA_KEY = "webui"
|
||||||
@ -48,6 +48,7 @@ def clean_generated_title(raw: str | None) -> str:
|
|||||||
return ""
|
return ""
|
||||||
text = re.sub(r"^\s*(title|标题)\s*[::]\s*", "", text, flags=re.IGNORECASE)
|
text = re.sub(r"^\s*(title|标题)\s*[::]\s*", "", text, flags=re.IGNORECASE)
|
||||||
text = text.strip().strip("\"'`“”‘’")
|
text = text.strip().strip("\"'`“”‘’")
|
||||||
|
text = strip_think(text)
|
||||||
text = re.sub(r"\s+", " ", text).strip()
|
text = re.sub(r"\s+", " ", text).strip()
|
||||||
text = text.rstrip("。.!!??,,;;:")
|
text = text.rstrip("。.!!??,,;;:")
|
||||||
if len(text) > TITLE_MAX_CHARS:
|
if len(text) > TITLE_MAX_CHARS:
|
||||||
@ -65,6 +66,9 @@ def _title_inputs(session: Session) -> tuple[str, str]:
|
|||||||
content = message.get("content")
|
content = message.get("content")
|
||||||
if not isinstance(content, str) or not content.strip():
|
if not isinstance(content, str) or not content.strip():
|
||||||
continue
|
continue
|
||||||
|
content = strip_think(content)
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
if role == "user" and not user_text:
|
if role == "user" and not user_text:
|
||||||
user_text = content.strip()
|
user_text = content.strip()
|
||||||
elif role == "assistant" and not assistant_text:
|
elif role == "assistant" and not assistant_text:
|
||||||
@ -89,7 +93,13 @@ async def maybe_generate_webui_title(
|
|||||||
return False
|
return False
|
||||||
current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY)
|
current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY)
|
||||||
if isinstance(current_title, str) and current_title.strip():
|
if isinstance(current_title, str) and current_title.strip():
|
||||||
|
cleaned_current_title = clean_generated_title(current_title)
|
||||||
|
if cleaned_current_title:
|
||||||
|
if cleaned_current_title != current_title:
|
||||||
|
session.metadata[WEBUI_TITLE_METADATA_KEY] = cleaned_current_title
|
||||||
|
sessions.save(session)
|
||||||
return False
|
return False
|
||||||
|
session.metadata.pop(WEBUI_TITLE_METADATA_KEY, None)
|
||||||
|
|
||||||
user_text, assistant_text = _title_inputs(session)
|
user_text, assistant_text = _title_inputs(session)
|
||||||
if not user_text:
|
if not user_text:
|
||||||
|
|||||||
@ -50,10 +50,17 @@ _MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({
|
|||||||
"image/jpeg",
|
"image/jpeg",
|
||||||
"image/webp",
|
"image/webp",
|
||||||
"image/gif",
|
"image/gif",
|
||||||
|
"image/svg+xml",
|
||||||
"video/mp4",
|
"video/mp4",
|
||||||
"video/webm",
|
"video/webm",
|
||||||
"video/quicktime",
|
"video/quicktime",
|
||||||
})
|
})
|
||||||
|
_SVG_MEDIA_HEADERS: tuple[tuple[str, str], ...] = (
|
||||||
|
(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
_BYTE_RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$")
|
_BYTE_RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$")
|
||||||
|
|
||||||
@ -203,6 +210,8 @@ def serve_signed_media(
|
|||||||
("Cache-Control", "private, max-age=31536000, immutable"),
|
("Cache-Control", "private, max-age=31536000, immutable"),
|
||||||
("X-Content-Type-Options", "nosniff"),
|
("X-Content-Type-Options", "nosniff"),
|
||||||
]
|
]
|
||||||
|
if mime == "image/svg+xml":
|
||||||
|
common_headers.extend(_SVG_MEDIA_HEADERS)
|
||||||
try:
|
try:
|
||||||
size = candidate.stat().st_size
|
size = candidate.stat().st_size
|
||||||
except OSError:
|
except OSError:
|
||||||
|
|||||||
@ -6,12 +6,15 @@ settings payload shape and the allowlisted config mutations exposed to WebUI.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from nanobot.config.loader import get_config_path, load_config, save_config
|
from nanobot.config.loader import get_config_path, load_config, save_config
|
||||||
from nanobot.config.schema import ModelPresetConfig
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
from nanobot.providers.image_generation import (
|
from nanobot.providers.image_generation import (
|
||||||
@ -87,6 +90,47 @@ _IMAGE_GENERATION_ASPECT_RATIOS = {
|
|||||||
}
|
}
|
||||||
_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 262_144}
|
_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 262_144}
|
||||||
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
||||||
|
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||||
|
|
||||||
|
_MODEL_LIST_UNSUPPORTED_BACKENDS = {
|
||||||
|
"anthropic",
|
||||||
|
"azure_openai",
|
||||||
|
"bedrock",
|
||||||
|
"github_copilot",
|
||||||
|
"openai_codex",
|
||||||
|
}
|
||||||
|
|
||||||
|
_MODEL_LIST_CATALOG_PROVIDERS = {
|
||||||
|
"aihubmix",
|
||||||
|
"byteplus",
|
||||||
|
"byteplus_coding_plan",
|
||||||
|
"huggingface",
|
||||||
|
"novita",
|
||||||
|
"openrouter",
|
||||||
|
"siliconflow",
|
||||||
|
"volcengine",
|
||||||
|
"volcengine_coding_plan",
|
||||||
|
}
|
||||||
|
|
||||||
|
_MODEL_LIST_OFFICIAL_PROVIDERS = {
|
||||||
|
"ant_ling",
|
||||||
|
"dashscope",
|
||||||
|
"deepseek",
|
||||||
|
"gemini",
|
||||||
|
"groq",
|
||||||
|
"longcat",
|
||||||
|
"minimax",
|
||||||
|
"minimax_anthropic",
|
||||||
|
"mistral",
|
||||||
|
"moonshot",
|
||||||
|
"nvidia",
|
||||||
|
"openai",
|
||||||
|
"qianfan",
|
||||||
|
"skywork",
|
||||||
|
"stepfun",
|
||||||
|
"xiaomi_mimo",
|
||||||
|
"zhipu",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class WebUISettingsError(ValueError):
|
class WebUISettingsError(ValueError):
|
||||||
@ -180,6 +224,25 @@ def _mask_secret_hint(secret: str | None) -> str | None:
|
|||||||
return f"{secret[:4]}••••{secret[-4:]}"
|
return f"{secret[:4]}••••{secret[-4:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_env_placeholders(value: str | None) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
missing = False
|
||||||
|
|
||||||
|
def replace(match: re.Match[str]) -> str:
|
||||||
|
nonlocal missing
|
||||||
|
env_value = os.environ.get(match.group(1))
|
||||||
|
if env_value is None:
|
||||||
|
missing = True
|
||||||
|
return ""
|
||||||
|
return env_value
|
||||||
|
|
||||||
|
resolved = _ENV_REF_RE.sub(replace, value).strip()
|
||||||
|
if missing and not resolved:
|
||||||
|
return None
|
||||||
|
return resolved or None
|
||||||
|
|
||||||
|
|
||||||
def _provider_requires_api_key(spec: Any) -> bool:
|
def _provider_requires_api_key(spec: Any) -> bool:
|
||||||
if spec.backend == "azure_openai":
|
if spec.backend == "azure_openai":
|
||||||
return True
|
return True
|
||||||
@ -251,6 +314,191 @@ def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _model_catalog_kind(spec: Any) -> str:
|
||||||
|
if spec.name in _MODEL_LIST_CATALOG_PROVIDERS:
|
||||||
|
return "catalog"
|
||||||
|
if spec.name in _MODEL_LIST_OFFICIAL_PROVIDERS:
|
||||||
|
return "official"
|
||||||
|
if spec.is_local:
|
||||||
|
return "local"
|
||||||
|
if spec.is_direct:
|
||||||
|
return "custom"
|
||||||
|
if spec.is_gateway:
|
||||||
|
return "catalog"
|
||||||
|
return "official"
|
||||||
|
|
||||||
|
|
||||||
|
def _model_id_from_row(row: Any) -> str | None:
|
||||||
|
if isinstance(row, str):
|
||||||
|
return row.strip() or None
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
return None
|
||||||
|
for key in ("id", "name", "model"):
|
||||||
|
value = row.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _model_context_window(row: Any) -> int | None:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
return None
|
||||||
|
for key in (
|
||||||
|
"context_window",
|
||||||
|
"context_length",
|
||||||
|
"max_context_length",
|
||||||
|
"max_model_len",
|
||||||
|
"max_input_tokens",
|
||||||
|
):
|
||||||
|
value = row.get(key)
|
||||||
|
if isinstance(value, int) and value > 0:
|
||||||
|
return value
|
||||||
|
if isinstance(value, float) and value > 0:
|
||||||
|
return int(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _model_row_payload(row: Any) -> dict[str, Any] | None:
|
||||||
|
model_id = _model_id_from_row(row)
|
||||||
|
if not model_id:
|
||||||
|
return None
|
||||||
|
label: str | None = None
|
||||||
|
owned_by: str | None = None
|
||||||
|
if isinstance(row, dict):
|
||||||
|
raw_label = row.get("display_name") or row.get("label") or row.get("name")
|
||||||
|
if isinstance(raw_label, str) and raw_label.strip() and raw_label.strip() != model_id:
|
||||||
|
label = raw_label.strip()
|
||||||
|
raw_owner = row.get("owned_by") or row.get("owner") or row.get("organization")
|
||||||
|
if isinstance(raw_owner, str) and raw_owner.strip():
|
||||||
|
owned_by = raw_owner.strip()
|
||||||
|
return {
|
||||||
|
"id": model_id,
|
||||||
|
"label": label,
|
||||||
|
"owned_by": owned_by,
|
||||||
|
"context_window": _model_context_window(row),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_model_rows(body: Any) -> list[dict[str, Any]]:
|
||||||
|
raw_rows = body.get("data") if isinstance(body, dict) else body
|
||||||
|
if not isinstance(raw_rows, list):
|
||||||
|
return []
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw_row in raw_rows:
|
||||||
|
row = _model_row_payload(raw_row)
|
||||||
|
if row is None or row["id"] in seen:
|
||||||
|
continue
|
||||||
|
seen.add(row["id"])
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def provider_models_payload(query: QueryParams) -> dict[str, Any]:
|
||||||
|
"""Fetch an OpenAI-compatible provider's model list for Settings.
|
||||||
|
|
||||||
|
The result is advisory only: users can always type a custom model id. This
|
||||||
|
helper deliberately avoids mutating config so probing model lists never
|
||||||
|
changes runtime behavior.
|
||||||
|
"""
|
||||||
|
provider_name = (_query_first(query, "provider") or "").strip()
|
||||||
|
if not provider_name:
|
||||||
|
raise WebUISettingsError("provider is required")
|
||||||
|
spec = find_by_name(provider_name)
|
||||||
|
if spec is None:
|
||||||
|
raise WebUISettingsError("unknown provider")
|
||||||
|
|
||||||
|
base_payload: dict[str, Any] = {
|
||||||
|
"provider": spec.name,
|
||||||
|
"label": spec.label,
|
||||||
|
"catalog_kind": _model_catalog_kind(spec),
|
||||||
|
"models": [],
|
||||||
|
"model_count": 0,
|
||||||
|
"message": None,
|
||||||
|
"fetched_at": time.time(),
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
spec.backend in _MODEL_LIST_UNSUPPORTED_BACKENDS
|
||||||
|
and spec.name != "minimax_anthropic"
|
||||||
|
) or spec.is_oauth:
|
||||||
|
return {
|
||||||
|
**base_payload,
|
||||||
|
"status": "unsupported",
|
||||||
|
"catalog_kind": "unsupported",
|
||||||
|
"message": "Model list is not available for this provider. Type a model ID manually.",
|
||||||
|
}
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
provider_config = getattr(config.providers, spec.name, None)
|
||||||
|
if provider_config is None:
|
||||||
|
raise WebUISettingsError("unknown provider")
|
||||||
|
|
||||||
|
api_base = _resolve_env_placeholders(provider_config.api_base) or spec.default_api_base
|
||||||
|
if spec.name == "openai" and not api_base:
|
||||||
|
api_base = "https://api.openai.com/v1"
|
||||||
|
if not api_base:
|
||||||
|
return {
|
||||||
|
**base_payload,
|
||||||
|
"status": "missing_api_base",
|
||||||
|
"message": "Configure an API base URL to load models.",
|
||||||
|
}
|
||||||
|
|
||||||
|
api_key = _resolve_env_placeholders(provider_config.api_key)
|
||||||
|
if _provider_requires_api_key(spec) and not api_key:
|
||||||
|
return {
|
||||||
|
**base_payload,
|
||||||
|
"status": "not_configured",
|
||||||
|
"message": "Configure this provider before loading models.",
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {"Accept": "application/json"}
|
||||||
|
if api_key:
|
||||||
|
if spec.name == "minimax_anthropic":
|
||||||
|
headers["X-Api-Key"] = api_key
|
||||||
|
else:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
|
||||||
|
models_url = f"{api_base.rstrip('/')}/models"
|
||||||
|
if spec.name == "minimax_anthropic" and not api_base.rstrip("/").endswith("/v1"):
|
||||||
|
models_url = f"{api_base.rstrip('/')}/v1/models"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = httpx.get(
|
||||||
|
models_url,
|
||||||
|
headers=headers,
|
||||||
|
timeout=10.0,
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
rows = _extract_model_rows(response.json())
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
status = exc.response.status_code
|
||||||
|
if status in {401, 403}:
|
||||||
|
return {
|
||||||
|
**base_payload,
|
||||||
|
"status": "not_configured",
|
||||||
|
"message": "The provider rejected the configured credential.",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
**base_payload,
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Model list request failed with HTTP {status}.",
|
||||||
|
}
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
return {
|
||||||
|
**base_payload,
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Could not load models: {exc}",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
**base_payload,
|
||||||
|
"status": "available",
|
||||||
|
"models": rows,
|
||||||
|
"model_count": len(rows),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _parse_bool(value: str, field: str) -> bool:
|
def _parse_bool(value: str, field: str) -> bool:
|
||||||
normalized = value.strip().lower()
|
normalized = value.strip().lower()
|
||||||
if normalized not in {"1", "0", "true", "false", "yes", "no"}:
|
if normalized not in {"1", "0", "true", "false", "yes", "no"}:
|
||||||
|
|||||||
329
nanobot/webui/settings_routes.py
Normal file
329
nanobot/webui/settings_routes.py
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
"""HTTP route adapter for WebUI Settings APIs.
|
||||||
|
|
||||||
|
Keep WebUI Settings route handlers here, not in ``channels/websocket.py``.
|
||||||
|
The websocket channel owns transport concerns; this module owns WebUI Settings
|
||||||
|
request mapping and response shaping.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from websockets.http11 import Request as WsRequest
|
||||||
|
from websockets.http11 import Response
|
||||||
|
|
||||||
|
from nanobot.agent.tools.mcp import request_mcp_reload
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload
|
||||||
|
from nanobot.webui.mcp_presets_api import mcp_presets_settings_action
|
||||||
|
from nanobot.webui.settings_api import (
|
||||||
|
WebUISettingsError,
|
||||||
|
create_model_configuration,
|
||||||
|
decorate_settings_payload,
|
||||||
|
login_oauth_provider,
|
||||||
|
logout_oauth_provider,
|
||||||
|
provider_models_payload,
|
||||||
|
settings_payload,
|
||||||
|
update_agent_settings,
|
||||||
|
update_image_generation_settings,
|
||||||
|
update_model_configuration,
|
||||||
|
update_network_safety_settings,
|
||||||
|
update_provider_settings,
|
||||||
|
update_web_search_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
QueryParams = dict[str, list[str]]
|
||||||
|
|
||||||
|
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
|
||||||
|
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||||
|
|
||||||
|
_MCP_PRESET_ACTIONS_BY_PATH = {
|
||||||
|
"/api/settings/mcp-presets/enable": "enable",
|
||||||
|
"/api/settings/mcp-presets/remove": "remove",
|
||||||
|
"/api/settings/mcp-presets/test": "test",
|
||||||
|
"/api/settings/mcp-presets/custom": "custom",
|
||||||
|
"/api/settings/mcp-presets/import": "import",
|
||||||
|
"/api/settings/mcp-presets/import-cursor": "import-cursor",
|
||||||
|
"/api/settings/mcp-presets/tools": "tools",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WebUISettingsRouter:
|
||||||
|
"""Route WebUI Settings HTTP requests behind a transport-neutral boundary."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
bus: MessageBus,
|
||||||
|
logger: Any,
|
||||||
|
check_api_token: Callable[[WsRequest], bool],
|
||||||
|
parse_query: Callable[[str], QueryParams],
|
||||||
|
json_response: Callable[[dict[str, Any]], Response],
|
||||||
|
error_response: Callable[[int, str | None], Response],
|
||||||
|
runtime_surface: str,
|
||||||
|
runtime_capabilities: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
self.bus = bus
|
||||||
|
self.logger = logger
|
||||||
|
self._check_api_token = check_api_token
|
||||||
|
self._parse_query = parse_query
|
||||||
|
self._json_response = json_response
|
||||||
|
self._error_response = error_response
|
||||||
|
self._runtime_surface = runtime_surface
|
||||||
|
self._runtime_capabilities = runtime_capabilities
|
||||||
|
self._restart_sections: set[str] = set()
|
||||||
|
|
||||||
|
async def dispatch(self, request: WsRequest, path: str) -> Response | None:
|
||||||
|
if path == "/api/settings":
|
||||||
|
return self._handle_settings(request)
|
||||||
|
if path == "/api/settings/update":
|
||||||
|
return self._handle_settings_update(request)
|
||||||
|
if path == "/api/settings/model-configurations/create":
|
||||||
|
return self._handle_settings_model_configuration_create(request)
|
||||||
|
if path == "/api/settings/model-configurations/update":
|
||||||
|
return self._handle_settings_model_configuration_update(request)
|
||||||
|
if path == "/api/settings/provider/update":
|
||||||
|
return self._handle_settings_provider_update(request)
|
||||||
|
if path == "/api/settings/provider-models":
|
||||||
|
return await self._handle_settings_provider_models(request)
|
||||||
|
if path == "/api/settings/provider/oauth-login":
|
||||||
|
return await self._handle_settings_provider_oauth(request, "login")
|
||||||
|
if path == "/api/settings/provider/oauth-logout":
|
||||||
|
return await self._handle_settings_provider_oauth(request, "logout")
|
||||||
|
if path == "/api/settings/web-search/update":
|
||||||
|
return self._handle_settings_web_search_update(request)
|
||||||
|
if path == "/api/settings/image-generation/update":
|
||||||
|
return self._handle_settings_image_generation_update(request)
|
||||||
|
if path == "/api/settings/network-safety/update":
|
||||||
|
return self._handle_settings_network_safety_update(request)
|
||||||
|
if path == "/api/settings/cli-apps":
|
||||||
|
return self._handle_settings_cli_apps(request)
|
||||||
|
if path == "/api/settings/cli-apps/install":
|
||||||
|
return await self._handle_settings_cli_apps_action(request, "install")
|
||||||
|
if path == "/api/settings/cli-apps/update":
|
||||||
|
return await self._handle_settings_cli_apps_action(request, "update")
|
||||||
|
if path == "/api/settings/cli-apps/uninstall":
|
||||||
|
return await self._handle_settings_cli_apps_action(request, "uninstall")
|
||||||
|
if path == "/api/settings/cli-apps/test":
|
||||||
|
return await self._handle_settings_cli_apps_action(request, "test")
|
||||||
|
if path == "/api/settings/mcp-presets":
|
||||||
|
return await self._handle_settings_mcp_presets(request)
|
||||||
|
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(path)
|
||||||
|
if mcp_action is not None:
|
||||||
|
return await self._handle_settings_mcp_presets(request, mcp_action)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _query(self, request: WsRequest) -> QueryParams:
|
||||||
|
return self._parse_query(request.path)
|
||||||
|
|
||||||
|
def _authorized(self, request: WsRequest) -> bool:
|
||||||
|
return self._check_api_token(request)
|
||||||
|
|
||||||
|
def _unauthorized(self) -> Response:
|
||||||
|
return self._error_response(401, "Unauthorized")
|
||||||
|
|
||||||
|
def _with_restart_state(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
section: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Keep restart-required state alive for this gateway process."""
|
||||||
|
if section and payload.get("requires_restart"):
|
||||||
|
self._restart_sections.add(section)
|
||||||
|
sections = sorted(self._restart_sections)
|
||||||
|
payload = dict(payload)
|
||||||
|
if sections:
|
||||||
|
payload["requires_restart"] = True
|
||||||
|
return decorate_settings_payload(
|
||||||
|
payload,
|
||||||
|
surface=self._runtime_surface,
|
||||||
|
runtime_capability_overrides=self._runtime_capabilities,
|
||||||
|
restart_required_sections=sections,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
|
||||||
|
query = self._query(request)
|
||||||
|
raw = request.headers.get(_MCP_VALUES_HEADER)
|
||||||
|
if not raw:
|
||||||
|
return query
|
||||||
|
if len(raw.encode("utf-8")) > _MCP_VALUES_HEADER_MAX_BYTES:
|
||||||
|
raise WebUISettingsError("MCP settings payload is too large")
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise WebUISettingsError("invalid MCP settings payload") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise WebUISettingsError("MCP settings payload must be a JSON object")
|
||||||
|
merged = {key: list(values) for key, values in query.items()}
|
||||||
|
for key, value in payload.items():
|
||||||
|
if not isinstance(key, str) or not key:
|
||||||
|
raise WebUISettingsError("MCP settings payload contains an invalid key")
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, str):
|
||||||
|
text = value.strip()
|
||||||
|
else:
|
||||||
|
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
if text:
|
||||||
|
merged[key] = [text]
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def _handle_settings(self, request: WsRequest) -> Response:
|
||||||
|
if not self._authorized(request):
|
||||||
|
return self._unauthorized()
|
||||||
|
return self._json_response(
|
||||||
|
self._with_restart_state(
|
||||||
|
settings_payload(
|
||||||
|
surface=self._runtime_surface,
|
||||||
|
runtime_capability_overrides=self._runtime_capabilities,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_settings_update(self, request: WsRequest) -> Response:
|
||||||
|
if not self._authorized(request):
|
||||||
|
return self._unauthorized()
|
||||||
|
try:
|
||||||
|
payload = update_agent_settings(self._query(request))
|
||||||
|
except WebUISettingsError as e:
|
||||||
|
return self._error_response(e.status, e.message)
|
||||||
|
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
||||||
|
|
||||||
|
def _handle_settings_model_configuration_create(self, request: WsRequest) -> Response:
|
||||||
|
if not self._authorized(request):
|
||||||
|
return self._unauthorized()
|
||||||
|
try:
|
||||||
|
payload = create_model_configuration(self._query(request))
|
||||||
|
except WebUISettingsError as e:
|
||||||
|
return self._error_response(e.status, e.message)
|
||||||
|
return self._json_response(self._with_restart_state(payload))
|
||||||
|
|
||||||
|
def _handle_settings_model_configuration_update(self, request: WsRequest) -> Response:
|
||||||
|
if not self._authorized(request):
|
||||||
|
return self._unauthorized()
|
||||||
|
try:
|
||||||
|
payload = update_model_configuration(self._query(request))
|
||||||
|
except WebUISettingsError as e:
|
||||||
|
return self._error_response(e.status, e.message)
|
||||||
|
return self._json_response(self._with_restart_state(payload))
|
||||||
|
|
||||||
|
def _handle_settings_provider_update(self, request: WsRequest) -> Response:
|
||||||
|
if not self._authorized(request):
|
||||||
|
return self._unauthorized()
|
||||||
|
try:
|
||||||
|
payload = update_provider_settings(self._query(request))
|
||||||
|
except WebUISettingsError as e:
|
||||||
|
return self._error_response(e.status, e.message)
|
||||||
|
return self._json_response(self._with_restart_state(payload, section="image"))
|
||||||
|
|
||||||
|
async def _handle_settings_provider_models(self, request: WsRequest) -> Response:
|
||||||
|
if not self._authorized(request):
|
||||||
|
return self._unauthorized()
|
||||||
|
try:
|
||||||
|
payload = await asyncio.to_thread(provider_models_payload, self._query(request))
|
||||||
|
except WebUISettingsError as e:
|
||||||
|
return self._error_response(e.status, e.message)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("failed to load provider model list")
|
||||||
|
return self._error_response(500, "failed to load provider model list")
|
||||||
|
return self._json_response(payload)
|
||||||
|
|
||||||
|
async def _handle_settings_provider_oauth(
|
||||||
|
self,
|
||||||
|
request: WsRequest,
|
||||||
|
action: str,
|
||||||
|
) -> Response:
|
||||||
|
if not self._authorized(request):
|
||||||
|
return self._unauthorized()
|
||||||
|
query = self._query(request)
|
||||||
|
try:
|
||||||
|
if action == "login":
|
||||||
|
payload = await asyncio.to_thread(login_oauth_provider, query)
|
||||||
|
else:
|
||||||
|
payload = await asyncio.to_thread(logout_oauth_provider, query)
|
||||||
|
except WebUISettingsError as e:
|
||||||
|
return self._error_response(e.status, e.message)
|
||||||
|
return self._json_response(self._with_restart_state(payload))
|
||||||
|
|
||||||
|
def _handle_settings_web_search_update(self, request: WsRequest) -> Response:
|
||||||
|
if not self._authorized(request):
|
||||||
|
return self._unauthorized()
|
||||||
|
try:
|
||||||
|
payload = update_web_search_settings(self._query(request))
|
||||||
|
except WebUISettingsError as e:
|
||||||
|
return self._error_response(e.status, e.message)
|
||||||
|
return self._json_response(self._with_restart_state(payload, section="browser"))
|
||||||
|
|
||||||
|
def _handle_settings_image_generation_update(self, request: WsRequest) -> Response:
|
||||||
|
if not self._authorized(request):
|
||||||
|
return self._unauthorized()
|
||||||
|
try:
|
||||||
|
payload = update_image_generation_settings(self._query(request))
|
||||||
|
except WebUISettingsError as e:
|
||||||
|
return self._error_response(e.status, e.message)
|
||||||
|
return self._json_response(self._with_restart_state(payload, section="image"))
|
||||||
|
|
||||||
|
def _handle_settings_network_safety_update(self, request: WsRequest) -> Response:
|
||||||
|
if not self._authorized(request):
|
||||||
|
return self._unauthorized()
|
||||||
|
try:
|
||||||
|
payload = update_network_safety_settings(self._query(request))
|
||||||
|
except WebUISettingsError as e:
|
||||||
|
return self._error_response(e.status, e.message)
|
||||||
|
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
||||||
|
|
||||||
|
def _handle_settings_cli_apps(self, request: WsRequest) -> Response:
|
||||||
|
if not self._authorized(request):
|
||||||
|
return self._unauthorized()
|
||||||
|
try:
|
||||||
|
payload = cli_apps_payload()
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("failed to load CLI Apps payload")
|
||||||
|
return self._error_response(500, "failed to load CLI Apps")
|
||||||
|
return self._json_response(payload)
|
||||||
|
|
||||||
|
async def _handle_settings_cli_apps_action(
|
||||||
|
self,
|
||||||
|
request: WsRequest,
|
||||||
|
action: str,
|
||||||
|
) -> Response:
|
||||||
|
if not self._authorized(request):
|
||||||
|
return self._unauthorized()
|
||||||
|
try:
|
||||||
|
payload = await asyncio.to_thread(cli_apps_action, action, self._query(request))
|
||||||
|
except WebUISettingsError as e:
|
||||||
|
return self._error_response(e.status, e.message)
|
||||||
|
except Exception as e:
|
||||||
|
status = getattr(e, "status", 500)
|
||||||
|
message = getattr(e, "message", str(e))
|
||||||
|
if status >= 500:
|
||||||
|
self.logger.exception("CLI Apps action '{}' failed", action)
|
||||||
|
return self._error_response(status, message)
|
||||||
|
return self._json_response(payload)
|
||||||
|
|
||||||
|
async def _handle_settings_mcp_presets(
|
||||||
|
self,
|
||||||
|
request: WsRequest,
|
||||||
|
action: str | None = None,
|
||||||
|
) -> Response:
|
||||||
|
if not self._authorized(request):
|
||||||
|
return self._unauthorized()
|
||||||
|
try:
|
||||||
|
payload = await mcp_presets_settings_action(
|
||||||
|
action,
|
||||||
|
self._parse_mcp_settings_query(request),
|
||||||
|
reload_mcp=lambda: request_mcp_reload(self.bus),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
status = getattr(e, "status", 500)
|
||||||
|
message = getattr(e, "message", str(e))
|
||||||
|
if status >= 500:
|
||||||
|
self.logger.exception("MCP preset action '{}' failed", action or "list")
|
||||||
|
return self._error_response(status, message)
|
||||||
|
if action is None:
|
||||||
|
return self._json_response(payload)
|
||||||
|
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
||||||
@ -27,6 +27,7 @@ _INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({
|
|||||||
".jpeg",
|
".jpeg",
|
||||||
".webp",
|
".webp",
|
||||||
".gif",
|
".gif",
|
||||||
|
".svg",
|
||||||
})
|
})
|
||||||
_INLINE_MARKDOWN_VIDEO_EXTS: frozenset[str] = frozenset({
|
_INLINE_MARKDOWN_VIDEO_EXTS: frozenset[str] = frozenset({
|
||||||
".mp4",
|
".mp4",
|
||||||
@ -87,7 +88,12 @@ def rewrite_local_markdown_images(
|
|||||||
|
|
||||||
|
|
||||||
def _media_kind_from_name(name: str) -> str:
|
def _media_kind_from_name(name: str) -> str:
|
||||||
return "video" if Path(name).suffix.lower() in _INLINE_MARKDOWN_VIDEO_EXTS else "image"
|
ext = Path(name).suffix.lower()
|
||||||
|
if ext in _INLINE_MARKDOWN_IMAGE_EXTS:
|
||||||
|
return "image"
|
||||||
|
if ext in _INLINE_MARKDOWN_VIDEO_EXTS:
|
||||||
|
return "video"
|
||||||
|
return "file"
|
||||||
|
|
||||||
|
|
||||||
def webui_transcript_path(session_key: str) -> Path:
|
def webui_transcript_path(session_key: str) -> Path:
|
||||||
|
|||||||
@ -17,6 +17,7 @@ from nanobot.session.webui_turns import (
|
|||||||
WEBUI_SESSION_METADATA_KEY,
|
WEBUI_SESSION_METADATA_KEY,
|
||||||
WEBUI_TITLE_METADATA_KEY,
|
WEBUI_TITLE_METADATA_KEY,
|
||||||
WebuiTurnCoordinator,
|
WebuiTurnCoordinator,
|
||||||
|
clean_generated_title,
|
||||||
maybe_generate_webui_title,
|
maybe_generate_webui_title,
|
||||||
)
|
)
|
||||||
from nanobot.utils.llm_runtime import LLMRuntime
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
@ -53,6 +54,11 @@ def test_agent_loop_llm_runtime_reflects_current_provider_and_model(tmp_path: Pa
|
|||||||
assert runtime.model == "next-model"
|
assert runtime.model == "next-model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_generated_title_strips_reasoning_tags() -> None:
|
||||||
|
assert clean_generated_title("<think>reasoning</think> WebUI polish") == "WebUI polish"
|
||||||
|
assert clean_generated_title("Title: <think> The user said hello") == ""
|
||||||
|
|
||||||
|
|
||||||
@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)
|
||||||
|
|||||||
@ -43,6 +43,32 @@ def test_list_sessions_includes_metadata_title(tmp_path):
|
|||||||
assert rows[0]["title"] == "自动生成标题"
|
assert rows[0]["title"] == "自动生成标题"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_sessions_hides_generated_think_title(tmp_path):
|
||||||
|
manager = SessionManager(tmp_path)
|
||||||
|
session = manager.get_or_create("websocket:chat-think-title")
|
||||||
|
session.metadata["title"] = "<think> The user said hello and assistant replied"
|
||||||
|
session.add_message("user", "hello")
|
||||||
|
manager.save(session)
|
||||||
|
|
||||||
|
rows = manager.list_sessions()
|
||||||
|
|
||||||
|
assert rows[0]["key"] == "websocket:chat-think-title"
|
||||||
|
assert rows[0]["title"] == ""
|
||||||
|
assert rows[0]["preview"] == "hello"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_sessions_keeps_user_edited_think_title(tmp_path):
|
||||||
|
manager = SessionManager(tmp_path)
|
||||||
|
session = manager.get_or_create("websocket:chat-user-title")
|
||||||
|
session.metadata["title"] = "<think> literally discussed"
|
||||||
|
session.metadata["title_user_edited"] = True
|
||||||
|
manager.save(session)
|
||||||
|
|
||||||
|
rows = manager.list_sessions()
|
||||||
|
|
||||||
|
assert rows[0]["title"] == "<think> literally discussed"
|
||||||
|
|
||||||
|
|
||||||
def test_list_sessions_includes_user_preview(tmp_path):
|
def test_list_sessions_includes_user_preview(tmp_path):
|
||||||
manager = SessionManager(tmp_path)
|
manager = SessionManager(tmp_path)
|
||||||
session = manager.get_or_create("websocket:chat-preview")
|
session = manager.get_or_create("websocket:chat-preview")
|
||||||
|
|||||||
@ -148,7 +148,7 @@ async def test_cli_apps_routes_require_token_and_return_payload(
|
|||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.channels.websocket.cli_apps_payload",
|
"nanobot.webui.settings_routes.cli_apps_payload",
|
||||||
lambda: {
|
lambda: {
|
||||||
"apps": [
|
"apps": [
|
||||||
{
|
{
|
||||||
@ -173,7 +173,7 @@ async def test_cli_apps_routes_require_token_and_return_payload(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.channels.websocket.cli_apps_action",
|
"nanobot.webui.settings_routes.cli_apps_action",
|
||||||
lambda action, query: {
|
lambda action, query: {
|
||||||
"apps": [],
|
"apps": [],
|
||||||
"installed_count": 1,
|
"installed_count": 1,
|
||||||
@ -280,7 +280,7 @@ async def test_mcp_presets_routes_require_token_and_return_payload(
|
|||||||
return {"ok": True, "message": "MCP config reloaded.", "requires_restart": False}
|
return {"ok": True, "message": "MCP config reloaded.", "requires_restart": False}
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.channels.websocket.request_mcp_reload",
|
"nanobot.webui.settings_routes.request_mcp_reload",
|
||||||
_hot_reload,
|
_hot_reload,
|
||||||
)
|
)
|
||||||
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29913)
|
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29913)
|
||||||
|
|||||||
@ -453,6 +453,35 @@ async def test_media_route_degrades_non_image_to_octet_stream(
|
|||||||
assert resp.headers.get("x-content-type-options") == "nosniff"
|
assert resp.headers.get("x-content-type-options") == "nosniff"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_media_route_serves_svg_with_strict_csp(
|
||||||
|
bus: MagicMock, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Generated SVG can preview as an image without becoming executable HTML."""
|
||||||
|
media = tmp_path / "media"
|
||||||
|
media.mkdir()
|
||||||
|
target = media / "chart.svg"
|
||||||
|
target.write_text("<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>")
|
||||||
|
|
||||||
|
channel = _ch(bus, port=29928)
|
||||||
|
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
|
||||||
|
url_path = channel._sign_media_path(target)
|
||||||
|
assert url_path is not None
|
||||||
|
server_task = asyncio.create_task(channel.start())
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
try:
|
||||||
|
resp = await _http_get(f"http://127.0.0.1:29928{url_path}")
|
||||||
|
finally:
|
||||||
|
await channel.stop()
|
||||||
|
await server_task
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"].startswith("image/svg+xml")
|
||||||
|
assert resp.headers.get("x-content-type-options") == "nosniff"
|
||||||
|
assert "default-src 'none'" in resp.headers.get("content-security-policy", "")
|
||||||
|
assert "sandbox" in resp.headers.get("content-security-policy", "")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /api/sessions/<key>/messages: media_urls hydration on session read
|
# /api/sessions/<key>/messages: media_urls hydration on session read
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -469,6 +469,28 @@ def test_config_auto_detects_xiaomi_mimo_from_model_keyword():
|
|||||||
assert config.get_api_base() == "https://api.xiaomimimo.com/v1"
|
assert config.get_api_base() == "https://api.xiaomimimo.com/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_explicit_minimax_anthropic_provider_uses_default_api_base():
|
||||||
|
config = Config.model_validate(
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"provider": "minimax_anthropic",
|
||||||
|
"model": "MiniMax-M2.7-highspeed",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"minimaxAnthropic": {
|
||||||
|
"apiKey": "test-key",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.get_provider_name() == "minimax_anthropic"
|
||||||
|
assert config.get_api_key() == "test-key"
|
||||||
|
assert config.get_api_base() == "https://api.minimax.io/anthropic"
|
||||||
|
|
||||||
|
|
||||||
def test_config_auto_detects_ollama_from_local_api_base():
|
def test_config_auto_detects_ollama_from_local_api_base():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -22,6 +22,18 @@ def test_anthropic_disables_sdk_retries_by_default() -> None:
|
|||||||
assert kwargs["max_retries"] == 0
|
assert kwargs["max_retries"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_anthropic_normalizes_versioned_base_url() -> None:
|
||||||
|
with patch("anthropic.AsyncAnthropic") as mock_client:
|
||||||
|
AnthropicProvider(
|
||||||
|
api_key="sk-test",
|
||||||
|
api_base="https://api.minimax.io/anthropic/v1",
|
||||||
|
default_model="MiniMax-M2.7-highspeed",
|
||||||
|
)
|
||||||
|
|
||||||
|
kwargs = mock_client.call_args.kwargs
|
||||||
|
assert kwargs["base_url"] == "https://api.minimax.io/anthropic"
|
||||||
|
|
||||||
|
|
||||||
def test_azure_openai_disables_sdk_retries_by_default() -> None:
|
def test_azure_openai_disables_sdk_retries_by_default() -> None:
|
||||||
with patch("nanobot.providers.azure_openai_provider.AsyncOpenAI") as mock_client:
|
with patch("nanobot.providers.azure_openai_provider.AsyncOpenAI") as mock_client:
|
||||||
AzureOpenAIProvider(
|
AzureOpenAIProvider(
|
||||||
|
|||||||
@ -84,6 +84,42 @@ def test_replay_infers_video_media_from_attachment_name() -> None:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_infers_svg_media_from_attachment_name() -> None:
|
||||||
|
msgs = replay_transcript_to_ui_messages(
|
||||||
|
[
|
||||||
|
{"event": "user", "chat_id": "t-svg", "text": "send svg"},
|
||||||
|
{
|
||||||
|
"event": "message",
|
||||||
|
"chat_id": "t-svg",
|
||||||
|
"text": "chart ready",
|
||||||
|
"media_urls": [{"url": "/api/media/sig/payload", "name": "chart.svg"}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert msgs[1]["media"] == [
|
||||||
|
{"kind": "image", "url": "/api/media/sig/payload", "name": "chart.svg"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_infers_file_media_from_attachment_name() -> None:
|
||||||
|
msgs = replay_transcript_to_ui_messages(
|
||||||
|
[
|
||||||
|
{"event": "user", "chat_id": "t-file-media", "text": "send html"},
|
||||||
|
{
|
||||||
|
"event": "message",
|
||||||
|
"chat_id": "t-file-media",
|
||||||
|
"text": "file ready",
|
||||||
|
"media_urls": [{"url": "/api/media/sig/payload", "name": "index.html"}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert msgs[1]["media"] == [
|
||||||
|
{"kind": "file", "url": "/api/media/sig/payload", "name": "index.html"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) -> None:
|
def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) -> None:
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
key = "websocket:t-file"
|
key = "websocket:t-file"
|
||||||
|
|||||||
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.config.loader import load_config, save_config
|
from nanobot.config.loader import load_config, save_config
|
||||||
@ -10,6 +11,7 @@ from nanobot.webui.settings_api import (
|
|||||||
WebUISettingsError,
|
WebUISettingsError,
|
||||||
_oauth_provider_status,
|
_oauth_provider_status,
|
||||||
create_model_configuration,
|
create_model_configuration,
|
||||||
|
provider_models_payload,
|
||||||
settings_payload,
|
settings_payload,
|
||||||
update_agent_settings,
|
update_agent_settings,
|
||||||
update_model_configuration,
|
update_model_configuration,
|
||||||
@ -336,6 +338,101 @@ def test_openai_codex_oauth_status_rejects_unavailable_token(
|
|||||||
assert status["account"] is None
|
assert status["account"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_models_payload_fetches_openai_compatible_models(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config = Config()
|
||||||
|
config.providers.deepseek.api_key = "sk-test"
|
||||||
|
save_config(config, config_path)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
|
def fake_get(url: str, **kwargs):
|
||||||
|
assert url == "https://api.deepseek.com/models"
|
||||||
|
assert kwargs["headers"]["Authorization"] == "Bearer sk-test"
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"data": [
|
||||||
|
{"id": "deepseek-chat", "owned_by": "deepseek"},
|
||||||
|
{"id": "deepseek-reasoner", "context_window": 65536},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
request=httpx.Request("GET", url),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.webui.settings_api.httpx.get", fake_get)
|
||||||
|
|
||||||
|
payload = provider_models_payload({"provider": ["deepseek"]})
|
||||||
|
|
||||||
|
assert payload["status"] == "available"
|
||||||
|
assert payload["catalog_kind"] == "official"
|
||||||
|
assert payload["model_count"] == 2
|
||||||
|
assert payload["models"][0]["id"] == "deepseek-chat"
|
||||||
|
assert payload["models"][1]["context_window"] == 65536
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("api_base", "expected_url"),
|
||||||
|
[
|
||||||
|
("https://api.minimaxi.com/anthropic", "https://api.minimaxi.com/anthropic/v1/models"),
|
||||||
|
("https://api.minimaxi.com/anthropic/v1", "https://api.minimaxi.com/anthropic/v1/models"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_provider_models_payload_fetches_minimax_anthropic_models(
|
||||||
|
api_base: str,
|
||||||
|
expected_url: str,
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config = Config()
|
||||||
|
config.providers.minimax_anthropic.api_key = "sk-test"
|
||||||
|
config.providers.minimax_anthropic.api_base = api_base
|
||||||
|
save_config(config, config_path)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
|
def fake_get(url: str, **kwargs):
|
||||||
|
assert url == expected_url
|
||||||
|
assert kwargs["headers"]["X-Api-Key"] == "sk-test"
|
||||||
|
assert "Authorization" not in kwargs["headers"]
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"data": [{"id": "MiniMax-M2.7-highspeed"}]},
|
||||||
|
request=httpx.Request("GET", url),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.webui.settings_api.httpx.get", fake_get)
|
||||||
|
|
||||||
|
payload = provider_models_payload({"provider": ["minimax_anthropic"]})
|
||||||
|
|
||||||
|
assert payload["status"] == "available"
|
||||||
|
assert payload["catalog_kind"] == "official"
|
||||||
|
assert payload["models"] == [
|
||||||
|
{
|
||||||
|
"id": "MiniMax-M2.7-highspeed",
|
||||||
|
"label": None,
|
||||||
|
"owned_by": None,
|
||||||
|
"context_window": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_models_payload_requires_gateway_key(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
save_config(Config(), config_path)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
|
payload = provider_models_payload({"provider": ["openrouter"]})
|
||||||
|
|
||||||
|
assert payload["status"] == "not_configured"
|
||||||
|
assert payload["models"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_create_model_configuration_accepts_configured_oauth_provider(
|
def test_create_model_configuration_accepts_configured_oauth_provider(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|||||||
@ -450,6 +450,7 @@ function Shell({
|
|||||||
const [workspaceOverrides, setWorkspaceOverrides] =
|
const [workspaceOverrides, setWorkspaceOverrides] =
|
||||||
useState<Record<string, WorkspaceScopePayload>>({});
|
useState<Record<string, WorkspaceScopePayload>>({});
|
||||||
const runningChatIdsRef = useRef<Set<string>>(new Set());
|
const runningChatIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
const activeChatIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@ -487,6 +488,16 @@ function Shell({
|
|||||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||||
const completedChatIdList = useMemo(() => Array.from(completedChatIds), [completedChatIds]);
|
const completedChatIdList = useMemo(() => Array.from(completedChatIds), [completedChatIds]);
|
||||||
const activeChatId = activeSession?.chatId ?? null;
|
const activeChatId = activeSession?.chatId ?? null;
|
||||||
|
useEffect(() => {
|
||||||
|
activeChatIdRef.current = activeChatId;
|
||||||
|
if (!activeChatId) return;
|
||||||
|
setCompletedChatIds((current) => {
|
||||||
|
if (!current.has(activeChatId)) return current;
|
||||||
|
const next = new Set(current);
|
||||||
|
next.delete(activeChatId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [activeChatId]);
|
||||||
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
|
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
|
||||||
if (activeChatId && workspaceOverrides[activeChatId]) {
|
if (activeChatId && workspaceOverrides[activeChatId]) {
|
||||||
return workspaceOverrides[activeChatId];
|
return workspaceOverrides[activeChatId];
|
||||||
@ -929,7 +940,11 @@ function Shell({
|
|||||||
setRunningChatIds(nextRunning);
|
setRunningChatIds(nextRunning);
|
||||||
setCompletedChatIds((current) => {
|
setCompletedChatIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
|
if (activeChatIdRef.current === chatId) {
|
||||||
|
next.delete(chatId);
|
||||||
|
} else {
|
||||||
next.add(chatId);
|
next.add(chatId);
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
173
webui/src/components/AttachmentTile.tsx
Normal file
173
webui/src/components/AttachmentTile.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { FileIcon, ImageIcon, PlaySquare } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { UIMediaAttachment } from "@/lib/types";
|
||||||
|
|
||||||
|
interface AttachmentTileProps {
|
||||||
|
attachment: UIMediaAttachment;
|
||||||
|
className?: string;
|
||||||
|
inline?: boolean;
|
||||||
|
variant?: "default" | "compact";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentTile({ attachment, className, inline = false, variant = "default" }: AttachmentTileProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const hasUrl = typeof attachment.url === "string" && attachment.url.length > 0;
|
||||||
|
const label = attachmentLabel(attachment, t);
|
||||||
|
|
||||||
|
if (attachment.kind === "image" && hasUrl && !failed) {
|
||||||
|
return (
|
||||||
|
<AttachmentFrame
|
||||||
|
attachment={attachment}
|
||||||
|
className={className}
|
||||||
|
inline={inline}
|
||||||
|
variant={variant}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={attachment.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
className="block bg-muted/20"
|
||||||
|
aria-label={attachment.name ? `Open ${attachment.name}` : t("lightbox.open", { defaultValue: "Open image" })}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={attachment.url}
|
||||||
|
alt={attachment.name ?? ""}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
draggable={false}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
className={cn(
|
||||||
|
"block h-auto max-w-full bg-background object-contain",
|
||||||
|
variant === "compact" ? "max-h-40" : "max-h-[34rem]",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</AttachmentFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachment.kind === "video" && hasUrl) {
|
||||||
|
return (
|
||||||
|
<AttachmentFrame
|
||||||
|
attachment={attachment}
|
||||||
|
className={className}
|
||||||
|
inline={inline}
|
||||||
|
variant={variant}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
src={attachment.url}
|
||||||
|
controls
|
||||||
|
preload="auto"
|
||||||
|
className={cn(
|
||||||
|
"block w-full bg-black",
|
||||||
|
variant === "compact" ? "max-h-40" : "max-h-[26rem]",
|
||||||
|
)}
|
||||||
|
aria-label={attachment.name ? `${t("message.videoAttachment", { defaultValue: "Video attachment" })}: ${attachment.name}` : t("message.videoAttachment", { defaultValue: "Video attachment" })}
|
||||||
|
/>
|
||||||
|
</AttachmentFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = attachment.kind === "video"
|
||||||
|
? PlaySquare
|
||||||
|
: attachment.kind === "image"
|
||||||
|
? ImageIcon
|
||||||
|
: FileIcon;
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
<Icon className="h-4 w-4 flex-none" aria-hidden />
|
||||||
|
<span className="min-w-0 truncate">{attachment.name ?? label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasUrl && !failed) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={attachment.url}
|
||||||
|
download={attachment.name ?? label}
|
||||||
|
title={attachment.name ?? undefined}
|
||||||
|
aria-label={label}
|
||||||
|
className={cn(
|
||||||
|
"flex max-w-[18rem] items-center gap-2 rounded-[14px]",
|
||||||
|
"border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground",
|
||||||
|
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||||
|
variant === "compact" && "max-w-[14rem] rounded-xl px-2.5 py-1.5 text-[11.5px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex max-w-[18rem] items-center gap-2 rounded-[14px]",
|
||||||
|
"border border-border/60 bg-muted/35 px-3 py-2 text-xs text-muted-foreground",
|
||||||
|
variant === "compact" && "max-w-[14rem] rounded-xl px-2.5 py-1.5 text-[11.5px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
title={attachment.name ?? undefined}
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
<span className="sr-only">
|
||||||
|
{t("message.attachmentUnavailable", { defaultValue: "Attachment unavailable" })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentFrame({
|
||||||
|
attachment,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
inline = false,
|
||||||
|
variant = "default",
|
||||||
|
}: {
|
||||||
|
attachment: UIMediaAttachment;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
inline?: boolean;
|
||||||
|
variant?: "default" | "compact";
|
||||||
|
}) {
|
||||||
|
const frameClassName = cn(
|
||||||
|
"not-prose my-3 block w-fit max-w-full overflow-hidden rounded-[14px]",
|
||||||
|
"border border-border/60 bg-muted/40",
|
||||||
|
attachment.kind === "image" && "bg-background/85",
|
||||||
|
attachment.kind === "video" ? "w-[min(100%,32rem)]" : "",
|
||||||
|
variant === "compact" && "my-1 rounded-xl shadow-none",
|
||||||
|
variant === "compact" && attachment.kind === "video" && "w-[min(100%,20rem)]",
|
||||||
|
className,
|
||||||
|
);
|
||||||
|
const bodyClassName = "block max-w-full";
|
||||||
|
const body = inline ? (
|
||||||
|
<span className={bodyClassName}>{children}</span>
|
||||||
|
) : (
|
||||||
|
<div className={bodyClassName}>{children}</div>
|
||||||
|
);
|
||||||
|
return inline ? (
|
||||||
|
<span className={frameClassName}>
|
||||||
|
{body}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<figure className={frameClassName}>
|
||||||
|
{body}
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachmentLabel(attachment: UIMediaAttachment, t: ReturnType<typeof useTranslation>["t"]): string {
|
||||||
|
if (attachment.kind === "video") {
|
||||||
|
return t("message.videoAttachment", { defaultValue: "Video attachment" });
|
||||||
|
}
|
||||||
|
if (attachment.kind === "image") {
|
||||||
|
return t("message.imageAttachment", { defaultValue: "Image attachment" });
|
||||||
|
}
|
||||||
|
return t("message.fileAttachment", { defaultValue: "File attachment" });
|
||||||
|
}
|
||||||
@ -540,7 +540,7 @@ function SessionActivityIndicator({
|
|||||||
title={label}
|
title={label}
|
||||||
className="grid h-4 w-4 shrink-0 place-items-center"
|
className="grid h-4 w-4 shrink-0 place-items-center"
|
||||||
>
|
>
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-500 dark:bg-blue-400" />
|
<span className="h-2 w-2 rounded-full bg-blue-500 dark:bg-blue-400" />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,9 +54,10 @@ const LazyHighlightedCode = lazy(async () => {
|
|||||||
function PlainCodeFallback({ code }: { code: string }) {
|
function PlainCodeFallback({ code }: { code: string }) {
|
||||||
return (
|
return (
|
||||||
<pre
|
<pre
|
||||||
className="m-0 overflow-x-auto whitespace-pre-wrap p-4 font-mono text-sm leading-[1.6]"
|
className="m-0 overflow-x-auto whitespace-pre-wrap bg-background p-4 font-mono text-sm leading-[1.6] text-foreground/90"
|
||||||
|
data-testid="plain-code-fallback"
|
||||||
>
|
>
|
||||||
<code>{code}</code>
|
<code className="text-inherit">{code}</code>
|
||||||
</pre>
|
</pre>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,7 @@ const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({
|
|||||||
const SHORT_STREAM_COMMIT_MS = 80;
|
const SHORT_STREAM_COMMIT_MS = 80;
|
||||||
const MEDIUM_STREAM_COMMIT_MS = 140;
|
const MEDIUM_STREAM_COMMIT_MS = 140;
|
||||||
const LONG_STREAM_COMMIT_MS = 220;
|
const LONG_STREAM_COMMIT_MS = 220;
|
||||||
|
const STREAMING_HIGHLIGHT_CHAR_LIMIT = 16_000;
|
||||||
|
|
||||||
export function preloadMarkdownText(): void {
|
export function preloadMarkdownText(): void {
|
||||||
void loadMarkdownRenderer();
|
void loadMarkdownRenderer();
|
||||||
@ -56,7 +57,9 @@ export function MarkdownText({
|
|||||||
streaming = false,
|
streaming = false,
|
||||||
}: MarkdownTextProps) {
|
}: MarkdownTextProps) {
|
||||||
const renderedSource = useStreamingMarkdownSource(children, streaming);
|
const renderedSource = useStreamingMarkdownSource(children, streaming);
|
||||||
const highlightCode = !streaming && renderedSource === children;
|
const highlightCode = streaming
|
||||||
|
? renderedSource.length <= STREAMING_HIGHLIGHT_CHAR_LIMIT
|
||||||
|
: renderedSource === children;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (streaming) preloadMarkdownText();
|
if (streaming) preloadMarkdownText();
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
import { Children, isValidElement, useMemo } from "react";
|
import { Children, isValidElement, useMemo, type ReactNode } from "react";
|
||||||
import type { Components } from "react-markdown";
|
import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import rehypeKatex from "rehype-katex";
|
import rehypeKatex from "rehype-katex";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
import remarkBreaks from "remark-breaks";
|
import remarkBreaks from "remark-breaks";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import remarkMath from "remark-math";
|
import remarkMath from "remark-math";
|
||||||
|
|
||||||
|
import { AttachmentTile } from "@/components/AttachmentTile";
|
||||||
import { CodeBlock } from "@/components/CodeBlock";
|
import { CodeBlock } from "@/components/CodeBlock";
|
||||||
import { FileReferenceChip, isLikelyFilePath } from "@/components/FileReferenceChip";
|
import { FileReferenceChip, isLikelyFilePath } from "@/components/FileReferenceChip";
|
||||||
import { inferMediaKind } from "@/lib/media";
|
import { inferMediaKind } from "@/lib/media";
|
||||||
@ -19,8 +21,181 @@ interface MarkdownTextRendererProps {
|
|||||||
highlightCode?: boolean;
|
highlightCode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const remarkPlugins = [remarkBreaks, remarkGfm, remarkMath];
|
type MarkdownAstNode = {
|
||||||
const rehypePlugins = [rehypeKatex];
|
type: string;
|
||||||
|
value?: string;
|
||||||
|
children?: MarkdownAstNode[];
|
||||||
|
data?: {
|
||||||
|
hName?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const SAFE_INLINE_HTML_TAGS = new Set(["mark", "sub", "sup"]);
|
||||||
|
|
||||||
|
function extensionOf(value: string): string {
|
||||||
|
const clean = value.split(/[?#]/, 1)[0]?.trim() ?? "";
|
||||||
|
const slash = clean.lastIndexOf("/");
|
||||||
|
const name = slash >= 0 ? clean.slice(slash + 1) : clean;
|
||||||
|
const dot = name.lastIndexOf(".");
|
||||||
|
return dot > 0 ? name.slice(dot).toLowerCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownAttachmentKind(source: string, label: string): "image" | "video" | "file" {
|
||||||
|
const inferredKind = inferMediaKind({ url: source, name: label });
|
||||||
|
if (inferredKind !== "file") return inferredKind;
|
||||||
|
return extensionOf(label) || extensionOf(source) ? "file" : "image";
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeHtmlNode(tagName: string, children: MarkdownAstNode[]): MarkdownAstNode {
|
||||||
|
return {
|
||||||
|
type: `nanobotSafeHtml${tagName}`,
|
||||||
|
data: { hName: tagName },
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeText(value: string): MarkdownAstNode {
|
||||||
|
return { type: "text", value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlTag(node: MarkdownAstNode): { tag: string; closing: boolean } | null {
|
||||||
|
if (node.type !== "html" || typeof node.value !== "string") return null;
|
||||||
|
const match = /^<\s*(\/?)\s*(mark|sub|sup)\s*>$/i.exec(node.value.trim());
|
||||||
|
if (!match) return null;
|
||||||
|
return { tag: match[2].toLowerCase(), closing: match[1] === "/" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSafeInlineHtml(children: MarkdownAstNode[]): MarkdownAstNode[] {
|
||||||
|
const next: MarkdownAstNode[] = [];
|
||||||
|
for (let index = 0; index < children.length; index += 1) {
|
||||||
|
const node = children[index];
|
||||||
|
if (node.children) {
|
||||||
|
node.children = normalizeSafeInlineHtml(node.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = htmlTag(node);
|
||||||
|
if (!tag || tag.closing || !SAFE_INLINE_HTML_TAGS.has(tag.tag)) {
|
||||||
|
next.push(node);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let closeIndex = -1;
|
||||||
|
for (let cursor = index + 1; cursor < children.length; cursor += 1) {
|
||||||
|
const closeTag = htmlTag(children[cursor]);
|
||||||
|
if (closeTag?.closing && closeTag.tag === tag.tag) {
|
||||||
|
closeIndex = cursor;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closeIndex === -1) {
|
||||||
|
next.push(node);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
next.push(
|
||||||
|
safeHtmlNode(
|
||||||
|
tag.tag,
|
||||||
|
normalizeSafeInlineHtml(children.slice(index + 1, closeIndex)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
index = closeIndex;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailsOpen(node: MarkdownAstNode): { summary: string } | null {
|
||||||
|
if (node.type !== "html" || typeof node.value !== "string") return null;
|
||||||
|
const value = node.value.trim();
|
||||||
|
const match = /^<\s*details\s*>\s*<\s*summary\s*>([\s\S]*?)<\s*\/\s*summary\s*>$/i.exec(value);
|
||||||
|
if (match) return { summary: match[1].trim() };
|
||||||
|
if (/^<\s*details\s*>$/i.test(value)) return { summary: "Details" };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDetailsClose(node: MarkdownAstNode): boolean {
|
||||||
|
return node.type === "html"
|
||||||
|
&& typeof node.value === "string"
|
||||||
|
&& /^<\s*\/\s*details\s*>$/i.test(node.value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSafeDetails(children: MarkdownAstNode[]): MarkdownAstNode[] {
|
||||||
|
const next: MarkdownAstNode[] = [];
|
||||||
|
for (let index = 0; index < children.length; index += 1) {
|
||||||
|
const node = children[index];
|
||||||
|
const open = detailsOpen(node);
|
||||||
|
if (!open) {
|
||||||
|
next.push(node);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeIndex = children.findIndex(
|
||||||
|
(candidate, candidateIndex) => candidateIndex > index && isDetailsClose(candidate),
|
||||||
|
);
|
||||||
|
if (closeIndex === -1) {
|
||||||
|
next.push(node);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = normalizeSafeInlineHtml(
|
||||||
|
normalizeSafeDetails(children.slice(index + 1, closeIndex)),
|
||||||
|
);
|
||||||
|
next.push({
|
||||||
|
type: "nanobotSafeHtmlDetails",
|
||||||
|
data: { hName: "details" },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "nanobotSafeHtmlSummary",
|
||||||
|
data: { hName: "summary" },
|
||||||
|
children: [safeText(open.summary)],
|
||||||
|
},
|
||||||
|
...body,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
index = closeIndex;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remarkSafeHtmlSubset() {
|
||||||
|
return (tree: MarkdownAstNode) => {
|
||||||
|
if (tree.children) {
|
||||||
|
tree.children = normalizeSafeInlineHtml(normalizeSafeDetails(tree.children));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const remarkPlugins: NonNullable<ReactMarkdownOptions["remarkPlugins"]> = [
|
||||||
|
remarkBreaks,
|
||||||
|
remarkGfm,
|
||||||
|
[remarkMath, { singleDollarTextMath: false }],
|
||||||
|
remarkSafeHtmlSubset,
|
||||||
|
];
|
||||||
|
const rehypePlugins: NonNullable<ReactMarkdownOptions["rehypePlugins"]> = [rehypeKatex];
|
||||||
|
|
||||||
|
function nodeText(value: ReactNode): string {
|
||||||
|
return Children.toArray(value)
|
||||||
|
.map((child) => (typeof child === "string" || typeof child === "number" ? String(child) : ""))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRenderedCodeBlock(value: ReactNode): boolean {
|
||||||
|
if (!isValidElement(value)) return false;
|
||||||
|
const props = value.props as { code?: unknown };
|
||||||
|
return value.type === CodeBlock || typeof props.code === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function codeFenceFromPreChild(value: ReactNode): { code: string; language?: string } | null {
|
||||||
|
if (!isValidElement(value)) return null;
|
||||||
|
const props = value.props as { className?: unknown; children?: ReactNode };
|
||||||
|
if (!("children" in props)) return null;
|
||||||
|
const className = typeof props.className === "string" ? props.className : "";
|
||||||
|
const language = /language-([^\s]+)/.exec(className)?.[1];
|
||||||
|
return {
|
||||||
|
code: nodeText(props.children).replace(/\n$/, ""),
|
||||||
|
language,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Heavy markdown stack (GFM, math, KaTeX, syntax highlighting) kept in a
|
* Heavy markdown stack (GFM, math, KaTeX, syntax highlighting) kept in a
|
||||||
@ -82,9 +257,20 @@ export default function MarkdownTextRenderer({
|
|||||||
const kids = Children.toArray(markdownChildren);
|
const kids = Children.toArray(markdownChildren);
|
||||||
const lone = kids.length === 1 ? kids[0] : null;
|
const lone = kids.length === 1 ? kids[0] : null;
|
||||||
/** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``<pre><div>``. */
|
/** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``<pre><div>``. */
|
||||||
if (lone != null && isValidElement(lone) && lone.type === CodeBlock) {
|
if (isRenderedCodeBlock(lone)) {
|
||||||
return <>{markdownChildren}</>;
|
return <>{markdownChildren}</>;
|
||||||
}
|
}
|
||||||
|
const fence = codeFenceFromPreChild(lone);
|
||||||
|
if (fence) {
|
||||||
|
return (
|
||||||
|
<CodeBlock
|
||||||
|
language={fence.language}
|
||||||
|
code={fence.code}
|
||||||
|
className="my-3"
|
||||||
|
highlight={highlightCode}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<pre
|
<pre
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -110,67 +296,66 @@ export default function MarkdownTextRenderer({
|
|||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
input({ type, checked }) {
|
||||||
|
if (type !== "checkbox") return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-testid="markdown-task-checkbox"
|
||||||
|
className={cn(
|
||||||
|
"mr-2 inline-grid h-4 w-4 translate-y-[2px] place-items-center rounded-[4px]",
|
||||||
|
"border border-border/70 bg-muted/55 text-background",
|
||||||
|
checked && "border-foreground/55 bg-foreground/65",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{checked ? <Check className="h-3 w-3 stroke-[3]" /> : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
mark({ children: markdownChildren }) {
|
||||||
|
return (
|
||||||
|
<mark className="rounded-[5px] bg-yellow-200/75 px-1 py-0.5 text-inherit dark:bg-yellow-300/25">
|
||||||
|
{markdownChildren}
|
||||||
|
</mark>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sub({ children: markdownChildren }) {
|
||||||
|
return <sub className="text-[0.72em] leading-none">{markdownChildren}</sub>;
|
||||||
|
},
|
||||||
|
sup({ children: markdownChildren }) {
|
||||||
|
return <sup className="text-[0.72em] leading-none">{markdownChildren}</sup>;
|
||||||
|
},
|
||||||
|
details({ children: markdownChildren }) {
|
||||||
|
return (
|
||||||
|
<details className="my-3 rounded-xl border border-border/65 bg-muted/25 px-4 py-3 open:pb-4">
|
||||||
|
{markdownChildren}
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
summary({ children: markdownChildren }) {
|
||||||
|
return (
|
||||||
|
<summary className="cursor-pointer select-none text-sm font-medium text-foreground/88 marker:text-muted-foreground">
|
||||||
|
{markdownChildren}
|
||||||
|
</summary>
|
||||||
|
);
|
||||||
|
},
|
||||||
img({ src, alt, node: _node, className: imgClassName, ...props }) {
|
img({ src, alt, node: _node, className: imgClassName, ...props }) {
|
||||||
void _node;
|
void _node;
|
||||||
|
void imgClassName;
|
||||||
|
void props;
|
||||||
const source = typeof src === "string" ? src : "";
|
const source = typeof src === "string" ? src : "";
|
||||||
if (!source) return null;
|
if (!source) return null;
|
||||||
const label = typeof alt === "string" ? alt : "";
|
const label = typeof alt === "string" ? alt : "";
|
||||||
if (inferMediaKind({ url: source, name: label }) === "video") {
|
const kind = markdownAttachmentKind(source, label);
|
||||||
return (
|
return (
|
||||||
<span
|
<AttachmentTile
|
||||||
className={cn(
|
attachment={{
|
||||||
"not-prose my-3 block w-fit max-w-full overflow-hidden rounded-[14px]",
|
kind,
|
||||||
"border border-border/70 bg-background shadow-sm",
|
url: source,
|
||||||
)}
|
name: label,
|
||||||
>
|
}}
|
||||||
<video
|
inline
|
||||||
src={source}
|
|
||||||
controls
|
|
||||||
preload="metadata"
|
|
||||||
className="block max-h-[26rem] max-w-full bg-black"
|
|
||||||
aria-label={label ? `Video attachment: ${label}` : "Video attachment"}
|
|
||||||
/>
|
/>
|
||||||
{label ? (
|
|
||||||
<span className="block max-w-full truncate px-3 py-2 text-xs text-muted-foreground">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"not-prose my-3 block w-fit max-w-full overflow-hidden rounded-[14px]",
|
|
||||||
"border border-border/70 bg-background shadow-sm",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={source}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
className="block bg-muted/20"
|
|
||||||
aria-label={label ? `Open ${label}` : "Open image"}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={source}
|
|
||||||
alt={label}
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
draggable={false}
|
|
||||||
className={cn(
|
|
||||||
"block h-auto max-h-[34rem] max-w-full bg-background object-contain",
|
|
||||||
imgClassName,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
{label ? (
|
|
||||||
<span className="block max-w-full truncate px-3 py-2 text-xs text-muted-foreground">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -6,14 +6,16 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Sparkles, Wrench } from "lucide-react";
|
import { Check, ChevronRight, Copy, ImageIcon, Sparkles, Wrench } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { AttachmentTile } from "@/components/AttachmentTile";
|
||||||
import { CliAppMentionText } from "@/components/CliAppMentionText";
|
import { CliAppMentionText } from "@/components/CliAppMentionText";
|
||||||
import { ImageLightbox } from "@/components/ImageLightbox";
|
import { ImageLightbox } from "@/components/ImageLightbox";
|
||||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { formatTurnLatency } from "@/lib/format";
|
import { formatTurnLatency } from "@/lib/format";
|
||||||
|
import { toMediaAttachment } from "@/lib/media";
|
||||||
import type {
|
import type {
|
||||||
CliAppInfo,
|
CliAppInfo,
|
||||||
McpPresetInfo,
|
McpPresetInfo,
|
||||||
@ -258,10 +260,11 @@ function MessageMedia({
|
|||||||
const images: UIImage[] = [];
|
const images: UIImage[] = [];
|
||||||
const nonImages: UIMediaAttachment[] = [];
|
const nonImages: UIMediaAttachment[] = [];
|
||||||
for (const item of media) {
|
for (const item of media) {
|
||||||
if (item.kind === "image") {
|
const normalized = toMediaAttachment(item);
|
||||||
images.push({ url: item.url, name: item.name });
|
if (normalized.kind === "image") {
|
||||||
|
images.push({ url: normalized.url, name: normalized.name });
|
||||||
} else {
|
} else {
|
||||||
nonImages.push(item);
|
nonImages.push(normalized);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -276,73 +279,12 @@ function MessageMedia({
|
|||||||
<UserImages images={images} align={align} size={align === "left" ? "large" : "compact"} />
|
<UserImages images={images} align={align} size={align === "left" ? "large" : "compact"} />
|
||||||
) : null}
|
) : null}
|
||||||
{nonImages.map((item, i) => (
|
{nonImages.map((item, i) => (
|
||||||
<MediaCell key={`${item.url ?? item.name ?? item.kind}-${i}`} media={item} />
|
<AttachmentTile key={`${item.url ?? item.name ?? item.kind}-${i}`} attachment={item} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MediaCell({ media }: { media: UIMediaAttachment }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const hasUrl = typeof media.url === "string" && media.url.length > 0;
|
|
||||||
|
|
||||||
if (media.kind === "video" && hasUrl) {
|
|
||||||
return (
|
|
||||||
<figure className="max-w-[min(100%,32rem)] overflow-hidden rounded-[14px] border border-border/60 bg-muted/40">
|
|
||||||
<video
|
|
||||||
src={media.url}
|
|
||||||
controls
|
|
||||||
preload="metadata"
|
|
||||||
className="block max-h-[26rem] w-full bg-black"
|
|
||||||
aria-label={media.name ? `${t("message.videoAttachment", { defaultValue: "Video attachment" })}: ${media.name}` : t("message.videoAttachment", { defaultValue: "Video attachment" })}
|
|
||||||
/>
|
|
||||||
{media.name ? (
|
|
||||||
<figcaption className="truncate px-3 py-1.5 text-[11.5px] text-muted-foreground">
|
|
||||||
{media.name}
|
|
||||||
</figcaption>
|
|
||||||
) : null}
|
|
||||||
</figure>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const label =
|
|
||||||
media.kind === "video"
|
|
||||||
? t("message.videoAttachment", { defaultValue: "Video attachment" })
|
|
||||||
: t("message.fileAttachment", { defaultValue: "File attachment" });
|
|
||||||
const Icon = media.kind === "video" ? PlaySquare : FileIcon;
|
|
||||||
|
|
||||||
const inner = (
|
|
||||||
<>
|
|
||||||
<Icon className="h-4 w-4 flex-none" aria-hidden />
|
|
||||||
<span className="truncate">{media.name ?? label}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasUrl) {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={media.url}
|
|
||||||
download={media.name ?? label}
|
|
||||||
title={media.name ?? undefined}
|
|
||||||
aria-label={label}
|
|
||||||
className="flex max-w-[18rem] items-center gap-2 rounded-[14px] border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground hover:underline"
|
|
||||||
>
|
|
||||||
{inner}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex max-w-[18rem] items-center gap-2 rounded-[14px] border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground"
|
|
||||||
title={media.name ?? undefined}
|
|
||||||
aria-label={label}
|
|
||||||
>
|
|
||||||
{inner}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Right-aligned preview row for images attached to a user turn.
|
* Right-aligned preview row for images attached to a user turn.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -57,6 +57,7 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
@ -74,6 +75,7 @@ import {
|
|||||||
fetchSettings,
|
fetchSettings,
|
||||||
fetchCliApps,
|
fetchCliApps,
|
||||||
fetchMcpPresets,
|
fetchMcpPresets,
|
||||||
|
fetchProviderModels,
|
||||||
importMcpConfig,
|
importMcpConfig,
|
||||||
loginProviderOAuth,
|
loginProviderOAuth,
|
||||||
logoutProviderOAuth,
|
logoutProviderOAuth,
|
||||||
@ -105,6 +107,7 @@ import type {
|
|||||||
McpPresetInfo,
|
McpPresetInfo,
|
||||||
McpPresetsPayload,
|
McpPresetsPayload,
|
||||||
NetworkSafetySettingsUpdate,
|
NetworkSafetySettingsUpdate,
|
||||||
|
ProviderModelsPayload,
|
||||||
SettingsPayload,
|
SettingsPayload,
|
||||||
WebSearchSettingsUpdate,
|
WebSearchSettingsUpdate,
|
||||||
WebuiDefaultAccessMode,
|
WebuiDefaultAccessMode,
|
||||||
@ -166,6 +169,23 @@ type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
|
|||||||
|
|
||||||
const NANOBOT_ICON_SRC = "/brand/nanobot_icon.png";
|
const NANOBOT_ICON_SRC = "/brand/nanobot_icon.png";
|
||||||
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 262_144] as const;
|
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 262_144] as const;
|
||||||
|
const DEFERRED_MODEL_LIST_PROVIDERS = new Set([
|
||||||
|
"aihubmix",
|
||||||
|
"atomic_chat",
|
||||||
|
"byteplus",
|
||||||
|
"byteplus_coding_plan",
|
||||||
|
"huggingface",
|
||||||
|
"lm_studio",
|
||||||
|
"novita",
|
||||||
|
"ollama",
|
||||||
|
"openrouter",
|
||||||
|
"ovms",
|
||||||
|
"siliconflow",
|
||||||
|
"vllm",
|
||||||
|
"volcengine",
|
||||||
|
"volcengine_coding_plan",
|
||||||
|
]);
|
||||||
|
const DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH = 2;
|
||||||
|
|
||||||
const FALLBACK_TIMEZONES = [
|
const FALLBACK_TIMEZONES = [
|
||||||
"UTC",
|
"UTC",
|
||||||
@ -1124,6 +1144,7 @@ export function SettingsView({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<ModelsSettings
|
<ModelsSettings
|
||||||
|
token={token}
|
||||||
form={form}
|
form={form}
|
||||||
setForm={setForm}
|
setForm={setForm}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
@ -1754,7 +1775,7 @@ function NewModelConfigurationDialog({
|
|||||||
<div className="space-y-4 px-5 py-5">
|
<div className="space-y-4 px-5 py-5">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
<span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||||
{tx("settings.models.configurationName", "Name")}
|
{tx("settings.models.configurationName", "Configuration name")}
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
@ -1827,6 +1848,7 @@ function NewModelConfigurationDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ModelsSettings({
|
function ModelsSettings({
|
||||||
|
token,
|
||||||
form,
|
form,
|
||||||
setForm,
|
setForm,
|
||||||
settings,
|
settings,
|
||||||
@ -1838,6 +1860,7 @@ function ModelsSettings({
|
|||||||
onSave,
|
onSave,
|
||||||
onCreateConfiguration,
|
onCreateConfiguration,
|
||||||
}: {
|
}: {
|
||||||
|
token: string;
|
||||||
form: AgentSettingsDraft;
|
form: AgentSettingsDraft;
|
||||||
setForm: Dispatch<SetStateAction<AgentSettingsDraft>>;
|
setForm: Dispatch<SetStateAction<AgentSettingsDraft>>;
|
||||||
settings: SettingsPayload;
|
settings: SettingsPayload;
|
||||||
@ -1876,8 +1899,8 @@ function ModelsSettings({
|
|||||||
<section>
|
<section>
|
||||||
<SettingsGroup>
|
<SettingsGroup>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={tx("settings.rows.currentModel", "Current model")}
|
title={tx("settings.rows.currentModel", "Current configuration")}
|
||||||
description={tx("settings.help.currentModel", "Choose the model nanobot uses for new replies.")}
|
description={tx("settings.help.currentModel", "Used for new replies.")}
|
||||||
>
|
>
|
||||||
<ModelPresetPicker
|
<ModelPresetPicker
|
||||||
presets={settings.model_presets}
|
presets={settings.model_presets}
|
||||||
@ -1906,7 +1929,7 @@ function ModelsSettings({
|
|||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
{selectedPreset && !selectedPreset.is_default ? (
|
{selectedPreset && !selectedPreset.is_default ? (
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={tx("settings.models.configurationName", "Name")}
|
title={tx("settings.models.configurationName", "Configuration name")}
|
||||||
description={tx("settings.models.configurationNameHelp", "Rename this saved model configuration.")}
|
description={tx("settings.models.configurationNameHelp", "Rename this saved model configuration.")}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
@ -1927,7 +1950,13 @@ function ModelsSettings({
|
|||||||
value={providerValue}
|
value={providerValue}
|
||||||
emptyLabel={t("settings.byok.noConfiguredProviders")}
|
emptyLabel={t("settings.byok.noConfiguredProviders")}
|
||||||
showProviderLogos={showBrandLogos}
|
showProviderLogos={showBrandLogos}
|
||||||
onChange={(provider) => setForm((prev) => ({ ...prev, provider }))}
|
onChange={(provider) =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
provider,
|
||||||
|
model: provider === prev.provider ? prev.model : "",
|
||||||
|
}))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
{selectedProviderNeedsSignIn ? (
|
{selectedProviderNeedsSignIn ? (
|
||||||
@ -1958,10 +1987,13 @@ function ModelsSettings({
|
|||||||
title={t("settings.rows.model")}
|
title={t("settings.rows.model")}
|
||||||
description={t("settings.help.model")}
|
description={t("settings.help.model")}
|
||||||
>
|
>
|
||||||
<Input
|
<ModelIdPicker
|
||||||
|
token={token}
|
||||||
|
settings={settings}
|
||||||
|
provider={form.provider}
|
||||||
value={form.model}
|
value={form.model}
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, model: event.target.value }))}
|
showProviderLogos={showBrandLogos}
|
||||||
className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
|
onChange={(model) => setForm((prev) => ({ ...prev, model }))}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
@ -4190,7 +4222,10 @@ function TimezonePicker({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 max-h-[18rem] overflow-y-auto pr-0.5" data-testid="timezone-picker-list">
|
<div
|
||||||
|
className="mt-1 max-h-[18rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent"
|
||||||
|
data-testid="timezone-picker-list"
|
||||||
|
>
|
||||||
{filteredOptions.length ? (
|
{filteredOptions.length ? (
|
||||||
filteredOptions.map((option) => {
|
filteredOptions.map((option) => {
|
||||||
const selected = option.name === value;
|
const selected = option.name === value;
|
||||||
@ -4268,7 +4303,7 @@ function ProviderPicker({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="end"
|
align="end"
|
||||||
className="max-h-[18rem] w-[240px] overflow-y-auto"
|
className="max-h-[18rem] w-[240px] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
|
||||||
>
|
>
|
||||||
{providers.map((provider) => {
|
{providers.map((provider) => {
|
||||||
const selected = provider.name === value;
|
const selected = provider.name === value;
|
||||||
@ -4300,6 +4335,239 @@ function ProviderPicker({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ModelIdPicker({
|
||||||
|
token,
|
||||||
|
settings,
|
||||||
|
provider,
|
||||||
|
value,
|
||||||
|
showProviderLogos,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
token: string;
|
||||||
|
settings: SettingsPayload;
|
||||||
|
provider: string;
|
||||||
|
value: string;
|
||||||
|
showProviderLogos: boolean;
|
||||||
|
onChange: (model: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [payload, setPayload] = useState<ProviderModelsPayload | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const effectiveProvider =
|
||||||
|
provider === "auto" ? settings.agent.resolved_provider ?? provider : provider;
|
||||||
|
const canFetchModels = Boolean(effectiveProvider && effectiveProvider !== "auto");
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
const providerModels = payload?.models ?? [];
|
||||||
|
const visibleModels = providerModels
|
||||||
|
.filter((model) => {
|
||||||
|
if (!normalizedQuery) return true;
|
||||||
|
return [model.id, model.label ?? "", model.owned_by ?? ""]
|
||||||
|
.some((field) => field.toLowerCase().includes(normalizedQuery));
|
||||||
|
})
|
||||||
|
.slice(0, 80);
|
||||||
|
const isCatalog = payload?.catalog_kind === "catalog";
|
||||||
|
const defersModelList = DEFERRED_MODEL_LIST_PROVIDERS.has(effectiveProvider);
|
||||||
|
const hasDeferredSearchQuery =
|
||||||
|
normalizedQuery.length >= DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH;
|
||||||
|
const shouldFetchModels =
|
||||||
|
canFetchModels && (!defersModelList || hasDeferredSearchQuery);
|
||||||
|
const waitingForModelSearch =
|
||||||
|
open && canFetchModels && defersModelList && !hasDeferredSearchQuery;
|
||||||
|
const hasModelList = payload?.status === "available";
|
||||||
|
const showModels = Boolean(hasModelList && payload && (!isCatalog || normalizedQuery));
|
||||||
|
const customCandidate = query.trim();
|
||||||
|
const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
|
||||||
|
const providerModelCount = payload?.model_count ?? providerModels.length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setQuery("");
|
||||||
|
}, [open, effectiveProvider]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !shouldFetchModels) {
|
||||||
|
setPayload(null);
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setPayload(null);
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
fetchProviderModels(token, effectiveProvider)
|
||||||
|
.then((nextPayload) => {
|
||||||
|
if (!cancelled) setPayload(nextPayload);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setError((err as Error).message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [effectiveProvider, open, shouldFetchModels, token]);
|
||||||
|
|
||||||
|
const selectModel = (model: string) => {
|
||||||
|
onChange(model);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderModelRow = (
|
||||||
|
model: ProviderModelsPayload["models"][number],
|
||||||
|
options: { selected?: boolean } = {},
|
||||||
|
) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={model.id}
|
||||||
|
onSelect={() => selectModel(model.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2 py-1.5 text-[12px]",
|
||||||
|
"focus:bg-muted/85 focus:text-foreground",
|
||||||
|
options.selected && "bg-muted/80 text-foreground focus:bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<ProviderPickerIcon provider={effectiveProvider} showBrandLogos={showProviderLogos} />
|
||||||
|
<span className="min-w-0 truncate font-medium text-foreground">
|
||||||
|
{model.label ?? model.id}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
|
||||||
|
{model.context_window ? <span>{formatContextWindow(model.context_window)}</span> : null}
|
||||||
|
{options.selected ? <Check className="h-3.5 w-3.5 text-foreground" aria-hidden /> : null}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-[min(360px,70vw)] justify-between rounded-full border-input bg-background px-3 text-[12px] font-normal shadow-none",
|
||||||
|
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<ProviderPickerIcon provider={effectiveProvider} showBrandLogos={showProviderLogos} />
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 truncate font-medium",
|
||||||
|
value ? "text-foreground" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value || tx("settings.models.selectModel", "Select model")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
className="w-[360px] max-w-[calc(100vw-2rem)] p-1.5"
|
||||||
|
>
|
||||||
|
<div className="p-1 pb-1.5">
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
onKeyDown={(event) => event.stopPropagation()}
|
||||||
|
placeholder={tx("settings.models.searchModels", "Search or type model ID")}
|
||||||
|
className="h-8 rounded-full pl-8 pr-3 text-[12px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!canFetchModels ? (
|
||||||
|
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||||
|
{tx("settings.models.autoProviderCustomOnly", "Auto provider mode uses custom model IDs.")}
|
||||||
|
</div>
|
||||||
|
) : waitingForModelSearch ? (
|
||||||
|
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||||
|
{tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1.5 text-[11px] text-muted-foreground">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||||
|
{tx("settings.models.loadingModels", "Loading models...")}
|
||||||
|
</div>
|
||||||
|
) : error || payload?.status === "error" ? (
|
||||||
|
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||||
|
{payload?.message || error || tx("settings.models.loadFailed", "Model list unavailable.")}
|
||||||
|
</div>
|
||||||
|
) : payload?.status === "not_configured" ? (
|
||||||
|
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||||
|
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
|
||||||
|
</div>
|
||||||
|
) : payload?.status === "unsupported" || payload?.status === "missing_api_base" ? (
|
||||||
|
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||||
|
{payload.message || tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
|
||||||
|
</div>
|
||||||
|
) : isCatalog && !normalizedQuery ? (
|
||||||
|
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||||
|
{tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
|
||||||
|
{providerModelCount ? ` ${providerModelCount} ${tx("settings.models.modelsAvailable", "available")}.` : ""}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showModels && visibleModels.length ? (
|
||||||
|
<div className="max-h-[16rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent">
|
||||||
|
{visibleModels.map((model) =>
|
||||||
|
renderModelRow(model, { selected: model.id === value }),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : showModels ? (
|
||||||
|
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">
|
||||||
|
{tx("settings.models.noModelResults", "No matching models.")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{customCandidate && !exactQueryMatch && customCandidate !== value ? (
|
||||||
|
<>
|
||||||
|
{showModels ? <DropdownMenuSeparator /> : null}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => selectModel(customCandidate)}
|
||||||
|
className="flex cursor-default items-center gap-2 rounded-[12px] px-2 py-1.5 text-[12px] focus:bg-muted/85"
|
||||||
|
>
|
||||||
|
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted/80 text-muted-foreground">
|
||||||
|
<Pencil className="h-3 w-3" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
{tx("settings.models.useCustomModel", "Use")}{" "}
|
||||||
|
<span className="font-medium text-foreground">“{customCandidate}”</span>
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatContextWindow(tokens: number): string {
|
||||||
|
if (tokens >= 1_000_000) {
|
||||||
|
const value = tokens / 1_000_000;
|
||||||
|
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
if (tokens >= 1_000) {
|
||||||
|
const value = tokens / 1_000;
|
||||||
|
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
return String(tokens);
|
||||||
|
}
|
||||||
|
|
||||||
function ProviderPickerIcon({
|
function ProviderPickerIcon({
|
||||||
provider,
|
provider,
|
||||||
showBrandLogos,
|
showBrandLogos,
|
||||||
@ -4860,7 +5128,7 @@ function ModelPresetPicker({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="end"
|
align="end"
|
||||||
className="max-h-[20rem] w-[430px] max-w-[calc(100vw-2rem)] overflow-y-auto"
|
className="max-h-[20rem] w-[430px] max-w-[calc(100vw-2rem)] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
|
||||||
>
|
>
|
||||||
{presets.map((preset) => {
|
{presets.map((preset) => {
|
||||||
const selected = preset.name === value;
|
const selected = preset.name === value;
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Check,
|
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CircleDashed,
|
FileImage,
|
||||||
Layers,
|
Layers,
|
||||||
Search,
|
Search,
|
||||||
Server,
|
Server,
|
||||||
@ -16,8 +15,20 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { cliAppInitials, mcpPresetInitials } from "@/components/CliAppMentionText";
|
import { cliAppInitials, mcpPresetInitials } from "@/components/CliAppMentionText";
|
||||||
import { FileReferenceChip } from "@/components/FileReferenceChip";
|
import { FileReferenceChip } from "@/components/FileReferenceChip";
|
||||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
|
||||||
import { StreamingLabelSheen } from "@/components/MessageBubble";
|
import { StreamingLabelSheen } from "@/components/MessageBubble";
|
||||||
|
import { ActivityEvidencePreview } from "@/components/thread/activity/ActivityEvidencePreview";
|
||||||
|
import { ActivityGroup } from "@/components/thread/activity/ActivityGroup";
|
||||||
|
import { ActivityStep } from "@/components/thread/activity/ActivityStep";
|
||||||
|
import { DiffPair } from "@/components/thread/activity/DiffPair";
|
||||||
|
import { FileEditGroup, hasVisibleDiffStats, type FileEditSummary } from "@/components/thread/activity/FileEditRow";
|
||||||
|
import { ReasoningRow } from "@/components/thread/activity/ReasoningRow";
|
||||||
|
import {
|
||||||
|
activityEvidenceFromMessageMedia,
|
||||||
|
activityEvidenceFromToolEvent,
|
||||||
|
isAgentActivityMember,
|
||||||
|
isReasoningOnlyAssistant,
|
||||||
|
type ActivityEvidence,
|
||||||
|
} from "@/lib/activity-timeline";
|
||||||
import { faviconUrls, logoFallbackUrls } from "@/lib/provider-brand";
|
import { faviconUrls, logoFallbackUrls } from "@/lib/provider-brand";
|
||||||
import { formatToolCallTrace } from "@/lib/tool-traces";
|
import { formatToolCallTrace } from "@/lib/tool-traces";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -27,15 +38,7 @@ import type { CliAppInfo, McpPresetInfo, ToolProgressEvent, UIFileEdit, UIMessag
|
|||||||
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
|
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
|
||||||
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
|
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
|
||||||
|
|
||||||
export function isReasoningOnlyAssistant(m: UIMessage): boolean {
|
export { isAgentActivityMember, isReasoningOnlyAssistant };
|
||||||
if (m.role !== "assistant" || m.kind === "trace") return false;
|
|
||||||
if (m.content.trim().length > 0) return false;
|
|
||||||
return !!(m.reasoning?.length || m.reasoningStreaming || m.isStreaming);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAgentActivityMember(m: UIMessage): boolean {
|
|
||||||
return isReasoningOnlyAssistant(m) || m.kind === "trace";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActivityCounts {
|
interface ActivityCounts {
|
||||||
reasoningSteps: number;
|
reasoningSteps: number;
|
||||||
@ -58,20 +61,6 @@ interface ActivityCounts {
|
|||||||
primaryMcpStatus?: McpRunStatus;
|
primaryMcpStatus?: McpRunStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileEditSummary {
|
|
||||||
key: string;
|
|
||||||
path: string;
|
|
||||||
absolute_path?: string;
|
|
||||||
added: number;
|
|
||||||
deleted: number;
|
|
||||||
approximate: boolean;
|
|
||||||
binary: boolean;
|
|
||||||
status: UIFileEdit["status"];
|
|
||||||
operation?: UIFileEdit["operation"];
|
|
||||||
pending: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CliRunSummary {
|
interface CliRunSummary {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -485,7 +474,7 @@ export function AgentActivityCluster({
|
|||||||
{outerExpanded && (
|
{outerExpanded && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-2 mt-1 overflow-hidden border-l border-muted-foreground/14 pl-4",
|
"ml-1 mt-1 overflow-hidden pl-1",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -497,11 +486,11 @@ export function AgentActivityCluster({
|
|||||||
"overflow-y-auto py-1 pr-1 scrollbar-thin scrollbar-track-transparent",
|
"overflow-y-auto py-1 pr-1 scrollbar-thin scrollbar-track-transparent",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div ref={activityContentRef} className="flex flex-col gap-1.5">
|
<div ref={activityContentRef} className="flex flex-col gap-0.5">
|
||||||
{messages.map((m) => {
|
{messages.map((m) => {
|
||||||
if (isReasoningOnlyAssistant(m)) {
|
if (isReasoningOnlyAssistant(m)) {
|
||||||
return (
|
return (
|
||||||
<ActivityReasoningRow
|
<ReasoningRow
|
||||||
key={m.id}
|
key={m.id}
|
||||||
text={m.reasoning ?? ""}
|
text={m.reasoning ?? ""}
|
||||||
streaming={isTurnStreaming && !!m.reasoningStreaming}
|
streaming={isTurnStreaming && !!m.reasoningStreaming}
|
||||||
@ -638,101 +627,14 @@ function traceLines(message: UIMessage): string[] {
|
|||||||
return message.content.trim() ? [message.content] : [];
|
return message.content.trim() ? [message.content] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActivityReasoningRow({
|
|
||||||
text,
|
|
||||||
streaming,
|
|
||||||
}: {
|
|
||||||
text: string;
|
|
||||||
streaming: boolean;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
useEffect(() => {
|
|
||||||
if (text.length > 0) preloadMarkdownText();
|
|
||||||
}, [text.length]);
|
|
||||||
return (
|
|
||||||
<div className="min-w-0 py-0.5">
|
|
||||||
<div className="flex min-w-0 items-center gap-2 text-[13px] leading-5 text-muted-foreground/78">
|
|
||||||
<ReasoningMarker streaming={streaming} />
|
|
||||||
<StreamingLabelSheen active={streaming} className="min-w-0 font-medium">
|
|
||||||
{streaming
|
|
||||||
? t("message.reasoningStreaming", { defaultValue: "Thinking…" })
|
|
||||||
: t("message.reasoning", { defaultValue: "Thinking" })}
|
|
||||||
</StreamingLabelSheen>
|
|
||||||
</div>
|
|
||||||
{text.trim() ? (
|
|
||||||
<MarkdownText
|
|
||||||
streaming={streaming}
|
|
||||||
className={cn(
|
|
||||||
"mt-1 min-w-0 pl-5 text-[12.5px] italic text-muted-foreground/78",
|
|
||||||
"prose-p:my-1 prose-li:my-0.5",
|
|
||||||
"prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium",
|
|
||||||
"prose-headings:text-muted-foreground/88 prose-strong:text-muted-foreground",
|
|
||||||
"prose-h1:text-[15px] prose-h2:text-[13.5px] prose-h3:text-[12.5px] prose-h4:text-[12px]",
|
|
||||||
"prose-a:text-muted-foreground/95 prose-a:underline hover:prose-a:opacity-90",
|
|
||||||
"prose-code:text-[0.92em]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</MarkdownText>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReasoningMarker({ streaming }: { streaming: boolean }) {
|
|
||||||
const wasStreamingRef = useRef(streaming);
|
|
||||||
const [justCompleted, setJustCompleted] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (wasStreamingRef.current && !streaming) {
|
|
||||||
setJustCompleted(true);
|
|
||||||
const timeout = window.setTimeout(() => setJustCompleted(false), 650);
|
|
||||||
wasStreamingRef.current = streaming;
|
|
||||||
return () => window.clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
wasStreamingRef.current = streaming;
|
|
||||||
return undefined;
|
|
||||||
}, [streaming]);
|
|
||||||
|
|
||||||
if (streaming) {
|
|
||||||
return (
|
|
||||||
<CircleDashed
|
|
||||||
data-testid="activity-reasoning-marker"
|
|
||||||
data-state="thinking"
|
|
||||||
className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground/55"
|
|
||||||
strokeWidth={1.8}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-testid="activity-reasoning-marker"
|
|
||||||
data-state="done"
|
|
||||||
className={cn(
|
|
||||||
"grid h-3.5 w-3.5 shrink-0 place-items-center rounded-full border border-emerald-500/28 text-emerald-500/78",
|
|
||||||
"bg-emerald-500/[0.035] transition-[border-color,background-color,box-shadow,transform] duration-300 ease-out",
|
|
||||||
justCompleted
|
|
||||||
&& "animate-in fade-in-0 zoom-in-75 shadow-[0_0_0_3px_rgba(16,185,129,0.10)] motion-reduce:animate-none",
|
|
||||||
)}
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"h-2.5 w-2.5 stroke-[2.4]",
|
|
||||||
justCompleted && "animate-in fade-in-0 zoom-in-50 duration-300 motion-reduce:animate-none",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActivityTraceList({
|
function ActivityTraceList({
|
||||||
lines,
|
lines,
|
||||||
active,
|
active,
|
||||||
|
evidenceByLine,
|
||||||
}: {
|
}: {
|
||||||
lines: string[];
|
lines: string[];
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
evidenceByLine?: Map<string, ActivityEvidence[]>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
@ -741,6 +643,7 @@ function ActivityTraceList({
|
|||||||
key={`${line}-${index}`}
|
key={`${line}-${index}`}
|
||||||
line={line}
|
line={line}
|
||||||
active={active && index === lines.length - 1}
|
active={active && index === lines.length - 1}
|
||||||
|
evidence={evidenceByLine?.get(line) ?? []}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -761,6 +664,8 @@ function ActivityTraceTimeline({
|
|||||||
const lines = traceLines(message);
|
const lines = traceLines(message);
|
||||||
const cliRunsByLine = cliRunMapByTraceLine(message);
|
const cliRunsByLine = cliRunMapByTraceLine(message);
|
||||||
const mcpRunsByLine = mcpRunMapByTraceLine(message);
|
const mcpRunsByLine = mcpRunMapByTraceLine(message);
|
||||||
|
const evidenceByLine = toolEvidenceByTraceLine(message);
|
||||||
|
const trailingEvidence = activityEvidenceFromMessageMedia(message);
|
||||||
const renderedRunKeys = new Set<string>();
|
const renderedRunKeys = new Set<string>();
|
||||||
const items: ReactNode[] = [];
|
const items: ReactNode[] = [];
|
||||||
let normalLines: string[] = [];
|
let normalLines: string[] = [];
|
||||||
@ -772,6 +677,7 @@ function ActivityTraceTimeline({
|
|||||||
key={`${message.id}:trace:${suffix}`}
|
key={`${message.id}:trace:${suffix}`}
|
||||||
lines={normalLines}
|
lines={normalLines}
|
||||||
active={active}
|
active={active}
|
||||||
|
evidenceByLine={evidenceByLine}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
normalLines = [];
|
normalLines = [];
|
||||||
@ -790,6 +696,15 @@ function ActivityTraceTimeline({
|
|||||||
cliAppsByName={cliAppsByName}
|
cliAppsByName={cliAppsByName}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
const evidence = evidenceByLine.get(line) ?? [];
|
||||||
|
if (evidence.length) {
|
||||||
|
items.push(
|
||||||
|
<ActivityEvidenceList
|
||||||
|
key={`${message.id}:cli-evidence:${cliRun.key}:${index}`}
|
||||||
|
evidence={evidence}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -805,6 +720,15 @@ function ActivityTraceTimeline({
|
|||||||
mcpPresetsByName={mcpPresetsByName}
|
mcpPresetsByName={mcpPresetsByName}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
const evidence = evidenceByLine.get(line) ?? [];
|
||||||
|
if (evidence.length) {
|
||||||
|
items.push(
|
||||||
|
<ActivityEvidenceList
|
||||||
|
key={`${message.id}:mcp-evidence:${mcpRun.key}:${index}`}
|
||||||
|
evidence={evidence}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -836,10 +760,25 @@ function ActivityTraceTimeline({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return items.length ? <>{items}</> : null;
|
if (trailingEvidence.length) {
|
||||||
|
items.push(
|
||||||
|
<ActivityEvidenceList
|
||||||
|
key={`${message.id}:media-evidence`}
|
||||||
|
evidence={trailingEvidence}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items.length) return null;
|
||||||
|
const group = describeActivityGroup(message, evidenceByLine, trailingEvidence);
|
||||||
|
return (
|
||||||
|
<ActivityGroup title={group.title} icon={group.icon}>
|
||||||
|
{items}
|
||||||
|
</ActivityGroup>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActivityTraceRow({ line, active }: { line: string; active: boolean }) {
|
function ActivityTraceRow({ line, active, evidence = [] }: { line: string; active: boolean; evidence?: ActivityEvidence[] }) {
|
||||||
const trace = describeTraceLine(line);
|
const trace = describeTraceLine(line);
|
||||||
const Icon = trace.kind === "search"
|
const Icon = trace.kind === "search"
|
||||||
? Search
|
? Search
|
||||||
@ -849,21 +788,90 @@ function ActivityTraceRow({ line, active }: { line: string; active: boolean }) {
|
|||||||
? Wrench
|
? Wrench
|
||||||
: Layers;
|
: Layers;
|
||||||
return (
|
return (
|
||||||
<li className="flex min-w-0 items-start gap-2 py-0.5 text-[13px] leading-5">
|
<ActivityStep
|
||||||
<TraceIconMark trace={trace} fallbackIcon={Icon} active={active} />
|
as="li"
|
||||||
<span className="min-w-0 flex-1">
|
marker={<TraceIconMark trace={trace} fallbackIcon={Icon} active={active} />}
|
||||||
<span className="font-medium text-muted-foreground/85">{trace.label}</span>
|
active={active && trace.kind !== "done"}
|
||||||
{trace.detail ? (
|
tone={trace.kind === "done" ? "success" : active ? "active" : "neutral"}
|
||||||
<>
|
label={trace.label}
|
||||||
<span className="text-muted-foreground/55"> </span>
|
detail={trace.detail}
|
||||||
<span className="break-words text-foreground/82">{trace.detail}</span>
|
title={`${trace.label}${trace.detail ? ` ${trace.detail}` : ""}`}
|
||||||
</>
|
>
|
||||||
) : null}
|
<ActivityEvidencePreview evidence={evidence} />
|
||||||
</span>
|
</ActivityStep>
|
||||||
</li>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ActivityEvidenceList({ evidence }: { evidence: ActivityEvidence[] }) {
|
||||||
|
return (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
<ActivityStep
|
||||||
|
as="li"
|
||||||
|
icon={FileImage}
|
||||||
|
tone="success"
|
||||||
|
label={evidenceLabel(evidence)}
|
||||||
|
>
|
||||||
|
<ActivityEvidencePreview evidence={evidence} />
|
||||||
|
</ActivityStep>
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceLabel(evidence: ActivityEvidence[]): string {
|
||||||
|
const first = evidence[0]?.attachment.kind;
|
||||||
|
if (first === "image") return evidence.length > 1 ? "Found images" : "Found image";
|
||||||
|
if (first === "video") return evidence.length > 1 ? "Found videos" : "Found video";
|
||||||
|
return evidence.length > 1 ? "Found files" : "Found file";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolEvidenceByTraceLine(message: UIMessage): Map<string, ActivityEvidence[]> {
|
||||||
|
const map = new Map<string, ActivityEvidence[]>();
|
||||||
|
for (const event of message.toolEvents ?? []) {
|
||||||
|
const evidence = activityEvidenceFromToolEvent(event);
|
||||||
|
if (!evidence.length) continue;
|
||||||
|
const line = formatToolCallTrace(event);
|
||||||
|
if (!line) continue;
|
||||||
|
const existing = map.get(line) ?? [];
|
||||||
|
map.set(line, [...existing, ...evidence]);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allToolEvidence(evidenceByLine: Map<string, ActivityEvidence[]>): ActivityEvidence[] {
|
||||||
|
return [...evidenceByLine.values()].flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeActivityGroup(
|
||||||
|
message: UIMessage,
|
||||||
|
evidenceByLine: Map<string, ActivityEvidence[]>,
|
||||||
|
mediaEvidence: ActivityEvidence[],
|
||||||
|
): { title: string; icon: LucideIcon } {
|
||||||
|
const names = [
|
||||||
|
...traceLines(message).map((line) => /^([a-zA-Z0-9_.-]+)\(/.exec(line.trim())?.[1] ?? line),
|
||||||
|
...(message.toolEvents ?? []).map(toolEventDisplayName),
|
||||||
|
].map((name) => name.toLowerCase());
|
||||||
|
const evidence = [...allToolEvidence(evidenceByLine), ...mediaEvidence];
|
||||||
|
const hasVisualEvidence = evidence.some((item) => item.attachment.kind === "image" || item.attachment.kind === "video");
|
||||||
|
if (hasVisualEvidence && names.some((name) => /browser|screenshot|vision|image|video/.test(name))) {
|
||||||
|
return { title: "Vision", icon: FileImage };
|
||||||
|
}
|
||||||
|
if (names.some((name) => /browser|screenshot/.test(name))) return { title: "Browser", icon: FileImage };
|
||||||
|
if (names.some((name) => /web|search|fetch|read|open/.test(name))) return { title: "Web", icon: Search };
|
||||||
|
if (names.some((name) => /exec|shell|terminal|bash|run_cli_app|cli_anything/.test(name))) return { title: "Shell", icon: Terminal };
|
||||||
|
if (names.some((name) => /^mcp_|mcp/.test(name))) return { title: "MCP", icon: Server };
|
||||||
|
if (message.fileEdits?.length) return { title: "Files", icon: Layers };
|
||||||
|
if (evidence.length) return { title: "Media", icon: FileImage };
|
||||||
|
return { title: "Working", icon: Layers };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolEventDisplayName(event: ToolProgressEvent): string {
|
||||||
|
return typeof (event as { function?: { name?: unknown } }).function?.name === "string"
|
||||||
|
? String((event as { function?: { name?: unknown } }).function?.name)
|
||||||
|
: typeof event.name === "string"
|
||||||
|
? event.name
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
interface TraceDescription {
|
interface TraceDescription {
|
||||||
kind: "search" | "tool" | "done" | "trace";
|
kind: "search" | "tool" | "done" | "trace";
|
||||||
label: string;
|
label: string;
|
||||||
@ -891,7 +899,7 @@ function TraceIconMark({
|
|||||||
<span
|
<span
|
||||||
data-testid={`activity-web-favicon-${trace.host}`}
|
data-testid={`activity-web-favicon-${trace.host}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-0.5 grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px] border border-border/45 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]",
|
"grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px] border border-border/45 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]",
|
||||||
active && "animate-pulse",
|
active && "animate-pulse",
|
||||||
)}
|
)}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@ -909,7 +917,7 @@ function TraceIconMark({
|
|||||||
return (
|
return (
|
||||||
<FallbackIcon
|
<FallbackIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-0.5 h-3.5 w-3.5 shrink-0",
|
"h-3.5 w-3.5 shrink-0",
|
||||||
trace.kind === "done"
|
trace.kind === "done"
|
||||||
? "text-emerald-500/75"
|
? "text-emerald-500/75"
|
||||||
: active
|
: active
|
||||||
@ -945,7 +953,7 @@ function describeTraceLine(line: string): TraceDescription {
|
|||||||
if (isShellTraceName(name)) {
|
if (isShellTraceName(name)) {
|
||||||
return {
|
return {
|
||||||
kind: "tool",
|
kind: "tool",
|
||||||
label: "Shell",
|
label: "Command",
|
||||||
detail: previewShellTraceDetail(args, trimmed),
|
detail: previewShellTraceDetail(args, trimmed),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -1633,27 +1641,6 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">): boolean {
|
|
||||||
return edit.added > 0 || edit.deleted > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFileEditError(error?: string): string {
|
|
||||||
const firstLine = (error || "").replace(/\s+/g, " ").trim();
|
|
||||||
if (!firstLine) return "";
|
|
||||||
const cleaned = firstLine
|
|
||||||
.replace(/^Error applying patch:\s*/i, "")
|
|
||||||
.replace(/^Error writing file:\s*/i, "")
|
|
||||||
.replace(/^Error editing file:\s*/i, "")
|
|
||||||
.replace(/^Error:\s*/i, "");
|
|
||||||
|
|
||||||
return cleaned
|
|
||||||
.replace(/^old_text not found in (.+)$/i, "Target text was not found in $1.")
|
|
||||||
.replace(/^old_text appears multiple times in (.+)$/i, "Target text matched multiple places in $1.")
|
|
||||||
.replace(/^file to (?:update|delete) does not exist: (.+)$/i, "File does not exist: $1.")
|
|
||||||
.replace(/^path to (?:update|delete) is not a file: (.+)$/i, "Path is not a file: $1.")
|
|
||||||
.slice(0, 180);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CliRunGroup({
|
function CliRunGroup({
|
||||||
runs,
|
runs,
|
||||||
active,
|
active,
|
||||||
@ -1694,10 +1681,13 @@ function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean;
|
|||||||
useEffect(() => setLogoIndex(0), [app?.logo_url]);
|
useEffect(() => setLogoIndex(0), [app?.logo_url]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<ActivityStep
|
||||||
className="flex min-w-0 items-center gap-2 py-0.5 text-[13px] leading-5"
|
as="li"
|
||||||
|
active={rowActive}
|
||||||
|
tone={failed ? "error" : rowActive ? "active" : run.status === "done" ? "success" : "neutral"}
|
||||||
title={`${label} @${run.name}${args ? ` ${args}` : ""}${run.error ? ` ${run.error}` : ""}`}
|
title={`${label} @${run.name}${args ? ` ${args}` : ""}${run.error ? ` ${run.error}` : ""}`}
|
||||||
>
|
label={label}
|
||||||
|
marker={(
|
||||||
<span
|
<span
|
||||||
data-testid={`activity-cli-logo-${run.name.toLowerCase()}`}
|
data-testid={`activity-cli-logo-${run.name.toLowerCase()}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -1724,10 +1714,9 @@ function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean;
|
|||||||
<Terminal className="h-3 w-3" aria-hidden />
|
<Terminal className="h-3 w-3" aria-hidden />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
|
)}
|
||||||
<StreamingLabelSheen active={rowActive} className="shrink-0 font-medium text-muted-foreground/85">
|
>
|
||||||
{label}
|
<div className="-mt-0.5 flex min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-0.5">
|
||||||
</StreamingLabelSheen>
|
|
||||||
<span className="max-w-[11rem] shrink-0 truncate font-mono text-[12.5px] font-semibold text-foreground/90">
|
<span className="max-w-[11rem] shrink-0 truncate font-mono text-[12.5px] font-semibold text-foreground/90">
|
||||||
@{run.name}
|
@{run.name}
|
||||||
</span>
|
</span>
|
||||||
@ -1758,8 +1747,8 @@ function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean;
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</div>
|
||||||
</li>
|
</ActivityStep>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1803,10 +1792,13 @@ function McpRunRow({ run, active, preset }: { run: McpRunSummary; active: boolea
|
|||||||
useEffect(() => setLogoIndex(0), [preset?.logo_url]);
|
useEffect(() => setLogoIndex(0), [preset?.logo_url]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<ActivityStep
|
||||||
className="flex min-w-0 items-center gap-2 py-0.5 text-[13px] leading-5"
|
as="li"
|
||||||
|
active={rowActive}
|
||||||
|
tone={failed ? "error" : rowActive ? "active" : run.status === "done" ? "success" : "neutral"}
|
||||||
title={`${label} ${displayName} ${run.toolName}${run.argsPreview ? ` ${run.argsPreview}` : ""}${run.error ? ` ${run.error}` : ""}`}
|
title={`${label} ${displayName} ${run.toolName}${run.argsPreview ? ` ${run.argsPreview}` : ""}${run.error ? ` ${run.error}` : ""}`}
|
||||||
>
|
label={label}
|
||||||
|
marker={(
|
||||||
<span
|
<span
|
||||||
data-testid={`activity-mcp-logo-${run.presetName.toLowerCase()}`}
|
data-testid={`activity-mcp-logo-${run.presetName.toLowerCase()}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -1833,10 +1825,9 @@ function McpRunRow({ run, active, preset }: { run: McpRunSummary; active: boolea
|
|||||||
<Server className="h-3 w-3" aria-hidden />
|
<Server className="h-3 w-3" aria-hidden />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
|
)}
|
||||||
<StreamingLabelSheen active={rowActive} className="shrink-0 font-medium text-muted-foreground/85">
|
>
|
||||||
{label}
|
<div className="-mt-0.5 flex min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-0.5">
|
||||||
</StreamingLabelSheen>
|
|
||||||
<span className="max-w-[12rem] shrink-0 truncate text-[12.5px] font-semibold text-foreground/90">
|
<span className="max-w-[12rem] shrink-0 truncate text-[12.5px] font-semibold text-foreground/90">
|
||||||
{displayName}
|
{displayName}
|
||||||
</span>
|
</span>
|
||||||
@ -1856,8 +1847,8 @@ function McpRunRow({ run, active, preset }: { run: McpRunSummary; active: boolea
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</div>
|
||||||
</li>
|
</ActivityStep>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1870,180 +1861,3 @@ function alphaColor(color: string, percent: number): string {
|
|||||||
}
|
}
|
||||||
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
|
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
|
|
||||||
if (edits.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{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 && hasVisibleDiffStats(edit);
|
|
||||||
const failureDetail = failed
|
|
||||||
? formatFileEditError(edit.error)
|
|
||||||
|| t("message.fileEditFailedFallback", { defaultValue: "File change was not applied." })
|
|
||||||
: "";
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-0.5 text-xs"
|
|
||||||
title={failureDetail || edit.absolute_path || edit.path}
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<span className="grid h-5 w-5 shrink-0 place-items-center text-muted-foreground/50">
|
|
||||||
{failed ? (
|
|
||||||
<AlertCircle className="h-3.5 w-3.5 text-destructive/75" aria-hidden />
|
|
||||||
) : editing ? (
|
|
||||||
<CircleDashed className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500/75" aria-hidden />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{edit.pending && !edit.path ? (
|
|
||||||
<StreamingLabelSheen
|
|
||||||
active={editing}
|
|
||||||
className="min-w-0 text-[12px] font-medium text-muted-foreground"
|
|
||||||
>
|
|
||||||
{t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })}
|
|
||||||
</StreamingLabelSheen>
|
|
||||||
) : (
|
|
||||||
<FileReferenceChip
|
|
||||||
path={edit.path}
|
|
||||||
tooltipPath={edit.absolute_path}
|
|
||||||
display="path"
|
|
||||||
active={editing}
|
|
||||||
className="min-w-0"
|
|
||||||
textClassName="text-[12px]"
|
|
||||||
testId="activity-file-reference"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{failed ? (
|
|
||||||
<span className="min-w-0 truncate text-[11px] leading-4 text-destructive/75">
|
|
||||||
{failureDetail}
|
|
||||||
</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-baseline gap-1.5 leading-[inherit] tabular-nums"
|
|
||||||
data-testid="activity-diff-pair"
|
|
||||||
>
|
|
||||||
<DiffValue
|
|
||||||
sign="+"
|
|
||||||
value={added}
|
|
||||||
className="text-emerald-600/75 dark:text-emerald-300/75"
|
|
||||||
/>
|
|
||||||
<DiffValue
|
|
||||||
sign="-"
|
|
||||||
value={deleted}
|
|
||||||
className="text-rose-600/70 dark:text-rose-300/75"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DiffValue({ sign, value, className }: { sign: string; value: number; className: string }) {
|
|
||||||
const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn("inline-flex items-baseline leading-[inherit]", className)}
|
|
||||||
aria-label={`${sign}${safeValue}`}
|
|
||||||
>
|
|
||||||
<span className="inline-flex items-baseline leading-none" aria-hidden>
|
|
||||||
{sign}
|
|
||||||
<AnimatedNumber value={safeValue} />
|
|
||||||
</span>
|
|
||||||
<span className="sr-only">{sign}{safeValue}</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 <RollingNumber value={display} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RollingNumber({ value }: { value: number }) {
|
|
||||||
const digits = String(value).split("");
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-baseline leading-none" aria-hidden>
|
|
||||||
{digits.map((digit, index) => (
|
|
||||||
<RollingDigit
|
|
||||||
key={`${digits.length}-${index}`}
|
|
||||||
digit={Number(digit)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RollingDigit({ digit }: { digit: number }) {
|
|
||||||
const safeDigit = Number.isFinite(digit) ? Math.min(9, Math.max(0, digit)) : 0;
|
|
||||||
return (
|
|
||||||
<span className="relative inline-block h-[1em] w-[0.62em] overflow-hidden align-baseline leading-none">
|
|
||||||
<span className="invisible block h-[1em] leading-none">0</span>
|
|
||||||
<span
|
|
||||||
className="absolute inset-x-0 top-0 flex flex-col transition-transform duration-200 ease-out will-change-transform"
|
|
||||||
style={{ transform: `translateY(-${safeDigit}em)` }}
|
|
||||||
>
|
|
||||||
{Array.from({ length: 10 }, (_, n) => (
|
|
||||||
<span key={n} className="block h-[1em] leading-none">
|
|
||||||
{n}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -2,15 +2,13 @@ import { useMemo } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { MessageBubble } from "@/components/MessageBubble";
|
import { MessageBubble } from "@/components/MessageBubble";
|
||||||
import {
|
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
||||||
AgentActivityCluster,
|
import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
|
||||||
isAgentActivityMember,
|
|
||||||
} from "@/components/thread/AgentActivityCluster";
|
|
||||||
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
interface ThreadMessagesProps {
|
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 timeline expanded. */
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
hiddenMessageCount?: number;
|
hiddenMessageCount?: number;
|
||||||
onLoadEarlier?: () => void;
|
onLoadEarlier?: () => void;
|
||||||
@ -18,9 +16,7 @@ interface ThreadMessagesProps {
|
|||||||
mcpPresets?: McpPresetInfo[];
|
mcpPresets?: McpPresetInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DisplayUnit =
|
export type DisplayUnit = TurnUnit;
|
||||||
| { type: "cluster"; messages: UIMessage[] }
|
|
||||||
| { type: "single"; message: UIMessage };
|
|
||||||
|
|
||||||
/** True when this unit index is the last assistant text slice before the next user message (or end of thread). */
|
/** True when this unit index is the last assistant text slice before the next user message (or end of thread). */
|
||||||
export function isFinalAssistantSliceBeforeNextUser(
|
export function isFinalAssistantSliceBeforeNextUser(
|
||||||
@ -28,170 +24,17 @@ export function isFinalAssistantSliceBeforeNextUser(
|
|||||||
index: number,
|
index: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
const u = units[index];
|
const u = units[index];
|
||||||
if (u.type !== "single" || u.message.role !== "assistant") return true;
|
if (u.type !== "message" || u.message.role !== "assistant") return true;
|
||||||
for (let j = index + 1; j < units.length; j++) {
|
for (let j = index + 1; j < units.length; j++) {
|
||||||
const v = units[j];
|
const v = units[j];
|
||||||
if (v.type === "single" && v.message.role === "user") break;
|
if (v.type === "message" && v.message.role === "user") break;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
|
export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
|
||||||
const out: DisplayUnit[] = [];
|
return normalizeActivityTimeline(messages);
|
||||||
let i = 0;
|
|
||||||
while (i < messages.length) {
|
|
||||||
const m = messages[i];
|
|
||||||
if (isAgentActivityMember(m)) {
|
|
||||||
const cluster: UIMessage[] = [];
|
|
||||||
let segmentId: string | undefined = m.activitySegmentId;
|
|
||||||
let clusterHasFileEdits = hasFileEdits(m);
|
|
||||||
while (
|
|
||||||
i < messages.length
|
|
||||||
&& isAgentActivityMember(messages[i])
|
|
||||||
&& canJoinActivityCluster(segmentId, clusterHasFileEdits, messages[i])
|
|
||||||
) {
|
|
||||||
const current = messages[i];
|
|
||||||
if (!segmentId && current.activitySegmentId) {
|
|
||||||
segmentId = current.activitySegmentId;
|
|
||||||
}
|
|
||||||
clusterHasFileEdits = clusterHasFileEdits || hasFileEdits(current);
|
|
||||||
cluster.push(current);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
pushActivityCluster(out, cluster);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const previous = out[out.length - 1];
|
|
||||||
if (
|
|
||||||
previous?.type === "cluster"
|
|
||||||
&& assistantHasInlineReasoning(m)
|
|
||||||
&& canFoldInlineReasoning(previous.messages, m)
|
|
||||||
) {
|
|
||||||
previous.messages.push(reasoningOnlyMessageFromAnswer(m));
|
|
||||||
out.push({ type: "single", message: stripInlineReasoning(m) });
|
|
||||||
i += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (assistantHasInlineReasoning(m)) {
|
|
||||||
out.push({ type: "cluster", messages: [reasoningOnlyMessageFromAnswer(m)] });
|
|
||||||
out.push({ type: "single", message: stripInlineReasoning(m) });
|
|
||||||
i += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out.push({ type: "single", message: m });
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pushActivityCluster(out: DisplayUnit[], cluster: UIMessage[]) {
|
|
||||||
const previous = out[out.length - 1];
|
|
||||||
if (
|
|
||||||
previous?.type !== "single"
|
|
||||||
|| !shouldPlaceLateActivityBeforeAssistant(out, previous.message)
|
|
||||||
) {
|
|
||||||
out.push({ type: "cluster", messages: cluster });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const beforeAssistant = out[out.length - 2];
|
|
||||||
if (beforeAssistant?.type === "cluster" && canMergeActivityClusters(beforeAssistant.messages, cluster)) {
|
|
||||||
beforeAssistant.messages.push(...cluster);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
out.splice(out.length - 1, 0, { type: "cluster", messages: cluster });
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldPlaceLateActivityBeforeAssistant(out: DisplayUnit[], message: UIMessage): boolean {
|
|
||||||
if (message.role !== "assistant" || message.kind === "trace") return false;
|
|
||||||
if (message.isStreaming) return true;
|
|
||||||
if (hasTurnLatency(message)) return true;
|
|
||||||
|
|
||||||
const beforeAssistant = out[out.length - 2];
|
|
||||||
return beforeAssistant?.type === "cluster";
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasTurnLatency(message: UIMessage): boolean {
|
|
||||||
return (
|
|
||||||
typeof message.latencyMs === "number"
|
|
||||||
&& Number.isFinite(message.latencyMs)
|
|
||||||
&& message.latencyMs >= 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clusterSegmentId(messages: UIMessage[]): string | undefined {
|
|
||||||
return messages.find((message) => message.activitySegmentId)?.activitySegmentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasFileEdits(message: UIMessage): boolean {
|
|
||||||
return !!message.fileEdits?.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clusterHasFileEdits(messages: UIMessage[]): boolean {
|
|
||||||
return messages.some(hasFileEdits);
|
|
||||||
}
|
|
||||||
|
|
||||||
function canJoinActivityCluster(
|
|
||||||
clusterSegmentId: string | undefined,
|
|
||||||
clusterIncludesFileEdits: boolean,
|
|
||||||
message: UIMessage,
|
|
||||||
): boolean {
|
|
||||||
const messageHasFileEdits = hasFileEdits(message);
|
|
||||||
if (!clusterIncludesFileEdits && !messageHasFileEdits) return true;
|
|
||||||
if (!clusterSegmentId || !message.activitySegmentId) return true;
|
|
||||||
return clusterSegmentId === message.activitySegmentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function canFoldInlineReasoning(cluster: UIMessage[], message: UIMessage): boolean {
|
|
||||||
if (!clusterHasFileEdits(cluster) && !hasFileEdits(message)) return true;
|
|
||||||
const segmentId = clusterSegmentId(cluster);
|
|
||||||
if (!segmentId || !message.activitySegmentId) return true;
|
|
||||||
return segmentId === message.activitySegmentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function canMergeActivityClusters(target: UIMessage[], incoming: UIMessage[]): boolean {
|
|
||||||
let segmentId = clusterSegmentId(target);
|
|
||||||
let includesFileEdits = clusterHasFileEdits(target);
|
|
||||||
for (const message of incoming) {
|
|
||||||
if (!canJoinActivityCluster(segmentId, includesFileEdits, message)) return false;
|
|
||||||
if (!segmentId && message.activitySegmentId) {
|
|
||||||
segmentId = message.activitySegmentId;
|
|
||||||
}
|
|
||||||
includesFileEdits = includesFileEdits || hasFileEdits(message);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
latencyMs: message.latencyMs,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripInlineReasoning(message: UIMessage): UIMessage {
|
|
||||||
const next = { ...message };
|
|
||||||
delete next.reasoning;
|
|
||||||
delete next.reasoningStreaming;
|
|
||||||
return next;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
|
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
|
||||||
@ -199,11 +42,11 @@ export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
|
|||||||
let hasLaterUnitBeforeUser = false;
|
let hasLaterUnitBeforeUser = false;
|
||||||
for (let i = units.length - 1; i >= 0; i -= 1) {
|
for (let i = units.length - 1; i >= 0; i -= 1) {
|
||||||
const unit = units[i];
|
const unit = units[i];
|
||||||
if (unit.type === "single" && unit.message.role === "user") {
|
if (unit.type === "message" && unit.message.role === "user") {
|
||||||
hasLaterUnitBeforeUser = false;
|
hasLaterUnitBeforeUser = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (unit.type === "single" && unit.message.role === "assistant") {
|
if (unit.type === "message" && unit.message.role === "assistant") {
|
||||||
flags[i] = !hasLaterUnitBeforeUser;
|
flags[i] = !hasLaterUnitBeforeUser;
|
||||||
}
|
}
|
||||||
hasLaterUnitBeforeUser = true;
|
hasLaterUnitBeforeUser = true;
|
||||||
@ -222,8 +65,8 @@ export function ThreadMessages({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
|
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
|
||||||
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
|
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
|
||||||
const liveActivityClusterIndex = useMemo(
|
const liveActivityClusterIndices = useMemo(
|
||||||
() => isStreaming ? currentActivityClusterIndex(units) : -1,
|
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
|
||||||
[isStreaming, units],
|
[isStreaming, units],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -251,20 +94,18 @@ export function ThreadMessages({
|
|||||||
: "";
|
: "";
|
||||||
const next = units[index + 1];
|
const next = units[index + 1];
|
||||||
const hasBodyBelow =
|
const hasBodyBelow =
|
||||||
unit.type === "cluster"
|
unit.type === "activity"
|
||||||
&& next?.type === "single"
|
&& next?.type === "message"
|
||||||
&& next.message.role === "assistant";
|
&& next.message.role === "assistant";
|
||||||
const turnLatencyMs =
|
|
||||||
unit.type === "cluster" ? activityClusterTurnLatencyMs(unit.messages, next) : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={unitKey(unit, index)} className={marginTop}>
|
<div key={unitKey(unit, index)} className={marginTop}>
|
||||||
{unit.type === "cluster" ? (
|
{unit.type === "activity" ? (
|
||||||
<AgentActivityCluster
|
<AgentActivityCluster
|
||||||
messages={unit.messages}
|
messages={unit.messages}
|
||||||
isTurnStreaming={index === liveActivityClusterIndex}
|
isTurnStreaming={liveActivityClusterIndices.has(index)}
|
||||||
hasBodyBelow={hasBodyBelow}
|
hasBodyBelow={hasBodyBelow}
|
||||||
turnLatencyMs={turnLatencyMs}
|
turnLatencyMs={unit.turnLatencyMs}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
/>
|
/>
|
||||||
@ -287,49 +128,45 @@ export function ThreadMessages({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function activityClusterTurnLatencyMs(
|
function currentActivityClusterIndices(units: DisplayUnit[]): Set<number> {
|
||||||
messages: UIMessage[],
|
const indices = new Set<number>();
|
||||||
next: DisplayUnit | undefined,
|
let markedCurrentActivity = false;
|
||||||
): number | undefined {
|
|
||||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
||||||
const latency = messages[i].latencyMs;
|
|
||||||
if (typeof latency === "number" && Number.isFinite(latency) && latency >= 0) {
|
|
||||||
return latency;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
next?.type === "single"
|
|
||||||
&& next.message.role === "assistant"
|
|
||||||
&& typeof next.message.latencyMs === "number"
|
|
||||||
&& Number.isFinite(next.message.latencyMs)
|
|
||||||
&& next.message.latencyMs >= 0
|
|
||||||
) {
|
|
||||||
return next.message.latencyMs;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentActivityClusterIndex(units: DisplayUnit[]): number {
|
|
||||||
for (let i = units.length - 1; i >= 0; i -= 1) {
|
for (let i = units.length - 1; i >= 0; i -= 1) {
|
||||||
const unit = units[i];
|
const unit = units[i];
|
||||||
if (unit.type === "cluster") return i;
|
if (unit.type === "activity") {
|
||||||
|
if (!markedCurrentActivity) {
|
||||||
|
indices.add(i);
|
||||||
|
markedCurrentActivity = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (activityHasLiveFileEdit(unit)) {
|
||||||
|
indices.add(i);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (unit.message.role === "assistant" && unit.message.isStreaming) continue;
|
if (unit.message.role === "assistant" && unit.message.isStreaming) continue;
|
||||||
if (unit.message.role === "user") break;
|
if (unit.message.role === "user") break;
|
||||||
return -1;
|
|
||||||
}
|
}
|
||||||
return -1;
|
return indices;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activityHasLiveFileEdit(unit: Extract<DisplayUnit, { type: "activity" }>): boolean {
|
||||||
|
return unit.messages.some((message) => (
|
||||||
|
message.kind === "trace"
|
||||||
|
&& message.fileEdits?.some((edit) => edit.status === "editing" || edit.pending || !edit.path)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
function unitKey(unit: DisplayUnit, index: number): string {
|
function unitKey(unit: DisplayUnit, index: number): string {
|
||||||
if (unit.type === "cluster") {
|
if (unit.type === "activity") {
|
||||||
const anchor = unit.messages[0]?.id;
|
const anchor = unit.messages[0]?.id;
|
||||||
return anchor != null ? `cluster-${anchor}` : `cluster-idx-${index}`;
|
return anchor != null ? `activity-${anchor}` : `activity-idx-${index}`;
|
||||||
}
|
}
|
||||||
return unit.message.id;
|
return unit.message.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function marginAfterPrevUnit(prev: DisplayUnit): string {
|
function marginAfterPrevUnit(prev: DisplayUnit): string {
|
||||||
if (prev.type === "cluster") {
|
if (prev.type === "activity") {
|
||||||
return "mt-4";
|
return "mt-4";
|
||||||
}
|
}
|
||||||
const p = prev.message;
|
const p = prev.message;
|
||||||
|
|||||||
@ -167,7 +167,6 @@ export function ThreadShell({
|
|||||||
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
|
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
|
||||||
const [mcpPresets, setMcpPresets] = useState<McpPresetInfo[]>([]);
|
const [mcpPresets, setMcpPresets] = useState<McpPresetInfo[]>([]);
|
||||||
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
|
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
|
||||||
const [heroImageMode, setHeroImageMode] = useState(false);
|
|
||||||
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
|
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
|
||||||
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
|
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
|
||||||
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
||||||
@ -211,8 +210,6 @@ export function ThreadShell({
|
|||||||
() => toModelBadgeInfo(modelName, settings),
|
() => toModelBadgeInfo(modelName, settings),
|
||||||
[modelName, settings],
|
[modelName, settings],
|
||||||
);
|
);
|
||||||
const imageGenerationEnabled = settings?.image_generation.enabled === true;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showHeroComposer && !wasShowingHeroComposerRef.current) {
|
if (showHeroComposer && !wasShowingHeroComposerRef.current) {
|
||||||
setHeroGreetingKey(randomHeroGreetingKey());
|
setHeroGreetingKey(randomHeroGreetingKey());
|
||||||
@ -508,9 +505,6 @@ export function ThreadShell({
|
|||||||
slashCommands={slashCommands}
|
slashCommands={slashCommands}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
imageGenerationEnabled={imageGenerationEnabled}
|
|
||||||
imageMode={showHeroComposer ? heroImageMode : undefined}
|
|
||||||
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
|
|
||||||
onStop={stop}
|
onStop={stop}
|
||||||
runStartedAt={runStartedAt}
|
runStartedAt={runStartedAt}
|
||||||
goalState={goalState}
|
goalState={goalState}
|
||||||
@ -520,6 +514,7 @@ export function ThreadShell({
|
|||||||
workspaceScopeDisabled={workspaceScopeDisabled}
|
workspaceScopeDisabled={workspaceScopeDisabled}
|
||||||
workspaceError={workspaceError}
|
workspaceError={workspaceError}
|
||||||
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||||
|
pendingQueueKey={chatId}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
@ -538,9 +533,6 @@ export function ThreadShell({
|
|||||||
slashCommands={slashCommands}
|
slashCommands={slashCommands}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
imageGenerationEnabled={imageGenerationEnabled}
|
|
||||||
imageMode={heroImageMode}
|
|
||||||
onImageModeChange={setHeroImageMode}
|
|
||||||
runStartedAt={runStartedAt}
|
runStartedAt={runStartedAt}
|
||||||
goalState={goalState}
|
goalState={goalState}
|
||||||
workspaceScope={workspaceScope}
|
workspaceScope={workspaceScope}
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
import { AttachmentTile } from "@/components/AttachmentTile";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ActivityEvidence } from "@/lib/activity-timeline";
|
||||||
|
|
||||||
|
interface ActivityEvidencePreviewProps {
|
||||||
|
evidence: ActivityEvidence[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityEvidencePreview({ evidence, className }: ActivityEvidencePreviewProps) {
|
||||||
|
if (evidence.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="activity-evidence-preview"
|
||||||
|
className={cn(
|
||||||
|
"flex max-w-full flex-wrap items-start gap-2 pt-0.5",
|
||||||
|
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 motion-safe:duration-200",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{evidence.slice(0, 4).map((item) => (
|
||||||
|
<AttachmentTile
|
||||||
|
key={item.id}
|
||||||
|
attachment={item.attachment}
|
||||||
|
variant="compact"
|
||||||
|
className={cn(
|
||||||
|
item.attachment.kind === "image" || item.attachment.kind === "video"
|
||||||
|
? "max-w-[min(100%,20rem)]"
|
||||||
|
: "max-w-[14rem]",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
webui/src/components/thread/activity/ActivityGroup.tsx
Normal file
28
webui/src/components/thread/activity/ActivityGroup.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ActivityGroupProps {
|
||||||
|
title: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityGroup({ title, icon: Icon, children, className }: ActivityGroupProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 py-1 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1 motion-safe:duration-200",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex min-w-0 items-center gap-1.5 pl-0.5 text-[12px] font-medium text-muted-foreground/70">
|
||||||
|
{Icon ? <Icon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
||||||
|
<span className="min-w-0 truncate">{title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
webui/src/components/thread/activity/ActivityStep.tsx
Normal file
95
webui/src/components/thread/activity/ActivityStep.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { StreamingLabelSheen } from "@/components/MessageBubble";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type ActivityStepTone = "neutral" | "active" | "success" | "error";
|
||||||
|
|
||||||
|
export interface ActivityStepProps {
|
||||||
|
as?: "div" | "li";
|
||||||
|
icon?: LucideIcon;
|
||||||
|
marker?: ReactNode;
|
||||||
|
label: ReactNode;
|
||||||
|
detail?: ReactNode;
|
||||||
|
aside?: ReactNode;
|
||||||
|
children?: ReactNode;
|
||||||
|
active?: boolean;
|
||||||
|
tone?: ActivityStepTone;
|
||||||
|
title?: string;
|
||||||
|
className?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
markerClassName?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityStep({
|
||||||
|
as: Component = "div",
|
||||||
|
icon: Icon,
|
||||||
|
marker,
|
||||||
|
label,
|
||||||
|
detail,
|
||||||
|
aside,
|
||||||
|
children,
|
||||||
|
active = false,
|
||||||
|
tone = active ? "active" : "neutral",
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
|
markerClassName,
|
||||||
|
style,
|
||||||
|
}: ActivityStepProps) {
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
className={cn(
|
||||||
|
"group/activity-step relative grid min-w-0 grid-cols-[1.125rem_minmax(0,1fr)] gap-2 py-0.5 text-[13px] leading-5",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
title={title}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-5 w-[1.125rem] shrink-0 items-start justify-center pt-[3px]",
|
||||||
|
"after:absolute after:left-1/2 after:top-[1.25rem] after:h-[calc(100%+0.375rem)] after:w-px after:-translate-x-1/2 after:bg-muted-foreground/14 group-last/activity-step:after:hidden",
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{marker ?? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid h-3.5 w-3.5 place-items-center rounded-full border bg-background transition-colors",
|
||||||
|
tone === "active" && "border-muted-foreground/28 text-muted-foreground/72",
|
||||||
|
tone === "success" && "border-emerald-500/28 text-emerald-500/78",
|
||||||
|
tone === "error" && "border-destructive/30 text-destructive/78",
|
||||||
|
tone === "neutral" && "border-muted-foreground/18 text-muted-foreground/50",
|
||||||
|
markerClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{Icon ? <Icon className="h-2.5 w-2.5" strokeWidth={2.15} /> : null}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className={cn("min-w-0", contentClassName)}>
|
||||||
|
<div className="flex min-w-0 items-baseline gap-1.5">
|
||||||
|
<StreamingLabelSheen
|
||||||
|
active={active}
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 shrink-0 font-medium",
|
||||||
|
tone === "error" ? "text-destructive/78" : "text-muted-foreground/85",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</StreamingLabelSheen>
|
||||||
|
{detail ? (
|
||||||
|
<span className="min-w-0 break-words text-foreground/82">
|
||||||
|
{detail}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{aside ? <span className="ml-auto shrink-0">{aside}</span> : null}
|
||||||
|
</div>
|
||||||
|
{children ? <div className="mt-1 min-w-0">{children}</div> : null}
|
||||||
|
</div>
|
||||||
|
</Component>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
webui/src/components/thread/activity/DiffPair.tsx
Normal file
114
webui/src/components/thread/activity/DiffPair.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function DiffPair({ added, deleted }: { added: number; deleted: number }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex shrink-0 items-baseline gap-1.5 leading-[inherit] tabular-nums"
|
||||||
|
data-testid="activity-diff-pair"
|
||||||
|
>
|
||||||
|
<DiffValue
|
||||||
|
sign="+"
|
||||||
|
value={added}
|
||||||
|
className="text-emerald-600/75 dark:text-emerald-300/75"
|
||||||
|
/>
|
||||||
|
<DiffValue
|
||||||
|
sign="-"
|
||||||
|
value={deleted}
|
||||||
|
className="text-rose-600/70 dark:text-rose-300/75"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffValue({ sign, value, className }: { sign: string; value: number; className: string }) {
|
||||||
|
const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("inline-flex items-baseline leading-[inherit]", className)}
|
||||||
|
aria-label={`${sign}${safeValue}`}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-baseline leading-none" aria-hidden>
|
||||||
|
{sign}
|
||||||
|
<AnimatedNumber value={safeValue} />
|
||||||
|
</span>
|
||||||
|
<span className="sr-only">{sign}{safeValue}</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 <RollingNumber value={display} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RollingNumber({ value }: { value: number }) {
|
||||||
|
const digits = String(value).split("");
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-baseline leading-none" aria-hidden>
|
||||||
|
{digits.map((digit, index) => (
|
||||||
|
<RollingDigit
|
||||||
|
key={`${digits.length}-${index}`}
|
||||||
|
digit={Number(digit)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RollingDigit({ digit }: { digit: number }) {
|
||||||
|
const safeDigit = Number.isFinite(digit) ? Math.min(9, Math.max(0, digit)) : 0;
|
||||||
|
return (
|
||||||
|
<span className="relative inline-block h-[1em] w-[0.62em] overflow-hidden align-baseline leading-none">
|
||||||
|
<span className="invisible block h-[1em] leading-none">0</span>
|
||||||
|
<span
|
||||||
|
className="absolute inset-x-0 top-0 flex flex-col transition-transform duration-200 ease-out will-change-transform"
|
||||||
|
style={{ transform: `translateY(-${safeDigit}em)` }}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 10 }, (_, n) => (
|
||||||
|
<span key={n} className="block h-[1em] leading-none">
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
webui/src/components/thread/activity/FileEditRow.tsx
Normal file
114
webui/src/components/thread/activity/FileEditRow.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { AlertCircle, CheckCircle2, CircleDashed } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { FileReferenceChip } from "@/components/FileReferenceChip";
|
||||||
|
import type { UIFileEdit } from "@/lib/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { ActivityStep } from "./ActivityStep";
|
||||||
|
import { DiffPair } from "./DiffPair";
|
||||||
|
|
||||||
|
export interface FileEditSummary {
|
||||||
|
key: string;
|
||||||
|
path: string;
|
||||||
|
absolute_path?: string;
|
||||||
|
added: number;
|
||||||
|
deleted: number;
|
||||||
|
approximate: boolean;
|
||||||
|
binary: boolean;
|
||||||
|
status: UIFileEdit["status"];
|
||||||
|
operation?: UIFileEdit["operation"];
|
||||||
|
pending: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
|
||||||
|
if (edits.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{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 && hasVisibleDiffStats(edit);
|
||||||
|
const failureDetail = failed
|
||||||
|
? formatFileEditError(edit.error)
|
||||||
|
|| t("message.fileEditFailedFallback", { defaultValue: "File change was not applied." })
|
||||||
|
: "";
|
||||||
|
const statusIcon = failed ? (
|
||||||
|
<AlertCircle className="h-3 w-3" aria-hidden />
|
||||||
|
) : editing ? (
|
||||||
|
<CircleDashed className="h-3 w-3 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="h-3 w-3" aria-hidden />
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<ActivityStep
|
||||||
|
as="li"
|
||||||
|
marker={(
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid h-3.5 w-3.5 place-items-center rounded-full border bg-background transition-colors",
|
||||||
|
failed && "border-destructive/30 text-destructive/78",
|
||||||
|
editing && "border-muted-foreground/24 text-muted-foreground/65",
|
||||||
|
!failed && !editing && "border-emerald-500/28 text-emerald-500/78",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusIcon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
active={editing}
|
||||||
|
tone={failed ? "error" : editing ? "active" : "success"}
|
||||||
|
className="text-xs"
|
||||||
|
contentClassName="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"
|
||||||
|
title={failureDetail || edit.absolute_path || edit.path}
|
||||||
|
label={edit.pending && !edit.path
|
||||||
|
? t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })
|
||||||
|
: (
|
||||||
|
<FileReferenceChip
|
||||||
|
path={edit.path}
|
||||||
|
tooltipPath={edit.absolute_path}
|
||||||
|
display="path"
|
||||||
|
active={editing}
|
||||||
|
className="min-w-0"
|
||||||
|
textClassName="text-[12px]"
|
||||||
|
testId="activity-file-reference"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
detail={failed ? (
|
||||||
|
<span className="min-w-0 truncate text-[11px] leading-4 text-destructive/75">
|
||||||
|
{failureDetail}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
aside={hasCountedDiff ? <DiffPair added={edit.added} deleted={edit.deleted} /> : null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">): boolean {
|
||||||
|
return edit.added > 0 || edit.deleted > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileEditError(error?: string): string {
|
||||||
|
const firstLine = (error || "").replace(/\s+/g, " ").trim();
|
||||||
|
if (!firstLine) return "";
|
||||||
|
const cleaned = firstLine
|
||||||
|
.replace(/^Error applying patch:\s*/i, "")
|
||||||
|
.replace(/^Error writing file:\s*/i, "")
|
||||||
|
.replace(/^Error editing file:\s*/i, "")
|
||||||
|
.replace(/^Error:\s*/i, "");
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
.replace(/^old_text not found in (.+)$/i, "Target text was not found in $1.")
|
||||||
|
.replace(/^old_text appears multiple times in (.+)$/i, "Target text matched multiple places in $1.")
|
||||||
|
.replace(/^file to (?:update|delete) does not exist: (.+)$/i, "File does not exist: $1.")
|
||||||
|
.replace(/^path to (?:update|delete) is not a file: (.+)$/i, "Path is not a file: $1.")
|
||||||
|
.slice(0, 180);
|
||||||
|
}
|
||||||
96
webui/src/components/thread/activity/ReasoningRow.tsx
Normal file
96
webui/src/components/thread/activity/ReasoningRow.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Check, CircleDashed } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { ActivityStep } from "./ActivityStep";
|
||||||
|
|
||||||
|
export function ReasoningRow({
|
||||||
|
text,
|
||||||
|
streaming,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
streaming: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
useEffect(() => {
|
||||||
|
if (text.length > 0) preloadMarkdownText();
|
||||||
|
}, [text.length]);
|
||||||
|
return (
|
||||||
|
<ActivityStep
|
||||||
|
marker={<ReasoningMarker streaming={streaming} />}
|
||||||
|
active={streaming}
|
||||||
|
tone={streaming ? "active" : "success"}
|
||||||
|
label={streaming
|
||||||
|
? t("message.reasoningStreaming", { defaultValue: "Thinking…" })
|
||||||
|
: t("message.reasoning", { defaultValue: "Thinking" })}
|
||||||
|
>
|
||||||
|
{text.trim() ? (
|
||||||
|
<MarkdownText
|
||||||
|
streaming={streaming}
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 text-[12.5px] italic text-muted-foreground/78",
|
||||||
|
"prose-p:my-1 prose-li:my-0.5",
|
||||||
|
"prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium",
|
||||||
|
"prose-headings:text-muted-foreground/88 prose-strong:text-muted-foreground",
|
||||||
|
"prose-h1:text-[15px] prose-h2:text-[13.5px] prose-h3:text-[12.5px] prose-h4:text-[12px]",
|
||||||
|
"prose-a:text-muted-foreground/95 prose-a:underline hover:prose-a:opacity-90",
|
||||||
|
"prose-code:text-[0.92em]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</MarkdownText>
|
||||||
|
) : null}
|
||||||
|
</ActivityStep>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReasoningMarker({ streaming }: { streaming: boolean }) {
|
||||||
|
const wasStreamingRef = useRef(streaming);
|
||||||
|
const [justCompleted, setJustCompleted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (wasStreamingRef.current && !streaming) {
|
||||||
|
setJustCompleted(true);
|
||||||
|
const timeout = window.setTimeout(() => setJustCompleted(false), 650);
|
||||||
|
wasStreamingRef.current = streaming;
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
wasStreamingRef.current = streaming;
|
||||||
|
return undefined;
|
||||||
|
}, [streaming]);
|
||||||
|
|
||||||
|
if (streaming) {
|
||||||
|
return (
|
||||||
|
<CircleDashed
|
||||||
|
data-testid="activity-reasoning-marker"
|
||||||
|
data-state="thinking"
|
||||||
|
className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground/55"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-testid="activity-reasoning-marker"
|
||||||
|
data-state="done"
|
||||||
|
className={cn(
|
||||||
|
"grid h-3.5 w-3.5 shrink-0 place-items-center rounded-full border border-emerald-500/28 text-emerald-500/78",
|
||||||
|
"bg-emerald-500/[0.035] transition-[border-color,background-color,box-shadow,transform] duration-300 ease-out",
|
||||||
|
justCompleted
|
||||||
|
&& "animate-in fade-in-0 zoom-in-75 shadow-[0_0_0_3px_rgba(16,185,129,0.10)] motion-reduce:animate-none",
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"h-2.5 w-2.5 stroke-[2.4]",
|
||||||
|
justCompleted && "animate-in fade-in-0 zoom-in-50 duration-300 motion-reduce:animate-none",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -12,7 +12,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
|||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
const menuContentClassName =
|
const menuContentClassName =
|
||||||
"z-50 max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem] overflow-x-hidden overflow-y-auto overscroll-contain rounded-[18px] border border-border/65 bg-popover/96 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]";
|
"z-50 max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem] overflow-x-hidden overflow-y-auto overscroll-contain rounded-[18px] border border-border/65 bg-popover/96 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur-xl scrollbar-thin scrollbar-track-transparent dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]";
|
||||||
|
|
||||||
const menuItemClassName =
|
const menuItemClassName =
|
||||||
"relative flex min-h-8 cursor-default select-none items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] outline-none transition-colors focus:bg-foreground/[0.055] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-white/[0.08]";
|
"relative flex min-h-8 cursor-default select-none items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] outline-none transition-colors focus:bg-foreground/[0.055] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-white/[0.08]";
|
||||||
|
|||||||
@ -110,6 +110,14 @@
|
|||||||
--tw-prose-lead: hsl(var(--foreground));
|
--tw-prose-lead: hsl(var(--foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown-content .contains-task-list {
|
||||||
|
@apply list-none pl-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content .task-list-item {
|
||||||
|
@apply list-none pl-0;
|
||||||
|
}
|
||||||
|
|
||||||
/* CJK-friendly line-height: prose paragraphs default to 1.625 which is
|
/* CJK-friendly line-height: prose paragraphs default to 1.625 which is
|
||||||
tight for Chinese/Japanese/Korean characters. Bump to 1.8 for better
|
tight for Chinese/Japanese/Korean characters. Bump to 1.8 for better
|
||||||
readability when the browser detects a CJK primary font. */
|
readability when the browser detects a CJK primary font. */
|
||||||
@ -185,6 +193,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes composer-status-strip-enter {
|
||||||
|
0% {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px) scale(0.985);
|
||||||
|
}
|
||||||
|
72% {
|
||||||
|
max-height: var(--composer-strip-max-height, 46px);
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-1px) scale(1.004);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
max-height: var(--composer-strip-max-height, 46px);
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes composer-status-strip-exit {
|
||||||
|
0% {
|
||||||
|
max-height: var(--composer-strip-max-height, 46px);
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(0.99);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.composer-status-strip {
|
||||||
|
transform-origin: bottom center;
|
||||||
|
will-change: max-height, opacity, transform;
|
||||||
|
}
|
||||||
|
.composer-status-strip[data-state="enter"] {
|
||||||
|
animation: composer-status-strip-enter 280ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
.composer-status-strip[data-state="exit"] {
|
||||||
|
animation: composer-status-strip-exit 180ms ease-in both;
|
||||||
|
}
|
||||||
|
@keyframes queued-prompt-row-enter {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.queued-prompt-row {
|
||||||
|
animation: queued-prompt-row-enter 220ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.composer-status-strip {
|
||||||
|
will-change: auto;
|
||||||
|
}
|
||||||
|
.composer-status-strip[data-state] {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.queued-prompt-row {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Goal halo: pale sky blue (not ``--primary``, which often reads as neutral gray). */
|
/** Goal halo: pale sky blue (not ``--primary``, which often reads as neutral gray). */
|
||||||
@keyframes goal-shell-glow-breathe {
|
@keyframes goal-shell-glow-breathe {
|
||||||
0%,
|
0%,
|
||||||
|
|||||||
@ -27,6 +27,11 @@ export interface AttachedImage {
|
|||||||
error?: AttachmentError;
|
error?: AttachmentError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RestoredReadyImage {
|
||||||
|
dataUrl: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Machine-readable rejection reasons surfaced as inline chip errors.
|
/** Machine-readable rejection reasons surfaced as inline chip errors.
|
||||||
*
|
*
|
||||||
* Callers localize these via the ``composer.imageRejected.*`` i18n table. */
|
* Callers localize these via the ``composer.imageRejected.*`` i18n table. */
|
||||||
@ -48,6 +53,27 @@ const ACCEPTED_MIMES: ReadonlySet<string> = new Set([
|
|||||||
"image/gif",
|
"image/gif",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
function dataUrlMime(dataUrl: string): string {
|
||||||
|
const match = /^data:([^;,]+)[;,]/.exec(dataUrl);
|
||||||
|
return match?.[1] || "image/png";
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataUrlToFile(dataUrl: string, name?: string): File {
|
||||||
|
const mime = dataUrlMime(dataUrl);
|
||||||
|
const fallbackName = `image.${mime.split("/")[1] || "png"}`;
|
||||||
|
try {
|
||||||
|
const [, base64 = ""] = dataUrl.split(",", 2);
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return new File([bytes], name || fallbackName, { type: mime });
|
||||||
|
} catch {
|
||||||
|
return new File([], name || fallbackName, { type: mime });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function uuid(): string {
|
function uuid(): string {
|
||||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||||
return (crypto as Crypto).randomUUID();
|
return (crypto as Crypto).randomUUID();
|
||||||
@ -84,6 +110,10 @@ export interface UseAttachedImagesApi {
|
|||||||
* successful submit — the optimistic bubble holds onto an independent
|
* successful submit — the optimistic bubble holds onto an independent
|
||||||
* ``data:`` URL so tearing down blob previews here is safe. */
|
* ``data:`` URL so tearing down blob previews here is safe. */
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
|
/** Restore already-encoded images, e.g. a queued composer draft moving back
|
||||||
|
* into the input. These entries are immediately sendable and use their
|
||||||
|
* ``data:`` URL as a stable preview. */
|
||||||
|
restoreReadyImages: (images: RestoredReadyImage[]) => void;
|
||||||
/** ``true`` when at least one image is still encoding — Send should wait. */
|
/** ``true`` when at least one image is still encoding — Send should wait. */
|
||||||
encoding: boolean;
|
encoding: boolean;
|
||||||
/** ``true`` when we've hit ``MAX_IMAGES_PER_MESSAGE``. */
|
/** ``true`` when we've hit ``MAX_IMAGES_PER_MESSAGE``. */
|
||||||
@ -211,6 +241,34 @@ export function useAttachedImages(): UseAttachedImagesApi {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const restoreReadyImages = useCallback((restored: RestoredReadyImage[]) => {
|
||||||
|
const toRestore = restored
|
||||||
|
.filter((img) => ACCEPTED_MIMES.has(dataUrlMime(img.dataUrl)))
|
||||||
|
.slice(0, MAX_IMAGES_PER_MESSAGE)
|
||||||
|
.map((img): AttachedImage => {
|
||||||
|
const file = dataUrlToFile(img.dataUrl, img.name);
|
||||||
|
return {
|
||||||
|
id: uuid(),
|
||||||
|
file,
|
||||||
|
previewUrl: img.dataUrl,
|
||||||
|
status: "ready",
|
||||||
|
dataUrl: img.dataUrl,
|
||||||
|
encodedBytes: file.size,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setImages((prev) => {
|
||||||
|
for (const img of prev) {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(img.previewUrl);
|
||||||
|
} catch {
|
||||||
|
// revoke is best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imagesRef.current = toRestore;
|
||||||
|
return toRestore;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Final safety net: revoke any outstanding blob URLs on unmount. Safe
|
// Final safety net: revoke any outstanding blob URLs on unmount. Safe
|
||||||
// under StrictMode double-invoke because revoked blob URLs are only
|
// under StrictMode double-invoke because revoked blob URLs are only
|
||||||
// referenced from in-hook chip state, which is rebuilt on remount.
|
// referenced from in-hook chip state, which is rebuilt on remount.
|
||||||
@ -229,5 +287,5 @@ export function useAttachedImages(): UseAttachedImagesApi {
|
|||||||
const encoding = images.some((img) => img.status === "encoding");
|
const encoding = images.some((img) => img.status === "encoding");
|
||||||
const full = images.length >= MAX_IMAGES_PER_MESSAGE;
|
const full = images.length >= MAX_IMAGES_PER_MESSAGE;
|
||||||
|
|
||||||
return { images, enqueue, remove, clear, encoding, full };
|
return { images, enqueue, remove, clear, restoreReadyImages, encoding, full };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -102,9 +102,19 @@
|
|||||||
"addConfiguration": "Add configuration",
|
"addConfiguration": "Add configuration",
|
||||||
"newConfiguration": "New model configuration",
|
"newConfiguration": "New model configuration",
|
||||||
"newConfigurationHelp": "Save a provider and model as a one-click option.",
|
"newConfigurationHelp": "Save a provider and model as a one-click option.",
|
||||||
"configurationName": "Name",
|
"configurationName": "Configuration name",
|
||||||
"configurationNameHelp": "Rename this saved model configuration.",
|
"configurationNameHelp": "Rename this saved model configuration.",
|
||||||
"configurationNamePlaceholder": "Fast writing"
|
"configurationNamePlaceholder": "Fast writing",
|
||||||
|
"searchModels": "Search or type model ID",
|
||||||
|
"useCustomModel": "Use",
|
||||||
|
"loadingModels": "Loading models...",
|
||||||
|
"searchCatalog": "Search provider catalog to choose a model.",
|
||||||
|
"modelsAvailable": "available",
|
||||||
|
"noModelResults": "No matching models.",
|
||||||
|
"loadFailed": "Model list unavailable.",
|
||||||
|
"unsupportedModelList": "Type a model ID manually.",
|
||||||
|
"providerNotConfigured": "Configure this provider before loading models.",
|
||||||
|
"autoProviderCustomOnly": "Auto provider mode uses custom model IDs."
|
||||||
},
|
},
|
||||||
"rows": {
|
"rows": {
|
||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
@ -117,7 +127,7 @@
|
|||||||
"gateway": "Gateway",
|
"gateway": "Gateway",
|
||||||
"restartState": "Restart state",
|
"restartState": "Restart state",
|
||||||
"pendingChanges": "Pending changes",
|
"pendingChanges": "Pending changes",
|
||||||
"currentModel": "Current model",
|
"currentModel": "Current configuration",
|
||||||
"selectedPreset": "Selected preset",
|
"selectedPreset": "Selected preset",
|
||||||
"presetModel": "Preset model",
|
"presetModel": "Preset model",
|
||||||
"density": "Density",
|
"density": "Density",
|
||||||
@ -155,7 +165,7 @@
|
|||||||
"provider": "Select the provider that should serve new model requests.",
|
"provider": "Select the provider that should serve new model requests.",
|
||||||
"model": "Set the default model name used by nanobot.",
|
"model": "Set the default model name used by nanobot.",
|
||||||
"configPath": "The gateway configuration file currently in use.",
|
"configPath": "The gateway configuration file currently in use.",
|
||||||
"currentModel": "Choose the model nanobot uses for new replies.",
|
"currentModel": "Used for new replies.",
|
||||||
"selectedModelProvider": "Set by the selected model.",
|
"selectedModelProvider": "Set by the selected model.",
|
||||||
"selectedModelValue": "Set by the selected model.",
|
"selectedModelValue": "Set by the selected model.",
|
||||||
"selectedPreset": "Named presets are read-only here; edit them in config.json.",
|
"selectedPreset": "Named presets are read-only here; edit them in config.json.",
|
||||||
@ -555,6 +565,13 @@
|
|||||||
"goalStateCloseAria": "Close goal",
|
"goalStateCloseAria": "Close goal",
|
||||||
"send": "Send message",
|
"send": "Send message",
|
||||||
"stop": "Stop response",
|
"stop": "Stop response",
|
||||||
|
"queued": {
|
||||||
|
"label": "Queued guidance",
|
||||||
|
"guide": "Guide",
|
||||||
|
"delete": "Delete guidance",
|
||||||
|
"edit": "Edit guidance",
|
||||||
|
"drag": "Drag to reorder"
|
||||||
|
},
|
||||||
"attachImage": "Attach image",
|
"attachImage": "Attach image",
|
||||||
"imageMode": {
|
"imageMode": {
|
||||||
"label": "Image Generation",
|
"label": "Image Generation",
|
||||||
|
|||||||
@ -131,7 +131,7 @@
|
|||||||
"workspacePath": "Workspace predeterminado",
|
"workspacePath": "Workspace predeterminado",
|
||||||
"localServiceAccess": "Local services",
|
"localServiceAccess": "Local services",
|
||||||
"webuiDefaultAccess": "Default access",
|
"webuiDefaultAccess": "Default access",
|
||||||
"currentModel": "Modelo actual",
|
"currentModel": "Configuración actual",
|
||||||
"brandLogos": "Logotipos de marca",
|
"brandLogos": "Logotipos de marca",
|
||||||
"cliAppsCatalog": "Catálogo de apps CLI",
|
"cliAppsCatalog": "Catálogo de apps CLI",
|
||||||
"cliAppsFilter": "Filtro de apps CLI",
|
"cliAppsFilter": "Filtro de apps CLI",
|
||||||
@ -167,7 +167,7 @@
|
|||||||
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
|
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
|
||||||
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
|
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
|
||||||
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
|
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
|
||||||
"currentModel": "Elige el modelo que nanobot usará para las próximas respuestas.",
|
"currentModel": "Elige la configuración de modelo que nanobot usará para las próximas respuestas.",
|
||||||
"selectedModelProvider": "Lo define el modelo seleccionado.",
|
"selectedModelProvider": "Lo define el modelo seleccionado.",
|
||||||
"selectedModelValue": "Lo define el modelo seleccionado.",
|
"selectedModelValue": "Lo define el modelo seleccionado.",
|
||||||
"brandLogos": "Los logotipos se cargan desde los dominios de las marcas con una reserva de icono local.",
|
"brandLogos": "Los logotipos se cargan desde los dominios de las marcas con una reserva de icono local.",
|
||||||
@ -295,9 +295,19 @@
|
|||||||
"addConfiguration": "Añadir configuración",
|
"addConfiguration": "Añadir configuración",
|
||||||
"newConfiguration": "Nueva configuración de modelo",
|
"newConfiguration": "Nueva configuración de modelo",
|
||||||
"newConfigurationHelp": "Guarda un proveedor y un modelo como una opción de un clic.",
|
"newConfigurationHelp": "Guarda un proveedor y un modelo como una opción de un clic.",
|
||||||
"configurationName": "Nombre",
|
"configurationName": "Nombre de configuración",
|
||||||
"configurationNameHelp": "Cambia el nombre de esta configuración de modelo guardada.",
|
"configurationNameHelp": "Cambia el nombre de esta configuración de modelo guardada.",
|
||||||
"configurationNamePlaceholder": "Escritura rápida"
|
"configurationNamePlaceholder": "Escritura rápida",
|
||||||
|
"searchModels": "Buscar o escribir ID de modelo",
|
||||||
|
"useCustomModel": "Usar",
|
||||||
|
"loadingModels": "Cargando modelos...",
|
||||||
|
"searchCatalog": "Busca en el catálogo del proveedor para elegir un modelo.",
|
||||||
|
"modelsAvailable": "disponibles",
|
||||||
|
"noModelResults": "No hay modelos coincidentes.",
|
||||||
|
"loadFailed": "Lista de modelos no disponible.",
|
||||||
|
"unsupportedModelList": "Escribe manualmente un ID de modelo.",
|
||||||
|
"providerNotConfigured": "Configura este proveedor antes de cargar modelos.",
|
||||||
|
"autoProviderCustomOnly": "El modo de proveedor automático usa ID de modelo personalizados."
|
||||||
},
|
},
|
||||||
"timezone": {
|
"timezone": {
|
||||||
"select": "Seleccionar zona horaria",
|
"select": "Seleccionar zona horaria",
|
||||||
@ -555,6 +565,13 @@
|
|||||||
"goalStateCloseAria": "Cerrar objetivo",
|
"goalStateCloseAria": "Cerrar objetivo",
|
||||||
"send": "Enviar mensaje",
|
"send": "Enviar mensaje",
|
||||||
"stop": "Detener respuesta",
|
"stop": "Detener respuesta",
|
||||||
|
"queued": {
|
||||||
|
"label": "Guía en cola",
|
||||||
|
"guide": "Guiar",
|
||||||
|
"delete": "Eliminar guía",
|
||||||
|
"edit": "Editar guía",
|
||||||
|
"drag": "Arrastrar para reordenar"
|
||||||
|
},
|
||||||
"attachImage": "Adjuntar imagen",
|
"attachImage": "Adjuntar imagen",
|
||||||
"imageMode": {
|
"imageMode": {
|
||||||
"label": "Generar imagen",
|
"label": "Generar imagen",
|
||||||
|
|||||||
@ -131,7 +131,7 @@
|
|||||||
"workspacePath": "Espace de travail par défaut",
|
"workspacePath": "Espace de travail par défaut",
|
||||||
"localServiceAccess": "Local services",
|
"localServiceAccess": "Local services",
|
||||||
"webuiDefaultAccess": "Default access",
|
"webuiDefaultAccess": "Default access",
|
||||||
"currentModel": "Modèle actuel",
|
"currentModel": "Configuration actuelle",
|
||||||
"brandLogos": "Logos de marque",
|
"brandLogos": "Logos de marque",
|
||||||
"cliAppsCatalog": "Catalogue d'apps CLI",
|
"cliAppsCatalog": "Catalogue d'apps CLI",
|
||||||
"cliAppsFilter": "Filtre des apps CLI",
|
"cliAppsFilter": "Filtre des apps CLI",
|
||||||
@ -167,7 +167,7 @@
|
|||||||
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
|
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
|
||||||
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
|
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
|
||||||
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
|
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
|
||||||
"currentModel": "Choisissez le modèle que nanobot utilisera pour les prochaines réponses.",
|
"currentModel": "Choisissez la configuration de modèle que nanobot utilisera pour les prochaines réponses.",
|
||||||
"selectedModelProvider": "Défini par le modèle sélectionné.",
|
"selectedModelProvider": "Défini par le modèle sélectionné.",
|
||||||
"selectedModelValue": "Défini par le modèle sélectionné.",
|
"selectedModelValue": "Défini par le modèle sélectionné.",
|
||||||
"brandLogos": "Les logos sont chargés depuis les domaines des marques avec une icône locale en secours.",
|
"brandLogos": "Les logos sont chargés depuis les domaines des marques avec une icône locale en secours.",
|
||||||
@ -295,9 +295,19 @@
|
|||||||
"addConfiguration": "Ajouter une configuration",
|
"addConfiguration": "Ajouter une configuration",
|
||||||
"newConfiguration": "Nouvelle configuration de modèle",
|
"newConfiguration": "Nouvelle configuration de modèle",
|
||||||
"newConfigurationHelp": "Enregistrez un fournisseur et un modèle comme option en un clic.",
|
"newConfigurationHelp": "Enregistrez un fournisseur et un modèle comme option en un clic.",
|
||||||
"configurationName": "Nom",
|
"configurationName": "Nom de la configuration",
|
||||||
"configurationNameHelp": "Renommez cette configuration de modèle enregistrée.",
|
"configurationNameHelp": "Renommez cette configuration de modèle enregistrée.",
|
||||||
"configurationNamePlaceholder": "Rédaction rapide"
|
"configurationNamePlaceholder": "Rédaction rapide",
|
||||||
|
"searchModels": "Rechercher ou saisir l’ID du modèle",
|
||||||
|
"useCustomModel": "Utiliser",
|
||||||
|
"loadingModels": "Chargement des modèles...",
|
||||||
|
"searchCatalog": "Recherchez dans le catalogue du fournisseur pour choisir un modèle.",
|
||||||
|
"modelsAvailable": "disponibles",
|
||||||
|
"noModelResults": "Aucun modèle correspondant.",
|
||||||
|
"loadFailed": "Liste des modèles indisponible.",
|
||||||
|
"unsupportedModelList": "Saisissez manuellement un ID de modèle.",
|
||||||
|
"providerNotConfigured": "Configurez ce fournisseur avant de charger les modèles.",
|
||||||
|
"autoProviderCustomOnly": "Le mode fournisseur automatique utilise des ID de modèle personnalisés."
|
||||||
},
|
},
|
||||||
"timezone": {
|
"timezone": {
|
||||||
"select": "Sélectionner un fuseau horaire",
|
"select": "Sélectionner un fuseau horaire",
|
||||||
@ -555,6 +565,13 @@
|
|||||||
"goalStateCloseAria": "Fermer l’objectif",
|
"goalStateCloseAria": "Fermer l’objectif",
|
||||||
"send": "Envoyer le message",
|
"send": "Envoyer le message",
|
||||||
"stop": "Arrêter la réponse",
|
"stop": "Arrêter la réponse",
|
||||||
|
"queued": {
|
||||||
|
"label": "Guidage en attente",
|
||||||
|
"guide": "Guider",
|
||||||
|
"delete": "Supprimer le guidage",
|
||||||
|
"edit": "Modifier le guidage",
|
||||||
|
"drag": "Faire glisser pour réordonner"
|
||||||
|
},
|
||||||
"attachImage": "Joindre une image",
|
"attachImage": "Joindre une image",
|
||||||
"imageMode": {
|
"imageMode": {
|
||||||
"label": "Génération d’image",
|
"label": "Génération d’image",
|
||||||
|
|||||||
@ -131,7 +131,7 @@
|
|||||||
"workspacePath": "Workspace default",
|
"workspacePath": "Workspace default",
|
||||||
"localServiceAccess": "Local services",
|
"localServiceAccess": "Local services",
|
||||||
"webuiDefaultAccess": "Default access",
|
"webuiDefaultAccess": "Default access",
|
||||||
"currentModel": "Model saat ini",
|
"currentModel": "Konfigurasi saat ini",
|
||||||
"brandLogos": "Logo merek",
|
"brandLogos": "Logo merek",
|
||||||
"cliAppsCatalog": "Katalog aplikasi CLI",
|
"cliAppsCatalog": "Katalog aplikasi CLI",
|
||||||
"cliAppsFilter": "Filter aplikasi CLI",
|
"cliAppsFilter": "Filter aplikasi CLI",
|
||||||
@ -167,7 +167,7 @@
|
|||||||
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
|
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
|
||||||
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
|
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
|
||||||
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
|
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
|
||||||
"currentModel": "Pilih model yang digunakan nanobot untuk balasan berikutnya.",
|
"currentModel": "Pilih konfigurasi model yang digunakan nanobot untuk balasan berikutnya.",
|
||||||
"selectedModelProvider": "Ditentukan oleh model yang dipilih.",
|
"selectedModelProvider": "Ditentukan oleh model yang dipilih.",
|
||||||
"selectedModelValue": "Ditentukan oleh model yang dipilih.",
|
"selectedModelValue": "Ditentukan oleh model yang dipilih.",
|
||||||
"brandLogos": "Logo dimuat dari domain merek dengan ikon lokal sebagai cadangan.",
|
"brandLogos": "Logo dimuat dari domain merek dengan ikon lokal sebagai cadangan.",
|
||||||
@ -295,9 +295,19 @@
|
|||||||
"addConfiguration": "Tambah konfigurasi",
|
"addConfiguration": "Tambah konfigurasi",
|
||||||
"newConfiguration": "Konfigurasi model baru",
|
"newConfiguration": "Konfigurasi model baru",
|
||||||
"newConfigurationHelp": "Simpan penyedia dan model sebagai opsi sekali klik.",
|
"newConfigurationHelp": "Simpan penyedia dan model sebagai opsi sekali klik.",
|
||||||
"configurationName": "Nama",
|
"configurationName": "Nama konfigurasi",
|
||||||
"configurationNameHelp": "Ganti nama konfigurasi model yang tersimpan ini.",
|
"configurationNameHelp": "Ganti nama konfigurasi model yang tersimpan ini.",
|
||||||
"configurationNamePlaceholder": "Penulisan cepat"
|
"configurationNamePlaceholder": "Penulisan cepat",
|
||||||
|
"searchModels": "Cari atau ketik ID model",
|
||||||
|
"useCustomModel": "Gunakan",
|
||||||
|
"loadingModels": "Memuat model...",
|
||||||
|
"searchCatalog": "Cari katalog penyedia untuk memilih model.",
|
||||||
|
"modelsAvailable": "tersedia",
|
||||||
|
"noModelResults": "Tidak ada model yang cocok.",
|
||||||
|
"loadFailed": "Daftar model tidak tersedia.",
|
||||||
|
"unsupportedModelList": "Ketik ID model secara manual.",
|
||||||
|
"providerNotConfigured": "Konfigurasikan penyedia ini sebelum memuat model.",
|
||||||
|
"autoProviderCustomOnly": "Mode penyedia otomatis menggunakan ID model khusus."
|
||||||
},
|
},
|
||||||
"timezone": {
|
"timezone": {
|
||||||
"select": "Pilih zona waktu",
|
"select": "Pilih zona waktu",
|
||||||
@ -555,6 +565,13 @@
|
|||||||
"goalStateCloseAria": "Tutup tujuan",
|
"goalStateCloseAria": "Tutup tujuan",
|
||||||
"send": "Kirim pesan",
|
"send": "Kirim pesan",
|
||||||
"stop": "Hentikan respons",
|
"stop": "Hentikan respons",
|
||||||
|
"queued": {
|
||||||
|
"label": "Panduan antrean",
|
||||||
|
"guide": "Pandu",
|
||||||
|
"delete": "Hapus panduan",
|
||||||
|
"edit": "Edit panduan",
|
||||||
|
"drag": "Seret untuk mengurutkan"
|
||||||
|
},
|
||||||
"attachImage": "Lampirkan gambar",
|
"attachImage": "Lampirkan gambar",
|
||||||
"imageMode": {
|
"imageMode": {
|
||||||
"label": "Buat gambar",
|
"label": "Buat gambar",
|
||||||
|
|||||||
@ -131,7 +131,7 @@
|
|||||||
"workspacePath": "デフォルトワークスペース",
|
"workspacePath": "デフォルトワークスペース",
|
||||||
"localServiceAccess": "Local services",
|
"localServiceAccess": "Local services",
|
||||||
"webuiDefaultAccess": "Default access",
|
"webuiDefaultAccess": "Default access",
|
||||||
"currentModel": "現在のモデル",
|
"currentModel": "現在の設定",
|
||||||
"brandLogos": "ブランドロゴ",
|
"brandLogos": "ブランドロゴ",
|
||||||
"cliAppsCatalog": "CLI アプリカタログ",
|
"cliAppsCatalog": "CLI アプリカタログ",
|
||||||
"cliAppsFilter": "CLI アプリフィルター",
|
"cliAppsFilter": "CLI アプリフィルター",
|
||||||
@ -167,7 +167,7 @@
|
|||||||
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
|
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
|
||||||
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
|
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
|
||||||
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
|
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
|
||||||
"currentModel": "今後の返信で nanobot が使用するモデルを選択します。",
|
"currentModel": "今後の返信で nanobot が使用するモデル設定を選択します。",
|
||||||
"selectedModelProvider": "選択したモデルによって設定されます。",
|
"selectedModelProvider": "選択したモデルによって設定されます。",
|
||||||
"selectedModelValue": "選択したモデルによって設定されます。",
|
"selectedModelValue": "選択したモデルによって設定されます。",
|
||||||
"brandLogos": "ロゴはブランドのドメインから読み込まれ、ローカルアイコンにフォールバックします。",
|
"brandLogos": "ロゴはブランドのドメインから読み込まれ、ローカルアイコンにフォールバックします。",
|
||||||
@ -295,9 +295,19 @@
|
|||||||
"addConfiguration": "設定を追加",
|
"addConfiguration": "設定を追加",
|
||||||
"newConfiguration": "新しいモデル設定",
|
"newConfiguration": "新しいモデル設定",
|
||||||
"newConfigurationHelp": "プロバイダーとモデルをワンクリックの選択肢として保存します。",
|
"newConfigurationHelp": "プロバイダーとモデルをワンクリックの選択肢として保存します。",
|
||||||
"configurationName": "名前",
|
"configurationName": "設定名",
|
||||||
"configurationNameHelp": "保存済みのモデル設定の名前を変更します。",
|
"configurationNameHelp": "保存済みのモデル設定の名前を変更します。",
|
||||||
"configurationNamePlaceholder": "高速ライティング"
|
"configurationNamePlaceholder": "高速ライティング",
|
||||||
|
"searchModels": "検索またはモデル ID を入力",
|
||||||
|
"useCustomModel": "使用",
|
||||||
|
"loadingModels": "モデルを読み込み中...",
|
||||||
|
"searchCatalog": "プロバイダーのカタログを検索してモデルを選択します。",
|
||||||
|
"modelsAvailable": "件利用可能",
|
||||||
|
"noModelResults": "一致するモデルはありません。",
|
||||||
|
"loadFailed": "モデル一覧を利用できません。",
|
||||||
|
"unsupportedModelList": "モデル ID を手動で入力してください。",
|
||||||
|
"providerNotConfigured": "モデルを読み込む前にこのプロバイダーを設定してください。",
|
||||||
|
"autoProviderCustomOnly": "自動プロバイダーモードではカスタムモデル ID を使用します。"
|
||||||
},
|
},
|
||||||
"timezone": {
|
"timezone": {
|
||||||
"select": "タイムゾーンを選択",
|
"select": "タイムゾーンを選択",
|
||||||
@ -555,6 +565,13 @@
|
|||||||
"goalStateCloseAria": "目標を閉じる",
|
"goalStateCloseAria": "目標を閉じる",
|
||||||
"send": "メッセージを送信",
|
"send": "メッセージを送信",
|
||||||
"stop": "応答を停止",
|
"stop": "応答を停止",
|
||||||
|
"queued": {
|
||||||
|
"label": "保留中のガイド",
|
||||||
|
"guide": "ガイド",
|
||||||
|
"delete": "ガイドを削除",
|
||||||
|
"edit": "ガイドを編集",
|
||||||
|
"drag": "ドラッグして並べ替え"
|
||||||
|
},
|
||||||
"attachImage": "画像を添付",
|
"attachImage": "画像を添付",
|
||||||
"imageMode": {
|
"imageMode": {
|
||||||
"label": "画像生成",
|
"label": "画像生成",
|
||||||
|
|||||||
@ -131,7 +131,7 @@
|
|||||||
"workspacePath": "기본 작업공간",
|
"workspacePath": "기본 작업공간",
|
||||||
"localServiceAccess": "Local services",
|
"localServiceAccess": "Local services",
|
||||||
"webuiDefaultAccess": "Default access",
|
"webuiDefaultAccess": "Default access",
|
||||||
"currentModel": "현재 모델",
|
"currentModel": "현재 구성",
|
||||||
"brandLogos": "브랜드 로고",
|
"brandLogos": "브랜드 로고",
|
||||||
"cliAppsCatalog": "CLI 앱 카탈로그",
|
"cliAppsCatalog": "CLI 앱 카탈로그",
|
||||||
"cliAppsFilter": "CLI 앱 필터",
|
"cliAppsFilter": "CLI 앱 필터",
|
||||||
@ -167,7 +167,7 @@
|
|||||||
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
|
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
|
||||||
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
|
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
|
||||||
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
|
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
|
||||||
"currentModel": "nanobot이 새 답변에 사용할 모델을 선택합니다.",
|
"currentModel": "nanobot이 새 답변에 사용할 모델 구성을 선택합니다.",
|
||||||
"selectedModelProvider": "선택한 모델에서 설정됩니다.",
|
"selectedModelProvider": "선택한 모델에서 설정됩니다.",
|
||||||
"selectedModelValue": "선택한 모델에서 설정됩니다.",
|
"selectedModelValue": "선택한 모델에서 설정됩니다.",
|
||||||
"brandLogos": "로고는 브랜드 도메인에서 불러오며, 실패하면 로컬 아이콘을 사용합니다.",
|
"brandLogos": "로고는 브랜드 도메인에서 불러오며, 실패하면 로컬 아이콘을 사용합니다.",
|
||||||
@ -295,9 +295,19 @@
|
|||||||
"addConfiguration": "구성 추가",
|
"addConfiguration": "구성 추가",
|
||||||
"newConfiguration": "새 모델 구성",
|
"newConfiguration": "새 모델 구성",
|
||||||
"newConfigurationHelp": "제공자와 모델을 한 번에 선택할 수 있는 옵션으로 저장합니다.",
|
"newConfigurationHelp": "제공자와 모델을 한 번에 선택할 수 있는 옵션으로 저장합니다.",
|
||||||
"configurationName": "이름",
|
"configurationName": "구성 이름",
|
||||||
"configurationNameHelp": "저장된 모델 구성의 이름을 변경합니다.",
|
"configurationNameHelp": "저장된 모델 구성의 이름을 변경합니다.",
|
||||||
"configurationNamePlaceholder": "빠른 글쓰기"
|
"configurationNamePlaceholder": "빠른 글쓰기",
|
||||||
|
"searchModels": "검색하거나 모델 ID 입력",
|
||||||
|
"useCustomModel": "사용",
|
||||||
|
"loadingModels": "모델을 불러오는 중...",
|
||||||
|
"searchCatalog": "제공자 카탈로그를 검색해 모델을 선택하세요.",
|
||||||
|
"modelsAvailable": "개 사용 가능",
|
||||||
|
"noModelResults": "일치하는 모델이 없습니다.",
|
||||||
|
"loadFailed": "모델 목록을 사용할 수 없습니다.",
|
||||||
|
"unsupportedModelList": "모델 ID를 직접 입력하세요.",
|
||||||
|
"providerNotConfigured": "모델을 불러오기 전에 이 제공자를 설정하세요.",
|
||||||
|
"autoProviderCustomOnly": "자동 제공자 모드는 사용자 지정 모델 ID를 사용합니다."
|
||||||
},
|
},
|
||||||
"timezone": {
|
"timezone": {
|
||||||
"select": "시간대 선택",
|
"select": "시간대 선택",
|
||||||
@ -555,6 +565,13 @@
|
|||||||
"goalStateCloseAria": "목표 닫기",
|
"goalStateCloseAria": "목표 닫기",
|
||||||
"send": "메시지 보내기",
|
"send": "메시지 보내기",
|
||||||
"stop": "응답 중지",
|
"stop": "응답 중지",
|
||||||
|
"queued": {
|
||||||
|
"label": "대기 중인 안내",
|
||||||
|
"guide": "안내",
|
||||||
|
"delete": "안내 삭제",
|
||||||
|
"edit": "안내 수정",
|
||||||
|
"drag": "드래그하여 순서 변경"
|
||||||
|
},
|
||||||
"attachImage": "이미지 첨부",
|
"attachImage": "이미지 첨부",
|
||||||
"imageMode": {
|
"imageMode": {
|
||||||
"label": "이미지 생성",
|
"label": "이미지 생성",
|
||||||
|
|||||||
@ -131,7 +131,7 @@
|
|||||||
"workspacePath": "Workspace mặc định",
|
"workspacePath": "Workspace mặc định",
|
||||||
"localServiceAccess": "Local services",
|
"localServiceAccess": "Local services",
|
||||||
"webuiDefaultAccess": "Default access",
|
"webuiDefaultAccess": "Default access",
|
||||||
"currentModel": "Mô hình hiện tại",
|
"currentModel": "Cấu hình hiện tại",
|
||||||
"brandLogos": "Logo thương hiệu",
|
"brandLogos": "Logo thương hiệu",
|
||||||
"cliAppsCatalog": "Danh mục ứng dụng CLI",
|
"cliAppsCatalog": "Danh mục ứng dụng CLI",
|
||||||
"cliAppsFilter": "Bộ lọc ứng dụng CLI",
|
"cliAppsFilter": "Bộ lọc ứng dụng CLI",
|
||||||
@ -167,7 +167,7 @@
|
|||||||
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
|
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
|
||||||
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
|
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
|
||||||
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
|
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
|
||||||
"currentModel": "Chọn mô hình nanobot dùng cho các câu trả lời mới.",
|
"currentModel": "Chọn cấu hình mô hình nanobot dùng cho các câu trả lời mới.",
|
||||||
"selectedModelProvider": "Được đặt bởi mô hình đã chọn.",
|
"selectedModelProvider": "Được đặt bởi mô hình đã chọn.",
|
||||||
"selectedModelValue": "Được đặt bởi mô hình đã chọn.",
|
"selectedModelValue": "Được đặt bởi mô hình đã chọn.",
|
||||||
"brandLogos": "Logo được tải từ tên miền thương hiệu, có biểu tượng cục bộ làm dự phòng.",
|
"brandLogos": "Logo được tải từ tên miền thương hiệu, có biểu tượng cục bộ làm dự phòng.",
|
||||||
@ -295,9 +295,19 @@
|
|||||||
"addConfiguration": "Thêm cấu hình",
|
"addConfiguration": "Thêm cấu hình",
|
||||||
"newConfiguration": "Cấu hình mô hình mới",
|
"newConfiguration": "Cấu hình mô hình mới",
|
||||||
"newConfigurationHelp": "Lưu nhà cung cấp và mô hình thành một lựa chọn một lần nhấp.",
|
"newConfigurationHelp": "Lưu nhà cung cấp và mô hình thành một lựa chọn một lần nhấp.",
|
||||||
"configurationName": "Tên",
|
"configurationName": "Tên cấu hình",
|
||||||
"configurationNameHelp": "Đổi tên cấu hình mô hình đã lưu này.",
|
"configurationNameHelp": "Đổi tên cấu hình mô hình đã lưu này.",
|
||||||
"configurationNamePlaceholder": "Viết nhanh"
|
"configurationNamePlaceholder": "Viết nhanh",
|
||||||
|
"searchModels": "Tìm hoặc nhập ID mô hình",
|
||||||
|
"useCustomModel": "Dùng",
|
||||||
|
"loadingModels": "Đang tải mô hình...",
|
||||||
|
"searchCatalog": "Tìm trong danh mục nhà cung cấp để chọn mô hình.",
|
||||||
|
"modelsAvailable": "khả dụng",
|
||||||
|
"noModelResults": "Không có mô hình phù hợp.",
|
||||||
|
"loadFailed": "Không tải được danh sách mô hình.",
|
||||||
|
"unsupportedModelList": "Nhập ID mô hình thủ công.",
|
||||||
|
"providerNotConfigured": "Cấu hình nhà cung cấp này trước khi tải mô hình.",
|
||||||
|
"autoProviderCustomOnly": "Chế độ nhà cung cấp tự động dùng ID mô hình tùy chỉnh."
|
||||||
},
|
},
|
||||||
"timezone": {
|
"timezone": {
|
||||||
"select": "Chọn múi giờ",
|
"select": "Chọn múi giờ",
|
||||||
@ -555,6 +565,13 @@
|
|||||||
"goalStateCloseAria": "Đóng mục tiêu",
|
"goalStateCloseAria": "Đóng mục tiêu",
|
||||||
"send": "Gửi tin nhắn",
|
"send": "Gửi tin nhắn",
|
||||||
"stop": "Dừng phản hồi",
|
"stop": "Dừng phản hồi",
|
||||||
|
"queued": {
|
||||||
|
"label": "Hướng dẫn đang chờ",
|
||||||
|
"guide": "Hướng dẫn",
|
||||||
|
"delete": "Xóa hướng dẫn",
|
||||||
|
"edit": "Sửa hướng dẫn",
|
||||||
|
"drag": "Kéo để sắp xếp"
|
||||||
|
},
|
||||||
"attachImage": "Đính kèm ảnh",
|
"attachImage": "Đính kèm ảnh",
|
||||||
"imageMode": {
|
"imageMode": {
|
||||||
"label": "Tạo ảnh",
|
"label": "Tạo ảnh",
|
||||||
|
|||||||
@ -102,9 +102,19 @@
|
|||||||
"addConfiguration": "添加配置",
|
"addConfiguration": "添加配置",
|
||||||
"newConfiguration": "新建模型配置",
|
"newConfiguration": "新建模型配置",
|
||||||
"newConfigurationHelp": "把服务商和模型保存为一个可直接切换的选项。",
|
"newConfigurationHelp": "把服务商和模型保存为一个可直接切换的选项。",
|
||||||
"configurationName": "名称",
|
"configurationName": "配置名称",
|
||||||
"configurationNameHelp": "重命名这个已保存的模型配置。",
|
"configurationNameHelp": "重命名这个已保存的模型配置。",
|
||||||
"configurationNamePlaceholder": "快速写作"
|
"configurationNamePlaceholder": "快速写作",
|
||||||
|
"searchModels": "搜索或输入模型 ID",
|
||||||
|
"useCustomModel": "使用",
|
||||||
|
"loadingModels": "正在加载模型...",
|
||||||
|
"searchCatalog": "搜索服务商目录来选择模型。",
|
||||||
|
"modelsAvailable": "个可用",
|
||||||
|
"noModelResults": "没有匹配的模型。",
|
||||||
|
"loadFailed": "模型列表暂不可用。",
|
||||||
|
"unsupportedModelList": "手动输入模型 ID。",
|
||||||
|
"providerNotConfigured": "先配置这个服务商再加载模型。",
|
||||||
|
"autoProviderCustomOnly": "自动服务商模式使用自定义模型 ID。"
|
||||||
},
|
},
|
||||||
"rows": {
|
"rows": {
|
||||||
"theme": "主题",
|
"theme": "主题",
|
||||||
@ -117,7 +127,7 @@
|
|||||||
"gateway": "网关",
|
"gateway": "网关",
|
||||||
"restartState": "重启状态",
|
"restartState": "重启状态",
|
||||||
"pendingChanges": "待处理更改",
|
"pendingChanges": "待处理更改",
|
||||||
"currentModel": "当前模型",
|
"currentModel": "当前配置",
|
||||||
"selectedPreset": "选中的预设",
|
"selectedPreset": "选中的预设",
|
||||||
"presetModel": "预设模型",
|
"presetModel": "预设模型",
|
||||||
"density": "密度",
|
"density": "密度",
|
||||||
@ -155,7 +165,7 @@
|
|||||||
"provider": "选择新模型请求使用的服务商。",
|
"provider": "选择新模型请求使用的服务商。",
|
||||||
"model": "设置 nanobot 默认使用的模型名称。",
|
"model": "设置 nanobot 默认使用的模型名称。",
|
||||||
"configPath": "当前网关正在使用的配置文件。",
|
"configPath": "当前网关正在使用的配置文件。",
|
||||||
"currentModel": "选择 nanobot 接下来回复时使用的模型。",
|
"currentModel": "选择 nanobot 接下来回复时使用的模型配置。",
|
||||||
"selectedModelProvider": "由当前模型决定。",
|
"selectedModelProvider": "由当前模型决定。",
|
||||||
"selectedModelValue": "由当前模型决定。",
|
"selectedModelValue": "由当前模型决定。",
|
||||||
"selectedPreset": "命名预设在这里只读;需要编辑时请改 config.json。",
|
"selectedPreset": "命名预设在这里只读;需要编辑时请改 config.json。",
|
||||||
@ -554,6 +564,13 @@
|
|||||||
"goalStateSheetTitle": "目标",
|
"goalStateSheetTitle": "目标",
|
||||||
"send": "发送消息",
|
"send": "发送消息",
|
||||||
"stop": "停止响应",
|
"stop": "停止响应",
|
||||||
|
"queued": {
|
||||||
|
"label": "待引导提示",
|
||||||
|
"guide": "引导",
|
||||||
|
"delete": "删除引导",
|
||||||
|
"edit": "编辑引导",
|
||||||
|
"drag": "拖动排序"
|
||||||
|
},
|
||||||
"attachImage": "添加图片",
|
"attachImage": "添加图片",
|
||||||
"imageMode": {
|
"imageMode": {
|
||||||
"label": "图片生成",
|
"label": "图片生成",
|
||||||
|
|||||||
@ -131,7 +131,7 @@
|
|||||||
"workspacePath": "預設工作區",
|
"workspacePath": "預設工作區",
|
||||||
"localServiceAccess": "Local services",
|
"localServiceAccess": "Local services",
|
||||||
"webuiDefaultAccess": "Default access",
|
"webuiDefaultAccess": "Default access",
|
||||||
"currentModel": "目前模型",
|
"currentModel": "目前設定",
|
||||||
"brandLogos": "品牌標誌",
|
"brandLogos": "品牌標誌",
|
||||||
"cliAppsCatalog": "CLI 應用目錄",
|
"cliAppsCatalog": "CLI 應用目錄",
|
||||||
"cliAppsFilter": "CLI 應用篩選",
|
"cliAppsFilter": "CLI 應用篩選",
|
||||||
@ -167,7 +167,7 @@
|
|||||||
"localServiceAccess": "允許完全存取模式下的 shell 命令存取 localhost 服務。",
|
"localServiceAccess": "允許完全存取模式下的 shell 命令存取 localhost 服務。",
|
||||||
"webuiDefaultAccess": "用於沒有單獨選擇權限的網頁端對話。",
|
"webuiDefaultAccess": "用於沒有單獨選擇權限的網頁端對話。",
|
||||||
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
|
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
|
||||||
"currentModel": "選擇 nanobot 接下來回覆時使用的模型。",
|
"currentModel": "選擇 nanobot 接下來回覆時使用的模型設定。",
|
||||||
"selectedModelProvider": "由目前模型決定。",
|
"selectedModelProvider": "由目前模型決定。",
|
||||||
"selectedModelValue": "由目前模型決定。",
|
"selectedModelValue": "由目前模型決定。",
|
||||||
"brandLogos": "標誌會從品牌網域載入,並提供本地圖示作為備援。",
|
"brandLogos": "標誌會從品牌網域載入,並提供本地圖示作為備援。",
|
||||||
@ -295,9 +295,19 @@
|
|||||||
"addConfiguration": "新增設定",
|
"addConfiguration": "新增設定",
|
||||||
"newConfiguration": "新增模型設定",
|
"newConfiguration": "新增模型設定",
|
||||||
"newConfigurationHelp": "把服務商和模型儲存為一個可直接切換的選項。",
|
"newConfigurationHelp": "把服務商和模型儲存為一個可直接切換的選項。",
|
||||||
"configurationName": "名稱",
|
"configurationName": "設定名稱",
|
||||||
"configurationNameHelp": "重新命名這個已儲存的模型配置。",
|
"configurationNameHelp": "重新命名這個已儲存的模型配置。",
|
||||||
"configurationNamePlaceholder": "快速寫作"
|
"configurationNamePlaceholder": "快速寫作",
|
||||||
|
"searchModels": "搜尋或輸入模型 ID",
|
||||||
|
"useCustomModel": "使用",
|
||||||
|
"loadingModels": "正在載入模型...",
|
||||||
|
"searchCatalog": "搜尋服務商目錄來選擇模型。",
|
||||||
|
"modelsAvailable": "個可用",
|
||||||
|
"noModelResults": "沒有符合的模型。",
|
||||||
|
"loadFailed": "模型列表暫不可用。",
|
||||||
|
"unsupportedModelList": "手動輸入模型 ID。",
|
||||||
|
"providerNotConfigured": "先設定這個服務商再載入模型。",
|
||||||
|
"autoProviderCustomOnly": "自動服務商模式使用自訂模型 ID。"
|
||||||
},
|
},
|
||||||
"timezone": {
|
"timezone": {
|
||||||
"select": "選擇時區",
|
"select": "選擇時區",
|
||||||
@ -555,6 +565,13 @@
|
|||||||
"goalStateCloseAria": "關閉目標",
|
"goalStateCloseAria": "關閉目標",
|
||||||
"send": "送出訊息",
|
"send": "送出訊息",
|
||||||
"stop": "停止回覆",
|
"stop": "停止回覆",
|
||||||
|
"queued": {
|
||||||
|
"label": "待引導提示",
|
||||||
|
"guide": "引導",
|
||||||
|
"delete": "刪除引導",
|
||||||
|
"edit": "編輯引導",
|
||||||
|
"drag": "拖曳排序"
|
||||||
|
},
|
||||||
"attachImage": "附加圖片",
|
"attachImage": "附加圖片",
|
||||||
"imageMode": {
|
"imageMode": {
|
||||||
"label": "圖片生成",
|
"label": "圖片生成",
|
||||||
|
|||||||
297
webui/src/lib/activity-timeline.ts
Normal file
297
webui/src/lib/activity-timeline.ts
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
import { toMediaAttachment } from "@/lib/media";
|
||||||
|
import type { ToolProgressEvent, UIMediaAttachment, UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
|
export type ActivityItemType = "reasoning" | "tool" | "cli" | "mcp" | "file_edit" | "media";
|
||||||
|
export type ActivityStepStatus = "pending" | "running" | "done" | "error";
|
||||||
|
export type ActivityStepSource = "reasoning" | "tool" | "web" | "browser" | "shell" | "mcp" | "file" | "media";
|
||||||
|
|
||||||
|
export interface ActivityItem {
|
||||||
|
type: ActivityItemType;
|
||||||
|
message: UIMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityEvidence {
|
||||||
|
id: string;
|
||||||
|
attachment: UIMediaAttachment;
|
||||||
|
caption?: string;
|
||||||
|
source: ActivityStepSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityStepItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
detail?: string;
|
||||||
|
status: ActivityStepStatus;
|
||||||
|
source: ActivityStepSource;
|
||||||
|
preview?: ActivityEvidence[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityGroup {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
source: ActivityStepSource;
|
||||||
|
steps: ActivityStepItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TurnUnit =
|
||||||
|
| { type: "activity"; messages: UIMessage[]; items: ActivityItem[]; turnLatencyMs?: number }
|
||||||
|
| { type: "message"; message: UIMessage };
|
||||||
|
|
||||||
|
export function isReasoningOnlyAssistant(message: UIMessage): boolean {
|
||||||
|
if (message.role !== "assistant" || message.kind === "trace") return false;
|
||||||
|
if (message.content.trim().length > 0) return false;
|
||||||
|
return !!(message.reasoning?.length || message.reasoningStreaming || message.isStreaming);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAgentActivityMember(message: UIMessage): boolean {
|
||||||
|
return isReasoningOnlyAssistant(message) || message.kind === "trace";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeActivityTimeline(messages: UIMessage[]): TurnUnit[] {
|
||||||
|
const units: TurnUnit[] = [];
|
||||||
|
let turnMessages: UIMessage[] = [];
|
||||||
|
|
||||||
|
const flushTurn = () => {
|
||||||
|
if (turnMessages.length === 0) return;
|
||||||
|
|
||||||
|
const activityMessages: UIMessage[] = [];
|
||||||
|
const visibleMessages: UIMessage[] = [];
|
||||||
|
|
||||||
|
for (const message of turnMessages) {
|
||||||
|
if (isAgentActivityMember(message)) {
|
||||||
|
activityMessages.push(message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assistantHasInlineReasoning(message)) {
|
||||||
|
activityMessages.push(reasoningOnlyMessageFromAnswer(message));
|
||||||
|
visibleMessages.push(stripInlineReasoning(message));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleMessages.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
pushActivityUnits(units, activityMessages, visibleMessages);
|
||||||
|
|
||||||
|
for (const message of visibleMessages) {
|
||||||
|
units.push({ type: "message", message });
|
||||||
|
}
|
||||||
|
|
||||||
|
turnMessages = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.role === "user") {
|
||||||
|
flushTurn();
|
||||||
|
units.push({ type: "message", message });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
turnMessages.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
flushTurn();
|
||||||
|
return units;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushActivityUnits(units: TurnUnit[], activityMessages: UIMessage[], visibleMessages: UIMessage[]) {
|
||||||
|
let runMessages: UIMessage[] = [];
|
||||||
|
let runBucket: "file" | "other" | undefined;
|
||||||
|
|
||||||
|
const flushRun = () => {
|
||||||
|
if (!runMessages.length) return;
|
||||||
|
units.push({
|
||||||
|
type: "activity",
|
||||||
|
messages: runMessages,
|
||||||
|
items: runMessages.flatMap(activityItemsForMessage),
|
||||||
|
turnLatencyMs: activityTurnLatencyMs(runMessages, visibleMessages),
|
||||||
|
});
|
||||||
|
runMessages = [];
|
||||||
|
runBucket = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const message of activityMessages) {
|
||||||
|
const bucket = isFileEditActivityMessage(message) ? "file" : "other";
|
||||||
|
if (runBucket && bucket !== runBucket) {
|
||||||
|
flushRun();
|
||||||
|
}
|
||||||
|
runBucket = bucket;
|
||||||
|
runMessages.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
flushRun();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFileEditActivityMessage(message: UIMessage): boolean {
|
||||||
|
return message.kind === "trace" && !!message.fileEdits?.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
latencyMs: message.latencyMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripInlineReasoning(message: UIMessage): UIMessage {
|
||||||
|
const next = { ...message };
|
||||||
|
delete next.reasoning;
|
||||||
|
delete next.reasoningStreaming;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activityItemsForMessage(message: UIMessage): ActivityItem[] {
|
||||||
|
if (isReasoningOnlyAssistant(message)) {
|
||||||
|
return [{ type: "reasoning", message }];
|
||||||
|
}
|
||||||
|
if (message.kind !== "trace") return [];
|
||||||
|
|
||||||
|
const items: ActivityItem[] = [];
|
||||||
|
if (message.fileEdits?.length) {
|
||||||
|
items.push({ type: "file_edit", message });
|
||||||
|
}
|
||||||
|
for (const event of message.toolEvents ?? []) {
|
||||||
|
const name = String(event.name ?? "").toLowerCase();
|
||||||
|
if (name === "run_cli_app") {
|
||||||
|
items.push({ type: "cli", message });
|
||||||
|
} else if (name === "mcp") {
|
||||||
|
items.push({ type: "mcp", message });
|
||||||
|
} else {
|
||||||
|
items.push({ type: "tool", message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (items.length === 0 && (message.traces?.length || message.content.trim())) {
|
||||||
|
items.push({ type: "tool", message });
|
||||||
|
}
|
||||||
|
if (message.media?.length) {
|
||||||
|
items.push({ type: "media", message });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activityTurnLatencyMs(activityMessages: UIMessage[], visibleMessages: UIMessage[]): number | undefined {
|
||||||
|
for (let i = activityMessages.length - 1; i >= 0; i -= 1) {
|
||||||
|
const latency = activityMessages[i].latencyMs;
|
||||||
|
if (isValidLatency(latency)) return latency;
|
||||||
|
}
|
||||||
|
for (let i = visibleMessages.length - 1; i >= 0; i -= 1) {
|
||||||
|
const latency = visibleMessages[i].latencyMs;
|
||||||
|
if (isValidLatency(latency)) return latency;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidLatency(value: unknown): value is number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activityEvidenceFromToolEvent(event: ToolProgressEvent): ActivityEvidence[] {
|
||||||
|
const source = activitySourceFromToolName(toolEventName(event));
|
||||||
|
const evidence: ActivityEvidence[] = [];
|
||||||
|
const extras = [
|
||||||
|
...unknownList((event as { embeds?: unknown }).embeds),
|
||||||
|
...unknownList((event as { files?: unknown }).files),
|
||||||
|
];
|
||||||
|
extras.forEach((value, index) => {
|
||||||
|
const attachment = mediaAttachmentFromUnknown(value);
|
||||||
|
if (!attachment) return;
|
||||||
|
evidence.push({
|
||||||
|
id: `${event.call_id || toolEventName(event) || "tool"}:${index}:${attachment.url || attachment.name || attachment.kind}`,
|
||||||
|
attachment,
|
||||||
|
caption: attachment.name,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return evidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activityEvidenceFromMessageMedia(message: UIMessage): ActivityEvidence[] {
|
||||||
|
return (message.media ?? []).map((attachment, index) => ({
|
||||||
|
id: `${message.id}:media:${index}:${attachment.url || attachment.name || attachment.kind}`,
|
||||||
|
attachment,
|
||||||
|
caption: attachment.name,
|
||||||
|
source: "media",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function unknownList(value: unknown): unknown[] {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolEventName(event: ToolProgressEvent): string {
|
||||||
|
return typeof (event as { function?: { name?: unknown } }).function?.name === "string"
|
||||||
|
? String((event as { function?: { name?: unknown } }).function?.name)
|
||||||
|
: typeof event.name === "string"
|
||||||
|
? event.name
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function activitySourceFromToolName(name: string): ActivityStepSource {
|
||||||
|
const compact = name.toLowerCase();
|
||||||
|
if (compact.includes("browser") || compact.includes("screenshot")) return "browser";
|
||||||
|
if (compact.includes("web") || compact.includes("search") || compact.includes("fetch") || compact.includes("read")) return "web";
|
||||||
|
if (compact.includes("exec") || compact.includes("shell") || compact.includes("cli")) return "shell";
|
||||||
|
if (compact.startsWith("mcp_") || compact === "mcp") return "mcp";
|
||||||
|
if (compact.includes("file") || compact.includes("patch")) return "file";
|
||||||
|
if (compact.includes("image") || compact.includes("video") || compact.includes("media")) return "media";
|
||||||
|
return "tool";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaAttachmentFromUnknown(value: unknown): UIMediaAttachment | null {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const text = value.trim();
|
||||||
|
if (!text) return null;
|
||||||
|
return toMediaAttachment({ url: looksLikeUrl(text) ? text : undefined, name: baseName(text) });
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const url = stringField(record, ["url", "href", "src", "uri", "signed_url", "thumbnail_url"]);
|
||||||
|
const path = stringField(record, ["path", "absolute_path", "file", "filename"]);
|
||||||
|
const name = stringField(record, ["name", "filename", "title", "label"]) ?? baseName(url ?? path ?? "");
|
||||||
|
const kind = mediaKindFromRecord(record, url, name);
|
||||||
|
return toMediaAttachment({ url, name, kind });
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringField(record: Record<string, unknown>, keys: string[]): string | undefined {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "string" && value.trim()) return value.trim();
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaKindFromRecord(record: Record<string, unknown>, url?: string, name?: string): UIMediaAttachment["kind"] | undefined {
|
||||||
|
const raw = stringField(record, ["kind", "type", "mime", "mime_type", "content_type"])?.toLowerCase() ?? "";
|
||||||
|
if (raw.includes("image") || raw.includes("screenshot")) return "image";
|
||||||
|
if (raw.includes("video") || raw.includes("mp4") || raw.includes("quicktime")) return "video";
|
||||||
|
if (raw.includes("file") || raw.includes("document")) return "file";
|
||||||
|
return toMediaAttachment({ url, name }).kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeUrl(value: string): boolean {
|
||||||
|
return /^(https?:|data:|\/api\/|blob:)/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseName(value: string): string | undefined {
|
||||||
|
const clean = value.split(/[?#]/, 1)[0] ?? "";
|
||||||
|
const last = clean.split(/[\\/]/).filter(Boolean).pop();
|
||||||
|
return last || undefined;
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import type {
|
|||||||
ModelConfigurationCreate,
|
ModelConfigurationCreate,
|
||||||
ModelConfigurationUpdate,
|
ModelConfigurationUpdate,
|
||||||
NetworkSafetySettingsUpdate,
|
NetworkSafetySettingsUpdate,
|
||||||
|
ProviderModelsPayload,
|
||||||
ProviderSettingsUpdate,
|
ProviderSettingsUpdate,
|
||||||
SettingsPayload,
|
SettingsPayload,
|
||||||
SettingsUpdate,
|
SettingsUpdate,
|
||||||
@ -174,6 +175,19 @@ export async function fetchMcpPresets(
|
|||||||
return request<McpPresetsPayload>(`${base}/api/settings/mcp-presets`, token);
|
return request<McpPresetsPayload>(`${base}/api/settings/mcp-presets`, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchProviderModels(
|
||||||
|
token: string,
|
||||||
|
provider: string,
|
||||||
|
base: string = "",
|
||||||
|
): Promise<ProviderModelsPayload> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
query.set("provider", provider);
|
||||||
|
return request<ProviderModelsPayload>(
|
||||||
|
`${base}/api/settings/provider-models?${query}`,
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function runMcpPresetAction(
|
export async function runMcpPresetAction(
|
||||||
token: string,
|
token: string,
|
||||||
action: "enable" | "remove" | "test",
|
action: "enable" | "remove" | "test",
|
||||||
|
|||||||
@ -8,6 +8,7 @@ const IMAGE_EXTENSIONS = new Set([
|
|||||||
".webp",
|
".webp",
|
||||||
".bmp",
|
".bmp",
|
||||||
".ico",
|
".ico",
|
||||||
|
".svg",
|
||||||
".tif",
|
".tif",
|
||||||
".tiff",
|
".tiff",
|
||||||
]);
|
]);
|
||||||
@ -34,26 +35,30 @@ function extensionOf(value?: string): string {
|
|||||||
return path.slice(dot);
|
return path.slice(dot);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function inferMediaKind(media: { url?: string; name?: string }): UIMediaKind {
|
function explicitMediaKind(media: { url?: string; name?: string }): UIMediaKind | null {
|
||||||
const url = media.url ?? "";
|
const url = media.url ?? "";
|
||||||
if (url.startsWith("data:image/")) return "image";
|
if (url.startsWith("data:image/")) return "image";
|
||||||
if (url.startsWith("data:video/")) return "video";
|
if (url.startsWith("data:video/")) return "video";
|
||||||
|
|
||||||
const ext = extensionOf(media.name) || extensionOf(url);
|
const ext = extensionOf(media.name) || extensionOf(url);
|
||||||
|
if (!ext) return null;
|
||||||
if (IMAGE_EXTENSIONS.has(ext)) return "image";
|
if (IMAGE_EXTENSIONS.has(ext)) return "image";
|
||||||
if (VIDEO_EXTENSIONS.has(ext)) return "video";
|
if (VIDEO_EXTENSIONS.has(ext)) return "video";
|
||||||
return "file";
|
return "file";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function inferMediaKind(media: { url?: string; name?: string }): UIMediaKind {
|
||||||
|
return explicitMediaKind(media) ?? "file";
|
||||||
|
}
|
||||||
|
|
||||||
export function toMediaAttachment(media: {
|
export function toMediaAttachment(media: {
|
||||||
url?: string;
|
url?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
kind?: UIMediaKind;
|
kind?: UIMediaKind;
|
||||||
}): UIMediaAttachment {
|
}): UIMediaAttachment {
|
||||||
return {
|
return {
|
||||||
kind: media.kind ?? inferMediaKind(media),
|
kind: explicitMediaKind(media) ?? media.kind ?? "file",
|
||||||
url: media.url,
|
url: media.url,
|
||||||
name: media.name,
|
name: media.name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -146,7 +146,9 @@ const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
|
|||||||
searxng: brand("searxng.org", "#3050FF", "SX"),
|
searxng: brand("searxng.org", "#3050FF", "SX"),
|
||||||
siliconflow: brand("siliconflow.cn", "#111827", "SF"),
|
siliconflow: brand("siliconflow.cn", "#111827", "SF"),
|
||||||
skywork: brand("skywork.ai", "#5B5BF6", "SW"),
|
skywork: brand("skywork.ai", "#5B5BF6", "SW"),
|
||||||
stepfun: brand("stepfun.com", "#2F6BFF", "SF"),
|
stepfun: brand("stepfun.com", "#2F6BFF", "SF", [
|
||||||
|
"https://www.stepfun.com/step_favicon.svg",
|
||||||
|
]),
|
||||||
tavily: brand("tavily.com", "#111827", "T"),
|
tavily: brand("tavily.com", "#111827", "T"),
|
||||||
volcengine: brand("volcengine.com", "#1664FF", "VE"),
|
volcengine: brand("volcengine.com", "#1664FF", "VE"),
|
||||||
vllm: brand("vllm.ai", "#2563EB", "VL"),
|
vllm: brand("vllm.ai", "#2563EB", "VL"),
|
||||||
|
|||||||
@ -221,6 +221,29 @@ export interface RuntimeCapabilities {
|
|||||||
can_export_diagnostics: boolean;
|
can_export_diagnostics: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProviderModelInfo {
|
||||||
|
id: string;
|
||||||
|
label?: string | null;
|
||||||
|
owned_by?: string | null;
|
||||||
|
context_window?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderModelsPayload {
|
||||||
|
provider: string;
|
||||||
|
label: string;
|
||||||
|
status:
|
||||||
|
| "available"
|
||||||
|
| "unsupported"
|
||||||
|
| "not_configured"
|
||||||
|
| "missing_api_base"
|
||||||
|
| "error";
|
||||||
|
catalog_kind: "official" | "catalog" | "local" | "custom" | "unsupported";
|
||||||
|
models: ProviderModelInfo[];
|
||||||
|
model_count: number;
|
||||||
|
message?: string | null;
|
||||||
|
fetched_at?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SettingsPayload {
|
export interface SettingsPayload {
|
||||||
surface?: RuntimeSurface;
|
surface?: RuntimeSurface;
|
||||||
runtime_surface?: RuntimeSurface;
|
runtime_surface?: RuntimeSurface;
|
||||||
|
|||||||
@ -736,7 +736,7 @@ describe("AgentActivityCluster", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /1 tool calls/i }));
|
fireEvent.click(screen.getByRole("button", { name: /1 tool calls/i }));
|
||||||
|
|
||||||
expect(screen.getByText("Shell")).toBeInTheDocument();
|
expect(screen.getByText("Command")).toBeInTheDocument();
|
||||||
expect(screen.getByText(/cat << 'EOF' \| bash · script, 6 lines/)).toBeInTheDocument();
|
expect(screen.getByText(/cat << 'EOF' \| bash · script, 6 lines/)).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/SECRET_TOKEN/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/SECRET_TOKEN/)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText(/for id in/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/for id in/)).not.toBeInTheDocument();
|
||||||
@ -936,4 +936,67 @@ describe("AgentActivityCluster", () => {
|
|||||||
restoreMotion();
|
restoreMotion();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders tool event embeds as inline activity evidence", () => {
|
||||||
|
render(
|
||||||
|
<AgentActivityCluster
|
||||||
|
messages={[{
|
||||||
|
id: "t-evidence",
|
||||||
|
role: "tool",
|
||||||
|
kind: "trace",
|
||||||
|
content: 'web_fetch({"url":"https://example.com"})',
|
||||||
|
traces: ['web_fetch({"url":"https://example.com"})'],
|
||||||
|
toolEvents: [{
|
||||||
|
phase: "end",
|
||||||
|
call_id: "call-fetch",
|
||||||
|
name: "web_fetch",
|
||||||
|
arguments: { url: "https://example.com" },
|
||||||
|
embeds: [{
|
||||||
|
url: "/api/media/signed/screenshot.png",
|
||||||
|
name: "Homepage screenshot",
|
||||||
|
type: "image/png",
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
createdAt: 1,
|
||||||
|
}]}
|
||||||
|
isTurnStreaming
|
||||||
|
hasBodyBelow={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Web")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("activity-evidence-preview")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("img", { name: "Homepage screenshot" })).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
"/api/media/signed/screenshot.png",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows missing evidence as a file-safe placeholder", () => {
|
||||||
|
render(
|
||||||
|
<AgentActivityCluster
|
||||||
|
messages={[{
|
||||||
|
id: "t-missing-evidence",
|
||||||
|
role: "tool",
|
||||||
|
kind: "trace",
|
||||||
|
content: 'screenshot({"path":"missing.png"})',
|
||||||
|
traces: ['screenshot({"path":"missing.png"})'],
|
||||||
|
toolEvents: [{
|
||||||
|
phase: "end",
|
||||||
|
call_id: "call-shot",
|
||||||
|
name: "screenshot",
|
||||||
|
arguments: { path: "missing.png" },
|
||||||
|
files: [{ name: "missing.png", type: "image/png" }],
|
||||||
|
}],
|
||||||
|
createdAt: 1,
|
||||||
|
}]}
|
||||||
|
isTurnStreaming
|
||||||
|
hasBodyBelow={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Vision")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("activity-evidence-preview")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("missing.png")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
deleteSession,
|
deleteSession,
|
||||||
fetchCliApps,
|
fetchCliApps,
|
||||||
fetchMcpPresets,
|
fetchMcpPresets,
|
||||||
|
fetchProviderModels,
|
||||||
fetchSidebarState,
|
fetchSidebarState,
|
||||||
fetchWebuiThread,
|
fetchWebuiThread,
|
||||||
fetchWorkspaces,
|
fetchWorkspaces,
|
||||||
@ -165,6 +166,17 @@ describe("webui API helpers", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fetches provider model lists", async () => {
|
||||||
|
await fetchProviderModels("tok", "deepseek");
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
"/api/settings/provider-models?provider=deepseek",
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("serializes provider OAuth login and logout actions", async () => {
|
it("serializes provider OAuth login and logout actions", async () => {
|
||||||
await loginProviderOAuth("tok", "openai_codex");
|
await loginProviderOAuth("tok", "openai_codex");
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
|||||||
@ -540,6 +540,58 @@ describe("App layout", () => {
|
|||||||
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
|
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not show a completed dot later when the active session finishes", async () => {
|
||||||
|
mockSessions = [
|
||||||
|
{
|
||||||
|
key: "websocket:chat-a",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "chat-a",
|
||||||
|
createdAt: "2026-04-16T10:00:00Z",
|
||||||
|
updatedAt: "2026-04-16T10:00:00Z",
|
||||||
|
preview: "Active work",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "websocket:chat-b",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "chat-b",
|
||||||
|
createdAt: "2026-04-16T11:00:00Z",
|
||||||
|
updatedAt: "2026-04-16T11:00:00Z",
|
||||||
|
preview: "Other chat",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
within(sidebar).getByRole("button", { name: /^Active work$/ }),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(within(sidebar).getByRole("button", { name: /^Active work$/ }));
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(document.title).toContain("Active work"));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
for (const handler of runStatusHandlers) handler("chat-a", 12_345);
|
||||||
|
});
|
||||||
|
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
for (const handler of runStatusHandlers) handler("chat-a", null);
|
||||||
|
});
|
||||||
|
expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument();
|
||||||
|
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(within(sidebar).getByRole("button", { name: /^Other chat$/ }));
|
||||||
|
});
|
||||||
|
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("restores sidebar run indicators after a page reload", async () => {
|
it("restores sidebar run indicators after a page reload", async () => {
|
||||||
mockSessions = [
|
mockSessions = [
|
||||||
{
|
{
|
||||||
@ -590,7 +642,22 @@ describe("App layout", () => {
|
|||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn(async (input: RequestInfo | URL) => {
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
if (String(input).includes("/api/settings")) {
|
const href = String(input);
|
||||||
|
if (href === "/api/settings/provider-models?provider=openai") {
|
||||||
|
return jsonResponse({
|
||||||
|
provider: "openai",
|
||||||
|
label: "OpenAI",
|
||||||
|
status: "available",
|
||||||
|
catalog_kind: "official",
|
||||||
|
models: [
|
||||||
|
{ id: "openai/gpt-4o", owned_by: "openai", context_window: 128000 },
|
||||||
|
{ id: "openai/gpt-4o-mini", owned_by: "openai", context_window: 128000 },
|
||||||
|
],
|
||||||
|
model_count: 2,
|
||||||
|
fetched_at: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (href.includes("/api/settings")) {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
@ -822,9 +889,9 @@ describe("App layout", () => {
|
|||||||
expect(screen.getByRole("switch", { name: "Brand logos" })).toBeInTheDocument();
|
expect(screen.getByRole("switch", { name: "Brand logos" })).toBeInTheDocument();
|
||||||
fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" }));
|
fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" }));
|
||||||
expect(screen.queryByText("AI")).not.toBeInTheDocument();
|
expect(screen.queryByText("AI")).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("Current model")).toBeInTheDocument();
|
expect(screen.getByText("Current configuration")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("Presets")).not.toBeInTheDocument();
|
expect(screen.queryByText("Presets")).not.toBeInTheDocument();
|
||||||
fireEvent.pointerDown(screen.getByRole("button", { name: /openai\/gpt-4o/ }));
|
fireEvent.pointerDown(screen.getAllByRole("button", { name: /openai\/gpt-4o/ })[0]);
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: "Add configuration" }));
|
fireEvent.click(screen.getByRole("menuitem", { name: "Add configuration" }));
|
||||||
const modelDialog = screen.getByRole("dialog", { name: "New model configuration" });
|
const modelDialog = screen.getByRole("dialog", { name: "New model configuration" });
|
||||||
expect(within(modelDialog).getByText("Save a provider and model as a one-click option.")).toBeInTheDocument();
|
expect(within(modelDialog).getByText("Save a provider and model as a one-click option.")).toBeInTheDocument();
|
||||||
@ -837,33 +904,47 @@ describe("App layout", () => {
|
|||||||
expect(within(modelDialog).getByRole("button", { name: /OpenAI/ })).toBeInTheDocument();
|
expect(within(modelDialog).getByRole("button", { name: /OpenAI/ })).toBeInTheDocument();
|
||||||
expect(within(modelDialog).getByRole("button", { name: "Save" })).toBeEnabled();
|
expect(within(modelDialog).getByRole("button", { name: "Save" })).toBeEnabled();
|
||||||
fireEvent.click(within(modelDialog).getByRole("button", { name: "Cancel" }));
|
fireEvent.click(within(modelDialog).getByRole("button", { name: "Cancel" }));
|
||||||
const modelInput = screen.getByDisplayValue("openai/gpt-4o");
|
|
||||||
expect(modelInput).toBeInTheDocument();
|
|
||||||
fireEvent.pointerDown(screen.getByRole("button", { name: /Auto/ }));
|
fireEvent.pointerDown(screen.getByRole("button", { name: /Auto/ }));
|
||||||
expect(screen.getAllByTestId("provider-picker-logo-openai").length).toBeGreaterThan(0);
|
expect(screen.getAllByTestId("provider-picker-logo-openai").length).toBeGreaterThan(0);
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: /Auto/ }));
|
fireEvent.click(screen.getByRole("menuitem", { name: /Auto/ }));
|
||||||
fireEvent.change(modelInput, { target: { value: "openai/gpt-4o-mini" } });
|
const openModelPicker = () => {
|
||||||
|
const modelButtons = screen.getAllByRole("button", { name: /openai\/gpt-4o/ });
|
||||||
|
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
|
||||||
|
};
|
||||||
|
openModelPicker();
|
||||||
|
await screen.findByText("openai/gpt-4o-mini");
|
||||||
|
fireEvent.click(screen.getAllByText("openai/gpt-4o-mini")[0]);
|
||||||
expect(screen.getByText("Unsaved changes.").parentElement?.className).toContain(
|
expect(screen.getByText("Unsaved changes.").parentElement?.className).toContain(
|
||||||
"text-blue-600",
|
"text-blue-600",
|
||||||
);
|
);
|
||||||
fireEvent.change(modelInput, { target: { value: "openai/gpt-4o" } });
|
const updatedModelButtons = screen.getAllByRole("button", { name: /openai\/gpt-4o-mini/ });
|
||||||
|
fireEvent.pointerDown(updatedModelButtons[updatedModelButtons.length - 1]);
|
||||||
|
await screen.findByText("openai/gpt-4o");
|
||||||
|
fireEvent.click(screen.getAllByText("openai/gpt-4o")[0]);
|
||||||
expect(screen.getByText("OpenRouter")).toBeInTheDocument();
|
expect(screen.getByText("OpenRouter")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Ant Ling")).toBeInTheDocument();
|
expect(screen.getByText("Ant Ling")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("provider-logo-openai")).toBeInTheDocument();
|
expect(screen.getByTestId("provider-logo-openai")).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Product names, logos, and brands/)).toBeInTheDocument();
|
expect(screen.getByText(/Product names, logos, and brands/)).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("Not configured").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("Not configured").length).toBeGreaterThan(0);
|
||||||
fireEvent.click(screen.getByText("OpenAI"));
|
const clickProviderRow = (label: string) => {
|
||||||
|
const providerLabel = screen
|
||||||
|
.getAllByText(label)
|
||||||
|
.find((element) => element.className.includes("font-semibold"));
|
||||||
|
expect(providerLabel).toBeTruthy();
|
||||||
|
fireEvent.click(providerLabel!);
|
||||||
|
};
|
||||||
|
clickProviderRow("OpenAI");
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||||
fireEvent.change(screen.getByPlaceholderText("Leave blank to keep the current key"), {
|
fireEvent.change(screen.getByPlaceholderText("Leave blank to keep the current key"), {
|
||||||
target: { value: "unsaved-openai-key" },
|
target: { value: "unsaved-openai-key" },
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByText("OpenRouter"));
|
clickProviderRow("OpenRouter");
|
||||||
fireEvent.click(screen.getByText("OpenAI"));
|
clickProviderRow("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("Ant Ling"));
|
clickProviderRow("Ant Ling");
|
||||||
expect(screen.getByDisplayValue("https://api.ant-ling.com/v1")).toBeInTheDocument();
|
expect(screen.getByDisplayValue("https://api.ant-ling.com/v1")).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByText("Atomic Chat"));
|
clickProviderRow("Atomic Chat");
|
||||||
expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
|
expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Save provider" })).toBeEnabled();
|
expect(screen.getByRole("button", { name: "Save provider" })).toBeEnabled();
|
||||||
|
|
||||||
|
|||||||
@ -204,7 +204,9 @@ describe("ChatList", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getAllByLabelText("Agent finished")).toHaveLength(1);
|
const finished = screen.getAllByLabelText("Agent finished");
|
||||||
|
expect(finished).toHaveLength(1);
|
||||||
|
expect(finished[0].firstElementChild).toHaveClass("h-2", "w-2");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("folds long default workspace chats and can show all", () => {
|
it("folds long default workspace chats and can show all", () => {
|
||||||
|
|||||||
@ -45,6 +45,7 @@ describe("CodeBlock", () => {
|
|||||||
expect(screen.queryByTestId("highlighted-code")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("highlighted-code")).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("const value = 1;")).toBeInTheDocument();
|
expect(screen.getByText("const value = 1;")).toBeInTheDocument();
|
||||||
expect(screen.getByText("ts")).toBeInTheDocument();
|
expect(screen.getByText("ts")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("plain-code-fallback")).toHaveClass("text-foreground/90");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads theme from context without creating per-block observers", async () => {
|
it("reads theme from context without creating per-block observers", async () => {
|
||||||
|
|||||||
@ -4,6 +4,30 @@ import { describe, expect, it } from "vitest";
|
|||||||
import MarkdownTextRenderer from "@/components/MarkdownTextRenderer";
|
import MarkdownTextRenderer from "@/components/MarkdownTextRenderer";
|
||||||
|
|
||||||
describe("MarkdownTextRenderer", () => {
|
describe("MarkdownTextRenderer", () => {
|
||||||
|
it("does not wrap complete fenced code blocks in an extra pre", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<MarkdownTextRenderer highlightCode={false}>
|
||||||
|
{"当前目录:\n\n```text\n/Users/renxubin/.nanobot/workspace\n```"}
|
||||||
|
</MarkdownTextRenderer>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("/Users/renxubin/.nanobot/workspace")).toBeInTheDocument();
|
||||||
|
expect(container.querySelectorAll("pre")).toHaveLength(1);
|
||||||
|
expect(container.querySelector("pre div")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps streaming unfinished fenced code blocks to a single shell", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<MarkdownTextRenderer highlightCode={false}>
|
||||||
|
{"当前目录:\n\n```text\n/Users/renxubin/.nanobot/workspace"}
|
||||||
|
</MarkdownTextRenderer>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("/Users/renxubin/.nanobot/workspace")).toBeInTheDocument();
|
||||||
|
expect(container.querySelectorAll("pre")).toHaveLength(1);
|
||||||
|
expect(container.querySelector("pre div")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders markdown images as inline previews", () => {
|
it("renders markdown images as inline previews", () => {
|
||||||
render(<MarkdownTextRenderer></MarkdownTextRenderer>);
|
render(<MarkdownTextRenderer></MarkdownTextRenderer>);
|
||||||
|
|
||||||
@ -13,7 +37,6 @@ describe("MarkdownTextRenderer", () => {
|
|||||||
"href",
|
"href",
|
||||||
"/api/media/sig/payload",
|
"/api/media/sig/payload",
|
||||||
);
|
);
|
||||||
expect(screen.getByText("Diagram")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders markdown videos as inline players", () => {
|
it("renders markdown videos as inline players", () => {
|
||||||
@ -25,4 +48,101 @@ describe("MarkdownTextRenderer", () => {
|
|||||||
expect(video).toHaveAttribute("controls");
|
expect(video).toHaveAttribute("controls");
|
||||||
expect(screen.queryByRole("img", { name: "nanobot-intro.mp4" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("img", { name: "nanobot-intro.mp4" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders markdown links with file-looking names as file attachments", () => {
|
||||||
|
render(<MarkdownTextRenderer></MarkdownTextRenderer>);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("File attachment")).toHaveTextContent("index.html");
|
||||||
|
expect(screen.queryByRole("img", { name: "index.html" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders media attachments without an extra preview/code wrapper", () => {
|
||||||
|
render(<MarkdownTextRenderer></MarkdownTextRenderer>);
|
||||||
|
|
||||||
|
expect(screen.getByRole("img", { name: "Diagram" })).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
"/api/media/sig/payload",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("link", { name: "Open Diagram" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"/api/media/sig/payload",
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("button", { name: "Code" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a safe subset of inline HTML", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<MarkdownTextRenderer>
|
||||||
|
{"<mark>高亮文本</mark>\n\n上标:x<sup>2</sup>\n下标:H<sub>2</sub>O"}
|
||||||
|
</MarkdownTextRenderer>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector("mark")).toHaveTextContent("高亮文本");
|
||||||
|
expect(container.querySelector("sup")).toHaveTextContent("2");
|
||||||
|
expect(container.querySelector("sub")).toHaveTextContent("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps unsafe HTML as text", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<MarkdownTextRenderer>
|
||||||
|
{"<script>alert(1)</script>\n\n<mark onclick=\"alert(1)\">bad</mark>"}
|
||||||
|
</MarkdownTextRenderer>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector("script")).toBeNull();
|
||||||
|
expect(container.querySelector("mark")).toBeNull();
|
||||||
|
expect(container).toHaveTextContent("<script>alert(1)</script>");
|
||||||
|
expect(container).toHaveTextContent("<mark onclick=\"alert(1)\">bad</mark>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders safe details blocks", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<MarkdownTextRenderer>
|
||||||
|
{
|
||||||
|
"<details><summary>点击展开更多内容</summary>\n\n这里是被折叠的内容。\n\n- 可以放列表\n\n</details>"
|
||||||
|
}
|
||||||
|
</MarkdownTextRenderer>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector("details")).toBeInTheDocument();
|
||||||
|
expect(container.querySelector("summary")).toHaveTextContent("点击展开更多内容");
|
||||||
|
expect(screen.getByText("这里是被折叠的内容。")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("可以放列表")).toBeInTheDocument();
|
||||||
|
expect(container).not.toHaveTextContent("</details>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders task list checkboxes as quiet status marks", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<MarkdownTextRenderer>
|
||||||
|
{"- [x] 写 Markdown 示例\n- [x] 加点 emoji\n- [ ] 测试渲染效果"}
|
||||||
|
</MarkdownTextRenderer>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelectorAll("input[type='checkbox']")).toHaveLength(0);
|
||||||
|
expect(screen.getAllByTestId("markdown-task-checkbox")).toHaveLength(3);
|
||||||
|
expect(container.querySelectorAll(".task-list-item")).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps dollar amounts from being parsed as inline math", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<MarkdownTextRenderer>
|
||||||
|
{
|
||||||
|
"VBeats mentions $24 million, while Globe states a total of $130.6 million since founding."
|
||||||
|
}
|
||||||
|
</MarkdownTextRenderer>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container).toHaveTextContent(
|
||||||
|
"VBeats mentions $24 million, while Globe states a total of $130.6 million since founding.",
|
||||||
|
);
|
||||||
|
expect(container.querySelector(".katex")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still renders explicit math blocks", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<MarkdownTextRenderer>{"$$x^2 + y^2 = z^2$$"}</MarkdownTextRenderer>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector(".katex")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -42,7 +42,7 @@ describe("MarkdownText", () => {
|
|||||||
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
|
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
|
||||||
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
|
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
|
||||||
"data-highlight-code",
|
"data-highlight-code",
|
||||||
"false",
|
"true",
|
||||||
);
|
);
|
||||||
expect(rendererSpy).toHaveBeenCalledTimes(1);
|
expect(rendererSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
@ -79,4 +79,30 @@ describe("MarkdownText", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps very large streaming snippets plain until the final render", async () => {
|
||||||
|
rendererSpy.mockClear();
|
||||||
|
const largeCode = `\`\`\`ts\n${"const value = 1;\n".repeat(1_100)}\`\`\``;
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<MarkdownText streaming>{largeCode}</MarkdownText>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
|
||||||
|
"data-highlight-code",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender(<MarkdownText>{largeCode}</MarkdownText>);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
|
||||||
|
"data-highlight-code",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -236,7 +236,10 @@ describe("MessageBubble", () => {
|
|||||||
const video = screen.getByLabelText(/video attachment/i);
|
const video = screen.getByLabelText(/video attachment/i);
|
||||||
expect(video.tagName).toBe("VIDEO");
|
expect(video.tagName).toBe("VIDEO");
|
||||||
expect(video).toHaveAttribute("src", "/api/media/sig/payload");
|
expect(video).toHaveAttribute("src", "/api/media/sig/payload");
|
||||||
|
expect(video).toHaveAttribute("preload", "auto");
|
||||||
expect(container.querySelector("video[controls]")).toBeInTheDocument();
|
expect(container.querySelector("video[controls]")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Preview")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Code")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("auto-expands the reasoning trace while streaming with a shimmer header", () => {
|
it("auto-expands the reasoning trace while streaming with a shimmer header", () => {
|
||||||
@ -366,4 +369,47 @@ describe("MessageBubble", () => {
|
|||||||
expect(imageButton).not.toHaveAttribute("title");
|
expect(imageButton).not.toHaveAttribute("title");
|
||||||
expect(container.querySelector("img")).toHaveClass("h-auto", "w-full", "object-contain");
|
expect(container.querySelector("img")).toHaveClass("h-auto", "w-full", "object-contain");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders mislabeled html assistant media as a file attachment", () => {
|
||||||
|
const message: UIMessage = {
|
||||||
|
id: "a-html",
|
||||||
|
role: "assistant",
|
||||||
|
content: "file ready",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
media: [
|
||||||
|
{
|
||||||
|
kind: "image",
|
||||||
|
url: "/api/media/sig/html",
|
||||||
|
name: "index.html",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = render(<MessageBubble message={message} />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("File attachment")).toHaveTextContent("index.html");
|
||||||
|
expect(container.querySelector("img")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders assistant svg media as an image preview", () => {
|
||||||
|
const message: UIMessage = {
|
||||||
|
id: "a-svg",
|
||||||
|
role: "assistant",
|
||||||
|
content: "chart ready",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
media: [
|
||||||
|
{
|
||||||
|
kind: "file",
|
||||||
|
url: "/api/media/sig/svg",
|
||||||
|
name: "growth.svg",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = render(<MessageBubble message={message} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /view image: growth.svg/i })).toBeInTheDocument();
|
||||||
|
expect(container.querySelector('img[src="/api/media/sig/svg"]')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("File attachment")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -35,8 +35,9 @@ describe("provider brand logos", () => {
|
|||||||
expect(providerBrand("zhipu")?.initials).toBe("Z");
|
expect(providerBrand("zhipu")?.initials).toBe("Z");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses official first-party assets for LongCat and Xiaomi MIMO", () => {
|
it("uses official first-party assets for LongCat, Step Fun, and Xiaomi MIMO", () => {
|
||||||
expect(providerBrand("longcat")?.logoUrls[0]).toBe("https://www.longcatai.org/favicon.svg");
|
expect(providerBrand("longcat")?.logoUrls[0]).toBe("https://www.longcatai.org/favicon.svg");
|
||||||
|
expect(providerBrand("stepfun")?.logoUrls[0]).toBe("https://www.stepfun.com/step_favicon.svg");
|
||||||
expect(providerBrand("xiaomi_mimo")?.logoUrls[0]).toBe("https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg");
|
expect(providerBrand("xiaomi_mimo")?.logoUrls[0]).toBe("https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg");
|
||||||
expect(providerBrand("mimo")?.logoUrls[0]).toBe("https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg");
|
expect(providerBrand("mimo")?.logoUrls[0]).toBe("https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -245,6 +245,104 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads provider models and lets users choose one without typing the id manually", async () => {
|
||||||
|
const payload: SettingsPayload = {
|
||||||
|
...settingsPayload(),
|
||||||
|
agent: {
|
||||||
|
...settingsPayload().agent,
|
||||||
|
model: "deepseek-chat",
|
||||||
|
provider: "deepseek",
|
||||||
|
resolved_provider: "deepseek",
|
||||||
|
},
|
||||||
|
model_presets: [
|
||||||
|
{
|
||||||
|
...settingsPayload().model_presets[0],
|
||||||
|
model: "deepseek-chat",
|
||||||
|
provider: "deepseek",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
name: "deepseek",
|
||||||
|
label: "DeepSeek",
|
||||||
|
configured: true,
|
||||||
|
auth_type: "api_key",
|
||||||
|
api_key_required: true,
|
||||||
|
api_key_hint: "sk-...",
|
||||||
|
api_base: "https://api.deepseek.com",
|
||||||
|
default_api_base: "https://api.deepseek.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const updatedPayload: SettingsPayload = {
|
||||||
|
...payload,
|
||||||
|
agent: {
|
||||||
|
...payload.agent,
|
||||||
|
model: "deepseek-reasoner",
|
||||||
|
},
|
||||||
|
model_presets: [
|
||||||
|
{
|
||||||
|
...payload.model_presets[0],
|
||||||
|
model: "deepseek-reasoner",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/settings") return jsonResponse(payload);
|
||||||
|
if (url === "/api/settings/cli-apps") {
|
||||||
|
return jsonResponse({ apps: [], installed_count: 0 });
|
||||||
|
}
|
||||||
|
if (url === "/api/settings/mcp-presets") {
|
||||||
|
return jsonResponse({ presets: [], installed_count: 0 });
|
||||||
|
}
|
||||||
|
if (url === "/api/settings/provider-models?provider=deepseek") {
|
||||||
|
return jsonResponse({
|
||||||
|
provider: "deepseek",
|
||||||
|
label: "DeepSeek",
|
||||||
|
status: "available",
|
||||||
|
catalog_kind: "official",
|
||||||
|
models: [
|
||||||
|
{ id: "deepseek-chat", owned_by: "deepseek", context_window: 65536 },
|
||||||
|
{ id: "deepseek-reasoner", owned_by: "deepseek", context_window: 65536 },
|
||||||
|
],
|
||||||
|
model_count: 2,
|
||||||
|
fetched_at: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === "/api/settings/update?model_preset=default&model=deepseek-reasoner") {
|
||||||
|
return jsonResponse(updatedPayload);
|
||||||
|
}
|
||||||
|
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
renderSettingsView({ initialSection: "models" });
|
||||||
|
|
||||||
|
const modelButtons = await screen.findAllByRole("button", { name: /deepseek-chat/i });
|
||||||
|
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
|
||||||
|
await screen.findByText("deepseek-reasoner");
|
||||||
|
fireEvent.click(screen.getAllByText("deepseek-reasoner")[0]);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"/api/settings/provider-models?provider=deepseek",
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"/api/settings/update?model_preset=default&model=deepseek-reasoner",
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("saves network safety without exposing technical SSRF copy", async () => {
|
it("saves network safety without exposing technical SSRF copy", async () => {
|
||||||
const payload = settingsPayload();
|
const payload = settingsPayload();
|
||||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
|||||||
@ -4,6 +4,15 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||||
import type { CliAppInfo, McpPresetInfo, SlashCommand } from "@/lib/types";
|
import type { CliAppInfo, McpPresetInfo, SlashCommand } from "@/lib/types";
|
||||||
|
|
||||||
|
vi.mock("@/lib/imageEncode", () => ({
|
||||||
|
encodeImage: vi.fn(async (file: File) => ({
|
||||||
|
ok: true,
|
||||||
|
dataUrl: `data:${file.type || "image/png"};base64,aW1hZ2U=`,
|
||||||
|
bytes: Math.max(1, file.size),
|
||||||
|
normalized: false,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
const COMMANDS: SlashCommand[] = [
|
const COMMANDS: SlashCommand[] = [
|
||||||
{
|
{
|
||||||
command: "/stop",
|
command: "/stop",
|
||||||
@ -113,6 +122,17 @@ const MCP_PRESETS: McpPresetInfo[] = [
|
|||||||
];
|
];
|
||||||
const ORIGINAL_INNER_HEIGHT = window.innerHeight;
|
const ORIGINAL_INNER_HEIGHT = window.innerHeight;
|
||||||
|
|
||||||
|
function mockBlobUrls() {
|
||||||
|
Object.defineProperty(URL, "createObjectURL", {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn(() => "blob:composer-test"),
|
||||||
|
});
|
||||||
|
Object.defineProperty(URL, "revokeObjectURL", {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
Reflect.deleteProperty(window, "nanobotHost");
|
Reflect.deleteProperty(window, "nanobotHost");
|
||||||
@ -160,6 +180,9 @@ describe("ThreadComposer", () => {
|
|||||||
const input = screen.getByPlaceholderText("Ask anything...");
|
const input = screen.getByPlaceholderText("Ask anything...");
|
||||||
expect(input).toBeInTheDocument();
|
expect(input).toBeInTheDocument();
|
||||||
expect(input.className).toContain("min-h-[78px]");
|
expect(input.className).toContain("min-h-[78px]");
|
||||||
|
expect(input.className).toContain("pt-[27px]");
|
||||||
|
fireEvent.change(input, { target: { value: "1" } });
|
||||||
|
expect(input.className).toContain("pt-[27px]");
|
||||||
expect(input.parentElement?.parentElement?.className).toContain("max-w-[58rem]");
|
expect(input.parentElement?.parentElement?.className).toContain("max-w-[58rem]");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -361,6 +384,8 @@ describe("ThreadComposer", () => {
|
|||||||
const status = screen.getByRole("status");
|
const status = screen.getByRole("status");
|
||||||
expect(status).toHaveTextContent(/Running/);
|
expect(status).toHaveTextContent(/Running/);
|
||||||
expect(status).toHaveTextContent(/2:05/);
|
expect(status).toHaveTextContent(/2:05/);
|
||||||
|
expect(status.parentElement).toHaveClass("composer-status-strip");
|
||||||
|
expect(status.parentElement).toHaveAttribute("data-state", "enter");
|
||||||
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
@ -802,7 +827,7 @@ describe("ThreadComposer", () => {
|
|||||||
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends image generation mode with automatic aspect ratio", () => {
|
it("keeps image generation mode out of the composer chrome", () => {
|
||||||
const onSend = vi.fn();
|
const onSend = vi.fn();
|
||||||
render(
|
render(
|
||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
@ -811,18 +836,14 @@ describe("ThreadComposer", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Toggle image generation mode" }));
|
expect(screen.queryByRole("button", { name: "Toggle image generation mode" })).not.toBeInTheDocument();
|
||||||
expect(screen.getByPlaceholderText("Describe or edit an image…")).toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Image aspect ratio" })).not.toBeInTheDocument();
|
||||||
|
|
||||||
const input = screen.getByLabelText("Message input");
|
const input = screen.getByLabelText("Message input");
|
||||||
fireEvent.change(input, { target: { value: "Draw a friendly robot" } });
|
fireEvent.change(input, { target: { value: "Draw a friendly robot" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||||
|
|
||||||
expect(onSend).toHaveBeenCalledWith(
|
expect(onSend).toHaveBeenCalledWith("Draw a friendly robot", undefined, undefined);
|
||||||
"Draw a friendly robot",
|
|
||||||
undefined,
|
|
||||||
{ imageGeneration: { enabled: true, aspect_ratio: null } },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a stop button while streaming", () => {
|
it("shows a stop button while streaming", () => {
|
||||||
@ -842,75 +863,407 @@ describe("ThreadComposer", () => {
|
|||||||
expect(screen.queryByRole("button", { name: "Send message" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Send message" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lets users select a concrete image aspect ratio", () => {
|
it("queues plain guidance while a task is running", () => {
|
||||||
const onSend = vi.fn();
|
const onSend = vi.fn();
|
||||||
render(
|
render(
|
||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
onSend={onSend}
|
onSend={onSend}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
isStreaming
|
||||||
placeholder="Type your message..."
|
placeholder="Type your message..."
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Toggle image generation mode" }));
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Image aspect ratio" }));
|
|
||||||
expect(screen.getByRole("listbox", { name: "Image aspect ratio" }).className).toContain(
|
|
||||||
"bottom-full",
|
|
||||||
);
|
|
||||||
fireEvent.mouseDown(screen.getByRole("option", { name: "Wide 16:9" }));
|
|
||||||
|
|
||||||
const input = screen.getByLabelText("Message input");
|
const input = screen.getByLabelText("Message input");
|
||||||
fireEvent.change(input, { target: { value: "Draw a banner" } });
|
fireEvent.change(input, { target: { value: "keep the UI minimal" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
|
||||||
expect(onSend).toHaveBeenCalledWith(
|
expect(onSend).not.toHaveBeenCalled();
|
||||||
"Draw a banner",
|
expect(input).toHaveValue("");
|
||||||
undefined,
|
expect(screen.getByText("keep the UI minimal")).toBeInTheDocument();
|
||||||
{ imageGeneration: { enabled: true, aspect_ratio: "16:9" } },
|
|
||||||
);
|
fireEvent.click(screen.getByRole("button", { name: "Guide" }));
|
||||||
|
|
||||||
|
expect(onSend).toHaveBeenCalledWith("keep the UI minimal");
|
||||||
|
expect(screen.queryByText("keep the UI minimal")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the hero image aspect menu downward", () => {
|
it("keeps queued guidance attached to the composer and sends it one item at a time", async () => {
|
||||||
render(
|
const onSend = vi.fn();
|
||||||
|
const { rerender } = render(
|
||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
onSend={vi.fn()}
|
onSend={onSend}
|
||||||
placeholder="Ask anything..."
|
onStop={vi.fn()}
|
||||||
variant="hero"
|
isStreaming
|
||||||
imageMode
|
placeholder="Type your message..."
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Image aspect ratio" }));
|
const input = screen.getByLabelText("Message input");
|
||||||
|
fireEvent.change(input, { target: { value: "first follow-up" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
fireEvent.change(input, { target: { value: "second follow-up" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
|
||||||
expect(screen.getByRole("listbox", { name: "Image aspect ratio" }).className).toContain(
|
const queue = screen.getByRole("group", { name: "Queued guidance" });
|
||||||
"top-full",
|
expect(queue).toHaveClass("composer-status-strip");
|
||||||
);
|
expect(queue).toHaveClass("mx-3");
|
||||||
});
|
expect(queue.parentElement?.className).toContain("group/composer");
|
||||||
|
expect(within(queue).getByText("first follow-up")).toBeInTheDocument();
|
||||||
|
expect(within(queue).getByText("second follow-up")).toBeInTheDocument();
|
||||||
|
expect(within(queue).getAllByRole("button", { name: "Edit guidance" })).toHaveLength(2);
|
||||||
|
expect(within(queue).getAllByRole("button", { name: "Guide" })).toHaveLength(2);
|
||||||
|
|
||||||
it("dismisses the image aspect menu on outside click, escape, and wheel", () => {
|
rerender(
|
||||||
render(
|
|
||||||
<div>
|
|
||||||
<button type="button">outside</button>
|
|
||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
onSend={vi.fn()}
|
onSend={onSend}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
isStreaming={false}
|
||||||
placeholder="Type your message..."
|
placeholder="Type your message..."
|
||||||
imageMode
|
/>,
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const aspectButton = screen.getByRole("button", { name: "Image aspect ratio" });
|
await waitFor(() => {
|
||||||
fireEvent.click(aspectButton);
|
expect(onSend).toHaveBeenCalledWith("first follow-up");
|
||||||
expect(screen.getByRole("listbox", { name: "Image aspect ratio" })).toBeInTheDocument();
|
|
||||||
|
|
||||||
fireEvent.pointerDown(screen.getByRole("button", { name: "outside" }));
|
|
||||||
expect(screen.queryByRole("listbox", { name: "Image aspect ratio" })).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
fireEvent.click(aspectButton);
|
|
||||||
fireEvent.keyDown(document, { key: "Escape" });
|
|
||||||
expect(screen.queryByRole("listbox", { name: "Image aspect ratio" })).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
fireEvent.click(aspectButton);
|
|
||||||
fireEvent.wheel(screen.getByRole("listbox", { name: "Image aspect ratio" }), { deltaY: 120 });
|
|
||||||
expect(screen.queryByRole("listbox", { name: "Image aspect ratio" })).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
expect(onSend).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.queryByText("first follow-up")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("second follow-up")).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={onSend}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
isStreaming
|
||||||
|
placeholder="Type your message..."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
rerender(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={onSend}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
isStreaming={false}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSend).toHaveBeenLastCalledWith("second follow-up");
|
||||||
|
});
|
||||||
|
expect(onSend).toHaveBeenCalledTimes(2);
|
||||||
|
expect(screen.queryByRole("group", { name: "Queued guidance" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets users edit queued guidance before it is sent", async () => {
|
||||||
|
const onSend = vi.fn();
|
||||||
|
const { rerender } = render(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={onSend}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
isStreaming
|
||||||
|
placeholder="Type your message..."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Message input");
|
||||||
|
fireEvent.change(input, { target: { value: "rough follow-up" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
|
||||||
|
const editButton = screen.getByRole("button", { name: "Edit guidance" });
|
||||||
|
fireEvent.click(editButton);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input).toHaveFocus();
|
||||||
|
});
|
||||||
|
expect(input).toHaveValue("rough follow-up");
|
||||||
|
expect(screen.queryByRole("group", { name: "Queued guidance" })).not.toBeInTheDocument();
|
||||||
|
fireEvent.change(input, { target: { value: "polished follow-up" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={onSend}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
isStreaming={false}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSend).toHaveBeenCalledWith("polished follow-up");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requeues edited guidance at the end of the pending list", async () => {
|
||||||
|
const onSend = vi.fn();
|
||||||
|
const { rerender } = render(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={onSend}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
isStreaming
|
||||||
|
placeholder="Type your message..."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Message input");
|
||||||
|
fireEvent.change(input, { target: { value: "first follow-up" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
fireEvent.change(input, { target: { value: "second follow-up" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: "Edit guidance" })[0]);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input).toHaveValue("first follow-up");
|
||||||
|
});
|
||||||
|
fireEvent.change(input, { target: { value: "first follow-up edited" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={onSend}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
isStreaming={false}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSend).toHaveBeenCalledWith("second follow-up");
|
||||||
|
});
|
||||||
|
expect(onSend).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={onSend}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
isStreaming
|
||||||
|
placeholder="Type your message..."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
rerender(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={onSend}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
isStreaming={false}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSend).toHaveBeenLastCalledWith("first follow-up edited");
|
||||||
|
});
|
||||||
|
expect(onSend).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queues image guidance while running and restores it for editing", async () => {
|
||||||
|
mockBlobUrls();
|
||||||
|
const onSend = vi.fn();
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={onSend}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
isStreaming
|
||||||
|
placeholder="Type your message..."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Message input");
|
||||||
|
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]');
|
||||||
|
expect(fileInput).toBeTruthy();
|
||||||
|
const file = new File(["image"], "draft.png", { type: "image/png" });
|
||||||
|
fireEvent.change(fileInput!, { target: { files: [file] } });
|
||||||
|
await screen.findByText("draft.png");
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: "look at this" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
|
||||||
|
expect(onSend).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole("group", { name: "Queued guidance" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("look at this")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("composer-chip")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Edit guidance" }));
|
||||||
|
expect(input).toHaveValue("look at this");
|
||||||
|
expect(screen.getByTestId("composer-chip")).toHaveTextContent("draft.png");
|
||||||
|
expect(screen.queryByRole("group", { name: "Queued guidance" })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
rerender(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={onSend}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
isStreaming={false}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSend).toHaveBeenCalledWith(
|
||||||
|
"look at this",
|
||||||
|
[expect.objectContaining({
|
||||||
|
media: expect.objectContaining({
|
||||||
|
data_url: "data:image/png;base64,aW1hZ2U=",
|
||||||
|
name: "draft.png",
|
||||||
|
}),
|
||||||
|
})],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reorders queued guidance while dragging over another row", async () => {
|
||||||
|
const onSend = vi.fn();
|
||||||
|
const { rerender } = render(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={onSend}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
isStreaming
|
||||||
|
placeholder="Type your message..."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Message input");
|
||||||
|
fireEvent.change(input, { target: { value: "first follow-up" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
fireEvent.change(input, { target: { value: "second follow-up" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
|
||||||
|
const handles = screen.getAllByLabelText("Drag to reorder");
|
||||||
|
const secondRow = screen
|
||||||
|
.getByText("second follow-up")
|
||||||
|
.closest("[data-queued-prompt-row='true']");
|
||||||
|
expect(secondRow).toBeTruthy();
|
||||||
|
|
||||||
|
const dataTransfer = {
|
||||||
|
effectAllowed: "",
|
||||||
|
dropEffect: "",
|
||||||
|
setData: vi.fn(),
|
||||||
|
getData: vi.fn(),
|
||||||
|
};
|
||||||
|
fireEvent.dragStart(handles[0], { dataTransfer });
|
||||||
|
fireEvent.dragEnter(secondRow!, { dataTransfer });
|
||||||
|
fireEvent.dragEnd(handles[0], { dataTransfer });
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={onSend}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
isStreaming={false}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSend).toHaveBeenCalledWith("second follow-up");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves later queued guidance before an earlier item while dragging", async () => {
|
||||||
|
const onSend = vi.fn();
|
||||||
|
const { rerender } = render(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={onSend}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
isStreaming
|
||||||
|
placeholder="Type your message..."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Message input");
|
||||||
|
fireEvent.change(input, { target: { value: "first follow-up" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
fireEvent.change(input, { target: { value: "second follow-up" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
|
||||||
|
const handles = screen.getAllByLabelText("Drag to reorder");
|
||||||
|
const firstRow = screen
|
||||||
|
.getByText("first follow-up")
|
||||||
|
.closest("[data-queued-prompt-row='true']");
|
||||||
|
expect(firstRow).toBeTruthy();
|
||||||
|
|
||||||
|
const dataTransfer = {
|
||||||
|
effectAllowed: "",
|
||||||
|
dropEffect: "",
|
||||||
|
setData: vi.fn(),
|
||||||
|
getData: vi.fn(),
|
||||||
|
};
|
||||||
|
fireEvent.dragStart(handles[1], { dataTransfer });
|
||||||
|
fireEvent.dragEnter(firstRow!, { dataTransfer });
|
||||||
|
fireEvent.dragEnd(handles[1], { dataTransfer });
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={onSend}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
isStreaming={false}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSend).toHaveBeenCalledWith("second follow-up");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists queued guidance per chat across remounts", async () => {
|
||||||
|
const onSend = vi.fn();
|
||||||
|
const { rerender, unmount } = render(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={onSend}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
isStreaming
|
||||||
|
pendingQueueKey="chat-a"
|
||||||
|
placeholder="Type your message..."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Message input");
|
||||||
|
fireEvent.change(input, { target: { value: "remember this follow-up" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Edit guidance" }));
|
||||||
|
fireEvent.change(input, { target: { value: "remember this edited follow-up" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
expect(screen.getByText("remember this edited follow-up")).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={onSend}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
isStreaming
|
||||||
|
pendingQueueKey="chat-b"
|
||||||
|
placeholder="Type your message..."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText("remember this edited follow-up")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
const remount = render(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={onSend}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
isStreaming
|
||||||
|
pendingQueueKey="chat-a"
|
||||||
|
placeholder="Type your message..."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("remember this edited follow-up")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Guide" }));
|
||||||
|
expect(onSend).toHaveBeenCalledWith("remember this edited follow-up");
|
||||||
|
|
||||||
|
remount.unmount();
|
||||||
|
render(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={onSend}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
isStreaming
|
||||||
|
pendingQueueKey="chat-a"
|
||||||
|
placeholder="Type your message..."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText("remember this edited follow-up")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
import type { UIMessage } from "@/lib/types";
|
import type { UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
describe("ThreadMessages", () => {
|
describe("ThreadMessages", () => {
|
||||||
it("groups consecutive reasoning and tool rows into one cluster before the answer", () => {
|
it("groups consecutive reasoning and tool rows into one timeline before the answer", () => {
|
||||||
const messages: UIMessage[] = [
|
const messages: UIMessage[] = [
|
||||||
{
|
{
|
||||||
id: "r1",
|
id: "r1",
|
||||||
@ -55,7 +55,7 @@ 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", () => {
|
it("keeps file edits as their own activity row inside a turn", () => {
|
||||||
const messages: UIMessage[] = [
|
const messages: UIMessage[] = [
|
||||||
{
|
{
|
||||||
id: "r1",
|
id: "r1",
|
||||||
@ -95,14 +95,11 @@ describe("ThreadMessages", () => {
|
|||||||
|
|
||||||
const units = buildDisplayUnits(messages);
|
const units = buildDisplayUnits(messages);
|
||||||
|
|
||||||
expect(units).toHaveLength(2);
|
expect(units).toHaveLength(3);
|
||||||
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
expect(units.map((unit) => unit.type)).toEqual(["activity", "activity", "activity"]);
|
||||||
"r1",
|
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
|
||||||
"t1",
|
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
|
||||||
]);
|
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["r2"]);
|
||||||
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", () => {
|
it("does not split ordinary tool activity just because segment ids changed", () => {
|
||||||
@ -146,7 +143,7 @@ describe("ThreadMessages", () => {
|
|||||||
const units = buildDisplayUnits(messages);
|
const units = buildDisplayUnits(messages);
|
||||||
|
|
||||||
expect(units).toHaveLength(1);
|
expect(units).toHaveLength(1);
|
||||||
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||||
"r1",
|
"r1",
|
||||||
"t1",
|
"t1",
|
||||||
"r2",
|
"r2",
|
||||||
@ -154,7 +151,7 @@ describe("ThreadMessages", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("only marks the current activity cluster as live while streaming", () => {
|
it("only marks the current activity timeline as live while streaming", () => {
|
||||||
const messages: UIMessage[] = [
|
const messages: UIMessage[] = [
|
||||||
{
|
{
|
||||||
id: "r1",
|
id: "r1",
|
||||||
@ -197,12 +194,10 @@ describe("ThreadMessages", () => {
|
|||||||
|
|
||||||
render(<ThreadMessages messages={messages} isStreaming />);
|
render(<ThreadMessages messages={messages} isStreaming />);
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: /edited foo\.txt/i })).toBeInTheDocument();
|
expect(screen.getByLabelText(/editing 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", () => {
|
it("folds final answer reasoning into the preceding activity timeline", () => {
|
||||||
const messages: UIMessage[] = [
|
const messages: UIMessage[] = [
|
||||||
{
|
{
|
||||||
id: "r1",
|
id: "r1",
|
||||||
@ -234,21 +229,21 @@ describe("ThreadMessages", () => {
|
|||||||
const units = buildDisplayUnits(messages);
|
const units = buildDisplayUnits(messages);
|
||||||
|
|
||||||
expect(units).toHaveLength(2);
|
expect(units).toHaveLength(2);
|
||||||
expect(units[0]).toMatchObject({ type: "cluster" });
|
expect(units[0]).toMatchObject({ type: "activity" });
|
||||||
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||||
"r1",
|
"r1",
|
||||||
"t1",
|
"t1",
|
||||||
"a1-reasoning",
|
"a1-reasoning",
|
||||||
]);
|
]);
|
||||||
expect(units[0].type === "cluster" ? units[0].messages.at(-1)?.latencyMs : undefined).toBe(9_200);
|
expect(units[0].type === "activity" ? units[0].messages.at(-1)?.latencyMs : undefined).toBe(9_200);
|
||||||
expect(units[1]).toMatchObject({
|
expect(units[1]).toMatchObject({
|
||||||
type: "single",
|
type: "message",
|
||||||
message: {
|
message: {
|
||||||
id: "a1",
|
id: "a1",
|
||||||
content: "final answer",
|
content: "final answer",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (units[1].type === "single") {
|
if (units[1].type === "message") {
|
||||||
expect(units[1].message).not.toHaveProperty("reasoning");
|
expect(units[1].message).not.toHaveProperty("reasoning");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -290,12 +285,12 @@ describe("ThreadMessages", () => {
|
|||||||
const units = buildDisplayUnits(messages);
|
const units = buildDisplayUnits(messages);
|
||||||
|
|
||||||
expect(units).toHaveLength(2);
|
expect(units).toHaveLength(2);
|
||||||
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||||
"t0",
|
"t0",
|
||||||
"t1",
|
"t1",
|
||||||
]);
|
]);
|
||||||
expect(units[1]).toMatchObject({
|
expect(units[1]).toMatchObject({
|
||||||
type: "single",
|
type: "message",
|
||||||
message: {
|
message: {
|
||||||
id: "a1",
|
id: "a1",
|
||||||
content: "partial answer",
|
content: "partial answer",
|
||||||
@ -340,12 +335,12 @@ describe("ThreadMessages", () => {
|
|||||||
const units = buildDisplayUnits(messages);
|
const units = buildDisplayUnits(messages);
|
||||||
|
|
||||||
expect(units).toHaveLength(2);
|
expect(units).toHaveLength(2);
|
||||||
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||||
"r1",
|
"r1",
|
||||||
"t1",
|
"t1",
|
||||||
]);
|
]);
|
||||||
expect(units[1]).toMatchObject({
|
expect(units[1]).toMatchObject({
|
||||||
type: "single",
|
type: "message",
|
||||||
message: {
|
message: {
|
||||||
id: "a1",
|
id: "a1",
|
||||||
content: "Hong Kong is hot today.",
|
content: "Hong Kong is hot today.",
|
||||||
@ -391,12 +386,12 @@ describe("ThreadMessages", () => {
|
|||||||
const units = buildDisplayUnits(messages);
|
const units = buildDisplayUnits(messages);
|
||||||
|
|
||||||
expect(units).toHaveLength(2);
|
expect(units).toHaveLength(2);
|
||||||
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||||
"prelude",
|
"prelude",
|
||||||
"tool",
|
"tool",
|
||||||
]);
|
]);
|
||||||
expect(units[1]).toMatchObject({
|
expect(units[1]).toMatchObject({
|
||||||
type: "single",
|
type: "message",
|
||||||
message: {
|
message: {
|
||||||
id: "final",
|
id: "final",
|
||||||
content: "Done. Open index.html to play.",
|
content: "Done. Open index.html to play.",
|
||||||
@ -404,7 +399,7 @@ describe("ThreadMessages", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes assistant turn latency to the preceding completed activity cluster", () => {
|
it("passes assistant turn latency to the preceding completed activity timeline", () => {
|
||||||
const messages: UIMessage[] = [
|
const messages: UIMessage[] = [
|
||||||
{
|
{
|
||||||
id: "r1",
|
id: "r1",
|
||||||
@ -496,7 +491,7 @@ describe("ThreadMessages", () => {
|
|||||||
const flags = assistantCopyFlags(units);
|
const flags = assistantCopyFlags(units);
|
||||||
const assistantFlags = units
|
const assistantFlags = units
|
||||||
.map((unit, index) =>
|
.map((unit, index) =>
|
||||||
unit.type === "single" && unit.message.role === "assistant"
|
unit.type === "message" && unit.message.role === "assistant"
|
||||||
? [unit.message.id, flags[index]]
|
? [unit.message.id, flags[index]]
|
||||||
: null,
|
: null,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -270,7 +270,7 @@ describe("ThreadShell", () => {
|
|||||||
expect(await screen.findByTestId("composer-model-logo-openai_codex")).toBeInTheDocument();
|
expect(await screen.findByTestId("composer-model-logo-openai_codex")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("only shows image generation controls when the setting is enabled", async () => {
|
it("keeps image generation controls out of the composer", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
const disabledSettings = modelSettings("deepseek-v4-pro", "deepseek");
|
const disabledSettings = modelSettings("deepseek-v4-pro", "deepseek");
|
||||||
const enabledSettings: SettingsPayload = {
|
const enabledSettings: SettingsPayload = {
|
||||||
@ -313,7 +313,7 @@ describe("ThreadShell", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: "Toggle image generation mode" })).toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Toggle image generation mode" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores in-memory messages when switching away and back to a session", async () => {
|
it("restores in-memory messages when switching away and back to a session", async () => {
|
||||||
@ -1092,8 +1092,6 @@ describe("ThreadShell", () => {
|
|||||||
expect(screen.queryByText("Design an app icon")).not.toBeInTheDocument();
|
expect(screen.queryByText("Design an app icon")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Write code")).not.toBeInTheDocument();
|
expect(screen.queryByText("Write code")).not.toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Toggle image generation mode" }));
|
|
||||||
|
|
||||||
expect(screen.queryByText("Design an app icon")).not.toBeInTheDocument();
|
expect(screen.queryByText("Design an app icon")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Write code")).not.toBeInTheDocument();
|
expect(screen.queryByText("Write code")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1240,6 +1240,66 @@ describe("useNanobotStream", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps assistant html media as a file attachment", () => {
|
||||||
|
const fake = fakeClient();
|
||||||
|
const { result } = renderHook(() => useNanobotStream("chat-html-media", EMPTY_MESSAGES), {
|
||||||
|
wrapper: wrap(fake.client),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fake.emit("chat-html-media", {
|
||||||
|
event: "message",
|
||||||
|
chat_id: "chat-html-media",
|
||||||
|
text: "file ready",
|
||||||
|
media_urls: [{ url: "/api/media/sig/html", name: "index.html" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.messages[0].media).toEqual([
|
||||||
|
{ kind: "file", url: "/api/media/sig/html", name: "index.html" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("infers assistant svg media as an image attachment", () => {
|
||||||
|
const fake = fakeClient();
|
||||||
|
const { result } = renderHook(() => useNanobotStream("chat-svg-media", EMPTY_MESSAGES), {
|
||||||
|
wrapper: wrap(fake.client),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fake.emit("chat-svg-media", {
|
||||||
|
event: "message",
|
||||||
|
chat_id: "chat-svg-media",
|
||||||
|
text: "chart ready",
|
||||||
|
media_urls: [{ url: "/api/media/sig/svg", name: "growth.svg" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.messages[0].media).toEqual([
|
||||||
|
{ kind: "image", url: "/api/media/sig/svg", name: "growth.svg" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("corrects explicit image media when the name is a non-image file", () => {
|
||||||
|
const fake = fakeClient();
|
||||||
|
const { result } = renderHook(() => useNanobotStream("chat-mislabelled-html", EMPTY_MESSAGES), {
|
||||||
|
wrapper: wrap(fake.client),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fake.emit("chat-mislabelled-html", {
|
||||||
|
event: "message",
|
||||||
|
chat_id: "chat-mislabelled-html",
|
||||||
|
text: "file ready",
|
||||||
|
media_urls: [{ kind: "image", url: "/api/media/sig/html", name: "index.html" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.messages[0].media).toEqual([
|
||||||
|
{ kind: "file", url: "/api/media/sig/html", name: "index.html" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("suppresses redundant stream confirmation after assistant media", () => {
|
it("suppresses redundant stream confirmation after assistant media", () => {
|
||||||
const fake = fakeClient();
|
const fake = fakeClient();
|
||||||
const { result } = renderHook(() => useNanobotStream("chat-img-result", EMPTY_MESSAGES), {
|
const { result } = renderHook(() => useNanobotStream("chat-img-result", EMPTY_MESSAGES), {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user