diff --git a/README.md b/README.md
index d4e5db46..b5e4b02c 100644
--- a/README.md
+++ b/README.md
@@ -212,6 +212,7 @@ nanobot agent
- Want different LLM providers, web search, MCP, security settings, or more config options? See [Configuration](./docs/configuration.md)
+- Want to run locally? Use [Atomic Chat](./docs/configuration.md#atomic-chat-local), [vLLM](./docs/configuration.md#vllm-local-openai-compatible), [Ollama](./docs/configuration.md#ollama-local), and [others](./docs/configuration.md#local-providers).
- Want to run nanobot in chat apps like Telegram, Discord, WeChat or Feishu? See [Chat Apps](./docs/chat-apps.md)
- Want Docker or Linux service deployment? See [Deployment](./docs/deployment.md)
@@ -329,4 +330,4 @@ This project was started by [Xubin Ren](https://github.com/re-bin) as a personal
Thanks for visiting ✨ nanobot!
-
\ No newline at end of file
+
diff --git a/docs/configuration.md b/docs/configuration.md
index b5d74f7c..fed07ff2 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -152,6 +152,7 @@ ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
| `mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) |
| `longcat` | LLM (LongCat) | [longcat.chat](https://longcat.chat/platform/docs/zh/) |
+| `ant_ling` | LLM (Ant Ling / 蚂蚁百灵) | [developer.ant-ling.com](https://developer.ant-ling.com/en/docs/api-reference/openai/) |
| `ollama` | LLM (local, Ollama) | — |
| `lm_studio` | LLM (local, LM Studio) | — |
| `atomic_chat` | LLM (local, [Atomic Chat](https://atomic.chat/)) | — |
@@ -444,6 +445,34 @@ Official model names include `LongCat-Flash-Chat`, `LongCat-Flash-Thinking`,
+
+Ant Ling (OpenAI-compatible)
+
+Ant Ling is available through nanobot's built-in OpenAI-compatible provider flow.
+The default API base points to `https://api.ant-ling.com/v1`, so you usually
+only need to set `apiKey`.
+
+```json
+{
+ "providers": {
+ "antLing": {
+ "apiKey": "${ANT_LING_API_KEY}"
+ }
+ },
+ "agents": {
+ "defaults": {
+ "provider": "ant_ling",
+ "model": "Ling-2.6-flash"
+ }
+ }
+}
+```
+
+Official OpenAI-compatible model names include `Ling-2.6-1T`,
+`Ling-2.6-flash`, `Ling-2.5-1T`, `Ling-1T`, `Ring-2.5-1T`, and `Ring-1T`.
+
+
+
Custom Provider (Any OpenAI-compatible API)
@@ -512,6 +541,8 @@ Some OpenAI-compatible gateways expose request-body extensions such as vLLM guid
+
+Ollama (local)
@@ -577,12 +608,19 @@ ollama run llama3.2
+Atomic Chat (local)
-[Atomic Chat](https://atomic.chat/) is a local-first desktop app that exposes an **OpenAI-compatible** HTTP API (default `http://localhost:1337/v1`). Start Atomic Chat and enable the local API server, then point nanobot at it.
+[Atomic Chat](https://atomic.chat/) is a local-first desktop app that exposes an **OpenAI-compatible** HTTP API (default `http://localhost:1337/v1`). Use it when you want to run nanobot against a model on your own machine instead of a hosted API provider.
-**1. Add to config** (partial — merge into `~/.nanobot/config.json`):
+**1. Start Atomic Chat**
+
+- Install [Atomic Chat](https://atomic.chat/) on your machine.
+- Open Atomic Chat, download a model, and keep the app running. The local API is enabled by default.
+- Copy the model ID exposed by the local API. For example, the model ID for `Qwen 3 32B` might be `qwen3-32b`.
+
+**2. Add to config** (partial — merge into `~/.nanobot/config.json`):
```json
{
@@ -595,13 +633,13 @@ ollama run llama3.2
"agents": {
"defaults": {
"provider": "atomic_chat",
- "model": "your-model-id-from-atomic-chat"
+ "model": "qwen3-32b"
}
}
}
```
-> **Note:** Set `apiKey` to `null` if your Atomic Chat server does not require a key. If it does, set `apiKey` (or the `ATOMIC_CHAT_API_KEY` environment variable) to the value Atomic Chat expects. The `model` string must match the model id Atomic Chat exposes on its OpenAI-compatible endpoint.
+> **Note:** Replace `qwen3-32b` with the model ID from Atomic Chat. Set `apiKey` to `null` if your Atomic Chat server does not require a key. If it does, set `apiKey` (or the `ATOMIC_CHAT_API_KEY` environment variable) to the value Atomic Chat expects.
> `provider: "auto"` also works when `providers.atomic_chat.apiBase` is configured, but setting `"provider": "atomic_chat"` is the clearest option.
@@ -682,6 +720,7 @@ docker run -d \
> See the [official OVMS docs](https://docs.openvino.ai/2026/model-server/ovms_docs_llm_quickstart.html) for more details.
+vLLM (local / OpenAI-compatible)
diff --git a/docs/image-generation.md b/docs/image-generation.md
index 5c63fddf..6ca04929 100644
--- a/docs/image-generation.md
+++ b/docs/image-generation.md
@@ -6,8 +6,6 @@ The feature is disabled by default. Enable it in `~/.nanobot/config.json`, confi
## Quick Setup
-OpenRouter example:
-
```json
{
"providers": {
@@ -19,34 +17,13 @@ OpenRouter example:
"imageGeneration": {
"enabled": true,
"provider": "openrouter",
- "model": "openai/gpt-5.4-image-2",
- "defaultAspectRatio": "1:1",
- "defaultImageSize": "1K"
+ "model": "openai/gpt-5.4-image-2"
}
}
}
```
-AIHubMix example:
-
-```json
-{
- "providers": {
- "aihubmix": {
- "apiKey": "${AIHUBMIX_API_KEY}"
- }
- },
- "tools": {
- "imageGeneration": {
- "enabled": true,
- "provider": "aihubmix",
- "model": "gpt-image-2-free",
- "defaultAspectRatio": "1:1",
- "defaultImageSize": "1K"
- }
- }
-}
-```
+See [Provider Notes](#provider-notes) for AIHubMix, MiniMax, and Gemini configuration examples.
> [!TIP]
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
@@ -69,7 +46,7 @@ The WebUI hides provider storage details from the user. The agent sees the saved
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
-| `tools.imageGeneration.provider` | string | `"openrouter"` | Image provider name. Currently `openrouter` and `aihubmix` are supported |
+| `tools.imageGeneration.provider` | string | `"openrouter"` | Image provider name. Supported values: `openrouter`, `aihubmix`, `minimax`, `gemini` |
| `tools.imageGeneration.model` | string | `"openai/gpt-5.4-image-2"` | Provider model name |
| `tools.imageGeneration.defaultAspectRatio` | string | `"1:1"` | Default ratio when the prompt/tool call does not specify one |
| `tools.imageGeneration.defaultImageSize` | string | `"1K"` | Default size hint, for example `1K`, `2K`, `4K`, or `1024x1024` |
@@ -139,6 +116,58 @@ Configure:
`quality: low` is optional. It can make free image models faster and less likely to time out, but it is not required for correctness.
+### MiniMax
+
+MiniMax `image-01` supports text-to-image and reference-image (subject reference) edits. Supported aspect ratios are `1:1`, `16:9`, `4:3`, `3:2`, `2:3`, `3:4`, `9:16`, and `21:9`.
+
+```json
+{
+ "providers": {
+ "minimax": {
+ "apiKey": "${MINIMAX_API_KEY}"
+ }
+ },
+ "tools": {
+ "imageGeneration": {
+ "enabled": true,
+ "provider": "minimax",
+ "model": "image-01",
+ "defaultAspectRatio": "1:1"
+ }
+ }
+}
+```
+
+### Gemini
+
+nanobot supports two Gemini image generation model families via Google's Generative Language API:
+
+| Model | Endpoint | Reference images |
+|-------|----------|-----------------|
+| `imagen-4.0-generate-001` | `:predict` | Not supported by this integration |
+| `gemini-2.5-flash-image` | `:generateContent` | Supported |
+
+For reference-image edits, use a Gemini Flash image model:
+
+```json
+{
+ "providers": {
+ "gemini": {
+ "apiKey": "${GEMINI_API_KEY}"
+ }
+ },
+ "tools": {
+ "imageGeneration": {
+ "enabled": true,
+ "provider": "gemini",
+ "model": "gemini-2.5-flash-image"
+ }
+ }
+}
+```
+
+Imagen 4 supports the aspect ratios `1:1`, `9:16`, `16:9`, `3:4`, and `4:3`. Unsupported ratios are ignored and the model uses its default. The `defaultImageSize` setting has no effect on Gemini models; sizing is controlled by `defaultAspectRatio` only. Reference images passed with an Imagen model are ignored (with a warning logged).
+
## Artifacts
Generated images are stored under the active nanobot instance's media directory:
@@ -193,7 +222,7 @@ Use the reference image. Keep the same robot and composition, change the palette
|---------|-------|
| `generate_image` is not available | Set `tools.imageGeneration.enabled` to `true` and restart the gateway |
| Missing API key error | Configure `providers..apiKey`; if using `${VAR_NAME}`, confirm the environment variable is visible to the gateway process |
-| `unsupported image generation provider` | Use `openrouter` or `aihubmix` |
+| `unsupported image generation provider` | Use `openrouter`, `aihubmix`, `minimax`, or `gemini` |
| AIHubMix says `Incorrect model ID` | Use `model: "gpt-image-2-free"`; nanobot expands it to the required `openai/gpt-image-2-free` model path internally |
| Generation times out | Try a smaller/default image size, set AIHubMix `extraBody.quality` to `"low"`, or retry later |
| Reference image rejected | Reference image paths must be inside the workspace or nanobot media directory and must be valid image files |
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index c1f52117..abc6449d 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -36,19 +36,17 @@ from nanobot.session.goal_state import (
runner_wall_llm_timeout_s,
)
from nanobot.session.manager import Session, SessionManager
-from nanobot.utils.artifacts import generated_image_paths_from_messages
+from nanobot.session.webui_turns import (
+ WebuiTurnCoordinator,
+ build_bus_progress_callback,
+ mark_webui_session,
+)
from nanobot.utils.document import extract_documents
from nanobot.utils.helpers import image_placeholder_text
from nanobot.utils.helpers import truncate_text as truncate_text_fn
from nanobot.utils.image_generation_intent import image_generation_prompt
from nanobot.utils.llm_runtime import LLMRuntime
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
-from nanobot.utils.session_attachments import merge_turn_media_into_last_assistant
-from nanobot.utils.webui_turn_helpers import (
- WebuiTurnCoordinator,
- build_bus_progress_callback,
- mark_webui_session,
-)
if TYPE_CHECKING:
from nanobot.config.schema import (
@@ -103,7 +101,6 @@ class TurnContext:
save_skip: int = 0
outbound: OutboundMessage | None = None
- generated_media: list[str] = field(default_factory=list)
on_progress: Callable[..., Awaitable[None]] | None = None
on_stream: Callable[[str], Awaitable[None]] | None = None
@@ -1194,7 +1191,6 @@ class AgentLoop:
all_msgs: list[dict[str, Any]],
stop_reason: str,
had_injections: bool,
- generated_media: list[str],
on_stream: Callable[[str], Awaitable[None]] | None,
*,
turn_latency_ms: int | None = None,
@@ -1218,7 +1214,6 @@ class AgentLoop:
channel=msg.channel,
chat_id=msg.chat_id,
content=final_content,
- media=generated_media,
metadata=meta,
)
@@ -1348,11 +1343,6 @@ class AgentLoop:
ctx.final_content = EMPTY_FINAL_RESPONSE_MESSAGE
ctx.save_skip = 1 + len(ctx.history) + (1 if ctx.user_persisted_early else 0)
- skip_msgs = ctx.all_messages[ctx.save_skip:]
- ctx.generated_media = generated_image_paths_from_messages(skip_msgs)
- mt = self.tools.get("message")
- extra = getattr(mt, "turn_delivered_media_paths", lambda: [])() if mt else []
- merge_turn_media_into_last_assistant(ctx.all_messages, ctx.generated_media, extra)
ctx.turn_latency_ms = max(0, int((time.time() - ctx.turn_wall_started_at) * 1000))
self._save_turn(
@@ -1380,7 +1370,6 @@ class AgentLoop:
ctx.all_messages,
ctx.stop_reason,
ctx.had_injections,
- ctx.generated_media,
ctx.on_stream,
turn_latency_ms=ctx.turn_latency_ms,
)
diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py
index 776885ec..0b0164fd 100644
--- a/nanobot/agent/runner.py
+++ b/nanobot/agent/runner.py
@@ -20,6 +20,7 @@ from nanobot.utils.file_edit_events import (
build_file_edit_error_event,
build_file_edit_start_event,
prepare_file_edit_tracker,
+ StreamingFileEditTracker,
)
from nanobot.utils.helpers import (
IncrementalThinkExtractor,
@@ -629,6 +630,24 @@ class AgentRunner:
)
progress_state: dict[str, bool] | None = None
+ live_file_edits: StreamingFileEditTracker | None = None
+
+ if (
+ spec.progress_callback is not None
+ and on_progress_accepts_file_edit_events(spec.progress_callback)
+ ):
+ async def _emit_live_file_edits(events: list[dict[str, Any]]) -> None:
+ await invoke_file_edit_progress(spec.progress_callback, events)
+
+ live_file_edits = StreamingFileEditTracker(
+ workspace=spec.workspace,
+ tools=spec.tools,
+ emit=_emit_live_file_edits,
+ )
+
+ async def _tool_call_delta(delta: dict[str, Any]) -> None:
+ if live_file_edits is not None:
+ await live_file_edits.update(delta)
if wants_streaming:
async def _stream(delta: str) -> None:
@@ -646,6 +665,7 @@ class AgentRunner:
**kwargs,
on_content_delta=_stream,
on_thinking_delta=_thinking,
+ on_tool_call_delta=_tool_call_delta if live_file_edits is not None else None,
)
elif wants_progress_streaming:
stream_buf = ""
@@ -675,6 +695,7 @@ class AgentRunner:
coro = self.provider.chat_stream_with_retry(
**kwargs,
on_content_delta=_stream_progress,
+ on_tool_call_delta=_tool_call_delta if live_file_edits is not None else None,
)
else:
coro = self.provider.chat_with_retry(**kwargs)
@@ -689,6 +710,14 @@ class AgentRunner:
await coro if outer_timeout_s is None
else await asyncio.wait_for(coro, timeout=outer_timeout_s)
)
+ if live_file_edits is not None:
+ await live_file_edits.flush()
+ if response.should_execute_tools:
+ live_file_edits.apply_final_call_ids(response.tool_calls)
+ await live_file_edits.error_unmatched(
+ response.tool_calls if response.should_execute_tools else [],
+ "Tool call did not complete.",
+ )
except asyncio.TimeoutError:
if outer_timeout_s is None:
return LLMResponse(
@@ -907,7 +936,10 @@ class AgentRunner:
if file_edit_tracker is not None and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
- [build_file_edit_end_event(file_edit_tracker)],
+ [build_file_edit_end_event(
+ file_edit_tracker,
+ params if isinstance(params, dict) else None,
+ )],
)
detail = "" if result is None else str(result)
diff --git a/nanobot/agent/tools/image_generation.py b/nanobot/agent/tools/image_generation.py
index f9d4056d..f2f599de 100644
--- a/nanobot/agent/tools/image_generation.py
+++ b/nanobot/agent/tools/image_generation.py
@@ -17,9 +17,9 @@ from nanobot.agent.tools.schema import (
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
from nanobot.providers.image_generation import (
- AIHubMixImageGenerationClient,
ImageGenerationError,
- OpenRouterImageGenerationClient,
+ ImageGenerationProvider,
+ get_image_gen_provider,
)
from nanobot.utils.artifacts import (
ArtifactError,
@@ -117,27 +117,24 @@ class ImageGenerationTool(Tool):
def _provider_config(self) -> ProviderConfig | None:
return self.provider_configs.get(self.config.provider)
- def _provider_client(self) -> OpenRouterImageGenerationClient | AIHubMixImageGenerationClient | None:
+ def _provider_client(self) -> ImageGenerationProvider | None:
provider = self._provider_config()
+ cls = get_image_gen_provider(self.config.provider)
+ if cls is None:
+ return None
kwargs = {
"api_key": provider.api_key if provider else None,
"api_base": provider.api_base if provider else None,
"extra_headers": provider.extra_headers if provider else None,
"extra_body": provider.extra_body if provider else None,
}
- if self.config.provider == "openrouter":
- return OpenRouterImageGenerationClient(**kwargs)
- if self.config.provider == "aihubmix":
- return AIHubMixImageGenerationClient(**kwargs)
- return None
+ return cls(**kwargs)
def _missing_api_key_error(self) -> str:
- provider = self.config.provider
- if provider == "openrouter":
- return "Error: OpenRouter API key is not configured. Set providers.openrouter.apiKey."
- if provider == "aihubmix":
- return "Error: AIHubMix API key is not configured. Set providers.aihubmix.apiKey."
- return f"Error: {provider} API key is not configured."
+ cls = get_image_gen_provider(self.config.provider)
+ if cls and cls.missing_key_message:
+ return f"Error: {cls.missing_key_message}"
+ return f"Error: {self.config.provider} API key is not configured."
def _resolve_reference_image(self, value: str) -> str:
raw_path = Path(value).expanduser()
diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py
index 725e824e..63b45c38 100644
--- a/nanobot/agent/tools/message.py
+++ b/nanobot/agent/tools/message.py
@@ -31,8 +31,8 @@ from nanobot.config.paths import get_workspace_path
media=ArraySchema(
StringSchema(""),
description=(
- "Optional list of existing file paths to attach for proactive or cross-channel delivery. "
- "Do not use this to resend generate_image outputs in the current chat."
+ "Optional list of existing file paths to attach. "
+ "Use artifact paths returned by generate_image here when delivering generated images."
),
),
buttons=ArraySchema(
@@ -140,8 +140,8 @@ class MessageTool(Tool, ContextAware):
"Do not use this for the normal reply in the current chat: answer naturally instead. "
"If channel/chat_id would target the current runtime conversation, do not call this tool "
"unless the user explicitly asked you to proactively send an existing file attachment. "
- "When generate_image creates images in the current chat, the final assistant reply "
- "automatically attaches them; do not call message just to announce or resend them. "
+ "When generate_image creates images in the current chat, use the message tool "
+ "with the artifact paths in the media parameter to deliver the images to the user. "
"For proactive attachment delivery, use the 'media' parameter with file paths. "
"Do NOT use read_file to send files — that only reads content for your own analysis."
)
diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py
index 0202bd33..11818ea9 100644
--- a/nanobot/channels/websocket.py
+++ b/nanobot/channels/websocket.py
@@ -37,15 +37,27 @@ from nanobot.command.builtin import builtin_command_palette
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
from nanobot.session.goal_state import goal_state_ws_blob
+from nanobot.session.webui_turns import websocket_turn_wall_started_at
from nanobot.utils.helpers import safe_filename
from nanobot.utils.media_decode import (
FileSizeExceeded,
save_base64_data_url,
)
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
-from nanobot.utils.webui_thread_disk import delete_webui_thread
-from nanobot.utils.webui_transcript import append_transcript_object, build_webui_thread_response
-from nanobot.utils.webui_turn_helpers import websocket_turn_wall_started_at
+from nanobot.webui.settings_api import (
+ WebUISettingsError,
+ settings_payload,
+ update_agent_settings,
+ update_image_generation_settings,
+ update_provider_settings,
+ update_web_search_settings,
+)
+from nanobot.webui.sidebar_state import (
+ read_webui_sidebar_state,
+ write_webui_sidebar_state,
+)
+from nanobot.webui.thread_disk import delete_webui_thread
+from nanobot.webui.transcript import append_transcript_object, build_webui_thread_response
if TYPE_CHECKING:
from nanobot.session.manager import SessionManager
@@ -222,47 +234,6 @@ def _query_first(query: dict[str, list[str]], key: str) -> str | None:
return values[0] if values else None
-def _mask_secret_hint(secret: str | None) -> str | None:
- if not secret:
- return None
- if len(secret) <= 8:
- return "••••"
- return f"{secret[:4]}••••{secret[-4:]}"
-
-
-def _provider_requires_api_key(spec: Any) -> bool:
- if spec.backend == "azure_openai":
- return True
- if spec.is_local or spec.is_direct:
- return False
- return True
-
-
-def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
- if _provider_requires_api_key(spec):
- return bool(provider_config.api_key)
- return bool(
- provider_config.api_key
- or provider_config.api_base
- or getattr(provider_config, "region", None)
- or getattr(provider_config, "profile", None)
- )
-
-
-_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
- {"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
- {"name": "brave", "label": "Brave Search", "credential": "api_key"},
- {"name": "tavily", "label": "Tavily", "credential": "api_key"},
- {"name": "searxng", "label": "SearXNG", "credential": "base_url"},
- {"name": "jina", "label": "Jina", "credential": "api_key"},
- {"name": "kagi", "label": "Kagi", "credential": "api_key"},
- {"name": "olostep", "label": "Olostep", "credential": "api_key"},
-)
-_WEB_SEARCH_PROVIDER_BY_NAME = {
- provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS
-}
-
-
def _parse_inbound_payload(raw: str) -> str | None:
"""Parse a client frame into text; return None for empty or unrecognized content."""
text = raw.strip()
@@ -501,6 +472,7 @@ class WebSocketChannel(BaseChannel):
static_dist_path.resolve() if static_dist_path is not None else None
)
self._runtime_model_name = runtime_model_name
+ self._settings_restart_sections: set[str] = set()
# 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
# file, nothing else. The secret regenerates on restart so links
@@ -663,6 +635,12 @@ class WebSocketChannel(BaseChannel):
if got == "/api/commands":
return self._handle_commands(request)
+ if got == "/api/webui/sidebar-state":
+ return self._handle_webui_sidebar_state(request)
+
+ if got == "/api/webui/sidebar-state/update":
+ return self._handle_webui_sidebar_state_update(request)
+
if got == "/api/settings/update":
return self._handle_settings_update(request)
@@ -672,6 +650,9 @@ class WebSocketChannel(BaseChannel):
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)
+
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
if m:
return self._handle_session_messages(request, m.group(1))
@@ -783,221 +764,115 @@ class WebSocketChannel(BaseChannel):
sessions = self._session_manager.list_sessions()
# Sidebar/chat listing for WS-backed sessions only — CLI / Slack / etc.
# keys are not intended for resume over this HTTP surface.
- cleaned = [
- {k: v for k, v in s.items() if k != "path"}
- for s in sessions
- if isinstance(s.get("key"), str) and s["key"].startswith("websocket:")
- ]
- return _http_json_response({"sessions": cleaned})
-
- def _settings_payload(self, *, requires_restart: bool = False) -> dict[str, Any]:
- from nanobot.config.loader import get_config_path, load_config
- from nanobot.providers.registry import PROVIDERS, find_by_name
-
- config = load_config()
- defaults = config.agents.defaults
- provider_name = config.get_provider_name(defaults.model) or defaults.provider
- provider = config.get_provider(defaults.model)
- selected_provider = provider_name
- if defaults.provider != "auto":
- spec = find_by_name(defaults.provider)
- selected_provider = spec.name if spec else provider_name
- providers = []
- for spec in PROVIDERS:
- provider_config = getattr(config.providers, spec.name, None)
- if provider_config is None or spec.is_oauth:
+ cleaned = []
+ for s in sessions:
+ key = s.get("key")
+ if not (isinstance(key, str) and key.startswith("websocket:")):
continue
- providers.append(
- {
- "name": spec.name,
- "label": spec.label,
- "configured": _provider_configured_for_settings(spec, provider_config),
- "api_key_required": _provider_requires_api_key(spec),
- "api_key_hint": _mask_secret_hint(provider_config.api_key),
- "api_base": provider_config.api_base,
- "default_api_base": spec.default_api_base or None,
- }
- )
- search_config = config.tools.web.search
- search_provider = (
- search_config.provider
- if search_config.provider in _WEB_SEARCH_PROVIDER_BY_NAME
- else "duckduckgo"
- )
- return {
- "agent": {
- "model": defaults.model,
- "provider": selected_provider,
- "resolved_provider": provider_name,
- "has_api_key": bool(provider and provider.api_key),
- },
- "providers": providers,
- "web_search": {
- "provider": search_provider,
- "api_key_hint": _mask_secret_hint(search_config.api_key),
- "base_url": search_config.base_url or None,
- "providers": list(_WEB_SEARCH_PROVIDER_OPTIONS),
- },
- "runtime": {
- "config_path": str(get_config_path().expanduser()),
- },
- "requires_restart": requires_restart,
- }
+ row = {k: v for k, v in s.items() if k != "path"}
+ chat_id = key.split(":", 1)[1]
+ started_at = websocket_turn_wall_started_at(chat_id)
+ if started_at is not None:
+ row["run_started_at"] = started_at
+ cleaned.append(row)
+ return _http_json_response({"sessions": cleaned})
def _handle_settings(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
- return _http_json_response(self._settings_payload())
+ return _http_json_response(self._with_settings_restart_state(settings_payload()))
+
+ 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)
+ if self._settings_restart_sections:
+ payload = dict(payload)
+ payload["requires_restart"] = True
+ payload["restart_required_sections"] = sorted(self._settings_restart_sections)
+ else:
+ payload = dict(payload)
+ payload["restart_required_sections"] = []
+ return payload
def _handle_commands(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response({"commands": builtin_command_palette()})
+ def _handle_webui_sidebar_state(self, request: WsRequest) -> Response:
+ if not self._check_api_token(request):
+ return _http_error(401, "Unauthorized")
+ return _http_json_response(read_webui_sidebar_state())
+
+ def _handle_webui_sidebar_state_update(self, request: WsRequest) -> Response:
+ if not self._check_api_token(request):
+ return _http_error(401, "Unauthorized")
+ query = _parse_query(request.path)
+ raw_state = _query_first(query, "state")
+ if raw_state is None:
+ return _http_error(400, "missing state")
+ try:
+ decoded = json.loads(raw_state)
+ except json.JSONDecodeError:
+ return _http_error(400, "state must be JSON")
+ if not isinstance(decoded, dict):
+ return _http_error(400, "state must be an object")
+ try:
+ state = write_webui_sidebar_state(decoded)
+ except ValueError as e:
+ return _http_error(400, str(e))
+ except OSError:
+ self.logger.exception("failed to write webui sidebar state")
+ return _http_error(500, "failed to write sidebar 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")
- from nanobot.config.loader import load_config, save_config
- from nanobot.providers.registry import find_by_name
-
query = _parse_query(request.path)
- config = load_config()
- defaults = config.agents.defaults
- changed = False
-
- model = _query_first(query, "model")
- if model is not None:
- model = model.strip()
- if not model:
- return _http_error(400, "model is required")
- if defaults.model != model:
- defaults.model = model
- changed = True
-
- provider = _query_first(query, "provider")
- if provider is not None:
- provider = provider.strip()
- if not provider:
- return _http_error(400, "provider is required")
- if find_by_name(provider) is None:
- return _http_error(400, "unknown provider")
- provider_config = getattr(config.providers, provider, None)
- spec = find_by_name(provider)
- if (
- provider_config is None
- or spec is None
- or not _provider_configured_for_settings(spec, provider_config)
- ):
- return _http_error(400, "provider is not configured")
- if defaults.provider != provider:
- defaults.provider = provider
- changed = True
-
- if changed:
- save_config(config)
- # LLM provider/model changes are hot-reloaded by AgentLoop before each
- # new turn via the provider snapshot loader, so a restart is unnecessary.
- return _http_json_response(self._settings_payload(requires_restart=False))
+ 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_provider_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
- from nanobot.config.loader import load_config, save_config
- from nanobot.providers.registry import find_by_name
-
query = _parse_query(request.path)
- provider_name = (_query_first(query, "provider") or "").strip()
- if not provider_name:
- return _http_error(400, "provider is required")
- spec = find_by_name(provider_name)
- if spec is None or spec.is_oauth:
- return _http_error(400, "unknown provider")
-
- config = load_config()
- provider_config = getattr(config.providers, spec.name, None)
- if provider_config is None:
- return _http_error(400, "unknown provider")
-
- changed = False
- if "api_key" in query or "apiKey" in query:
- api_key = _query_first(query, "api_key")
- if api_key is None:
- api_key = _query_first(query, "apiKey")
- api_key = (api_key or "").strip() or None
- if provider_config.api_key != api_key:
- provider_config.api_key = api_key
- changed = True
-
- if "api_base" in query or "apiBase" in query:
- api_base = _query_first(query, "api_base")
- if api_base is None:
- api_base = _query_first(query, "apiBase")
- api_base = (api_base or "").strip() or None
- if provider_config.api_base != api_base:
- provider_config.api_base = api_base
- changed = True
-
- if changed:
- save_config(config)
- # API key/base changes are picked up by the next provider snapshot refresh.
- return _http_json_response(self._settings_payload(requires_restart=False))
+ 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"))
def _handle_settings_web_search_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
- from nanobot.config.loader import load_config, save_config
-
query = _parse_query(request.path)
- provider_name = (_query_first(query, "provider") or "").strip().lower()
- provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
- if provider_option is None:
- return _http_error(400, "unknown web search provider")
+ 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="web"))
- config = load_config()
- search_config = config.tools.web.search
- previous_provider = search_config.provider
- changed = False
-
- def set_value(attr: str, value: str | None) -> None:
- nonlocal changed
- if getattr(search_config, attr) != value:
- setattr(search_config, attr, value)
- changed = True
-
- if search_config.provider != provider_name:
- search_config.provider = provider_name
- changed = True
-
- credential = provider_option["credential"]
- if credential == "none":
- set_value("api_key", "")
- set_value("base_url", "")
- elif credential == "base_url":
- base_url = _query_first(query, "base_url")
- if base_url is None:
- base_url = _query_first(query, "baseUrl")
- base_url = base_url.strip() if base_url is not None else None
- if not base_url and previous_provider == provider_name and search_config.base_url:
- base_url = search_config.base_url
- if not base_url:
- return _http_error(400, "base_url is required")
- set_value("base_url", base_url)
- set_value("api_key", "")
- else:
- api_key = _query_first(query, "api_key")
- if api_key is None:
- api_key = _query_first(query, "apiKey")
- api_key = api_key.strip() if api_key is not None else None
- if not api_key and previous_provider == provider_name and search_config.api_key:
- api_key = search_config.api_key
- if not api_key:
- return _http_error(400, "api_key is required")
- set_value("api_key", api_key)
- set_value("base_url", "")
-
- if changed:
- save_config(config)
- return _http_json_response(self._settings_payload(requires_restart=False))
+ 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"))
@staticmethod
def _is_websocket_channel_session_key(key: str) -> bool:
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 69420543..f7bf043a 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -620,6 +620,7 @@ def serve(
from nanobot.api.server import create_app
from nanobot.bus.queue import MessageBus
+ from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.session.manager import SessionManager
if verbose:
@@ -639,10 +640,7 @@ def serve(
agent_loop = AgentLoop.from_config(
runtime_config, bus,
session_manager=session_manager,
- image_generation_provider_configs={
- "openrouter": runtime_config.providers.openrouter,
- "aihubmix": runtime_config.providers.aihubmix,
- },
+ image_generation_provider_configs=image_gen_provider_configs(runtime_config),
)
except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]")
@@ -722,6 +720,7 @@ def _run_gateway(
from nanobot.cron.types import CronJob
from nanobot.heartbeat.service import HeartbeatService
from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot
+ from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.session.manager import SessionManager
port = port if port is not None else config.gateway.port
@@ -752,10 +751,7 @@ def _run_gateway(
context_window_tokens=provider_snapshot.context_window_tokens,
cron_service=cron,
session_manager=session_manager,
- image_generation_provider_configs={
- "openrouter": config.providers.openrouter,
- "aihubmix": config.providers.aihubmix,
- },
+ image_generation_provider_configs=image_gen_provider_configs(config),
provider_snapshot_loader=load_provider_snapshot,
runtime_model_publisher=lambda model, preset: publish_runtime_model_update(
bus,
@@ -1122,6 +1118,7 @@ def agent(
from nanobot.bus.queue import MessageBus
from nanobot.cron.service import CronService
+ from nanobot.providers.image_generation import image_gen_provider_configs
config = _load_runtime_config(config, workspace)
sync_workspace_templates(config.workspace_path)
@@ -1145,6 +1142,7 @@ def agent(
agent_loop = AgentLoop.from_config(
config, bus,
cron_service=cron,
+ image_generation_provider_configs=image_gen_provider_configs(config),
)
except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]")
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 96f9014a..6ccabea3 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -207,6 +207,7 @@ class ProvidersConfig(Base):
stepfun: ProviderConfig = Field(default_factory=ProviderConfig) # Step Fun (阶跃星辰)
xiaomi_mimo: ProviderConfig = Field(default_factory=ProviderConfig) # Xiaomi MIMO (小米)
longcat: ProviderConfig = Field(default_factory=ProviderConfig) # LongCat
+ ant_ling: ProviderConfig = Field(default_factory=ProviderConfig) # Ant Ling
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动)
volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎)
diff --git a/nanobot/nanobot.py b/nanobot/nanobot.py
index bfedb761..95185ba4 100644
--- a/nanobot/nanobot.py
+++ b/nanobot/nanobot.py
@@ -8,6 +8,7 @@ from typing import Any
from nanobot.agent.hook import AgentHook, SDKCaptureHook
from nanobot.agent.loop import AgentLoop
+from nanobot.providers.image_generation import image_gen_provider_configs
@dataclass(slots=True)
@@ -63,10 +64,7 @@ class Nanobot:
loop = AgentLoop.from_config(
config,
- image_generation_provider_configs={
- "openrouter": config.providers.openrouter,
- "aihubmix": config.providers.aihubmix,
- },
+ image_generation_provider_configs=image_gen_provider_configs(config),
)
return cls(loop)
diff --git a/nanobot/providers/anthropic_provider.py b/nanobot/providers/anthropic_provider.py
index b667853a..31f2bc2f 100644
--- a/nanobot/providers/anthropic_provider.py
+++ b/nanobot/providers/anthropic_provider.py
@@ -590,6 +590,7 @@ class AnthropicProvider(LLMProvider):
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
+ on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
kwargs = self._build_kwargs(
messages, tools, model, max_tokens, temperature,
@@ -598,11 +599,12 @@ class AnthropicProvider(LLMProvider):
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
try:
async with self._client.messages.stream(**kwargs) as stream:
- if on_content_delta or on_thinking_delta:
+ if on_content_delta or on_thinking_delta or on_tool_call_delta:
# Idle timeout must track *any* SSE chunk (thinking_delta,
# tool JSON deltas, etc.), not only text_stream tokens.
# Otherwise extended thinking can stall text_stream for minutes
# while the connection is healthy (e.g. MiniMax Anthropic).
+ tool_blocks: dict[int, dict[str, str]] = {}
while True:
try:
chunk = await asyncio.wait_for(
@@ -611,7 +613,22 @@ class AnthropicProvider(LLMProvider):
)
except StopAsyncIteration:
break
- if (
+ if chunk.type == "content_block_start":
+ block = getattr(chunk, "content_block", None)
+ if getattr(block, "type", None) == "tool_use":
+ index = int(getattr(chunk, "index", 0) or 0)
+ state = {
+ "call_id": str(getattr(block, "id", "") or ""),
+ "name": str(getattr(block, "name", "") or ""),
+ }
+ tool_blocks[index] = state
+ if on_tool_call_delta:
+ await on_tool_call_delta({
+ "index": index,
+ **state,
+ "arguments_delta": "",
+ })
+ elif (
chunk.type == "content_block_delta"
and getattr(chunk.delta, "type", None) == "thinking_delta"
):
@@ -625,6 +642,20 @@ class AnthropicProvider(LLMProvider):
text = getattr(chunk.delta, "text", None) or ""
if text and on_content_delta:
await on_content_delta(text)
+ elif (
+ chunk.type == "content_block_delta"
+ and getattr(chunk.delta, "type", None) == "input_json_delta"
+ ):
+ partial = getattr(chunk.delta, "partial_json", None) or ""
+ if partial and on_tool_call_delta:
+ index = int(getattr(chunk, "index", 0) or 0)
+ state = tool_blocks.get(index, {})
+ await on_tool_call_delta({
+ "index": index,
+ "call_id": state.get("call_id", ""),
+ "name": state.get("name", ""),
+ "arguments_delta": partial,
+ })
response = await asyncio.wait_for(
stream.get_final_message(),
timeout=idle_timeout_s,
diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py
index 918a11ce..24a65cdf 100644
--- a/nanobot/providers/azure_openai_provider.py
+++ b/nanobot/providers/azure_openai_provider.py
@@ -158,6 +158,7 @@ class AzureOpenAIProvider(LLMProvider):
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
+ on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
_ = on_thinking_delta
body = self._build_body(
@@ -169,7 +170,7 @@ class AzureOpenAIProvider(LLMProvider):
try:
stream = await self._client.responses.create(**body)
content, tool_calls, finish_reason, usage, reasoning_content = (
- await consume_sdk_stream(stream, on_content_delta)
+ await consume_sdk_stream(stream, on_content_delta, on_tool_call_delta)
)
return LLMResponse(
content=content or None,
diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py
index 98f048db..87697650 100644
--- a/nanobot/providers/base.py
+++ b/nanobot/providers/base.py
@@ -70,11 +70,11 @@ class LLMResponse:
@property
def should_execute_tools(self) -> bool:
- """Tools execute only when has_tool_calls AND finish_reason is ``tool_calls`` / ``stop``.
+ """Tools execute only when has_tool_calls AND finish_reason is a tool-capable stop.
Blocks gateway-injected calls under ``refusal`` / ``content_filter`` / ``error`` (#3220)."""
if not self.has_tool_calls:
return False
- return self.finish_reason in ("tool_calls", "stop")
+ return self.finish_reason in ("tool_calls", "function_call", "stop")
@dataclass(frozen=True)
@@ -501,6 +501,7 @@ class LLMProvider(ABC):
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
+ on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
"""Stream a chat completion, calling *on_content_delta* for each text chunk.
@@ -514,7 +515,7 @@ class LLMProvider(ABC):
full content as a single delta. Providers that support native
streaming should override this method.
"""
- _ = on_thinking_delta
+ _ = on_thinking_delta, on_tool_call_delta
response = await self.chat(
messages=messages, tools=tools, model=model,
max_tokens=max_tokens, temperature=temperature,
@@ -544,6 +545,7 @@ class LLMProvider(ABC):
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
+ on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
retry_mode: str = "standard",
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
) -> LLMResponse:
@@ -561,6 +563,7 @@ class LLMProvider(ABC):
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
+ on_tool_call_delta=on_tool_call_delta,
)
return await self._run_with_retry(
self._safe_chat_stream,
diff --git a/nanobot/providers/bedrock_provider.py b/nanobot/providers/bedrock_provider.py
index b3f4ea57..ff74badb 100644
--- a/nanobot/providers/bedrock_provider.py
+++ b/nanobot/providers/bedrock_provider.py
@@ -704,8 +704,9 @@ class BedrockProvider(LLMProvider):
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
+ on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
- _ = on_thinking_delta
+ _ = on_thinking_delta, on_tool_call_delta
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
content_parts: list[str] = []
reasoning_parts: list[str] = []
diff --git a/nanobot/providers/github_copilot_provider.py b/nanobot/providers/github_copilot_provider.py
index fdba99eb..bec7c11e 100644
--- a/nanobot/providers/github_copilot_provider.py
+++ b/nanobot/providers/github_copilot_provider.py
@@ -243,6 +243,7 @@ class GitHubCopilotProvider(OpenAICompatProvider):
tool_choice: str | dict[str, object] | None = None,
on_content_delta: Callable[[str], None] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
+ on_tool_call_delta: Callable[[dict[str, object]], Awaitable[None]] | None = None,
):
await self._refresh_client_api_key()
return await super().chat_stream(
@@ -255,4 +256,5 @@ class GitHubCopilotProvider(OpenAICompatProvider):
tool_choice=tool_choice,
on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
+ on_tool_call_delta=on_tool_call_delta,
)
diff --git a/nanobot/providers/image_generation.py b/nanobot/providers/image_generation.py
index d1e7a1b2..08e57cba 100644
--- a/nanobot/providers/image_generation.py
+++ b/nanobot/providers/image_generation.py
@@ -3,11 +3,14 @@
from __future__ import annotations
import base64
+import binascii
+from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import httpx
+from loguru import logger
from nanobot.providers.registry import find_by_name
from nanobot.utils.helpers import detect_image_mime
@@ -26,6 +29,8 @@ _AIHUBMIX_ASPECT_RATIO_SIZES = {
"4:3": "1536x1024",
"16:9": "1536x1024",
}
+_GEMINI_DEFAULT_TIMEOUT_S = 120.0
+_GEMINI_IMAGEN_ASPECT_RATIOS = {"1:1", "9:16", "16:9", "3:4", "4:3"}
class ImageGenerationError(RuntimeError):
@@ -41,28 +46,38 @@ class GeneratedImageResponse:
raw: dict[str, Any]
-def _provider_base_url(provider: str, api_base: str | None, fallback: str) -> str:
- if api_base:
- return api_base.rstrip("/")
- spec = find_by_name(provider)
- if spec and spec.default_api_base:
- return spec.default_api_base.rstrip("/")
- return fallback
-
-
-def image_path_to_data_url(path: str | Path) -> str:
- """Convert a local image path to an image data URL."""
+def _read_image_b64(path: str | Path) -> tuple[str, str]:
+ """Return ``(mime, base64)`` for the image at ``path``."""
p = Path(path).expanduser()
raw = p.read_bytes()
mime = detect_image_mime(raw)
if mime is None:
raise ImageGenerationError(f"unsupported reference image: {p}")
- encoded = base64.b64encode(raw).decode("ascii")
+ return mime, base64.b64encode(raw).decode("ascii")
+
+
+def image_path_to_data_url(path: str | Path) -> str:
+ """Convert a local image path to an image data URL."""
+ mime, encoded = _read_image_b64(path)
return f"data:{mime};base64,{encoded}"
-def _b64_png_data_url(value: str) -> str:
- return f"data:image/png;base64,{value}"
+def image_path_to_inline_data(path: str | Path) -> dict[str, str]:
+ """Convert a local image path to a Gemini ``inlineData`` payload dict."""
+ mime, encoded = _read_image_b64(path)
+ return {"mimeType": mime, "data": encoded}
+
+
+def _b64_image_data_url(value: str) -> str:
+ encoded = "".join(value.split())
+ try:
+ raw = base64.b64decode(encoded, validate=True)
+ except binascii.Error as exc:
+ raise ImageGenerationError("generated image payload was not valid base64") from exc
+ mime = detect_image_mime(raw)
+ if mime is None:
+ raise ImageGenerationError("generated image payload was not a supported image")
+ return f"data:{mime};base64,{encoded}"
def _aihubmix_size(aspect_ratio: str | None, image_size: str | None) -> str:
@@ -106,8 +121,49 @@ async def _download_image_data_url(
return f"data:{mime};base64,{encoded}"
-class OpenRouterImageGenerationClient:
- """Small async client for OpenRouter Chat Completions image generation."""
+# ---------------------------------------------------------------------------
+# Registry
+# ---------------------------------------------------------------------------
+
+_IMAGE_GEN_PROVIDERS: dict[str, type[ImageGenerationProvider]] = {}
+
+
+def register_image_gen_provider(cls: type[ImageGenerationProvider]) -> None:
+ name = cls.provider_name
+ if not name:
+ raise ValueError(f"{cls.__name__} must set provider_name")
+ _IMAGE_GEN_PROVIDERS[name] = cls
+
+
+def get_image_gen_provider(name: str) -> type[ImageGenerationProvider] | None:
+ return _IMAGE_GEN_PROVIDERS.get(name)
+
+
+def image_gen_provider_names() -> tuple[str, ...]:
+ """Return registered image generation provider names in registry order."""
+ return tuple(_IMAGE_GEN_PROVIDERS)
+
+
+def image_gen_provider_configs(config: Any) -> dict[str, Any]:
+ providers_cfg = config.providers
+ return {
+ name: pc
+ for name in _IMAGE_GEN_PROVIDERS
+ if (pc := getattr(providers_cfg, name, None)) is not None
+ }
+
+
+# ---------------------------------------------------------------------------
+# Base class
+# ---------------------------------------------------------------------------
+
+
+class ImageGenerationProvider(ABC):
+ """Base class for image generation provider clients."""
+
+ provider_name: str = ""
+ missing_key_message: str = ""
+ default_timeout: float = _DEFAULT_TIMEOUT_S
def __init__(
self,
@@ -116,20 +172,71 @@ class OpenRouterImageGenerationClient:
api_base: str | None = None,
extra_headers: dict[str, str] | None = None,
extra_body: dict[str, Any] | None = None,
- timeout: float = _DEFAULT_TIMEOUT_S,
+ timeout: float | None = None,
client: httpx.AsyncClient | None = None,
) -> None:
self.api_key = api_key
- self.api_base = _provider_base_url(
- "openrouter",
- api_base,
- "https://openrouter.ai/api/v1",
- )
+ self.api_base = self._resolve_base_url(api_base)
self.extra_headers = extra_headers or {}
self.extra_body = extra_body or {}
- self.timeout = timeout
+ self.timeout = timeout if timeout is not None else self.default_timeout
self._client = client
+ def _resolve_base_url(self, api_base: str | None) -> str:
+ if api_base:
+ return api_base.rstrip("/")
+ spec = find_by_name(self.provider_name)
+ if spec and spec.default_api_base:
+ return spec.default_api_base.rstrip("/")
+ return self._default_base_url()
+
+ def _default_base_url(self) -> str:
+ return ""
+
+ @abstractmethod
+ async def generate(
+ self,
+ *,
+ prompt: str,
+ model: str,
+ reference_images: list[str] | None = None,
+ aspect_ratio: str | None = None,
+ image_size: str | None = None,
+ ) -> GeneratedImageResponse: ...
+
+ def _require_images(self, images: list[str], data: dict[str, Any]) -> None:
+ if images:
+ return
+ provider_error = data.get("error") if isinstance(data, dict) else None
+ label = self.provider_name
+ if provider_error:
+ raise ImageGenerationError(f"{label} returned no images: {provider_error}")
+ raise ImageGenerationError(f"{label} returned no images for this request")
+
+ async def _http_post(
+ self,
+ url: str,
+ *,
+ headers: dict[str, str],
+ body: dict[str, Any],
+ ) -> httpx.Response:
+ if self._client is not None:
+ return await self._client.post(url, headers=headers, json=body)
+ async with httpx.AsyncClient(timeout=self.timeout) as c:
+ return await c.post(url, headers=headers, json=body)
+
+
+class OpenRouterImageGenerationClient(ImageGenerationProvider):
+ """Small async client for OpenRouter Chat Completions image generation."""
+
+ provider_name = "openrouter"
+ missing_key_message = (
+ "OpenRouter API key is not configured. Set providers.openrouter.apiKey."
+ )
+
+ def _default_base_url(self) -> str:
+ return "https://openrouter.ai/api/v1"
+
async def generate(
self,
*,
@@ -140,9 +247,7 @@ class OpenRouterImageGenerationClient:
image_size: str | None = None,
) -> GeneratedImageResponse:
if not self.api_key:
- raise ImageGenerationError(
- "OpenRouter API key is not configured. Set providers.openrouter.apiKey."
- )
+ raise ImageGenerationError(self.missing_key_message)
content: str | list[dict[str, Any]]
references = list(reference_images or [])
@@ -178,12 +283,7 @@ class OpenRouterImageGenerationClient:
**self.extra_headers,
}
url = f"{self.api_base}/chat/completions"
-
- if self._client is not None:
- response = await self._client.post(url, headers=headers, json=body)
- else:
- async with httpx.AsyncClient(timeout=self.timeout) as client:
- response = await client.post(url, headers=headers, json=body)
+ response = await self._http_post(url, headers=headers, body=body)
try:
response.raise_for_status()
@@ -208,11 +308,7 @@ class OpenRouterImageGenerationClient:
if isinstance(url_value, str) and url_value.startswith("data:image/"):
images.append(url_value)
- if not images:
- provider_error = data.get("error") if isinstance(data, dict) else None
- if provider_error:
- raise ImageGenerationError(f"OpenRouter returned no images: {provider_error}")
- raise ImageGenerationError("OpenRouter returned no images for this request")
+ self._require_images(images, data)
return GeneratedImageResponse(
images=images,
@@ -221,29 +317,17 @@ class OpenRouterImageGenerationClient:
)
-class AIHubMixImageGenerationClient:
+class AIHubMixImageGenerationClient(ImageGenerationProvider):
"""Small async client for AIHubMix unified image generation."""
- def __init__(
- self,
- *,
- api_key: str | None,
- api_base: str | None = None,
- extra_headers: dict[str, str] | None = None,
- extra_body: dict[str, Any] | None = None,
- timeout: float = _AIHUBMIX_TIMEOUT_S,
- client: httpx.AsyncClient | None = None,
- ) -> None:
- self.api_key = api_key
- self.api_base = _provider_base_url(
- "aihubmix",
- api_base,
- "https://aihubmix.com/v1",
- )
- self.extra_headers = extra_headers or {}
- self.extra_body = extra_body or {}
- self.timeout = timeout
- self._client = client
+ provider_name = "aihubmix"
+ missing_key_message = (
+ "AIHubMix API key is not configured. Set providers.aihubmix.apiKey."
+ )
+ default_timeout = _AIHUBMIX_TIMEOUT_S
+
+ def _default_base_url(self) -> str:
+ return "https://aihubmix.com/v1"
async def generate(
self,
@@ -255,9 +339,7 @@ class AIHubMixImageGenerationClient:
image_size: str | None = None,
) -> GeneratedImageResponse:
if not self.api_key:
- raise ImageGenerationError(
- "AIHubMix API key is not configured. Set providers.aihubmix.apiKey."
- )
+ raise ImageGenerationError(self.missing_key_message)
refs = list(reference_images or [])
headers = {
@@ -266,16 +348,8 @@ class AIHubMixImageGenerationClient:
}
size = _aihubmix_size(aspect_ratio, image_size)
- if self._client is not None:
- return await self._generate_with_client(
- self._client,
- prompt=prompt,
- model=model,
- reference_images=refs,
- size=size,
- headers=headers,
- )
- async with httpx.AsyncClient(timeout=self.timeout) as client:
+ client = self._client or httpx.AsyncClient(timeout=self.timeout)
+ try:
return await self._generate_with_client(
client,
prompt=prompt,
@@ -284,6 +358,9 @@ class AIHubMixImageGenerationClient:
size=size,
headers=headers,
)
+ finally:
+ if self._client is None:
+ await client.aclose()
async def _generate_with_client(
self,
@@ -332,15 +409,182 @@ class AIHubMixImageGenerationClient:
payload = response.json()
images = await _aihubmix_images_from_payload(client, payload)
- if not images:
- provider_error = payload.get("error") if isinstance(payload, dict) else None
- if provider_error:
- raise ImageGenerationError(f"AIHubMix returned no images: {provider_error}")
- raise ImageGenerationError("AIHubMix returned no images for this request")
+ self._require_images(images, payload)
return GeneratedImageResponse(images=images, content="", raw=payload)
+def _http_error_detail(response: httpx.Response) -> str:
+ """Extract a readable error message from an HTTP error response."""
+ try:
+ data = response.json()
+ if isinstance(data, dict):
+ err = data.get("error")
+ if isinstance(err, dict):
+ return err.get("message") or str(err)
+ if err:
+ return str(err)
+ except Exception:
+ pass
+ return response.text[:500] or ""
+
+
+class GeminiImageGenerationClient(ImageGenerationProvider):
+ """Async client for Gemini/Imagen image generation via the Generative Language API."""
+
+ provider_name = "gemini"
+ missing_key_message = (
+ "Gemini API key is not configured. Set providers.gemini.apiKey."
+ )
+ default_timeout = _GEMINI_DEFAULT_TIMEOUT_S
+
+ def _default_base_url(self) -> str:
+ return "https://generativelanguage.googleapis.com/v1beta"
+
+ def _resolve_base_url(self, api_base: str | None) -> str:
+ # The Gemini provider's registry default_api_base is the OpenAI-compat
+ # shim (.../v1beta/openai/), which has no image endpoints.
+ # Skip the registry lookup and use the native API base directly.
+ if api_base:
+ return api_base.rstrip("/")
+ return self._default_base_url()
+
+ async def generate(
+ self,
+ *,
+ prompt: str,
+ model: str,
+ reference_images: list[str] | None = None,
+ aspect_ratio: str | None = None,
+ image_size: str | None = None,
+ ) -> GeneratedImageResponse:
+ if not self.api_key:
+ raise ImageGenerationError(self.missing_key_message)
+ if "imagen" in model.lower():
+ if reference_images:
+ logger.warning(
+ "Imagen models do not support reference images; "
+ "ignoring {} reference image(s) for {}",
+ len(reference_images),
+ model,
+ )
+ return await self._generate_imagen(
+ prompt=prompt, model=model, aspect_ratio=aspect_ratio
+ )
+ return await self._generate_gemini_flash(
+ prompt=prompt, model=model, reference_images=reference_images or []
+ )
+
+ async def _generate_imagen(
+ self,
+ *,
+ prompt: str,
+ model: str,
+ aspect_ratio: str | None,
+ ) -> GeneratedImageResponse:
+ parameters: dict[str, Any] = {"sampleCount": 1}
+ if aspect_ratio in _GEMINI_IMAGEN_ASPECT_RATIOS:
+ parameters["aspectRatio"] = aspect_ratio
+ body: dict[str, Any] = {
+ "instances": [{"prompt": prompt}],
+ "parameters": parameters,
+ }
+ body.update(self.extra_body)
+
+ url = f"{self.api_base}/models/{model}:predict"
+ headers = {
+ "x-goog-api-key": self.api_key or "",
+ "Content-Type": "application/json",
+ **self.extra_headers,
+ }
+ response = await self._http_post(url, headers=headers, body=body)
+
+ try:
+ response.raise_for_status()
+ except httpx.HTTPStatusError as exc:
+ detail = _http_error_detail(response)
+ logger.error("Gemini Imagen generation failed (HTTP {}): {}", response.status_code, detail)
+ raise ImageGenerationError(
+ f"Gemini Imagen generation failed (HTTP {response.status_code}): {detail}"
+ ) from exc
+
+ data = response.json()
+ images: list[str] = []
+ for prediction in data.get("predictions") or []:
+ if not isinstance(prediction, dict):
+ continue
+ b64 = prediction.get("bytesBase64Encoded")
+ mime = prediction.get("mimeType", "image/png")
+ if isinstance(b64, str) and b64:
+ images.append(f"data:{mime};base64,{b64}")
+
+ self._require_images(images, data)
+
+ return GeneratedImageResponse(images=images, content="", raw=data)
+
+ async def _generate_gemini_flash(
+ self,
+ *,
+ prompt: str,
+ model: str,
+ reference_images: list[str],
+ ) -> GeneratedImageResponse:
+ parts: list[dict[str, Any]] = [
+ {"inlineData": image_path_to_inline_data(path)} for path in reference_images
+ ]
+ parts.append({"text": prompt})
+
+ body: dict[str, Any] = {
+ "contents": [{"role": "user", "parts": parts}],
+ "generationConfig": {"responseModalities": ["TEXT", "IMAGE"]},
+ }
+ body.update(self.extra_body)
+
+ url = f"{self.api_base}/models/{model}:generateContent"
+ headers = {
+ "x-goog-api-key": self.api_key or "",
+ "Content-Type": "application/json",
+ **self.extra_headers,
+ }
+ response = await self._http_post(url, headers=headers, body=body)
+
+ try:
+ response.raise_for_status()
+ except httpx.HTTPStatusError as exc:
+ detail = _http_error_detail(response)
+ logger.error("Gemini image generation failed (HTTP {}): {}", response.status_code, detail)
+ raise ImageGenerationError(
+ f"Gemini image generation failed (HTTP {response.status_code}): {detail}"
+ ) from exc
+
+ data = response.json()
+ images: list[str] = []
+ text_parts: list[str] = []
+ for candidate in data.get("candidates") or []:
+ if not isinstance(candidate, dict):
+ continue
+ content = candidate.get("content") or {}
+ for part in content.get("parts") or []:
+ if not isinstance(part, dict):
+ continue
+ if "text" in part:
+ text_parts.append(part["text"])
+ inline = part.get("inlineData")
+ if isinstance(inline, dict):
+ mime = inline.get("mimeType", "image/png")
+ b64 = inline.get("data", "")
+ if b64:
+ images.append(f"data:{mime};base64,{b64}")
+
+ self._require_images(images, data)
+
+ return GeneratedImageResponse(
+ images=images,
+ content="\n".join(t for t in text_parts if t).strip(),
+ raw=data,
+ )
+
+
async def _aihubmix_images_from_payload(
client: httpx.AsyncClient,
payload: dict[str, Any],
@@ -368,13 +612,13 @@ async def _aihubmix_images_from_payload(
b64_json = value.get("b64_json")
if isinstance(b64_json, str) and b64_json:
- images.append(_b64_png_data_url(b64_json))
+ images.append(_b64_image_data_url(b64_json))
elif b64_json is not None:
await collect(b64_json)
bytes_base64 = value.get("bytesBase64") or value.get("bytes_base64") or value.get("base64")
if isinstance(bytes_base64, str) and bytes_base64:
- images.append(_b64_png_data_url(bytes_base64))
+ images.append(_b64_image_data_url(bytes_base64))
image_url = value.get("image_url") or value.get("imageUrl")
if isinstance(image_url, dict):
@@ -393,3 +637,130 @@ async def _aihubmix_images_from_payload(
for candidate in candidates:
await collect(candidate)
return images
+
+
+_MINIMAX_TIMEOUT_S = 300.0
+
+_MINIMAX_ASPECT_RATIO_SIZES = {
+ "1:1": "1:1",
+ "16:9": "16:9",
+ "4:3": "4:3",
+ "3:2": "3:2",
+ "2:3": "2:3",
+ "3:4": "3:4",
+ "9:16": "9:16",
+ "21:9": "21:9",
+}
+
+
+class MiniMaxImageGenerationClient(ImageGenerationProvider):
+ """Async client for MiniMax image generation API."""
+
+ provider_name = "minimax"
+ missing_key_message = (
+ "MiniMax API key is not configured. Set providers.minimax.apiKey."
+ )
+ default_timeout = _MINIMAX_TIMEOUT_S
+
+ def _default_base_url(self) -> str:
+ return "https://api.minimaxi.com/v1"
+
+ def _resolve_aspect_ratio(self, aspect_ratio: str | None) -> str:
+ if aspect_ratio and aspect_ratio in _MINIMAX_ASPECT_RATIO_SIZES:
+ return _MINIMAX_ASPECT_RATIO_SIZES[aspect_ratio]
+ return "1:1"
+
+ async def generate(
+ self,
+ *,
+ prompt: str,
+ model: str,
+ reference_images: list[str] | None = None,
+ aspect_ratio: str | None = None,
+ image_size: str | None = None,
+ ) -> GeneratedImageResponse:
+ if not self.api_key:
+ raise ImageGenerationError(self.missing_key_message)
+
+ headers = {
+ "Authorization": f"Bearer {self.api_key}",
+ "Content-Type": "application/json",
+ **self.extra_headers,
+ }
+
+ body: dict[str, Any] = {
+ "model": model,
+ "prompt": prompt,
+ "response_format": "base64",
+ }
+
+ resolved_ratio = self._resolve_aspect_ratio(aspect_ratio)
+ body["aspect_ratio"] = resolved_ratio
+
+ refs = list(reference_images or [])
+ if refs:
+ image_refs = [image_path_to_data_url(path) for path in refs]
+ body["subject_reference"] = [
+ {"type": "character", "image_file": ref} for ref in image_refs
+ ]
+
+ body.update(self.extra_body)
+
+ client = self._client or httpx.AsyncClient(timeout=self.timeout)
+ try:
+ return await self._generate_with_client(client, body, headers)
+ finally:
+ if self._client is None:
+ await client.aclose()
+
+ async def _generate_with_client(
+ self,
+ client: httpx.AsyncClient,
+ body: dict[str, Any],
+ headers: dict[str, str],
+ ) -> GeneratedImageResponse:
+ url = f"{self.api_base}/image_generation"
+ try:
+ response = await client.post(url, headers=headers, json=body)
+ except httpx.TimeoutException as exc:
+ raise ImageGenerationError("MiniMax image generation timed out") from exc
+ except httpx.RequestError as exc:
+ raise ImageGenerationError(f"MiniMax image generation request failed: {exc}") from exc
+
+ try:
+ response.raise_for_status()
+ except httpx.HTTPStatusError as exc:
+ detail = response.text[:500]
+ raise ImageGenerationError(f"MiniMax image generation failed: {detail}") from exc
+
+ payload = response.json()
+ images = _minimax_images_from_payload(payload)
+
+ self._require_images(images, payload)
+
+ return GeneratedImageResponse(images=images, content="", raw=payload)
+
+
+def _minimax_images_from_payload(payload: dict[str, Any]) -> list[str]:
+ """Extract base64 images from MiniMax API response.
+
+ MiniMax returns images in ``data.image_base64`` (list of base64 strings).
+ """
+ images: list[str] = []
+ data = payload.get("data")
+ if not isinstance(data, dict):
+ return images
+ for b64 in data.get("image_base64") or []:
+ if isinstance(b64, str) and b64:
+ images.append(_b64_image_data_url(b64))
+ return images
+
+
+# ---------------------------------------------------------------------------
+# Provider registration
+# ---------------------------------------------------------------------------
+
+register_image_gen_provider(OpenRouterImageGenerationClient)
+register_image_gen_provider(AIHubMixImageGenerationClient)
+register_image_gen_provider(GeminiImageGenerationClient)
+register_image_gen_provider(MiniMaxImageGenerationClient)
diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py
index 38209f59..523b2a72 100644
--- a/nanobot/providers/openai_codex_provider.py
+++ b/nanobot/providers/openai_codex_provider.py
@@ -40,6 +40,7 @@ class OpenAICodexProvider(LLMProvider):
reasoning_effort: str | None,
tool_choice: str | dict[str, Any] | None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
+ on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
"""Shared request logic for both chat() and chat_stream()."""
model = model or self.default_model
@@ -70,6 +71,7 @@ class OpenAICodexProvider(LLMProvider):
content, tool_calls, finish_reason = await _request_codex(
DEFAULT_CODEX_URL, headers, body, verify=True,
on_content_delta=on_content_delta,
+ on_tool_call_delta=on_tool_call_delta,
)
except Exception as e:
if "CERTIFICATE_VERIFY_FAILED" not in str(e):
@@ -78,6 +80,7 @@ class OpenAICodexProvider(LLMProvider):
content, tool_calls, finish_reason = await _request_codex(
DEFAULT_CODEX_URL, headers, body, verify=False,
on_content_delta=on_content_delta,
+ on_tool_call_delta=on_tool_call_delta,
)
return LLMResponse(content=content, tool_calls=tool_calls, finish_reason=finish_reason)
except Exception as e:
@@ -100,9 +103,18 @@ class OpenAICodexProvider(LLMProvider):
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
+ on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
_ = on_thinking_delta
- return await self._call_codex(messages, tools, model, reasoning_effort, tool_choice, on_content_delta)
+ return await self._call_codex(
+ messages,
+ tools,
+ model,
+ reasoning_effort,
+ tool_choice,
+ on_content_delta,
+ on_tool_call_delta,
+ )
def get_default_model(self) -> str:
return self.default_model
@@ -138,6 +150,7 @@ async def _request_codex(
body: dict[str, Any],
verify: bool,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
+ on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str]:
async with httpx.AsyncClient(timeout=60.0, verify=verify) as client:
async with client.stream("POST", url, headers=headers, json=body) as response:
@@ -148,7 +161,7 @@ async def _request_codex(
_friendly_error(response.status_code, text.decode("utf-8", "ignore")),
retry_after=retry_after,
)
- return await consume_sse(response, on_content_delta)
+ return await consume_sse(response, on_content_delta, on_tool_call_delta)
def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py
index 2bcb840c..2f845541 100644
--- a/nanobot/providers/openai_compat_provider.py
+++ b/nanobot/providers/openai_compat_provider.py
@@ -999,6 +999,21 @@ class OpenAICompatProvider(LLMProvider):
if fn_prov:
buf["fn_prov"] = fn_prov
+ def _accum_legacy_function_call(function_call: Any) -> None:
+ """Accumulate legacy ``delta.function_call`` streaming chunks."""
+ if not function_call:
+ return
+ buf = tc_bufs.setdefault(0, {
+ "id": "", "name": "", "arguments": "",
+ "extra_content": None, "prov": None, "fn_prov": None,
+ })
+ fn_name = _get(function_call, "name")
+ if fn_name:
+ buf["name"] = str(fn_name)
+ fn_args = _get(function_call, "arguments")
+ if fn_args:
+ buf["arguments"] += str(fn_args)
+
for chunk in chunks:
if isinstance(chunk, str):
content_parts.append(chunk)
@@ -1029,6 +1044,7 @@ class OpenAICompatProvider(LLMProvider):
reasoning_parts.append(text)
for idx, tc in enumerate(delta.get("tool_calls") or []):
_accum_tc(tc, idx)
+ _accum_legacy_function_call(delta.get("function_call"))
usage = cls._extract_usage(chunk_map) or usage
continue
@@ -1047,8 +1063,10 @@ class OpenAICompatProvider(LLMProvider):
reasoning = getattr(delta, "reasoning", None)
if reasoning:
reasoning_parts.append(reasoning)
- for tc in (delta.tool_calls or []) if delta else []:
+ for tc in (getattr(delta, "tool_calls", None) or []) if delta else []:
_accum_tc(tc, getattr(tc, "index", 0))
+ if delta:
+ _accum_legacy_function_call(getattr(delta, "function_call", None))
return LLMResponse(
content="".join(content_parts) or None,
@@ -1203,6 +1221,7 @@ class OpenAICompatProvider(LLMProvider):
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
+ on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
try:
@@ -1226,9 +1245,16 @@ class OpenAICompatProvider(LLMProvider):
except StopAsyncIteration:
break
- content, tool_calls, finish_reason, usage, reasoning_content = await consume_sdk_stream(
+ (
+ content,
+ tool_calls,
+ finish_reason,
+ usage,
+ reasoning_content,
+ ) = await consume_sdk_stream(
_timed_stream(),
on_content_delta,
+ on_tool_call_delta=on_tool_call_delta,
)
self._record_responses_success(model, reasoning_effort)
return LLMResponse(
@@ -1252,6 +1278,12 @@ class OpenAICompatProvider(LLMProvider):
messages, tools, model, max_tokens, temperature,
reasoning_effort, tool_choice,
)
+ if self._spec and self._spec.name == "zhipu" and tools and on_tool_call_delta:
+ # Z.AI/GLM keeps streaming tool-call arguments behind an
+ # explicit provider flag. Pass it through the OpenAI SDK's
+ # extra_body escape hatch so the usual delta.tool_calls path
+ # can surface live file-edit progress.
+ kwargs.setdefault("extra_body", {})["tool_stream"] = True
kwargs["stream"] = True
kwargs["stream_options"] = {"include_usage": True}
stream = await self._client.chat.completions.create(**kwargs)
@@ -1279,6 +1311,28 @@ class OpenAICompatProvider(LLMProvider):
r_text = self._extract_text_content(reasoning)
if r_text:
await on_thinking_delta(r_text)
+ if on_tool_call_delta:
+ for idx, tool_delta in enumerate(
+ getattr(delta_obj, "tool_calls", None) or []
+ ):
+ fn = _get(tool_delta, "function")
+ tool_index = _get(tool_delta, "index")
+ await on_tool_call_delta({
+ "index": tool_index if tool_index is not None else idx,
+ "call_id": str(_get(tool_delta, "id") or ""),
+ "name": str(_get(fn, "name") or "") if fn is not None else "",
+ "arguments_delta": (
+ str(_get(fn, "arguments") or "") if fn is not None else ""
+ ),
+ })
+ function_call = getattr(delta_obj, "function_call", None)
+ if function_call:
+ await on_tool_call_delta({
+ "index": 0,
+ "call_id": "",
+ "name": str(_get(function_call, "name") or ""),
+ "arguments_delta": str(_get(function_call, "arguments") or ""),
+ })
return self._parse_chunks(chunks)
except asyncio.TimeoutError:
return LLMResponse(
diff --git a/nanobot/providers/openai_responses/parsing.py b/nanobot/providers/openai_responses/parsing.py
index 9e3f0ef0..707652d7 100644
--- a/nanobot/providers/openai_responses/parsing.py
+++ b/nanobot/providers/openai_responses/parsing.py
@@ -62,6 +62,7 @@ async def iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], N
async def consume_sse(
response: httpx.Response,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
+ on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str]:
"""Consume a Responses API SSE stream into ``(content, tool_calls, finish_reason)``."""
content = ""
@@ -82,6 +83,12 @@ async def consume_sse(
"name": item.get("name"),
"arguments": item.get("arguments") or "",
}
+ if on_tool_call_delta:
+ await on_tool_call_delta({
+ "call_id": str(call_id),
+ "name": str(item.get("name") or ""),
+ "arguments_delta": "",
+ })
elif event_type == "response.output_text.delta":
delta_text = event.get("delta") or ""
content += delta_text
@@ -90,7 +97,14 @@ async def consume_sse(
elif event_type == "response.function_call_arguments.delta":
call_id = event.get("call_id")
if call_id and call_id in tool_call_buffers:
- tool_call_buffers[call_id]["arguments"] += event.get("delta") or ""
+ delta = event.get("delta") or ""
+ tool_call_buffers[call_id]["arguments"] += delta
+ if on_tool_call_delta and delta:
+ await on_tool_call_delta({
+ "call_id": str(call_id),
+ "name": str(tool_call_buffers[call_id].get("name") or ""),
+ "arguments_delta": str(delta),
+ })
elif event_type == "response.function_call_arguments.done":
call_id = event.get("call_id")
if call_id and call_id in tool_call_buffers:
@@ -210,6 +224,7 @@ def parse_response_output(response: Any) -> LLMResponse:
async def consume_sdk_stream(
stream: Any,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
+ on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
"""Consume an SDK async stream from ``client.responses.create(stream=True)``."""
content = ""
@@ -232,6 +247,12 @@ async def consume_sdk_stream(
"name": getattr(item, "name", None),
"arguments": getattr(item, "arguments", None) or "",
}
+ if on_tool_call_delta:
+ await on_tool_call_delta({
+ "call_id": str(call_id),
+ "name": str(getattr(item, "name", None) or ""),
+ "arguments_delta": "",
+ })
elif event_type == "response.output_text.delta":
delta_text = getattr(event, "delta", "") or ""
content += delta_text
@@ -240,7 +261,14 @@ async def consume_sdk_stream(
elif event_type == "response.function_call_arguments.delta":
call_id = getattr(event, "call_id", None)
if call_id and call_id in tool_call_buffers:
- tool_call_buffers[call_id]["arguments"] += getattr(event, "delta", "") or ""
+ delta = getattr(event, "delta", "") or ""
+ tool_call_buffers[call_id]["arguments"] += delta
+ if on_tool_call_delta and delta:
+ await on_tool_call_delta({
+ "call_id": str(call_id),
+ "name": str(tool_call_buffers[call_id].get("name") or ""),
+ "arguments_delta": str(delta),
+ })
elif event_type == "response.function_call_arguments.done":
call_id = getattr(event, "call_id", None)
if call_id and call_id in tool_call_buffers:
diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py
index e6f02218..0f8e4593 100644
--- a/nanobot/providers/registry.py
+++ b/nanobot/providers/registry.py
@@ -390,6 +390,16 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
backend="openai_compat",
default_api_base="https://api.longcat.chat/openai/v1",
),
+ # Ant Ling: OpenAI-compatible API for Ling/Ring model families.
+ ProviderSpec(
+ name="ant_ling",
+ keywords=("ant_ling", "ant-ling", "ling-", "ring-"),
+ env_key="ANT_LING_API_KEY",
+ display_name="Ant Ling",
+ backend="openai_compat",
+ detect_by_base_keyword="ant-ling.com",
+ default_api_base="https://api.ant-ling.com/v1",
+ ),
# === Local deployment (matched by config key, NOT by api_base) =========
# vLLM / any OpenAI-compatible local server
ProviderSpec(
diff --git a/nanobot/utils/webui_turn_helpers.py b/nanobot/session/webui_turns.py
similarity index 99%
rename from nanobot/utils/webui_turn_helpers.py
rename to nanobot/session/webui_turns.py
index 6a3ac2ba..de1083b0 100644
--- a/nanobot/utils/webui_turn_helpers.py
+++ b/nanobot/session/webui_turns.py
@@ -1,4 +1,4 @@
-"""Outbound helpers for the WebSocket/WebUI wire contract.
+"""Session turn helpers for WebUI-capable WebSocket sessions.
AgentLoop uses these without importing a concrete channel plugin; only
``channel == "websocket"`` messages are affected.
diff --git a/nanobot/skills/image-generation/SKILL.md b/nanobot/skills/image-generation/SKILL.md
index 3ba0e2f4..d50fb064 100644
--- a/nanobot/skills/image-generation/SKILL.md
+++ b/nanobot/skills/image-generation/SKILL.md
@@ -15,7 +15,7 @@ If the `generate_image` tool is not available in the current tool list, tell the
- Image editing: pass the saved artifact path or user image path in `reference_images`.
- Iterative edits in the same conversation: prefer the most recent generated image artifact if the user says things like "make it brighter", "change the background", or "try another version".
- Ambiguous edits: ask a short clarifying question if multiple recent images could be the target.
-- In the current chat, do not call `message` just to announce or resend generated images. The runtime attaches images from `generate_image` to the final assistant reply automatically.
+- After generating images, call the `message` tool with the artifact paths in the `media` parameter to deliver them to the user.
## Prompt Rules
@@ -42,52 +42,6 @@ For follow-up edits, pass the prior artifact `path` to `reference_images`. If th
Do not include internal replay markers such as `[Message Time: ...]`, `[image: /local/path]`, `generate_image(...)`, or `message(...)` in user-facing replies.
-## Provider Notes
-
-Do not ask users to paste API keys into chat. If configuration is needed, describe the fields; LLM provider and BYOK changes are hot-reloaded for new turns.
-
-For OpenRouter, the image tool expects:
-
-```json
-{
- "providers": {
- "openrouter": {
- "apiKey": "sk-or-..."
- }
- },
- "tools": {
- "imageGeneration": {
- "enabled": true,
- "provider": "openrouter",
- "model": "openai/gpt-5.4-image-2"
- }
- }
-}
-```
-
-For AIHubMix, the image tool expects:
-
-```json
-{
- "providers": {
- "aihubmix": {
- "apiKey": "sk-..."
- }
- },
- "tools": {
- "imageGeneration": {
- "enabled": true,
- "provider": "aihubmix",
- "model": "gpt-image-2-free"
- }
- }
-}
-```
-
-AIHubMix `gpt-image-2-free` uses AIHubMix's unified predictions endpoint internally (`/v1/models/openai/gpt-image-2-free/predictions`), not the OpenAI Images `/v1/images/generations` endpoint. If it fails with "Incorrect model ID", do not assume the key lacks permission until the provider config, model name, and gateway restart have been checked.
-
-`providers.aihubmix.extraBody` can be used for provider-specific options. For example, `"extraBody": {"quality": "low"}` is optional but can make `gpt-image-2-free` faster and less likely to time out.
-
## Examples
Generate a new image:
diff --git a/nanobot/templates/agent/identity.md b/nanobot/templates/agent/identity.md
index 6548c1de..e6fa5535 100644
--- a/nanobot/templates/agent/identity.md
+++ b/nanobot/templates/agent/identity.md
@@ -30,5 +30,5 @@ Output is rendered in a terminal. Avoid markdown headings and tables. Use plain
Reply directly with text for the current conversation. Do not use the 'message' tool for normal replies in the current chat.
When you need to call tools before answering, do not include the final user-visible answer in the same assistant message as the tool calls. Wait for the tool results, then answer once.
-Use the 'message' tool only for proactive sends, cross-channel delivery, or explicitly sending existing local files as attachments. When a tool such as 'generate_image' creates user-visible media, the runtime attaches those artifacts to the final assistant reply automatically, so do not call 'message' just to announce or resend them.
+Use the 'message' tool only for proactive sends, cross-channel delivery, or explicitly sending existing local files as attachments. When 'generate_image' creates images, call 'message' with the artifact paths in the 'media' parameter to deliver them to the user.
To send an existing local file that was not automatically attached by another tool, call 'message' with the 'media' parameter. Do NOT use read_file to "send" a file — reading a file only shows its content to you, it does NOT deliver the file to the user. Example: message(content="Here is the document", channel="telegram", chat_id="...", media=["/path/to/file.pdf"])
diff --git a/nanobot/utils/__init__.py b/nanobot/utils/__init__.py
index 9ad157c2..15dbe2e9 100644
--- a/nanobot/utils/__init__.py
+++ b/nanobot/utils/__init__.py
@@ -1,6 +1,42 @@
"""Utility functions for nanobot."""
+from __future__ import annotations
+
+import sys
+from importlib import import_module
+from types import ModuleType
+
from nanobot.utils.helpers import ensure_dir
from nanobot.utils.path import abbreviate_path
__all__ = ["ensure_dir", "abbreviate_path"]
+
+
+class _LazyModuleAlias(ModuleType):
+ def __init__(self, name: str, target: str) -> None:
+ super().__init__(name)
+ self.__dict__["_target"] = target
+
+ def _load(self) -> ModuleType:
+ module = import_module(self.__dict__["_target"])
+ sys.modules[self.__name__] = module
+ return module
+
+ def __getattr__(self, name: str) -> object:
+ return getattr(self._load(), name)
+
+ def __dir__(self) -> list[str]:
+ return sorted(set(super().__dir__()) | set(dir(self._load())))
+
+
+_LEGACY_MODULE_ALIASES = {
+ "webui_thread_disk": "nanobot.webui.thread_disk",
+ "webui_transcript": "nanobot.webui.transcript",
+ "webui_turn_helpers": "nanobot.session.webui_turns",
+}
+
+for _legacy_name, _target_name in _LEGACY_MODULE_ALIASES.items():
+ sys.modules.setdefault(
+ f"{__name__}.{_legacy_name}",
+ _LazyModuleAlias(f"{__name__}.{_legacy_name}", _target_name),
+ )
diff --git a/nanobot/utils/artifacts.py b/nanobot/utils/artifacts.py
index eca706ee..6366c18c 100644
--- a/nanobot/utils/artifacts.py
+++ b/nanobot/utils/artifacts.py
@@ -21,8 +21,6 @@ _MIME_EXTENSIONS = {
"image/webp": ".webp",
"image/gif": ".gif",
}
-_GENERATE_IMAGE_TOOL_NAME = "generate_image"
-
class ArtifactError(ValueError):
"""Raised when an artifact cannot be safely decoded or stored."""
@@ -115,48 +113,10 @@ def generated_image_tool_result(artifacts: list[dict[str, Any]]) -> str:
"artifacts": artifacts,
"next_step": (
"Use these artifact paths as reference_images for follow-up edits. "
- "For the current chat, reply naturally; the runtime attaches generated images automatically. "
- "Do not call message just to announce or resend them. Keep raw paths internal unless the user asks for debug details."
+ "Call the message tool with the artifact paths in the media parameter "
+ "to deliver the images to the user. Keep raw paths internal unless the "
+ "user asks for debug details."
),
},
ensure_ascii=False,
)
-
-
-def _extract_text_payload(content: Any) -> str | None:
- if isinstance(content, str):
- return content
- if isinstance(content, list):
- parts: list[str] = []
- for block in content:
- if isinstance(block, dict) and isinstance(block.get("text"), str):
- parts.append(block["text"])
- return "\n".join(parts) if parts else None
- return None
-
-
-def generated_image_paths_from_messages(messages: list[dict[str, Any]]) -> list[str]:
- """Collect generated image artifact paths from generate_image tool results."""
- paths: list[str] = []
- seen: set[str] = set()
- for message in messages:
- if message.get("role") != "tool" or message.get("name") != _GENERATE_IMAGE_TOOL_NAME:
- continue
- payload = _extract_text_payload(message.get("content"))
- if not payload:
- continue
- try:
- data = json.loads(payload)
- except json.JSONDecodeError:
- continue
- artifacts = data.get("artifacts") if isinstance(data, dict) else None
- if not isinstance(artifacts, list):
- continue
- for artifact in artifacts:
- if not isinstance(artifact, dict):
- continue
- path = artifact.get("path")
- if isinstance(path, str) and path and path not in seen:
- paths.append(path)
- seen.add(path)
- return paths
diff --git a/nanobot/utils/file_edit_events.py b/nanobot/utils/file_edit_events.py
index 8164aa18..b5d2f6d7 100644
--- a/nanobot/utils/file_edit_events.py
+++ b/nanobot/utils/file_edit_events.py
@@ -4,13 +4,17 @@ from __future__ import annotations
import difflib
import json
-from dataclasses import dataclass
+import re
+import time
+from dataclasses import dataclass, field
from pathlib import Path
-from typing import Any
+from typing import Any, Awaitable, Callable
TRACKED_FILE_EDIT_TOOLS = frozenset({"write_file", "edit_file", "notebook_edit"})
_MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024
+_LIVE_EMIT_INTERVAL_S = 0.18
+_LIVE_EMIT_LINE_STEP = 24
@dataclass(slots=True)
@@ -103,6 +107,8 @@ def line_diff_stats(before: str | None, after: str | None) -> tuple[int, int]:
"""Return ``(added, deleted)`` for a UTF-8 text line-level diff."""
if before is None or after is None:
return 0, 0
+ if before == "":
+ return _text_line_count(after), 0
before_lines = before.replace("\r\n", "\n").splitlines()
after_lines = after.replace("\r\n", "\n").splitlines()
added = 0
@@ -118,6 +124,28 @@ def line_diff_stats(before: str | None, after: str | None) -> tuple[int, int]:
return added, deleted
+def _text_line_count(text: str) -> int:
+ if not text:
+ return 0
+ line_count = 0
+ last_was_newline = False
+ last_was_cr = False
+ for ch in text:
+ if ch == "\r":
+ line_count += 1
+ last_was_newline = True
+ last_was_cr = True
+ elif ch == "\n":
+ if not last_was_cr:
+ line_count += 1
+ last_was_newline = True
+ last_was_cr = False
+ else:
+ last_was_newline = False
+ last_was_cr = False
+ return line_count if last_was_newline else line_count + 1
+
+
def prepare_file_edit_tracker(
*,
call_id: str,
@@ -160,12 +188,22 @@ def build_file_edit_start_event(
)
-def build_file_edit_end_event(tracker: FileEditTracker) -> dict[str, Any]:
+def build_file_edit_end_event(
+ tracker: FileEditTracker,
+ params: dict[str, Any] | None = None,
+) -> dict[str, Any]:
after = read_file_snapshot(tracker.path)
+ counted = False
if tracker.before.countable and after.countable:
added, deleted = line_diff_stats(tracker.before.text, after.text)
+ counted = True
else:
- added, deleted = 0, 0
+ predicted_after = _predict_after_text(tracker.tool, params or {}, tracker.before)
+ if tracker.before.countable and predicted_after is not None:
+ added, deleted = line_diff_stats(tracker.before.text, predicted_after)
+ counted = True
+ else:
+ added, deleted = 0, 0
return _event_payload(
tracker,
phase="end",
@@ -173,11 +211,14 @@ def build_file_edit_end_event(tracker: FileEditTracker) -> dict[str, Any]:
added=added,
deleted=deleted,
approximate=False,
- binary=after.binary or after.oversized or after.unreadable,
+ binary=(after.binary or after.oversized or after.unreadable) and not counted,
)
-def build_file_edit_error_event(tracker: FileEditTracker, error: str | None = None) -> dict[str, Any]:
+def build_file_edit_error_event(
+ tracker: FileEditTracker,
+ error: str | None = None,
+) -> dict[str, Any]:
payload = _event_payload(
tracker,
phase="error",
@@ -191,6 +232,427 @@ def build_file_edit_error_event(tracker: FileEditTracker, error: str | None = No
return payload
+def build_file_edit_live_event(
+ tracker: FileEditTracker,
+ *,
+ added: int,
+ deleted: int = 0,
+) -> dict[str, Any]:
+ """Build an approximate in-progress event while tool-call arguments stream."""
+ return _event_payload(
+ tracker,
+ phase="start",
+ status="editing",
+ added=added,
+ deleted=deleted,
+ approximate=True,
+ )
+
+
+def build_file_edit_pending_event(
+ *,
+ call_id: str,
+ tool_name: str,
+ added: int = 0,
+ deleted: int = 0,
+) -> dict[str, Any]:
+ """Build an early placeholder before the streamed JSON path is available."""
+ return {
+ "version": 1,
+ "call_id": str(call_id or ""),
+ "tool": tool_name,
+ "path": "",
+ "phase": "start",
+ "added": max(0, int(added)),
+ "deleted": max(0, int(deleted)),
+ "approximate": True,
+ "status": "editing",
+ "pending": True,
+ }
+
+
+class StreamingFileEditTracker:
+ """Track file-edit tool arguments while the model is still streaming them.
+
+ Tool execution events only begin after the provider has completed the full
+ function call. For large ``write_file`` calls, the long wait is usually the
+ model producing the JSON ``content`` argument. Large ``edit_file`` calls
+ can have the same wait while ``old_text`` / ``new_text`` stream in. This
+ tracker converts those argument deltas into approximate WebUI file-edit
+ events before the final exact diff is available.
+ """
+
+ def __init__(
+ self,
+ *,
+ workspace: Path | None,
+ tools: Any,
+ emit: Callable[[list[dict[str, Any]]], Awaitable[None]],
+ ) -> None:
+ self._workspace = workspace
+ self._tools = tools
+ self._emit = emit
+ self._states: dict[str, _StreamingFileEditState] = {}
+
+ async def update(self, payload: dict[str, Any]) -> None:
+ key = _stream_key(payload)
+ if not key:
+ return
+ state = self._states.get(key)
+ if state is None:
+ state = _StreamingFileEditState(key=key)
+ self._states[key] = state
+
+ state.apply_delta(payload)
+ if state.name not in {"write_file", "edit_file"}:
+ return
+ if state.path is None:
+ state.path = _extract_complete_json_string(state.arguments, "path")
+ if state.path is None:
+ added, deleted = state.live_diff_counts()
+ now = time.monotonic()
+ if state.should_emit_pending(added, deleted, now):
+ state.mark_pending_emitted(added, deleted, now)
+ await self._emit([build_file_edit_pending_event(
+ call_id=state.call_id or state.key,
+ tool_name=state.name,
+ added=added,
+ deleted=deleted,
+ )])
+ return
+ if state.tracker is None:
+ tool = self._tools.get(state.name) if hasattr(self._tools, "get") else None
+ state.tracker = prepare_file_edit_tracker(
+ call_id=state.call_id or state.key,
+ tool_name=state.name,
+ tool=tool,
+ workspace=self._workspace,
+ params={"path": state.path},
+ )
+ if state.tracker is None:
+ return
+
+ added, deleted = state.live_diff_counts()
+ now = time.monotonic()
+ if not state.should_emit(added, deleted, now):
+ return
+ state.mark_emitted(added, deleted, now)
+ await self._emit([build_file_edit_live_event(
+ state.tracker,
+ added=added,
+ deleted=deleted,
+ )])
+
+ async def flush(self) -> None:
+ events: list[dict[str, Any]] = []
+ now = time.monotonic()
+ for state in self._states.values():
+ if state.tracker is None:
+ continue
+ added, deleted = state.live_diff_counts()
+ if (
+ state.last_emitted_added == added
+ and state.last_emitted_deleted == deleted
+ and state.emitted_once
+ ):
+ continue
+ state.mark_emitted(added, deleted, now)
+ events.append(build_file_edit_live_event(
+ state.tracker,
+ added=added,
+ deleted=deleted,
+ ))
+ if events:
+ await self._emit(events)
+
+ def apply_final_call_ids(self, final_tool_calls: list[Any]) -> None:
+ """Keep final start/end events keyed to any earlier streamed placeholder."""
+ for tool_call in final_tool_calls:
+ canonical = self.canonical_call_id_for(tool_call)
+ if canonical:
+ try:
+ tool_call.id = canonical
+ except Exception:
+ pass
+
+ def canonical_call_id_for(self, tool_call: Any) -> str | None:
+ for state in self._states.values():
+ if state.matches_final_tool_call(tool_call):
+ return state.call_id or (state.tracker.call_id if state.tracker else None) or state.key
+ return None
+
+ async def error_unmatched(
+ self,
+ final_tool_calls: list[Any],
+ error: str,
+ ) -> None:
+ """Mark streamed edits as failed when no final tool call will run."""
+ events: list[dict[str, Any]] = []
+ for state in self._states.values():
+ if state.tracker is None:
+ continue
+ if any(state.matches_final_tool_call(tool_call) for tool_call in final_tool_calls):
+ continue
+ events.append(build_file_edit_error_event(state.tracker, error))
+ if events:
+ await self._emit(events)
+
+
+@dataclass(slots=True)
+class _StreamingJsonStringField:
+ key: str
+ scan_pos: int | None = None
+ closed: bool = False
+ escape: bool = False
+ unicode_remaining: int = 0
+ unicode_buffer: str = ""
+ newline_count: int = 0
+ has_chars: bool = False
+ last_char_newline: bool = False
+ last_char_cr: bool = False
+
+ @property
+ def line_count(self) -> int:
+ if not self.has_chars:
+ return 0
+ return self.newline_count + (0 if self.last_char_newline else 1)
+
+ def reset(self) -> None:
+ self.scan_pos = None
+ self.closed = False
+ self.escape = False
+ self.unicode_remaining = 0
+ self.unicode_buffer = ""
+ self.newline_count = 0
+ self.has_chars = False
+ self.last_char_newline = False
+ self.last_char_cr = False
+
+ def scan(self, source: str) -> None:
+ if self.closed:
+ return
+ if self.scan_pos is None:
+ match = re.search(rf'"{re.escape(self.key)}"\s*:\s*"', source)
+ if match is None:
+ return
+ self.scan_pos = match.end()
+ i = self.scan_pos
+ while i < len(source):
+ ch = source[i]
+ if self.unicode_remaining > 0:
+ self.unicode_buffer += ch
+ self.unicode_remaining -= 1
+ if self.unicode_remaining == 0:
+ try:
+ decoded = chr(int(self.unicode_buffer, 16))
+ except ValueError:
+ decoded = "x"
+ self.unicode_buffer = ""
+ self._mark_char(decoded)
+ i += 1
+ continue
+ if self.escape:
+ self.escape = False
+ if ch == "u":
+ self.unicode_remaining = 4
+ self.unicode_buffer = ""
+ elif ch == "n":
+ self._mark_char("\n")
+ elif ch == "r":
+ self._mark_char("\r")
+ else:
+ self._mark_char(ch)
+ i += 1
+ continue
+ if ch == "\\":
+ self.escape = True
+ i += 1
+ continue
+ if ch == '"':
+ self.closed = True
+ i += 1
+ break
+ self._mark_char(ch)
+ i += 1
+ self.scan_pos = i
+
+ def _mark_char(self, ch: str) -> None:
+ self.has_chars = True
+ if ch == "\r":
+ self.newline_count += 1
+ self.last_char_newline = True
+ self.last_char_cr = True
+ elif ch == "\n":
+ if not self.last_char_cr:
+ self.newline_count += 1
+ self.last_char_newline = True
+ self.last_char_cr = False
+ else:
+ self.last_char_newline = False
+ self.last_char_cr = False
+
+
+@dataclass(slots=True)
+class _StreamingFileEditState:
+ key: str
+ call_id: str = ""
+ name: str = ""
+ arguments: str = ""
+ path: str | None = None
+ tracker: FileEditTracker | None = None
+ content: _StreamingJsonStringField = field(
+ default_factory=lambda: _StreamingJsonStringField("content")
+ )
+ old_text: _StreamingJsonStringField = field(
+ default_factory=lambda: _StreamingJsonStringField("old_text")
+ )
+ new_text: _StreamingJsonStringField = field(
+ default_factory=lambda: _StreamingJsonStringField("new_text")
+ )
+ emitted_once: bool = False
+ last_emitted_added: int = -1
+ last_emitted_deleted: int = -1
+ last_emit_at: float = 0.0
+ pending_emitted: bool = False
+ last_pending_added: int = -1
+ last_pending_deleted: int = -1
+ last_pending_at: float = 0.0
+
+ def apply_delta(self, payload: dict[str, Any]) -> None:
+ call_id = payload.get("call_id")
+ if isinstance(call_id, str) and call_id:
+ self.call_id = call_id
+ name = payload.get("name")
+ if isinstance(name, str) and name:
+ self.name = name
+ args = payload.get("arguments")
+ if isinstance(args, str):
+ self.arguments = args
+ self.content.reset()
+ self.old_text.reset()
+ self.new_text.reset()
+ return
+ delta = payload.get("arguments_delta")
+ if isinstance(delta, str) and delta:
+ self.arguments += delta
+
+ def live_diff_counts(self) -> tuple[int, int]:
+ if self.name == "write_file":
+ self.content.scan(self.arguments)
+ return self.content.line_count, 0
+ if self.name == "edit_file":
+ self.old_text.scan(self.arguments)
+ self.new_text.scan(self.arguments)
+ return self.new_text.line_count, self.old_text.line_count
+ return 0, 0
+
+ def should_emit(self, added: int, deleted: int, now: float) -> bool:
+ if not self.emitted_once:
+ return True
+ if added == self.last_emitted_added and deleted == self.last_emitted_deleted:
+ return False
+ if max(
+ abs(added - self.last_emitted_added),
+ abs(deleted - self.last_emitted_deleted),
+ ) >= _LIVE_EMIT_LINE_STEP:
+ return True
+ return now - self.last_emit_at >= _LIVE_EMIT_INTERVAL_S
+
+ def mark_emitted(self, added: int, deleted: int, now: float) -> None:
+ self.emitted_once = True
+ self.last_emitted_added = added
+ self.last_emitted_deleted = deleted
+ self.last_emit_at = now
+
+ def should_emit_pending(self, added: int, deleted: int, now: float) -> bool:
+ if not self.pending_emitted:
+ return True
+ if added == self.last_pending_added and deleted == self.last_pending_deleted:
+ return False
+ if max(
+ abs(added - self.last_pending_added),
+ abs(deleted - self.last_pending_deleted),
+ ) >= _LIVE_EMIT_LINE_STEP:
+ return True
+ return now - self.last_pending_at >= _LIVE_EMIT_INTERVAL_S
+
+ def mark_pending_emitted(self, added: int, deleted: int, now: float) -> None:
+ self.pending_emitted = True
+ self.last_pending_added = added
+ self.last_pending_deleted = deleted
+ self.last_pending_at = now
+
+ def matches_final_tool_call(self, tool_call: Any) -> bool:
+ call_id = getattr(tool_call, "id", None)
+ canonical = self.call_id or (self.tracker.call_id if self.tracker else "")
+ if isinstance(call_id, str) and call_id and canonical and call_id == canonical:
+ return True
+ name = getattr(tool_call, "name", None)
+ if name != self.name:
+ return False
+ arguments = getattr(tool_call, "arguments", None)
+ if not isinstance(arguments, dict):
+ return False
+ path = arguments.get("path")
+ if self.path is None and isinstance(path, str) and path:
+ self.path = path
+ return True
+ return isinstance(path, str) and path == self.path
+
+
+def _stream_key(payload: dict[str, Any]) -> str:
+ index = payload.get("index")
+ if isinstance(index, int):
+ return f"idx:{index}"
+ if isinstance(index, str) and index:
+ return f"idx:{index}"
+ call_id = payload.get("call_id")
+ if isinstance(call_id, str) and call_id:
+ return f"id:{call_id}"
+ return ""
+
+
+def _extract_complete_json_string(source: str, key: str) -> str | None:
+ match = re.search(rf'"{re.escape(key)}"\s*:\s*"', source)
+ if match is None:
+ return None
+ out: list[str] = []
+ i = match.end()
+ escape = False
+ while i < len(source):
+ ch = source[i]
+ if escape:
+ escape = False
+ if ch == "n":
+ out.append("\n")
+ elif ch == "r":
+ out.append("\r")
+ elif ch == "t":
+ out.append("\t")
+ elif ch == "u":
+ digits = source[i + 1:i + 5]
+ if len(digits) < 4:
+ return None
+ try:
+ out.append(chr(int(digits, 16)))
+ except ValueError:
+ return None
+ i += 4
+ else:
+ out.append(ch)
+ i += 1
+ continue
+ if ch == "\\":
+ escape = True
+ i += 1
+ continue
+ if ch == '"':
+ return "".join(out)
+ out.append(ch)
+ i += 1
+ return None
+
+
def _event_payload(
tracker: FileEditTracker,
*,
@@ -206,6 +668,7 @@ def _event_payload(
"call_id": tracker.call_id,
"tool": tracker.tool,
"path": tracker.display_path,
+ "absolute_path": tracker.path.as_posix(),
"phase": phase,
"added": max(0, int(added)),
"deleted": max(0, int(deleted)),
@@ -260,8 +723,14 @@ def _predict_notebook_after_text(params: dict[str, Any], before_text: str) -> st
return None
new_source = params.get("new_source")
source = new_source if isinstance(new_source, str) else ""
- cell_type = params.get("cell_type") if params.get("cell_type") in ("code", "markdown") else "code"
- mode = params.get("edit_mode") if params.get("edit_mode") in ("replace", "insert", "delete") else "replace"
+ cell_type = (
+ params.get("cell_type") if params.get("cell_type") in ("code", "markdown") else "code"
+ )
+ mode = (
+ params.get("edit_mode")
+ if params.get("edit_mode") in ("replace", "insert", "delete")
+ else "replace"
+ )
if mode == "delete":
if 0 <= cell_index < len(cells):
cells.pop(cell_index)
diff --git a/nanobot/utils/session_attachments.py b/nanobot/utils/session_attachments.py
deleted file mode 100644
index d761d33b..00000000
--- a/nanobot/utils/session_attachments.py
+++ /dev/null
@@ -1,74 +0,0 @@
-"""Session replay: ensure assistant ``media`` paths are under the media root.
-
-WebUI history signing (``/api/.../messages``) only works for files inside
-``get_media_dir``. Tool-driven attachments may live in the workspace; stage
-copies into the websocket media bucket before persisting message JSON.
-"""
-
-from __future__ import annotations
-
-import shutil
-import uuid
-from pathlib import Path
-from typing import Any
-
-from loguru import logger
-
-from nanobot.config.paths import get_media_dir
-from nanobot.utils.helpers import safe_filename
-
-
-def stage_media_paths_for_session_replay(paths: list[str]) -> list[str]:
- """Keep local files only; copy anything outside the media root into ``media/websocket``."""
- root = get_media_dir().resolve()
- out: list[str] = []
- seen: set[str] = set()
- for raw in paths:
- if not isinstance(raw, str) or not raw.strip():
- continue
- if raw.startswith(("http://", "https://")):
- continue
- try:
- p = Path(raw).expanduser().resolve()
- except OSError:
- continue
- if not p.is_file():
- continue
- try:
- p.relative_to(root)
- key = str(p)
- except ValueError:
- try:
- media_dir = get_media_dir("websocket")
- staged = media_dir / f"{uuid.uuid4().hex[:12]}-{safe_filename(p.name) or 'attachment'}"
- shutil.copyfile(p, staged)
- key = str(staged.resolve())
- except OSError as exc:
- logger.warning("failed to stage session media from {}: {}", raw, exc)
- continue
- if key not in seen:
- out.append(key)
- seen.add(key)
- return out
-
-
-def merge_turn_media_into_last_assistant(
- all_messages: list[dict[str, Any]],
- generated_image_paths: list[str],
- extra_attachment_paths: list[str],
-) -> None:
- """Attach staged paths to the last assistant row in *all_messages* (in-place)."""
- merged = list(
- dict.fromkeys(
- [
- *stage_media_paths_for_session_replay(generated_image_paths),
- *stage_media_paths_for_session_replay(extra_attachment_paths),
- ]
- )
- )
- last = all_messages[-1] if all_messages else None
- if not merged or not last or last.get("role") != "assistant":
- return
- existing = last.get("media")
- base = existing if isinstance(existing, list) else []
- last["media"] = list(dict.fromkeys([*base, *merged]))
diff --git a/nanobot/webui/__init__.py b/nanobot/webui/__init__.py
new file mode 100644
index 00000000..1ee95c7b
--- /dev/null
+++ b/nanobot/webui/__init__.py
@@ -0,0 +1,2 @@
+"""Backend helpers for the bundled WebUI surface."""
+
diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py
new file mode 100644
index 00000000..a5ab13c5
--- /dev/null
+++ b/nanobot/webui/settings_api.py
@@ -0,0 +1,609 @@
+"""Settings REST helpers for the WebUI HTTP surface.
+
+The WebSocket channel owns transport/authentication. This module owns the
+settings payload shape and the allowlisted config mutations exposed to WebUI.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+from zoneinfo import ZoneInfo
+
+from nanobot.config.loader import get_config_path, load_config, save_config
+from nanobot.providers.image_generation import (
+ get_image_gen_provider,
+ image_gen_provider_names,
+)
+from nanobot.providers.registry import PROVIDERS, find_by_name
+
+QueryParams = dict[str, list[str]]
+
+_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
+ {"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
+ {"name": "brave", "label": "Brave Search", "credential": "api_key"},
+ {"name": "tavily", "label": "Tavily", "credential": "api_key"},
+ {"name": "searxng", "label": "SearXNG", "credential": "base_url"},
+ {"name": "jina", "label": "Jina", "credential": "api_key"},
+ {"name": "kagi", "label": "Kagi", "credential": "api_key"},
+ {"name": "olostep", "label": "Olostep", "credential": "api_key"},
+)
+_WEB_SEARCH_PROVIDER_BY_NAME = {
+ provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS
+}
+
+_IMAGE_GENERATION_ASPECT_RATIOS = {
+ "1:1",
+ "3:4",
+ "9:16",
+ "4:3",
+ "16:9",
+ "3:2",
+ "2:3",
+ "21:9",
+}
+
+
+class WebUISettingsError(ValueError):
+ """User-facing settings validation failure."""
+
+ def __init__(self, message: str, *, status: int = 400) -> None:
+ super().__init__(message)
+ self.message = message
+ self.status = status
+
+
+def _query_first(query: QueryParams, key: str) -> str | None:
+ values = query.get(key)
+ return values[0] if values else None
+
+
+def _query_first_alias(query: QueryParams, snake: str, camel: str) -> str | None:
+ value = _query_first(query, snake)
+ return _query_first(query, camel) if value is None else value
+
+
+def _mask_secret_hint(secret: str | None) -> str | None:
+ if not secret:
+ return None
+ if len(secret) <= 8:
+ return "••••"
+ return f"{secret[:4]}••••{secret[-4:]}"
+
+
+def _provider_requires_api_key(spec: Any) -> bool:
+ if spec.backend == "azure_openai":
+ return True
+ if spec.is_local or spec.is_direct:
+ return False
+ return True
+
+
+def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
+ if _provider_requires_api_key(spec):
+ return bool(provider_config.api_key)
+ return bool(
+ provider_config.api_key
+ or provider_config.api_base
+ or getattr(provider_config, "region", None)
+ or getattr(provider_config, "profile", None)
+ )
+
+
+def _parse_bool(value: str, field: str) -> bool:
+ normalized = value.strip().lower()
+ if normalized not in {"1", "0", "true", "false", "yes", "no"}:
+ raise WebUISettingsError(f"{field} must be boolean")
+ return normalized in {"1", "true", "yes"}
+
+
+def _image_generation_provider_rows(config: Any) -> list[dict[str, Any]]:
+ rows: list[dict[str, Any]] = []
+ for name in image_gen_provider_names():
+ spec = find_by_name(name)
+ provider_config = getattr(config.providers, name, None)
+ configured = (
+ _provider_configured_for_settings(spec, provider_config)
+ if spec is not None and provider_config is not None
+ else bool(getattr(provider_config, "api_key", None))
+ )
+ rows.append(
+ {
+ "name": name,
+ "label": spec.label if spec is not None else name,
+ "configured": configured,
+ "api_key_hint": _mask_secret_hint(
+ getattr(provider_config, "api_key", None)
+ ),
+ "api_base": getattr(provider_config, "api_base", None),
+ "default_api_base": (
+ spec.default_api_base if spec and spec.default_api_base else None
+ ),
+ }
+ )
+ return rows
+
+
+def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
+ config = load_config()
+ defaults = config.agents.defaults
+ active_preset_name = defaults.model_preset or "default"
+ try:
+ effective_preset = config.resolve_preset()
+ except Exception:
+ effective_preset = config.resolve_default_preset()
+ active_preset_name = "default"
+
+ provider_name = (
+ config.get_provider_name(effective_preset.model, preset=effective_preset)
+ or effective_preset.provider
+ )
+ provider = config.get_provider(effective_preset.model, preset=effective_preset)
+ selected_provider = provider_name
+ if effective_preset.provider != "auto":
+ spec = find_by_name(effective_preset.provider)
+ selected_provider = spec.name if spec else provider_name
+
+ providers = []
+ for spec in PROVIDERS:
+ provider_config = getattr(config.providers, spec.name, None)
+ if provider_config is None or spec.is_oauth:
+ continue
+ providers.append(
+ {
+ "name": spec.name,
+ "label": spec.label,
+ "configured": _provider_configured_for_settings(spec, provider_config),
+ "api_key_required": _provider_requires_api_key(spec),
+ "api_key_hint": _mask_secret_hint(provider_config.api_key),
+ "api_base": provider_config.api_base,
+ "default_api_base": spec.default_api_base or None,
+ }
+ )
+
+ search_config = config.tools.web.search
+ image_config = config.tools.image_generation
+ search_provider = (
+ search_config.provider
+ if search_config.provider in _WEB_SEARCH_PROVIDER_BY_NAME
+ else "duckduckgo"
+ )
+ image_providers = _image_generation_provider_rows(config)
+ selected_image_provider = next(
+ (
+ provider
+ for provider in image_providers
+ if provider["name"] == image_config.provider
+ ),
+ None,
+ )
+ model_presets = [
+ {
+ "name": "default",
+ "label": "Default",
+ "active": active_preset_name == "default",
+ "is_default": True,
+ "model": defaults.model,
+ "provider": defaults.provider,
+ "max_tokens": defaults.max_tokens,
+ "context_window_tokens": defaults.context_window_tokens,
+ "temperature": defaults.temperature,
+ "reasoning_effort": defaults.reasoning_effort,
+ }
+ ]
+ for name, preset in config.model_presets.items():
+ model_presets.append(
+ {
+ "name": name,
+ "label": name,
+ "active": active_preset_name == name,
+ "is_default": False,
+ "model": preset.model,
+ "provider": preset.provider,
+ "max_tokens": preset.max_tokens,
+ "context_window_tokens": preset.context_window_tokens,
+ "temperature": preset.temperature,
+ "reasoning_effort": preset.reasoning_effort,
+ }
+ )
+
+ exec_config = config.tools.exec
+ return {
+ "agent": {
+ "model": effective_preset.model,
+ "provider": selected_provider,
+ "resolved_provider": provider_name,
+ "has_api_key": bool(provider and provider.api_key),
+ "model_preset": active_preset_name,
+ "max_tokens": effective_preset.max_tokens,
+ "context_window_tokens": effective_preset.context_window_tokens,
+ "temperature": effective_preset.temperature,
+ "reasoning_effort": effective_preset.reasoning_effort,
+ "timezone": defaults.timezone,
+ "bot_name": defaults.bot_name,
+ "bot_icon": defaults.bot_icon,
+ "tool_hint_max_length": defaults.tool_hint_max_length,
+ },
+ "model_presets": model_presets,
+ "providers": providers,
+ "web_search": {
+ "provider": search_provider,
+ "api_key_hint": _mask_secret_hint(search_config.api_key),
+ "base_url": search_config.base_url or None,
+ "max_results": search_config.max_results,
+ "timeout": search_config.timeout,
+ "providers": list(_WEB_SEARCH_PROVIDER_OPTIONS),
+ },
+ "web": {
+ "enable": config.tools.web.enable,
+ "proxy": config.tools.web.proxy,
+ "user_agent": config.tools.web.user_agent,
+ "search": {
+ "max_results": search_config.max_results,
+ "timeout": search_config.timeout,
+ },
+ "fetch": {
+ "use_jina_reader": config.tools.web.fetch.use_jina_reader,
+ },
+ },
+ "image_generation": {
+ "enabled": image_config.enabled,
+ "provider": image_config.provider,
+ "provider_configured": bool(
+ selected_image_provider and selected_image_provider["configured"]
+ ),
+ "model": image_config.model,
+ "default_aspect_ratio": image_config.default_aspect_ratio,
+ "default_image_size": image_config.default_image_size,
+ "max_images_per_turn": image_config.max_images_per_turn,
+ "save_dir": image_config.save_dir,
+ "providers": image_providers,
+ },
+ "runtime": {
+ "config_path": str(get_config_path().expanduser()),
+ "workspace_path": str(config.workspace_path),
+ "gateway_host": config.gateway.host,
+ "gateway_port": config.gateway.port,
+ "heartbeat": {
+ "enabled": config.gateway.heartbeat.enabled,
+ "interval_s": config.gateway.heartbeat.interval_s,
+ "keep_recent_messages": config.gateway.heartbeat.keep_recent_messages,
+ },
+ "dream": {
+ "schedule": defaults.dream.describe_schedule(),
+ "max_batch_size": defaults.dream.max_batch_size,
+ "max_iterations": defaults.dream.max_iterations,
+ "annotate_line_ages": defaults.dream.annotate_line_ages,
+ },
+ "unified_session": defaults.unified_session,
+ },
+ "advanced": {
+ "restrict_to_workspace": config.tools.restrict_to_workspace,
+ "ssrf_whitelist_count": len(config.tools.ssrf_whitelist),
+ "mcp_server_count": len(config.tools.mcp_servers),
+ "exec_enabled": exec_config.enable,
+ "exec_sandbox": exec_config.sandbox or None,
+ "exec_path_append_set": bool(exec_config.path_append),
+ },
+ "requires_restart": requires_restart,
+ }
+
+
+def update_agent_settings(query: QueryParams) -> dict[str, Any]:
+ config = load_config()
+ defaults = config.agents.defaults
+ changed = False
+ restart_required = False
+
+ if "model_preset" in query or "modelPreset" in query:
+ preset = (_query_first_alias(query, "model_preset", "modelPreset") or "").strip()
+ preset_value = None if not preset or preset == "default" else preset
+ if preset_value is not None and preset_value not in config.model_presets:
+ raise WebUISettingsError("unknown model preset")
+ if defaults.model_preset != preset_value:
+ defaults.model_preset = preset_value
+ changed = True
+
+ model = _query_first(query, "model")
+ if model is not None:
+ model = model.strip()
+ if not model:
+ raise WebUISettingsError("model is required")
+ if defaults.model != model:
+ defaults.model = model
+ changed = True
+
+ provider = _query_first(query, "provider")
+ if provider is not None:
+ provider = provider.strip()
+ if not provider:
+ raise WebUISettingsError("provider is required")
+ spec = find_by_name(provider)
+ if spec is None:
+ raise WebUISettingsError("unknown provider")
+ provider_config = getattr(config.providers, provider, None)
+ if (
+ provider_config is None
+ or not _provider_configured_for_settings(spec, provider_config)
+ ):
+ raise WebUISettingsError("provider is not configured")
+ if defaults.provider != provider:
+ defaults.provider = provider
+ changed = True
+
+ timezone = _query_first(query, "timezone")
+ if timezone is not None:
+ timezone = timezone.strip()
+ if not timezone:
+ raise WebUISettingsError("timezone is required")
+ try:
+ ZoneInfo(timezone)
+ except Exception:
+ raise WebUISettingsError("invalid timezone") from None
+ if defaults.timezone != timezone:
+ defaults.timezone = timezone
+ changed = True
+ restart_required = True
+
+ bot_name = _query_first_alias(query, "bot_name", "botName")
+ if bot_name is not None:
+ bot_name = bot_name.strip()
+ if not bot_name:
+ raise WebUISettingsError("bot_name is required")
+ if defaults.bot_name != bot_name:
+ defaults.bot_name = bot_name
+ changed = True
+ restart_required = True
+
+ bot_icon = _query_first_alias(query, "bot_icon", "botIcon")
+ if bot_icon is not None:
+ bot_icon = bot_icon.strip()
+ if defaults.bot_icon != bot_icon:
+ defaults.bot_icon = bot_icon
+ changed = True
+ restart_required = True
+
+ tool_hint_max_length = _query_first_alias(
+ query,
+ "tool_hint_max_length",
+ "toolHintMaxLength",
+ )
+ if tool_hint_max_length is not None:
+ try:
+ parsed = int(tool_hint_max_length)
+ except ValueError:
+ raise WebUISettingsError("tool_hint_max_length must be an integer") from None
+ if parsed < 20 or parsed > 500:
+ raise WebUISettingsError("tool_hint_max_length must be between 20 and 500")
+ if defaults.tool_hint_max_length != parsed:
+ defaults.tool_hint_max_length = parsed
+ changed = True
+ restart_required = True
+
+ if changed:
+ save_config(config)
+ return settings_payload(requires_restart=restart_required)
+
+
+def update_provider_settings(query: QueryParams) -> dict[str, Any]:
+ 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 or spec.is_oauth:
+ raise WebUISettingsError("unknown provider")
+
+ config = load_config()
+ provider_config = getattr(config.providers, spec.name, None)
+ if provider_config is None:
+ raise WebUISettingsError("unknown provider")
+
+ changed = False
+ if "api_key" in query or "apiKey" in query:
+ api_key = _query_first_alias(query, "api_key", "apiKey")
+ api_key = (api_key or "").strip() or None
+ if provider_config.api_key != api_key:
+ provider_config.api_key = api_key
+ changed = True
+
+ if "api_base" in query or "apiBase" in query:
+ api_base = _query_first_alias(query, "api_base", "apiBase")
+ api_base = (api_base or "").strip() or None
+ if provider_config.api_base != api_base:
+ provider_config.api_base = api_base
+ changed = True
+
+ if changed:
+ save_config(config)
+ image_config = config.tools.image_generation
+ restart_required = (
+ changed
+ and image_config.enabled
+ and image_config.provider == spec.name
+ and get_image_gen_provider(spec.name) is not None
+ )
+ return settings_payload(requires_restart=restart_required)
+
+
+def update_web_search_settings(query: QueryParams) -> dict[str, Any]:
+ provider_name = (_query_first(query, "provider") or "").strip().lower()
+ provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
+ if provider_option is None:
+ raise WebUISettingsError("unknown web search provider")
+
+ config = load_config()
+ search_config = config.tools.web.search
+ web_config = config.tools.web
+ previous_provider = search_config.provider
+ changed = False
+ restart_required = False
+
+ def set_search_value(attr: str, value: object) -> None:
+ nonlocal changed
+ if getattr(search_config, attr) != value:
+ setattr(search_config, attr, value)
+ changed = True
+
+ def set_fetch_value(attr: str, value: object) -> None:
+ nonlocal changed
+ if getattr(web_config.fetch, attr) != value:
+ setattr(web_config.fetch, attr, value)
+ changed = True
+
+ if search_config.provider != provider_name:
+ search_config.provider = provider_name
+ changed = True
+
+ credential = provider_option["credential"]
+ if credential == "none":
+ set_search_value("api_key", "")
+ set_search_value("base_url", "")
+ elif credential == "base_url":
+ base_url = _query_first_alias(query, "base_url", "baseUrl")
+ base_url = base_url.strip() if base_url is not None else None
+ if not base_url and previous_provider == provider_name and search_config.base_url:
+ base_url = search_config.base_url
+ if not base_url:
+ raise WebUISettingsError("base_url is required")
+ set_search_value("base_url", base_url)
+ set_search_value("api_key", "")
+ else:
+ api_key = _query_first_alias(query, "api_key", "apiKey")
+ api_key = api_key.strip() if api_key is not None else None
+ if not api_key and previous_provider == provider_name and search_config.api_key:
+ api_key = search_config.api_key
+ if not api_key:
+ raise WebUISettingsError("api_key is required")
+ set_search_value("api_key", api_key)
+ set_search_value("base_url", "")
+
+ max_results = _query_first_alias(query, "max_results", "maxResults")
+ if max_results is not None:
+ try:
+ parsed = int(max_results)
+ except ValueError:
+ raise WebUISettingsError("max_results must be an integer") from None
+ if parsed < 1 or parsed > 10:
+ raise WebUISettingsError("max_results must be between 1 and 10")
+ set_search_value("max_results", parsed)
+
+ timeout = _query_first(query, "timeout")
+ if timeout is not None:
+ try:
+ parsed_timeout = int(timeout)
+ except ValueError:
+ raise WebUISettingsError("timeout must be an integer") from None
+ if parsed_timeout < 1 or parsed_timeout > 120:
+ raise WebUISettingsError("timeout must be between 1 and 120")
+ set_search_value("timeout", parsed_timeout)
+
+ use_jina_reader = _query_first_alias(query, "use_jina_reader", "useJinaReader")
+ if use_jina_reader is not None:
+ normalized = use_jina_reader.strip().lower()
+ if normalized not in {"1", "0", "true", "false", "yes", "no"}:
+ raise WebUISettingsError("use_jina_reader must be boolean")
+ previous_jina_reader = web_config.fetch.use_jina_reader
+ set_fetch_value("use_jina_reader", normalized in {"1", "true", "yes"})
+ if web_config.fetch.use_jina_reader != previous_jina_reader:
+ restart_required = True
+
+ if changed:
+ save_config(config)
+ return settings_payload(requires_restart=restart_required)
+
+
+def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
+ config = load_config()
+ image_config = config.tools.image_generation
+ changed = False
+
+ provider_name = _query_first(query, "provider")
+ if provider_name is not None:
+ provider_name = provider_name.strip().lower()
+ if not provider_name:
+ raise WebUISettingsError("image generation provider is required")
+ if get_image_gen_provider(provider_name) is None:
+ raise WebUISettingsError("unknown image generation provider")
+ if image_config.provider != provider_name:
+ image_config.provider = provider_name
+ changed = True
+
+ enabled = _query_first(query, "enabled")
+ if enabled is not None:
+ parsed_enabled = _parse_bool(enabled, "enabled")
+ if image_config.enabled != parsed_enabled:
+ image_config.enabled = parsed_enabled
+ changed = True
+
+ model = _query_first(query, "model")
+ if model is not None:
+ model = model.strip()
+ if not model:
+ raise WebUISettingsError("image generation model is required")
+ if len(model) > 200:
+ raise WebUISettingsError("image generation model is too long")
+ if image_config.model != model:
+ image_config.model = model
+ changed = True
+
+ default_aspect_ratio = _query_first_alias(
+ query,
+ "default_aspect_ratio",
+ "defaultAspectRatio",
+ )
+ if default_aspect_ratio is not None:
+ default_aspect_ratio = default_aspect_ratio.strip()
+ if default_aspect_ratio not in _IMAGE_GENERATION_ASPECT_RATIOS:
+ raise WebUISettingsError("unsupported image generation aspect ratio")
+ if image_config.default_aspect_ratio != default_aspect_ratio:
+ image_config.default_aspect_ratio = default_aspect_ratio
+ changed = True
+
+ default_image_size = _query_first_alias(
+ query,
+ "default_image_size",
+ "defaultImageSize",
+ )
+ if default_image_size is not None:
+ default_image_size = default_image_size.strip()
+ if not default_image_size:
+ raise WebUISettingsError("default image size is required")
+ if len(default_image_size) > 32 or not all(
+ char.isascii() and (char.isalnum() or char in {"x", "X", ":", "-", "_"})
+ for char in default_image_size
+ ):
+ raise WebUISettingsError("unsupported image generation size")
+ if image_config.default_image_size != default_image_size:
+ image_config.default_image_size = default_image_size
+ changed = True
+
+ max_images_per_turn = _query_first_alias(
+ query,
+ "max_images_per_turn",
+ "maxImagesPerTurn",
+ )
+ if max_images_per_turn is not None:
+ try:
+ parsed_max = int(max_images_per_turn)
+ except ValueError:
+ raise WebUISettingsError("max_images_per_turn must be an integer") from None
+ if parsed_max < 1 or parsed_max > 8:
+ raise WebUISettingsError("max_images_per_turn must be between 1 and 8")
+ if image_config.max_images_per_turn != parsed_max:
+ image_config.max_images_per_turn = parsed_max
+ changed = True
+
+ if image_config.enabled:
+ selected_provider = next(
+ (
+ provider
+ for provider in _image_generation_provider_rows(config)
+ if provider["name"] == image_config.provider
+ ),
+ None,
+ )
+ if not selected_provider or not selected_provider["configured"]:
+ raise WebUISettingsError("image generation provider is not configured")
+
+ if changed:
+ save_config(config)
+ return settings_payload(requires_restart=changed)
diff --git a/nanobot/webui/sidebar_state.py b/nanobot/webui/sidebar_state.py
new file mode 100644
index 00000000..12d26c10
--- /dev/null
+++ b/nanobot/webui/sidebar_state.py
@@ -0,0 +1,193 @@
+"""Persisted WebUI sidebar workspace state.
+
+This state is UI-only metadata, scoped to the active nanobot instance data
+directory (the directory containing the current config.json). It deliberately
+does not modify agent sessions.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import time
+from pathlib import Path
+from typing import Any
+
+from loguru import logger
+
+from nanobot.config.paths import get_webui_dir
+
+WEBUI_SIDEBAR_STATE_SCHEMA_VERSION = 1
+_MAX_STATE_FILE_BYTES = 256 * 1024
+_MAX_LIST_ITEMS = 2_000
+_MAX_MAP_ITEMS = 2_000
+_MAX_KEY_LEN = 512
+_MAX_TITLE_LEN = 160
+_MAX_TAG_LEN = 40
+_ALLOWED_DENSITIES = {"comfortable", "compact"}
+_ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc"}
+
+
+def webui_sidebar_state_path() -> Path:
+ return get_webui_dir() / "sidebar-state.json"
+
+
+def default_webui_sidebar_state() -> dict[str, Any]:
+ return {
+ "schema_version": WEBUI_SIDEBAR_STATE_SCHEMA_VERSION,
+ "pinned_keys": [],
+ "archived_keys": [],
+ "title_overrides": {},
+ "tags_by_key": {},
+ "collapsed_groups": {},
+ "view": {
+ "density": "comfortable",
+ "show_previews": False,
+ "show_timestamps": False,
+ "show_archived": False,
+ "sort": "updated_desc",
+ },
+ "updated_at": None,
+ }
+
+
+def _clean_string(value: Any, *, max_len: int = _MAX_KEY_LEN) -> str | None:
+ if not isinstance(value, str):
+ return None
+ cleaned = value.strip()
+ if not cleaned:
+ return None
+ return cleaned[:max_len]
+
+
+def _clean_string_list(value: Any, *, max_len: int = _MAX_KEY_LEN) -> list[str]:
+ if not isinstance(value, list):
+ return []
+ out: list[str] = []
+ seen: set[str] = set()
+ for item in value[:_MAX_LIST_ITEMS]:
+ cleaned = _clean_string(item, max_len=max_len)
+ if cleaned is None or cleaned in seen:
+ continue
+ seen.add(cleaned)
+ out.append(cleaned)
+ return out
+
+
+def _clean_bool_map(value: Any) -> dict[str, bool]:
+ if not isinstance(value, dict):
+ return {}
+ out: dict[str, bool] = {}
+ for key, raw in list(value.items())[:_MAX_MAP_ITEMS]:
+ cleaned_key = _clean_string(key)
+ if cleaned_key is None:
+ continue
+ out[cleaned_key] = bool(raw)
+ return out
+
+
+def _clean_title_overrides(value: Any) -> dict[str, str]:
+ if not isinstance(value, dict):
+ return {}
+ out: dict[str, str] = {}
+ for key, raw_title in list(value.items())[:_MAX_MAP_ITEMS]:
+ cleaned_key = _clean_string(key)
+ cleaned_title = _clean_string(raw_title, max_len=_MAX_TITLE_LEN)
+ if cleaned_key is None or cleaned_title is None:
+ continue
+ out[cleaned_key] = cleaned_title
+ return out
+
+
+def _clean_tags_by_key(value: Any) -> dict[str, list[str]]:
+ if not isinstance(value, dict):
+ return {}
+ out: dict[str, list[str]] = {}
+ for key, raw_tags in list(value.items())[:_MAX_MAP_ITEMS]:
+ cleaned_key = _clean_string(key)
+ if cleaned_key is None:
+ continue
+ tags = _clean_string_list(raw_tags, max_len=_MAX_TAG_LEN)[:12]
+ if tags:
+ out[cleaned_key] = tags
+ return out
+
+
+def _clean_view(value: Any) -> dict[str, Any]:
+ default = default_webui_sidebar_state()["view"]
+ if not isinstance(value, dict):
+ return dict(default)
+ density = value.get("density")
+ sort = value.get("sort")
+ return {
+ "density": density if density in _ALLOWED_DENSITIES else default["density"],
+ "show_previews": bool(value.get("show_previews", default["show_previews"])),
+ "show_timestamps": bool(value.get("show_timestamps", default["show_timestamps"])),
+ "show_archived": bool(value.get("show_archived", default["show_archived"])),
+ "sort": sort if sort in _ALLOWED_SORTS else default["sort"],
+ }
+
+
+def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
+ """Return a schema-v1 sidebar state from any older/partial input."""
+ if not isinstance(raw, dict):
+ raw = {}
+ state = default_webui_sidebar_state()
+ state["pinned_keys"] = _clean_string_list(raw.get("pinned_keys"))
+ state["archived_keys"] = _clean_string_list(raw.get("archived_keys"))
+ state["title_overrides"] = _clean_title_overrides(raw.get("title_overrides"))
+ state["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key"))
+ state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups"))
+ state["view"] = _clean_view(raw.get("view"))
+ updated_at = raw.get("updated_at")
+ state["updated_at"] = updated_at if isinstance(updated_at, str) else None
+ return state
+
+
+def read_webui_sidebar_state() -> dict[str, Any]:
+ path = webui_sidebar_state_path()
+ if not path.is_file():
+ return default_webui_sidebar_state()
+ try:
+ if path.stat().st_size > _MAX_STATE_FILE_BYTES:
+ logger.warning("webui sidebar state too large, ignoring: {}", path)
+ return default_webui_sidebar_state()
+ with open(path, encoding="utf-8") as f:
+ raw = json.load(f)
+ except (OSError, json.JSONDecodeError) as e:
+ logger.warning("read webui sidebar state failed {}: {}", path, e)
+ return default_webui_sidebar_state()
+ return normalize_webui_sidebar_state(raw)
+
+
+def write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]:
+ state = normalize_webui_sidebar_state(raw)
+ state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
+ encoded = json.dumps(
+ state,
+ ensure_ascii=False,
+ indent=2,
+ sort_keys=True,
+ ).encode("utf-8")
+ if len(encoded) > _MAX_STATE_FILE_BYTES:
+ raise ValueError("sidebar state is too large")
+
+ path = webui_sidebar_state_path()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_suffix(".json.tmp")
+ with open(tmp, "wb") as f:
+ f.write(encoded)
+ f.write(b"\n")
+ f.flush()
+ os.fsync(f.fileno())
+ os.replace(tmp, path)
+ try:
+ dir_fd = os.open(path.parent, os.O_RDONLY)
+ except OSError:
+ return state
+ try:
+ os.fsync(dir_fd)
+ finally:
+ os.close(dir_fd)
+ return state
+
diff --git a/nanobot/utils/webui_thread_disk.py b/nanobot/webui/thread_disk.py
similarity index 90%
rename from nanobot/utils/webui_thread_disk.py
rename to nanobot/webui/thread_disk.py
index 65f12825..03438f8a 100644
--- a/nanobot/utils/webui_thread_disk.py
+++ b/nanobot/webui/thread_disk.py
@@ -1,4 +1,4 @@
-"""Legacy WebUI JSON snapshot path helpers (JSON file); transcripts use webui_transcript."""
+"""Legacy WebUI JSON snapshot path helpers (JSON file); transcripts use transcript."""
from __future__ import annotations
@@ -8,7 +8,7 @@ from loguru import logger
from nanobot.config.paths import get_webui_dir
from nanobot.session.manager import SessionManager
-from nanobot.utils.webui_transcript import delete_webui_transcript
+from nanobot.webui.transcript import delete_webui_transcript
def webui_thread_file_path(session_key: str) -> Path:
diff --git a/nanobot/utils/webui_transcript.py b/nanobot/webui/transcript.py
similarity index 87%
rename from nanobot/utils/webui_transcript.py
rename to nanobot/webui/transcript.py
index 819804a0..d7978d9f 100644
--- a/nanobot/utils/webui_transcript.py
+++ b/nanobot/webui/transcript.py
@@ -166,6 +166,17 @@ def replay_transcript_to_ui_messages(
def _ensure_activity_segment() -> str:
return active_activity_segment_id or _new_activity_segment()
+ def close_activity_for_answer() -> None:
+ nonlocal active_activity_segment_id, active_file_edit_segment_id
+ active_activity_segment_id = None
+ active_file_edit_segment_id = None
+
+ def close_file_edit_phase_before_activity() -> None:
+ nonlocal active_activity_segment_id, active_file_edit_segment_id
+ if active_file_edit_segment_id:
+ active_activity_segment_id = None
+ active_file_edit_segment_id = None
+
def attach_reasoning_chunk(prev: list[dict[str, Any]], chunk: str, idx: int) -> None:
for i in range(len(prev) - 1, -1, -1):
candidate = prev[i]
@@ -265,7 +276,7 @@ def replay_transcript_to_ui_messages(
return
def absorb_complete(extra: dict[str, Any], idx: int) -> None:
- nonlocal active_activity_segment_id
+ nonlocal active_activity_segment_id, active_file_edit_segment_id
last = messages[-1] if messages else None
if last and is_reasoning_only_placeholder(last):
messages[-1] = {
@@ -284,35 +295,50 @@ def replay_transcript_to_ui_messages(
},
)
active_activity_segment_id = None
+ active_file_edit_segment_id = None
def _file_edit_key(edit: dict[str, Any]) -> str:
- return "|".join(
- str(edit.get(k) or "")
- for k in ("call_id", "tool", "path")
- )
+ call_id = str(edit.get("call_id") or "")
+ tool = str(edit.get("tool") or "")
+ if call_id:
+ return f"{call_id}|{tool}"
+ return f"{tool}|{edit.get('path') or ''}"
+
+ def find_file_edit_trace_index(
+ segment: str | None,
+ edits: list[dict[str, Any]],
+ ) -> int | None:
+ incoming_keys = {_file_edit_key(edit) for edit in edits if isinstance(edit, dict)}
+ for i in range(len(messages) - 1, -1, -1):
+ candidate = messages[i]
+ if candidate.get("role") == "user":
+ break
+ if candidate.get("kind") != "trace" or not candidate.get("fileEdits"):
+ continue
+ if segment and candidate.get("activitySegmentId") == segment:
+ return i
+ existing_edits = candidate.get("fileEdits")
+ if not isinstance(existing_edits, list):
+ continue
+ for existing in existing_edits:
+ if isinstance(existing, dict) and _file_edit_key(existing) in incoming_keys:
+ return i
+ return None
def upsert_file_edits(edits: list[dict[str, Any]], idx: int) -> None:
nonlocal active_file_edit_segment_id
if not edits:
return
- last = messages[-1] if messages else None
- if (
- active_file_edit_segment_id
- and last
- and last.get("kind") == "trace"
- and last.get("fileEdits")
- ):
- segment = active_file_edit_segment_id
+ segment = active_file_edit_segment_id
+ target_index = find_file_edit_trace_index(segment, edits)
+ if target_index is not None:
+ last = messages[target_index]
+ segment = str(last.get("activitySegmentId") or segment or _new_activity_segment(activate=False))
+ active_file_edit_segment_id = segment
else:
- segment = _new_activity_segment(activate=False)
+ if not segment:
+ segment = _new_activity_segment(activate=False)
active_file_edit_segment_id = segment
- if not (
- last
- and last.get("kind") == "trace"
- and not last.get("isStreaming")
- and last.get("fileEdits")
- and last.get("activitySegmentId") == segment
- ):
messages.append(
{
"id": _new_id("tr", idx),
@@ -325,7 +351,11 @@ def replay_transcript_to_ui_messages(
"createdAt": _ts_base + idx,
},
)
- last = messages[-1]
+ target_index = len(messages) - 1
+ last = messages[target_index]
+ if not segment:
+ segment = _new_activity_segment(activate=False)
+ active_file_edit_segment_id = segment
existing = list(last.get("fileEdits") or [])
index_by_key = {
_file_edit_key(edit): pos
@@ -338,11 +368,14 @@ def replay_transcript_to_ui_messages(
key = _file_edit_key(edit)
if key in index_by_key:
pos = index_by_key[key]
- existing[pos] = {**existing[pos], **edit}
+ merged = {**existing[pos], **edit}
+ if edit.get("path") and not edit.get("pending"):
+ merged.pop("pending", None)
+ existing[pos] = merged
else:
index_by_key[key] = len(existing)
existing.append(dict(edit))
- messages[-1] = {
+ messages[target_index] = {
**last,
"fileEdits": existing,
"activitySegmentId": last.get("activitySegmentId") or segment,
@@ -387,6 +420,7 @@ def replay_transcript_to_ui_messages(
chunk = rec.get("text")
if not isinstance(chunk, str):
continue
+ close_activity_for_answer()
adopted = find_active_placeholder(messages) if buffer_message_id is None else None
if buffer_message_id is None:
if adopted:
@@ -425,6 +459,7 @@ def replay_transcript_to_ui_messages(
chunk = rec.get("text")
if not isinstance(chunk, str) or not chunk:
continue
+ close_file_edit_phase_before_activity()
attach_reasoning_chunk(messages, chunk, idx)
continue
@@ -446,6 +481,7 @@ def replay_transcript_to_ui_messages(
line = rec.get("text")
if not isinstance(line, str) or not line:
continue
+ close_file_edit_phase_before_activity()
attach_reasoning_chunk(messages, line, idx)
close_reasoning(messages)
continue
diff --git a/tests/agent/test_context_prompt_cache.py b/tests/agent/test_context_prompt_cache.py
index 4b6f3dad..bbafd489 100644
--- a/tests/agent/test_context_prompt_cache.py
+++ b/tests/agent/test_context_prompt_cache.py
@@ -314,8 +314,8 @@ def test_system_prompt_keeps_message_tool_out_of_current_chat_replies(tmp_path)
prompt = builder.build_system_prompt(channel="slack")
assert "Do not use the 'message' tool for normal replies in the current chat" in prompt
- assert "the runtime attaches those artifacts to the final assistant reply automatically" in prompt
- assert "do not call 'message' just to announce or resend them" in prompt
+ assert "When 'generate_image' creates images" in prompt
+ assert "call 'message' with the artifact paths in the 'media' parameter" in prompt
assert "Wait for the tool results, then answer once" in prompt
diff --git a/tests/agent/test_loop_image_generation_media.py b/tests/agent/test_loop_image_generation_media.py
index 6c10ecb1..cfcc3b2c 100644
--- a/tests/agent/test_loop_image_generation_media.py
+++ b/tests/agent/test_loop_image_generation_media.py
@@ -29,14 +29,15 @@ class FakeImageClient:
@pytest.mark.asyncio
-async def test_generated_image_media_is_attached_to_final_assistant_message(
+async def test_outbound_no_longer_carries_generated_media(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
+ """Media delivery is now the LLM's responsibility via the message tool."""
set_config_path(tmp_path / "config.json")
monkeypatch.setattr(
- "nanobot.agent.tools.image_generation.OpenRouterImageGenerationClient",
- FakeImageClient,
+ "nanobot.agent.tools.image_generation.get_image_gen_provider",
+ lambda name: FakeImageClient if name == "openrouter" else None,
)
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
@@ -81,9 +82,6 @@ async def test_generated_image_media_is_attached_to_final_assistant_message(
assert result is not None
assert result.content == "Done"
- assert len(result.media) == 1
- assert Path(result.media[0]).is_file()
-
- session = loop.sessions.get_or_create("websocket:chat-image")
- assert session.messages[-1]["role"] == "assistant"
- assert session.messages[-1]["media"] == result.media
+ # OutboundMessage no longer carries generated media —
+ # the LLM sends images via the message tool instead.
+ assert result.media == []
diff --git a/tests/agent/test_loop_progress.py b/tests/agent/test_loop_progress.py
index 43a69143..f7bd038b 100644
--- a/tests/agent/test_loop_progress.py
+++ b/tests/agent/test_loop_progress.py
@@ -133,6 +133,7 @@ class TestToolEventProgress:
"call_id": "call-write",
"tool": "write_file",
"path": "foo.txt",
+ "absolute_path": (tmp_path / "foo.txt").resolve().as_posix(),
"phase": "start",
"added": 2,
"deleted": 1,
@@ -309,6 +310,100 @@ class TestToolEventProgress:
await invoke_file_edit_progress(telegram_progress, edit_events)
assert bus.outbound_size == 0
+ @pytest.mark.asyncio
+ async def test_goal_turn_keeps_live_file_edit_progress_for_webui(self, tmp_path: Path) -> None:
+ """The /goal command rewrites the prompt but must not bypass WebUI file-edit progress."""
+ bus = MessageBus()
+ provider = MagicMock()
+ provider.supports_progress_deltas = True
+ provider.get_default_model.return_value = "test-model"
+ call_count = 0
+ target = tmp_path / "goal.txt"
+
+ async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs):
+ nonlocal call_count
+ call_count += 1
+ if call_count == 1:
+ assert on_tool_call_delta is not None
+ await on_tool_call_delta({
+ "index": 0,
+ "call_id": "call-goal-write",
+ "name": "write_file",
+ "arguments_delta": '{"path":"goal.txt","content":"',
+ })
+ await on_tool_call_delta({
+ "index": 0,
+ "arguments_delta": "one\\ntwo\\nthree\\n",
+ })
+ await on_tool_call_delta({"index": 0, "arguments_delta": '"}'})
+ return LLMResponse(
+ content=None,
+ tool_calls=[
+ ToolCallRequest(
+ id="call-goal-write",
+ name="write_file",
+ arguments={
+ "path": "goal.txt",
+ "content": "one\ntwo\nthree\n",
+ },
+ )
+ ],
+ usage={},
+ )
+ return LLMResponse(content="Done", tool_calls=[], usage={})
+
+ async def execute(name: str, params: dict) -> str:
+ assert name == "write_file"
+ target.write_text(params["content"], encoding="utf-8")
+ return "ok"
+
+ provider.chat_stream_with_retry = chat_stream_with_retry
+ provider.chat_with_retry = AsyncMock()
+ loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
+ loop.tools.get_definitions = MagicMock(return_value=[
+ {"type": "function", "function": {"name": "write_file"}},
+ ])
+ loop.tools.prepare_call = MagicMock(
+ return_value=(
+ None,
+ {"path": "goal.txt", "content": "one\ntwo\nthree\n"},
+ None,
+ ),
+ )
+ loop.tools.execute = AsyncMock(side_effect=execute)
+ loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
+
+ await loop._dispatch(InboundMessage(
+ channel="websocket",
+ sender_id="u1",
+ chat_id="chat1",
+ content="/goal create goal file",
+ metadata={"_wants_stream": True},
+ ))
+
+ outbound = []
+ while bus.outbound_size > 0:
+ outbound.append(await bus.consume_outbound())
+
+ edit_events = [
+ event
+ for msg in outbound
+ for event in msg.metadata.get("_file_edit_events", [])
+ ]
+ assert any(
+ event["status"] == "editing"
+ and event["approximate"]
+ and event["added"] == 3
+ for event in edit_events
+ )
+ assert any(
+ event["status"] == "done"
+ and not event["approximate"]
+ and event["added"] == 3
+ for event in edit_events
+ )
+ provider.chat_with_retry.assert_not_awaited()
+
@pytest.mark.asyncio
async def test_non_streaming_channel_does_not_publish_codex_progress_deltas(
self,
@@ -556,7 +651,7 @@ class TestToolEventProgress:
return False
monkeypatch.setattr(
- "nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn",
+ "nanobot.session.webui_turns.maybe_generate_webui_title_after_turn",
fake_title_after_turn,
)
scheduled_title: list[object] = []
@@ -603,7 +698,7 @@ class TestToolEventProgress:
raise AssertionError("command-only turns should not generate titles")
monkeypatch.setattr(
- "nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn",
+ "nanobot.session.webui_turns.maybe_generate_webui_title_after_turn",
fake_title_after_turn,
)
scheduled: list[object] = []
diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py
index 9814c386..06ed8a0c 100644
--- a/tests/agent/test_loop_save_turn.py
+++ b/tests/agent/test_loop_save_turn.py
@@ -11,7 +11,7 @@ from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
from nanobot.session.goal_state import GOAL_STATE_KEY
from nanobot.session.manager import Session, SessionManager
-from nanobot.utils.webui_turn_helpers import (
+from nanobot.session.webui_turns import (
TITLE_GENERATION_MAX_TOKENS,
TITLE_GENERATION_REASONING_EFFORT,
WEBUI_SESSION_METADATA_KEY,
@@ -143,7 +143,7 @@ def test_webui_title_update_uses_captured_llm_runtime(
return False
monkeypatch.setattr(
- "nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn",
+ "nanobot.session.webui_turns.maybe_generate_webui_title_after_turn",
fake_title_after_turn,
)
coordinator = WebuiTurnCoordinator(
diff --git a/tests/agent/test_runner_progress_deltas.py b/tests/agent/test_runner_progress_deltas.py
index 13d5ea79..27a85ab8 100644
--- a/tests/agent/test_runner_progress_deltas.py
+++ b/tests/agent/test_runner_progress_deltas.py
@@ -6,7 +6,7 @@ import pytest
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.config.schema import AgentDefaults
-from nanobot.providers.base import LLMResponse
+from nanobot.providers.base import LLMResponse, ToolCallRequest
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
@@ -77,3 +77,220 @@ async def test_runner_streams_provider_progress_deltas_by_default():
assert result.final_content == "hello"
assert [call.args[0] for call in progress_cb.await_args_list] == ["he", "llo"]
provider.chat_with_retry.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_runner_streams_live_write_file_activity_from_tool_argument_deltas(tmp_path):
+ provider = MagicMock()
+ provider.supports_progress_deltas = True
+ call_count = 0
+ progress_events: list[dict] = []
+
+ async def progress_cb(content, *, file_edit_events=None, **kwargs):
+ if file_edit_events:
+ progress_events.extend(file_edit_events)
+
+ class Tools:
+ def get_definitions(self):
+ return [{"type": "function", "function": {"name": "write_file"}}]
+
+ def get(self, name):
+ return None
+
+ async def execute(self, name, params):
+ assert name == "write_file"
+ assert any(event["approximate"] and event["added"] == 24 for event in progress_events)
+ target = tmp_path / params["path"]
+ target.write_text(params["content"], encoding="utf-8")
+ return "ok"
+
+ async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs):
+ nonlocal call_count
+ call_count += 1
+ if call_count == 1:
+ assert on_tool_call_delta is not None
+ await on_tool_call_delta({
+ "index": 0,
+ "call_id": "call-write",
+ "name": "write_file",
+ "arguments_delta": '{"path":"big.txt","content":"',
+ })
+ await on_tool_call_delta({"index": 0, "arguments_delta": "line\\n" * 24})
+ return LLMResponse(
+ content=None,
+ tool_calls=[
+ ToolCallRequest(
+ id="call-write",
+ name="write_file",
+ arguments={"path": "big.txt", "content": "line\n" * 24},
+ )
+ ],
+ usage={},
+ )
+ return LLMResponse(content="done", tool_calls=[], usage={})
+
+ provider.chat_stream_with_retry = chat_stream_with_retry
+ provider.chat_with_retry = AsyncMock()
+
+ runner = AgentRunner(provider)
+ result = await runner.run(AgentRunSpec(
+ initial_messages=[{"role": "user", "content": "write a large file"}],
+ tools=Tools(),
+ model="test-model",
+ max_iterations=2,
+ max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
+ progress_callback=progress_cb,
+ workspace=tmp_path,
+ ))
+
+ assert result.final_content == "done"
+ assert any(event["approximate"] and event["added"] == 24 for event in progress_events)
+ assert any(
+ not event["approximate"] and event["phase"] == "end" and event["added"] == 24
+ for event in progress_events
+ )
+ provider.chat_with_retry.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_runner_streams_live_edit_file_activity_from_tool_argument_deltas(tmp_path):
+ provider = MagicMock()
+ provider.supports_progress_deltas = True
+ call_count = 0
+ progress_events: list[dict] = []
+ target = tmp_path / "notes.txt"
+ target.write_text("old\nkeep\n", encoding="utf-8")
+
+ async def progress_cb(content, *, file_edit_events=None, **kwargs):
+ if file_edit_events:
+ progress_events.extend(file_edit_events)
+
+ class Tools:
+ def get_definitions(self):
+ return [{"type": "function", "function": {"name": "edit_file"}}]
+
+ def get(self, name):
+ return None
+
+ async def execute(self, name, params):
+ assert name == "edit_file"
+ assert any(
+ event["tool"] == "edit_file"
+ and event["approximate"]
+ and event["added"] == 3
+ and event["deleted"] == 2
+ for event in progress_events
+ )
+ target.write_text(params["new_text"], encoding="utf-8")
+ return "ok"
+
+ async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs):
+ nonlocal call_count
+ call_count += 1
+ if call_count == 1:
+ assert on_tool_call_delta is not None
+ await on_tool_call_delta({
+ "index": 0,
+ "call_id": "call-edit",
+ "name": "edit_file",
+ "arguments_delta": (
+ '{"path":"notes.txt","old_text":"old\\nkeep\\n","new_text":"'
+ ),
+ })
+ await on_tool_call_delta({
+ "index": 0,
+ "arguments_delta": "new\\nkeep\\nextra\\n",
+ })
+ await on_tool_call_delta({"index": 0, "arguments_delta": '"}'})
+ return LLMResponse(
+ content=None,
+ tool_calls=[
+ ToolCallRequest(
+ id="call-edit",
+ name="edit_file",
+ arguments={
+ "path": "notes.txt",
+ "old_text": "old\nkeep\n",
+ "new_text": "new\nkeep\nextra\n",
+ },
+ )
+ ],
+ usage={},
+ )
+ return LLMResponse(content="done", tool_calls=[], usage={})
+
+ provider.chat_stream_with_retry = chat_stream_with_retry
+ provider.chat_with_retry = AsyncMock()
+
+ runner = AgentRunner(provider)
+ result = await runner.run(AgentRunSpec(
+ initial_messages=[{"role": "user", "content": "edit a file"}],
+ tools=Tools(),
+ model="test-model",
+ max_iterations=2,
+ max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
+ progress_callback=progress_cb,
+ workspace=tmp_path,
+ ))
+
+ assert result.final_content == "done"
+ assert any(
+ event["tool"] == "edit_file"
+ and event["approximate"]
+ and event["added"] == 3
+ and event["deleted"] == 2
+ for event in progress_events
+ )
+ assert any(
+ event["tool"] == "edit_file"
+ and not event["approximate"]
+ and event["phase"] == "end"
+ and event["added"] == 2
+ and event["deleted"] == 1
+ for event in progress_events
+ )
+ provider.chat_with_retry.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_runner_marks_unfinished_live_write_file_activity_failed(tmp_path):
+ provider = MagicMock()
+ provider.supports_progress_deltas = True
+ progress_events: list[dict] = []
+
+ async def progress_cb(content, *, file_edit_events=None, **kwargs):
+ if file_edit_events:
+ progress_events.extend(file_edit_events)
+
+ async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs):
+ assert on_tool_call_delta is not None
+ await on_tool_call_delta({
+ "index": 0,
+ "call_id": "call-write",
+ "name": "write_file",
+ "arguments_delta": '{"path":"aborted.txt","content":"partial\\n',
+ })
+ return LLMResponse(content="stopped", tool_calls=[], finish_reason="stop", usage={})
+
+ provider.chat_stream_with_retry = chat_stream_with_retry
+ provider.chat_with_retry = AsyncMock()
+ tools = MagicMock()
+ tools.get_definitions.return_value = [{"type": "function", "function": {"name": "write_file"}}]
+ tools.get.return_value = None
+
+ runner = AgentRunner(provider)
+ result = await runner.run(AgentRunSpec(
+ initial_messages=[{"role": "user", "content": "write a large file"}],
+ tools=tools,
+ model="test-model",
+ max_iterations=1,
+ max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
+ progress_callback=progress_cb,
+ workspace=tmp_path,
+ ))
+
+ assert result.final_content == "stopped"
+ assert progress_events[-1]["path"] == "aborted.txt"
+ assert progress_events[-1]["phase"] == "error"
+ assert progress_events[-1]["status"] == "error"
+ provider.chat_with_retry.assert_not_awaited()
diff --git a/tests/agent/test_session_media_persist.py b/tests/agent/test_session_media_persist.py
deleted file mode 100644
index 98b77ffd..00000000
--- a/tests/agent/test_session_media_persist.py
+++ /dev/null
@@ -1,34 +0,0 @@
-"""Tests for staging attachment paths into the media bucket for session replay."""
-
-from pathlib import Path
-
-from nanobot.config.loader import set_config_path
-from nanobot.config.paths import get_media_dir
-from nanobot.utils.session_attachments import stage_media_paths_for_session_replay
-
-
-def test_persist_media_stages_workspace_file(tmp_path: Path) -> None:
- set_config_path(tmp_path / "config.json")
- outside = tmp_path / "workspace" / "report.md"
- outside.parent.mkdir(parents=True)
- outside.write_text("body", encoding="utf-8")
-
- out = stage_media_paths_for_session_replay([str(outside)])
-
- assert len(out) == 1
- staged = Path(out[0])
- assert staged.is_file()
- assert staged.read_text(encoding="utf-8") == "body"
- assert staged.resolve().is_relative_to(get_media_dir().resolve())
-
-
-def test_persist_media_keeps_files_already_under_media_root(tmp_path: Path) -> None:
- set_config_path(tmp_path / "config.json")
- media = get_media_dir("websocket")
- media.mkdir(parents=True, exist_ok=True)
- inside = media / "keep-me.txt"
- inside.write_text("x", encoding="utf-8")
-
- out = stage_media_paths_for_session_replay([str(inside.resolve())])
-
- assert out == [str(inside.resolve())]
diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py
index c6f9d66a..78953864 100644
--- a/tests/channels/test_websocket_channel.py
+++ b/tests/channels/test_websocket_channel.py
@@ -29,7 +29,8 @@ from nanobot.channels.websocket import (
publish_runtime_model_update,
)
from nanobot.config.loader import load_config, save_config
-from nanobot.config.schema import Config
+from nanobot.config.schema import Config, ModelPresetConfig
+from nanobot.webui.settings_api import settings_payload
# -- Shared helpers (aligned with test_websocket_integration.py) ---------------
@@ -756,7 +757,7 @@ async def test_maybe_push_turn_run_wall_clock_skips_when_no_active_turn() -> Non
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
- from nanobot.utils import webui_turn_helpers as wth
+ from nanobot.session import webui_turns as wth
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
await channel._maybe_push_turn_run_wall_clock("chat-1")
@@ -769,7 +770,7 @@ async def test_maybe_push_turn_run_wall_clock_replays_running() -> None:
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
- from nanobot.utils import webui_turn_helpers as wth
+ from nanobot.session import webui_turns as wth
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
try:
@@ -991,6 +992,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
config = Config()
config.agents.defaults.model = "openai/gpt-4o"
config.providers.openai.api_key = "secret-key"
+ config.model_presets["deep"] = ModelPresetConfig(
+ model="anthropic/claude-opus-4-5",
+ provider="anthropic",
+ reasoning_effort="high",
+ )
config.tools.web.search.provider = "brave"
config.tools.web.search.api_key = "brave-secret"
save_config(config, config_path)
@@ -1011,21 +1017,49 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
body = settings.json()
assert body["agent"]["model"] == "openai/gpt-4o"
assert body["agent"]["provider"] == "openai"
+ assert body["agent"]["model_preset"] == "default"
+ assert body["agent"]["max_tokens"] == 8192
+ assert body["agent"]["timezone"] == "UTC"
+ assert body["agent"]["tool_hint_max_length"] == 40
+ presets = {preset["name"]: preset for preset in body["model_presets"]}
+ assert presets["default"]["active"] is True
+ assert presets["deep"]["reasoning_effort"] == "high"
providers = {provider["name"]: provider for provider in body["providers"]}
assert providers["openai"]["configured"] is True
assert providers["openai"]["api_key_hint"] == "secr••••-key"
assert providers["azure_openai"]["api_key_required"] is True
assert providers["openrouter"]["configured"] is False
assert providers["openrouter"]["api_key_required"] is True
+ assert providers["ant_ling"]["label"] == "Ant Ling"
+ assert providers["ant_ling"]["default_api_base"] == "https://api.ant-ling.com/v1"
assert providers["atomic_chat"]["configured"] is False
assert providers["atomic_chat"]["api_key_required"] is False
assert providers["atomic_chat"]["default_api_base"] == "http://localhost:1337/v1"
assert body["agent"]["has_api_key"] is True
assert body["web_search"]["provider"] == "brave"
assert body["web_search"]["api_key_hint"] == "brav••••cret"
+ assert body["web_search"]["max_results"] == 5
+ assert body["web"]["fetch"]["use_jina_reader"] is True
search_providers = {provider["name"]: provider for provider in body["web_search"]["providers"]}
assert search_providers["duckduckgo"]["credential"] == "none"
assert search_providers["searxng"]["credential"] == "base_url"
+ assert body["image_generation"]["enabled"] is False
+ assert body["image_generation"]["provider"] == "openrouter"
+ assert body["image_generation"]["provider_configured"] is False
+ assert body["image_generation"]["default_aspect_ratio"] == "1:1"
+ image_providers = {
+ provider["name"]: provider
+ for provider in body["image_generation"]["providers"]
+ }
+ assert image_providers["openrouter"]["label"] == "OpenRouter"
+ assert image_providers["openrouter"]["configured"] is False
+ assert image_providers["gemini"]["label"] == "Gemini"
+ assert body["runtime"]["config_path"] == str(config_path)
+ assert body["runtime"]["workspace_path"].endswith(".nanobot/workspace")
+ assert body["runtime"]["gateway_port"] == 18790
+ assert body["advanced"]["exec_enabled"] is True
+ assert body["advanced"]["mcp_server_count"] == 0
+ assert body["restart_required_sections"] == []
assert "secret-key" not in settings.text
assert "brave-secret" not in settings.text
@@ -1040,6 +1074,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert provider_body["requires_restart"] is False
provider_rows = {provider["name"]: provider for provider in provider_body["providers"]}
assert provider_rows["openrouter"]["configured"] is True
+ assert provider_body["image_generation"]["provider_configured"] is True
assert "sk-or-test" not in provider_updated.text
local_provider_updated = await _http_get(
@@ -1059,34 +1094,117 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/update?model=atomic_chat/test"
- "&provider=atomic_chat",
+ "&provider=atomic_chat&timezone=Asia%2FShanghai"
+ "&bot_name=Nano&bot_icon=N&tool_hint_max_length=120",
headers={"Authorization": "Bearer tok"},
)
assert updated.status_code == 200
- assert updated.json()["requires_restart"] is False
+ updated_body = updated.json()
+ assert updated_body["requires_restart"] is True
+ assert updated_body["restart_required_sections"] == ["runtime"]
+
+ preset_updated = await _http_get(
+ "http://127.0.0.1:"
+ f"{port}/api/settings/update?model_preset=deep",
+ headers={"Authorization": "Bearer tok"},
+ )
+ assert preset_updated.status_code == 200
+ assert preset_updated.json()["agent"]["model"] == "anthropic/claude-opus-4-5"
+
+ bad_preset = await _http_get(
+ "http://127.0.0.1:"
+ f"{port}/api/settings/update?model_preset=missing",
+ headers={"Authorization": "Bearer tok"},
+ )
+ assert bad_preset.status_code == 400
search_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/web-search/update?provider=searxng"
- "&base_url=https%3A%2F%2Fsearch.example.com",
+ "&base_url=https%3A%2F%2Fsearch.example.com"
+ "&max_results=8&timeout=45&use_jina_reader=false",
headers={"Authorization": "Bearer tok"},
)
assert search_updated.status_code == 200
search_body = search_updated.json()
- assert search_body["requires_restart"] is False
+ assert search_body["requires_restart"] is True
+ assert search_body["restart_required_sections"] == ["runtime", "web"]
assert search_body["web_search"]["provider"] == "searxng"
assert search_body["web_search"]["api_key_hint"] is None
assert search_body["web_search"]["base_url"] == "https://search.example.com"
+ assert search_body["web_search"]["max_results"] == 8
+ assert search_body["web"]["fetch"]["use_jina_reader"] is False
+
+ image_updated = await _http_get(
+ "http://127.0.0.1:"
+ f"{port}/api/settings/image-generation/update?enabled=true"
+ "&provider=openrouter&model=openai%2Fgpt-image-1"
+ "&default_aspect_ratio=16%3A9&default_image_size=2K"
+ "&max_images_per_turn=3",
+ headers={"Authorization": "Bearer tok"},
+ )
+ assert image_updated.status_code == 200
+ image_body = image_updated.json()
+ assert image_body["requires_restart"] is True
+ assert image_body["restart_required_sections"] == ["image", "runtime", "web"]
+ assert image_body["image_generation"]["enabled"] is True
+ assert image_body["image_generation"]["model"] == "openai/gpt-image-1"
+ assert image_body["image_generation"]["default_aspect_ratio"] == "16:9"
+ assert image_body["image_generation"]["default_image_size"] == "2K"
+ assert image_body["image_generation"]["max_images_per_turn"] == 3
+
+ image_provider_updated = await _http_get(
+ "http://127.0.0.1:"
+ f"{port}/api/settings/provider/update?provider=openrouter"
+ "&api_key=sk-or-next&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
+ headers={"Authorization": "Bearer tok"},
+ )
+ assert image_provider_updated.status_code == 200
+ assert image_provider_updated.json()["requires_restart"] is True
+ assert image_provider_updated.json()["restart_required_sections"] == [
+ "image",
+ "runtime",
+ "web",
+ ]
+ assert "sk-or-next" not in image_provider_updated.text
+
+ bad_web = await _http_get(
+ "http://127.0.0.1:"
+ f"{port}/api/settings/web-search/update?provider=duckduckgo&max_results=99",
+ headers={"Authorization": "Bearer tok"},
+ )
+ assert bad_web.status_code == 400
+
+ bad_image = await _http_get(
+ "http://127.0.0.1:"
+ f"{port}/api/settings/image-generation/update?provider=missing",
+ headers={"Authorization": "Bearer tok"},
+ )
+ assert bad_image.status_code == 400
saved = load_config(config_path)
assert saved.agents.defaults.model == "atomic_chat/test"
assert saved.agents.defaults.provider == "atomic_chat"
- assert saved.providers.openrouter.api_key == "sk-or-test"
+ assert saved.agents.defaults.model_preset == "deep"
+ assert saved.agents.defaults.timezone == "Asia/Shanghai"
+ assert saved.agents.defaults.bot_name == "Nano"
+ assert saved.agents.defaults.bot_icon == "N"
+ assert saved.agents.defaults.tool_hint_max_length == 120
+ assert saved.providers.openrouter.api_key == "sk-or-next"
assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1"
assert saved.providers.atomic_chat.api_base == "http://localhost:1337/v1"
assert saved.tools.web.search.provider == "searxng"
assert saved.tools.web.search.api_key == ""
assert saved.tools.web.search.base_url == "https://search.example.com"
+ assert saved.tools.web.search.max_results == 8
+ assert saved.tools.web.search.timeout == 45
+ assert saved.tools.web.fetch.use_jina_reader is False
+ assert saved.tools.image_generation.enabled is True
+ assert saved.tools.image_generation.provider == "openrouter"
+ assert saved.tools.image_generation.model == "openai/gpt-image-1"
+ assert saved.tools.image_generation.default_aspect_ratio == "16:9"
+ assert saved.tools.image_generation.default_image_size == "2K"
+ assert saved.tools.image_generation.max_images_per_turn == 3
finally:
await channel.stop()
await server_task
@@ -1131,7 +1249,7 @@ def test_settings_payload_normalizes_camel_case_provider(
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
- body = _ch(bus)._settings_payload()
+ body = settings_payload()
assert body["agent"]["provider"] == "minimax_anthropic"
@@ -1548,6 +1666,54 @@ def test_parse_envelope_rejects_legacy_and_garbage() -> None:
assert _parse_envelope('{"type":123}') is None
+def test_sessions_list_includes_active_run_started_at() -> None:
+ from websockets.datastructures import Headers
+ from websockets.http11 import Request
+
+ from nanobot.session import webui_turns as wth
+
+ bus = MagicMock()
+ channel = _ch(bus)
+ channel._api_tokens["tok"] = time.monotonic() + 300.0
+ channel._session_manager = MagicMock()
+ channel._session_manager.list_sessions.return_value = [
+ {
+ "key": "websocket:chat-1",
+ "created_at": "2026-05-19T10:00:00Z",
+ "updated_at": "2026-05-19T10:01:00Z",
+ "title": "Running",
+ "preview": "work",
+ "path": "/private/path",
+ },
+ {
+ "key": "cli:chat-2",
+ "created_at": "2026-05-19T10:00:00Z",
+ "updated_at": "2026-05-19T10:01:00Z",
+ },
+ ]
+
+ wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
+ try:
+ wth._WEBSOCKET_TURN_WALL_STARTED_AT["chat-1"] = 1_700_000_000.0
+ req = Request("/api/sessions", Headers([("Authorization", "Bearer tok")]))
+ resp = channel._handle_sessions_list(req)
+ finally:
+ wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
+
+ assert resp.status_code == 200
+ body = json.loads(resp.body.decode())
+ assert body["sessions"] == [
+ {
+ "key": "websocket:chat-1",
+ "created_at": "2026-05-19T10:00:00Z",
+ "updated_at": "2026-05-19T10:01:00Z",
+ "title": "Running",
+ "preview": "work",
+ "run_started_at": 1_700_000_000.0,
+ }
+ ]
+
+
@pytest.mark.parametrize(
("value", "expected"),
[
@@ -1574,7 +1740,7 @@ def test_handle_webui_thread_get_returns_json(tmp_path, monkeypatch) -> None:
from websockets.datastructures import Headers
from websockets.http11 import Request
- from nanobot.utils.webui_transcript import append_transcript_object
+ from nanobot.webui.transcript import append_transcript_object
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:c1"
diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py
index 9286670d..ddb45dfb 100644
--- a/tests/channels/test_websocket_http_routes.py
+++ b/tests/channels/test_websocket_http_routes.py
@@ -6,6 +6,7 @@ import json
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock
+from urllib.parse import urlencode
import httpx
import pytest
@@ -176,13 +177,62 @@ async def test_sessions_list_only_returns_websocket_sessions_by_default(
await server_task
+@pytest.mark.asyncio
+async def test_webui_sidebar_state_routes_are_config_dir_scoped(
+ bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
+ sm = _seed_session(tmp_path, key="websocket:sidebar")
+ channel = _ch(bus, session_manager=sm, port=29911)
+ server_task = asyncio.create_task(channel.start())
+ await asyncio.sleep(0.3)
+ try:
+ boot = await _http_get("http://127.0.0.1:29911/webui/bootstrap")
+ token = boot.json()["token"]
+ auth = {"Authorization": f"Bearer {token}"}
+
+ initial = await _http_get(
+ "http://127.0.0.1:29911/api/webui/sidebar-state",
+ headers=auth,
+ )
+ assert initial.status_code == 200
+ assert initial.json()["schema_version"] == 1
+ assert initial.json()["pinned_keys"] == []
+
+ payload = {
+ "pinned_keys": ["websocket:sidebar"],
+ "archived_keys": ["websocket:old"],
+ "title_overrides": {"websocket:sidebar": "Pinned work"},
+ "view": {"density": "compact", "show_archived": True},
+ }
+ query = urlencode({"state": json.dumps(payload)})
+ updated = await _http_get(
+ f"http://127.0.0.1:29911/api/webui/sidebar-state/update?{query}",
+ headers=auth,
+ )
+ assert updated.status_code == 200
+ body = updated.json()
+ assert body["pinned_keys"] == ["websocket:sidebar"]
+ assert body["title_overrides"] == {"websocket:sidebar": "Pinned work"}
+ assert body["view"]["density"] == "compact"
+
+ state_path = tmp_path / "webui" / "sidebar-state.json"
+ assert state_path.is_file()
+ assert json.loads(state_path.read_text(encoding="utf-8"))["pinned_keys"] == [
+ "websocket:sidebar"
+ ]
+ finally:
+ await channel.stop()
+ await server_task
+
+
@pytest.mark.asyncio
async def test_session_delete_removes_file(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sm = _seed_session(tmp_path, key="websocket:doomed")
- from nanobot.utils.webui_transcript import append_transcript_object
+ from nanobot.webui.transcript import append_transcript_object
append_transcript_object("websocket:doomed", {"event": "user", "chat_id": "doomed", "text": "x"})
channel = _ch(bus, session_manager=sm, port=29903)
diff --git a/tests/providers/test_ant_ling_provider.py b/tests/providers/test_ant_ling_provider.py
new file mode 100644
index 00000000..64f93cca
--- /dev/null
+++ b/tests/providers/test_ant_ling_provider.py
@@ -0,0 +1,73 @@
+"""Tests for the Ant Ling provider registration."""
+
+from unittest.mock import patch
+
+from nanobot.config.schema import Config, ProvidersConfig
+from nanobot.providers.openai_compat_provider import OpenAICompatProvider
+from nanobot.providers.registry import PROVIDERS, find_by_name
+
+
+def test_ant_ling_config_field_exists() -> None:
+ config = ProvidersConfig()
+
+ assert hasattr(config, "ant_ling")
+
+
+def test_ant_ling_provider_in_registry() -> None:
+ specs = {spec.name: spec for spec in PROVIDERS}
+
+ assert "ant_ling" in specs
+ ant_ling = specs["ant_ling"]
+ assert ant_ling.backend == "openai_compat"
+ assert ant_ling.env_key == "ANT_LING_API_KEY"
+ assert ant_ling.display_name == "Ant Ling"
+ assert ant_ling.default_api_base == "https://api.ant-ling.com/v1"
+
+
+def test_find_by_name_accepts_ant_ling_spellings() -> None:
+ spec = find_by_name("ant_ling")
+
+ assert spec is not None
+ assert find_by_name("ant-ling") is spec
+ assert find_by_name("antLing") is spec
+
+
+def test_ant_ling_model_auto_matches_with_default_api_base() -> None:
+ config = Config.model_validate({
+ "providers": {
+ "antLing": {
+ "apiKey": "ling-key",
+ },
+ },
+ "agents": {
+ "defaults": {
+ "model": "Ling-2.6-flash",
+ },
+ },
+ })
+
+ assert config.get_provider_name("Ling-2.6-flash") == "ant_ling"
+ assert config.get_api_key("Ling-2.6-flash") == "ling-key"
+ assert config.get_api_base("Ling-2.6-flash") == "https://api.ant-ling.com/v1"
+
+
+def test_ant_ling_preserves_official_model_name() -> None:
+ spec = find_by_name("ant_ling")
+ with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
+ provider = OpenAICompatProvider(
+ api_key="ling-key",
+ default_model="Ling-2.6-flash",
+ spec=spec,
+ )
+
+ kwargs = provider._build_kwargs(
+ messages=[{"role": "user", "content": "hi"}],
+ tools=None,
+ model="Ling-2.6-flash",
+ max_tokens=1024,
+ temperature=0.7,
+ reasoning_effort=None,
+ tool_choice=None,
+ )
+
+ assert kwargs["model"] == "Ling-2.6-flash"
diff --git a/tests/providers/test_anthropic_stream_idle.py b/tests/providers/test_anthropic_stream_idle.py
index da4939bf..d46f291f 100644
--- a/tests/providers/test_anthropic_stream_idle.py
+++ b/tests/providers/test_anthropic_stream_idle.py
@@ -129,6 +129,74 @@ async def test_chat_stream_invokes_on_thinking_delta_for_thinking_delta() -> Non
assert text_parts == ["X"]
+@pytest.mark.asyncio
+async def test_chat_stream_invokes_tool_call_delta_for_input_json_delta() -> None:
+ provider = AnthropicProvider(api_key="sk-test")
+ provider._client = MagicMock()
+
+ chunks = [
+ SimpleNamespace(
+ type="content_block_start",
+ index=1,
+ content_block=SimpleNamespace(
+ type="tool_use",
+ id="toolu_1",
+ name="write_file",
+ ),
+ ),
+ SimpleNamespace(
+ type="content_block_delta",
+ index=1,
+ delta=SimpleNamespace(
+ type="input_json_delta",
+ partial_json='{"path":"notes.md","content":"',
+ ),
+ ),
+ SimpleNamespace(
+ type="content_block_delta",
+ index=1,
+ delta=SimpleNamespace(type="input_json_delta", partial_json="line\\n"),
+ ),
+ ]
+ fake = _FakeAsyncStream(chunks)
+ stream_cm = MagicMock()
+ stream_cm.__aenter__ = AsyncMock(return_value=fake)
+ stream_cm.__aexit__ = AsyncMock(return_value=None)
+ provider._client.messages.stream = MagicMock(return_value=stream_cm)
+
+ deltas: list[dict] = []
+
+ async def on_tool_delta(delta: dict) -> None:
+ deltas.append(delta)
+
+ await provider.chat_stream(
+ messages=[{"role": "user", "content": "write"}],
+ on_tool_call_delta=on_tool_delta,
+ )
+
+ assert deltas == [
+ {
+ "index": 1,
+ "call_id": "toolu_1",
+ "name": "write_file",
+ "arguments_delta": "",
+ },
+ {
+ "index": 1,
+ "call_id": "toolu_1",
+ "name": "write_file",
+ "arguments_delta": '{"path":"notes.md","content":"',
+ },
+ {
+ "index": 1,
+ "call_id": "toolu_1",
+ "name": "write_file",
+ "arguments_delta": "line\\n",
+ },
+ ]
+ fake.get_final_message.assert_awaited_once()
+
+
@pytest.mark.asyncio
async def test_chat_stream_without_callback_still_finalizes() -> None:
provider = AnthropicProvider(api_key="sk-test")
diff --git a/tests/providers/test_image_generation.py b/tests/providers/test_image_generation.py
index 8f2801d6..c38f9488 100644
--- a/tests/providers/test_image_generation.py
+++ b/tests/providers/test_image_generation.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import base64
from pathlib import Path
from typing import Any
@@ -8,8 +9,10 @@ import pytest
from nanobot.providers.image_generation import (
AIHubMixImageGenerationClient,
+ GeminiImageGenerationClient,
GeneratedImageResponse,
ImageGenerationError,
+ MiniMaxImageGenerationClient,
OpenRouterImageGenerationClient,
)
@@ -23,6 +26,7 @@ PNG_DATA_URL = (
"data:image/png;base64,"
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="
)
+JPEG_BYTES = b"\xff\xd8\xff\xe0" + b"0" * 12
class FakeResponse:
@@ -202,3 +206,184 @@ async def test_aihubmix_image_generation_downloads_url_response() -> None:
assert response.images[0].startswith("data:image/png;base64,")
assert fake.get_calls[0]["url"] == "https://cdn.example/image.png"
+
+
+@pytest.mark.asyncio
+async def test_aihubmix_base64_response_uses_detected_mime() -> None:
+ raw_b64 = base64.b64encode(JPEG_BYTES).decode("ascii")
+ fake = FakeClient(FakeResponse({"output": {"b64_json": raw_b64}}))
+ client = AIHubMixImageGenerationClient(
+ api_key="sk-ahm-test",
+ client=fake, # type: ignore[arg-type]
+ )
+
+ response = await client.generate(prompt="draw", model="gpt-image-2-free")
+
+ assert response.images == [f"data:image/jpeg;base64,{raw_b64}"]
+
+
+RAW_B64 = PNG_DATA_URL.removeprefix("data:image/png;base64,")
+
+
+@pytest.mark.asyncio
+async def test_gemini_imagen_payload_and_response() -> None:
+ fake = FakeClient(
+ FakeResponse({"predictions": [{"bytesBase64Encoded": RAW_B64, "mimeType": "image/png"}]})
+ )
+ client = GeminiImageGenerationClient(
+ api_key="AIza-test",
+ api_base="https://generativelanguage.googleapis.com/v1beta",
+ client=fake, # type: ignore[arg-type]
+ )
+
+ response = await client.generate(
+ prompt="a sunset",
+ model="imagen-4.0-generate-001",
+ aspect_ratio="16:9",
+ )
+
+ assert response.images == [PNG_DATA_URL]
+ assert response.content == ""
+ call = fake.calls[0]
+ assert call["url"].endswith(":predict")
+ assert call["headers"]["x-goog-api-key"] == "AIza-test"
+ assert "params" not in call
+ body = call["json"]
+ assert body["instances"] == [{"prompt": "a sunset"}]
+ assert body["parameters"]["sampleCount"] == 1
+ assert body["parameters"]["aspectRatio"] == "16:9"
+
+
+@pytest.mark.asyncio
+async def test_gemini_imagen_ignores_unsupported_aspect_ratio() -> None:
+ fake = FakeClient(
+ FakeResponse({"predictions": [{"bytesBase64Encoded": RAW_B64, "mimeType": "image/png"}]})
+ )
+ client = GeminiImageGenerationClient(api_key="AIza-test", client=fake) # type: ignore[arg-type]
+
+ await client.generate(prompt="a sunset", model="imagen-4.0-generate-001", aspect_ratio="2:3")
+
+ body = fake.calls[0]["json"]
+ assert "aspectRatio" not in body["parameters"]
+
+
+@pytest.mark.asyncio
+async def test_gemini_flash_payload_and_response() -> None:
+ fake = FakeClient(
+ FakeResponse(
+ {
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ {"text": "here is your image"},
+ {"inlineData": {"mimeType": "image/png", "data": RAW_B64}},
+ ]
+ }
+ }
+ ]
+ }
+ )
+ )
+ client = GeminiImageGenerationClient(
+ api_key="AIza-test",
+ api_base="https://generativelanguage.googleapis.com/v1beta",
+ client=fake, # type: ignore[arg-type]
+ )
+
+ response = await client.generate(
+ prompt="draw a cat",
+ model="gemini-2.0-flash-preview-image-generation",
+ )
+
+ assert response.images == [PNG_DATA_URL]
+ assert response.content == "here is your image"
+ call = fake.calls[0]
+ assert call["url"].endswith(":generateContent")
+ assert call["headers"]["x-goog-api-key"] == "AIza-test"
+ assert "params" not in call
+ body = call["json"]
+ assert body["generationConfig"]["responseModalities"] == ["TEXT", "IMAGE"]
+ assert body["contents"][0]["parts"][-1] == {"text": "draw a cat"}
+
+
+@pytest.mark.asyncio
+async def test_gemini_flash_reference_images(tmp_path: Path) -> None:
+ ref = tmp_path / "ref.png"
+ ref.write_bytes(PNG_BYTES)
+ fake = FakeClient(
+ FakeResponse(
+ {
+ "candidates": [
+ {
+ "content": {
+ "parts": [{"inlineData": {"mimeType": "image/png", "data": RAW_B64}}]
+ }
+ }
+ ]
+ }
+ )
+ )
+ client = GeminiImageGenerationClient(api_key="AIza-test", client=fake) # type: ignore[arg-type]
+
+ response = await client.generate(
+ prompt="edit this",
+ model="gemini-2.0-flash-preview-image-generation",
+ reference_images=[str(ref)],
+ )
+
+ assert response.images == [PNG_DATA_URL]
+ parts = fake.calls[0]["json"]["contents"][0]["parts"]
+ assert parts[0]["inlineData"]["mimeType"] == "image/png"
+ assert parts[0]["inlineData"]["data"].startswith("iVBOR")
+ assert parts[1] == {"text": "edit this"}
+
+
+@pytest.mark.asyncio
+async def test_gemini_requires_api_key() -> None:
+ client = GeminiImageGenerationClient(api_key=None)
+
+ with pytest.raises(ImageGenerationError, match="API key"):
+ await client.generate(prompt="draw", model="imagen-4.0-generate-001")
+
+
+@pytest.mark.asyncio
+async def test_gemini_no_images_raises() -> None:
+ fake = FakeClient(FakeResponse({"candidates": [{"content": {"parts": [{"text": "sorry"}]}}]}))
+ client = GeminiImageGenerationClient(api_key="AIza-test", client=fake) # type: ignore[arg-type]
+
+ with pytest.raises(ImageGenerationError, match="returned no images"):
+ await client.generate(prompt="draw", model="gemini-2.0-flash-preview-image-generation")
+
+
+@pytest.mark.asyncio
+async def test_minimax_payload_and_response_with_reference_image(tmp_path: Path) -> None:
+ ref = tmp_path / "ref.png"
+ ref.write_bytes(PNG_BYTES)
+ fake = FakeClient(FakeResponse({"data": {"image_base64": [RAW_B64]}}))
+ client = MiniMaxImageGenerationClient(
+ api_key="sk-mm-test",
+ api_base="https://api.minimaxi.com/v1/",
+ extra_headers={"X-Test": "1"},
+ client=fake, # type: ignore[arg-type]
+ )
+
+ response = await client.generate(
+ prompt="draw a character",
+ model="image-01",
+ reference_images=[str(ref)],
+ aspect_ratio="21:9",
+ )
+
+ assert response.images == [PNG_DATA_URL]
+ call = fake.calls[0]
+ assert call["url"] == "https://api.minimaxi.com/v1/image_generation"
+ assert call["headers"]["Authorization"] == "Bearer sk-mm-test"
+ assert call["headers"]["X-Test"] == "1"
+ body = call["json"]
+ assert body["model"] == "image-01"
+ assert body["prompt"] == "draw a character"
+ assert body["response_format"] == "base64"
+ assert body["aspect_ratio"] == "21:9"
+ assert body["subject_reference"][0]["type"] == "character"
+ assert body["subject_reference"][0]["image_file"].startswith("data:image/png;base64,")
diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py
index 7ae97159..3acb2e76 100644
--- a/tests/providers/test_litellm_kwargs.py
+++ b/tests/providers/test_litellm_kwargs.py
@@ -164,6 +164,130 @@ def _fake_chat_stream_reasoning_chunks():
return _stream()
+def _fake_chat_stream_tool_call_chunks():
+ """Mimic OpenAI-compatible streaming tool-call argument deltas."""
+
+ async def _stream():
+ yield SimpleNamespace(
+ choices=[
+ SimpleNamespace(
+ finish_reason=None,
+ delta=SimpleNamespace(
+ content=None,
+ reasoning_content=None,
+ reasoning=None,
+ tool_calls=[
+ SimpleNamespace(
+ index=0,
+ id="call_write",
+ function=SimpleNamespace(
+ name="write_file",
+ arguments='{"path":"notes.md","content":"',
+ ),
+ )
+ ],
+ ),
+ ),
+ ],
+ usage=None,
+ )
+ yield SimpleNamespace(
+ choices=[
+ SimpleNamespace(
+ finish_reason=None,
+ delta=SimpleNamespace(
+ content=None,
+ reasoning_content=None,
+ reasoning=None,
+ tool_calls=[
+ SimpleNamespace(
+ index=0,
+ id=None,
+ function=SimpleNamespace(name=None, arguments='line\\n"}'),
+ )
+ ],
+ ),
+ ),
+ ],
+ usage=None,
+ )
+ yield SimpleNamespace(
+ choices=[
+ SimpleNamespace(
+ finish_reason="tool_calls",
+ delta=SimpleNamespace(
+ content=None,
+ reasoning_content=None,
+ reasoning=None,
+ tool_calls=None,
+ ),
+ ),
+ ],
+ usage=SimpleNamespace(prompt_tokens=10, completion_tokens=5, total_tokens=15),
+ )
+
+ return _stream()
+
+
+def _fake_chat_stream_legacy_function_call_chunks():
+ """Mimic older OpenAI-compatible ``delta.function_call`` chunks."""
+
+ async def _stream():
+ yield SimpleNamespace(
+ choices=[
+ SimpleNamespace(
+ finish_reason=None,
+ delta=SimpleNamespace(
+ content=None,
+ reasoning_content=None,
+ reasoning=None,
+ tool_calls=None,
+ function_call=SimpleNamespace(
+ name="write_file",
+ arguments='{"path":"notes.md","content":"',
+ ),
+ ),
+ ),
+ ],
+ usage=None,
+ )
+ yield SimpleNamespace(
+ choices=[
+ SimpleNamespace(
+ finish_reason=None,
+ delta=SimpleNamespace(
+ content=None,
+ reasoning_content=None,
+ reasoning=None,
+ tool_calls=None,
+ function_call=SimpleNamespace(
+ name=None,
+ arguments='line\\n"}',
+ ),
+ ),
+ ),
+ ],
+ usage=None,
+ )
+ yield SimpleNamespace(
+ choices=[
+ SimpleNamespace(
+ finish_reason="function_call",
+ delta=SimpleNamespace(
+ content=None,
+ reasoning_content=None,
+ reasoning=None,
+ tool_calls=None,
+ function_call=None,
+ ),
+ ),
+ ],
+ usage=SimpleNamespace(prompt_tokens=10, completion_tokens=5, total_tokens=15),
+ )
+
+ return _stream()
+
+
@pytest.mark.asyncio
async def test_openai_compat_stream_forwards_reasoning_deltas_deepseek_style() -> None:
"""Regression: DeepSeek-V4 / reasoner expose ``delta.reasoning_content`` during streaming."""
@@ -202,6 +326,98 @@ async def test_openai_compat_stream_forwards_reasoning_deltas_deepseek_style() -
mock_chat.assert_awaited_once()
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("provider_name", "model"),
+ [
+ ("openai", "gpt-4o"),
+ ("deepseek", "deepseek-chat"),
+ ("minimax", "MiniMax-M2.7"),
+ ("zhipu", "glm-4.6"),
+ ],
+)
+async def test_openai_compat_stream_forwards_tool_call_argument_deltas(
+ provider_name: str,
+ model: str,
+) -> None:
+ mock_chat = AsyncMock(return_value=_fake_chat_stream_tool_call_chunks())
+ spec = find_by_name(provider_name)
+ deltas: list[dict] = []
+
+ async def on_tool_delta(delta: dict) -> None:
+ deltas.append(delta)
+
+ with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_openai:
+ client_instance = mock_openai.return_value
+ client_instance.chat.completions.create = mock_chat
+
+ provider = OpenAICompatProvider(
+ api_key="sk-test",
+ default_model=model,
+ spec=spec,
+ )
+ result = await provider.chat_stream(
+ messages=[{"role": "user", "content": "write"}],
+ tools=[{"type": "function", "function": {"name": "write_file"}}],
+ model=model,
+ on_tool_call_delta=on_tool_delta,
+ )
+
+ assert deltas == [
+ {
+ "index": 0,
+ "call_id": "call_write",
+ "name": "write_file",
+ "arguments_delta": '{"path":"notes.md","content":"',
+ },
+ {"index": 0, "call_id": "", "name": "", "arguments_delta": 'line\\n"}'},
+ ]
+ assert result.tool_calls[0].name == "write_file"
+ assert result.tool_calls[0].arguments == {"path": "notes.md", "content": "line\n"}
+ kwargs = mock_chat.await_args.kwargs
+ if provider_name == "zhipu":
+ assert kwargs["extra_body"]["tool_stream"] is True
+ else:
+ assert kwargs.get("extra_body", {}).get("tool_stream") is None
+
+
+@pytest.mark.asyncio
+async def test_openai_compat_stream_forwards_legacy_function_call_argument_deltas() -> None:
+ mock_chat = AsyncMock(return_value=_fake_chat_stream_legacy_function_call_chunks())
+ deltas: list[dict] = []
+
+ async def on_tool_delta(delta: dict) -> None:
+ deltas.append(delta)
+
+ with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_openai:
+ client_instance = mock_openai.return_value
+ client_instance.chat.completions.create = mock_chat
+
+ provider = OpenAICompatProvider(
+ api_key="sk-test",
+ default_model="deepseek-chat",
+ spec=find_by_name("deepseek"),
+ )
+ result = await provider.chat_stream(
+ messages=[{"role": "user", "content": "write"}],
+ tools=[{"type": "function", "function": {"name": "write_file"}}],
+ model="deepseek-chat",
+ on_tool_call_delta=on_tool_delta,
+ )
+
+ assert deltas == [
+ {
+ "index": 0,
+ "call_id": "",
+ "name": "write_file",
+ "arguments_delta": '{"path":"notes.md","content":"',
+ },
+ {"index": 0, "call_id": "", "name": "", "arguments_delta": 'line\\n"}'},
+ ]
+ assert result.tool_calls[0].name == "write_file"
+ assert result.tool_calls[0].arguments == {"path": "notes.md", "content": "line\n"}
+
+
class _FakeResponsesError(Exception):
def __init__(self, status_code: int, text: str):
super().__init__(text)
diff --git a/tests/providers/test_llm_response.py b/tests/providers/test_llm_response.py
index ca9644dc..fff0ccaa 100644
--- a/tests/providers/test_llm_response.py
+++ b/tests/providers/test_llm_response.py
@@ -44,9 +44,15 @@ class TestShouldExecuteTools:
resp = _response("stop")
assert resp.should_execute_tools is True
+ def test_legacy_function_call_reason_executes(self) -> None:
+ # Older OpenAI-compatible streaming APIs can still use the singular
+ # function_call finish reason while carrying a tool-call-shaped payload.
+ resp = _response("function_call")
+ assert resp.should_execute_tools is True
+
@pytest.mark.parametrize(
"anomalous_reason",
- ["refusal", "content_filter", "error", "length", "function_call", ""],
+ ["refusal", "content_filter", "error", "length", ""],
)
def test_tool_calls_under_anomalous_reason_blocked(self, anomalous_reason: str) -> None:
# This is the #3220 bug: gateways injecting tool_calls under any of these
diff --git a/tests/providers/test_openai_codex_provider.py b/tests/providers/test_openai_codex_provider.py
index ef9a91a7..e31b8547 100644
--- a/tests/providers/test_openai_codex_provider.py
+++ b/tests/providers/test_openai_codex_provider.py
@@ -16,7 +16,15 @@ async def test_codex_prompt_cache_key_uses_stable_conversation_prefix(monkeypatc
lambda: SimpleNamespace(account_id="acct", access="token"),
)
- async def fake_request(url, headers, body, verify, on_content_delta=None):
+ async def fake_request(
+ url,
+ headers,
+ body,
+ verify,
+ on_content_delta=None,
+ on_tool_call_delta=None,
+ ):
+ _ = on_tool_call_delta
bodies.append(body)
return "ok", [], "stop"
diff --git a/tests/providers/test_openai_responses.py b/tests/providers/test_openai_responses.py
index ce422065..74a934f8 100644
--- a/tests/providers/test_openai_responses.py
+++ b/tests/providers/test_openai_responses.py
@@ -453,6 +453,56 @@ class TestConsumeSdkStream:
assert tool_calls[0].name == "get_weather"
assert tool_calls[0].arguments == {"city": "SF"}
+ @pytest.mark.asyncio
+ async def test_tool_call_argument_delta_callback(self):
+ item_added = MagicMock(type="function_call", call_id="c1", id="fc1", arguments="")
+ item_added.name = "write_file"
+ ev1 = MagicMock(type="response.output_item.added", item=item_added)
+ ev2 = MagicMock(
+ type="response.function_call_arguments.delta",
+ call_id="c1",
+ delta='{"path":"a.txt","content":"',
+ )
+ ev3 = MagicMock(
+ type="response.function_call_arguments.delta",
+ call_id="c1",
+ delta='hello\\n',
+ )
+ ev4 = MagicMock(
+ type="response.function_call_arguments.done",
+ call_id="c1",
+ arguments='{"path":"a.txt","content":"hello\\n"}',
+ )
+ item_done = MagicMock(
+ type="function_call",
+ call_id="c1",
+ id="fc1",
+ arguments='{"path":"a.txt","content":"hello\\n"}',
+ )
+ item_done.name = "write_file"
+ ev5 = MagicMock(type="response.output_item.done", item=item_done)
+ resp_obj = MagicMock(status="completed", usage=None, output=[])
+ ev6 = MagicMock(type="response.completed", response=resp_obj)
+ deltas: list[dict] = []
+
+ async def cb(delta: dict) -> None:
+ deltas.append(delta)
+
+ async def stream():
+ for e in [ev1, ev2, ev3, ev4, ev5, ev6]:
+ yield e
+
+ await consume_sdk_stream(stream(), on_tool_call_delta=cb)
+ assert deltas == [
+ {"call_id": "c1", "name": "write_file", "arguments_delta": ""},
+ {
+ "call_id": "c1",
+ "name": "write_file",
+ "arguments_delta": '{"path":"a.txt","content":"',
+ },
+ {"call_id": "c1", "name": "write_file", "arguments_delta": "hello\\n"},
+ ]
+
@pytest.mark.asyncio
async def test_usage_extracted(self):
usage_obj = MagicMock(input_tokens=10, output_tokens=5, total_tokens=15)
diff --git a/tests/tools/test_image_generation_tool.py b/tests/tools/test_image_generation_tool.py
index 2afdbdff..92ed8a33 100644
--- a/tests/tools/test_image_generation_tool.py
+++ b/tests/tools/test_image_generation_tool.py
@@ -44,8 +44,8 @@ async def test_generate_image_tool_stores_artifact_and_source_images(
set_config_path(tmp_path / "config.json")
FakeImageClient.instances = []
monkeypatch.setattr(
- "nanobot.agent.tools.image_generation.OpenRouterImageGenerationClient",
- FakeImageClient,
+ "nanobot.agent.tools.image_generation.get_image_gen_provider",
+ lambda name: FakeImageClient if name == "openrouter" else None,
)
ref = tmp_path / "ref.png"
ref.write_bytes(PNG_BYTES)
@@ -98,8 +98,8 @@ async def test_generate_image_tool_selects_aihubmix_provider(
set_config_path(tmp_path / "config.json")
FakeImageClient.instances = []
monkeypatch.setattr(
- "nanobot.agent.tools.image_generation.AIHubMixImageGenerationClient",
- FakeImageClient,
+ "nanobot.agent.tools.image_generation.get_image_gen_provider",
+ lambda name: FakeImageClient if name == "aihubmix" else None,
)
tool = ImageGenerationTool(
workspace=tmp_path,
diff --git a/tests/utils/test_artifacts.py b/tests/utils/test_artifacts.py
index 64d2e3f3..941c1a40 100644
--- a/tests/utils/test_artifacts.py
+++ b/tests/utils/test_artifacts.py
@@ -10,8 +10,6 @@ from nanobot.config.loader import set_config_path
from nanobot.utils.artifacts import (
ArtifactError,
decode_image_data_url,
- generated_image_paths_from_messages,
- generated_image_tool_result,
store_generated_image_artifact,
)
@@ -66,22 +64,3 @@ def test_store_generated_image_artifact_rejects_unsafe_save_dir(tmp_path: Path)
model="m",
save_dir="../outside",
)
-
-
-def test_generated_image_paths_from_tool_results() -> None:
- result = generated_image_tool_result(
- [
- {"id": "img_1", "path": "/tmp/one.png"},
- {"id": "img_2", "path": "/tmp/two.png"},
- ]
- )
- payload = json.loads(result)
-
- assert generated_image_paths_from_messages(
- [
- {"role": "tool", "name": "generate_image", "content": result},
- {"role": "tool", "name": "other", "content": result},
- ]
- ) == ["/tmp/one.png", "/tmp/two.png"]
- assert "runtime attaches generated images automatically" in payload["next_step"]
- assert "Do not call message" in payload["next_step"]
diff --git a/tests/utils/test_file_edit_events.py b/tests/utils/test_file_edit_events.py
index 6176a5e3..cdaae516 100644
--- a/tests/utils/test_file_edit_events.py
+++ b/tests/utils/test_file_edit_events.py
@@ -1,6 +1,8 @@
from __future__ import annotations
+import asyncio
from pathlib import Path
+from types import SimpleNamespace
from nanobot.utils.file_edit_events import (
build_file_edit_end_event,
@@ -8,6 +10,7 @@ from nanobot.utils.file_edit_events import (
line_diff_stats,
prepare_file_edit_tracker,
read_file_snapshot,
+ StreamingFileEditTracker,
)
@@ -20,6 +23,10 @@ def test_line_diff_stats_normalizes_crlf() -> None:
assert line_diff_stats("a\r\nb\r\n", "a\nb\nc\n") == (1, 0)
+def test_line_diff_stats_counts_new_file_crlf_lines_once() -> None:
+ assert line_diff_stats("", "a\r\nb\r\n") == (2, 0)
+
+
def test_write_file_start_predicts_and_end_calibrates_exact_diff(tmp_path: Path) -> None:
target = tmp_path / "notes.txt"
target.write_text("old\nkeep\n", encoding="utf-8")
@@ -39,6 +46,7 @@ def test_write_file_start_predicts_and_end_calibrates_exact_diff(tmp_path: Path)
"call_id": "call-write",
"tool": "write_file",
"path": "notes.txt",
+ "absolute_path": (tmp_path / "notes.txt").resolve().as_posix(),
"phase": "start",
"added": 2,
"deleted": 1,
@@ -73,6 +81,307 @@ def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
assert (event["added"], event["deleted"]) == (0, 0)
+def test_oversized_write_file_end_uses_known_content_for_exact_count(tmp_path: Path) -> None:
+ target = tmp_path / "large.txt"
+ params = {"path": "large.txt", "content": "x" * (2 * 1024 * 1024 + 1)}
+ tracker = prepare_file_edit_tracker(
+ call_id="call-large",
+ tool_name="write_file",
+ tool=None,
+ workspace=tmp_path,
+ params=params,
+ )
+
+ assert tracker is not None
+ target.write_text(params["content"], encoding="utf-8")
+ event = build_file_edit_end_event(tracker, params)
+ assert event.get("binary") is not True
+ assert event["added"] == 1
+ assert event["deleted"] == 0
+
+
+def test_streaming_write_file_tracker_emits_live_line_counts(tmp_path: Path) -> None:
+ events: list[dict] = []
+
+ async def emit(batch: list[dict]) -> None:
+ events.extend(batch)
+
+ async def run() -> None:
+ tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
+ await tracker.update({
+ "index": 0,
+ "call_id": "call-live",
+ "name": "write_file",
+ "arguments_delta": '{"path":"notes.md","content":"',
+ })
+ await tracker.update({
+ "index": 0,
+ "arguments_delta": "line\\n" * 24,
+ })
+
+ asyncio.run(run())
+
+ assert events[0] == {
+ "version": 1,
+ "call_id": "call-live",
+ "tool": "write_file",
+ "path": "notes.md",
+ "absolute_path": (tmp_path / "notes.md").resolve().as_posix(),
+ "phase": "start",
+ "added": 0,
+ "deleted": 0,
+ "approximate": True,
+ "status": "editing",
+ }
+ assert events[-1]["path"] == "notes.md"
+ assert events[-1]["status"] == "editing"
+ assert events[-1]["approximate"] is True
+ assert events[-1]["added"] == 24
+ assert events[-1]["deleted"] == 0
+
+
+def test_streaming_write_file_tracker_emits_pending_before_path(tmp_path: Path) -> None:
+ events: list[dict] = []
+
+ async def emit(batch: list[dict]) -> None:
+ events.extend(batch)
+
+ async def run() -> None:
+ tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
+ await tracker.update({
+ "index": 0,
+ "call_id": "call-live",
+ "name": "write_file",
+ "arguments_delta": '{"content":"line\\n',
+ })
+ await tracker.update({
+ "index": 0,
+ "arguments_delta": 'more\\n","path":"late.md"',
+ })
+
+ asyncio.run(run())
+
+ assert events[0] == {
+ "version": 1,
+ "call_id": "call-live",
+ "tool": "write_file",
+ "path": "",
+ "phase": "start",
+ "added": 1,
+ "deleted": 0,
+ "approximate": True,
+ "status": "editing",
+ "pending": True,
+ }
+ assert events[-1]["path"] == "late.md"
+ assert events[-1].get("pending") is not True
+ assert events[-1]["added"] == 2
+
+
+def test_streaming_write_file_tracker_flushes_small_pending_count(tmp_path: Path) -> None:
+ events: list[dict] = []
+
+ async def emit(batch: list[dict]) -> None:
+ events.extend(batch)
+
+ async def run() -> None:
+ tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
+ await tracker.update({
+ "index": 0,
+ "call_id": "call-live",
+ "name": "write_file",
+ "arguments_delta": '{"path":"small.md","content":"one\\n',
+ })
+ await tracker.flush()
+
+ asyncio.run(run())
+ assert events
+ assert events[-1]["path"] == "small.md"
+ assert events[-1]["added"] == 1
+
+
+def test_streaming_write_file_tracker_normalizes_crlf_line_counts(tmp_path: Path) -> None:
+ events: list[dict] = []
+
+ async def emit(batch: list[dict]) -> None:
+ events.extend(batch)
+
+ async def run() -> None:
+ tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
+ await tracker.update({
+ "index": 0,
+ "call_id": "call-live",
+ "name": "write_file",
+ "arguments_delta": '{"path":"windows.txt","content":"one\\r\\ntwo\\r\\n',
+ })
+ await tracker.flush()
+
+ asyncio.run(run())
+ assert events[-1]["path"] == "windows.txt"
+ assert events[-1]["added"] == 2
+
+
+def test_streaming_write_file_tracker_counts_unicode_escaped_newlines(tmp_path: Path) -> None:
+ events: list[dict] = []
+
+ async def emit(batch: list[dict]) -> None:
+ events.extend(batch)
+
+ async def run() -> None:
+ tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
+ await tracker.update({
+ "index": 0,
+ "call_id": "call-live",
+ "name": "write_file",
+ "arguments_delta": '{"path":"unicode.txt","content":"one\\u000atwo',
+ })
+ await tracker.flush()
+
+ asyncio.run(run())
+ assert events[-1]["path"] == "unicode.txt"
+ assert events[-1]["added"] == 2
+
+
+def test_streaming_edit_file_tracker_emits_live_line_counts(tmp_path: Path) -> None:
+ target = tmp_path / "notes.md"
+ target.write_text("old\nkeep\n", encoding="utf-8")
+ events: list[dict] = []
+
+ async def emit(batch: list[dict]) -> None:
+ events.extend(batch)
+
+ async def run() -> None:
+ tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
+ await tracker.update({
+ "index": 0,
+ "call_id": "call-edit",
+ "name": "edit_file",
+ "arguments_delta": '{"path":"notes.md","old_text":"old\\nkeep","new_text":"',
+ })
+ await tracker.update({
+ "index": 0,
+ "arguments_delta": "new\\nkeep\\nextra\\n" * 8,
+ })
+
+ asyncio.run(run())
+
+ assert events[0] == {
+ "version": 1,
+ "call_id": "call-edit",
+ "tool": "edit_file",
+ "path": "notes.md",
+ "absolute_path": (tmp_path / "notes.md").resolve().as_posix(),
+ "phase": "start",
+ "added": 0,
+ "deleted": 2,
+ "approximate": True,
+ "status": "editing",
+ }
+ assert events[-1]["path"] == "notes.md"
+ assert events[-1]["status"] == "editing"
+ assert events[-1]["approximate"] is True
+ assert events[-1]["added"] == 24
+ assert events[-1]["deleted"] == 2
+
+
+def test_streaming_tracker_applies_canonical_call_id_to_final_tool(tmp_path: Path) -> None:
+ events: list[dict] = []
+
+ async def emit(batch: list[dict]) -> None:
+ events.extend(batch)
+
+ async def run() -> None:
+ tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
+ await tracker.update({
+ "index": 0,
+ "name": "write_file",
+ "arguments_delta": '{"path":"matched.md","content":"one\\n',
+ })
+ final = SimpleNamespace(
+ id="provider-final-id",
+ name="write_file",
+ arguments={"path": "matched.md", "content": "one\n"},
+ )
+ tracker.apply_final_call_ids([final])
+ assert final.id == "idx:0"
+
+ asyncio.run(run())
+
+
+def test_streaming_edit_file_tracker_flushes_small_pending_count(tmp_path: Path) -> None:
+ target = tmp_path / "small.py"
+ target.write_text("old\n", encoding="utf-8")
+ events: list[dict] = []
+
+ async def emit(batch: list[dict]) -> None:
+ events.extend(batch)
+
+ async def run() -> None:
+ tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
+ await tracker.update({
+ "index": 0,
+ "call_id": "call-edit",
+ "name": "edit_file",
+ "arguments_delta": '{"path":"small.py","old_text":"old\\n","new_text":"new\\nextra',
+ })
+ await tracker.flush()
+
+ asyncio.run(run())
+ assert events
+ assert events[-1]["path"] == "small.py"
+ assert events[-1]["added"] == 2
+ assert events[-1]["deleted"] == 1
+
+
+def test_streaming_write_file_tracker_errors_unmatched_live_edits(tmp_path: Path) -> None:
+ events: list[dict] = []
+
+ async def emit(batch: list[dict]) -> None:
+ events.extend(batch)
+
+ async def run() -> None:
+ tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
+ await tracker.update({
+ "index": 0,
+ "call_id": "call-live",
+ "name": "write_file",
+ "arguments_delta": '{"path":"aborted.md","content":"one\\n',
+ })
+ await tracker.error_unmatched([], "Tool call did not complete.")
+
+ asyncio.run(run())
+ assert events[-1]["path"] == "aborted.md"
+ assert events[-1]["phase"] == "error"
+ assert events[-1]["status"] == "error"
+
+
+def test_streaming_write_file_tracker_keeps_matched_final_tool_call(tmp_path: Path) -> None:
+ events: list[dict] = []
+
+ async def emit(batch: list[dict]) -> None:
+ events.extend(batch)
+
+ async def run() -> None:
+ tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
+ await tracker.update({
+ "index": 0,
+ "call_id": "idx-only",
+ "name": "write_file",
+ "arguments_delta": '{"path":"matched.md","content":"one\\n',
+ })
+ await tracker.error_unmatched([
+ SimpleNamespace(
+ id="final-call",
+ name="write_file",
+ arguments={"path": "matched.md", "content": "one\n"},
+ )
+ ], "Tool call did not complete.")
+
+ asyncio.run(run())
+ assert events
+ assert all(event["status"] == "editing" for event in events)
+
+
def test_untracked_tools_do_not_prepare_file_edit_tracker(tmp_path: Path) -> None:
assert prepare_file_edit_tracker(
call_id="call-exec",
diff --git a/tests/utils/test_webui_compat_imports.py b/tests/utils/test_webui_compat_imports.py
new file mode 100644
index 00000000..ccb97e28
--- /dev/null
+++ b/tests/utils/test_webui_compat_imports.py
@@ -0,0 +1,14 @@
+import importlib
+
+from nanobot.session import webui_turns
+from nanobot.webui import thread_disk, transcript
+
+
+def test_legacy_webui_utils_imports_resolve_to_new_modules() -> None:
+ legacy_thread_disk = importlib.import_module("nanobot.utils.webui_thread_disk")
+ legacy_transcript = importlib.import_module("nanobot.utils.webui_transcript")
+ legacy_turn_helpers = importlib.import_module("nanobot.utils.webui_turn_helpers")
+
+ assert legacy_thread_disk.delete_webui_thread is thread_disk.delete_webui_thread
+ assert legacy_transcript.append_transcript_object is transcript.append_transcript_object
+ assert legacy_turn_helpers.mark_webui_session is webui_turns.mark_webui_session
diff --git a/tests/utils/test_webui_sidebar_state.py b/tests/utils/test_webui_sidebar_state.py
new file mode 100644
index 00000000..0244a304
--- /dev/null
+++ b/tests/utils/test_webui_sidebar_state.py
@@ -0,0 +1,73 @@
+import json
+
+from nanobot.webui.sidebar_state import (
+ default_webui_sidebar_state,
+ read_webui_sidebar_state,
+ webui_sidebar_state_path,
+ write_webui_sidebar_state,
+)
+
+
+def test_sidebar_state_defaults_when_file_missing(tmp_path, monkeypatch) -> None:
+ monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
+
+ state = read_webui_sidebar_state()
+
+ assert state == default_webui_sidebar_state()
+ assert webui_sidebar_state_path() == tmp_path / "webui" / "sidebar-state.json"
+
+
+def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch) -> None:
+ monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
+ path = webui_sidebar_state_path()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ json.dumps(
+ {
+ "pinned_keys": ["websocket:a", "websocket:a", "", 123],
+ "archived_keys": ["websocket:b"],
+ "title_overrides": {"websocket:a": " Release notes ", "bad": ""},
+ "tags_by_key": {"websocket:a": ["work", "work", ""]},
+ "collapsed_groups": {"Earlier": 1},
+ "view": {"density": "tiny", "show_archived": True, "sort": "nope"},
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ state = read_webui_sidebar_state()
+
+ assert state["schema_version"] == 1
+ assert state["pinned_keys"] == ["websocket:a"]
+ assert state["archived_keys"] == ["websocket:b"]
+ assert state["title_overrides"] == {"websocket:a": "Release notes"}
+ assert state["tags_by_key"] == {"websocket:a": ["work"]}
+ assert state["collapsed_groups"] == {"Earlier": True}
+ assert state["view"] == {
+ "density": "comfortable",
+ "show_previews": False,
+ "show_timestamps": False,
+ "show_archived": True,
+ "sort": "updated_desc",
+ }
+
+
+def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch) -> None:
+ monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
+
+ state = write_webui_sidebar_state(
+ {
+ "pinned_keys": ["websocket:a"],
+ "archived_keys": ["websocket:b"],
+ "title_overrides": {"websocket:a": "Release"},
+ "view": {"density": "compact", "show_previews": True},
+ }
+ )
+
+ assert state["pinned_keys"] == ["websocket:a"]
+ assert state["archived_keys"] == ["websocket:b"]
+ assert state["title_overrides"] == {"websocket:a": "Release"}
+ assert state["view"]["density"] == "compact"
+ assert state["view"]["show_previews"] is True
+ assert webui_sidebar_state_path().is_file()
+ assert read_webui_sidebar_state()["pinned_keys"] == ["websocket:a"]
diff --git a/tests/utils/test_webui_thread_disk.py b/tests/utils/test_webui_thread_disk.py
index 36680b45..53094d65 100644
--- a/tests/utils/test_webui_thread_disk.py
+++ b/tests/utils/test_webui_thread_disk.py
@@ -2,8 +2,8 @@
from __future__ import annotations
-from nanobot.utils.webui_thread_disk import delete_webui_thread, webui_thread_file_path
-from nanobot.utils.webui_transcript import append_transcript_object, webui_transcript_path
+from nanobot.webui.thread_disk import delete_webui_thread, webui_thread_file_path
+from nanobot.webui.transcript import append_transcript_object, webui_transcript_path
def test_delete_webui_thread_removes_legacy_json_and_transcript(tmp_path, monkeypatch) -> None:
diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py
index 4ed9c132..4c2226f0 100644
--- a/tests/utils/test_webui_transcript.py
+++ b/tests/utils/test_webui_transcript.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from nanobot.utils.webui_transcript import (
+from nanobot.webui.transcript import (
WEBUI_TRANSCRIPT_SCHEMA_VERSION,
append_transcript_object,
read_transcript_lines,
@@ -145,8 +145,203 @@ def test_replay_tool_events_dedupes_finish_after_start() -> None:
]
+def test_replay_file_edit_progress_merges_after_interleaved_activity(tmp_path, monkeypatch) -> None:
+ monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
+ key = "websocket:t-file-progress"
+ for ev in (
+ {"event": "user", "chat_id": "t-file-progress", "text": "edit"},
+ {
+ "event": "message",
+ "chat_id": "t-file-progress",
+ "text": 'write_file({"path":"foo.txt"})',
+ "kind": "tool_hint",
+ },
+ {
+ "event": "file_edit",
+ "chat_id": "t-file-progress",
+ "edits": [
+ {
+ "version": 1,
+ "call_id": "call-write",
+ "tool": "write_file",
+ "path": "foo.txt",
+ "phase": "start",
+ "added": 12,
+ "deleted": 0,
+ "approximate": True,
+ "status": "editing",
+ },
+ ],
+ },
+ {
+ "event": "message",
+ "chat_id": "t-file-progress",
+ "text": "still working",
+ "kind": "progress",
+ },
+ {
+ "event": "file_edit",
+ "chat_id": "t-file-progress",
+ "edits": [
+ {
+ "version": 1,
+ "call_id": "call-write",
+ "tool": "write_file",
+ "path": "foo.txt",
+ "phase": "end",
+ "added": 30,
+ "deleted": 0,
+ "approximate": False,
+ "status": "done",
+ },
+ ],
+ },
+ ):
+ append_transcript_object(key, ev)
+
+ msgs = replay_transcript_to_ui_messages(read_transcript_lines(key))
+ file_edit_messages = [msg for msg in msgs if msg.get("fileEdits")]
+
+ assert len(file_edit_messages) == 1
+ assert file_edit_messages[0]["fileEdits"] == [
+ {
+ "version": 1,
+ "call_id": "call-write",
+ "tool": "write_file",
+ "path": "foo.txt",
+ "phase": "end",
+ "added": 30,
+ "deleted": 0,
+ "approximate": False,
+ "status": "done",
+ },
+ ]
+
+
+def test_replay_file_edit_pending_placeholder_upgrades_to_path(tmp_path, monkeypatch) -> None:
+ monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
+ key = "websocket:t-file-pending"
+ for ev in (
+ {"event": "user", "chat_id": "t-file-pending", "text": "write"},
+ {
+ "event": "file_edit",
+ "chat_id": "t-file-pending",
+ "edits": [
+ {
+ "version": 1,
+ "call_id": "call-write",
+ "tool": "write_file",
+ "path": "",
+ "phase": "start",
+ "added": 1,
+ "deleted": 0,
+ "approximate": True,
+ "status": "editing",
+ "pending": True,
+ },
+ ],
+ },
+ {
+ "event": "file_edit",
+ "chat_id": "t-file-pending",
+ "edits": [
+ {
+ "version": 1,
+ "call_id": "call-write",
+ "tool": "write_file",
+ "path": "foo.txt",
+ "phase": "start",
+ "added": 12,
+ "deleted": 0,
+ "approximate": True,
+ "status": "editing",
+ },
+ ],
+ },
+ ):
+ append_transcript_object(key, ev)
+
+ msgs = replay_transcript_to_ui_messages(read_transcript_lines(key))
+ file_edit_messages = [msg for msg in msgs if msg.get("fileEdits")]
+
+ assert len(file_edit_messages) == 1
+ assert file_edit_messages[0]["fileEdits"] == [
+ {
+ "version": 1,
+ "call_id": "call-write",
+ "tool": "write_file",
+ "path": "foo.txt",
+ "phase": "start",
+ "added": 12,
+ "deleted": 0,
+ "approximate": True,
+ "status": "editing",
+ },
+ ]
+
+
+def test_replay_keeps_new_file_edit_after_reasoning_in_order(tmp_path, monkeypatch) -> None:
+ monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
+ key = "websocket:t-file-order"
+ for ev in (
+ {"event": "user", "chat_id": "t-file-order", "text": "edit"},
+ {
+ "event": "file_edit",
+ "chat_id": "t-file-order",
+ "edits": [
+ {
+ "version": 1,
+ "call_id": "call-one",
+ "tool": "write_file",
+ "path": "one.txt",
+ "phase": "start",
+ "added": 10,
+ "deleted": 0,
+ "approximate": True,
+ "status": "editing",
+ },
+ ],
+ },
+ {"event": "reasoning_delta", "chat_id": "t-file-order", "text": "Check next."},
+ {"event": "reasoning_end", "chat_id": "t-file-order"},
+ {
+ "event": "file_edit",
+ "chat_id": "t-file-order",
+ "edits": [
+ {
+ "version": 1,
+ "call_id": "call-two",
+ "tool": "write_file",
+ "path": "two.txt",
+ "phase": "start",
+ "added": 20,
+ "deleted": 0,
+ "approximate": True,
+ "status": "editing",
+ },
+ ],
+ },
+ ):
+ append_transcript_object(key, ev)
+
+ msgs = replay_transcript_to_ui_messages(read_transcript_lines(key))
+
+ assert [msg.get("fileEdits", [{}])[0].get("path") if msg.get("fileEdits") else msg.get("reasoning") for msg in msgs[1:]] == [
+ "one.txt",
+ "Check next.",
+ "two.txt",
+ ]
+ file_edit_segments = [
+ msg.get("activitySegmentId")
+ for msg in msgs
+ if msg.get("fileEdits")
+ ]
+ assert len(file_edit_segments) == 2
+ assert file_edit_segments[0] != file_edit_segments[1]
+
+
def test_build_response_schema(monkeypatch, tmp_path) -> None:
- from nanobot.utils.webui_transcript import build_webui_thread_response
+ from nanobot.webui.transcript import build_webui_thread_response
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t3"
diff --git a/tests/utils/test_webui_turn_helpers.py b/tests/utils/test_webui_turn_helpers.py
index f3c0b174..be01c5f4 100644
--- a/tests/utils/test_webui_turn_helpers.py
+++ b/tests/utils/test_webui_turn_helpers.py
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.bus.events import InboundMessage
-from nanobot.utils import webui_turn_helpers as wth
+from nanobot.session import webui_turns as wth
@pytest.fixture(autouse=True)
diff --git a/webui/package-lock.json b/webui/package-lock.json
index 471d08bb..2f278a23 100644
--- a/webui/package-lock.json
+++ b/webui/package-lock.json
@@ -26,6 +26,7 @@
"react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1",
+ "remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"tailwind-merge": "^2.6.0"
@@ -3922,6 +3923,20 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/mdast-util-newline-to-break": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz",
+ "integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-find-and-replace": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/mdast-util-phrasing": {
"version": "4.1.0",
"license": "MIT",
@@ -5141,6 +5156,21 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/remark-breaks": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz",
+ "integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-newline-to-break": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/remark-gfm": {
"version": "4.0.1",
"license": "MIT",
diff --git a/webui/src/App.tsx b/webui/src/App.tsx
index 7ff9bae2..c303446e 100644
--- a/webui/src/App.tsx
+++ b/webui/src/App.tsx
@@ -1,13 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { DeleteConfirm } from "@/components/DeleteConfirm";
+import { RenameChatDialog } from "@/components/RenameChatDialog";
import { Sidebar } from "@/components/Sidebar";
+import { SessionSearchDialog } from "@/components/SessionSearchDialog";
import { SettingsView } from "@/components/settings/SettingsView";
import { ThreadShell } from "@/components/thread/ThreadShell";
-import { Sheet, SheetContent } from "@/components/ui/sheet";
+import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { useSessions } from "@/hooks/useSessions";
import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh";
+import { useSidebarState } from "@/hooks/useSidebarState";
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
import { cn } from "@/lib/utils";
import {
@@ -37,6 +40,7 @@ type BootState =
};
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
+const COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1";
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
const SIDEBAR_WIDTH = 272;
const TOKEN_REFRESH_MARGIN_MS = 30_000;
@@ -121,6 +125,29 @@ function readSidebarOpen(): boolean {
}
}
+function readCompletedRunChatIds(): Set {
+ if (typeof window === "undefined") return new Set();
+ try {
+ const raw = window.localStorage.getItem(COMPLETED_RUNS_STORAGE_KEY);
+ const parsed = raw ? JSON.parse(raw) : [];
+ if (!Array.isArray(parsed)) return new Set();
+ return new Set(parsed.filter((item): item is string => typeof item === "string"));
+ } catch {
+ return new Set();
+ }
+}
+
+function writeCompletedRunChatIds(chatIds: Set): void {
+ try {
+ window.localStorage.setItem(
+ COMPLETED_RUNS_STORAGE_KEY,
+ JSON.stringify(Array.from(chatIds)),
+ );
+ } catch {
+ // ignore storage errors (private mode, etc.)
+ }
+}
+
export default function App() {
const { t } = useTranslation();
const [state, setState] = useState({ status: "loading" });
@@ -293,18 +320,28 @@ function Shell({
const { client } = useClient();
const { theme, toggle } = useTheme();
const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
+ const { state: sidebarState, update: updateSidebarState } =
+ useSidebarState(sessions, !loading);
const [activeKey, setActiveKey] = useState(null);
const [view, setView] = useState("chat");
const [desktopSidebarOpen, setDesktopSidebarOpen] =
useState(readSidebarOpen);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
+ const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
const [pendingDelete, setPendingDelete] = useState<{
key: string;
label: string;
} | null>(null);
+ const [pendingRename, setPendingRename] = useState<{
+ key: string;
+ label: string;
+ } | null>(null);
const restartSawDisconnectRef = useRef(false);
const [restartToast, setRestartToast] = useState(null);
const [isRestarting, setIsRestarting] = useState(false);
+ const [runningChatIds, setRunningChatIds] = useState>(() => new Set());
+ const [completedChatIds, setCompletedChatIds] = useState>(readCompletedRunChatIds);
+ const runningChatIdsRef = useRef>(new Set());
useEffect(() => {
try {
@@ -317,12 +354,58 @@ function Shell({
}
}, [desktopSidebarOpen]);
-
+ useEffect(() => {
+ writeCompletedRunChatIds(completedChatIds);
+ }, [completedChatIds]);
const activeSession = useMemo(() => {
if (!activeKey) return null;
return sessions.find((s) => s.key === activeKey) ?? null;
}, [sessions, activeKey]);
+ const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
+ const completedChatIdList = useMemo(() => Array.from(completedChatIds), [completedChatIds]);
+
+ useEffect(() => {
+ if (loading) return;
+ const knownChatIds = new Set(sessions.map((session) => session.chatId));
+ setCompletedChatIds((current) => {
+ const next = new Set(
+ Array.from(current).filter((chatId) => knownChatIds.has(chatId)),
+ );
+ return next.size === current.size ? current : next;
+ });
+ }, [loading, sessions]);
+
+ useEffect(() => {
+ if (loading) return;
+ const activeRunIds = sessions
+ .filter((session) => typeof session.runStartedAt === "number")
+ .map((session) => session.chatId);
+ if (activeRunIds.length === 0) return;
+
+ for (const chatId of activeRunIds) {
+ client.attach(chatId);
+ }
+ setRunningChatIds((current) => {
+ let changed = false;
+ const next = new Set(current);
+ for (const chatId of activeRunIds) {
+ if (!next.has(chatId)) changed = true;
+ next.add(chatId);
+ }
+ if (!changed) return current;
+ runningChatIdsRef.current = next;
+ return next;
+ });
+ setCompletedChatIds((current) => {
+ let changed = false;
+ const next = new Set(current);
+ for (const chatId of activeRunIds) {
+ if (next.delete(chatId)) changed = true;
+ }
+ return changed ? next : current;
+ });
+ }, [client, loading, sessions]);
const closeDesktopSidebar = useCallback(() => {
setDesktopSidebarOpen(false);
@@ -364,14 +447,129 @@ function Shell({
const onSelectChat = useCallback(
(key: string) => {
+ const selectedChatId = sessions.find((session) => session.key === key)?.chatId;
+ if (selectedChatId) {
+ setCompletedChatIds((current) => {
+ if (!current.has(selectedChatId)) return current;
+ const next = new Set(current);
+ next.delete(selectedChatId);
+ return next;
+ });
+ }
setActiveKey(key);
setView("chat");
setMobileSidebarOpen(false);
},
- [],
+ [sessions],
+ );
+
+ const onTogglePin = useCallback(
+ (key: string) => {
+ void updateSidebarState((current) => {
+ const pinned = new Set(current.pinned_keys);
+ if (pinned.has(key)) {
+ pinned.delete(key);
+ } else {
+ pinned.add(key);
+ }
+ return {
+ ...current,
+ pinned_keys: Array.from(pinned),
+ };
+ });
+ },
+ [updateSidebarState],
+ );
+
+ const onRequestRename = useCallback((key: string, label: string) => {
+ setPendingRename({ key, label });
+ }, []);
+
+ const onConfirmRename = useCallback(
+ (title: string) => {
+ if (!pendingRename) return;
+ const key = pendingRename.key;
+ setPendingRename(null);
+ void updateSidebarState((current) => {
+ const titleOverrides = { ...current.title_overrides };
+ const cleaned = title.trim();
+ if (cleaned) {
+ titleOverrides[key] = cleaned;
+ } else {
+ delete titleOverrides[key];
+ }
+ return {
+ ...current,
+ title_overrides: titleOverrides,
+ };
+ });
+ },
+ [pendingRename, updateSidebarState],
+ );
+
+ const onToggleArchive = useCallback(
+ (key: string) => {
+ void updateSidebarState((current) => {
+ const archived = new Set(current.archived_keys);
+ const pinned = current.pinned_keys.filter((item) => item !== key);
+ if (archived.has(key)) {
+ archived.delete(key);
+ } else {
+ archived.add(key);
+ }
+ return {
+ ...current,
+ pinned_keys: pinned,
+ archived_keys: Array.from(archived),
+ };
+ });
+ if (activeKey === key && !sidebarState.archived_keys.includes(key)) {
+ const archived = new Set([...sidebarState.archived_keys, key]);
+ const next = sessions.find((session) => !archived.has(session.key));
+ setActiveKey(next?.key ?? null);
+ }
+ },
+ [activeKey, sessions, sidebarState.archived_keys, updateSidebarState],
+ );
+
+ const onToggleArchived = useCallback(() => {
+ void updateSidebarState((current) => ({
+ ...current,
+ view: {
+ ...current.view,
+ show_archived: !current.view.show_archived,
+ },
+ }));
+ }, [updateSidebarState]);
+
+ const onUpdateSidebarView = useCallback(
+ (viewUpdate: Partial) => {
+ void updateSidebarState((current) => ({
+ ...current,
+ view: {
+ ...current.view,
+ ...viewUpdate,
+ },
+ }));
+ },
+ [updateSidebarState],
+ );
+
+ const onOpenSessionSearch = useCallback(() => {
+ setMobileSidebarOpen(false);
+ setSessionSearchOpen(true);
+ }, []);
+
+ const onSelectSearchResult = useCallback(
+ (key: string) => {
+ setSessionSearchOpen(false);
+ onSelectChat(key);
+ },
+ [onSelectChat],
);
const onOpenSettings = useCallback(() => {
+ setSessionSearchOpen(false);
setView("settings");
setMobileSidebarOpen(false);
}, []);
@@ -405,6 +603,35 @@ function Shell({
});
}, [client, onModelNameChange]);
+ useEffect(() => {
+ return client.onRunStatus((chatId, startedAt) => {
+ if (startedAt != null) {
+ const nextRunning = new Set(runningChatIdsRef.current);
+ nextRunning.add(chatId);
+ runningChatIdsRef.current = nextRunning;
+ setRunningChatIds(nextRunning);
+ setCompletedChatIds((current) => {
+ if (!current.has(chatId)) return current;
+ const next = new Set(current);
+ next.delete(chatId);
+ return next;
+ });
+ return;
+ }
+
+ if (!runningChatIdsRef.current.has(chatId)) return;
+ const nextRunning = new Set(runningChatIdsRef.current);
+ nextRunning.delete(chatId);
+ runningChatIdsRef.current = nextRunning;
+ setRunningChatIds(nextRunning);
+ setCompletedChatIds((current) => {
+ const next = new Set(current);
+ next.add(chatId);
+ return next;
+ });
+ });
+ }, [client]);
+
useEffect(() => {
return client.onStatus((status) => {
let startedAt = 0;
@@ -452,7 +679,8 @@ function Shell({
}, [pendingDelete, deleteChat, activeKey, sessions]);
const headerTitle = activeSession
- ? activeSession.title ||
+ ? sidebarState.title_overrides[activeSession.key] ||
+ activeSession.title ||
deriveTitle(activeSession.preview, t("chat.newChat"))
: t("app.brand");
@@ -476,7 +704,21 @@ function Shell({
onSelect: onSelectChat,
onRequestDelete: (key: string, label: string) =>
setPendingDelete({ key, label }),
+ onTogglePin,
+ onRequestRename,
+ onToggleArchive,
onOpenSettings,
+ onOpenSearch: onOpenSessionSearch,
+ onToggleArchived,
+ onUpdateView: onUpdateSidebarView,
+ pinnedKeys: sidebarState.pinned_keys,
+ archivedKeys: sidebarState.archived_keys,
+ titleOverrides: sidebarState.title_overrides,
+ runningChatIds: runningChatIdList,
+ completedChatIds: completedChatIdList,
+ viewState: sidebarState.view,
+ showArchived: sidebarState.view.show_archived,
+ archivedCount: sidebarState.archived_keys.length,
};
const showMainSidebar = view !== "settings";
@@ -513,14 +755,32 @@ function Shell({
-
+ {t("sidebar.navigation")}
+
) : null}
+ {showMainSidebar ? (
+
+ ) : null}
+