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:
@@ -17,6 +17,7 @@ from nanobot.session.webui_turns import (
|
||||
WEBUI_SESSION_METADATA_KEY,
|
||||
WEBUI_TITLE_METADATA_KEY,
|
||||
WebuiTurnCoordinator,
|
||||
clean_generated_title,
|
||||
maybe_generate_webui_title,
|
||||
)
|
||||
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"
|
||||
|
||||
|
||||
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
|
||||
async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
|
||||
@@ -43,6 +43,32 @@ def test_list_sessions_includes_metadata_title(tmp_path):
|
||||
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):
|
||||
manager = SessionManager(tmp_path)
|
||||
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,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.websocket.cli_apps_payload",
|
||||
"nanobot.webui.settings_routes.cli_apps_payload",
|
||||
lambda: {
|
||||
"apps": [
|
||||
{
|
||||
@@ -173,7 +173,7 @@ async def test_cli_apps_routes_require_token_and_return_payload(
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.websocket.cli_apps_action",
|
||||
"nanobot.webui.settings_routes.cli_apps_action",
|
||||
lambda action, query: {
|
||||
"apps": [],
|
||||
"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}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.websocket.request_mcp_reload",
|
||||
"nanobot.webui.settings_routes.request_mcp_reload",
|
||||
_hot_reload,
|
||||
)
|
||||
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"
|
||||
|
||||
|
||||
@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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -469,6 +469,28 @@ def test_config_auto_detects_xiaomi_mimo_from_model_keyword():
|
||||
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():
|
||||
config = Config.model_validate(
|
||||
{
|
||||
|
||||
@@ -22,6 +22,18 @@ def test_anthropic_disables_sdk_retries_by_default() -> None:
|
||||
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:
|
||||
with patch("nanobot.providers.azure_openai_provider.AsyncOpenAI") as mock_client:
|
||||
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:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
key = "websocket:t-file"
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
@@ -10,6 +11,7 @@ from nanobot.webui.settings_api import (
|
||||
WebUISettingsError,
|
||||
_oauth_provider_status,
|
||||
create_model_configuration,
|
||||
provider_models_payload,
|
||||
settings_payload,
|
||||
update_agent_settings,
|
||||
update_model_configuration,
|
||||
@@ -336,6 +338,101 @@ def test_openai_codex_oauth_status_rejects_unavailable_token(
|
||||
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(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
Reference in New Issue
Block a user