Merge remote-tracking branch 'origin/main' into nightly

This commit is contained in:
chengyongru 2026-05-07 00:46:59 +08:00
commit 22b3010bd0
100 changed files with 5694 additions and 1307 deletions

View File

@ -165,6 +165,10 @@ export class WhatsAppClient {
fallbackContent = '[Video]';
const path = await this.downloadMedia(msg, unwrapped.videoMessage.mimetype ?? undefined);
if (path) mediaPaths.push(path);
} else if (unwrapped.audioMessage) {
fallbackContent = '[Voice Message]';
const path = await this.downloadMedia(msg, unwrapped.audioMessage.mimetype ?? undefined);
if (path) mediaPaths.push(path);
}
const finalContent = content || (mediaPaths.length === 0 ? fallbackContent : '') || '';

View File

@ -55,6 +55,7 @@ from nanobot.utils.progress_events import (
on_progress_accepts_tool_events,
)
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
from nanobot.utils.webui_titles import mark_webui_session, maybe_generate_webui_title_after_turn
if TYPE_CHECKING:
from nanobot.config.schema import ChannelsConfig, ExecToolConfig, ToolsConfig, WebToolsConfig
@ -112,6 +113,11 @@ class _LoopHook(AgentHook):
async def before_iteration(self, context: AgentHookContext) -> None:
self._loop._current_iteration = context.iteration
logger.debug(
"Starting agent loop iteration {} for session {}",
context.iteration,
self._session_key,
)
async def before_execute_tools(self, context: AgentHookContext) -> None:
if self._on_progress:
@ -422,7 +428,7 @@ class AgentLoop:
logger.warning("MCP connection cancelled (will retry next message)")
self._mcp_stacks.clear()
except BaseException as e:
logger.error("Failed to connect MCP servers (will retry next message): {}", e)
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
self._mcp_stacks.clear()
finally:
self._mcp_connecting = False
@ -648,6 +654,7 @@ class AgentLoop:
context_block_limit=self.context_block_limit,
provider_retry_mode=self.provider_retry_mode,
progress_callback=on_progress,
stream_progress_deltas=on_stream is not None,
retry_wait_callback=on_retry_wait,
checkpoint_callback=_checkpoint,
injection_callback=_drain_pending,
@ -800,6 +807,33 @@ class AgentLoop:
channel=msg.channel, chat_id=msg.chat_id,
content="", metadata=msg.metadata or {},
))
if msg.channel == "websocket":
# Signal that the turn is fully complete (all tools executed,
# final text streamed). This lets WS clients know when to
# definitively stop the loading indicator.
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="", metadata={**msg.metadata, "_turn_end": True},
))
if msg.metadata.get("webui") is True:
async def _generate_title_and_notify() -> None:
generated = await maybe_generate_webui_title_after_turn(
channel=msg.channel,
metadata=msg.metadata,
sessions=self.sessions,
session_key=session_key,
provider=self.provider,
model=self.model,
)
if generated:
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content="",
metadata={**msg.metadata, "_session_updated": True},
))
self._schedule_background(_generate_title_and_notify())
except asyncio.CancelledError:
logger.info("Task cancelled for session {}", session_key)
# Preserve partial context from the interrupted turn so
@ -903,6 +937,8 @@ class AgentLoop:
self.sessions.save(session)
session, pending = self.auto_compact.prepare_session(session, key)
if pending:
logger.info("Memory compact triggered for session {}", key)
await self.consolidator.maybe_consolidate_by_tokens(
session,
@ -915,6 +951,7 @@ class AgentLoop:
# LLM via the merged prompt. See _persist_subagent_followup.
is_subagent = msg.sender_id == "subagent"
if is_subagent and self._persist_subagent_followup(session, msg):
logger.debug("Subagent result persisted for session {}", key)
self.sessions.save(session)
self._set_tool_context(
channel, chat_id, msg.metadata.get("message_id"),
@ -986,6 +1023,7 @@ class AgentLoop:
key = session_key or msg.session_key
session = self.sessions.get_or_create(key)
mark_webui_session(session, msg.metadata)
if self._restore_runtime_checkpoint(session):
self.sessions.save(session)
if self._restore_pending_user_turn(session):
@ -1131,7 +1169,7 @@ class AgentLoop:
ask_user_options_from_messages(all_msgs) if stop_reason == "ask_user" else [],
msg.channel,
)
if on_stream is not None and stop_reason not in {"ask_user", "error"}:
if on_stream is not None and stop_reason not in {"ask_user", "error", "tool_error"}:
meta["_streamed"] = True
return OutboundMessage(
channel=msg.channel,

View File

@ -974,12 +974,10 @@ class Dream:
if event["status"] == "ok":
changelog.append(f"{event['name']}: {event['detail']}")
# Advance cursor — always, to avoid re-processing Phase 1
new_cursor = batch[-1]["cursor"]
self.store.set_last_dream_cursor(new_cursor)
self.store.compact_history()
# Only advance cursor on successful completion to prevent silent loss
if result and result.stop_reason == "completed":
new_cursor = batch[-1]["cursor"]
self.store.set_last_dream_cursor(new_cursor)
logger.info(
"Dream done: {} change(s), cursor advanced to {}",
len(changelog), new_cursor,
@ -987,10 +985,12 @@ class Dream:
else:
reason = result.stop_reason if result else "exception"
logger.warning(
"Dream incomplete ({}): cursor advanced to {}",
reason, new_cursor,
"Dream incomplete ({}): cursor NOT advanced, will retry next cron cycle",
reason,
)
self.store.compact_history()
# Git auto-commit (only when there are actual changes)
if changelog and self.store.git.is_initialized():
ts = batch[-1]["timestamp"]

View File

@ -33,6 +33,7 @@ from nanobot.utils.runtime import (
ensure_nonempty_tool_result,
is_blank_text,
repeated_external_lookup_error,
repeated_workspace_violation_error,
)
_DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model."
@ -75,6 +76,7 @@ class AgentRunSpec:
context_block_limit: int | None = None
provider_retry_mode: str = "standard"
progress_callback: Any | None = None
stream_progress_deltas: bool = True
retry_wait_callback: Any | None = None
checkpoint_callback: Any | None = None
injection_callback: Any | None = None
@ -239,6 +241,8 @@ class AgentRunner:
stop_reason = "completed"
tool_events: list[dict[str, str]] = []
external_lookup_counts: dict[str, int] = {}
# Per-turn throttle for repeated attempts against the same outside target.
workspace_violation_counts: dict[str, int] = {}
empty_content_retries = 0
length_recovery_count = 0
had_injections = False
@ -258,12 +262,11 @@ class AgentRunner:
# Snipping may have created new orphans; clean them up.
messages_for_model = self._drop_orphan_tool_results(messages_for_model)
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
except Exception as exc:
logger.warning(
"Context governance failed on turn {} for {}: {}; applying minimal repair",
except Exception:
logger.exception(
"Context governance failed on turn {} for {}; applying minimal repair",
iteration,
spec.session_key or "default",
exc,
)
try:
messages_for_model = self._drop_orphan_tool_results(messages)
@ -314,6 +317,7 @@ class AgentRunner:
spec,
tool_calls,
external_lookup_counts,
workspace_violation_counts,
)
tool_events.extend(new_events)
context.tool_results = list(results)
@ -612,6 +616,7 @@ class AgentRunner:
wants_streaming = hook.wants_streaming()
wants_progress_streaming = (
not wants_streaming
and spec.stream_progress_deltas
and spec.progress_callback is not None
and getattr(self.provider, "supports_progress_deltas", False) is True
)
@ -698,20 +703,25 @@ class AgentRunner:
spec: AgentRunSpec,
tool_calls: list[ToolCallRequest],
external_lookup_counts: dict[str, int],
workspace_violation_counts: dict[str, int],
) -> tuple[list[Any], list[dict[str, str]], BaseException | None]:
batches = self._partition_tool_batches(spec, tool_calls)
tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = []
for batch in batches:
if spec.concurrent_tools and len(batch) > 1:
batch_results = await asyncio.gather(*(
self._run_tool(spec, tool_call, external_lookup_counts)
self._run_tool(
spec, tool_call, external_lookup_counts, workspace_violation_counts,
)
for tool_call in batch
))
tool_results.extend(batch_results)
else:
batch_results = []
for tool_call in batch:
result = await self._run_tool(spec, tool_call, external_lookup_counts)
result = await self._run_tool(
spec, tool_call, external_lookup_counts, workspace_violation_counts,
)
tool_results.append(result)
batch_results.append(result)
if isinstance(result[2], AskUserInterrupt):
@ -734,6 +744,7 @@ class AgentRunner:
spec: AgentRunSpec,
tool_call: ToolCallRequest,
external_lookup_counts: dict[str, int],
workspace_violation_counts: dict[str, int],
) -> tuple[Any, dict[str, str], BaseException | None]:
hint = "\n\n[Analyze the error above and try a different approach.]"
lookup_error = repeated_external_lookup_error(
@ -763,16 +774,18 @@ class AgentRunner:
"status": "error",
"detail": prep_error.split(": ", 1)[-1][:120],
}
if self._is_workspace_violation(prep_error):
logger.warning(
"Tool {} blocked by workspace/safety guard during preparation; aborting turn: {}",
tool_call.name,
prep_error.replace("\n", " ").strip()[:200],
)
event["detail"] = ("workspace_violation: "
+ prep_error.replace("\n", " ").strip())[:160]
return prep_error, event, RuntimeError(prep_error)
return prep_error + hint, event, RuntimeError(prep_error) if spec.fail_on_tool_error else None
handled = self._classify_violation(
raw_text=prep_error,
soft_payload=prep_error + hint,
event=event,
tool_call=tool_call,
workspace_violation_counts=workspace_violation_counts,
)
if handled is not None:
return handled
return prep_error + hint, event, (
RuntimeError(prep_error) if spec.fail_on_tool_error else None
)
try:
if tool is not None:
result = await tool.execute(**params)
@ -789,18 +802,20 @@ class AgentRunner:
if isinstance(exc, AskUserInterrupt):
event["status"] = "waiting"
return "", event, exc
if self._is_workspace_violation(str(exc)):
logger.warning(
"Tool {} blocked by workspace/safety guard; aborting turn: {}",
tool_call.name,
str(exc).replace("\n", " ").strip()[:200],
)
event["detail"] = ("workspace_violation: "
+ str(exc).replace("\n", " ").strip())[:160]
return f"Error: {type(exc).__name__}: {exc}", event, exc
payload = f"Error: {type(exc).__name__}: {exc}"
handled = self._classify_violation(
raw_text=str(exc),
# Preserve legacy exception payloads without the retry hint.
soft_payload=payload,
event=event,
tool_call=tool_call,
workspace_violation_counts=workspace_violation_counts,
)
if handled is not None:
return handled
if spec.fail_on_tool_error:
return f"Error: {type(exc).__name__}: {exc}", event, exc
return f"Error: {type(exc).__name__}: {exc}", event, None
return payload, event, exc
return payload, event, None
if isinstance(result, str) and result.startswith("Error"):
event = {
@ -808,17 +823,15 @@ class AgentRunner:
"status": "error",
"detail": result.replace("\n", " ").strip()[:120],
}
# check the outside workspace error and break loop
if self._is_workspace_violation(result):
logger.warning(
"Tool {} blocked by workspace/safety guard; aborting turn: {}",
tool_call.name,
result.replace("\n", " ").strip()[:200],
)
event["detail"] = ("workspace_violation: "
+ result.replace("\n", " ").strip())[:160]
return result, event, RuntimeError(result)
handled = self._classify_violation(
raw_text=result,
soft_payload=result + hint,
event=event,
tool_call=tool_call,
workspace_violation_counts=workspace_violation_counts,
)
if handled is not None:
return handled
if spec.fail_on_tool_error:
return result + hint, event, RuntimeError(result)
return result + hint, event, None
@ -831,23 +844,97 @@ class AgentRunner:
detail = detail[:120] + "..."
return result, {"name": tool_call.name, "status": "ok", "detail": detail}, None
# Markers identifying tool results that represent a workspace / safety boundary rejection.
_WORKSPACE_BLOCK_MARKERS: tuple[str, ...] = (
# SSRF is a hard security block at the tool boundary, but the agent turn
# should recover conversationally instead of aborting the runtime.
_SSRF_MARKERS: tuple[str, ...] = (
"internal/private url detected",
"private/internal address",
"private address",
)
_SSRF_BOUNDARY_NOTE: str = (
"This is a non-bypassable security boundary. Stop trying to access "
"private/internal URLs. Do not retry with curl, wget, encoded IPs, "
"alternate DNS, redirects, proxies, or another tool. Ask the user for "
"local files, logs, screenshots, or an explicit safe public URL instead. "
"If the user explicitly trusts this private URL, ask them to whitelist "
"the exact IP/CIDR via tools.ssrfWhitelist."
)
# Non-SSRF boundary markers returned to the LLM as recoverable tool errors.
_WORKSPACE_VIOLATION_MARKERS: tuple[str, ...] = (
"outside the configured workspace",
"outside allowed directory",
"working_dir is outside",
"working_dir could not be resolved",
"path traversal detected",
"path outside working dir",
"internal/private url detected",
"path traversal detected",
)
@classmethod
def _is_workspace_violation(cls, text: str) -> bool:
def _is_ssrf_violation(cls, text: str) -> bool:
if not text:
return False
lowered = text.lower()
return any(marker in lowered for marker in cls._WORKSPACE_BLOCK_MARKERS)
return any(marker in lowered for marker in cls._SSRF_MARKERS)
@classmethod
def _is_workspace_violation(cls, text: str) -> bool:
"""True when *text* looks like any policy boundary rejection."""
if not text:
return False
lowered = text.lower()
if cls._is_ssrf_violation(lowered):
return True
return any(marker in lowered for marker in cls._WORKSPACE_VIOLATION_MARKERS)
def _classify_violation(
self,
*,
raw_text: str,
soft_payload: str,
event: dict[str, str],
tool_call: ToolCallRequest,
workspace_violation_counts: dict[str, int],
) -> tuple[Any, dict[str, str], BaseException | None] | None:
"""Classify safety-boundary failures, or return ``None`` to pass through."""
if self._is_ssrf_violation(raw_text):
logger.warning(
"Tool {} blocked by SSRF guard; returning non-retryable tool error: {}",
tool_call.name,
raw_text.replace("\n", " ").strip()[:200],
)
event["detail"] = self._event_detail("ssrf_violation: ", raw_text)
return self._ssrf_soft_payload(raw_text), event, None
if self._is_workspace_violation(raw_text):
escalation = repeated_workspace_violation_error(
tool_call.name,
tool_call.arguments,
workspace_violation_counts,
)
event["detail"] = self._event_detail("workspace_violation: ", raw_text)
if escalation is not None:
logger.warning(
"Tool {} hit workspace boundary repeatedly; escalating hint",
tool_call.name,
)
event["detail"] = self._event_detail(
"workspace_violation_escalated: ",
raw_text,
)
return escalation, event, None
return soft_payload, event, None
return None
@classmethod
def _ssrf_soft_payload(cls, raw_text: str) -> str:
text = raw_text.strip() or "Error: request blocked by SSRF guard"
return f"{text}\n\n{cls._SSRF_BOUNDARY_NOTE}"
@staticmethod
def _event_detail(prefix: str, text: str, limit: int = 160) -> str:
return (prefix + text.replace("\n", " ").strip())[:limit]
async def _emit_checkpoint(
self,
@ -895,12 +982,11 @@ class AgentRunner:
result,
max_chars=spec.max_tool_result_chars,
)
except Exception as exc:
logger.warning(
"Tool result persist failed for {} in {}: {}; using raw result",
except Exception:
logger.exception(
"Tool result persist failed for {} in {}; using raw result",
tool_call_id,
spec.session_key or "default",
exc,
)
content = result
if isinstance(content, str) and len(content) > spec.max_tool_result_chars:

View File

@ -250,7 +250,7 @@ class SubagentManager:
except Exception as e:
status.phase = "error"
status.error = str(e)
logger.error("Subagent [{}] failed: {}", task_id, e)
logger.exception("Subagent [{}] failed", task_id)
await self._announce_result(task_id, label, task, f"Error: {e}", origin, "error", origin_message_id)
async def _announce_result(

View File

@ -14,6 +14,13 @@ from nanobot.utils.helpers import build_image_content_blocks, detect_image_mime
from nanobot.config.paths import get_media_dir
_FS_WORKSPACE_BOUNDARY_NOTE = (
" (this is a hard policy boundary, not a transient failure; "
"do not retry with shell tricks or alternative tools, and ask "
"the user how to proceed if the resource is genuinely required)"
)
def _resolve_path(
path: str,
workspace: Path | None = None,
@ -29,7 +36,10 @@ def _resolve_path(
media_path = get_media_dir().resolve()
all_dirs = [allowed_dir] + [media_path] + (extra_allowed_dirs or [])
if not any(_is_under(resolved, d) for d in all_dirs):
raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}")
raise PermissionError(
f"Path {path} is outside allowed directory {allowed_dir}"
+ _FS_WORKSPACE_BOUNDARY_NOTE
)
return resolved

View File

@ -198,11 +198,10 @@ class MCPToolWrapper(Tool):
await asyncio.sleep(1) # Brief backoff before retry
continue
# Second transient failure — give up with retry-specific message
logger.error(
"MCP tool '{}' failed after retry: {}: {}",
logger.exception(
"MCP tool '{}' failed after retry: {}",
self._name,
type(exc).__name__,
exc,
)
return f"(MCP tool call failed after retry: {type(exc).__name__})"
logger.exception(
@ -287,11 +286,10 @@ class MCPResourceWrapper(Tool):
)
await asyncio.sleep(1)
continue
logger.error(
"MCP resource '{}' failed after retry: {}: {}",
logger.exception(
"MCP resource '{}' failed after retry: {}",
self._name,
type(exc).__name__,
exc,
)
return f"(MCP resource read failed after retry: {type(exc).__name__})"
logger.exception(
@ -383,7 +381,7 @@ class MCPPromptWrapper(Tool):
logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name)
return "(MCP prompt call was cancelled)"
except McpError as exc:
logger.error(
logger.exception(
"MCP prompt '{}' failed: code={} message={}",
self._name,
exc.error.code,
@ -400,11 +398,10 @@ class MCPPromptWrapper(Tool):
)
await asyncio.sleep(1)
continue
logger.error(
"MCP prompt '{}' failed after retry: {}: {}",
logger.exception(
"MCP prompt '{}' failed after retry: {}",
self._name,
type(exc).__name__,
exc,
)
return f"(MCP prompt call failed after retry: {type(exc).__name__})"
logger.exception(
@ -439,8 +436,8 @@ async def connect_mcp_servers(
"""Connect to configured MCP servers and register their tools, resources, prompts.
Returns a dict mapping server name -> its dedicated AsyncExitStack.
Each server gets its own stack and runs in its own task to prevent
cancel scope conflicts when multiple MCP servers are configured.
Each server gets its own stack to prevent cancel scope conflicts
when multiple MCP servers are configured.
"""
from mcp import ClientSession, StdioServerParameters
from mcp.client.sse import sse_client
@ -608,26 +605,20 @@ async def connect_mcp_servers(
" Hint: this looks like stdio protocol pollution. Make sure the MCP server writes "
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
)
logger.error("MCP server '{}': failed to connect: {}{}", name, e, hint)
logger.exception("MCP server '{}': failed to connect: {}", name, hint)
with suppress(Exception):
await server_stack.aclose()
return name, None
server_stacks: dict[str, AsyncExitStack] = {}
tasks: list[asyncio.Task] = []
for name, cfg in mcp_servers.items():
task = asyncio.create_task(connect_single_server(name, cfg))
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
for i, result in enumerate(results):
name = list(mcp_servers.keys())[i]
if isinstance(result, BaseException):
if not isinstance(result, asyncio.CancelledError):
logger.error("MCP server '{}' connection task failed: {}", name, result)
elif result is not None and result[1] is not None:
try:
result = await connect_single_server(name, cfg)
except Exception as e:
logger.error("MCP server '{}' connection failed: {}", name, e)
continue
if result is not None and result[1] is not None:
server_stacks[result[0]] = result[1]
return server_stacks

View File

@ -19,6 +19,16 @@ from nanobot.config.paths import get_media_dir
_IS_WINDOWS = sys.platform == "win32"
# Policy note appended to recoverable workspace-boundary guard errors.
_WORKSPACE_BOUNDARY_NOTE = (
"\n\nNote: this is a hard policy boundary, not a transient failure. "
"Do NOT retry with shell tricks (symlinks, base64 piping, alternative "
"tools, working_dir overrides). If the user genuinely needs this "
"resource, tell them you cannot reach it under the current "
"restrict_to_workspace policy and ask how to proceed."
)
@tool_parameters(
tool_parameters_schema(
command=StringSchema("The shell command to execute"),
@ -83,6 +93,19 @@ class ExecTool(Tool):
_MAX_TIMEOUT = 600
_MAX_OUTPUT = 10_000
# Kernel device files safe as stdio redirect targets (#3599).
_BENIGN_DEVICE_PATHS: frozenset[str] = frozenset({
"/dev/null",
"/dev/zero",
"/dev/full",
"/dev/random",
"/dev/urandom",
"/dev/stdin",
"/dev/stdout",
"/dev/stderr",
"/dev/tty",
})
@property
def description(self) -> str:
return (
@ -113,9 +136,15 @@ class ExecTool(Tool):
requested = Path(cwd).expanduser().resolve()
workspace_root = Path(self.working_dir).expanduser().resolve()
except Exception:
return "Error: working_dir could not be resolved"
return (
"Error: working_dir could not be resolved"
+ _WORKSPACE_BOUNDARY_NOTE
)
if requested != workspace_root and workspace_root not in requested.parents:
return "Error: working_dir is outside the configured workspace"
return (
"Error: working_dir is outside the configured workspace"
+ _WORKSPACE_BOUNDARY_NOTE
)
guard_error = self._guard_command(command, cwd)
if guard_error:
@ -191,9 +220,12 @@ class ExecTool(Tool):
) -> asyncio.subprocess.Process:
"""Launch *command* in a platform-appropriate shell."""
if _IS_WINDOWS:
comspec = env.get("COMSPEC", os.environ.get("COMSPEC", "cmd.exe"))
return await asyncio.create_subprocess_exec(
comspec, "/c", command,
# create_subprocess_exec re-quotes args via list2cmdline, which
# breaks commands containing paths with spaces (e.g. "D:\Program
# Files\python.exe" "script.py"). create_subprocess_shell passes
# the raw command string to COMSPEC without re-quoting.
return await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
@ -289,21 +321,33 @@ class ExecTool(Tool):
from nanobot.security.network import contains_internal_url
if contains_internal_url(cmd):
# The runner turns this marker into a non-retryable security hint.
return "Error: Command blocked by safety guard (internal/private URL detected)"
if self.restrict_to_workspace:
if "..\\" in cmd or "../" in cmd:
return "Error: Command blocked by safety guard (path traversal detected)"
return (
"Error: Command blocked by safety guard (path traversal detected)"
+ _WORKSPACE_BOUNDARY_NOTE
)
cwd_path = Path(cwd).resolve()
for raw in self._extract_absolute_paths(cmd):
try:
expanded = os.path.expandvars(raw.strip())
# Match against the un-resolved path first. On Linux,
# /dev/stderr is a symlink to /proc/self/fd/2 and
# ``Path.resolve()`` would mask the device-file intent.
if self._is_benign_device_path(expanded):
continue
p = Path(expanded).expanduser().resolve()
except Exception:
continue
if self._is_benign_device_path(str(p)):
continue
media_path = get_media_dir().resolve()
if (p.is_absolute()
and cwd_path not in p.parents
@ -311,15 +355,25 @@ class ExecTool(Tool):
and media_path not in p.parents
and p != media_path
):
return "Error: Command blocked by safety guard (path outside working dir)"
return (
"Error: Command blocked by safety guard (path outside working dir)"
+ _WORKSPACE_BOUNDARY_NOTE
)
return None
@classmethod
def _is_benign_device_path(cls, path: str) -> bool:
"""Return True for kernel device files that should never be workspace-blocked."""
if path in cls._BENIGN_DEVICE_PATHS:
return True
return path.startswith("/dev/fd/")
@staticmethod
def _extract_absolute_paths(command: str) -> list[str]:
# Windows: match drive-root paths like `C:\` as well as `C:\path\to\file`
# NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted.
win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]*", command)
posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
home_paths = re.findall(r"(?:^|[\s|>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~
home_paths = re.findall(r"(?:^|[\s>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~
return win_paths + posix_paths + home_paths

View File

@ -500,10 +500,10 @@ class WebFetchTool(Tool):
"untrusted": True, "text": text,
}, ensure_ascii=False)
except httpx.ProxyError as e:
logger.error("WebFetch proxy error for {}: {}", url, e)
logger.exception("WebFetch proxy error for {}", url)
return json.dumps({"error": f"Proxy error: {e}", "url": url}, ensure_ascii=False)
except Exception as e:
logger.error("WebFetch error for {}: {}", url, e)
logger.exception("WebFetch error for {}", url)
return json.dumps({"error": str(e), "url": url}, ensure_ascii=False)
def _to_markdown(self, html_content: str) -> str:

View File

@ -38,6 +38,7 @@ class BaseChannel(ABC):
bus: The message bus for communication.
"""
self.config = config
self.logger = logger.bind(channel=self.name)
self.bus = bus
self._running = False
@ -61,8 +62,8 @@ class BaseChannel(ABC):
language=self.transcription_language or None,
)
return await provider.transcribe(file_path)
except Exception as e:
logger.warning("{}: audio transcription failed: {}", self.name, e)
except Exception:
self.logger.exception("Audio transcription failed")
return ""
async def login(self, force: bool = False) -> bool:
@ -136,7 +137,7 @@ class BaseChannel(ABC):
else:
allow_list = getattr(self.config, "allow_from", [])
if not allow_list:
logger.warning("{}: allow_from is empty — all access denied", self.name)
self.logger.warning("allow_from is empty — all access denied")
return False
if "*" in allow_list:
return True
@ -165,10 +166,10 @@ class BaseChannel(ABC):
session_key: Optional session key override (e.g. thread-scoped sessions).
"""
if not self.is_allowed(sender_id):
logger.warning(
"Access denied for sender {} on channel {}. "
self.logger.warning(
"Access denied for sender {}. "
"Add them to allowFrom list in config to grant access.",
sender_id, self.name,
sender_id,
)
return

View File

@ -12,7 +12,6 @@ from typing import Any
from urllib.parse import unquote, urljoin, urlparse
import httpx
from loguru import logger
from pydantic import Field
from nanobot.bus.events import OutboundMessage
@ -113,7 +112,7 @@ class NanobotDingTalkHandler(CallbackHandler):
content = content + "\n\nReceived files:\n" + file_list
if not content:
logger.warning(
self.channel.logger.warning(
"Received empty or unsupported message type: {}",
chatbot_msg.message_type,
)
@ -128,7 +127,7 @@ class NanobotDingTalkHandler(CallbackHandler):
or message.data.get("openConversationId")
)
logger.info("Received DingTalk message from {} ({}): {}", sender_name, sender_id, content)
self.channel.logger.info("Received message from {} ({}): {}", sender_name, sender_id, content)
# Forward to Nanobot via _on_message (non-blocking).
# Store reference to prevent GC before task completes.
@ -146,8 +145,8 @@ class NanobotDingTalkHandler(CallbackHandler):
return AckMessage.STATUS_OK, "OK"
except Exception as e:
logger.error("Error processing DingTalk message: {}", e)
except Exception:
self.channel.logger.exception("Error processing message")
# Return OK to avoid retry loop from DingTalk server
return AckMessage.STATUS_OK, "Error"
@ -204,20 +203,20 @@ class DingTalkChannel(BaseChannel):
"""Start the DingTalk bot with Stream Mode."""
try:
if not DINGTALK_AVAILABLE:
logger.error(
"DingTalk Stream SDK not installed. Run: pip install dingtalk-stream"
self.logger.error(
"Stream SDK not installed. Run: pip install dingtalk-stream"
)
return
if not self.config.client_id or not self.config.client_secret:
logger.error("DingTalk client_id and client_secret not configured")
self.logger.error("client_id and client_secret not configured")
return
self._running = True
self._http = httpx.AsyncClient()
logger.info(
"Initializing DingTalk Stream Client with Client ID: {}...",
self.logger.info(
"Initializing Stream Client with Client ID: {}...",
self.config.client_id,
)
credential = Credential(self.config.client_id, self.config.client_secret)
@ -227,20 +226,20 @@ class DingTalkChannel(BaseChannel):
handler = NanobotDingTalkHandler(self)
self._client.register_callback_handler(ChatbotMessage.TOPIC, handler)
logger.info("DingTalk bot started with Stream Mode")
self.logger.info("bot started with Stream Mode")
# Reconnect loop: restart stream if SDK exits or crashes
while self._running:
try:
await self._client.start()
except Exception as e:
logger.warning("DingTalk stream error: {}", e)
self.logger.warning("stream error: {}", e)
if self._running:
logger.info("Reconnecting DingTalk stream in 5 seconds...")
self.logger.info("Reconnecting stream in 5 seconds...")
await asyncio.sleep(5)
except Exception as e:
logger.exception("Failed to start DingTalk channel: {}", e)
except Exception:
self.logger.exception("Failed to start channel")
async def stop(self) -> None:
"""Stop the DingTalk bot."""
@ -266,7 +265,7 @@ class DingTalkChannel(BaseChannel):
}
if not self._http:
logger.warning("DingTalk HTTP client not initialized, cannot refresh token")
self.logger.warning("HTTP client not initialized, cannot refresh token")
return None
try:
@ -277,8 +276,8 @@ class DingTalkChannel(BaseChannel):
# Expire 60s early to be safe
self._token_expiry = time.time() + int(res_data.get("expireIn", 7200)) - 60
return self._access_token
except Exception as e:
logger.error("Failed to get DingTalk access token: {}", e)
except Exception:
self.logger.exception("Failed to get access token")
return None
@staticmethod
@ -317,8 +316,8 @@ class DingTalkChannel(BaseChannel):
) -> tuple[bytes, str, str | None]:
ext = Path(filename).suffix.lower()
if ext in self._ZIP_BEFORE_UPLOAD_EXTS or content_type == "text/html":
logger.info(
"DingTalk does not accept raw HTML attachments, zipping {} before upload",
self.logger.info(
"does not accept raw HTML attachments, zipping {} before upload",
filename,
)
return self._zip_bytes(filename, data)
@ -327,7 +326,7 @@ class DingTalkChannel(BaseChannel):
def _validate_remote_media_url(self, media_ref: str) -> bool:
ok, err = validate_url_target(media_ref)
if not ok:
logger.warning("DingTalk remote media URL blocked ref={} reason={}", media_ref, err)
self.logger.warning("remote media URL blocked ref={} reason={}", media_ref, err)
return False
return True
@ -343,15 +342,15 @@ class DingTalkChannel(BaseChannel):
def _next_remote_media_url(self, current_url: str, location: str | None) -> str | None:
if not self.config.allow_remote_media_redirects:
logger.warning("DingTalk media download redirect refused ref={}", current_url)
self.logger.warning("media download redirect refused ref={}", current_url)
return None
if not location:
logger.warning("DingTalk media download redirect without Location ref={}", current_url)
self.logger.warning("media download redirect without Location ref={}", current_url)
return None
next_url = urljoin(current_url, location)
if not self._redirect_host_allowed(current_url, next_url):
logger.warning(
"DingTalk media download cross-host redirect refused ref={} next={}",
self.logger.warning(
"media download cross-host redirect refused ref={} next={}",
current_url,
next_url,
)
@ -382,8 +381,8 @@ class DingTalkChannel(BaseChannel):
async with stream("GET", current_url, follow_redirects=False) as resp:
final_ok, final_err = validate_resolved_url(str(resp.url))
if not final_ok:
logger.warning(
"DingTalk remote media redirect blocked ref={} final={} reason={}",
self.logger.warning(
"remote media redirect blocked ref={} final={} reason={}",
media_ref,
resp.url,
final_err,
@ -398,8 +397,8 @@ class DingTalkChannel(BaseChannel):
current_url = next_url
continue
if resp.status_code >= 400:
logger.warning(
"DingTalk media download failed status={} ref={}",
self.logger.warning(
"media download failed status={} ref={}",
resp.status_code,
current_url,
)
@ -409,15 +408,15 @@ class DingTalkChannel(BaseChannel):
async for chunk in resp.aiter_bytes():
total += len(chunk)
if total > DINGTALK_MAX_REMOTE_MEDIA_BYTES:
logger.warning(
"DingTalk media download too large ref={} bytes>{}",
self.logger.warning(
"media download too large ref={} bytes>{}",
current_url,
DINGTALK_MAX_REMOTE_MEDIA_BYTES,
)
return None, None
chunks.append(chunk)
return b"".join(chunks), (resp.headers.get("content-type") or "")
logger.warning("DingTalk media download exceeded redirect limit ref={}", media_ref)
self.logger.warning("media download exceeded redirect limit ref={}", media_ref)
return None, None
current_url = media_ref
@ -425,8 +424,8 @@ class DingTalkChannel(BaseChannel):
resp = await self._http.get(current_url, follow_redirects=False)
final_ok, final_err = validate_resolved_url(str(getattr(resp, "url", current_url)))
if not final_ok:
logger.warning(
"DingTalk remote media redirect blocked ref={} final={} reason={}",
self.logger.warning(
"remote media redirect blocked ref={} final={} reason={}",
media_ref,
getattr(resp, "url", current_url),
final_err,
@ -441,27 +440,27 @@ class DingTalkChannel(BaseChannel):
current_url = next_url
continue
if resp.status_code >= 400:
logger.warning(
"DingTalk media download failed status={} ref={}",
self.logger.warning(
"media download failed status={} ref={}",
resp.status_code,
current_url,
)
return None, None
if len(resp.content) > DINGTALK_MAX_REMOTE_MEDIA_BYTES:
logger.warning(
"DingTalk media download too large ref={} bytes>{}",
self.logger.warning(
"media download too large ref={} bytes>{}",
current_url,
DINGTALK_MAX_REMOTE_MEDIA_BYTES,
)
return None, None
return resp.content, (resp.headers.get("content-type") or "")
logger.warning("DingTalk media download exceeded redirect limit ref={}", media_ref)
self.logger.warning("media download exceeded redirect limit ref={}", media_ref)
return None, None
except httpx.TransportError as e:
logger.error("DingTalk media download network error ref={} err={}", media_ref, e)
except httpx.TransportError:
self.logger.exception("media download network error ref={}", media_ref)
raise
except Exception as e:
logger.error("DingTalk media download error ref={} err={}", media_ref, e)
except Exception:
self.logger.exception("media download error ref={}", media_ref)
return None, None
async def _read_media_bytes(
@ -486,13 +485,13 @@ class DingTalkChannel(BaseChannel):
else:
local_path = Path(os.path.expanduser(media_ref))
if not local_path.is_file():
logger.warning("DingTalk media file not found: {}", local_path)
self.logger.warning("media file not found: {}", local_path)
return None, None, None
data = await asyncio.to_thread(local_path.read_bytes)
content_type = mimetypes.guess_type(local_path.name)[0]
return data, local_path.name, content_type
except Exception as e:
logger.error("DingTalk media read error ref={} err={}", media_ref, e)
except Exception:
self.logger.exception("media read error ref={}", media_ref)
return None, None, None
async def _upload_media(
@ -514,23 +513,23 @@ class DingTalkChannel(BaseChannel):
text = resp.text
result = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
if resp.status_code >= 400:
logger.error("DingTalk media upload failed status={} type={} body={}", resp.status_code, media_type, text[:500])
self.logger.error("media upload failed status={} type={} body={}", resp.status_code, media_type, text[:500])
return None
errcode = result.get("errcode", 0)
if errcode != 0:
logger.error("DingTalk media upload api error type={} errcode={} body={}", media_type, errcode, text[:500])
self.logger.error("media upload api error type={} errcode={} body={}", media_type, errcode, text[:500])
return None
sub = result.get("result") or {}
media_id = result.get("media_id") or result.get("mediaId") or sub.get("media_id") or sub.get("mediaId")
if not media_id:
logger.error("DingTalk media upload missing media_id body={}", text[:500])
self.logger.error("media upload missing media_id body={}", text[:500])
return None
return str(media_id)
except httpx.TransportError as e:
logger.error("DingTalk media upload network error type={} err={}", media_type, e)
except httpx.TransportError:
self.logger.exception("media upload network error type={}", media_type)
raise
except Exception as e:
logger.error("DingTalk media upload error type={} err={}", media_type, e)
except Exception:
self.logger.exception("media upload error type={}", media_type)
return None
async def _send_batch_message(
@ -541,7 +540,7 @@ class DingTalkChannel(BaseChannel):
msg_param: dict[str, Any],
) -> bool:
if not self._http:
logger.warning("DingTalk HTTP client not initialized, cannot send")
self.logger.warning("HTTP client not initialized, cannot send")
return False
headers = {"x-acs-dingtalk-access-token": token}
@ -568,7 +567,7 @@ class DingTalkChannel(BaseChannel):
resp = await self._http.post(url, json=payload, headers=headers)
body = resp.text
if resp.status_code != 200:
logger.error("DingTalk send failed msgKey={} status={} body={}", msg_key, resp.status_code, body[:500])
self.logger.error("send failed msgKey={} status={} body={}", msg_key, resp.status_code, body[:500])
return False
try:
result = resp.json()
@ -576,15 +575,15 @@ class DingTalkChannel(BaseChannel):
result = {}
errcode = result.get("errcode")
if errcode not in (None, 0):
logger.error("DingTalk send api error msgKey={} errcode={} body={}", msg_key, errcode, body[:500])
self.logger.error("send api error msgKey={} errcode={} body={}", msg_key, errcode, body[:500])
return False
logger.debug("DingTalk message sent to {} with msgKey={}", chat_id, msg_key)
self.logger.debug("message sent to {} with msgKey={}", chat_id, msg_key)
return True
except httpx.TransportError as e:
logger.error("DingTalk network error sending message msgKey={} err={}", msg_key, e)
except httpx.TransportError:
self.logger.exception("network error sending message msgKey={}", msg_key)
raise
except Exception as e:
logger.error("Error sending DingTalk message msgKey={} err={}", msg_key, e)
except Exception:
self.logger.exception("Error sending message msgKey={}", msg_key)
return False
async def _send_markdown_text(self, token: str, chat_id: str, content: str) -> bool:
@ -610,11 +609,11 @@ class DingTalkChannel(BaseChannel):
)
if ok:
return True
logger.warning("DingTalk image url send failed, trying upload fallback: {}", media_ref)
self.logger.warning("image url send failed, trying upload fallback: {}", media_ref)
data, filename, content_type = await self._read_media_bytes(media_ref)
if not data:
logger.error("DingTalk media read failed: {}", media_ref)
self.logger.error("media read failed: {}", media_ref)
return False
filename = filename or self._guess_filename(media_ref, upload_type)
@ -646,7 +645,7 @@ class DingTalkChannel(BaseChannel):
)
if ok:
return True
logger.warning("DingTalk image media_id send failed, falling back to file: {}", media_ref)
self.logger.warning("image media_id send failed, falling back to file: {}", media_ref)
return await self._send_batch_message(
token,
@ -668,7 +667,7 @@ class DingTalkChannel(BaseChannel):
ok = await self._send_media_ref(token, msg.chat_id, media_ref)
if ok:
continue
logger.error("DingTalk media send failed for {}", media_ref)
self.logger.error("media send failed for {}", media_ref)
# Send visible fallback so failures are observable by the user.
filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref))
await self._send_markdown_text(
@ -691,7 +690,7 @@ class DingTalkChannel(BaseChannel):
permission checks before publishing to the bus.
"""
try:
logger.info("DingTalk inbound: {} from {}", content, sender_name)
self.logger.info("inbound: {} from {}", content, sender_name)
is_group = conversation_type == "2" and conversation_id
chat_id = f"group:{conversation_id}" if is_group else sender_id
await self._handle_message(
@ -704,8 +703,8 @@ class DingTalkChannel(BaseChannel):
"conversation_type": conversation_type,
},
)
except Exception as e:
logger.error("Error publishing DingTalk message: {}", e)
except Exception:
self.logger.exception("Error publishing message")
async def _download_dingtalk_file(
self,
@ -719,7 +718,7 @@ class DingTalkChannel(BaseChannel):
try:
token = await self._get_access_token()
if not token or not self._http:
logger.error("DingTalk file download: no token or http client")
self.logger.error("file download: no token or http client")
return None
# Step 1: Exchange downloadCode for a temporary download URL
@ -728,19 +727,19 @@ class DingTalkChannel(BaseChannel):
payload = {"downloadCode": download_code, "robotCode": self.config.client_id}
resp = await self._http.post(api_url, json=payload, headers=headers)
if resp.status_code != 200:
logger.error("DingTalk get download URL failed: status={}, body={}", resp.status_code, resp.text)
self.logger.error("get download URL failed: status={}, body={}", resp.status_code, resp.text)
return None
result = resp.json()
download_url = result.get("downloadUrl")
if not download_url:
logger.error("DingTalk download URL not found in response: {}", result)
self.logger.error("download URL not found in response: {}", result)
return None
# Step 2: Download the file content
file_resp = await self._http.get(download_url, follow_redirects=True)
if file_resp.status_code != 200:
logger.error("DingTalk file download failed: status={}", file_resp.status_code)
self.logger.error("file download failed: status={}", file_resp.status_code)
return None
# Save to media directory (accessible under workspace)
@ -748,8 +747,8 @@ class DingTalkChannel(BaseChannel):
download_dir.mkdir(parents=True, exist_ok=True)
file_path = download_dir / filename
await asyncio.to_thread(file_path.write_bytes, file_resp.content)
logger.info("DingTalk file saved: {}", file_path)
self.logger.info("file saved: {}", file_path)
return str(file_path)
except Exception as e:
logger.error("DingTalk file download error: {}", e)
except Exception:
self.logger.exception("file download error")
return None

View File

@ -10,7 +10,6 @@ from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
from loguru import logger
from pydantic import Field
from nanobot.bus.events import OutboundMessage
@ -86,12 +85,12 @@ if DISCORD_AVAILABLE:
async def on_ready(self) -> None:
self._channel._bot_user_id = str(self.user.id) if self.user else None
logger.info("Discord bot connected as user {}", self._channel._bot_user_id)
self._channel.logger.info("bot connected as user {}", self._channel._bot_user_id)
try:
synced = await self.tree.sync()
logger.info("Discord app commands synced: {}", len(synced))
self._channel.logger.info("app commands synced: {}", len(synced))
except Exception as e:
logger.warning("Discord app command sync failed: {}", e)
self._channel.logger.warning("app command sync failed: {}", e)
async def on_message(self, message: discord.Message) -> None:
await self._channel._handle_discord_message(message)
@ -111,7 +110,7 @@ if DISCORD_AVAILABLE:
await interaction.response.send_message(text, ephemeral=True)
return True
except Exception as e:
logger.warning("Discord interaction response failed: {}", e)
self._channel.logger.warning("interaction response failed: {}", e)
return False
async def _resolve_interaction_channel(
@ -126,7 +125,7 @@ if DISCORD_AVAILABLE:
try:
channel = await self.fetch_channel(channel_id)
except Exception as e:
logger.warning("Discord interaction channel {} unavailable: {}", channel_id, e)
self._channel.logger.warning("interaction channel {} unavailable: {}", channel_id, e)
return None
self._channel._remember_channel(channel)
return channel
@ -154,7 +153,7 @@ if DISCORD_AVAILABLE:
channel_id = interaction.channel_id
if channel_id is None:
logger.warning("Discord slash command missing channel_id: {}", command_text)
self._channel.logger.warning("slash command missing channel_id: {}", command_text)
return
if not self._channel.is_allowed(sender_id):
@ -226,8 +225,8 @@ if DISCORD_AVAILABLE:
error: app_commands.AppCommandError,
) -> None:
command_name = interaction.command.qualified_name if interaction.command else "?"
logger.warning(
"Discord app command failed user={} channel={} cmd={} error={}",
self._channel.logger.warning(
"app command failed user={} channel={} cmd={} error={}",
interaction.user.id,
interaction.channel_id,
command_name,
@ -243,7 +242,7 @@ if DISCORD_AVAILABLE:
try:
channel = await self.fetch_channel(channel_id)
except Exception as e:
logger.warning("Discord channel {} unavailable: {}", msg.chat_id, e)
self._channel.logger.warning("channel {} unavailable: {}", msg.chat_id, e)
return
reference, mention_settings = self._build_reply_context(channel, msg.reply_to)
@ -281,11 +280,11 @@ if DISCORD_AVAILABLE:
"""Send a file attachment via discord.py."""
path = Path(file_path)
if not path.is_file():
logger.warning("Discord file not found, skipping: {}", file_path)
self._channel.logger.warning("file not found, skipping: {}", file_path)
return False
if path.stat().st_size > MAX_ATTACHMENT_BYTES:
logger.warning("Discord file too large (>20MB), skipping: {}", path.name)
self._channel.logger.warning("file too large (>20MB), skipping: {}", path.name)
return False
try:
@ -294,10 +293,10 @@ if DISCORD_AVAILABLE:
kwargs["reference"] = reference
kwargs["allowed_mentions"] = mention_settings
await channel.send(**kwargs)
logger.info("Discord file sent: {}", path.name)
self._channel.logger.info("file sent: {}", path.name)
return True
except Exception as e:
logger.error("Error sending Discord file {}: {}", path.name, e)
except Exception:
self._channel.logger.exception("Error sending file {}", path.name)
return False
@staticmethod
@ -321,7 +320,7 @@ if DISCORD_AVAILABLE:
try:
message_id = int(reply_to)
except ValueError:
logger.warning("Invalid Discord reply target: {}", reply_to)
self._channel.logger.warning("Invalid reply target: {}", reply_to)
return None, mention_settings
return channel.get_partial_message(message_id), mention_settings
@ -385,11 +384,11 @@ class DiscordChannel(BaseChannel):
async def start(self) -> None:
"""Start the Discord client."""
if not DISCORD_AVAILABLE:
logger.error("discord.py not installed. Run: pip install nanobot-ai[discord]")
self.logger.error("discord.py not installed. Run: pip install nanobot-ai[discord]")
return
if not self.config.token:
logger.error("Discord bot token not configured")
self.logger.error("bot token not configured")
return
try:
@ -407,8 +406,8 @@ class DiscordChannel(BaseChannel):
password=self.config.proxy_password,
)
elif has_user != has_pass:
logger.warning(
"Discord proxy auth incomplete: both proxy_username and "
self.logger.warning(
"proxy auth incomplete: both proxy_username and "
"proxy_password must be set; ignoring partial credentials",
)
@ -418,21 +417,21 @@ class DiscordChannel(BaseChannel):
proxy=self.config.proxy,
proxy_auth=proxy_auth,
)
except Exception as e:
logger.error("Failed to initialize Discord client: {}", e)
except Exception:
self.logger.exception("Failed to initialize client")
self._client = None
self._running = False
return
self._running = True
logger.info("Starting Discord client via discord.py...")
self.logger.info("Starting client via discord.py...")
try:
await self._client.start(self.config.token)
except asyncio.CancelledError:
raise
except Exception as e:
logger.error("Discord client startup failed: {}", e)
except Exception:
self.logger.exception("client startup failed")
finally:
self._running = False
await self._reset_runtime_state(close_client=True)
@ -446,15 +445,15 @@ class DiscordChannel(BaseChannel):
"""Send a message through Discord using discord.py."""
client = self._client
if client is None or not client.is_ready():
logger.warning("Discord client not ready; dropping outbound message")
self.logger.warning("client not ready; dropping outbound message")
return
is_progress = bool((msg.metadata or {}).get("_progress"))
try:
await client.send_outbound(msg)
except Exception as e:
logger.error("Error sending Discord message: {}", e)
except Exception:
self.logger.exception("Error sending message")
raise
finally:
if not is_progress:
@ -467,7 +466,7 @@ class DiscordChannel(BaseChannel):
"""Progressive Discord delivery: send once, then edit until the stream ends."""
client = self._client
if client is None or not client.is_ready():
logger.warning("Discord client not ready; dropping stream delta")
self.logger.warning("client not ready; dropping stream delta")
return
meta = metadata or {}
@ -497,7 +496,7 @@ class DiscordChannel(BaseChannel):
target = await self._resolve_channel(chat_id)
if target is None:
logger.warning("Discord stream target {} unavailable", chat_id)
self.logger.warning("stream target {} unavailable", chat_id)
return
now = time.monotonic()
@ -506,7 +505,7 @@ class DiscordChannel(BaseChannel):
buf.message = await target.send(content=buf.text)
buf.last_edit = now
except Exception as e:
logger.warning("Discord stream initial send failed: {}", e)
self.logger.warning("stream initial send failed: {}", e)
raise
return
@ -517,7 +516,7 @@ class DiscordChannel(BaseChannel):
await buf.message.edit(content=DiscordBotClient._build_chunks(buf.text, [], False)[0])
buf.last_edit = now
except Exception as e:
logger.warning("Discord stream edit failed: {}", e)
self.logger.warning("stream edit failed: {}", e)
raise
async def _handle_discord_message(self, message: discord.Message) -> None:
@ -560,7 +559,7 @@ class DiscordChannel(BaseChannel):
await message.add_reaction(self.config.read_receipt_emoji)
self._pending_reactions[channel_id] = message
except Exception as e:
logger.debug("Failed to add read receipt reaction: {}", e)
self.logger.debug("Failed to add read receipt reaction: {}", e)
# Delayed working indicator (cosmetic — not tied to subagent lifecycle)
async def _delayed_working_emoji() -> None:
@ -603,7 +602,7 @@ class DiscordChannel(BaseChannel):
try:
return await client.fetch_channel(channel_id)
except Exception as e:
logger.warning("Discord channel {} unavailable: {}", chat_id, e)
self.logger.warning("channel {} unavailable: {}", chat_id, e)
return None
async def _finalize_stream(self, chat_id: str, buf: _StreamBuf) -> None:
@ -616,12 +615,12 @@ class DiscordChannel(BaseChannel):
try:
await buf.message.edit(content=chunks[0])
except Exception as e:
logger.warning("Discord final stream edit failed: {}", e)
self.logger.warning("final stream edit failed: {}", e)
raise
target = getattr(buf.message, "channel", None) or await self._resolve_channel(chat_id)
if target is None:
logger.warning("Discord stream follow-up target {} unavailable", chat_id)
self.logger.warning("stream follow-up target {} unavailable", chat_id)
self._stream_bufs.pop(chat_id, None)
return
@ -673,7 +672,7 @@ class DiscordChannel(BaseChannel):
media_paths.append(str(file_path))
markers.append(f"[attachment: {file_path.name}]")
except Exception as e:
logger.warning("Failed to download Discord attachment: {}", e)
self.logger.warning("Failed to download attachment: {}", e)
markers.append(f"[attachment: {filename} - download failed]")
return media_paths, markers
@ -715,8 +714,8 @@ class DiscordChannel(BaseChannel):
if bot_user_id is None and self._client and self._client.user:
bot_user_id = str(self._client.user.id)
if bot_user_id is None:
logger.debug(
"Discord message in {} ignored (bot identity unavailable)", message.channel.id
self.logger.debug(
"message in {} ignored (bot identity unavailable)", message.channel.id
)
return False
@ -729,7 +728,7 @@ class DiscordChannel(BaseChannel):
if self._references_bot_message(message, bot_user_id):
return True
logger.debug("Discord message in {} ignored (bot not mentioned)", message.channel.id)
self.logger.debug("message in {} ignored (bot not mentioned)", message.channel.id)
return False
return True
@ -759,7 +758,7 @@ class DiscordChannel(BaseChannel):
except asyncio.CancelledError:
return
except Exception as e:
logger.debug("Discord typing indicator failed for {}: {}", channel_id, e)
self.logger.debug("typing indicator failed for {}: {}", channel_id, e)
return
self._typing_tasks[channel_id] = asyncio.create_task(typing_loop())
@ -803,6 +802,6 @@ class DiscordChannel(BaseChannel):
try:
await self._client.close()
except Exception as e:
logger.warning("Discord client close failed: {}", e)
self.logger.warning("client close failed: {}", e)
self._client = None
self._bot_user_id = None

View File

@ -128,7 +128,7 @@ class EmailChannel(BaseChannel):
async def start(self) -> None:
"""Start polling IMAP for inbound emails."""
if not self.config.consent_granted:
logger.warning(
self.logger.warning(
"Email channel disabled: consent_granted is false. "
"Set channels.email.consentGranted=true after explicit user permission."
)
@ -139,12 +139,12 @@ class EmailChannel(BaseChannel):
self._running = True
if not self.config.verify_dkim and not self.config.verify_spf:
logger.warning(
"Email channel: DKIM and SPF verification are both DISABLED. "
self.logger.warning(
"DKIM and SPF verification are both DISABLED. "
"Emails with spoofed From headers will be accepted. "
"Set verify_dkim=true and verify_spf=true for anti-spoofing protection."
)
logger.info("Starting Email channel (IMAP polling mode)...")
self.logger.info("Starting Email channel (IMAP polling mode)...")
poll_seconds = max(5, int(self.config.poll_interval_seconds))
while self._running:
@ -167,8 +167,8 @@ class EmailChannel(BaseChannel):
media=item.get("media") or None,
metadata=item.get("metadata", {}),
)
except Exception as e:
logger.error("Email polling error: {}", e)
except Exception:
self.logger.exception("Polling error")
await asyncio.sleep(poll_seconds)
@ -179,16 +179,16 @@ class EmailChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None:
"""Send email via SMTP."""
if not self.config.consent_granted:
logger.warning("Skip email send: consent_granted is false")
self.logger.warning("Skip email send: consent_granted is false")
return
if not self.config.smtp_host:
logger.warning("Email channel SMTP host not configured")
self.logger.warning("SMTP host not configured")
return
to_addr = msg.chat_id.strip()
if not to_addr:
logger.warning("Email channel missing recipient address")
self.logger.warning("Missing recipient address")
return
# Determine if this is a reply (recipient has sent us an email before)
@ -197,7 +197,7 @@ class EmailChannel(BaseChannel):
# autoReplyEnabled only controls automatic replies, not proactive sends
if is_reply and not self.config.auto_reply_enabled and not force_send:
logger.info("Skip automatic email reply to {}: auto_reply_enabled is false", to_addr)
self.logger.info("Skip automatic reply to {}: auto_reply_enabled is false", to_addr)
return
base_subject = self._last_subject_by_chat.get(to_addr, "nanobot reply")
@ -220,8 +220,8 @@ class EmailChannel(BaseChannel):
try:
await asyncio.to_thread(self._smtp_send, email_msg)
except Exception as e:
logger.error("Error sending email to {}: {}", to_addr, e)
except Exception:
self.logger.exception("Error sending to {}", to_addr)
raise
def _validate_config(self) -> bool:
@ -240,7 +240,7 @@ class EmailChannel(BaseChannel):
missing.append("smtp_password")
if missing:
logger.error("Email channel not configured, missing: {}", ', '.join(missing))
self.logger.error("Channel not configured, missing: {}", ', '.join(missing))
return False
return True
@ -321,7 +321,7 @@ class EmailChannel(BaseChannel):
except Exception as exc:
if attempt == 1 or not self._is_stale_imap_error(exc):
raise
logger.warning("Email IMAP connection went stale, retrying once: {}", exc)
self.logger.warning("IMAP connection went stale, retrying once: {}", exc)
return messages
@ -348,11 +348,11 @@ class EmailChannel(BaseChannel):
status, _ = client.select(mailbox)
except Exception as exc:
if self._is_missing_mailbox_error(exc):
logger.warning("Email mailbox unavailable, skipping poll for {}: {}", mailbox, exc)
self.logger.warning("Mailbox unavailable, skipping poll for {}: {}", mailbox, exc)
return messages
raise
if status != "OK":
logger.warning("Email mailbox select returned {}, skipping poll for {}", status, mailbox)
self.logger.warning("Mailbox select returned {}, skipping poll for {}", status, mailbox)
return messages
status, data = client.search(None, *search_criteria)
@ -382,7 +382,7 @@ class EmailChannel(BaseChannel):
if not sender:
continue
if self._is_self_address(sender):
logger.info("Email from {} ignored: matches bot-owned address", sender)
self.logger.info("From {} ignored: matches bot-owned address", sender)
self._remember_processed_uid(uid, dedupe, cycle_uids)
if mark_seen:
client.store(imap_id, "+FLAGS", "\\Seen")
@ -391,22 +391,28 @@ class EmailChannel(BaseChannel):
# --- Anti-spoofing: verify Authentication-Results ---
spf_pass, dkim_pass = self._check_authentication_results(parsed)
if self.config.verify_spf and not spf_pass:
logger.warning(
"Email from {} rejected: SPF verification failed "
self.logger.warning(
"From {} rejected: SPF verification failed "
"(no 'spf=pass' in Authentication-Results header)",
sender,
)
self._remember_processed_uid(uid, dedupe, cycle_uids)
continue
if self.config.verify_dkim and not dkim_pass:
logger.warning(
"Email from {} rejected: DKIM verification failed "
self.logger.warning(
"From {} rejected: DKIM verification failed "
"(no 'dkim=pass' in Authentication-Results header)",
sender,
)
self._remember_processed_uid(uid, dedupe, cycle_uids)
continue
if not self.is_allowed(sender):
self._remember_processed_uid(uid, dedupe, cycle_uids)
if mark_seen:
client.store(imap_id, "+FLAGS", "\\Seen")
continue
subject = self._decode_header_value(parsed.get("Subject", ""))
date_value = parsed.get("Date", "")
message_id = parsed.get("Message-ID", "").strip()
@ -635,7 +641,7 @@ class EmailChannel(BaseChannel):
content_type = part.get_content_type()
if not any(fnmatch(content_type, pat) for pat in allowed_types):
logger.debug("Email attachment skipped (type {}): not in allowed list", content_type)
logger.debug("Attachment skipped (type {}): not in allowed list", content_type)
continue
payload = part.get_payload(decode=True)
@ -643,7 +649,7 @@ class EmailChannel(BaseChannel):
continue
if len(payload) > max_size:
logger.warning(
"Email attachment skipped: size {} exceeds limit {}",
"Attachment skipped: size {} exceeds limit {}",
len(payload),
max_size,
)
@ -656,9 +662,9 @@ class EmailChannel(BaseChannel):
try:
dest.write_bytes(payload)
saved.append(dest)
logger.info("Email attachment saved: {}", dest)
logger.info("Attachment saved: {}", dest)
except Exception as exc:
logger.warning("Failed to save email attachment {}: {}", dest, exc)
logger.warning("Failed to save attachment {}: {}", dest, exc)
return saved

View File

@ -15,7 +15,6 @@ from typing import Any, Literal
from lark_oapi.api.im.v1.model import MentionEvent, P2ImMessageReceiveV1
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
from loguru import logger
from pydantic import Field
from nanobot.bus.events import OutboundMessage
@ -23,6 +22,7 @@ from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
from nanobot.utils.logging_bridge import redirect_lib_logging
FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
@ -320,15 +320,17 @@ class FeishuChannel(BaseChannel):
async def start(self) -> None:
"""Start the Feishu bot with WebSocket long connection."""
if not FEISHU_AVAILABLE:
logger.error("Feishu SDK not installed. Run: pip install lark-oapi")
self.logger.error("SDK not installed. Run: pip install lark-oapi")
return
if not self.config.app_id or not self.config.app_secret:
logger.error("Feishu app_id and app_secret not configured")
self.logger.error("app_id and app_secret not configured")
return
import lark_oapi as lark
redirect_lib_logging("Lark")
self._running = True
self._loop = asyncio.get_running_loop()
@ -390,7 +392,7 @@ class FeishuChannel(BaseChannel):
try:
self._ws_client.start()
except Exception as e:
logger.warning("Feishu WebSocket error: {}", e)
self.logger.warning("WebSocket error: {}", e)
if self._running:
time.sleep(5)
finally:
@ -404,12 +406,12 @@ class FeishuChannel(BaseChannel):
None, self._fetch_bot_open_id
)
if self._bot_open_id:
logger.info("Feishu bot open_id: {}", self._bot_open_id)
self.logger.info("bot open_id: {}", self._bot_open_id)
else:
logger.warning("Could not fetch bot open_id; @mention matching may be inaccurate")
self.logger.warning("Could not fetch bot open_id; @mention matching may be inaccurate")
logger.info("Feishu bot started with WebSocket long connection")
logger.info("No public IP required - using WebSocket to receive events")
self.logger.info("bot started with WebSocket long connection")
self.logger.info("No public IP required - using WebSocket to receive events")
# Keep running until stopped
while self._running:
@ -424,7 +426,7 @@ class FeishuChannel(BaseChannel):
Reference: https://github.com/larksuite/oapi-sdk-python/blob/v2_main/lark_oapi/ws/client.py#L86
"""
self._running = False
logger.info("Feishu bot stopped")
self.logger.info("bot stopped")
def _fetch_bot_open_id(self) -> str | None:
"""Fetch the bot's own open_id via GET /open-apis/bot/v3/info."""
@ -445,10 +447,10 @@ class FeishuChannel(BaseChannel):
data = json.loads(response.raw.content)
bot = (data.get("data") or data).get("bot") or data.get("bot") or {}
return bot.get("open_id")
logger.warning("Failed to get bot info: code={}, msg={}", response.code, response.msg)
self.logger.warning("Failed to get bot info: code={}, msg={}", response.code, response.msg)
return None
except Exception as e:
logger.warning("Error fetching bot info: {}", e)
self.logger.warning("Error fetching bot info: {}", e)
return None
@staticmethod
@ -539,15 +541,15 @@ class FeishuChannel(BaseChannel):
response = self._client.im.v1.message_reaction.create(request)
if not response.success():
logger.warning(
self.logger.warning(
"Failed to add reaction: code={}, msg={}", response.code, response.msg
)
return None
else:
logger.debug("Added {} reaction to message {}", emoji_type, message_id)
self.logger.debug("Added {} reaction to message {}", emoji_type, message_id)
return response.data.reaction_id if response.data else None
except Exception as e:
logger.warning("Error adding reaction: {}", e)
self.logger.warning("Error adding reaction: {}", e)
return None
async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> str | None:
@ -579,13 +581,13 @@ class FeishuChannel(BaseChannel):
response = self._client.im.v1.message_reaction.delete(request)
if response.success():
logger.debug("Removed reaction {} from message {}", reaction_id, message_id)
self.logger.debug("Removed reaction {} from message {}", reaction_id, message_id)
else:
logger.debug(
self.logger.debug(
"Failed to remove reaction: code={}, msg={}", response.code, response.msg
)
except Exception as e:
logger.debug("Error removing reaction: {}", e)
self.logger.debug("Error removing reaction: {}", e)
async def _remove_reaction(self, message_id: str, reaction_id: str) -> None:
"""
@ -607,7 +609,7 @@ class FeishuChannel(BaseChannel):
try:
task.result()
except Exception as exc:
logger.warning("Background task failed: {}", exc)
self.logger.warning("Background task failed: {}", exc)
def _on_reaction_added(self, message_id: str, task: asyncio.Task) -> None:
"""Callback: store reaction_id after background add-reaction completes."""
@ -917,15 +919,15 @@ class FeishuChannel(BaseChannel):
response = self._client.im.v1.image.create(request)
if response.success():
image_key = response.data.image_key
logger.debug("Uploaded image {}: {}", os.path.basename(file_path), image_key)
self.logger.debug("Uploaded image {}: {}", os.path.basename(file_path), image_key)
return image_key
else:
logger.error(
self.logger.error(
"Failed to upload image: code={}, msg={}", response.code, response.msg
)
return None
except Exception as e:
logger.error("Error uploading image {}: {}", file_path, e)
except Exception:
self.logger.exception("Error uploading image {}", file_path)
return None
def _upload_file_sync(self, file_path: str) -> str | None:
@ -951,15 +953,15 @@ class FeishuChannel(BaseChannel):
response = self._client.im.v1.file.create(request)
if response.success():
file_key = response.data.file_key
logger.debug("Uploaded file {}: {}", file_name, file_key)
self.logger.debug("Uploaded file {}: {}", file_name, file_key)
return file_key
else:
logger.error(
self.logger.error(
"Failed to upload file: code={}, msg={}", response.code, response.msg
)
return None
except Exception as e:
logger.error("Error uploading file {}: {}", file_path, e)
except Exception:
self.logger.exception("Error uploading file {}", file_path)
return None
def _download_image_sync(
@ -984,12 +986,12 @@ class FeishuChannel(BaseChannel):
file_data = file_data.read()
return file_data, response.file_name
else:
logger.error(
self.logger.error(
"Failed to download image: code={}, msg={}", response.code, response.msg
)
return None, None
except Exception as e:
logger.error("Error downloading image {}: {}", image_key, e)
except Exception:
self.logger.exception("Error downloading image {}", image_key)
return None, None
def _download_file_sync(
@ -1018,7 +1020,7 @@ class FeishuChannel(BaseChannel):
file_data = file_data.read()
return file_data, response.file_name
else:
logger.error(
self.logger.error(
"Failed to download {}: code={}, msg={}",
resource_type,
response.code,
@ -1026,7 +1028,7 @@ class FeishuChannel(BaseChannel):
)
return None, None
except Exception:
logger.exception("Error downloading {} {}", resource_type, file_key)
self.logger.exception("Error downloading {} {}", resource_type, file_key)
return None, None
async def _download_and_save_media(
@ -1055,10 +1057,10 @@ class FeishuChannel(BaseChannel):
elif msg_type in ("audio", "file", "media"):
file_key = content_json.get("file_key")
if not file_key:
logger.warning("Feishu {} message missing file_key: {}", msg_type, content_json)
self.logger.warning("{} message missing file_key: {}", msg_type, content_json)
return None, f"[{msg_type}: missing file_key]"
if not message_id:
logger.warning("Feishu {} message missing message_id", msg_type)
self.logger.warning("{} message missing message_id", msg_type)
return None, f"[{msg_type}: missing message_id]"
data, filename = await loop.run_in_executor(
@ -1066,7 +1068,7 @@ class FeishuChannel(BaseChannel):
)
if not data:
logger.warning("Feishu {} download failed: file_key={}", msg_type, file_key)
self.logger.warning("{} download failed: file_key={}", msg_type, file_key)
return None, f"[{msg_type}: download failed]"
if not filename:
@ -1081,8 +1083,9 @@ class FeishuChannel(BaseChannel):
if data and filename:
file_path = media_dir / filename
file_path.write_bytes(data)
logger.debug("Downloaded {} to {}", msg_type, file_path)
return str(file_path), f"[{msg_type}: {filename}]"
path_str = str(file_path)
self.logger.debug("Downloaded {} to {}", msg_type, path_str)
return path_str, f"[{msg_type}: {path_str}]"
return None, f"[{msg_type}: download failed]"
@ -1099,8 +1102,8 @@ class FeishuChannel(BaseChannel):
request = GetMessageRequest.builder().message_id(message_id).build()
response = self._client.im.v1.message.get(request)
if not response.success():
logger.debug(
"Feishu: could not fetch parent message {}: code={}, msg={}",
self.logger.debug(
"could not fetch parent message {}: code={}, msg={}",
message_id,
response.code,
response.msg,
@ -1132,7 +1135,7 @@ class FeishuChannel(BaseChannel):
text = text[: self._REPLY_CONTEXT_MAX_LEN] + "..."
return f"[Reply to: {text}]"
except Exception as e:
logger.debug("Feishu: error fetching parent message {}: {}", message_id, e)
self.logger.debug("error fetching parent message {}: {}", message_id, e)
return None
def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str, *, reply_in_thread: bool = False) -> bool:
@ -1156,18 +1159,18 @@ class FeishuChannel(BaseChannel):
)
response = self._client.im.v1.message.reply(request)
if not response.success():
logger.error(
"Failed to reply to Feishu message {}: code={}, msg={}, log_id={}",
self.logger.error(
"Failed to reply to message {}: code={}, msg={}, log_id={}",
parent_message_id,
response.code,
response.msg,
response.get_log_id(),
)
return False
logger.debug("Feishu reply sent to message {}", parent_message_id)
self.logger.debug("reply sent to message {}", parent_message_id)
return True
except Exception as e:
logger.error("Error replying to Feishu message {}: {}", parent_message_id, e)
except Exception:
self.logger.exception("Error replying to message {}", parent_message_id)
return False
def _should_use_reply_in_thread(self, metadata: dict[str, Any]) -> bool:
@ -1206,8 +1209,8 @@ class FeishuChannel(BaseChannel):
)
response = self._client.im.v1.message.create(request)
if not response.success():
logger.error(
"Failed to send Feishu {} message: code={}, msg={}, log_id={}",
self.logger.error(
"Failed to send {} message: code={}, msg={}, log_id={}",
msg_type,
response.code,
response.msg,
@ -1215,10 +1218,10 @@ class FeishuChannel(BaseChannel):
)
return None
msg_id = getattr(response.data, "message_id", None)
logger.debug("Feishu {} message sent to {}: {}", msg_type, receive_id, msg_id)
self.logger.debug("{} message sent to {}: {}", msg_type, receive_id, msg_id)
return msg_id
except Exception as e:
logger.error("Error sending Feishu {} message: {}", msg_type, e)
except Exception:
self.logger.exception("Error sending {} message", msg_type)
return None
def _create_streaming_card_sync(
@ -1258,7 +1261,7 @@ class FeishuChannel(BaseChannel):
)
response = self._client.cardkit.v1.card.create(request)
if not response.success():
logger.warning(
self.logger.warning(
"Failed to create streaming card: code={}, msg={}", response.code, response.msg
)
return None
@ -1278,12 +1281,12 @@ class FeishuChannel(BaseChannel):
) is not None
if sent:
return card_id
logger.warning(
self.logger.warning(
"Created streaming card {} but failed to send it to {}", card_id, chat_id
)
return None
except Exception as e:
logger.warning("Error creating streaming card: {}", e)
self.logger.warning("Error creating streaming card: {}", e)
return None
def _stream_update_text_sync(self, card_id: str, content: str, sequence: int) -> bool:
@ -1308,7 +1311,7 @@ class FeishuChannel(BaseChannel):
)
response = self._client.cardkit.v1.card_element.content(request)
if not response.success():
logger.warning(
self.logger.warning(
"Failed to stream-update card {}: code={}, msg={}",
card_id,
response.code,
@ -1317,7 +1320,7 @@ class FeishuChannel(BaseChannel):
return False
return True
except Exception as e:
logger.warning("Error stream-updating card {}: {}", card_id, e)
self.logger.warning("Error stream-updating card {}: {}", card_id, e)
return False
def _close_streaming_mode_sync(self, card_id: str, sequence: int) -> bool:
@ -1345,7 +1348,7 @@ class FeishuChannel(BaseChannel):
)
response = self._client.cardkit.v1.card.settings(request)
if not response.success():
logger.warning(
self.logger.warning(
"Failed to close streaming on card {}: code={}, msg={}",
card_id,
response.code,
@ -1354,7 +1357,7 @@ class FeishuChannel(BaseChannel):
return False
return True
except Exception as e:
logger.warning("Error closing streaming on card {}: {}", card_id, e)
self.logger.warning("Error closing streaming on card {}: {}", card_id, e)
return False
async def send_delta(
@ -1415,7 +1418,7 @@ class FeishuChannel(BaseChannel):
buf.sequence,
)
return
logger.warning(
self.logger.warning(
"Streaming card {} final update failed, falling back to regular card",
buf.card_id,
)
@ -1483,7 +1486,7 @@ class FeishuChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Feishu, including media (images/files) if present."""
if not self._client:
logger.warning("Feishu client not initialized")
self.logger.warning("client not initialized")
return
try:
@ -1565,7 +1568,7 @@ class FeishuChannel(BaseChannel):
for file_path in msg.media:
if not os.path.isfile(file_path):
logger.warning("Media file not found: {}", file_path)
self.logger.warning("Media file not found: {}", file_path)
continue
ext = os.path.splitext(file_path)[1].lower()
if ext in self._IMAGE_EXTS:
@ -1621,8 +1624,8 @@ class FeishuChannel(BaseChannel):
json.dumps(card, ensure_ascii=False),
)
except Exception as e:
logger.error("Error sending Feishu message: {}", e)
except Exception:
self.logger.exception("Error sending message")
raise
def _on_message_sync(self, data: Any) -> None:
@ -1640,18 +1643,10 @@ class FeishuChannel(BaseChannel):
message = event.message
sender = event.sender
logger.debug("Feishu raw message: {}", message.content)
logger.debug("Feishu mentions: {}", getattr(message, "mentions", None))
self.logger.debug("raw message: {}", message.content)
self.logger.debug("mentions: {}", getattr(message, "mentions", None))
# Deduplication check
message_id = message.message_id
if message_id in self._processed_message_ids:
return
self._processed_message_ids[message_id] = None
# Trim cache
while len(self._processed_message_ids) > 1000:
self._processed_message_ids.popitem(last=False)
# Skip bot messages
if sender.sender_type == "bot":
@ -1662,10 +1657,22 @@ class FeishuChannel(BaseChannel):
chat_type = message.chat_type
msg_type = message.message_type
if chat_type == "group" and not self._is_group_message_for_bot(message):
logger.debug("Feishu: skipping group message (not mentioned)")
if not self.is_allowed(sender_id):
return
if chat_type == "group" and not self._is_group_message_for_bot(message):
self.logger.debug("skipping group message (not mentioned)")
return
# Deduplication check
if message_id in self._processed_message_ids:
return
self._processed_message_ids[message_id] = None
# Trim cache
while len(self._processed_message_ids) > 1000:
self._processed_message_ids.popitem(last=False)
# Add reaction (non-blocking — tracked background task)
task = asyncio.create_task(
self._add_reaction(message_id, self.config.react_emoji)
@ -1779,8 +1786,8 @@ class FeishuChannel(BaseChannel):
session_key=session_key,
)
except Exception as e:
logger.error("Error processing Feishu message: {}", e)
except Exception:
self.logger.exception("Error processing message")
def _on_reaction_created(self, data: Any) -> None:
"""Ignore reaction events so they do not generate SDK noise."""
@ -1796,7 +1803,7 @@ class FeishuChannel(BaseChannel):
def _on_bot_p2p_chat_entered(self, data: Any) -> None:
"""Ignore p2p-enter events when a user opens a bot chat."""
logger.debug("Bot entered p2p chat (user opened chat window)")
self.logger.debug("Bot entered p2p chat (user opened chat window)")
pass
@staticmethod

View File

@ -174,8 +174,8 @@ class ChannelManager:
"""Start a channel and log any exceptions."""
try:
await channel.start()
except Exception as e:
logger.error("Failed to start channel {}: {}", name, e)
except Exception:
logger.exception("Failed to start channel {}", name)
async def start_all(self) -> None:
"""Start all channels and the outbound dispatcher."""
@ -230,8 +230,8 @@ class ChannelManager:
try:
await channel.stop()
logger.info("Stopped {} channel", name)
except Exception as e:
logger.error("Error stopping {}: {}", name, e)
except Exception:
logger.exception("Error stopping {}", name)
@staticmethod
def _fingerprint_content(content: str) -> str:
@ -392,9 +392,9 @@ class ChannelManager:
raise # Propagate cancellation for graceful shutdown
except Exception as e:
if attempt == max_attempts - 1:
logger.error(
"Failed to send to {} after {} attempts: {} - {}",
msg.channel, max_attempts, type(e).__name__, e
logger.exception(
"Failed to send to {} after {} attempts",
msg.channel, max_attempts
)
return
delay = _SEND_RETRY_DELAYS[min(attempt, len(_SEND_RETRY_DELAYS) - 1)]

View File

@ -2,7 +2,6 @@
import asyncio
import json
import logging
import mimetypes
import time
from contextlib import suppress
@ -10,7 +9,6 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal, TypeAlias
from loguru import logger
from pydantic import Field
try:
@ -47,6 +45,7 @@ from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_data_dir, get_media_dir
from nanobot.config.schema import Base
from nanobot.utils.helpers import safe_filename
from nanobot.utils.logging_bridge import redirect_lib_logging
TYPING_NOTICE_TIMEOUT_MS = 30_000
# Must stay below TYPING_NOTICE_TIMEOUT_MS so the indicator doesn't expire mid-processing.
@ -178,28 +177,6 @@ def _build_matrix_text_content(
return content
class _NioLoguruHandler(logging.Handler):
"""Route matrix-nio stdlib logs into Loguru."""
def emit(self, record: logging.LogRecord) -> None:
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno
frame, depth = logging.currentframe(), 2
while frame and frame.f_code.co_filename == logging.__file__:
frame, depth = frame.f_back, depth + 1
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
def _configure_nio_logging_bridge() -> None:
"""Bridge matrix-nio logs to Loguru (idempotent)."""
nio_logger = logging.getLogger("nio")
if not any(isinstance(h, _NioLoguruHandler) for h in nio_logger.handlers):
nio_logger.handlers = [_NioLoguruHandler()]
nio_logger.propagate = False
class MatrixConfig(Base):
"""Matrix (Element) channel configuration."""
@ -259,7 +236,7 @@ class MatrixChannel(BaseChannel):
"""Start Matrix client and begin sync loop."""
self._running = True
self._started_at_ms = int(time.time() * 1000)
_configure_nio_logging_bridge()
redirect_lib_logging("nio", level="WARNING")
self.store_path = get_data_dir() / "matrix-store"
self.store_path.mkdir(parents=True, exist_ok=True)
@ -283,15 +260,15 @@ class MatrixChannel(BaseChannel):
self._register_response_callbacks()
if not self.config.e2ee_enabled:
logger.warning("Matrix E2EE disabled; encrypted rooms may be undecryptable.")
self.logger.warning("E2EE disabled; encrypted rooms may be undecryptable.")
if self.config.password:
if self.config.access_token or self.config.device_id:
logger.warning("Password-based Matrix login active; access_token and device_id fields will be ignored.")
self.logger.warning("Password-based login active; access_token and device_id fields will be ignored.")
create_new_session = True
if self.session_path.exists():
logger.info("Found session.json at {}; attempting to use existing session...", self.session_path)
self.logger.info("Found session.json at {}; attempting to use existing session...", self.session_path)
try:
with open(self.session_path, "r", encoding="utf-8") as f:
session = json.load(f)
@ -299,20 +276,20 @@ class MatrixChannel(BaseChannel):
self.client.access_token = session["access_token"]
self.client.device_id = session["device_id"]
self.client.load_store()
logger.info("Successfully loaded from existing session")
self.logger.info("Successfully loaded from existing session")
create_new_session = False
except Exception as e:
logger.warning("Failed to load from existing session: {}", e)
logger.info("Falling back to password login...")
self.logger.warning("Failed to load from existing session: {}", e)
self.logger.info("Falling back to password login...")
if create_new_session:
logger.info("Using password login...")
self.logger.info("Using password login...")
resp = await self.client.login(self.config.password)
if isinstance(resp, LoginResponse):
logger.info("Logged in using a password; saving details to disk")
self.logger.info("Logged in using a password; saving details to disk")
self._write_session_to_disk(resp)
else:
logger.error("Failed to log in: {}", resp)
self.logger.error("Failed to log in: {}", resp)
return
elif self.config.access_token and self.config.device_id:
@ -321,12 +298,12 @@ class MatrixChannel(BaseChannel):
self.client.access_token = self.config.access_token
self.client.device_id = self.config.device_id
self.client.load_store()
logger.info("Successfully loaded from existing session")
self.logger.info("Successfully loaded from existing session")
except Exception as e:
logger.warning("Failed to load from existing session: {}", e)
self.logger.warning("Failed to load from existing session: {}", e)
else:
logger.warning("Unable to load a Matrix session due to missing password, access_token, or device_id; encryption may not work")
self.logger.warning("Unable to load a session due to missing password, access_token, or device_id; encryption may not work")
return
self._sync_task = asyncio.create_task(self._sync_loop())
@ -358,9 +335,9 @@ class MatrixChannel(BaseChannel):
try:
with open(self.session_path, "w", encoding="utf-8") as f:
json.dump(session, f, indent=2)
logger.info("Session saved to {}", self.session_path)
self.logger.info("Session saved to {}", self.session_path)
except Exception as e:
logger.warning("Failed to save session: {}", e)
self.logger.warning("Failed to save session: {}", e)
def _is_workspace_path_allowed(self, path: Path) -> bool:
"""Check path is inside workspace (when restriction enabled)."""
@ -598,14 +575,14 @@ class MatrixChannel(BaseChannel):
def _log_response_error(self, label: str, response: Any) -> None:
"""Log Matrix response errors — auth errors at ERROR level, rest at WARNING."""
is_fatal = self._is_fatal_auth_response(response)
(logger.error if is_fatal else logger.warning)("Matrix {} failed: {}", label, response)
(self.logger.error if is_fatal else self.logger.warning)("{} failed: {}", label, response)
async def _on_sync_error(self, response: SyncError) -> None:
self._log_response_error("sync", response)
if self._is_fatal_auth_response(response):
# Auth errors won't recover by retry; stop the sync loop instead of
# spamming the homeserver every 2s (#1851).
logger.error("Matrix authentication failed irrecoverably; stopping sync loop")
self.logger.error("Authentication failed irrecoverably; stopping sync loop")
self._running = False
if self.client:
with suppress(Exception):
@ -625,7 +602,7 @@ class MatrixChannel(BaseChannel):
response = await self.client.room_typing(room_id=room_id, typing_state=typing,
timeout=TYPING_NOTICE_TIMEOUT_MS)
if isinstance(response, RoomTypingError):
logger.debug("Matrix typing failed for {}: {}", room_id, response)
self.logger.debug("typing failed for {}: {}", room_id, response)
async def _start_typing_keepalive(self, room_id: str) -> None:
"""Start periodic typing refresh (spec-recommended keepalive)."""
@ -796,7 +773,7 @@ class MatrixChannel(BaseChannel):
return None
response = await self.client.download(mxc=mxc_url)
if isinstance(response, DownloadError):
logger.warning("Matrix download failed for {}: {}", mxc_url, response)
self.logger.warning("download failed for {}: {}", mxc_url, response)
return None
body = getattr(response, "body", None)
if isinstance(body, (bytes, bytearray)):
@ -821,7 +798,7 @@ class MatrixChannel(BaseChannel):
try:
return decrypt_attachment(ciphertext, key, sha256, iv)
except (EncryptionError, ValueError, TypeError):
logger.warning("Matrix decrypt failed for event {}", getattr(event, "event_id", ""))
self.logger.warning("decrypt failed for event {}", getattr(event, "event_id", ""))
return None
async def _fetch_media_attachment(

View File

@ -11,7 +11,6 @@ from datetime import datetime
from typing import Any
import httpx
from loguru import logger
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
@ -303,7 +302,7 @@ class MochatChannel(BaseChannel):
async def start(self) -> None:
"""Start Mochat channel workers and websocket connection."""
if not self.config.claw_token:
logger.error("Mochat claw_token not configured")
self.logger.error("claw_token not configured")
return
self._running = True
@ -348,7 +347,7 @@ class MochatChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None:
"""Send outbound message to session or panel."""
if not self.config.claw_token:
logger.warning("Mochat claw_token missing, skip send")
self.logger.warning("claw_token missing, skip send")
return
parts = ([msg.content.strip()] if msg.content and msg.content.strip() else [])
@ -360,7 +359,7 @@ class MochatChannel(BaseChannel):
target = resolve_mochat_target(msg.chat_id)
if not target.id:
logger.warning("Mochat outbound target is empty")
self.logger.warning("outbound target is empty")
return
is_panel = (target.is_panel or target.id in self._panel_set) and not target.id.startswith("session_")
@ -371,8 +370,8 @@ class MochatChannel(BaseChannel):
else:
await self._api_send("/api/claw/sessions/send", "sessionId", target.id,
content, msg.reply_to)
except Exception as e:
logger.error("Failed to send Mochat message: {}", e)
except Exception:
self.logger.exception("Failed to send message")
raise
# ---- config / init helpers ---------------------------------------------
@ -395,7 +394,7 @@ class MochatChannel(BaseChannel):
async def _start_socket_client(self) -> bool:
if not SOCKETIO_AVAILABLE:
logger.warning("python-socketio not installed, Mochat using polling fallback")
self.logger.warning("python-socketio not installed, using polling fallback")
return False
serializer = "default"
@ -403,7 +402,7 @@ class MochatChannel(BaseChannel):
if MSGPACK_AVAILABLE:
serializer = "msgpack"
else:
logger.warning("msgpack not installed but socket_disable_msgpack=false; using JSON")
self.logger.warning("msgpack not installed but socket_disable_msgpack=false; using JSON")
client = socketio.AsyncClient(
reconnection=True,
@ -416,7 +415,7 @@ class MochatChannel(BaseChannel):
@client.event
async def connect() -> None:
self._ws_connected, self._ws_ready = True, False
logger.info("Mochat websocket connected")
self.logger.info("websocket connected")
subscribed = await self._subscribe_all()
self._ws_ready = subscribed
await (self._stop_fallback_workers() if subscribed else self._ensure_fallback_workers())
@ -426,12 +425,12 @@ class MochatChannel(BaseChannel):
if not self._running:
return
self._ws_connected = self._ws_ready = False
logger.warning("Mochat websocket disconnected")
self.logger.warning("websocket disconnected")
await self._ensure_fallback_workers()
@client.event
async def connect_error(data: Any) -> None:
logger.error("Mochat websocket connect error: {}", data)
self.logger.error("websocket connect error: {}", data)
@client.on("claw.session.events")
async def on_session_events(payload: dict[str, Any]) -> None:
@ -457,8 +456,8 @@ class MochatChannel(BaseChannel):
wait_timeout=max(1.0, self.config.socket_connect_timeout_ms / 1000.0),
)
return True
except Exception as e:
logger.error("Failed to connect Mochat websocket: {}", e)
except Exception:
self.logger.exception("Failed to connect websocket")
with suppress(Exception):
await client.disconnect()
self._socket = None
@ -493,7 +492,7 @@ class MochatChannel(BaseChannel):
"limit": self.config.watch_limit,
})
if not ack.get("result"):
logger.error("Mochat subscribeSessions failed: {}", ack.get('message', 'unknown error'))
self.logger.error("subscribeSessions failed: {}", ack.get('message', 'unknown error'))
return False
data = ack.get("data")
@ -515,7 +514,7 @@ class MochatChannel(BaseChannel):
return True
ack = await self._socket_call("com.claw.im.subscribePanels", {"panelIds": panel_ids})
if not ack.get("result"):
logger.error("Mochat subscribePanels failed: {}", ack.get('message', 'unknown error'))
self.logger.error("subscribePanels failed: {}", ack.get('message', 'unknown error'))
return False
return True
@ -537,7 +536,7 @@ class MochatChannel(BaseChannel):
try:
await self._refresh_targets(subscribe_new=self._ws_ready)
except Exception as e:
logger.warning("Mochat refresh failed: {}", e)
self.logger.warning("refresh failed: {}", e)
if self._fallback_mode:
await self._ensure_fallback_workers()
@ -551,7 +550,7 @@ class MochatChannel(BaseChannel):
try:
response = await self._post_json("/api/claw/sessions/list", {})
except Exception as e:
logger.warning("Mochat listSessions failed: {}", e)
self.logger.warning("listSessions failed: {}", e)
return
sessions = response.get("sessions")
@ -585,7 +584,7 @@ class MochatChannel(BaseChannel):
try:
response = await self._post_json("/api/claw/groups/get", {})
except Exception as e:
logger.warning("Mochat getWorkspaceGroup failed: {}", e)
self.logger.warning("getWorkspaceGroup failed: {}", e)
return
raw_panels = response.get("panels")
@ -647,7 +646,7 @@ class MochatChannel(BaseChannel):
except asyncio.CancelledError:
break
except Exception as e:
logger.warning("Mochat watch fallback error ({}): {}", session_id, e)
self.logger.warning("watch fallback error ({}): {}", session_id, e)
await asyncio.sleep(max(0.1, self.config.retry_delay_ms / 1000.0))
async def _panel_poll_worker(self, panel_id: str) -> None:
@ -674,7 +673,7 @@ class MochatChannel(BaseChannel):
except asyncio.CancelledError:
break
except Exception as e:
logger.warning("Mochat panel polling error ({}): {}", panel_id, e)
self.logger.warning("panel polling error ({}): {}", panel_id, e)
await asyncio.sleep(sleep_s)
# ---- inbound event processing ------------------------------------------
@ -885,7 +884,7 @@ class MochatChannel(BaseChannel):
try:
data = json.loads(self._cursor_path.read_text("utf-8"))
except Exception as e:
logger.warning("Failed to read Mochat cursor file: {}", e)
self.logger.warning("Failed to read cursor file: {}", e)
return
cursors = data.get("cursors") if isinstance(data, dict) else None
if isinstance(cursors, dict):
@ -901,7 +900,7 @@ class MochatChannel(BaseChannel):
"cursors": self._session_cursor,
}, ensure_ascii=False, indent=2) + "\n", "utf-8")
except Exception as e:
logger.warning("Failed to save Mochat cursor file: {}", e)
self.logger.warning("Failed to save cursor file: {}", e)
# ---- HTTP helpers ------------------------------------------------------

View File

@ -32,7 +32,6 @@ except ImportError: # pragma: no cover
fcntl = None
import httpx
from loguru import logger
from pydantic import Field
from nanobot.bus.events import OutboundMessage
@ -134,16 +133,16 @@ class MSTeamsChannel(BaseChannel):
async def start(self) -> None:
"""Start the Teams webhook listener."""
if not MSTEAMS_AVAILABLE:
logger.error("PyJWT not installed. Run: pip install nanobot-ai[msteams]")
self.logger.error("PyJWT not installed. Run: pip install nanobot-ai[msteams]")
return
if not self.config.app_id or not self.config.app_password:
logger.error("MSTeams app_id/app_password not configured")
self.logger.error("app_id/app_password not configured")
return
if not self.config.validate_inbound_auth:
logger.warning(
"MSTeams inbound auth validation was explicitly DISABLED in config. "
self.logger.warning(
"Inbound auth validation was explicitly DISABLED in config. "
"Anyone who knows the webhook URL can send messages as any user. "
"Only disable this for local development or controlled testing."
)
@ -166,7 +165,7 @@ class MSTeamsChannel(BaseChannel):
raw = self.rfile.read(length) if length > 0 else b"{}"
payload = json.loads(raw.decode("utf-8"))
except Exception as e:
logger.warning("MSTeams invalid request body: {}", e)
channel.logger.warning("Invalid request body: {}", e)
self.send_response(400)
self.end_headers()
return
@ -180,7 +179,7 @@ class MSTeamsChannel(BaseChannel):
)
fut.result(timeout=15)
except Exception as e:
logger.warning("MSTeams inbound auth validation failed: {}", e)
channel.logger.warning("Inbound auth validation failed: {}", e)
self.send_response(401)
self.send_header("Content-Type", "application/json")
self.end_headers()
@ -193,7 +192,7 @@ class MSTeamsChannel(BaseChannel):
)
fut.result(timeout=15)
except Exception as e:
logger.warning("MSTeams activity handling failed: {}", e)
channel.logger.warning("Activity handling failed: {}", e)
self.send_response(200)
self.send_header("Content-Type", "application/json")
@ -211,8 +210,8 @@ class MSTeamsChannel(BaseChannel):
)
self._server_thread.start()
logger.info(
"MSTeams webhook listening on http://{}:{}{}",
self.logger.info(
"Webhook listening on http://{}:{}{}",
self.config.host,
self.config.port,
self.config.path,
@ -261,10 +260,10 @@ class MSTeamsChannel(BaseChannel):
try:
resp = await self._http.post(base_url, headers=headers, json=payload)
resp.raise_for_status()
logger.info("MSTeams message sent to {}", ref.conversation_id)
self.logger.info("Message sent to {}", ref.conversation_id)
self._touch_conversation_ref(str(msg.chat_id), persist=True)
except Exception as e:
logger.error("MSTeams send failed: {}", e)
except Exception:
self.logger.exception("Send failed")
raise
async def _handle_activity(self, activity: dict[str, Any]) -> None:
@ -291,18 +290,18 @@ class MSTeamsChannel(BaseChannel):
# DM-only MVP: ignore group/channel traffic for now
if conversation_type and conversation_type not in ("personal", ""):
logger.debug("MSTeams ignoring non-DM conversation {}", conversation_type)
self.logger.debug("Ignoring non-DM conversation {}", conversation_type)
return
text = self._sanitize_inbound_text(activity)
if not text:
text = self.config.mention_only_response.strip()
if not text:
logger.debug("MSTeams ignoring empty message after Teams text sanitization")
self.logger.debug("Ignoring empty message after Teams text sanitization")
return
if not self.is_allowed(sender_id):
logger.warning(
self.logger.warning(
"Access denied for sender {} on channel {}. "
"Add them to allowFrom list in config to grant access.",
sender_id, self.name,
@ -554,7 +553,7 @@ class MSTeamsChannel(BaseChannel):
if isinstance(loaded, dict):
main_data = loaded
except Exception as e:
logger.warning("Failed to load MSTeams conversation refs: {}", e)
self.logger.warning("Failed to load conversation refs: {}", e)
if meta_exists:
try:
@ -562,7 +561,7 @@ class MSTeamsChannel(BaseChannel):
if isinstance(loaded_meta, dict):
meta_data = loaded_meta
except Exception as e:
logger.warning("Failed to load MSTeams conversation refs metadata: {}", e)
self.logger.warning("Failed to load conversation refs metadata: {}", e)
return main_data, meta_data, meta_exists
@ -660,8 +659,8 @@ class MSTeamsChannel(BaseChannel):
for key in keys_to_drop:
self._conversation_refs.pop(key, None)
logger.info(
"MSTeams pruned {} stale/unsupported conversation refs (ttl={} days)",
self.logger.info(
"Pruned {} stale/unsupported conversation refs (ttl={} days)",
len(keys_to_drop),
ttl_days,
)
@ -742,7 +741,7 @@ class MSTeamsChannel(BaseChannel):
self._write_json_atomically(self._refs_path, refs_data)
self._write_json_atomically(self._refs_meta_path, refs_meta)
except Exception as e:
logger.warning("Failed to save MSTeams conversation refs: {}", e)
self.logger.warning("Failed to save conversation refs: {}", e)
def _save_refs(self, *, prune: bool = True) -> None:
"""Persist conversation references."""

View File

@ -38,7 +38,7 @@ from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.schema import Base
from nanobot.security.network import validate_url_target
from nanobot.utils.logging_bridge import redirect_lib_logging
try:
from nanobot.config.paths import get_media_dir
@ -187,24 +187,25 @@ class QQChannel(BaseChannel):
root = Path.home() / ".nanobot" / "media" / "qq"
root.mkdir(parents=True, exist_ok=True)
logger.info("QQ media directory: {}", str(root))
self.logger.info("media directory: {}", str(root))
return root
async def start(self) -> None:
"""Start the QQ bot with auto-reconnect loop."""
redirect_lib_logging("botpy", level="WARNING")
if not QQ_AVAILABLE:
logger.error("QQ SDK not installed. Run: pip install qq-botpy")
self.logger.error("SDK not installed. Run: pip install qq-botpy")
return
if not self.config.app_id or not self.config.secret:
logger.error("QQ app_id and secret not configured")
self.logger.error("app_id and secret not configured")
return
self._running = True
self._http = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=120))
self._client = _make_bot_class(self)()
logger.info("QQ bot started (C2C & Group supported)")
self.logger.info("bot started (C2C & Group supported)")
await self._run_bot()
async def _run_bot(self) -> None:
@ -213,9 +214,9 @@ class QQChannel(BaseChannel):
try:
await self._client.start(appid=self.config.app_id, secret=self.config.secret)
except Exception as e:
logger.warning("QQ bot error: {}", e)
self.logger.warning("bot error: {}", e)
if self._running:
logger.info("Reconnecting QQ bot in 5 seconds...")
self.logger.info("Reconnecting bot in 5 seconds...")
await asyncio.sleep(5)
async def stop(self) -> None:
@ -231,7 +232,7 @@ class QQChannel(BaseChannel):
await self._http.close()
self._http = None
logger.info("QQ bot stopped")
self.logger.info("bot stopped")
# ---------------------------
# Outbound (send)
@ -241,7 +242,7 @@ class QQChannel(BaseChannel):
"""Send attachments first, then text."""
try:
if not self._client:
logger.warning("QQ client not initialized")
self.logger.warning("client not initialized")
return
msg_id = msg.metadata.get("message_id")
@ -281,7 +282,7 @@ class QQChannel(BaseChannel):
# Network / transport errors — propagate so ChannelManager can retry
raise
except Exception:
logger.exception("Error sending QQ message to chat_id={}", msg.chat_id)
self.logger.exception("Error sending message to chat_id={}", msg.chat_id)
async def _send_text_only(
self,
@ -339,7 +340,7 @@ class QQChannel(BaseChannel):
srv_send_msg=False,
)
if not media_obj:
logger.error("QQ media upload failed: empty response")
self.logger.error("media upload failed: empty response")
return False
self._msg_seq += 1
@ -360,15 +361,15 @@ class QQChannel(BaseChannel):
media=media_obj,
)
logger.info("QQ media sent: {}", filename)
self.logger.info("media sent: {}", filename)
return True
except (aiohttp.ClientError, OSError) as e:
# Network / transport errors — propagate for retry by caller
logger.warning("QQ send media network error filename={} err={}", filename, e)
self.logger.warning("send media network error filename={} err={}", filename, e)
raise
except Exception as e:
except Exception:
# API-level or other non-network errors — return False so send() can fallback
logger.error("QQ send media failed filename={} err={}", filename, e)
self.logger.exception("send media failed filename={}", filename)
return False
async def _read_media_bytes(self, media_ref: str) -> tuple[bytes | None, str | None]:
@ -389,19 +390,19 @@ class QQChannel(BaseChannel):
local_path = Path(os.path.expanduser(media_ref))
if not local_path.is_file():
logger.warning("QQ outbound media file not found: {}", str(local_path))
self.logger.warning("outbound media file not found: {}", str(local_path))
return None, None
data = await asyncio.to_thread(local_path.read_bytes)
return data, local_path.name
except Exception as e:
logger.warning("QQ outbound media read error ref={} err={}", media_ref, e)
self.logger.warning("outbound media read error ref={} err={}", media_ref, e)
return None, None
# Remote URL
ok, err = validate_url_target(media_ref)
if not ok:
logger.warning("QQ outbound media URL validation failed url={} err={}", media_ref, err)
self.logger.warning("outbound media URL validation failed url={} err={}", media_ref, err)
return None, None
if not self._http:
@ -409,8 +410,8 @@ class QQChannel(BaseChannel):
try:
async with self._http.get(media_ref, allow_redirects=True) as resp:
if resp.status >= 400:
logger.warning(
"QQ outbound media download failed status={} url={}",
self.logger.warning(
"outbound media download failed status={} url={}",
resp.status,
media_ref,
)
@ -421,7 +422,7 @@ class QQChannel(BaseChannel):
filename = os.path.basename(urlparse(media_ref).path) or "file.bin"
return data, filename
except Exception as e:
logger.warning("QQ outbound media download error url={} err={}", media_ref, e)
self.logger.warning("outbound media download error url={} err={}", media_ref, e)
return None, None
# https://github.com/tencent-connect/botpy/issues/198
@ -474,24 +475,28 @@ class QQChannel(BaseChannel):
async def _on_message(self, data: C2CMessage | GroupMessage, is_group: bool = False) -> None:
"""Parse inbound message, download attachments, and publish to the bus."""
try:
if data.id in self._processed_ids:
return
self._processed_ids.append(data.id)
if is_group:
chat_id = data.group_openid
user_id = data.author.member_openid
self._chat_type_cache[chat_id] = "group"
chat_type = "group"
else:
chat_id = str(
getattr(data.author, "id", None)
or getattr(data.author, "user_openid", "unknown")
)
user_id = chat_id
self._chat_type_cache[chat_id] = "c2c"
chat_type = "c2c"
content = (data.content or "").strip()
if not self.is_allowed(user_id):
return
if data.id in self._processed_ids:
return
self._processed_ids.append(data.id)
self._chat_type_cache[chat_id] = chat_type
# the data used by tests don't contain attachments property
# so we use getattr with a default of [] to avoid AttributeError in tests
attachments = getattr(data, "attachments", None) or []
@ -521,7 +526,7 @@ class QQChannel(BaseChannel):
content=self.config.ack_message,
)
except Exception:
logger.debug("QQ ack message failed for chat_id={}", chat_id)
self.logger.debug("ack message failed for chat_id={}", chat_id)
await self._handle_message(
sender_id=user_id,
@ -534,7 +539,7 @@ class QQChannel(BaseChannel):
},
)
except Exception:
logger.exception("Error handling QQ inbound message id={}", getattr(data, "id", "?"))
self.logger.exception("Error handling inbound message id={}", getattr(data, "id", "?"))
async def _handle_attachments(
self,
@ -553,7 +558,7 @@ class QQChannel(BaseChannel):
filename = getattr(att, "filename", None) or ""
ctype = getattr(att, "content_type", None) or ""
logger.info("Downloading file from QQ: {}", filename or url)
self.logger.info("Downloading file: {}", filename or url)
local_path = await self._download_to_media_dir_chunked(url, filename_hint=filename)
att_meta.append(
@ -604,7 +609,7 @@ class QQChannel(BaseChannel):
allow_redirects=True,
) as resp:
if resp.status != 200:
logger.warning("QQ download failed: status={} url={}", resp.status, url)
self.logger.warning("download failed: status={} url={}", resp.status, url)
return None
ctype = (resp.headers.get("Content-Type") or "").lower()
@ -658,8 +663,8 @@ class QQChannel(BaseChannel):
continue
downloaded += len(chunk)
if downloaded > max_bytes:
logger.warning(
"QQ download exceeded max_bytes={} url={} -> abort",
self.logger.warning(
"download exceeded max_bytes={} url={} -> abort",
max_bytes,
url,
)
@ -671,11 +676,11 @@ class QQChannel(BaseChannel):
# Atomic rename
await asyncio.to_thread(os.replace, tmp_path, target)
tmp_path = None # mark as moved
logger.info("QQ file saved: {}", str(target))
self.logger.info("file saved: {}", str(target))
return str(target)
except Exception as e:
logger.error("QQ download error: {}", e)
except Exception:
self.logger.exception("download error")
return None
finally:
# Cleanup partial file

View File

@ -6,7 +6,6 @@ from pathlib import Path
from typing import Any
import httpx
from loguru import logger
from pydantic import Field
from slack_sdk.socket_mode.request import SocketModeRequest
from slack_sdk.socket_mode.response import SocketModeResponse
@ -84,10 +83,10 @@ class SlackChannel(BaseChannel):
async def start(self) -> None:
"""Start the Slack Socket Mode client."""
if not self.config.bot_token or not self.config.app_token:
logger.error("Slack bot/app token not configured")
self.logger.error("bot/app token not configured")
return
if self.config.mode != "socket":
logger.error("Unsupported Slack mode: {}", self.config.mode)
self.logger.error("Unsupported mode: {}", self.config.mode)
return
self._running = True
@ -104,11 +103,11 @@ class SlackChannel(BaseChannel):
try:
auth = await self._web_client.auth_test()
self._bot_user_id = auth.get("user_id")
logger.info("Slack bot connected as {}", self._bot_user_id)
self.logger.info("bot connected as {}", self._bot_user_id)
except Exception as e:
logger.warning("Slack auth_test failed: {}", e)
self.logger.warning("auth_test failed: {}", e)
logger.info("Starting Slack Socket Mode client...")
self.logger.info("Starting Socket Mode client...")
await self._socket_client.connect()
while self._running:
@ -121,13 +120,13 @@ class SlackChannel(BaseChannel):
try:
await self._socket_client.close()
except Exception as e:
logger.warning("Slack socket close failed: {}", e)
self.logger.warning("socket close failed: {}", e)
self._socket_client = None
async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Slack."""
if not self._web_client:
logger.warning("Slack client not running")
self.logger.warning("client not running")
return
try:
target_chat_id = await self._resolve_target_chat_id(msg.chat_id)
@ -162,16 +161,16 @@ class SlackChannel(BaseChannel):
file=media_path,
thread_ts=thread_ts_param,
)
except Exception as e:
logger.error("Failed to upload file {}: {}", media_path, e)
except Exception:
self.logger.exception("Failed to upload file {}", media_path)
# Update reaction emoji when the final (non-progress) response is sent
if not (msg.metadata or {}).get("_progress"):
event = slack_meta.get("event", {})
await self._update_react_emoji(origin_chat_id, event.get("ts"))
except Exception as e:
logger.error("Error sending Slack message: {}", e)
except Exception:
self.logger.exception("Error sending message")
raise
async def _resolve_target_chat_id(self, target: str) -> str:
@ -328,8 +327,8 @@ class SlackChannel(BaseChannel):
return
# Debug: log basic event shape
logger.debug(
"Slack event: type={} subtype={} user={} channel={} channel_type={} text={}",
self.logger.debug(
"event: type={} subtype={} user={} channel={} channel_type={} text={}",
event_type,
subtype,
sender_id,
@ -371,7 +370,7 @@ class SlackChannel(BaseChannel):
timestamp=event.get("ts"),
)
except Exception as e:
logger.debug("Slack reactions_add failed: {}", e)
self.logger.debug("reactions_add failed: {}", e)
# Thread-scoped session key whenever the user is in a real thread
# (raw_thread_ts is set). DM threads get their own session, separate
@ -420,7 +419,7 @@ class SlackChannel(BaseChannel):
session_key=session_key,
)
except Exception:
logger.exception("Error handling Slack message from {}", sender_id)
self.logger.exception("Error handling message from {}", sender_id)
async def _download_slack_file(self, file_info: dict[str, Any]) -> tuple[str | None, str]:
"""Download a Slack private file to the local media directory."""
@ -453,7 +452,7 @@ class SlackChannel(BaseChannel):
path.write_bytes(response.content)
return str(path), marker
except Exception as e:
logger.warning("Failed to download Slack file {}: {}", file_id, e)
self.logger.warning("Failed to download file {}: {}", file_id, e)
return None, self._download_failure_marker(marker_type, name, "download failed")
@staticmethod
@ -500,7 +499,7 @@ class SlackChannel(BaseChannel):
session_key=session_key,
)
except Exception:
logger.exception("Error handling Slack button click from {}", sender_id)
self.logger.exception("Error handling button click from {}", sender_id)
async def _with_thread_context(
self,
@ -537,7 +536,7 @@ class SlackChannel(BaseChannel):
limit=max(1, self.config.thread_context_limit),
)
except Exception as e:
logger.warning("Slack thread context unavailable for {}: {}", key, e)
self.logger.warning("thread context unavailable for {}: {}", key, e)
return text
lines = self._format_thread_context(
@ -597,7 +596,7 @@ class SlackChannel(BaseChannel):
timestamp=ts,
)
except Exception as e:
logger.debug("Slack reactions_remove failed: {}", e)
self.logger.debug("reactions_remove failed: {}", e)
if self.config.done_emoji:
try:
await self._web_client.reactions_add(
@ -606,7 +605,7 @@ class SlackChannel(BaseChannel):
timestamp=ts,
)
except Exception as e:
logger.debug("Slack done reaction failed: {}", e)
self.logger.debug("done reaction failed: {}", e)
def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool:
if channel_type == "im":

View File

@ -11,7 +11,6 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
from loguru import logger
from pydantic import Field
from telegram import (
BotCommand,
@ -320,7 +319,7 @@ class TelegramChannel(BaseChannel):
async def start(self) -> None:
"""Start the Telegram bot with long polling."""
if not self.config.token:
logger.error("Telegram bot token not configured")
self.logger.error("bot token not configured")
return
self._running = True
@ -382,11 +381,11 @@ class TelegramChannel(BaseChannel):
if self.config.inline_keyboards:
self._app.add_handler(CallbackQueryHandler(self._on_callback_query))
allowed_updates = ["message", "callback_query"]
logger.debug("Telegram inline keyboards enabled")
self.logger.debug("inline keyboards enabled")
else:
allowed_updates = ["message"]
logger.info("Starting Telegram bot (polling mode)...")
self.logger.info("Starting bot (polling mode)...")
# Initialize and start polling
await self._app.initialize()
@ -396,13 +395,13 @@ class TelegramChannel(BaseChannel):
bot_info = await self._app.bot.get_me()
self._bot_user_id = getattr(bot_info, "id", None)
self._bot_username = getattr(bot_info, "username", None)
logger.info("Telegram bot @{} connected", bot_info.username)
self.logger.info("bot @{} connected", bot_info.username)
try:
await self._app.bot.set_my_commands(self.BOT_COMMANDS)
logger.debug("Telegram bot commands registered")
self.logger.debug("bot commands registered")
except Exception as e:
logger.warning("Failed to register bot commands: {}", e)
self.logger.warning("Failed to register bot commands: {}", e)
# Start polling (this runs until stopped)
await self._app.updater.start_polling(
@ -429,7 +428,7 @@ class TelegramChannel(BaseChannel):
self._media_group_buffers.clear()
if self._app:
logger.info("Stopping Telegram bot...")
self.logger.info("Stopping bot...")
await self._app.updater.stop()
await self._app.stop()
await self._app.shutdown()
@ -456,7 +455,7 @@ class TelegramChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Telegram."""
if not self._app:
logger.warning("Telegram bot not running")
self.logger.warning("bot not running")
return
# Only stop typing indicator and remove reaction for final responses
@ -469,7 +468,7 @@ class TelegramChannel(BaseChannel):
try:
chat_id = int(msg.chat_id)
except ValueError:
logger.error("Invalid chat_id: {}", msg.chat_id)
self.logger.exception("Invalid chat_id: {}", msg.chat_id)
return
reply_to_message_id = msg.metadata.get("message_id")
message_thread_id = msg.metadata.get("message_thread_id")
@ -533,9 +532,9 @@ class TelegramChannel(BaseChannel):
**extra,
**send_kwargs,
)
except Exception as e:
except Exception:
filename = media_path.rsplit("/", 1)[-1]
logger.error("Failed to send media {}: {}", media_path, e)
self.logger.exception("Failed to send media {}", media_path)
await self._app.bot.send_message(
chat_id=chat_id,
text=f"[Failed to send: {filename}]",
@ -572,8 +571,8 @@ class TelegramChannel(BaseChannel):
if attempt == _SEND_MAX_RETRIES:
raise
delay = _SEND_RETRY_BASE_DELAY * (2 ** (attempt - 1))
logger.warning(
"Telegram timeout (attempt {}/{}), retrying in {:.1f}s",
self.logger.warning(
"timeout (attempt {}/{}), retrying in {:.1f}s",
attempt, _SEND_MAX_RETRIES, delay,
)
await asyncio.sleep(delay)
@ -581,8 +580,8 @@ class TelegramChannel(BaseChannel):
if attempt == _SEND_MAX_RETRIES:
raise
delay = float(e.retry_after)
logger.warning(
"Telegram Flood Control (attempt {}/{}), retrying in {:.1f}s",
self.logger.warning(
"Flood Control (attempt {}/{}), retrying in {:.1f}s",
attempt, _SEND_MAX_RETRIES, delay,
)
await asyncio.sleep(delay)
@ -607,7 +606,7 @@ class TelegramChannel(BaseChannel):
**(thread_kwargs or {}),
)
except BadRequest as e:
logger.warning("HTML parse failed, falling back to plain text: {}", e)
self.logger.warning("HTML parse failed, falling back to plain text: {}", e)
try:
await self._call_with_retry(
self._app.bot.send_message,
@ -617,8 +616,8 @@ class TelegramChannel(BaseChannel):
reply_markup=reply_markup,
**(thread_kwargs or {}),
)
except Exception as e2:
logger.error("Error sending Telegram message: {}", e2)
except Exception:
self.logger.exception("Error sending message")
raise
@staticmethod
@ -666,10 +665,10 @@ class TelegramChannel(BaseChannel):
# Network errors (TimedOut, NetworkError) should propagate immediately
# to avoid doubling connection demand during pool exhaustion.
if self._is_not_modified_error(e):
logger.debug("Final stream edit already applied for {}", chat_id)
self.logger.debug("Final stream edit already applied for {}", chat_id)
self._stream_bufs.pop(chat_id, None)
return
logger.debug("Final stream edit failed (HTML), trying plain: {}", e)
self.logger.debug("Final stream edit failed (HTML), trying plain: {}", e)
# Fall back to raw markdown (not HTML) so users don't see raw tags.
primary_plain = split_message(raw_text, TELEGRAM_MAX_MESSAGE_LEN)[0] if len(raw_text) > TELEGRAM_MAX_MESSAGE_LEN else raw_text
try:
@ -680,9 +679,9 @@ class TelegramChannel(BaseChannel):
)
except Exception as e2:
if self._is_not_modified_error(e2):
logger.debug("Final stream plain edit already applied for {}", chat_id)
self.logger.debug("Final stream plain edit already applied for {}", chat_id)
else:
logger.warning("Final stream edit failed: {}", e2)
self.logger.warning("Final stream edit failed: {}", e2)
raise # Let ChannelManager handle retry
for extra_html_chunk in extra_html_chunks:
try:
@ -724,7 +723,7 @@ class TelegramChannel(BaseChannel):
buf.message_id = sent.message_id
buf.last_edit = now
except Exception as e:
logger.warning("Stream initial send failed: {}", e)
self.logger.warning("Stream initial send failed: {}", e)
raise # Let ChannelManager handle retry
elif (now - buf.last_edit) >= self.config.stream_edit_interval:
if len(buf.text) > TELEGRAM_MAX_MESSAGE_LEN:
@ -743,7 +742,7 @@ class TelegramChannel(BaseChannel):
if self._is_not_modified_error(e):
buf.last_edit = now
return
logger.warning("Stream edit failed: {}", e)
self.logger.warning("Stream edit failed: {}", e)
raise # Let ChannelManager handle retry
async def _flush_stream_overflow(
@ -769,7 +768,7 @@ class TelegramChannel(BaseChannel):
)
except Exception as e:
if not self._is_not_modified_error(e):
logger.warning("Stream overflow edit failed: {}", e)
self.logger.warning("Stream overflow edit failed: {}", e)
raise
for chunk in chunks[1:-1]:
await self._call_with_retry(
@ -790,6 +789,8 @@ class TelegramChannel(BaseChannel):
return
user = update.effective_user
if not self.is_allowed(self._sender_id(user)):
return
await update.message.reply_text(
f"👋 Hi {user.first_name}! I'm nanobot.\n\n"
"Send me a message and I'll respond!\n"
@ -797,8 +798,10 @@ class TelegramChannel(BaseChannel):
)
async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /help command, bypassing ACL so all users can access it."""
if not update.message:
"""Handle /help command for allowed users only."""
if not update.message or not update.effective_user:
return
if not self.is_allowed(self._sender_id(update.effective_user)):
return
await update.message.reply_text(build_help_text())
@ -899,12 +902,12 @@ class TelegramChannel(BaseChannel):
if media_type in ("voice", "audio"):
transcription = await self.transcribe_audio(file_path)
if transcription:
logger.info("Transcribed {}: {}...", media_type, transcription[:50])
self.logger.info("Transcribed {}: {}...", media_type, transcription[:50])
return [path_str], [f"[transcription: {transcription}]"]
return [path_str], [f"[{media_type}: {path_str}]"]
return [path_str], [f"[{media_type}: {path_str}]"]
except Exception as e:
logger.warning("Failed to download message media: {}", e)
self.logger.warning("Failed to download message media: {}", e)
if add_failure_content:
return [], [f"[{media_type}: download failed]"]
return [], []
@ -989,6 +992,9 @@ class TelegramChannel(BaseChannel):
return
message = update.message
user = update.effective_user
sender_id = self._sender_id(user)
if not self.is_allowed(sender_id):
return
self._remember_thread_context(message)
# Strip @bot_username suffix if present
@ -1000,7 +1006,7 @@ class TelegramChannel(BaseChannel):
content = self._normalize_telegram_command(content)
await self._handle_message(
sender_id=self._sender_id(user),
sender_id=sender_id,
chat_id=str(message.chat_id),
content=content,
metadata=self._build_message_metadata(message, user),
@ -1016,6 +1022,8 @@ class TelegramChannel(BaseChannel):
user = update.effective_user
chat_id = message.chat_id
sender_id = self._sender_id(user)
if not self.is_allowed(sender_id):
return
self._remember_thread_context(message)
# Store chat_id for replies
@ -1047,7 +1055,7 @@ class TelegramChannel(BaseChannel):
media_paths.extend(current_media_paths)
content_parts.extend(current_media_parts)
if current_media_paths:
logger.debug("Downloaded message media to {}", current_media_paths[0])
self.logger.debug("Downloaded message media to {}", current_media_paths[0])
# Reply context: text and/or media from the replied-to message
reply = getattr(message, "reply_to_message", None)
@ -1056,13 +1064,13 @@ class TelegramChannel(BaseChannel):
reply_media, reply_media_parts = await self._download_message_media(reply)
if reply_media:
media_paths = reply_media + media_paths
logger.debug("Attached replied-to media: {}", reply_media[0])
self.logger.debug("Attached replied-to media: {}", reply_media[0])
tag = reply_ctx or (f"[Reply to: {reply_media_parts[0]}]" if reply_media_parts else None)
if tag:
content_parts.insert(0, tag)
content = "\n".join(content_parts) if content_parts else "[empty message]"
logger.debug("Telegram message from {}: {}...", sender_id, content[:50])
self.logger.debug("message from {}: {}...", sender_id, content[:50])
str_chat_id = str(chat_id)
metadata = self._build_message_metadata(message, user)
@ -1141,7 +1149,7 @@ class TelegramChannel(BaseChannel):
reaction=[ReactionTypeEmoji(emoji=emoji)],
)
except Exception as e:
logger.debug("Telegram reaction failed: {}", e)
self.logger.debug("reaction failed: {}", e)
async def _remove_reaction(self, chat_id: str, message_id: int) -> None:
"""Remove emoji reaction from a message (best-effort, non-blocking)."""
@ -1154,7 +1162,7 @@ class TelegramChannel(BaseChannel):
reaction=[],
)
except Exception as e:
logger.debug("Telegram reaction removal failed: {}", e)
self.logger.debug("reaction removal failed: {}", e)
async def _typing_loop(self, chat_id: str) -> None:
"""Repeatedly send 'typing' action until cancelled."""
@ -1164,7 +1172,7 @@ class TelegramChannel(BaseChannel):
await self._app.bot.send_chat_action(chat_id=int(chat_id), action="typing")
await asyncio.sleep(4)
except Exception as e:
logger.debug("Typing indicator stopped for {}: {}", chat_id, e)
self.logger.debug("Typing indicator stopped for {}: {}", chat_id, e)
@staticmethod
def _format_telegram_error(exc: Exception) -> str:
@ -1184,18 +1192,18 @@ class TelegramChannel(BaseChannel):
"""Keep long-polling network failures to a single readable line."""
summary = self._format_telegram_error(exc)
if isinstance(exc, (NetworkError, TimedOut)):
logger.warning("Telegram polling network issue: {}", summary)
self.logger.warning("polling network issue: {}", summary)
else:
logger.error("Telegram polling error: {}", summary)
self.logger.error("polling error: {}", summary)
async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Log polling / handler errors instead of silently swallowing them."""
summary = self._format_telegram_error(context.error)
if isinstance(context.error, (NetworkError, TimedOut)):
logger.warning("Telegram network issue: {}", summary)
self.logger.warning("network issue: {}", summary)
else:
logger.error("Telegram error: {}", summary)
self.logger.error("error: {}", summary)
def _get_extension(
self,
@ -1256,14 +1264,16 @@ class TelegramChannel(BaseChannel):
chat_id = query.message.chat_id if query.message else None
sender_id = self._sender_id(user)
if not chat_id:
logger.warning("Callback query without chat_id")
self.logger.warning("Callback query without chat_id")
return
if not self.is_allowed(sender_id):
return
button_label = query.data or ""
await query.answer()
if query.message:
with suppress(Exception):
await query.message.edit_reply_markup(reply_markup=None)
logger.debug("Inline button tap from {}: {}", sender_id, button_label)
self.logger.debug("Inline button tap from {}: {}", sender_id, button_label)
self._start_typing(str(chat_id))
await self._handle_message(
sender_id=sender_id,

View File

@ -32,6 +32,7 @@ from websockets.http11 import Response
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.command.builtin import builtin_command_palette
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
from nanobot.utils.helpers import safe_filename
@ -128,6 +129,17 @@ class WebSocketConfig(Base):
raise ValueError("token_issue_path must differ from path (the WebSocket upgrade path)")
return self
@model_validator(mode="after")
def wildcard_host_requires_auth(self) -> Self:
if self.host not in ("0.0.0.0", "::"):
return self
if self.token.strip() or self.token_issue_secret.strip():
return self
raise ValueError(
"host is 0.0.0.0 (all interfaces) but neither token nor "
"token_issue_secret is set — set one to prevent unauthenticated access"
)
def _http_json_response(data: dict[str, Any], *, status: int = 200) -> Response:
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
@ -448,7 +460,7 @@ class WebSocketChannel(BaseChannel):
except ConnectionClosed:
self._cleanup_connection(connection)
except Exception as e:
logger.warning("websocket: failed to send {} event: {}", event, e)
self.logger.warning("failed to send {} event: {}", event, e)
@classmethod
def default_config(cls) -> dict[str, Any]:
@ -464,7 +476,7 @@ class WebSocketChannel(BaseChannel):
return None
if not cert or not key:
raise ValueError(
"websocket: ssl_certfile and ssl_keyfile must both be set for WSS, or both left empty"
"ssl_certfile and ssl_keyfile must both be set for WSS, or both left empty"
)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
@ -501,14 +513,14 @@ class WebSocketChannel(BaseChannel):
if not _issue_route_secret_matches(request.headers, secret):
return connection.respond(401, "Unauthorized")
else:
logger.warning(
"websocket: token_issue_path is set but token_issue_secret is empty; "
self.logger.warning(
"token_issue_path is set but token_issue_secret is empty; "
"any client can obtain connection tokens — set token_issue_secret for production."
)
self._purge_expired_issued_tokens()
if len(self._issued_tokens) >= self._MAX_ISSUED_TOKENS:
logger.error(
"websocket: too many outstanding issued tokens ({}), rejecting issuance",
self.logger.error(
"too many outstanding issued tokens ({}), rejecting issuance",
len(self._issued_tokens),
)
return _http_json_response({"error": "too many outstanding tokens"}, status=429)
@ -531,9 +543,9 @@ class WebSocketChannel(BaseChannel):
if got == issue_expected:
return self._handle_token_issue_http(connection, request)
# 2. WebUI bootstrap: localhost-only, mints tokens for the embedded UI.
# 2. WebUI bootstrap: mints tokens for the embedded UI.
if got == "/webui/bootstrap":
return self._handle_webui_bootstrap(connection)
return self._handle_webui_bootstrap(connection, request)
# 3. REST surface for the embedded UI.
if got == "/api/sessions":
@ -542,6 +554,9 @@ class WebSocketChannel(BaseChannel):
if got == "/api/settings":
return self._handle_settings(request)
if got == "/api/commands":
return self._handle_commands(request)
if got == "/api/settings/update":
return self._handle_settings_update(request)
@ -606,8 +621,16 @@ class WebSocketChannel(BaseChannel):
if now > expiry:
self._api_tokens.pop(token_key, None)
def _handle_webui_bootstrap(self, connection: Any) -> Response:
if not _is_localhost(connection):
def _handle_webui_bootstrap(self, connection: Any, request: Any) -> Response:
# When a secret is configured (token_issue_secret or static token),
# validate it regardless of source IP. This secures deployments
# behind a reverse proxy where all connections appear as localhost.
secret = self.config.token_issue_secret.strip() or self.config.token.strip()
if secret:
if not _issue_route_secret_matches(request.headers, secret):
return _http_error(401, "Unauthorized")
elif not _is_localhost(connection):
# No secret configured: only allow localhost (local dev mode).
return _http_error(403, "webui bootstrap is localhost-only")
# Cap outstanding tokens to avoid runaway growth from a misbehaving client.
self._purge_expired_issued_tokens()
@ -689,6 +712,11 @@ class WebSocketChannel(BaseChannel):
return _http_error(401, "Unauthorized")
return _http_json_response(self._settings_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_settings_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
@ -821,7 +849,7 @@ class WebSocketChannel(BaseChannel):
staged = media_dir / f"{uuid.uuid4().hex[:12]}-{safe_name}"
shutil.copyfile(path, staged)
except OSError as exc:
logger.warning("websocket: failed to stage outbound media {}: {}", path, exc)
self.logger.warning("failed to stage outbound media {}: {}", path, exc)
return None
signed = self._sign_media_path(staged)
if signed is None:
@ -917,7 +945,7 @@ class WebSocketChannel(BaseChannel):
try:
body = candidate.read_bytes()
except OSError as e:
logger.warning("websocket static: failed to read {}: {}", candidate, e)
self.logger.warning("static: failed to read {}: {}", candidate, e)
return _http_error(500, "Internal Server Error")
ctype, _ = mimetypes.guess_type(candidate.name)
if ctype is None:
@ -972,7 +1000,7 @@ class WebSocketChannel(BaseChannel):
async def handler(connection: ServerConnection) -> None:
await self._connection_loop(connection)
logger.info(
self.logger.info(
"WebSocket server listening on {}://{}:{}{}",
scheme,
self.config.host,
@ -980,7 +1008,7 @@ class WebSocketChannel(BaseChannel):
self.config.path,
)
if self.config.token_issue_path:
logger.info(
self.logger.info(
"WebSocket token issue route: {}://{}:{}{}",
scheme,
self.config.host,
@ -1014,7 +1042,7 @@ class WebSocketChannel(BaseChannel):
if not client_id:
client_id = f"anon-{uuid.uuid4().hex[:12]}"
elif len(client_id) > 128:
logger.warning("websocket: client_id too long ({} chars), truncating", len(client_id))
self.logger.warning("client_id too long ({} chars), truncating", len(client_id))
client_id = client_id[:128]
default_chat_id = str(uuid.uuid4())
@ -1039,7 +1067,7 @@ class WebSocketChannel(BaseChannel):
try:
raw = raw.decode("utf-8")
except UnicodeDecodeError:
logger.warning("websocket: ignoring non-utf8 binary frame")
self.logger.warning("ignoring non-utf8 binary frame")
continue
envelope = _parse_envelope(raw)
@ -1057,7 +1085,7 @@ class WebSocketChannel(BaseChannel):
metadata={"remote": getattr(connection, "remote_address", None)},
)
except Exception as e:
logger.debug("websocket connection ended: {}", e)
self.logger.debug("connection ended: {}", e)
finally:
self._cleanup_connection(connection)
@ -1097,8 +1125,8 @@ class WebSocketChannel(BaseChannel):
try:
Path(p).unlink(missing_ok=True)
except OSError as exc:
logger.warning(
"websocket: failed to unlink partial media {}: {}", p, exc
self.logger.warning(
"failed to unlink partial media {}: {}", p, exc
)
return [], reason
@ -1122,7 +1150,7 @@ class WebSocketChannel(BaseChannel):
except FileSizeExceeded:
return _abort("size")
except Exception as exc:
logger.warning("websocket: media decode failed: {}", exc)
self.logger.warning("media decode failed: {}", exc)
return _abort("decode")
if saved is None:
return _abort("decode")
@ -1184,12 +1212,15 @@ class WebSocketChannel(BaseChannel):
# Auto-attach on first use so clients can one-shot without a separate attach.
self._attach(connection, cid)
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)}
if envelope.get("webui") is True:
metadata["webui"] = True
await self._handle_message(
sender_id=client_id,
chat_id=cid,
content=content,
media=media_paths or None,
metadata={"remote": getattr(connection, "remote_address", None)},
metadata=metadata,
)
return
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
@ -1204,7 +1235,7 @@ class WebSocketChannel(BaseChannel):
try:
await self._server_task
except Exception as e:
logger.warning("websocket: server task error during shutdown: {}", e)
self.logger.warning("server task error during shutdown: {}", e)
self._server_task = None
self._subs.clear()
self._conn_chats.clear()
@ -1218,16 +1249,23 @@ class WebSocketChannel(BaseChannel):
await connection.send(raw)
except ConnectionClosed:
self._cleanup_connection(connection)
logger.warning("websocket{}connection gone", label)
except Exception as e:
logger.error("websocket{}send failed: {}", label, e)
self.logger.warning("connection gone{}", label)
except Exception:
self.logger.exception("send failed{}", label)
raise
async def send(self, msg: OutboundMessage) -> None:
# Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe.
conns = list(self._subs.get(msg.chat_id, ()))
if not conns:
logger.warning("websocket: no active subscribers for chat_id={}", msg.chat_id)
self.logger.warning("no active subscribers for chat_id={}", msg.chat_id)
return
# Signal that the agent has fully finished processing the current turn.
if msg.metadata.get("_turn_end"):
await self.send_turn_end(msg.chat_id)
return
if msg.metadata.get("_session_updated"):
await self.send_session_updated(msg.chat_id)
return
text = msg.content
if msg.buttons:
@ -1285,3 +1323,23 @@ class WebSocketChannel(BaseChannel):
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" stream ")
async def send_turn_end(self, chat_id: str) -> None:
"""Signal that the agent has fully finished processing the current turn."""
conns = list(self._subs.get(chat_id, ()))
if not conns:
return
body: dict[str, Any] = {"event": "turn_end", "chat_id": chat_id}
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" turn_end ")
async def send_session_updated(self, chat_id: str) -> None:
"""Notify clients that session metadata changed outside the main turn."""
conns = list(self._subs.get(chat_id, ()))
if not conns:
return
body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id}
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" session_updated ")

View File

@ -10,14 +10,13 @@ from collections import OrderedDict
from pathlib import Path
from typing import Any
from loguru import logger
from pydantic import Field
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
from pydantic import Field
WECOM_AVAILABLE = importlib.util.find_spec("wecom_aibot_sdk") is not None
@ -103,11 +102,11 @@ class WecomChannel(BaseChannel):
async def start(self) -> None:
"""Start the WeCom bot with WebSocket long connection."""
if not WECOM_AVAILABLE:
logger.error("WeCom SDK not installed. Run: pip install nanobot-ai[wecom]")
self.logger.error("SDK not installed. Run: pip install nanobot-ai[wecom]")
return
if not self.config.bot_id or not self.config.secret:
logger.error("WeCom bot_id and secret not configured")
self.logger.error("bot_id and secret not configured")
return
from wecom_aibot_sdk import WSClient, generate_req_id
@ -137,8 +136,8 @@ class WecomChannel(BaseChannel):
self._client.on("message.mixed", self._on_mixed_message)
self._client.on("event.enter_chat", self._on_enter_chat)
logger.info("WeCom bot starting with WebSocket long connection")
logger.info("No public IP required - using WebSocket to receive events")
self.logger.info("bot starting with WebSocket long connection")
self.logger.info("No public IP required - using WebSocket to receive events")
# Connect
await self._client.connect_async()
@ -152,24 +151,24 @@ class WecomChannel(BaseChannel):
self._running = False
if self._client:
await self._client.disconnect()
logger.info("WeCom bot stopped")
self.logger.info("bot stopped")
async def _on_connected(self, frame: Any) -> None:
"""Handle WebSocket connected event."""
logger.info("WeCom WebSocket connected")
self.logger.info("WebSocket connected")
async def _on_authenticated(self, frame: Any) -> None:
"""Handle authentication success event."""
logger.info("WeCom authenticated successfully")
self.logger.info("authenticated successfully")
async def _on_disconnected(self, frame: Any) -> None:
"""Handle WebSocket disconnected event."""
reason = frame.body if hasattr(frame, 'body') else str(frame)
logger.warning("WeCom WebSocket disconnected: {}", reason)
self.logger.warning("WebSocket disconnected: {}", reason)
async def _on_error(self, frame: Any) -> None:
"""Handle error event."""
logger.error("WeCom error: {}", frame)
self.logger.error("error: {}", frame)
async def _on_text_message(self, frame: Any) -> None:
"""Handle text message."""
@ -204,13 +203,16 @@ class WecomChannel(BaseChannel):
chat_id = body.get("chatid", "") if isinstance(body, dict) else ""
if chat_id and not self.is_allowed(chat_id):
return
if chat_id and self.config.welcome_message:
await self._client.reply_welcome(frame, {
"msgtype": "text",
"text": {"content": self.config.welcome_message},
})
except Exception as e:
logger.error("Error handling enter_chat: {}", e)
except Exception:
self.logger.exception("Error handling enter_chat")
async def _process_message(self, frame: Any, msg_type: str) -> None:
"""Process incoming message and forward to bus."""
@ -225,7 +227,7 @@ class WecomChannel(BaseChannel):
# Ensure body is a dict
if not isinstance(body, dict):
logger.warning("Invalid body type: {}", type(body))
self.logger.warning("Invalid body type: {}", type(body))
return
# Extract message info
@ -233,6 +235,12 @@ class WecomChannel(BaseChannel):
if not msg_id:
msg_id = f"{body.get('chatid', '')}_{body.get('sendertime', '')}"
# Extract sender info from "from" field (SDK format)
from_info = body.get("from", {})
sender_id = from_info.get("userid", "unknown") if isinstance(from_info, dict) else "unknown"
if not self.is_allowed(sender_id):
return
# Deduplication check
if msg_id in self._processed_message_ids:
return
@ -242,10 +250,6 @@ class WecomChannel(BaseChannel):
while len(self._processed_message_ids) > 1000:
self._processed_message_ids.popitem(last=False)
# Extract sender info from "from" field (SDK format)
from_info = body.get("from", {})
sender_id = from_info.get("userid", "unknown") if isinstance(from_info, dict) else "unknown"
# For single chat, chatid is the sender's userid
# For group chat, chatid is provided in body
chat_type = body.get("chattype", "single")
@ -345,8 +349,8 @@ class WecomChannel(BaseChannel):
}
)
except Exception as e:
logger.error("Error processing WeCom message: {}", e)
except Exception:
self.logger.exception("Error processing message")
async def _download_and_save_media(
self,
@ -365,12 +369,12 @@ class WecomChannel(BaseChannel):
data, fname = await self._client.download_file(file_url, aes_key)
if not data:
logger.warning("Failed to download media from WeCom")
self.logger.warning("Failed to download media")
return None
if len(data) > WECOM_UPLOAD_MAX_BYTES:
logger.warning(
"WeCom inbound media too large: {} bytes (max {})",
self.logger.warning(
"inbound media too large: {} bytes (max {})",
len(data),
WECOM_UPLOAD_MAX_BYTES,
)
@ -383,11 +387,11 @@ class WecomChannel(BaseChannel):
file_path = media_dir / filename
await asyncio.to_thread(file_path.write_bytes, data)
logger.debug("Downloaded {} to {}", media_type, file_path)
self.logger.debug("Downloaded {} to {}", media_type, file_path)
return str(file_path)
except Exception as e:
logger.error("Error downloading media: {}", e)
except Exception:
self.logger.exception("Error downloading media")
return None
async def _upload_media_ws(
@ -424,9 +428,9 @@ class WecomChannel(BaseChannel):
# MD5 is used for file integrity only, not cryptographic security
md5_hash = hashlib.md5(data).hexdigest()
CHUNK_SIZE = 512 * 1024 # 512 KB raw (before base64)
chunk_size = 512 * 1024 # 512 KB raw (before base64)
mv = memoryview(data)
chunk_list = [bytes(mv[i : i + CHUNK_SIZE]) for i in range(0, file_size, CHUNK_SIZE)]
chunk_list = [bytes(mv[i : i + chunk_size]) for i in range(0, file_size, chunk_size)]
n_chunks = len(chunk_list)
del mv, data
@ -440,11 +444,11 @@ class WecomChannel(BaseChannel):
"md5": md5_hash,
}, "aibot_upload_media_init")
if resp.errcode != 0:
logger.warning("WeCom upload init failed ({}): {}", resp.errcode, resp.errmsg)
self.logger.warning("upload init failed ({}): {}", resp.errcode, resp.errmsg)
return None, None
upload_id = resp.body.get("upload_id") if resp.body else None
if not upload_id:
logger.warning("WeCom upload init: no upload_id in response")
self.logger.warning("upload init: no upload_id in response")
return None, None
# Step 2: send chunks
@ -456,7 +460,7 @@ class WecomChannel(BaseChannel):
"base64_data": base64.b64encode(chunk).decode(),
}, "aibot_upload_media_chunk")
if resp.errcode != 0:
logger.warning("WeCom upload chunk {} failed ({}): {}", i, resp.errcode, resp.errmsg)
self.logger.warning("upload chunk {} failed ({}): {}", i, resp.errcode, resp.errmsg)
return None, None
# Step 3: finish
@ -465,29 +469,29 @@ class WecomChannel(BaseChannel):
"upload_id": upload_id,
}, "aibot_upload_media_finish")
if resp.errcode != 0:
logger.warning("WeCom upload finish failed ({}): {}", resp.errcode, resp.errmsg)
self.logger.warning("upload finish failed ({}): {}", resp.errcode, resp.errmsg)
return None, None
media_id = resp.body.get("media_id") if resp.body else None
if not media_id:
logger.warning("WeCom upload finish: no media_id in response body={}", resp.body)
self.logger.warning("upload finish: no media_id in response body={}", resp.body)
return None, None
suffix = "..." if len(media_id) > 16 else ""
logger.debug("WeCom uploaded {} ({}) → media_id={}", fname, media_type, media_id[:16] + suffix)
self.logger.debug("uploaded {} ({}) → media_id={}", fname, media_type, media_id[:16] + suffix)
return media_id, media_type
except ValueError as e:
logger.warning("WeCom upload skipped for {}: {}", file_path, e)
self.logger.warning("upload skipped for {}: {}", file_path, e)
return None, None
except Exception as e:
logger.error("WeCom _upload_media_ws error for {}: {}", file_path, e)
except Exception:
self.logger.exception("_upload_media_ws error for {}", file_path)
return None, None
async def send(self, msg: OutboundMessage) -> None:
"""Send a message through WeCom."""
if not self._client:
logger.warning("WeCom client not initialized")
self.logger.warning("client not initialized")
return
try:
@ -500,7 +504,7 @@ class WecomChannel(BaseChannel):
# Send media files via WebSocket upload
for file_path in msg.media or []:
if not os.path.isfile(file_path):
logger.warning("WeCom media file not found: {}", file_path)
self.logger.warning("media file not found: {}", file_path)
continue
media_id, media_type = await self._upload_media_ws(self._client, file_path)
if media_id:
@ -514,7 +518,7 @@ class WecomChannel(BaseChannel):
"msgtype": media_type,
media_type: {"media_id": media_id},
})
logger.debug("WeCom sent {}{}", media_type, msg.chat_id)
self.logger.debug("sent {}{}", media_type, msg.chat_id)
else:
content += f"\n[file upload failed: {os.path.basename(file_path)}]"
@ -532,8 +536,8 @@ class WecomChannel(BaseChannel):
content,
finish=not is_progress,
)
logger.debug(
"WeCom {} sent to {}",
self.logger.debug(
"{} sent to {}",
"progress" if is_progress else "message",
msg.chat_id,
)
@ -543,7 +547,7 @@ class WecomChannel(BaseChannel):
"msgtype": "markdown",
"markdown": {"content": content},
})
logger.info("WeCom proactive send to {}", msg.chat_id)
self.logger.info("proactive send to {}", msg.chat_id)
except Exception:
logger.exception("Error sending WeCom message to chat_id={}", msg.chat_id)
self.logger.exception("Error sending message to chat_id={}", msg.chat_id)

View File

@ -366,14 +366,14 @@ class WeixinChannel(BaseChannel):
if base_url:
self.config.base_url = base_url
self._save_state()
logger.info(
"WeChat login successful! bot_id={} user_id={}",
self.logger.info(
"login successful! bot_id={} user_id={}",
bot_id,
user_id,
)
return True
else:
logger.error("Login confirmed but no bot_token in response")
self.logger.error("Login confirmed but no bot_token in response")
return False
elif status == "scaned_but_redirect":
redirect_host = str(status_data.get("redirect_host", "") or "").strip()
@ -387,7 +387,7 @@ class WeixinChannel(BaseChannel):
elif status == "expired":
refresh_count += 1
if refresh_count > MAX_QR_REFRESH_COUNT:
logger.warning(
self.logger.warning(
"QR code expired too many times ({}/{}), giving up.",
refresh_count - 1,
MAX_QR_REFRESH_COUNT,
@ -401,8 +401,8 @@ class WeixinChannel(BaseChannel):
await asyncio.sleep(1)
except Exception as e:
logger.error("WeChat QR login failed: {}", e)
except Exception:
self.logger.exception("QR login failed")
return False
@ -469,11 +469,11 @@ class WeixinChannel(BaseChannel):
self._token = self.config.token
elif not self._load_state():
if not await self._qr_login():
logger.error("WeChat login failed. Run 'nanobot channels login weixin' to authenticate.")
self.logger.error("login failed. Run 'nanobot channels login weixin' to authenticate.")
self._running = False
return
logger.info("WeChat channel starting with long-poll...")
self.logger.info("channel starting with long-poll...")
consecutive_failures = 0
while self._running:
@ -551,8 +551,8 @@ class WeixinChannel(BaseChannel):
if errcode == ERRCODE_SESSION_EXPIRED or ret == ERRCODE_SESSION_EXPIRED:
self._pause_session()
remaining = self._session_pause_remaining_s()
logger.warning(
"WeChat session expired (errcode {}). Pausing {} min.",
self.logger.warning(
"session expired (errcode {}). Pausing {} min.",
errcode,
max((remaining + 59) // 60, 1),
)
@ -588,20 +588,24 @@ class WeixinChannel(BaseChannel):
if msg.get("message_type") == MESSAGE_TYPE_BOT:
return
# Deduplication by message_id
msg_id = str(msg.get("message_id", "") or msg.get("seq", ""))
if not msg_id:
msg_id = f"{msg.get('from_user_id', '')}_{msg.get('create_time_ms', '')}"
from_user_id = msg.get("from_user_id", "") or ""
if not from_user_id:
return
if not self.is_allowed(from_user_id):
return
# Deduplication by message_id
if msg_id in self._processed_ids:
return
self._processed_ids[msg_id] = None
while len(self._processed_ids) > 1000:
self._processed_ids.popitem(last=False)
from_user_id = msg.get("from_user_id", "") or ""
if not from_user_id:
return
# Cache context_token (required for all replies — inbound.ts:23-27)
ctx_token = msg.get("context_token", "")
if ctx_token:
@ -755,8 +759,8 @@ class WeixinChannel(BaseChannel):
if not content:
return
logger.info(
"WeChat inbound: from={} items={} bodyLen={}",
self.logger.info(
"inbound: from={} items={} bodyLen={}",
from_user_id,
",".join(str(i.get("type", 0)) for i in item_list),
len(content),
@ -839,8 +843,8 @@ class WeixinChannel(BaseChannel):
and self._is_retryable_media_download_error(e)
)
if should_fallback:
logger.warning(
"WeChat media download failed via full_url, falling back to encrypt_query_param: type={} err={}",
self.logger.warning(
"media download failed via full_url, falling back to encrypt_query_param: type={} err={}",
media_type,
e,
)
@ -865,8 +869,8 @@ class WeixinChannel(BaseChannel):
file_path.write_bytes(data)
return str(file_path)
except Exception as e:
logger.error("Error downloading WeChat media: {}", e)
except Exception:
self.logger.exception("Error downloading media")
return None
# ------------------------------------------------------------------
@ -936,12 +940,8 @@ class WeixinChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None:
if not self._client or not self._token:
logger.warning("WeChat client not initialized or not authenticated")
return
try:
self._assert_session_active()
except RuntimeError:
return
raise RuntimeError("WeChat client not initialized or not authenticated")
self._assert_session_active()
is_progress = bool((msg.metadata or {}).get("_progress", False))
if not is_progress:
@ -950,11 +950,9 @@ class WeixinChannel(BaseChannel):
content = msg.content.strip()
ctx_token = self._context_tokens.get(msg.chat_id, "")
if not ctx_token:
logger.warning(
"WeChat: no context_token for chat_id={}, cannot send",
msg.chat_id,
raise RuntimeError(
f"WeChat context_token missing for chat_id={msg.chat_id}, cannot send"
)
return
typing_ticket = ""
with suppress(Exception):
@ -976,14 +974,13 @@ class WeixinChannel(BaseChannel):
for media_path in (msg.media or []):
try:
await self._send_media_file(msg.chat_id, media_path, ctx_token)
except (httpx.TimeoutException, httpx.TransportError) as net_err:
except (httpx.TimeoutException, httpx.TransportError):
# Network/transport errors: do NOT fall back to text —
# the text send would also likely fail, and the outer
# except will re-raise so ChannelManager retries properly.
logger.error(
"Network error sending WeChat media {}: {}",
self.logger.opt(exception=True).warning(
"Network error sending media {}",
media_path,
net_err,
)
raise
except httpx.HTTPStatusError as http_err:
@ -994,27 +991,26 @@ class WeixinChannel(BaseChannel):
)
if status_code >= 500:
# Server-side / retryable HTTP error — same as network.
logger.error(
"Server error ({} {}) sending WeChat media {}: {}",
self.logger.exception(
"Server error ({} {}) sending media {}",
status_code,
http_err.response.reason_phrase
if http_err.response is not None
else "",
media_path,
http_err,
)
raise
# 4xx client errors are NOT retryable — fall back to text.
filename = Path(media_path).name
logger.error("Failed to send WeChat media {}: {}", media_path, http_err)
self.logger.exception("Failed to send media {}", media_path)
await self._send_text(
msg.chat_id, f"[Failed to send: {filename}]", ctx_token,
)
except Exception as e:
except Exception:
# Non-network errors (format, file-not-found, etc.):
# notify the user via text fallback.
filename = Path(media_path).name
logger.error("Failed to send WeChat media {}: {}", media_path, e)
self.logger.exception("Failed to send media {}", media_path)
# Notify user about failure via text
await self._send_text(
msg.chat_id, f"[Failed to send: {filename}]", ctx_token,
@ -1027,8 +1023,8 @@ class WeixinChannel(BaseChannel):
chunks = split_message(content, WEIXIN_MAX_MESSAGE_LEN)
for chunk in chunks:
await self._send_text(msg.chat_id, chunk, ctx_token)
except Exception as e:
logger.error("Error sending WeChat message: {}", e)
except Exception:
self.logger.exception("Error sending message")
raise
finally:
if typing_keepalive_task:
@ -1052,7 +1048,7 @@ class WeixinChannel(BaseChannel):
return
await self._send_typing(chat_id, ticket, TYPING_STATUS_TYPING)
except Exception as e:
logger.debug("WeChat typing indicator start failed for {}: {}", chat_id, e)
self.logger.debug("typing indicator start failed for {}: {}", chat_id, e)
return
stop_event = asyncio.Event()
@ -1091,7 +1087,7 @@ class WeixinChannel(BaseChannel):
try:
await self._send_typing(chat_id, ticket, TYPING_STATUS_CANCEL)
except Exception as e:
logger.debug("WeChat typing clear failed for {}: {}", chat_id, e)
self.logger.debug("typing clear failed for {}: {}", chat_id, e)
async def _send_text(
self,
@ -1126,10 +1122,8 @@ class WeixinChannel(BaseChannel):
data = await self._api_post("ilink/bot/sendmessage", body)
errcode = data.get("errcode", 0)
if errcode and errcode != 0:
logger.warning(
"WeChat send error (code {}): {}",
errcode,
data.get("errmsg", ""),
raise RuntimeError(
f"WeChat send text error (code {errcode}): {data.get('errmsg', '')}"
)
async def _send_media_file(

View File

@ -8,8 +8,8 @@ import os
import secrets
import shutil
import subprocess
from contextlib import suppress
from collections import OrderedDict
from contextlib import suppress
from pathlib import Path
from typing import Any, Literal
@ -99,15 +99,15 @@ class WhatsAppChannel(BaseChannel):
"""
try:
bridge_dir = _ensure_bridge_setup()
except RuntimeError as e:
logger.error("{}", e)
except RuntimeError:
self.logger.exception("bridge setup failed")
return False
env = {**os.environ}
env["BRIDGE_TOKEN"] = self._effective_bridge_token()
env["AUTH_DIR"] = str(_bridge_token_path().parent)
logger.info("Starting WhatsApp bridge for QR login...")
self.logger.info("Starting WhatsApp bridge for QR login...")
try:
subprocess.run(
[shutil.which("npm"), "start"], cwd=bridge_dir, check=True, env=env
@ -123,7 +123,7 @@ class WhatsAppChannel(BaseChannel):
bridge_url = self.config.bridge_url
logger.info("Connecting to WhatsApp bridge at {}...", bridge_url)
self.logger.info("Connecting to WhatsApp bridge at {}...", bridge_url)
self._running = True
@ -135,24 +135,24 @@ class WhatsAppChannel(BaseChannel):
json.dumps({"type": "auth", "token": self._effective_bridge_token()})
)
self._connected = True
logger.info("Connected to WhatsApp bridge")
self.logger.info("Connected to WhatsApp bridge")
# Listen for messages
async for message in ws:
try:
await self._handle_bridge_message(message)
except Exception as e:
logger.error("Error handling bridge message: {}", e)
except Exception:
self.logger.exception("Error handling bridge message")
except asyncio.CancelledError:
break
except Exception as e:
self._connected = False
self._ws = None
logger.warning("WhatsApp bridge connection error: {}", e)
self.logger.warning("WhatsApp bridge connection error: {}", e)
if self._running:
logger.info("Reconnecting in 5 seconds...")
self.logger.info("Reconnecting in 5 seconds...")
await asyncio.sleep(5)
async def stop(self) -> None:
@ -167,7 +167,7 @@ class WhatsAppChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None:
"""Send a message through WhatsApp."""
if not self._ws or not self._connected:
logger.warning("WhatsApp bridge not connected")
self.logger.warning("WhatsApp bridge not connected")
return
chat_id = msg.chat_id
@ -176,8 +176,8 @@ class WhatsAppChannel(BaseChannel):
try:
payload = {"type": "send", "to": chat_id, "text": msg.content}
await self._ws.send(json.dumps(payload, ensure_ascii=False))
except Exception as e:
logger.error("Error sending WhatsApp message: {}", e)
except Exception:
self.logger.exception("Error sending message")
raise
for media_path in msg.media or []:
@ -191,8 +191,8 @@ class WhatsAppChannel(BaseChannel):
"fileName": media_path.rsplit("/", 1)[-1],
}
await self._ws.send(json.dumps(payload, ensure_ascii=False))
except Exception as e:
logger.error("Error sending WhatsApp media {}: {}", media_path, e)
except Exception:
self.logger.exception("Error sending media {}", media_path)
raise
async def _handle_bridge_message(self, raw: str) -> None:
@ -200,7 +200,7 @@ class WhatsAppChannel(BaseChannel):
try:
data = json.loads(raw)
except json.JSONDecodeError:
logger.warning("Invalid JSON from bridge: {}", raw[:100])
self.logger.warning("Invalid JSON from bridge: {}", raw[:100])
return
msg_type = data.get("type")
@ -214,13 +214,6 @@ class WhatsAppChannel(BaseChannel):
content = data.get("content", "")
message_id = data.get("id", "")
if message_id:
if message_id in self._processed_message_ids:
return
self._processed_message_ids[message_id] = None
while len(self._processed_message_ids) > 1000:
self._processed_message_ids.popitem(last=False)
# Extract just the phone number or lid as chat_id
is_group = data.get("isGroup", False)
was_mentioned = data.get("wasMentioned", False)
@ -246,11 +239,21 @@ class WhatsAppChannel(BaseChannel):
elif extracted and not phone_id:
phone_id = extracted # best guess for bare values
sender_id = phone_id or self._lid_to_phone.get(lid_id, "") or lid_id or id_a or id_b
if not self.is_allowed(sender_id):
return
if message_id:
if message_id in self._processed_message_ids:
return
self._processed_message_ids[message_id] = None
while len(self._processed_message_ids) > 1000:
self._processed_message_ids.popitem(last=False)
if phone_id and lid_id:
self._lid_to_phone[lid_id] = phone_id
sender_id = phone_id or self._lid_to_phone.get(lid_id, "") or lid_id or id_a or id_b
logger.info("Sender phone={} lid={} → sender_id={}", phone_id or "(empty)", lid_id or "(empty)", sender_id)
self.logger.info("Sender phone={} lid={} → sender_id={}", phone_id or "(empty)", lid_id or "(empty)", sender_id)
# Extract media paths (images/documents/videos downloaded by the bridge)
media_paths = data.get("media") or []
@ -258,11 +261,11 @@ class WhatsAppChannel(BaseChannel):
# Handle voice transcription if it's a voice message
if content == "[Voice Message]":
if media_paths:
logger.info("Transcribing voice message from {}...", sender_id)
self.logger.info("Transcribing voice message from {}...", sender_id)
transcription = await self.transcribe_audio(media_paths[0])
if transcription:
content = transcription
logger.info("Transcribed voice from {}: {}...", sender_id, transcription[:50])
self.logger.info("Transcribed voice from {}: {}...", sender_id, transcription[:50])
else:
content = "[Voice Message: Transcription failed]"
else:
@ -291,7 +294,7 @@ class WhatsAppChannel(BaseChannel):
elif msg_type == "status":
# Connection status update
status = data.get("status")
logger.info("WhatsApp status: {}", status)
self.logger.info("Status: {}", status)
if status == "connected":
self._connected = True
@ -300,10 +303,10 @@ class WhatsAppChannel(BaseChannel):
elif msg_type == "qr":
# QR code for authentication
logger.info("Scan QR code in the bridge terminal to connect WhatsApp")
self.logger.info("Scan QR code in the bridge terminal to connect WhatsApp")
elif msg_type == "error":
logger.error("WhatsApp bridge error: {}", data.get("error"))
self.logger.error("Bridge error: {}", data.get("error"))
def _ensure_bridge_setup() -> Path:

View File

@ -21,6 +21,22 @@ if sys.platform == "win32":
import typer
from loguru import logger
# Remove default handler and re-add with unified nanobot format
logger.remove()
_log_handler_id = logger.add(
sys.stderr,
format=(
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
"<level>{level: <5}</level> | "
"<cyan>{extra[channel]}</cyan> | "
"<level>{message}</level>"
),
level="INFO",
colorize=None,
filter=lambda record: record["extra"].setdefault("channel", "-") or True,
)
from prompt_toolkit import PromptSession, print_formatted_text
from prompt_toolkit.application import run_in_terminal
from prompt_toolkit.formatted_text import ANSI, HTML
@ -217,6 +233,29 @@ async def _print_interactive_progress_line(text: str, thinking: ThinkingSpinner
await _print_interactive_line(text)
async def _maybe_print_interactive_progress(
msg: Any,
thinking: ThinkingSpinner | None,
channels_config: Any,
) -> bool:
metadata = msg.metadata or {}
if metadata.get("_retry_wait"):
await _print_interactive_progress_line(msg.content, thinking)
return True
if not metadata.get("_progress"):
return False
is_tool_hint = metadata.get("_tool_hint", False)
if channels_config and is_tool_hint and not channels_config.send_tool_hints:
return True
if channels_config and not is_tool_hint and not channels_config.send_progress:
return True
await _print_interactive_progress_line(msg.content, thinking)
return True
def _is_exit_command(command: str) -> bool:
"""Return True when input should end interactive chat."""
return command.lower() in EXIT_COMMANDS
@ -575,9 +614,19 @@ def gateway(
):
"""Start the nanobot gateway."""
if verbose:
import logging
logging.basicConfig(level=logging.DEBUG)
logger.remove(_log_handler_id)
logger.add(
sys.stderr,
format=(
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
"<level>{level: <5}</level> | "
"<cyan>{extra[channel]}</cyan> | "
"<level>{message}</level>"
),
level="DEBUG",
colorize=None,
filter=lambda record: record["extra"].setdefault("channel", "-") or True,
)
cfg = _load_runtime_config(config, workspace)
_run_gateway(cfg, port=port)
@ -1131,15 +1180,11 @@ def agent(
turn_done.set()
continue
if msg.metadata.get("_progress"):
is_tool_hint = msg.metadata.get("_tool_hint", False)
ch = agent_loop.channels_config
if ch and is_tool_hint and not ch.send_tool_hints:
pass
elif ch and not is_tool_hint and not ch.send_progress:
pass
else:
await _print_interactive_progress_line(msg.content, _thinking)
if await _maybe_print_interactive_progress(
msg,
_thinking,
agent_loop.channels_config,
):
continue
if not turn_done.is_set():

View File

@ -840,7 +840,7 @@ def _get_channel_info() -> dict[str, tuple[str, type[BaseModel]]]:
display_name = getattr(channel_cls, "display_name", name.capitalize())
result[name] = (display_name, config_cls)
except Exception:
logger.warning(f"Failed to load channel module: {name}")
logger.warning("Failed to load channel module: {}", name)
return result

View File

@ -6,6 +6,7 @@ import asyncio
import os
import sys
from contextlib import suppress
from dataclasses import dataclass
from nanobot import __version__
from nanobot.bus.events import OutboundMessage
@ -14,6 +15,88 @@ from nanobot.utils.helpers import build_status_content
from nanobot.utils.restart import set_restart_notice_to_env
@dataclass(frozen=True)
class BuiltinCommandSpec:
command: str
title: str
description: str
icon: str
arg_hint: str = ""
def as_dict(self) -> dict[str, str]:
return {
"command": self.command,
"title": self.title,
"description": self.description,
"icon": self.icon,
"arg_hint": self.arg_hint,
}
BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
BuiltinCommandSpec(
"/new",
"New chat",
"Stop the current task and start a fresh conversation.",
"square-pen",
),
BuiltinCommandSpec(
"/stop",
"Stop current task",
"Cancel the active agent turn for this chat.",
"square",
),
BuiltinCommandSpec(
"/restart",
"Restart nanobot",
"Restart the bot process in place.",
"rotate-cw",
),
BuiltinCommandSpec(
"/status",
"Show status",
"Display runtime, provider, and channel status.",
"activity",
),
BuiltinCommandSpec(
"/history",
"Show conversation history",
"Print the last N persisted conversation messages.",
"history",
"[n]",
),
BuiltinCommandSpec(
"/dream",
"Run Dream",
"Manually trigger memory consolidation.",
"sparkles",
),
BuiltinCommandSpec(
"/dream-log",
"Show Dream log",
"Show what the last Dream consolidation changed.",
"book-open",
),
BuiltinCommandSpec(
"/dream-restore",
"Restore memory",
"Revert memory to a previous Dream snapshot.",
"undo-2",
),
BuiltinCommandSpec(
"/help",
"Show help",
"List available slash commands.",
"circle-help",
),
)
def builtin_command_palette() -> list[dict[str, str]]:
"""Return structured command metadata for UI command palettes."""
return [spec.as_dict() for spec in BUILTIN_COMMAND_SPECS]
async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
"""Cancel all active tasks and subagents for the session."""
loop = ctx.loop
@ -378,18 +461,12 @@ async def cmd_help(ctx: CommandContext) -> OutboundMessage:
def build_help_text() -> str:
"""Build canonical help text shared across channels."""
lines = [
"🐈 nanobot commands:",
"/new — Stop current task and start a new conversation",
"/stop — Stop the current task",
"/restart — Restart the bot",
"/status — Show bot status",
"/history [n] — Show the last N conversation messages (default 10)",
"/dream — Manually trigger Dream consolidation",
"/dream-log — Show what the last Dream changed",
"/dream-restore — Revert memory to a previous state",
"/help — Show available commands",
]
lines = ["🐈 nanobot commands:"]
for spec in BUILTIN_COMMAND_SPECS:
command = spec.command
if spec.arg_hint:
command = f"{command} {spec.arg_hint}"
lines.append(f"{command}{spec.description}")
return "\n".join(lines)

View File

@ -49,7 +49,7 @@ def load_config(config_path: Path | None = None) -> Config:
data = _migrate_config(data)
config = Config.model_validate(data)
except (json.JSONDecodeError, ValueError, pydantic.ValidationError) as e:
logger.warning(f"Failed to load config from {path}: {e}")
logger.warning("Failed to load config from {}: {}", path, e)
logger.warning("Using default configuration.")
_apply_ssrf_whitelist(config)

View File

@ -2,8 +2,10 @@
import asyncio
import json
import os
import time
import uuid
from contextlib import suppress
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
@ -12,7 +14,14 @@ from typing import Any, Callable, Coroutine, Literal
from filelock import FileLock
from loguru import logger
from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronRunRecord, CronSchedule, CronStore
from nanobot.cron.types import (
CronJob,
CronJobState,
CronPayload,
CronRunRecord,
CronSchedule,
CronStore,
)
def _now_ms() -> int:
@ -83,8 +92,20 @@ class CronService:
self._timer_active = False
self.max_sleep_ms = max_sleep_ms
def _load_jobs(self) -> tuple[list[CronJob], int]:
jobs = []
def _load_jobs(self) -> tuple[list[CronJob], int] | None:
"""Load jobs from disk.
Returns:
``(jobs, version)`` tuple on success or when no store file exists
(in which case an empty list and version 1 are returned).
``None`` when the store file exists but cannot be parsed; the
corrupt file is preserved with a ``.corrupt-<ts>`` suffix so the
caller can decide whether to overwrite or bail out. Returning a
sentinel here is important: silently treating a parse error as an
empty job list would cause the next ``_save_store`` to wipe every
job from disk.
"""
jobs: list[CronJob] = []
version = 1
if self.store_path.exists():
try:
@ -135,8 +156,22 @@ class CronService:
updated_at_ms=j.get("updatedAtMs", 0),
delete_after_run=j.get("deleteAfterRun", False),
))
except Exception as e:
logger.warning("Failed to load cron store: {}", e)
except Exception:
# Preserve the corrupt file for forensic recovery instead of
# letting the next save overwrite it with an empty job list.
backup = self.store_path.with_suffix(
self.store_path.suffix + f".corrupt-{int(time.time())}"
)
with suppress(OSError):
self.store_path.rename(backup)
logger.exception(
"Failed to load cron store at {}. "
"Corrupt file preserved at {}. "
"Refusing to overwrite to avoid data loss.",
self.store_path,
backup,
)
return None
return jobs, version
def _merge_action(self):
@ -166,8 +201,8 @@ class CronService:
else:
_update(action.get("params", {}))
changed = True
except Exception as exp:
logger.debug(f"load action line error: {exp}")
except Exception:
logger.exception("load action line error")
continue
self._store.jobs = list(jobs_map.values())
if self._running and changed:
@ -175,15 +210,28 @@ class CronService:
self._save_store()
return
def _load_store(self) -> CronStore:
def _load_store(self) -> CronStore | None:
"""Load jobs from disk. Reloads automatically if file was modified externally.
- Reload every time because it needs to merge operations on the jobs object from other instances.
- During _on_timer execution, return the existing store to prevent concurrent
_load_store calls (e.g. from list_jobs polling) from replacing it mid-execution.
- When the on-disk store exists but is unreadable: keep using the
previous in-memory ``self._store`` if we already have one (so a
transient corruption does not drop live jobs); only the very first
load (during ``start``) can return ``None`` to signal an unrecoverable
state to the caller.
"""
if self._timer_active and self._store:
return self._store
jobs, version = self._load_jobs()
loaded = self._load_jobs()
if loaded is None:
# Corrupt store on disk. Prefer the last good in-memory snapshot
# over wiping live jobs; ``_load_jobs`` has already moved the
# corrupt file aside with a ``.corrupt-<ts>`` suffix.
if self._store is not None:
return self._store
return None
jobs, version = loaded
self._store = CronStore(version=version, jobs=jobs)
self._merge_action()
@ -242,12 +290,56 @@ class CronService:
]
}
self.store_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
self._atomic_write(self.store_path, json.dumps(data, indent=2, ensure_ascii=False))
@staticmethod
def _atomic_write(path: Path, content: str) -> None:
"""Write *content* to *path* atomically with fsync.
Uses a temp-file + ``os.replace`` + ``fsync`` pattern so a crash or
SIGKILL mid-write cannot leave the destination truncated or invalid.
Mirrors ``nanobot.session.manager.SessionManager.save`` (see
commit 512bf59, ``fix(session): fsync sessions on graceful shutdown
to prevent data loss``). Without this, ``jobs.json`` could be
corrupted on container shutdown and silently re-created empty on
next start, wiping every scheduled job.
"""
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(path.suffix + ".tmp")
try:
with open(tmp_path, "w", encoding="utf-8") as f:
f.write(content)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, path)
# fsync the parent directory so the rename itself is durable.
# Skip on Windows where opening a directory raises PermissionError;
# NTFS journals metadata synchronously so this is a no-op there.
with suppress(PermissionError):
fd = os.open(str(path.parent), os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
except BaseException:
tmp_path.unlink(missing_ok=True)
raise
async def start(self) -> None:
"""Start the cron service."""
self._running = True
self._load_store()
loaded = self._load_store()
if loaded is None:
# Store file existed but was corrupt and has been preserved with
# a ``.corrupt-<ts>`` suffix. Bail out instead of starting with
# an empty store; that would call ``_save_store`` and overwrite
# the now-renamed (but still recoverable) data with [].
self._running = False
raise RuntimeError(
f"cron store at {self.store_path} is corrupt and was preserved; "
"refusing to start with an empty job list. "
"Inspect the .corrupt-<ts> backup and restore manually."
)
self._recompute_next_runs()
self._save_store()
self._arm_timer()
@ -302,6 +394,9 @@ class CronService:
async def _on_timer(self) -> None:
"""Handle timer tick - run due jobs."""
self._load_store()
# If a hot reload found a corrupt store on disk, ``self._store`` may
# still hold the previous, known-good in-memory snapshot. Keep using
# it rather than crashing the timer or wiping live jobs.
if not self._store:
self._arm_timer()
return
@ -338,7 +433,7 @@ class CronService:
except Exception as e:
job.state.last_status = "error"
job.state.last_error = str(e)
logger.error("Cron: job '{}' failed: {}", job.name, e)
logger.exception("Cron: job '{}' failed", job.name)
end_ms = _now_ms()
job.state.last_run_at_ms = start_ms

View File

@ -144,8 +144,8 @@ class HeartbeatService:
await self._tick()
except asyncio.CancelledError:
break
except Exception as e:
logger.error("Heartbeat error: {}", e)
except Exception:
logger.exception("Heartbeat error")
@staticmethod
def _is_deliverable(response: str) -> bool:

View File

@ -449,59 +449,6 @@ class OpenAICompatProvider(LLMProvider):
clean["content"] = self._coerce_content_to_string(clean.get("content"))
return self._enforce_role_alternation(sanitized)
def _drop_deepseek_incomplete_reasoning_history(
self,
messages: list[dict[str, Any]],
model_name: str,
reasoning_effort: str | None,
) -> list[dict[str, Any]]:
if (
not self._spec
or self._spec.name != "deepseek"
):
return messages
semantic_effort = reasoning_effort.lower() if isinstance(reasoning_effort, str) else None
if semantic_effort in {"none", "minimal", "minimum"}:
return messages
# DeepSeek-V4 can require reasoning_content even when the config did
# not explicitly request reasoning_effort. Keep that implicit-thinking
# cleanup scoped to known thinking-capable DeepSeek models so normal
# deepseek-chat history is not trimmed.
if semantic_effort is None:
model_lower = model_name.lower()
if not any(token in model_lower for token in ("deepseek-v4", "deepseek-reasoner")):
return messages
bad_idx = None
for idx, msg in enumerate(messages):
if (
msg.get("role") == "assistant"
and msg.get("tool_calls")
and not msg.get("reasoning_content")
):
bad_idx = idx
if bad_idx is None:
return messages
keep_from = None
for idx in range(bad_idx + 1, len(messages)):
if messages[idx].get("role") == "user":
keep_from = idx
break
if keep_from is None:
trimmed = messages[:bad_idx]
else:
prefix = [msg for msg in messages[:keep_from] if msg.get("role") == "system"]
trimmed = prefix + messages[keep_from:]
logger.warning(
"Dropped {} DeepSeek thinking history message(s) with incomplete reasoning_content",
len(messages) - len(trimmed),
)
return trimmed
# ------------------------------------------------------------------
# Build kwargs
# ------------------------------------------------------------------
@ -542,11 +489,6 @@ class OpenAICompatProvider(LLMProvider):
if spec and spec.strip_model_prefix:
model_name = model_name.split("/")[-1]
messages = self._drop_deepseek_incomplete_reasoning_history(
messages,
model_name,
reasoning_effort,
)
kwargs: dict[str, Any] = {
"model": model_name,
"messages": self._sanitize_messages(self._sanitize_empty_content(messages)),
@ -611,22 +553,22 @@ class OpenAICompatProvider(LLMProvider):
kwargs["tools"] = tools
kwargs["tool_choice"] = tool_choice or "auto"
# Backfill reasoning_content on legacy assistant messages.
# DeepSeek V4 (and potentially others) rejects thinking-mode
# requests that contain assistant messages without reasoning_content
# — even on turns that had no tool calls. This happens when a
# session was started with a non-thinking model or without
# reasoning_effort, then the user switches thinking mode on
# mid-session. Injecting an empty string satisfies the API
# without altering semantics (the model treats it as "no
# thinking happened on that turn").
thinking_active = (
(spec and spec.thinking_style and reasoning_effort is not None
and semantic_effort not in ("none", "minimal"))
or (reasoning_effort is not None and _is_kimi_thinking_model(model_name)
and semantic_effort not in ("none", "minimal"))
# Backfill reasoning_content="" on assistants missing it: DeepSeek
# thinking mode rejects history otherwise (#3554, #3584); "" reads
# as "no thinking that turn". DeepSeek-V4/reasoner reason natively,
# so backfill even without explicit reasoning_effort.
explicit_thinking = (
reasoning_effort is not None
and semantic_effort not in ("none", "minimal")
and ((spec and spec.thinking_style) or _is_kimi_thinking_model(model_name))
)
if thinking_active:
implicit_deepseek_thinking = (
spec is not None
and spec.name == "deepseek"
and semantic_effort not in ("none", "minimal", "minimum")
and any(t in model_name.lower() for t in ("deepseek-v4", "deepseek-reasoner"))
)
if explicit_thinking or implicit_deepseek_thinking:
for msg in kwargs["messages"]:
if msg.get("role") == "assistant" and "reasoning_content" not in msg:
msg["reasoning_content"] = ""

View File

@ -547,10 +547,13 @@ class SessionManager:
data = json.loads(first_line)
if data.get("_type") == "metadata":
key = data.get("key") or path.stem.replace("_", ":", 1)
metadata = data.get("metadata", {})
title = metadata.get("title") if isinstance(metadata, dict) else None
sessions.append({
"key": key,
"created_at": data.get("created_at"),
"updated_at": data.get("updated_at"),
"title": title if isinstance(title, str) else "",
"path": str(path)
})
except Exception:
@ -560,6 +563,11 @@ class SessionManager:
"key": repaired.key,
"created_at": repaired.created_at.isoformat(),
"updated_at": repaired.updated_at.isoformat(),
"title": (
repaired.metadata.get("title")
if isinstance(repaired.metadata.get("title"), str)
else ""
),
"path": str(path)
})
continue

View File

@ -93,7 +93,7 @@ def _extract_pdf(path: Path) -> str:
pages.append(f"--- Page {i} ---\n{text}")
return _truncate("\n\n".join(pages), _MAX_TEXT_LENGTH)
except Exception as e:
logger.error("Failed to extract PDF {}: {}", path, e)
logger.exception("Failed to extract PDF {}", path)
return f"[error: failed to extract PDF: {e!s}]"
@ -108,7 +108,7 @@ def _extract_docx(path: Path) -> str:
paragraphs: list[str] = [p.text for p in doc.paragraphs if p.text.strip()]
return _truncate("\n\n".join(paragraphs), _MAX_TEXT_LENGTH)
except Exception as e:
logger.error("Failed to extract DOCX {}: {}", path, e)
logger.exception("Failed to extract DOCX {}", path)
return f"[error: failed to extract DOCX: {e!s}]"
@ -135,7 +135,7 @@ def _extract_xlsx(path: Path) -> str:
finally:
wb.close()
except Exception as e:
logger.error("Failed to extract XLSX {}: {}", path, e)
logger.exception("Failed to extract XLSX {}", path)
return f"[error: failed to extract XLSX: {e!s}]"
@ -156,7 +156,7 @@ def _extract_pptx(path: Path) -> str:
slides.append(f"--- Slide {i} ---\n" + "\n".join(slide_text))
return _truncate("\n\n".join(slides), _MAX_TEXT_LENGTH)
except Exception as e:
logger.error("Failed to extract PPTX {}: {}", path, e)
logger.exception("Failed to extract PPTX {}", path)
return f"[error: failed to extract PPTX: {e!s}]"
@ -195,7 +195,7 @@ def _extract_text_file(path: Path) -> str:
content = path.read_text(encoding="latin-1")
return _truncate(content, _MAX_TEXT_LENGTH)
except Exception as e:
logger.error("Failed to read text file {}: {}", path, e)
logger.exception("Failed to read text file {}", path)
return f"[error: failed to read file: {e!s}]"

View File

@ -113,7 +113,7 @@ class GitStore:
logger.info("Git store initialized at {}", self._workspace)
return True
except Exception:
logger.warning("Git store init failed for {}", self._workspace)
logger.exception("Git store init failed for {}", self._workspace)
return False
# -- daily operations ------------------------------------------------------
@ -149,7 +149,7 @@ class GitStore:
logger.debug("Git auto-commit: {} ({})", sha, message)
return sha
except Exception:
logger.warning("Git auto-commit failed: {}", message)
logger.exception("Git auto-commit failed: {}", message)
return None
# -- internal helpers ------------------------------------------------------
@ -243,7 +243,7 @@ class GitStore:
return entries
except Exception:
logger.warning("Git log failed")
logger.exception("Git log failed")
return []
def line_ages(self, file_path: str) -> list[LineAge]:
@ -266,7 +266,7 @@ class GitStore:
annotated = porcelain.annotate(str(self._workspace), file_path)
except Exception:
logger.warning("Git line_ages annotate failed for {}", file_path)
logger.exception("Git line_ages annotate failed for {}", file_path)
return []
if not annotated:
@ -296,7 +296,7 @@ class GitStore:
)
return out.getvalue().decode("utf-8", errors="replace")
except Exception:
logger.warning("Git diff_commits failed")
logger.exception("Git diff_commits failed")
return ""
def find_commit(self, short_sha: str, max_entries: int = 20) -> CommitInfo | None:
@ -367,7 +367,7 @@ class GitStore:
msg = f"revert: undo {commit}"
return self.auto_commit(msg)
except Exception:
logger.warning("Git revert failed for {}", commit)
logger.exception("Git revert failed for {}", commit)
return None
@staticmethod

View File

@ -268,8 +268,8 @@ def maybe_persist_tool_result(
bucket = ensure_dir(root / safe_filename(session_key or "default"))
try:
_cleanup_tool_result_buckets(root, bucket)
except Exception as exc:
logger.warning("Failed to clean stale tool result buckets in {}: {}", root, exc)
except Exception:
logger.exception("Failed to clean stale tool result buckets in {}", root)
path = bucket / f"{safe_filename(tool_call_id)}.{suffix}"
if not path.exists():
if suffix == "json" and isinstance(content, list):
@ -540,6 +540,6 @@ def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]
)
gs.init()
except Exception:
logger.warning("Failed to initialize git store for {}", workspace)
logger.exception("Failed to initialize git store for {}", workspace)
return added

View File

@ -0,0 +1,47 @@
"""Utilities for redirecting stdlib logging to loguru."""
from __future__ import annotations
import logging
from loguru import logger
class _LoguruBridge(logging.Handler):
"""Route stdlib log records into loguru with consistent formatting."""
_LEVEL_MAP: dict[int, str] = {
logging.DEBUG: "DEBUG",
logging.INFO: "INFO",
logging.WARNING: "WARNING",
logging.ERROR: "ERROR",
logging.CRITICAL: "CRITICAL",
}
def __init__(self, lib_name: str) -> None:
super().__init__()
self.lib_name = lib_name
def emit(self, record: logging.LogRecord) -> None:
level = self._LEVEL_MAP.get(record.levelno, "INFO")
frame, depth = logging.currentframe(), 2
while frame and frame.f_code.co_filename == logging.__file__:
frame, depth = frame.f_back, depth + 1
logger.opt(depth=depth, exception=record.exc_info).log(
level, "[{lib}] {message}", lib=self.lib_name, message=record.getMessage()
)
def redirect_lib_logging(name: str, level: str | None = None) -> None:
"""Redirect stdlib logging from *name* into loguru.
Adds a bridge handler if one is not already present and disables
propagation so messages are not duplicated. When *level* is None the
handler does not filter loguru's own level controls visibility.
"""
lib_logger = logging.getLogger(name)
if not any(isinstance(h, _LoguruBridge) for h in lib_logger.handlers):
handler = _LoguruBridge(name)
if level is not None:
handler.setLevel(getattr(logging, level.upper(), logging.WARNING))
lib_logger.handlers = [handler]
lib_logger.propagate = False

View File

@ -2,6 +2,8 @@
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from loguru import logger
@ -10,6 +12,9 @@ from nanobot.utils.helpers import stringify_text_blocks
_MAX_REPEAT_EXTERNAL_LOOKUPS = 2
# Third same-target workspace violation in a turn escalates to "stop retrying".
_MAX_REPEAT_WORKSPACE_VIOLATIONS = 2
EMPTY_FINAL_RESPONSE_MESSAGE = (
"I completed the tool steps but couldn't produce a final answer. "
"Please try again or narrow the task."
@ -95,3 +100,71 @@ def repeated_external_lookup_error(
"Error: repeated external lookup blocked. "
"Use the results you already have to answer, or try a meaningfully different source."
)
# Workspace-boundary violations are soft errors, with per-target throttling.
_OUTSIDE_PATH_PATTERN = re.compile(r"(?:^|[\s|>'\"])((?:/[^\s\"'>;|<]+)|(?:~[^\s\"'>;|<]+))")
def workspace_violation_signature(
tool_name: str,
arguments: dict[str, Any],
) -> str | None:
"""Return a stable cross-tool signature for the outside-workspace target."""
for key in ("path", "file_path", "target", "source", "destination"):
val = arguments.get(key)
if isinstance(val, str) and val.strip():
return _normalize_violation_target(val.strip())
if tool_name in {"exec", "shell"}:
cmd = str(arguments.get("command") or "").strip()
if cmd:
match = _OUTSIDE_PATH_PATTERN.search(cmd)
if match:
return _normalize_violation_target(match.group(1))
cwd = str(arguments.get("working_dir") or "").strip()
if cwd:
return _normalize_violation_target(cwd)
return None
def _normalize_violation_target(raw: str) -> str:
"""Normalize *raw* path so that equivalent spellings collide on the same key."""
try:
normalized = Path(raw).expanduser().resolve().as_posix()
except Exception:
normalized = raw.replace("\\", "/")
return f"violation:{normalized}".lower()
def repeated_workspace_violation_error(
tool_name: str,
arguments: dict[str, Any],
seen_counts: dict[str, int],
) -> str | None:
"""Return an escalated error after repeated bypass attempts."""
signature = workspace_violation_signature(tool_name, arguments)
if signature is None:
return None
count = seen_counts.get(signature, 0) + 1
seen_counts[signature] = count
if count <= _MAX_REPEAT_WORKSPACE_VIOLATIONS:
return None
logger.warning(
"Escalating repeated workspace bypass attempt {} (attempt {})",
signature[:160],
count,
)
target = signature.split("violation:", 1)[1] if "violation:" in signature else signature
return (
"Error: refusing repeated workspace-bypass attempts.\n"
f"You have tried to access '{target}' (or an equivalent path) "
f"{count} times in this turn. This is a hard policy boundary -- "
"switching tools, shell tricks, working_dir overrides, symlinks, "
"or base64 piping will NOT change the answer. Stop retrying. "
"If the user genuinely needs this resource, tell them you cannot "
"access it and ask how they want to proceed (e.g. copy the file "
"into the workspace, or disable restrict_to_workspace for this run)."
)

View File

@ -0,0 +1,138 @@
"""Helpers for WebUI chat title generation."""
from __future__ import annotations
import re
from typing import Any
from loguru import logger
from nanobot.providers.base import LLMProvider
from nanobot.session.manager import Session, SessionManager
from nanobot.utils.helpers import truncate_text
WEBUI_SESSION_METADATA_KEY = "webui"
WEBUI_TITLE_METADATA_KEY = "title"
WEBUI_TITLE_USER_EDITED_METADATA_KEY = "title_user_edited"
TITLE_MAX_CHARS = 60
def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool:
"""Persist a WebUI marker only when the inbound websocket frame opted in."""
if metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return False
session.metadata[WEBUI_SESSION_METADATA_KEY] = True
return True
def clean_generated_title(raw: str | None) -> str:
text = (raw or "").strip()
if not text:
return ""
text = re.sub(r"^\s*(title|标题)\s*[:]\s*", "", text, flags=re.IGNORECASE)
text = text.strip().strip("\"'`“”‘’")
text = re.sub(r"\s+", " ", text).strip()
text = text.rstrip("。.!?,;:")
if len(text) > TITLE_MAX_CHARS:
text = text[: TITLE_MAX_CHARS - 1].rstrip() + ""
return text
def _title_inputs(session: Session) -> tuple[str, str]:
user_text = ""
assistant_text = ""
for message in session.messages:
role = message.get("role")
content = message.get("content")
if not isinstance(content, str) or not content.strip():
continue
if role == "user" and not user_text:
user_text = content.strip()
elif role == "assistant" and not assistant_text:
assistant_text = content.strip()
if user_text and assistant_text:
break
return user_text, assistant_text
async def maybe_generate_webui_title(
*,
sessions: SessionManager,
session_key: str,
provider: LLMProvider,
model: str,
) -> bool:
"""Generate and persist a short title for WebUI-owned sessions only."""
session = sessions.get_or_create(session_key)
if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return False
if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True:
return False
current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY)
if isinstance(current_title, str) and current_title.strip():
return False
user_text, assistant_text = _title_inputs(session)
if not user_text:
return False
prompt = (
"Generate a concise title for this chat.\n"
"Rules:\n"
"- Use the same language as the user when practical.\n"
"- 3 to 8 words.\n"
"- No quotes.\n"
"- No punctuation at the end.\n"
"- Return only the title.\n\n"
f"User: {truncate_text(user_text, 1_000)}"
)
if assistant_text:
prompt += f"\nAssistant: {truncate_text(assistant_text, 1_000)}"
try:
response = await provider.chat_with_retry(
[
{
"role": "system",
"content": (
"You write short, neutral chat titles. "
"Return only the title text."
),
},
{"role": "user", "content": prompt},
],
tools=None,
model=model,
max_tokens=32,
temperature=0.2,
retry_mode="standard",
)
except Exception:
logger.debug("Failed to generate webui session title for {}", session_key, exc_info=True)
return False
title = clean_generated_title(response.content)
if not title or title.lower().startswith("error"):
return False
session.metadata[WEBUI_TITLE_METADATA_KEY] = title
sessions.save(session)
return True
async def maybe_generate_webui_title_after_turn(
*,
channel: str,
metadata: dict[str, Any],
sessions: SessionManager,
session_key: str,
provider: LLMProvider,
model: str,
) -> bool:
if channel != "websocket" or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return False
return await maybe_generate_webui_title(
sessions=sessions,
session_key=session_key,
provider=provider,
model=model,
)

View File

@ -1,5 +1,6 @@
"""Tests for structured tool-event progress metadata emitted by AgentLoop."""
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
@ -130,11 +131,44 @@ class TestToolEventProgress:
assert finish["result"] == "file.txt"
@pytest.mark.asyncio
async def test_bus_progress_streams_provider_deltas_for_codex_style_provider(
async def test_non_streaming_channel_does_not_publish_codex_progress_deltas(
self,
tmp_path: Path,
) -> None:
"""Providers that opt in can stream content deltas through _progress messages."""
"""Non-streaming channels should get one final reply, not token progress spam."""
bus = MessageBus()
provider = MagicMock()
provider.supports_progress_deltas = True
provider.get_default_model.return_value = "openai-codex/gpt-5.5"
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Hello", tool_calls=[]))
provider.chat_stream_with_retry = AsyncMock()
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="openai-codex/gpt-5.5")
loop.tools.get_definitions = MagicMock(return_value=[])
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
await loop._dispatch(InboundMessage(
channel="whatsapp",
sender_id="u1",
chat_id="chat1",
content="say hello",
))
outbound = []
while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound())
assert [m.content for m in outbound] == ["Hello"]
assert not any(m.metadata.get("_progress") for m in outbound)
assert not any(m.metadata.get("_streamed") for m in outbound)
provider.chat_stream_with_retry.assert_not_awaited()
provider.chat_with_retry.assert_awaited_once()
@pytest.mark.asyncio
async def test_streaming_channel_streams_provider_deltas_for_codex_style_provider(
self,
tmp_path: Path,
) -> None:
"""Streaming channels still receive provider deltas through _stream_delta messages."""
bus = MessageBus()
provider = MagicMock()
provider.supports_progress_deltas = True
@ -149,23 +183,34 @@ class TestToolEventProgress:
provider.chat_with_retry = AsyncMock()
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="openai-codex/gpt-5.5")
loop.tools.get_definitions = MagicMock(return_value=[])
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="say hello",
metadata={"_wants_stream": True},
))
outbound = []
while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound())
progress = [m for m in outbound if m.metadata.get("_progress")]
final = [m for m in outbound if not m.metadata.get("_progress")]
deltas = [m for m in outbound if m.metadata.get("_stream_delta")]
stream_end = [m for m in outbound if m.metadata.get("_stream_end")]
final = [
m for m in outbound
if not m.metadata.get("_stream_delta")
and not m.metadata.get("_stream_end")
and not m.metadata.get("_turn_end")
]
assert [m.content for m in progress] == ["Hel", "lo"]
assert [m.content for m in deltas] == ["Hel", "lo"]
assert len(stream_end) == 1
assert final[-1].content == "Hello"
assert final[-1].metadata.get("_streamed") is True
assert outbound[-1].metadata.get("_turn_end") is True
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
@ -195,8 +240,12 @@ class TestToolEventProgress:
loop.tools.prepare_call = MagicMock(return_value=(None, {"path": "foo.txt"}, None))
loop.tools.execute = AsyncMock(return_value="ok")
streamed: list[str] = []
progress: list[tuple[str, bool, list[dict] | None]] = []
async def on_stream(delta: str) -> None:
streamed.append(delta)
async def on_progress(
content: str,
*,
@ -205,12 +254,107 @@ class TestToolEventProgress:
) -> None:
progress.append((content, tool_hint, tool_events))
final_content, _, _, _, _ = await loop._run_agent_loop([], on_progress=on_progress)
final_content, _, _, _, _ = await loop._run_agent_loop(
[],
on_progress=on_progress,
on_stream=on_stream,
)
assert final_content == "Done"
assert [item[0] for item in progress[:3]] == [
"I will",
" inspect it.",
'custom_tool("foo.txt")',
]
assert streamed == ["I will", " inspect it."]
assert progress[0][0] == 'custom_tool("foo.txt")'
assert all(item[0] != "I will inspect it." for item in progress)
@pytest.mark.asyncio
async def test_websocket_dispatch_publishes_final_turn_end_marker(self, tmp_path: Path) -> None:
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[]))
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
loop.tools.get_definitions = MagicMock(return_value=[])
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
await loop._dispatch(InboundMessage(
channel="websocket",
sender_id="u1",
chat_id="chat1",
content="say hello",
))
outbound = []
while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound())
assert outbound[-2].content == "Done"
assert (outbound[-2].metadata or {}).get("_turn_end") is not True
assert outbound[-1].content == ""
assert (outbound[-1].metadata or {}).get("_turn_end") is True
assert outbound[-1].chat_id == "chat1"
@pytest.mark.asyncio
async def test_webui_title_generation_runs_after_turn_end(self, tmp_path: Path) -> None:
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
title_started = asyncio.Event()
release_title = asyncio.Event()
calls = 0
async def chat_with_retry(*_args: object, **_kwargs: object) -> LLMResponse:
nonlocal calls
calls += 1
if calls == 1:
return LLMResponse(content="Done", tool_calls=[])
title_started.set()
await release_title.wait()
return LLMResponse(content="Generated title", tool_calls=[])
provider.chat_with_retry = AsyncMock(side_effect=chat_with_retry)
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
loop.tools.get_definitions = MagicMock(return_value=[])
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
await asyncio.wait_for(loop._dispatch(InboundMessage(
channel="websocket",
sender_id="u1",
chat_id="chat1",
content="say hello",
metadata={"webui": True},
)), timeout=0.5)
outbound = [await bus.consume_outbound(), await bus.consume_outbound()]
assert outbound[0].content == "Done"
assert (outbound[1].metadata or {}).get("_turn_end") is True
await asyncio.wait_for(title_started.wait(), timeout=0.5)
release_title.set()
session_updated = await asyncio.wait_for(bus.consume_outbound(), timeout=0.5)
assert (session_updated.metadata or {}).get("_session_updated") is True
assert provider.chat_with_retry.await_count == 2
@pytest.mark.asyncio
async def test_non_websocket_dispatch_does_not_publish_turn_end_marker(self, tmp_path: Path) -> None:
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[]))
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
loop.tools.get_definitions = MagicMock(return_value=[])
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
await loop._dispatch(InboundMessage(
channel="slack",
sender_id="u1",
chat_id="chat1",
content="say hello",
))
outbound = []
while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound())
assert len(outbound) == 1
assert outbound[0].content == "Done"
assert (outbound[0].metadata or {}).get("_turn_end") is not True

View File

@ -8,7 +8,13 @@ from nanobot.agent.context import ContextBuilder
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
from nanobot.session.manager import Session
from nanobot.utils.webui_titles import (
WEBUI_SESSION_METADATA_KEY,
WEBUI_TITLE_METADATA_KEY,
maybe_generate_webui_title,
)
def _mk_loop() -> AgentLoop:
@ -22,9 +28,56 @@ def _mk_loop() -> AgentLoop:
def _make_full_loop(tmp_path: Path) -> AgentLoop:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Test title"))
return AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
@pytest.mark.asyncio
async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
loop.provider.chat_with_retry = AsyncMock(
return_value=LLMResponse(content='"优化 WebUI 侧边栏。"', finish_reason="stop")
)
session = loop.sessions.get_or_create("websocket:chat-title")
session.metadata[WEBUI_SESSION_METADATA_KEY] = True
session.add_message("user", "帮我优化一下 webui 的 sidebar")
session.add_message("assistant", "可以,我会先调整布局和视觉层级。")
loop.sessions.save(session)
generated = await maybe_generate_webui_title(
sessions=loop.sessions,
session_key="websocket:chat-title",
provider=loop.provider,
model=loop.model,
)
assert generated is True
assert session.metadata[WEBUI_TITLE_METADATA_KEY] == "优化 WebUI 侧边栏"
loop.provider.chat_with_retry.assert_awaited_once()
@pytest.mark.asyncio
async def test_generate_webui_title_skips_plain_websocket_sessions(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
loop.provider.chat_with_retry = AsyncMock(
return_value=LLMResponse(content="Plain websocket title", finish_reason="stop")
)
session = loop.sessions.get_or_create("websocket:custom-client")
session.add_message("user", "hello from a custom websocket client")
loop.sessions.save(session)
generated = await maybe_generate_webui_title(
sessions=loop.sessions,
session_key="websocket:custom-client",
provider=loop.provider,
model=loop.model,
)
assert generated is False
assert WEBUI_TITLE_METADATA_KEY not in session.metadata
loop.provider.chat_with_retry.assert_not_awaited()
def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None:
loop = _mk_loop()
session = Session(key="test:runtime-only")

View File

@ -313,21 +313,33 @@ async def test_runner_returns_structured_tool_error():
@pytest.mark.asyncio
async def test_runner_stops_on_workspace_violation_without_fail_on_tool_error():
async def test_runner_does_not_abort_on_workspace_violation_anymore():
"""v2 behavior: workspace-bound rejections are *soft* tool errors.
Previously (PR #3493) any workspace boundary error became a fatal
RuntimeError that aborted the turn. That silently killed legitimate
workspace commands once the heuristic guard misfired (#3599 #3605), so
we now hand the error back to the LLM as a recoverable tool result and
rely on ``repeated_workspace_violation_error`` to throttle bypass loops.
"""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock()
provider.chat_with_retry = AsyncMock(side_effect=[
LLMResponse(
content="working",
tool_calls=[ToolCallRequest(id="call_1", name="read_file", arguments={"path": "/tmp/outside.md"})],
content="trying outside",
tool_calls=[ToolCallRequest(
id="call_1", name="read_file", arguments={"path": "/tmp/outside.md"},
)],
),
LLMResponse(content="should not continue", tool_calls=[]),
LLMResponse(content="ok, telling the user instead", tool_calls=[]),
])
tools = MagicMock()
tools.get_definitions.return_value = []
tools.execute = AsyncMock(
side_effect=PermissionError("Path /tmp/outside.md is outside allowed directory /workspace")
side_effect=PermissionError(
"Path /tmp/outside.md is outside allowed directory /workspace"
)
)
runner = AgentRunner(provider)
@ -336,41 +348,202 @@ async def test_runner_stops_on_workspace_violation_without_fail_on_tool_error():
initial_messages=[],
tools=tools,
model="test-model",
max_iterations=2,
max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
))
assert provider.chat_with_retry.await_count == 1
assert result.stop_reason == "tool_error"
assert "outside allowed directory" in (result.error or "")
assert result.tool_events == [
{
"name": "read_file",
"status": "error",
"detail": "workspace_violation: Path /tmp/outside.md is outside allowed directory /workspace",
}
]
assert provider.chat_with_retry.await_count == 2, (
"workspace violation must NOT short-circuit the loop"
)
assert result.stop_reason != "tool_error"
assert result.error is None
assert result.final_content == "ok, telling the user instead"
assert result.tool_events and result.tool_events[0]["status"] == "error"
# Detail still carries the workspace_violation breadcrumb for telemetry,
# but the runner did not raise.
assert "workspace_violation" in result.tool_events[0]["detail"]
def test_is_workspace_violation_recognizes_ssrf_block():
"""Internal/private URL block must be classified as a fatal workspace violation.
Regression guard: the deny/allowlist filter messages were intentionally split
out of `_WORKSPACE_BLOCK_MARKERS` so the LLM can retry, but SSRF rejections
are a hard security boundary and must remain fatal.
"""
def test_is_ssrf_violation_recognizes_private_url_blocks():
"""SSRF rejections are classified separately from workspace boundaries."""
from nanobot.agent.runner import AgentRunner
ssrf_msg = "Error: Command blocked by safety guard (internal/private URL detected)"
assert AgentRunner._is_workspace_violation(ssrf_msg) is True
assert AgentRunner._is_ssrf_violation(ssrf_msg) is True
assert AgentRunner._is_ssrf_violation(
"URL validation failed: Blocked: host resolves to private/internal address 192.168.1.2"
) is True
# Sanity: deny/allowlist filter messages are deliberately *not* fatal.
assert AgentRunner._is_workspace_violation(
# Workspace-bound markers are NOT classified as SSRF.
assert AgentRunner._is_ssrf_violation(
"Error: Command blocked by safety guard (path outside working dir)"
) is False
assert AgentRunner._is_ssrf_violation(
"Path /tmp/x is outside allowed directory /ws"
) is False
# Deny / allowlist filter messages stay non-fatal too.
assert AgentRunner._is_ssrf_violation(
"Error: Command blocked by deny pattern filter"
) is False
assert AgentRunner._is_workspace_violation(
"Error: Command blocked by allowlist filter (not in allowlist)"
) is False
@pytest.mark.asyncio
async def test_runner_returns_non_retryable_hint_on_ssrf_violation():
"""SSRF stays blocked, but the runtime gives the LLM a final chance to recover."""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock()
provider.chat_with_retry = AsyncMock(side_effect=[
LLMResponse(
content="curl-ing metadata",
tool_calls=[ToolCallRequest(
id="call_ssrf",
name="exec",
arguments={"command": "curl http://169.254.169.254"},
)],
),
LLMResponse(
content="I cannot access that private URL. Please share local files.",
tool_calls=[],
),
])
tools = MagicMock()
tools.get_definitions.return_value = []
tools.execute = AsyncMock(return_value=(
"Error: Command blocked by safety guard (internal/private URL detected)"
))
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[],
tools=tools,
model="test-model",
max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
))
assert provider.chat_with_retry.await_count == 2
assert result.stop_reason == "completed"
assert result.error is None
assert result.final_content == "I cannot access that private URL. Please share local files."
assert result.tool_events and result.tool_events[0]["detail"].startswith("ssrf_violation:")
tool_messages = [m for m in result.messages if m.get("role") == "tool"]
assert tool_messages
assert "non-bypassable security boundary" in tool_messages[0]["content"]
assert "Do not retry" in tool_messages[0]["content"]
assert "tools.ssrfWhitelist" in tool_messages[0]["content"]
@pytest.mark.asyncio
async def test_runner_lets_llm_recover_from_shell_guard_path_outside():
"""Reporter scenario for #3599 / #3605 -- guard hit, agent recovers.
The shell `_guard_command` heuristic fires on `2>/dev/null`-style
redirects and other shell idioms. Before v2 that abort'd the whole
turn (silent hang on Telegram per #3605); now the LLM gets the soft
error back and can finalize on the next iteration.
"""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock()
captured_second_call: list[dict] = []
async def chat_with_retry(*, messages, **kwargs):
if provider.chat_with_retry.await_count == 1:
return LLMResponse(
content="trying noisy cleanup",
tool_calls=[ToolCallRequest(
id="call_blocked",
name="exec",
arguments={"command": "rm scratch.txt 2>/dev/null"},
)],
)
captured_second_call[:] = list(messages)
return LLMResponse(content="recovered final answer", tool_calls=[])
provider.chat_with_retry = AsyncMock(side_effect=chat_with_retry)
tools = MagicMock()
tools.get_definitions.return_value = []
tools.execute = AsyncMock(
return_value="Error: Command blocked by safety guard (path outside working dir)"
)
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[],
tools=tools,
model="test-model",
max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
))
assert provider.chat_with_retry.await_count == 2, (
"guard hit must NOT short-circuit the loop -- LLM should get a second turn"
)
assert result.stop_reason != "tool_error"
assert result.error is None
assert result.final_content == "recovered final answer"
assert result.tool_events and result.tool_events[0]["status"] == "error"
# v2: detail keeps the breadcrumb but the runner did not raise.
assert "workspace_violation" in result.tool_events[0]["detail"]
@pytest.mark.asyncio
async def test_runner_throttles_repeated_workspace_bypass_attempts():
"""#3493 motivation: stop the LLM bypass loop without aborting the turn.
LLM keeps switching tools (read_file -> exec cat -> python -c open(...))
against the same outside path. After the soft retry budget is exhausted
the runner replaces the tool result with a hard "stop trying" message
so the model finally gives up and surfaces the boundary to the user.
"""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
bypass_attempts = [
ToolCallRequest(
id=f"a{i}", name="exec",
arguments={"command": f"cat /Users/x/Downloads/01.md # try {i}"},
)
for i in range(4)
]
responses: list[LLMResponse] = [
LLMResponse(content=f"try {i}", tool_calls=[bypass_attempts[i]])
for i in range(4)
]
responses.append(LLMResponse(content="ok telling user", tool_calls=[]))
provider = MagicMock()
provider.chat_with_retry = AsyncMock(side_effect=responses)
tools = MagicMock()
tools.get_definitions.return_value = []
tools.execute = AsyncMock(
return_value="Error: Command blocked by safety guard (path outside working dir)"
)
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[],
tools=tools,
model="test-model",
max_iterations=10,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
))
# All 4 bypass attempts surface to the LLM (no fatal abort), and the
# runner finally completes once the LLM stops asking.
assert result.stop_reason != "tool_error"
assert result.error is None
assert result.final_content == "ok telling user"
# The third+ attempts must have been escalated -- look at the events.
escalated = [
ev for ev in result.tool_events
if ev["status"] == "error"
and ev["detail"].startswith("workspace_violation_escalated:")
]
assert escalated, (
"expected at least one escalated workspace_violation event, got: "
f"{result.tool_events}"
)
@pytest.mark.asyncio
@ -470,7 +643,7 @@ def test_persist_tool_result_logs_cleanup_failures(monkeypatch, tmp_path):
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("busy")),
)
monkeypatch.setattr(
"nanobot.utils.helpers.logger.warning",
"nanobot.utils.helpers.logger.exception",
lambda message, *args: warnings.append(message.format(*args)),
)
@ -851,6 +1024,7 @@ async def test_runner_batches_read_only_tools_before_exclusive_work():
ToolCallRequest(id="rw1", name="write_a", arguments={}),
],
{},
{},
)
assert shared_events[0:2] == ["start:read_a", "start:read_b"]
@ -895,6 +1069,7 @@ async def test_runner_does_not_batch_exclusive_read_only_tools():
ToolCallRequest(id="ro2", name="read_b", arguments={}),
],
{},
{},
)
assert shared_events[0] == "start:read_a"
@ -1122,6 +1297,51 @@ async def test_streamed_flag_not_set_on_llm_error(tmp_path):
"_streamed must not be set when stop_reason is error"
@pytest.mark.asyncio
async def test_ssrf_soft_block_can_finalize_after_streamed_tool_call(tmp_path):
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
tool_call_resp = LLMResponse(
content="checking metadata",
tool_calls=[ToolCallRequest(
id="call_ssrf",
name="exec",
arguments={"command": "curl http://169.254.169.254/latest/meta-data/"},
)],
usage={},
)
provider.chat_stream_with_retry = AsyncMock(side_effect=[
tool_call_resp,
LLMResponse(
content="I cannot access private URLs. Please share the local file.",
tool_calls=[],
usage={},
),
])
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
loop.tools.get_definitions = MagicMock(return_value=[])
loop.tools.prepare_call = MagicMock(return_value=(None, {}, None))
loop.tools.execute = AsyncMock(return_value=(
"Error: Command blocked by safety guard (internal/private URL detected)"
))
result = await loop._process_message(
InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="hi"),
on_stream=AsyncMock(),
on_stream_end=AsyncMock(),
)
assert result is not None
assert result.content == "I cannot access private URLs. Please share the local file."
assert result.metadata.get("_streamed") is True
@pytest.mark.asyncio
async def test_next_turn_after_llm_error_keeps_turn_boundary(tmp_path):
from nanobot.agent.loop import AgentLoop

View File

@ -0,0 +1,79 @@
"""Tests for provider progress delta routing in the shared runner."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
@pytest.mark.asyncio
async def test_runner_can_disable_provider_progress_delta_streaming():
"""AgentLoop disables token progress streaming for non-streaming channels."""
provider = MagicMock()
provider.supports_progress_deltas = True
provider.chat_with_retry = AsyncMock(
return_value=LLMResponse(content="done", tool_calls=[], usage={})
)
provider.chat_stream_with_retry = AsyncMock()
tools = MagicMock()
tools.get_definitions.return_value = []
progress_cb = AsyncMock()
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[
{"role": "system", "content": "system"},
{"role": "user", "content": "hi"},
],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
stream_progress_deltas=False,
))
assert result.final_content == "done"
provider.chat_with_retry.assert_awaited_once()
provider.chat_stream_with_retry.assert_not_awaited()
progress_cb.assert_not_awaited()
@pytest.mark.asyncio
async def test_runner_streams_provider_progress_deltas_by_default():
"""Direct runner users keep the existing opt-in provider progress behavior."""
provider = MagicMock()
provider.supports_progress_deltas = True
async def chat_stream_with_retry(*, on_content_delta, **kwargs):
await on_content_delta("he")
await on_content_delta("llo")
return LLMResponse(content="hello", tool_calls=[], usage={})
provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock()
tools = MagicMock()
tools.get_definitions.return_value = []
progress_cb = AsyncMock()
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[
{"role": "system", "content": "system"},
{"role": "user", "content": "hi"},
],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
))
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()

View File

@ -1,4 +1,4 @@
from nanobot.session.manager import Session
from nanobot.session.manager import Session, SessionManager
def _assert_no_orphans(history: list[dict]) -> None:
@ -31,6 +31,18 @@ def _tool_turn(prefix: str, idx: int) -> list[dict]:
]
def test_list_sessions_includes_metadata_title(tmp_path):
manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:chat-title")
session.metadata["title"] = "自动生成标题"
manager.save(session)
rows = manager.list_sessions()
assert rows[0]["key"] == "websocket:chat-title"
assert rows[0]["title"] == "自动生成标题"
# --- Original regression test (from PR 2075) ---
def test_get_history_drops_orphan_tool_results_when_window_cuts_tool_calls():

View File

@ -1,14 +1,13 @@
from email.message import EmailMessage
from datetime import date
from pathlib import Path
import imaplib
from datetime import date
from email.message import EmailMessage
from pathlib import Path
import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.email import EmailChannel
from nanobot.channels.email import EmailConfig
from nanobot.channels.email import EmailChannel, EmailConfig
def _make_config(**overrides) -> EmailConfig:
@ -24,6 +23,7 @@ def _make_config(**overrides) -> EmailConfig:
smtp_username="bot@example.com",
smtp_password="secret",
mark_seen=True,
allow_from=["*"],
# Disable auth verification by default so existing tests are unaffected
verify_dkim=False,
verify_spf=False,
@ -707,8 +707,8 @@ def test_email_content_tagged_with_email_context(monkeypatch) -> None:
def test_check_authentication_results_method() -> None:
"""Unit test for the _check_authentication_results static method."""
from email.parser import BytesParser
from email import policy
from email.parser import BytesParser
# No Authentication-Results header
msg_no_auth = EmailMessage()
@ -788,6 +788,32 @@ def _make_raw_email_with_attachment(
return msg.as_bytes()
def test_fetch_new_messages_ignores_unauthorized_sender_before_attachments(monkeypatch) -> None:
raw = _make_raw_email_with_attachment(from_addr="blocked@example.com")
fake = _make_fake_imap(raw)
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
called = {"attachments": False}
def _extract_attachments(*_args, **_kwargs):
called["attachments"] = True
return []
monkeypatch.setattr(EmailChannel, "_extract_attachments", _extract_attachments)
cfg = _make_config(
allow_from=["allowed@example.com"],
allowed_attachment_types=["application/pdf"],
verify_dkim=False,
verify_spf=False,
)
channel = EmailChannel(cfg, MessageBus())
assert channel._fetch_new_messages() == []
assert called["attachments"] is False
assert fake.store_calls == [(b"1", "+FLAGS", "\\Seen")]
def test_extract_attachments_saves_pdf(tmp_path, monkeypatch) -> None:
"""PDF attachment is saved to media dir and path returned in media list."""
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)

View File

@ -445,6 +445,58 @@ async def test_on_message_no_extra_api_call_when_no_parent_id() -> None:
assert len(captured) == 1
# ---------------------------------------------------------------------------
# Inbound media tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_on_message_audio_publishes_downloaded_path_and_transcription() -> None:
channel = _make_feishu_channel()
channel._processed_message_ids.clear()
captured = []
async def capture(msg):
captured.append(msg)
channel.bus.publish_inbound = capture
channel._download_and_save_media = AsyncMock(
return_value=(r"C:\\Users\\dodre\\.nanobot\\media\\feishu\\voice.ogg", "[audio: voice.ogg]")
)
channel.transcribe_audio = AsyncMock(return_value="hello from voice")
channel._add_reaction = AsyncMock(return_value=None)
event = _make_feishu_event(
msg_type="audio",
content='{"file_key": "audio_key", "duration": 1000}',
message_id="om_audio",
)
await channel._on_message(event)
channel._download_and_save_media.assert_awaited_once_with(
"audio", {"file_key": "audio_key", "duration": 1000}, "om_audio"
)
channel.transcribe_audio.assert_awaited_once_with(r"C:\\Users\\dodre\\.nanobot\\media\\feishu\\voice.ogg")
assert len(captured) == 1
assert captured[0].media == [r"C:\\Users\\dodre\\.nanobot\\media\\feishu\\voice.ogg"]
assert captured[0].content == "[transcription: hello from voice]"
@pytest.mark.asyncio
async def test_download_and_save_media_returns_absolute_path_in_content(monkeypatch, tmp_path) -> None:
channel = _make_feishu_channel()
monkeypatch.setattr(feishu, "get_media_dir", lambda _channel: tmp_path)
channel._download_file_sync = MagicMock(return_value=(b"voice-bytes", None))
file_path, content_text = await channel._download_and_save_media(
"audio", {"file_key": "voice_key"}, "om_audio"
)
assert file_path == str(tmp_path / "voice_key.ogg")
assert (tmp_path / "voice_key.ogg").read_bytes() == b"voice-bytes"
assert content_text == f"[audio: {file_path}]"
# ---------------------------------------------------------------------------
# Session key derivation tests
# ---------------------------------------------------------------------------
@ -754,3 +806,26 @@ def test_on_background_task_done_removes_from_set() -> None:
loop.close()
assert task not in channel._background_tasks
@pytest.mark.asyncio
async def test_on_message_ignores_unauthorized_sender_before_side_effects() -> None:
channel = _make_feishu_channel(group_policy="open")
channel.config.allow_from = ["ou_allowed"]
channel._add_reaction = AsyncMock()
channel._download_and_save_media = AsyncMock(return_value=("/tmp/audio.ogg", "[audio]"))
channel.transcribe_audio = AsyncMock(return_value="transcript")
channel._handle_message = AsyncMock()
event = _make_feishu_event(
msg_type="audio",
content='{"file_key": "file_1"}',
sender_open_id="ou_blocked",
)
await channel._on_message(event)
channel._add_reaction.assert_not_awaited()
channel._download_and_save_media.assert_not_awaited()
channel.transcribe_audio.assert_not_awaited()
channel._handle_message.assert_not_awaited()

View File

@ -1,7 +1,7 @@
"""Tests for QQ channel media support: helpers, send, inbound, and upload."""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, patch
import pytest
@ -182,6 +182,35 @@ async def test_send_media_failure_falls_back_to_text() -> None:
assert "bad.png" in failure_calls[0]["content"]
@pytest.mark.asyncio
async def test_on_message_ignores_unauthorized_sender_before_attachments_and_ack() -> None:
channel = QQChannel(
QQConfig(
app_id="app",
secret="secret",
allow_from=["allowed-user"],
ack_message="Processing...",
),
MessageBus(),
)
channel._client = _FakeClient()
channel._handle_attachments = AsyncMock(return_value=(["/tmp/a.png"], ["file"], []))
channel._handle_message = AsyncMock()
data = SimpleNamespace(
id="msg-blocked",
content="hello",
author=SimpleNamespace(user_openid="blocked-user"),
attachments=[SimpleNamespace(filename="a.png")],
)
await channel._on_message(data, is_group=False)
channel._handle_attachments.assert_not_awaited()
channel._handle_message.assert_not_awaited()
assert channel._client.api.c2c_calls == []
# ── _on_message() exception handling ────────────────────────────────

View File

@ -306,17 +306,19 @@ async def test_on_error_logs_network_issues_as_warning(monkeypatch) -> None:
recorded: list[tuple[str, str]] = []
monkeypatch.setattr(
"nanobot.channels.telegram.logger.warning",
channel.logger,
"warning",
lambda message, error: recorded.append(("warning", message.format(error))),
)
monkeypatch.setattr(
"nanobot.channels.telegram.logger.error",
channel.logger,
"error",
lambda message, error: recorded.append(("error", message.format(error))),
)
await channel._on_error(object(), SimpleNamespace(error=NetworkError("proxy disconnected")))
assert recorded == [("warning", "Telegram network issue: proxy disconnected")]
assert recorded == [("warning", "network issue: proxy disconnected")]
@pytest.mark.asyncio
@ -330,13 +332,14 @@ async def test_on_error_summarizes_empty_network_error(monkeypatch) -> None:
recorded: list[tuple[str, str]] = []
monkeypatch.setattr(
"nanobot.channels.telegram.logger.warning",
channel.logger,
"warning",
lambda message, error: recorded.append(("warning", message.format(error))),
)
await channel._on_error(object(), SimpleNamespace(error=NetworkError("")))
assert recorded == [("warning", "Telegram network issue: NetworkError")]
assert recorded == [("warning", "network issue: NetworkError")]
@pytest.mark.asyncio
@ -348,17 +351,19 @@ async def test_on_error_keeps_non_network_exceptions_as_error(monkeypatch) -> No
recorded: list[tuple[str, str]] = []
monkeypatch.setattr(
"nanobot.channels.telegram.logger.warning",
channel.logger,
"warning",
lambda message, error: recorded.append(("warning", message.format(error))),
)
monkeypatch.setattr(
"nanobot.channels.telegram.logger.error",
channel.logger,
"error",
lambda message, error: recorded.append(("error", message.format(error))),
)
await channel._on_error(object(), SimpleNamespace(error=RuntimeError("boom")))
assert recorded == [("error", "Telegram error: boom")]
assert recorded == [("error", "error: boom")]
@pytest.mark.asyncio
@ -1309,6 +1314,58 @@ async def test_on_help_includes_restart_command() -> None:
assert "/dream-restore" in help_text
@pytest.mark.asyncio
async def test_on_start_ignores_unauthorized_user_silently() -> None:
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], group_policy="open"),
MessageBus(),
)
update = _make_telegram_update(text="/start", chat_type="private")
update.message.reply_text = AsyncMock()
await channel._on_start(update, None)
update.message.reply_text.assert_not_awaited()
@pytest.mark.asyncio
async def test_on_help_ignores_unauthorized_user_silently() -> None:
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], group_policy="open"),
MessageBus(),
)
update = _make_telegram_update(text="/help", chat_type="private")
update.message.reply_text = AsyncMock()
await channel._on_help(update, None)
update.message.reply_text.assert_not_awaited()
@pytest.mark.asyncio
async def test_on_message_ignores_unauthorized_user_before_side_effects() -> None:
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], group_policy="open"),
MessageBus(),
)
channel._app = _FakeApp(lambda: None)
started_typing: list[str] = []
handled: list[dict] = []
channel._start_typing = lambda chat_id: started_typing.append(chat_id)
channel._add_reaction = AsyncMock(return_value=None)
async def capture_handle(**kwargs) -> None:
handled.append(kwargs)
channel._handle_message = capture_handle
await channel._on_message(_make_telegram_update(text="hello", chat_type="private"), None)
assert started_typing == []
channel._add_reaction.assert_not_awaited()
assert handled == []
@pytest.mark.asyncio
async def test_on_message_location_content() -> None:
"""Location messages are forwarded as [location: lat, lon] content."""
@ -1750,3 +1807,32 @@ async def test_send_uses_native_keyboard_when_flag_on() -> None:
sent = channel._app.bot.sent_messages[0]
assert isinstance(sent.get("reply_markup"), InlineKeyboardMarkup)
assert "[Yes]" not in sent["text"] # native keyboard owns the rendering
@pytest.mark.asyncio
async def test_callback_query_ignores_unauthorized_user_before_side_effects() -> None:
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], inline_keyboards=True),
MessageBus(),
)
channel._handle_message = AsyncMock()
query = SimpleNamespace(
id="cb_1",
data="Yes",
answer=AsyncMock(),
message=SimpleNamespace(
chat_id=123,
edit_reply_markup=AsyncMock(),
),
)
update = SimpleNamespace(
callback_query=query,
effective_user=SimpleNamespace(id=12345, username="alice", first_name="Alice"),
)
await channel._on_callback_query(update, None)
query.answer.assert_not_awaited()
query.message.edit_reply_markup.assert_not_awaited()
channel._handle_message.assert_not_awaited()

View File

@ -167,6 +167,40 @@ def test_issue_route_secret_matches_empty_secret() -> None:
assert _issue_route_secret_matches(Headers([("Authorization", "Bearer anything")]), "") is True
@pytest.mark.asyncio
async def test_webui_message_envelope_marks_inbound_metadata(bus: MagicMock) -> None:
channel = _ch(bus)
conn = MagicMock()
conn.remote_address = ("127.0.0.1", 50123)
await channel._dispatch_envelope(
conn,
"webui-client",
{"type": "message", "chat_id": "chat-1", "content": "hello", "webui": True},
)
msg = bus.publish_inbound.await_args.args[0]
assert msg.channel == "websocket"
assert msg.chat_id == "chat-1"
assert msg.metadata["webui"] is True
assert msg.metadata["_wants_stream"] is True
@pytest.mark.asyncio
async def test_plain_websocket_message_does_not_mark_webui(bus: MagicMock) -> None:
channel = _ch(bus)
conn = MagicMock()
await channel._dispatch_envelope(
conn,
"custom-client",
{"type": "message", "chat_id": "chat-1", "content": "hello"},
)
msg = bus.publish_inbound.await_args.args[0]
assert "webui" not in msg.metadata
@pytest.mark.asyncio
async def test_send_delivers_json_message_with_media_and_reply() -> None:
bus = MagicMock()
@ -287,6 +321,44 @@ async def test_send_delta_emits_delta_and_stream_end() -> None:
assert second["stream_id"] == "sid"
@pytest.mark.asyncio
async def test_send_turn_end_emits_turn_end_event() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_turn_end": True},
))
mock_ws.send.assert_awaited_once()
body = json.loads(mock_ws.send.await_args.args[0])
assert body == {"event": "turn_end", "chat_id": "chat-1"}
@pytest.mark.asyncio
async def test_send_session_updated_emits_session_updated_event() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_session_updated": True},
))
mock_ws.send.assert_awaited_once()
body = json.loads(mock_ws.send.await_args.args[0])
assert body == {"event": "session_updated", "chat_id": "chat-1"}
@pytest.mark.asyncio
async def test_send_non_connection_closed_exception_is_raised() -> None:
bus = MagicMock()
@ -491,6 +563,34 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
await server_task
@pytest.mark.asyncio
async def test_commands_api_returns_slash_command_metadata(bus: MagicMock) -> None:
port = 29892
channel = _ch(bus, port=port)
channel._api_tokens["tok"] = time.monotonic() + 300
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
denied = await _http_get(f"http://127.0.0.1:{port}/api/commands")
assert denied.status_code == 401
response = await _http_get(
f"http://127.0.0.1:{port}/api/commands",
headers={"Authorization": "Bearer tok"},
)
assert response.status_code == 200
body = response.json()
commands = {row["command"]: row for row in body["commands"]}
assert commands["/stop"]["title"] == "Stop current task"
assert commands["/history"]["arg_hint"] == "[n]"
assert all("description" in row for row in body["commands"])
finally:
await channel.stop()
await server_task
def test_settings_payload_normalizes_camel_case_provider(
bus: MagicMock,
monkeypatch,
@ -545,6 +645,16 @@ async def test_end_to_end_server_pushes_streaming_deltas_to_client(bus: MagicMoc
end = json.loads(await client.recv())
assert end["event"] == "stream_end"
assert end["stream_id"] == "s1"
await channel.send(OutboundMessage(
channel="websocket",
chat_id=chat_id,
content="",
metadata={"_turn_end": True},
))
turn_end = json.loads(await client.recv())
assert turn_end == {"event": "turn_end", "chat_id": chat_id}
finally:
await channel.stop()
await server_task

View File

@ -379,3 +379,111 @@ async def test_api_token_pool_purges_expired(bus: MagicMock, tmp_path: Path) ->
headers = {"Authorization": "Bearer live"}
assert channel._check_api_token(_LiveReq()) is True
class _FakeConn:
"""Minimal connection stub with a configurable remote_address."""
def __init__(self, remote_address: tuple[str, int]):
self.remote_address = remote_address
def respond(self, status: int, body: str) -> Any:
from websockets.http11 import Response
return Response(status=status, body=body.encode())
class _FakeReq:
"""Minimal request stub with configurable headers."""
def __init__(self, headers: dict[str, str] | None = None):
self.headers = headers or {}
_REMOTE = _FakeConn(("192.168.1.5", 12345))
_LOCAL = _FakeConn(("127.0.0.1", 12345))
_NO_HEADERS = _FakeReq()
def test_wildcard_host_without_auth_raises_on_startup(bus: MagicMock) -> None:
import pytest
from pydantic_core import ValidationError
with pytest.raises(ValidationError, match="token"):
_ch(bus, host="0.0.0.0")
def test_wildcard_host_with_token_is_valid(bus: MagicMock) -> None:
channel = _ch(bus, host="0.0.0.0", token="my-token")
assert channel.config.host == "0.0.0.0"
def test_wildcard_host_with_secret_is_valid(bus: MagicMock) -> None:
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
assert channel.config.host == "0.0.0.0"
def test_wildcard_ipv6_without_auth_raises(bus: MagicMock) -> None:
import pytest
from pydantic_core import ValidationError
with pytest.raises(ValidationError, match="token"):
_ch(bus, host="::")
def test_wildcard_ipv6_with_secret_is_valid(bus: MagicMock) -> None:
channel = _ch(bus, host="::", tokenIssueSecret="s3cret")
resp = channel._handle_webui_bootstrap(
_REMOTE, _FakeReq({"X-Nanobot-Auth": "s3cret"})
)
assert resp.status_code == 200
def test_bootstrap_accepts_static_token_as_secret(bus: MagicMock) -> None:
"""When only token (not token_issue_secret) is set, bootstrap accepts it."""
channel = _ch(bus, host="0.0.0.0", token="static-tok")
resp = channel._handle_webui_bootstrap(
_REMOTE, _FakeReq({"Authorization": "Bearer static-tok"})
)
assert resp.status_code == 200
body = json.loads(resp.body)
assert body["token"].startswith("nbwt_")
def test_localhost_without_auth_is_valid(bus: MagicMock) -> None:
channel = _ch(bus, host="127.0.0.1")
resp = channel._handle_webui_bootstrap(_LOCAL, _NO_HEADERS)
assert resp.status_code == 200
def test_bootstrap_rejects_wrong_secret(bus: MagicMock) -> None:
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="correct")
resp = channel._handle_webui_bootstrap(
_REMOTE, _FakeReq({"Authorization": "Bearer wrong"})
)
assert resp.status_code == 401
def test_bootstrap_accepts_remote_with_valid_secret(bus: MagicMock) -> None:
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
resp = channel._handle_webui_bootstrap(
_REMOTE, _FakeReq({"Authorization": "Bearer s3cret"})
)
assert resp.status_code == 200
body = json.loads(resp.body)
assert body["token"].startswith("nbwt_")
def test_bootstrap_accepts_x_nanobot_auth_header(bus: MagicMock) -> None:
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
resp = channel._handle_webui_bootstrap(
_REMOTE, _FakeReq({"X-Nanobot-Auth": "s3cret"})
)
assert resp.status_code == 200
def test_bootstrap_secret_also_enforced_on_localhost(bus: MagicMock) -> None:
"""When secret is set, even localhost must provide it (reverse-proxy safety)."""
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
resp = channel._handle_webui_bootstrap(_LOCAL, _NO_HEADERS)
assert resp.status_code == 401

View File

@ -3,7 +3,6 @@
import os
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -451,6 +450,39 @@ async def test_process_text_message() -> None:
assert msg.metadata["msg_type"] == "text"
@pytest.mark.asyncio
async def test_enter_chat_ignores_unauthorized_user_before_welcome() -> None:
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["allowed"]), MessageBus())
client = _FakeWeComClient()
channel._client = client
channel.config.welcome_message = "hello"
await channel._on_enter_chat(_FakeFrame(body={"chatid": "blocked"}))
client.reply_welcome.assert_not_awaited()
@pytest.mark.asyncio
async def test_process_message_ignores_unauthorized_sender_before_download() -> None:
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["allowed"]), MessageBus())
client = _FakeWeComClient()
channel._client = client
channel._handle_message = AsyncMock()
frame = _FakeFrame(body={
"msgid": "msg_blocked",
"chatid": "chat1",
"from": {"userid": "blocked"},
"image": {"url": "https://example.com/img.png", "aeskey": "key123"},
})
await channel._process_message(frame, "image")
client.download_file.assert_not_awaited()
channel._handle_message.assert_not_awaited()
assert channel.bus.inbound_size == 0
@pytest.mark.asyncio
async def test_process_image_message() -> None:
"""Image message: download success → media_paths non-empty."""

View File

@ -5,8 +5,8 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import httpx
import pytest
import nanobot.channels.weixin as weixin_mod
from nanobot.bus.queue import MessageBus
@ -15,10 +15,10 @@ from nanobot.channels.weixin import (
ITEM_TEXT,
MESSAGE_TYPE_BOT,
WEIXIN_CHANNEL_VERSION,
_decrypt_aes_ecb,
_encrypt_aes_ecb,
WeixinChannel,
WeixinConfig,
_decrypt_aes_ecb,
_encrypt_aes_ecb,
)
@ -128,6 +128,34 @@ async def test_process_message_caches_context_token_and_send_uses_it() -> None:
channel._send_text.assert_awaited_once_with("wx-user", "pong", "ctx-2")
@pytest.mark.asyncio
async def test_process_message_ignores_unauthorized_sender_before_side_effects(tmp_path) -> None:
bus = MessageBus()
channel = WeixinChannel(
WeixinConfig(enabled=True, allow_from=["allowed-user"], state_dir=str(tmp_path)),
bus,
)
channel._download_media_item = AsyncMock(return_value="/tmp/test.jpg")
channel._start_typing = AsyncMock()
await channel._process_message(
{
"message_type": 1,
"message_id": "m-unauthorized",
"from_user_id": "blocked-user",
"context_token": "ctx-blocked",
"item_list": [
{"type": ITEM_IMAGE, "image_item": {"media": {"encrypt_query_param": "x"}}},
],
}
)
assert channel._context_tokens == {}
channel._download_media_item.assert_not_awaited()
channel._start_typing.assert_not_awaited()
assert bus.inbound_size == 0
@pytest.mark.asyncio
async def test_process_message_persists_context_token_to_state_file(tmp_path) -> None:
bus = MessageBus()
@ -291,21 +319,22 @@ async def test_process_message_does_not_fallback_when_top_level_media_exists_but
@pytest.mark.asyncio
async def test_send_without_context_token_does_not_send_text() -> None:
async def test_send_without_context_token_raises() -> None:
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel._send_text = AsyncMock()
await channel.send(
type("Msg", (), {"chat_id": "unknown-user", "content": "pong", "media": [], "metadata": {}})()
)
with pytest.raises(RuntimeError, match="context_token missing"):
await channel.send(
type("Msg", (), {"chat_id": "unknown-user", "content": "pong", "media": [], "metadata": {}})()
)
channel._send_text.assert_not_awaited()
@pytest.mark.asyncio
async def test_send_does_not_send_when_session_is_paused() -> None:
async def test_send_raises_when_session_is_paused() -> None:
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
@ -313,9 +342,10 @@ async def test_send_does_not_send_when_session_is_paused() -> None:
channel._pause_session(60)
channel._send_text = AsyncMock()
await channel.send(
type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})()
)
with pytest.raises(RuntimeError, match="session paused"):
await channel.send(
type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})()
)
channel._send_text.assert_not_awaited()
@ -1185,3 +1215,38 @@ async def test_send_media_network_error_does_not_double_api_calls() -> None:
# _send_media_file called once, _send_text never called
channel._send_media_file.assert_awaited_once()
channel._send_text.assert_not_awaited()
# ---------------------------------------------------------------------------
# Tests for _send_text raising on API errors (previously silently swallowed)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_text_raises_on_api_error() -> None:
"""_send_text must raise RuntimeError when the API returns a non-zero errcode,
matching _send_media_file behavior. This ensures ChannelManager can retry."""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel._api_post = AsyncMock(
return_value={"errcode": -14, "errmsg": "session expired"}
)
with pytest.raises(RuntimeError, match="WeChat send text error.*-14"):
await channel._send_text("wx-user", "hello", "ctx-expired")
channel._api_post.assert_awaited_once()
@pytest.mark.asyncio
async def test_send_text_succeeds_on_zero_errcode() -> None:
"""_send_text must NOT raise when errcode is 0."""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel._api_post = AsyncMock(return_value={"errcode": 0})
await channel._send_text("wx-user", "hello", "ctx-ok")
channel._api_post.assert_awaited_once()

View File

@ -116,7 +116,7 @@ async def test_send_when_disconnected_is_noop():
@pytest.mark.asyncio
async def test_group_policy_mention_skips_unmentioned_group_message():
ch = WhatsAppChannel({"enabled": True, "groupPolicy": "mention"}, MagicMock())
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"], "groupPolicy": "mention"}, MagicMock())
ch._handle_message = AsyncMock()
await ch._handle_bridge_message(
@ -139,7 +139,7 @@ async def test_group_policy_mention_skips_unmentioned_group_message():
@pytest.mark.asyncio
async def test_group_policy_mention_accepts_mentioned_group_message():
ch = WhatsAppChannel({"enabled": True, "groupPolicy": "mention"}, MagicMock())
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"], "groupPolicy": "mention"}, MagicMock())
ch._handle_message = AsyncMock()
await ch._handle_bridge_message(
@ -166,7 +166,7 @@ async def test_group_policy_mention_accepts_mentioned_group_message():
@pytest.mark.asyncio
async def test_sender_id_prefers_phone_jid_over_lid():
"""sender_id should resolve to phone number when @s.whatsapp.net JID is present."""
ch = WhatsAppChannel({"enabled": True}, MagicMock())
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
ch._handle_message = AsyncMock()
await ch._handle_bridge_message(
@ -187,7 +187,7 @@ async def test_sender_id_prefers_phone_jid_over_lid():
@pytest.mark.asyncio
async def test_lid_to_phone_cache_resolves_lid_only_messages():
"""When only LID is present, a cached LID→phone mapping should be used."""
ch = WhatsAppChannel({"enabled": True}, MagicMock())
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
ch._handle_message = AsyncMock()
# First message: both phone and LID → builds cache
@ -220,7 +220,7 @@ async def test_lid_to_phone_cache_resolves_lid_only_messages():
@pytest.mark.asyncio
async def test_voice_message_transcription_uses_media_path():
"""Voice messages are transcribed when media path is available."""
ch = WhatsAppChannel({"enabled": True}, MagicMock())
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
ch.transcription_provider = "openai"
ch.transcription_api_key = "sk-test"
ch._handle_message = AsyncMock()
@ -243,10 +243,32 @@ async def test_voice_message_transcription_uses_media_path():
assert kwargs["content"].startswith("Hello world")
@pytest.mark.asyncio
async def test_unauthorized_voice_message_does_not_transcribe() -> None:
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["allowed"]}, MagicMock())
ch._handle_message = AsyncMock()
ch.transcribe_audio = AsyncMock(return_value="Hello world")
await ch._handle_bridge_message(
json.dumps({
"type": "message",
"id": "v-blocked",
"sender": "blocked@s.whatsapp.net",
"pn": "",
"content": "[Voice Message]",
"timestamp": 1,
"media": ["/tmp/voice.ogg"],
})
)
ch.transcribe_audio.assert_not_awaited()
ch._handle_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_voice_message_no_media_shows_not_available():
"""Voice messages without media produce a fallback placeholder."""
ch = WhatsAppChannel({"enabled": True}, MagicMock())
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
ch._handle_message = AsyncMock()
await ch._handle_bridge_message(

View File

@ -0,0 +1,31 @@
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from nanobot.cli import commands
@pytest.mark.asyncio
async def test_interactive_retry_wait_is_rendered_as_progress_even_when_progress_disabled():
"""Provider retry waits should not fall through as assistant responses."""
calls: list[tuple[str, object | None]] = []
thinking = None
channels_config = SimpleNamespace(send_progress=False, send_tool_hints=False)
msg = SimpleNamespace(
content="Model request failed, retry in 2s (attempt 1).",
metadata={"_retry_wait": True},
)
async def fake_print(text: str, active_thinking: object | None) -> None:
calls.append((text, active_thinking))
with patch("nanobot.cli.commands._print_interactive_progress_line", side_effect=fake_print):
handled = await commands._maybe_print_interactive_progress(
msg,
thinking,
channels_config,
)
assert handled is True
assert calls == [("Model request failed, retry in 2s (attempt 1).", thinking)]

View File

@ -0,0 +1,166 @@
"""Persistence tests for ``nanobot.cron.service.CronService``.
These tests target the specific failure mode where a corrupt or partially
written ``jobs.json`` would silently turn into an empty job list on the next
start, deleting every scheduled job. See ``fix(cron): atomic write for
jobs.json + don't silently overwrite corrupt store``.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from nanobot.cron.service import CronService
from nanobot.cron.types import CronSchedule
def _seeded_store(tmp_path: Path) -> tuple[CronService, Path]:
"""Build a service with one persisted job on disk and return both the
service and the resolved store path. Adds the job via the action log
(the path used when the service is not running) and then triggers a
merge so ``jobs.json`` is written, mirroring the persisted on-disk
state seen in production."""
store_path = tmp_path / "cron" / "jobs.json"
service = CronService(store_path)
service.add_job(
name="Daily Loving Message",
schedule=CronSchedule(kind="cron", expr="0 10 * * *", tz="Asia/Kuwait"),
message="hello",
)
# add_job appended to action.jsonl; flush to jobs.json by toggling
# ``_running`` long enough for ``_merge_action`` to do its rewrite.
service._running = True
try:
service._load_store()
finally:
service._running = False
assert store_path.exists()
return service, store_path
def test_save_store_is_atomic(tmp_path: Path) -> None:
"""``_save_store`` must use temp-file + rename so an interrupted write
cannot leave the destination truncated or invalid."""
service, store_path = _seeded_store(tmp_path)
# Simulate an arbitrary save and confirm the result parses cleanly and
# no orphan ``.tmp`` is left behind.
service._save_store()
data = json.loads(store_path.read_text(encoding="utf-8"))
assert len(data["jobs"]) == 1
tmp_files = list(store_path.parent.glob("*.tmp"))
assert tmp_files == [], f"unexpected temp files left behind: {tmp_files}"
def test_save_store_failure_does_not_corrupt_existing_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""If writing the temp file blows up partway through, the previous
``jobs.json`` must remain readable. This is the regression we are
actually fixing: pre-fix, ``write_text`` would truncate the destination
in place and leave it corrupt."""
service, store_path = _seeded_store(tmp_path)
original = store_path.read_bytes()
# Inject a failure inside the temp-file write. ``os.replace`` should
# never run; the destination must keep its previous content.
real_open = open
def boom(path, *args, **kwargs): # type: ignore[no-untyped-def]
if str(path).endswith(".tmp"):
raise OSError("simulated disk full")
return real_open(path, *args, **kwargs)
monkeypatch.setattr("builtins.open", boom)
with pytest.raises(OSError, match="simulated disk full"):
service._save_store()
assert store_path.read_bytes() == original
def test_load_jobs_preserves_corrupt_store_and_returns_none(
tmp_path: Path,
) -> None:
"""A corrupt ``jobs.json`` must not be silently treated as an empty
list. The loader returns ``None`` and the corrupt file is moved aside
with a ``.corrupt-<ts>`` suffix so an operator can recover it."""
store_path = tmp_path / "cron" / "jobs.json"
store_path.parent.mkdir(parents=True)
store_path.write_text("{not valid json", encoding="utf-8")
service = CronService(store_path)
assert service._load_jobs() is None
# Original path is gone; a ``.corrupt-<ts>`` backup exists alongside it.
assert not store_path.exists()
backups = list(store_path.parent.glob("jobs.json.corrupt-*"))
assert len(backups) == 1
assert backups[0].read_text(encoding="utf-8") == "{not valid json"
def test_start_refuses_to_overwrite_corrupt_store(tmp_path: Path) -> None:
"""``start`` must abort instead of running ``_save_store`` against an
empty in-memory state when the on-disk store is corrupt. Otherwise the
next save would overwrite the (recoverable) corrupt file with an empty
job list and the user's jobs would be unrecoverable."""
store_path = tmp_path / "cron" / "jobs.json"
store_path.parent.mkdir(parents=True)
store_path.write_text("{still not json", encoding="utf-8")
service = CronService(store_path)
import asyncio
with pytest.raises(RuntimeError, match="corrupt"):
asyncio.run(service.start())
# Service is left in a stopped state so the operator notices.
assert service._running is False
# And the corrupt file is still recoverable from the .corrupt-<ts> copy.
backups = list(store_path.parent.glob("jobs.json.corrupt-*"))
assert len(backups) == 1
def test_load_store_falls_back_to_in_memory_on_corruption_after_start(
tmp_path: Path,
) -> None:
"""If the store file becomes corrupt *after* a successful start (e.g. a
rclone-mounted Drive returns a partial read), the service must keep
using its existing in-memory snapshot instead of dropping every job."""
service, store_path = _seeded_store(tmp_path)
# Force load so ``self._store`` is populated.
service._load_store()
snapshot = service._store
assert snapshot is not None and len(snapshot.jobs) == 1
# Now corrupt the file on disk.
store_path.write_text("\x00garbage\x00", encoding="utf-8")
# Subsequent reload returns the in-memory snapshot, not None or empty.
result = service._load_store()
assert result is snapshot
assert len(result.jobs) == 1
assert result.jobs[0].name == "Daily Loving Message"
def test_full_round_trip_survives_repeated_save_load(tmp_path: Path) -> None:
"""Sanity check: jobs survive add → save → reload across fresh
``CronService`` instances pointing at the same store."""
store_path = tmp_path / "cron" / "jobs.json"
s1 = CronService(store_path)
s1.add_job(
name="Daily Loving Message",
schedule=CronSchedule(kind="cron", expr="0 10 * * *", tz="Asia/Kuwait"),
message="hello",
)
s2 = CronService(store_path)
s2._load_store()
assert s2._store is not None
assert [j.name for j in s2._store.jobs] == ["Daily Loving Message"]

View File

@ -228,8 +228,9 @@ async def test_running_service_honors_external_disable(tmp_path) -> None:
)
await service.start()
try:
# Wait slightly to ensure file mtime is definitively different
await asyncio.sleep(0.05)
# Disable before yielding back to the event loop. On slower Windows CI
# a short sleep here can overrun the 200ms schedule and let the job fire
# before the external update is written.
external = CronService(store_path)
updated = external.enable_job(job.id, enabled=False)
assert updated is not None
@ -552,7 +553,7 @@ def test_update_job_offline_writes_action(tmp_path) -> None:
action_path = tmp_path / "cron" / "action.jsonl"
assert action_path.exists()
lines = [l for l in action_path.read_text().strip().split("\n") if l]
lines = [line for line in action_path.read_text().strip().split("\n") if line]
last = json.loads(lines[-1])
assert last["action"] == "update"
assert last["params"]["name"] == "updated-offline"

View File

@ -620,7 +620,8 @@ def _tool_call(call_id: str) -> dict:
}
def test_deepseek_thinking_drops_tool_history_missing_reasoning_content() -> None:
def test_deepseek_thinking_backfills_missing_reasoning_content_on_tool_history() -> None:
"""Backfill reasoning_content="" instead of dropping the turn (#3554, #3584)."""
kwargs = _deepseek_kwargs([
{"role": "system", "content": "system"},
{"role": "user", "content": "can we use wechat?"},
@ -629,10 +630,12 @@ def test_deepseek_thinking_drops_tool_history_missing_reasoning_content() -> Non
{"role": "user", "content": "continue"},
])
assert kwargs["messages"] == [
{"role": "system", "content": "system"},
{"role": "user", "content": "continue"},
assert [m["role"] for m in kwargs["messages"]] == [
"system", "user", "assistant", "tool", "user",
]
assistant = kwargs["messages"][2]
assert assistant["reasoning_content"] == ""
assert assistant["tool_calls"][0]["function"]["name"] == "my"
def test_deepseek_thinking_keeps_tool_history_with_reasoning_content() -> None:
@ -654,20 +657,6 @@ def test_deepseek_thinking_keeps_tool_history_with_reasoning_content() -> None:
assert kwargs["messages"][2]["role"] == "tool"
def test_deepseek_thinking_drops_current_bad_tool_turn_without_followup_user() -> None:
kwargs = _deepseek_kwargs([
{"role": "system", "content": "system"},
{"role": "user", "content": "can we use wechat?"},
{"role": "assistant", "content": "", "tool_calls": [_tool_call("call_bad")]},
{"role": "tool", "tool_call_id": "call_bad", "name": "my", "content": "channels"},
])
assert kwargs["messages"] == [
{"role": "system", "content": "system"},
{"role": "user", "content": "can we use wechat?"},
]
def test_openai_compat_keeps_tool_calls_after_consecutive_assistant_messages() -> None:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
@ -937,8 +926,8 @@ def test_backfill_does_not_touch_messages_when_thinking_explicitly_off() -> None
assert "reasoning_content" not in msg
def test_deepseek_v4_drops_incomplete_reasoning_history_when_effort_implicit() -> None:
"""DeepSeek-V4 may default to thinking, so incomplete legacy history is trimmed."""
def test_deepseek_v4_backfills_incomplete_reasoning_history_when_effort_implicit() -> None:
"""DeepSeek-V4 reasons natively: backfill even without explicit reasoning_effort."""
spec = find_by_name("deepseek")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
p = OpenAICompatProvider(api_key="k", default_model="deepseek-v4-pro", spec=spec)
@ -958,12 +947,16 @@ def test_deepseek_v4_drops_incomplete_reasoning_history_when_effort_implicit() -
reasoning_effort=None, tool_choice=None,
)
assert [msg["role"] for msg in kw["messages"]] == ["system", "user"]
assert [msg["role"] for msg in kw["messages"]] == [
"system", "user", "assistant", "tool", "user",
]
assert kw["messages"][2]["reasoning_content"] == ""
assert kw["messages"][-1]["content"] == "thanks"
def test_deepseek_chat_keeps_tool_history_when_effort_implicit() -> None:
"""Implicit cleanup must not trim non-thinking DeepSeek chat models."""
"""Non-thinking deepseek-chat must keep history untouched and must NOT
receive backfilled reasoning_content (#3554, #3584)."""
spec = find_by_name("deepseek")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
p = OpenAICompatProvider(api_key="k", default_model="deepseek-chat", spec=spec)
@ -985,6 +978,7 @@ def test_deepseek_chat_keeps_tool_history_when_effort_implicit() -> None:
roles = [msg["role"] for msg in kw["messages"]]
assert roles == ["user", "assistant", "tool", "user"]
assert kw["messages"][1]["tool_calls"]
assert "reasoning_content" not in kw["messages"][1]
def test_deepseek_coerces_list_content_to_string() -> None:

View File

@ -835,7 +835,7 @@ async def test_start_logs_install_hint_when_pyjwt_missing(make_channel, monkeypa
ch = make_channel()
errors = []
monkeypatch.setattr(msteams_module, "MSTEAMS_AVAILABLE", False)
monkeypatch.setattr(msteams_module.logger, "error", lambda message, *args: errors.append(message.format(*args)))
monkeypatch.setattr(ch.logger, "error", lambda message, *args: errors.append(message.format(*args)))
await ch.start()

View File

@ -112,33 +112,31 @@ class TestSpawnUnix:
class TestSpawnWindows:
@pytest.mark.asyncio
async def test_uses_comspec_from_env(self):
async def test_uses_create_subprocess_shell(self):
env = {"COMSPEC": r"C:\Windows\system32\cmd.exe", "PATH": ""}
with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
patch("asyncio.create_subprocess_shell", new_callable=AsyncMock) as mock_shell,
):
mock_exec.return_value = AsyncMock()
await ExecTool._spawn("dir", r"C:\Users", env)
mock_shell.return_value = AsyncMock()
await ExecTool._spawn("dir", r"C:\work", env)
args = mock_exec.call_args[0]
assert "cmd.exe" in args[0]
assert "/c" in args
args = mock_shell.call_args[0]
assert "dir" in args
@pytest.mark.asyncio
async def test_falls_back_to_default_comspec(self):
env = {"PATH": ""}
async def test_passes_cwd_and_env(self):
env = {"PATH": "/usr/bin"}
with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
patch.dict("os.environ", {}, clear=True),
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
patch("asyncio.create_subprocess_shell", new_callable=AsyncMock) as mock_shell,
):
mock_exec.return_value = AsyncMock()
await ExecTool._spawn("dir", r"C:\Users", env)
mock_shell.return_value = AsyncMock()
await ExecTool._spawn("echo hi", r"C:\work", env)
args = mock_exec.call_args[0]
assert args[0] == "cmd.exe"
kwargs = mock_shell.call_args[1]
assert kwargs["cwd"] == r"C:\work"
assert kwargs["env"] == env
# ---------------------------------------------------------------------------

View File

@ -3,6 +3,7 @@
from __future__ import annotations
import socket
import sys
from unittest.mock import patch
import pytest
@ -182,3 +183,63 @@ async def test_exec_ignores_workspace_check_when_not_restricted(tmp_path):
result = await tool.execute(command="echo ok", working_dir=str(other))
assert "ok" in result
assert "outside the configured workspace" not in result
# --- #3599: stdio redirects to /dev/null must not trip the workspace guard ----
@pytest.mark.parametrize(
"command",
[
# The exact command from the #3599 reporter.
'rm test_print.txt 2>/dev/null; echo "done"',
# Plain redirect of stdout / stderr.
"find . -type f >/dev/null",
"noisy_cmd 2>/dev/null",
"noisy_cmd >/dev/null 2>&1",
# Read from /dev/urandom is also a benign device read.
"head -c 16 /dev/urandom | xxd",
"echo done >/dev/stderr",
"echo line </dev/stdin",
# Per-process FD aliases never escape the workspace.
"cat /dev/fd/3",
],
)
def test_exec_allows_benign_device_targets_inside_workspace(tmp_path, command):
workspace = tmp_path / "workspace"
workspace.mkdir()
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
assert tool._guard_command(command, str(workspace)) is None
@pytest.mark.asyncio
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX rm and /dev/null syntax")
async def test_exec_3599_regression_rm_with_dev_null_redirect(tmp_path):
"""#3599: ``rm <ws-path> 2>/dev/null`` must succeed against the workspace guard."""
workspace = tmp_path / "workspace"
workspace.mkdir()
target = workspace / "test_print.txt"
target.write_text("scratch")
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True, timeout=5)
result = await tool.execute(
command=f'rm {target} 2>/dev/null; echo "done"',
working_dir=str(workspace),
)
assert "done" in result
assert "path outside working dir" not in result
assert not target.exists()
def test_exec_still_blocks_real_outside_path_via_redirect(tmp_path):
"""Redirect *targets* outside the workspace (not /dev/...) must still be blocked.
We only whitelist kernel device files; arbitrary outside redirects such as
``> /etc/issue`` should remain caught by the workspace guard so a buggy
LLM cannot exfiltrate data outside the workspace via stderr redirection.
"""
workspace = tmp_path / "workspace"
workspace.mkdir()
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
blocked = tool._guard_command("echo pwn > /etc/issue", str(workspace))
assert blocked is not None
assert "path outside working dir" in blocked

View File

@ -467,7 +467,7 @@ async def test_connect_mcp_servers_logs_stdio_pollution_hint(
yield # pragma: no cover
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _broken_stdio_client)
monkeypatch.setattr("nanobot.agent.tools.mcp.logger.error", _error)
monkeypatch.setattr("nanobot.agent.tools.mcp.logger.exception", _error)
registry = ToolRegistry()
stacks = await connect_mcp_servers({"gh": MCPServerConfig(command="github-mcp")}, registry)

View File

@ -242,13 +242,21 @@ def test_exec_extract_absolute_paths_captures_quoted_paths() -> None:
def test_exec_guard_blocks_home_path_outside_workspace(tmp_path) -> None:
tool = ExecTool(restrict_to_workspace=True)
error = tool._guard_command("cat ~/.nanobot/config.json", str(tmp_path))
assert error == "Error: Command blocked by safety guard (path outside working dir)"
assert error is not None
assert error.startswith(
"Error: Command blocked by safety guard (path outside working dir)"
)
assert "hard policy boundary" in error
def test_exec_guard_blocks_quoted_home_path_outside_workspace(tmp_path) -> None:
tool = ExecTool(restrict_to_workspace=True)
error = tool._guard_command('cat "~/.nanobot/config.json"', str(tmp_path))
assert error == "Error: Command blocked by safety guard (path outside working dir)"
assert error is not None
assert error.startswith(
"Error: Command blocked by safety guard (path outside working dir)"
)
assert "hard policy boundary" in error
def test_exec_guard_allows_media_path_outside_workspace(tmp_path, monkeypatch) -> None:
@ -300,7 +308,39 @@ def test_exec_guard_blocks_windows_drive_root_outside_workspace(monkeypatch) ->
tool = ExecTool(restrict_to_workspace=True)
error = tool._guard_command("dir E:\\", "E:\\workspace")
assert error == "Error: Command blocked by safety guard (path outside working dir)"
assert error is not None
assert error.startswith(
"Error: Command blocked by safety guard (path outside working dir)"
)
assert "hard policy boundary" in error
def test_exec_guard_allows_dev_null_redirect(tmp_path) -> None:
tool = ExecTool(restrict_to_workspace=True)
ws = tmp_path / "workspace"
ws.mkdir()
(ws / "file.txt").write_text("ok", encoding="utf-8")
error = tool._guard_command(f'rm "{ws / "file.txt"}" 2>/dev/null', str(ws))
assert error is None
def test_exec_guard_allows_dev_urandom(tmp_path) -> None:
tool = ExecTool(restrict_to_workspace=True)
error = tool._guard_command("cat /dev/urandom | head -c 16 > random.bin", str(tmp_path))
assert error is None
def test_exec_guard_blocks_non_benign_dev_path(tmp_path) -> None:
tool = ExecTool(restrict_to_workspace=True)
error = tool._guard_command("cat /dev/sda", str(tmp_path))
assert error is not None
assert "path outside working dir" in error
def test_exec_extract_absolute_paths_ignores_pipe_tilde() -> None:
cmd = "python query.py --query '{job=\"app\"} |~ \"error\"'"
paths = ExecTool._extract_absolute_paths(cmd)
assert not any(p.startswith("~") for p in paths)
# --- cast_params tests ---

View File

@ -0,0 +1,120 @@
"""Tests for repeated_workspace_violation throttle and signature."""
from __future__ import annotations
from nanobot.utils.runtime import (
repeated_workspace_violation_error,
workspace_violation_signature,
)
def test_signature_for_filesystem_tools_uses_path_argument():
sig_a = workspace_violation_signature(
"read_file", {"path": "/Users/x/Downloads/01.md"}
)
sig_b = workspace_violation_signature(
"write_file", {"path": "/Users/x/Downloads/01.md"}
)
sig_c = workspace_violation_signature(
"edit_file", {"file_path": "/Users/x/Downloads/01.md"}
)
assert sig_a is not None
assert sig_a == sig_b == sig_c, (
"the throttle must collapse equivalent paths across different tools "
"so the LLM cannot bypass it by switching tool"
)
assert "/users/x/downloads/01.md" in sig_a
def test_signature_for_exec_extracts_first_absolute_path_in_command():
sig = workspace_violation_signature(
"exec",
{"command": "cat /Users/x/Downloads/01.md && echo done"},
)
assert sig is not None
assert "/users/x/downloads/01.md" in sig
def test_signature_collides_across_filesystem_and_exec_for_same_target():
"""LLM bypass loops jump tools (read_file -> exec cat). Throttle must
treat both attempts as targeting the same outside resource."""
fs_sig = workspace_violation_signature(
"read_file", {"path": "/Users/x/Downloads/01.md"}
)
exec_sig = workspace_violation_signature(
"exec", {"command": "cat /Users/x/Downloads/01.md"}
)
assert fs_sig == exec_sig
def test_signature_falls_back_to_working_dir_when_no_absolute_in_command():
sig = workspace_violation_signature(
"exec",
{"command": "ls -la", "working_dir": "/etc"},
)
assert sig is not None
assert "/etc" in sig
def test_signature_is_none_for_unknown_tool_with_no_path():
assert workspace_violation_signature("web_search", {"query": "anything"}) is None
assert workspace_violation_signature("exec", {"command": "echo hello"}) is None
def test_repeated_workspace_violation_returns_none_within_budget():
counts: dict[str, int] = {}
arguments = {"path": "/Users/x/Downloads/01.md"}
assert repeated_workspace_violation_error("read_file", arguments, counts) is None
assert repeated_workspace_violation_error("read_file", arguments, counts) is None
def test_repeated_workspace_violation_escalates_after_third_attempt():
counts: dict[str, int] = {}
arguments = {"path": "/Users/x/Downloads/01.md"}
repeated_workspace_violation_error("read_file", arguments, counts)
repeated_workspace_violation_error("read_file", arguments, counts)
third = repeated_workspace_violation_error("read_file", arguments, counts)
assert third is not None
assert "refusing repeated workspace-bypass" in third
assert "/users/x/downloads/01.md" in third
assert "ask how they want to proceed" in third
def test_repeated_workspace_violation_independent_per_target():
"""Different outside paths must each get their own retry budget."""
counts: dict[str, int] = {}
repeated_workspace_violation_error(
"read_file", {"path": "/Users/x/Downloads/01.md"}, counts,
)
repeated_workspace_violation_error(
"read_file", {"path": "/Users/x/Downloads/01.md"}, counts,
)
# Different target, fresh budget.
assert repeated_workspace_violation_error(
"read_file", {"path": "/Users/x/Documents/notes.md"}, counts,
) is None
def test_repeated_workspace_violation_collapses_tool_switching():
"""LLM switches from read_file to exec cat then to python -c open(...)
against the same path; the throttle must escalate on the third attempt."""
counts: dict[str, int] = {}
repeated_workspace_violation_error(
"read_file", {"path": "/Users/x/Downloads/01.md"}, counts,
)
repeated_workspace_violation_error(
"exec", {"command": "cat /Users/x/Downloads/01.md"}, counts,
)
third = repeated_workspace_violation_error(
"exec",
{"command": "python3 -c \"open('/Users/x/Downloads/01.md').read()\""},
counts,
)
assert third is not None
assert "refusing repeated workspace-bypass" in third

View File

@ -72,6 +72,27 @@ If your gateway listens on a non-default port, point the dev server at it:
NANOBOT_API_URL=http://127.0.0.1:9000 bun run dev
```
### Access from another device (LAN)
To use the webui from another device on the same network, set `host` to `"0.0.0.0"` and configure a `token` or `tokenIssueSecret` in `~/.nanobot/config.json`:
```json
{
"channels": {
"websocket": {
"enabled": true,
"host": "0.0.0.0",
"port": 8765,
"tokenIssueSecret": "your-secret-here"
}
}
}
```
The gateway will refuse to start if `host` is `"0.0.0.0"` and neither `token` nor `tokenIssueSecret` is set.
Then open `http://<your-ip>:8765` on the other device. The webui will show an authentication form where you enter the secret. It is saved in your browser so you only need to enter it once.
## Build for packaged runtime
```bash

View File

@ -9,14 +9,23 @@ import { preloadMarkdownText } from "@/components/MarkdownText";
import { useSessions } from "@/hooks/useSessions";
import { useTheme } from "@/hooks/useTheme";
import { cn } from "@/lib/utils";
import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
import {
clearSavedSecret,
deriveWsUrl,
fetchBootstrap,
loadSavedSecret,
saveSecret,
} from "@/lib/bootstrap";
import { NanobotClient } from "@/lib/nanobot-client";
import { ClientProvider } from "@/providers/ClientProvider";
import type { ChatSummary } from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type BootState =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "auth"; failed?: boolean }
| {
status: "ready";
client: NanobotClient;
@ -25,9 +34,63 @@ type BootState =
};
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
const SIDEBAR_WIDTH = 279;
const SIDEBAR_WIDTH = 272;
type ShellView = "chat" | "settings";
function AuthForm({
failed,
onSecret,
}: {
failed: boolean;
onSecret: (secret: string) => void;
}) {
const { t } = useTranslation();
const [value, setValue] = useState("");
const [submitting, setSubmitting] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const secret = value.trim();
if (!secret) return;
setSubmitting(true);
onSecret(secret);
};
return (
<div className="flex h-full w-full items-center justify-center px-6">
<form
onSubmit={handleSubmit}
className="flex w-full max-w-sm flex-col gap-4"
>
<div className="flex flex-col items-center gap-1 text-center">
<p className="text-lg font-semibold">{t("app.auth.title")}</p>
<p className="text-sm text-muted-foreground">{t("app.auth.hint")}</p>
</div>
{failed && (
<p className="text-center text-sm text-destructive">
{t("app.auth.invalid")}
</p>
)}
<Input
type="password"
placeholder={t("app.auth.placeholder")}
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={submitting}
autoFocus
/>
<Button
type="submit"
className="w-full"
disabled={!value.trim() || submitting}
>
{t("app.auth.submit")}
</Button>
</form>
</div>
);
}
function readSidebarOpen(): boolean {
if (typeof window === "undefined") return true;
try {
@ -43,40 +106,55 @@ export default function App() {
const { t } = useTranslation();
const [state, setState] = useState<BootState>({ status: "loading" });
const bootstrapWithSecret = useCallback(
(secret: string) => {
let cancelled = false;
(async () => {
setState({ status: "loading" });
try {
const boot = await fetchBootstrap("", secret);
if (cancelled) return;
if (secret) saveSecret(secret);
const url = deriveWsUrl(boot.ws_path, boot.token);
const client = new NanobotClient({
url,
onReauth: async () => {
try {
const refreshed = await fetchBootstrap("", secret);
return deriveWsUrl(refreshed.ws_path, refreshed.token);
} catch {
return null;
}
},
});
client.connect();
setState({
status: "ready",
client,
token: boot.token,
modelName: boot.model_name ?? null,
});
} catch (e) {
if (cancelled) return;
const msg = (e as Error).message;
if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) {
setState({ status: "auth", failed: true });
} else {
setState({ status: "error", message: msg });
}
}
})();
return () => {
cancelled = true;
};
},
[],
);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const boot = await fetchBootstrap();
if (cancelled) return;
const url = deriveWsUrl(boot.ws_path, boot.token);
const client = new NanobotClient({
url,
onReauth: async () => {
try {
const refreshed = await fetchBootstrap();
return deriveWsUrl(refreshed.ws_path, refreshed.token);
} catch {
return null;
}
},
});
client.connect();
setState({
status: "ready",
client,
token: boot.token,
modelName: boot.model_name ?? null,
});
} catch (e) {
if (cancelled) return;
setState({ status: "error", message: (e as Error).message });
}
})();
return () => {
cancelled = true;
};
}, []);
const saved = loadSavedSecret();
return bootstrapWithSecret(saved);
}, [bootstrapWithSecret]);
useEffect(() => {
const warm = () => preloadMarkdownText();
@ -99,13 +177,6 @@ export default function App() {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col items-center gap-3 animate-in fade-in-0 duration-300">
<img
src="/brand/nanobot_icon.png"
alt=""
className="h-10 w-10 animate-pulse select-none"
aria-hidden
draggable={false}
/>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-foreground/40" />
@ -117,17 +188,18 @@ export default function App() {
</div>
);
}
if (state.status === "auth") {
return (
<AuthForm
failed={!!state.failed}
onSecret={(s) => bootstrapWithSecret(s)}
/>
);
}
if (state.status === "error") {
return (
<div className="flex h-full w-full items-center justify-center px-4 text-center">
<div className="flex max-w-md flex-col items-center gap-3">
<img
src="/brand/nanobot_icon.png"
alt=""
className="h-10 w-10 opacity-60 grayscale select-none"
aria-hidden
draggable={false}
/>
<p className="text-lg font-semibold">{t("app.error.title")}</p>
<p className="text-sm text-muted-foreground">{state.message}</p>
<p className="text-xs text-muted-foreground">
@ -144,18 +216,26 @@ export default function App() {
);
};
const handleLogout = () => {
if (state.status === "ready") {
state.client.close();
}
clearSavedSecret();
setState({ status: "auth" });
};
return (
<ClientProvider
client={state.client}
token={state.token}
modelName={state.modelName}
>
<Shell onModelNameChange={handleModelNameChange} />
<Shell onModelNameChange={handleModelNameChange} onLogout={handleLogout} />
</ClientProvider>
);
}
function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | null) => void }) {
function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: string | null) => void; onLogout: () => void }) {
const { t, i18n } = useTranslation();
const { theme, toggle } = useTheme();
const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
@ -213,7 +293,7 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
}
}, []);
const onNewChat = useCallback(async () => {
const onCreateChat = useCallback(async () => {
try {
const chatId = await createChat();
setActiveKey(`websocket:${chatId}`);
@ -226,6 +306,12 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
}
}, [createChat]);
const onNewChat = useCallback(() => {
setActiveKey(null);
setView("chat");
setMobileSidebarOpen(false);
}, []);
const onSelectChat = useCallback(
(key: string) => {
setActiveKey(key);
@ -235,6 +321,15 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
[],
);
const onOpenSettings = useCallback(() => {
setView("settings");
setMobileSidebarOpen(false);
}, []);
const onTurnEnd = useCallback(() => {
void refresh();
}, [refresh]);
const onConfirmDelete = useCallback(async () => {
if (!pendingDelete) return;
const key = pendingDelete.key;
@ -254,7 +349,8 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
}, [pendingDelete, deleteChat, activeKey, sessions]);
const headerTitle = activeSession
? activeSession.preview ||
? activeSession.title ||
activeSession.preview ||
t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) })
: t("app.brand");
@ -268,20 +364,10 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
sessions,
activeKey,
loading,
theme,
onToggleTheme: toggle,
onNewChat: () => {
void onNewChat();
},
onNewChat,
onSelect: onSelectChat,
onRefresh: () => void refresh(),
onRequestDelete: (key: string, label: string) =>
setPendingDelete({ key, label }),
activeView: view,
onOpenSettings: () => {
setView("settings" as const);
setMobileSidebarOpen(false);
},
};
return (
@ -296,10 +382,11 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
>
<div
className={cn(
"absolute inset-y-0 left-0 h-full w-[279px] overflow-hidden bg-sidebar shadow-inner-right",
"absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
"transition-transform duration-300 ease-out",
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
)}
style={{ width: SIDEBAR_WIDTH }}
>
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
</div>
@ -312,7 +399,8 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
<SheetContent
side="left"
showCloseButton={false}
className="w-[279px] p-0 sm:max-w-[279px] lg:hidden"
className="p-0 lg:hidden"
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
>
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
</SheetContent>
@ -325,14 +413,19 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
onToggleTheme={toggle}
onBackToChat={() => setView("chat")}
onModelNameChange={onModelNameChange}
onLogout={onLogout}
/>
) : (
<ThreadShell
session={activeSession}
title={headerTitle}
onToggleSidebar={toggleSidebar}
onGoHome={() => setActiveKey(null)}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
onOpenSettings={onOpenSettings}
hideSidebarToggleOnDesktop={desktopSidebarOpen}
/>
)}

View File

@ -8,7 +8,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import { relativeTime } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { ChatSummary } from "@/lib/types";
@ -18,10 +17,11 @@ interface ChatListProps {
onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => void;
loading?: boolean;
emptyLabel?: string;
}
function titleFor(s: ChatSummary, fallbackTitle: string): string {
const p = s.preview?.trim();
const p = (s.title || s.preview)?.trim();
if (p) return p.length > 48 ? `${p.slice(0, 45)}` : p;
return fallbackTitle;
}
@ -32,6 +32,7 @@ export function ChatList({
onSelect,
onRequestDelete,
loading,
emptyLabel,
}: ChatListProps) {
const { t } = useTranslation();
if (loading && sessions.length === 0) {
@ -44,73 +45,111 @@ export function ChatList({
if (sessions.length === 0) {
return (
<div className="px-3 py-6 text-xs text-muted-foreground">
{t("chat.noSessions")}
<div className="px-3 py-6 text-[12px] leading-5 text-muted-foreground/80">
{emptyLabel ?? t("chat.noSessions")}
</div>
);
}
const groups = groupSessions(sessions, {
today: t("chat.groups.today"),
yesterday: t("chat.groups.yesterday"),
earlier: t("chat.groups.earlier"),
});
return (
<ScrollArea className="h-full">
<ul className="space-y-0.5 px-2 py-1">
{sessions.map((s) => {
const active = s.key === activeKey;
const title = titleFor(
s,
t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }),
);
return (
<li key={s.key}>
<div
className={cn(
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-[12.5px] transition-colors",
active
? "bg-sidebar-accent/80 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--border)/0.4)]"
: "text-sidebar-foreground/88 hover:bg-sidebar-accent/45",
)}
>
<button
type="button"
onClick={() => onSelect(s.key)}
className="flex min-w-0 flex-1 flex-col items-start text-left"
>
<span className="w-full truncate font-medium leading-5">{title}</span>
<span className="text-[10.5px] text-muted-foreground/80">
{relativeTime(s.updatedAt ?? s.createdAt) || "—"}
</span>
</button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className={cn(
"inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
"focus-visible:opacity-100",
active && "opacity-100",
)}
aria-label={t("chat.actions", { title })}
>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
onSelect={() => {
window.setTimeout(() => onRequestDelete(s.key, title), 0);
}}
className="text-destructive focus:text-destructive"
<div className="space-y-3 px-2 py-1.5">
{groups.map((group) => (
<section key={group.label} aria-label={group.label}>
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
{group.label}
</div>
<ul className="space-y-0.5">
{group.sessions.map((s) => {
const active = s.key === activeKey;
const title = titleFor(
s,
t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }),
);
return (
<li key={s.key}>
<div
className={cn(
"group flex min-h-8 items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
active
? "bg-sidebar-accent/70 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.28)]"
: "text-sidebar-foreground/82 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
)}
>
<Trash2 className="mr-2 h-4 w-4" />
{t("chat.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
);
})}
</ul>
<button
type="button"
onClick={() => onSelect(s.key)}
className="min-w-0 flex-1 py-1.5 text-left"
>
<span className="block w-full truncate font-medium leading-5">{title}</span>
</button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className={cn(
"inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground/75 opacity-0 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
"focus-visible:opacity-100",
active && "opacity-100",
)}
aria-label={t("chat.actions", { title })}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
onSelect={() => {
window.setTimeout(() => onRequestDelete(s.key, title), 0);
}}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{t("chat.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
);
})}
</ul>
</section>
))}
</div>
</ScrollArea>
);
}
function groupSessions(
sessions: ChatSummary[],
labels: { today: string; yesterday: string; earlier: string },
): Array<{ label: string; sessions: ChatSummary[] }> {
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
const buckets = new Map<string, ChatSummary[]>();
for (const session of sessions) {
const timestamp = Date.parse(session.updatedAt ?? session.createdAt ?? "");
const label = Number.isFinite(timestamp) && timestamp >= startOfToday
? labels.today
: Number.isFinite(timestamp) && timestamp >= startOfYesterday
? labels.yesterday
: labels.earlier;
const bucket = buckets.get(label) ?? [];
bucket.push(session);
buckets.set(label, bucket);
}
return [labels.today, labels.yesterday, labels.earlier]
.map((label) => ({ label, sessions: buckets.get(label) ?? [] }))
.filter((group) => group.sessions.length > 0);
}

View File

@ -22,7 +22,7 @@ interface ChatPaneProps {
export function ChatPane({ session, onNewChat }: ChatPaneProps) {
const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null;
const { messages: historical, loading } = useSessionHistory(historyKey);
const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey);
const { client } = useClient();
const [booting, setBooting] = useState(false);
const pendingFirstRef = useRef<string | null>(null);
@ -31,6 +31,7 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) {
const { messages, isStreaming, send, setMessages } = useNanobotStream(
chatId,
initial,
hasPendingToolCalls,
);
useEffect(() => {
@ -78,20 +79,8 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) {
<section className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-1 flex-col items-center justify-center gap-8 px-4 pb-6">
<div className="flex flex-col items-center gap-4 animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<picture>
<source
srcSet="/brand/nanobot_logo.webp"
type="image/webp"
/>
<img
src="/brand/nanobot_logo.png"
alt="nanobot"
className="h-12 w-auto select-none drop-shadow-sm"
draggable={false}
/>
</picture>
<h1 className="text-xl font-medium tracking-tight text-foreground/90">
What's on your mind?
What can I do for you?
</h1>
<p className="max-w-md text-center text-sm text-muted-foreground">
Your conversations are persisted locally under the nanobot
@ -104,7 +93,7 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) {
disabled={booting}
onSend={handleWelcomeSend}
placeholder={
booting ? "Opening a new chat…" : "Type your message…"
booting ? "Opening a new chat…" : "Ask anything..."
}
/>
</div>

View File

@ -6,21 +6,21 @@ import { useClient } from "@/providers/ClientProvider";
import type { ConnectionStatus } from "@/lib/types";
const COPY: Record<ConnectionStatus, { color: string }> = {
idle: { color: "bg-card/40 text-muted-foreground" },
idle: { color: "text-muted-foreground" },
connecting: {
color: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
color: "text-amber-700 dark:text-amber-300",
},
open: {
color: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
color: "text-emerald-700 dark:text-emerald-400",
},
reconnecting: {
color: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
color: "text-amber-700 dark:text-amber-300",
},
closed: {
color: "bg-card/40 text-muted-foreground",
color: "text-muted-foreground",
},
error: {
color: "bg-destructive/10 text-destructive",
color: "text-destructive",
},
};
@ -39,7 +39,7 @@ export function ConnectionBadge() {
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-md border border-border/60 px-2 py-1 text-[11px] font-medium transition-colors",
"inline-flex min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 text-[11px] font-medium transition-colors",
meta.color,
)}
aria-live="polite"

View File

@ -1,5 +1,5 @@
import { useState } from "react";
import { ChevronRight, FileIcon, ImageIcon, PlaySquare, Wrench } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ImageLightbox } from "@/components/ImageLightbox";
@ -21,8 +21,33 @@ interface MessageBubbleProps {
* collapsible group so intermediate steps never masquerade as replies.
*/
export function MessageBubble({ message }: MessageBubbleProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copyResetRef = useRef<number | null>(null);
const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300";
useEffect(() => {
return () => {
if (copyResetRef.current !== null) {
window.clearTimeout(copyResetRef.current);
}
};
}, []);
const onCopyAssistantReply = useCallback(() => {
if (!navigator.clipboard) return;
void navigator.clipboard.writeText(message.content).then(() => {
setCopied(true);
if (copyResetRef.current !== null) {
window.clearTimeout(copyResetRef.current);
}
copyResetRef.current = window.setTimeout(() => {
setCopied(false);
copyResetRef.current = null;
}, 1_500);
});
}, [message.content]);
if (message.kind === "trace") {
return <TraceGroup message={message} animClass={baseAnim} />;
}
@ -60,6 +85,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
const empty = message.content.trim().length === 0;
const media = message.media ?? [];
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
return (
<div className={cn("w-full text-sm", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
{empty && message.isStreaming ? (
@ -69,6 +95,27 @@ export function MessageBubble({ message }: MessageBubbleProps) {
<MarkdownText>{message.content}</MarkdownText>
{message.isStreaming && <StreamCursor />}
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
{showAssistantActions ? (
<div className="mt-2 flex items-center gap-1 text-muted-foreground">
<button
type="button"
onClick={onCopyAssistantReply}
aria-label={copied ? t("message.copiedReply") : t("message.copyReply")}
title={copied ? t("message.copiedReply") : t("message.copyReply")}
className={cn(
"inline-flex h-8 w-8 items-center justify-center rounded-full",
"transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
{copied ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<Copy className="h-4 w-4" aria-hidden />
)}
</button>
</div>
) : null}
</>
)}
</div>

View File

@ -1,109 +1,121 @@
import { Moon, PanelLeftClose, RefreshCcw, Settings, SquarePen, Sun } from "lucide-react";
import { useMemo, useState } from "react";
import {
PanelLeftClose,
Search,
SquarePen,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import type { ChatSummary } from "@/lib/types";
interface SidebarProps {
sessions: ChatSummary[];
activeKey: string | null;
loading: boolean;
theme: "light" | "dark";
onToggleTheme: () => void;
onNewChat: () => void;
onSelect: (key: string) => void;
onRefresh: () => void;
onRequestDelete: (key: string, label: string) => void;
onCollapse: () => void;
activeView?: "chat" | "settings";
onOpenSettings: () => void;
}
export function Sidebar(props: SidebarProps) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const filteredSessions = useMemo(() => {
if (!normalizedQuery) return props.sessions;
return props.sessions.filter((session) => {
const haystack = [
session.preview,
session.chatId,
session.channel,
session.key,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(normalizedQuery);
});
}, [normalizedQuery, props.sessions]);
return (
<aside className="flex h-full w-full flex-col border-r border-sidebar-border/70 bg-sidebar text-sidebar-foreground">
<div className="flex items-center justify-between px-3 pb-2 pt-3">
<nav
aria-label={t("sidebar.navigation")}
className="flex h-full w-full flex-col border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground"
>
<div className="flex items-center justify-between px-3 pb-2.5 pt-3">
<picture className="block min-w-0">
<source srcSet="/brand/nanobot_logo.webp" type="image/webp" />
<img
src="/brand/nanobot_logo.png"
alt="nanobot"
className="h-7 w-auto select-none object-contain"
className="h-6 w-auto select-none object-contain opacity-95"
draggable={false}
/>
</picture>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
aria-label={t("sidebar.toggleTheme")}
onClick={props.onToggleTheme}
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
{props.theme === "dark" ? (
<Sun className="h-3.5 w-3.5" />
) : (
<Moon className="h-3.5 w-3.5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t("sidebar.collapse")}
onClick={props.onCollapse}
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</Button>
</div>
<Button
variant="ghost"
size="icon"
aria-label={t("sidebar.collapse")}
onClick={props.onCollapse}
className="h-7 w-7 rounded-lg text-muted-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</Button>
</div>
<div className="px-2 pb-2">
<div className="space-y-1.5 px-2 pb-2">
<label className="relative block">
<span className="sr-only">{t("sidebar.searchAria")}</span>
<Search
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/70"
aria-hidden
/>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("sidebar.searchPlaceholder")}
aria-label={t("sidebar.searchAria")}
className={cn(
"h-8 w-full rounded-full border border-transparent bg-sidebar-accent/45",
"pl-8 pr-3 text-[12.5px] text-sidebar-foreground outline-none",
"placeholder:text-muted-foreground/75",
"transition-colors hover:bg-sidebar-accent/65",
"focus:border-sidebar-border/80 focus:bg-sidebar-accent/70",
"focus:ring-1 focus:ring-sidebar-border/70",
)}
/>
</label>
<Button
onClick={props.onNewChat}
className="h-9 w-full justify-start gap-2 rounded-full px-3 text-[13px] font-medium text-sidebar-foreground/90 hover:bg-sidebar-accent hover:text-sidebar-foreground"
className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/92 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
variant="ghost"
>
<SquarePen className="h-3.5 w-3.5" />
{t("sidebar.newChat")}
</Button>
</div>
<div className="flex items-center justify-between px-3 pb-1.5 pt-2.5 text-[11px] font-medium text-muted-foreground">
<span>{t("sidebar.recent")}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-md text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
onClick={props.onRefresh}
aria-label={t("sidebar.refreshSessions")}
>
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex-1 overflow-hidden">
<ChatList
sessions={props.sessions}
sessions={filteredSessions}
activeKey={props.activeKey}
loading={props.loading}
emptyLabel={
normalizedQuery ? t("sidebar.noSearchResults") : t("chat.noSessions")
}
onSelect={props.onSelect}
onRequestDelete={props.onRequestDelete}
/>
</div>
<Separator className="bg-sidebar-border/50" />
<div className="flex items-center justify-between gap-2 px-2.5 py-2 text-xs">
<div className="flex items-center px-2.5 py-2.5 text-xs">
<ConnectionBadge />
<Button
onClick={props.onOpenSettings}
className="h-7 gap-1.5 rounded-md px-2 text-[11px] text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
variant={props.activeView === "settings" ? "secondary" : "ghost"}
>
<Settings className="h-3.5 w-3.5" />
Settings
</Button>
</div>
</aside>
</nav>
);
}

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronLeft, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { Button } from "@/components/ui/button";
@ -14,11 +15,13 @@ interface SettingsViewProps {
onToggleTheme: () => void;
onBackToChat: () => void;
onModelNameChange: (modelName: string | null) => void;
onLogout?: () => void;
}
export function SettingsView({
onBackToChat,
onModelNameChange,
onLogout,
}: SettingsViewProps) {
const { token } = useClient();
const [settings, setSettings] = useState<SettingsPayload | null>(null);
@ -115,6 +118,7 @@ export function SettingsView({
dirty={dirty}
saving={saving}
onSave={save}
onLogout={onLogout}
/>
) : null}
</main>
@ -129,6 +133,7 @@ function SettingsSection({
dirty,
saving,
onSave,
onLogout,
}: {
form: {
model: string;
@ -142,7 +147,9 @@ function SettingsSection({
dirty: boolean;
saving: boolean;
onSave: () => void;
onLogout?: () => void;
}) {
const { t } = useTranslation();
return (
<div className="space-y-7">
<section>
@ -192,6 +199,19 @@ function SettingsSection({
</SettingsRow>
</SettingsGroup>
</section>
{onLogout && (
<section>
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">{t("app.account.section")}</h2>
<SettingsGroup>
<SettingsRow title={t("app.account.logoutHint")}>
<Button size="sm" variant="outline" onClick={onLogout}>
{t("app.account.logout")}
</Button>
</SettingsRow>
</SettingsGroup>
</section>
)}
</div>
);
}

View File

@ -7,11 +7,21 @@ import {
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import {
Activity,
ArrowUp,
BookOpen,
CircleHelp,
History,
ImageIcon,
Loader2,
Paperclip,
Plus,
RotateCw,
Sparkles,
Square,
SquarePen,
Undo2,
X,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
@ -24,6 +34,7 @@ import {
} from "@/hooks/useAttachedImages";
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import type { SendImage } from "@/hooks/useNanobotStream";
import type { SlashCommand } from "@/lib/types";
import { cn } from "@/lib/utils";
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
@ -40,26 +51,49 @@ interface ThreadComposerProps {
onSend: (content: string, images?: SendImage[]) => void;
disabled?: boolean;
placeholder?: string;
isStreaming?: boolean;
modelLabel?: string | null;
variant?: "thread" | "hero";
slashCommands?: SlashCommand[];
}
const COMMAND_ICONS: Record<string, LucideIcon> = {
activity: Activity,
"book-open": BookOpen,
"circle-help": CircleHelp,
history: History,
"rotate-cw": RotateCw,
sparkles: Sparkles,
square: Square,
"square-pen": SquarePen,
"undo-2": Undo2,
};
function slashCommandI18nKey(command: string): string {
return command.replace(/^\//, "").replace(/-/g, "_");
}
export function ThreadComposer({
onSend,
disabled,
placeholder,
isStreaming = false,
modelLabel = null,
variant = "thread",
slashCommands = [],
}: ThreadComposerProps) {
const { t } = useTranslation();
const [value, setValue] = useState("");
const [inlineError, setInlineError] = useState<string | null>(null);
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const chipRefs = useRef(new Map<string, HTMLButtonElement>());
const isHero = variant === "hero";
const resolvedPlaceholder =
placeholder ?? t("thread.composer.placeholderThread");
const resolvedPlaceholder = isStreaming
? t("thread.composer.placeholderStreaming")
: placeholder ?? t("thread.composer.placeholderThread");
const { images, enqueue, remove, clear, encoding, full } =
useAttachedImages();
@ -116,6 +150,66 @@ export function ThreadComposer({
&& !hasErrors
&& (value.trim().length > 0 || readyImages.length > 0);
const slashQuery = useMemo(() => {
if (disabled || slashMenuDismissed || !value.startsWith("/")) return null;
const commandToken = value.slice(1);
if (/\s/.test(commandToken)) return null;
return commandToken.toLowerCase();
}, [disabled, slashMenuDismissed, value]);
const filteredSlashCommands = useMemo(() => {
if (slashQuery === null) return [];
return slashCommands
.filter((command) => {
const haystack = [
command.command,
command.title,
command.description,
command.argHint ?? "",
t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.title`, {
defaultValue: "",
}),
t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.description`, {
defaultValue: "",
}),
].join(" ").toLowerCase();
return haystack.includes(slashQuery);
})
.slice(0, 8);
}, [slashCommands, slashQuery, t]);
const showSlashMenu = filteredSlashCommands.length > 0;
useEffect(() => {
setSelectedCommandIndex(0);
}, [slashQuery]);
useEffect(() => {
if (selectedCommandIndex >= filteredSlashCommands.length) {
setSelectedCommandIndex(0);
}
}, [filteredSlashCommands.length, selectedCommandIndex]);
const resizeTextarea = useCallback(() => {
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
el.focus();
});
}, []);
const chooseSlashCommand = useCallback(
(command: SlashCommand) => {
setValue(command.argHint ? `${command.command} ` : command.command);
setSlashMenuDismissed(true);
setInlineError(null);
resizeTextarea();
},
[resizeTextarea],
);
const submit = useCallback(() => {
if (!canSend) return;
const trimmed = value.trim();
@ -139,16 +233,35 @@ export function ThreadComposer({
// Bubble owns the data URL copy; safe to revoke every staged blob
// preview here without affecting the rendered message.
clear();
requestAnimationFrame(() => {
const el = textareaRef.current;
if (el) {
el.style.height = "auto";
el.focus();
}
});
}, [canSend, clear, onSend, readyImages, value]);
setSlashMenuDismissed(false);
resizeTextarea();
}, [canSend, clear, onSend, readyImages, resizeTextarea, value]);
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (showSlashMenu) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedCommandIndex((idx) => (idx + 1) % filteredSlashCommands.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedCommandIndex(
(idx) => (idx - 1 + filteredSlashCommands.length) % filteredSlashCommands.length,
);
return;
}
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
e.preventDefault();
chooseSlashCommand(filteredSlashCommands[selectedCommandIndex]);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setSlashMenuDismissed(true);
return;
}
}
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
submit();
@ -210,14 +323,23 @@ export function ThreadComposer({
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={cn("w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
className={cn("relative w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
>
{showSlashMenu ? (
<SlashCommandPalette
commands={filteredSlashCommands}
selectedIndex={selectedCommandIndex}
isHero={isHero}
onHover={setSelectedCommandIndex}
onChoose={chooseSlashCommand}
/>
) : null}
<div
className={cn(
"relative mx-auto flex w-full flex-col overflow-hidden transition-all duration-200",
isHero
? "max-w-[40rem] rounded-[24px] border border-border/75 bg-card shadow-[0_10px_30px_rgba(0,0,0,0.10)]"
: "max-w-[49.5rem] rounded-[16px] border border-border/70 bg-card",
? "max-w-[58rem] rounded-[28px] border border-black/[0.035] bg-card shadow-[0_20px_55px_rgba(15,23,42,0.08)] dark:border-white/[0.06] dark:shadow-[0_24px_55px_rgba(0,0,0,0.34)]"
: "max-w-[49.5rem] rounded-[22px] border border-black/[0.035] bg-card shadow-[0_12px_30px_rgba(15,23,42,0.07)] dark:border-white/[0.06] dark:shadow-[0_16px_34px_rgba(0,0,0,0.28)]",
"focus-within:ring-1 focus-within:ring-foreground/8",
disabled && "opacity-60",
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
@ -254,7 +376,10 @@ export function ThreadComposer({
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onChange={(e) => {
setValue(e.target.value);
setSlashMenuDismissed(false);
}}
onInput={onInput}
onKeyDown={onKeyDown}
onPaste={onPaste}
@ -265,9 +390,9 @@ export function ThreadComposer({
className={cn(
"w-full resize-none bg-transparent",
isHero
? "min-h-[96px] px-4 pb-2 pt-4 text-[15px] leading-6"
? "min-h-[78px] px-5 pb-2 pt-5 text-[16px] leading-6"
: "min-h-[50px] px-4 pb-1.5 pt-3 text-sm",
"placeholder:text-muted-foreground",
"placeholder:text-muted-foreground/70",
"focus:outline-none focus-visible:outline-none",
"disabled:cursor-not-allowed",
)}
@ -286,7 +411,7 @@ export function ThreadComposer({
<div
className={cn(
"flex items-center justify-between gap-2",
isHero ? "px-3.5 pb-3.5" : "px-3 pb-2",
isHero ? "px-4 pb-4" : "px-3 pb-2",
)}
>
<div className="flex min-w-0 items-center gap-2">
@ -307,10 +432,12 @@ export function ThreadComposer({
onClick={() => fileInputRef.current?.click()}
className={cn(
"rounded-full text-muted-foreground hover:text-foreground",
isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5",
isHero
? "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
: "h-7.5 w-7.5 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
)}
>
<Paperclip className={cn(isHero ? "h-4 w-4" : "h-3.5 w-3.5")} />
<Plus className={cn(isHero ? "h-5 w-5" : "h-4 w-4")} />
</Button>
{modelLabel ? (
<span
@ -318,7 +445,9 @@ export function ThreadComposer({
className={cn(
"inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-1",
"border-foreground/10 bg-foreground/[0.035] font-medium text-foreground/80",
isHero ? "text-[11px]" : "text-[10.5px]",
isHero
? "max-w-[13rem] text-[12px] shadow-[0_2px_8px_rgba(15,23,42,0.04)]"
: "max-w-[10rem] text-[10.5px] shadow-[0_2px_8px_rgba(15,23,42,0.035)]",
)}
>
<span
@ -328,23 +457,31 @@ export function ThreadComposer({
<span className="truncate">{modelLabel}</span>
</span>
) : null}
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
{t("thread.composer.sendHint")}
</span>
{!isHero ? (
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
{t("thread.composer.sendHint")}
</span>
) : null}
</div>
<span className="sm:hidden" aria-hidden />
<span className={cn(isHero ? "hidden" : "sm:hidden")} aria-hidden />
<Button
type="submit"
size="icon"
disabled={!canSend}
aria-label={t("thread.composer.send")}
className={cn(
"rounded-full border border-border/70 bg-secondary/85 text-secondary-foreground shadow-none transition-transform hover:bg-accent",
isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5",
isHero
? "h-9 w-9 rounded-full border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80"
: "rounded-full border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] transition-transform hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80",
isHero ? "" : "h-7.5 w-7.5",
canSend && "hover:scale-[1.03] active:scale-95",
)}
>
<ArrowUp className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4")} />
{isStreaming ? (
<Loader2 className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4", "animate-spin")} />
) : (
<ArrowUp className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4")} />
)}
</Button>
</div>
</div>
@ -352,6 +489,106 @@ export function ThreadComposer({
);
}
interface SlashCommandPaletteProps {
commands: SlashCommand[];
selectedIndex: number;
isHero: boolean;
onHover: (index: number) => void;
onChoose: (command: SlashCommand) => void;
}
function SlashCommandPalette({
commands,
selectedIndex,
isHero,
onHover,
onChoose,
}: SlashCommandPaletteProps) {
const { t } = useTranslation();
return (
<div
role="listbox"
aria-label={t("thread.composer.slash.ariaLabel")}
className={cn(
"absolute bottom-full left-1/2 z-30 mb-2 max-h-[22rem] w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
"border-border/65 bg-popover/98 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur",
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
)}
>
<div className="px-2 pb-1 pt-1 text-[11px] font-medium tracking-[0.08em] text-muted-foreground/70">
{t("thread.composer.slash.label")}
</div>
<div className="max-h-[18rem] overflow-y-auto pr-0.5">
{commands.map((command, index) => {
const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp;
const selected = index === selectedIndex;
const commandKey = slashCommandI18nKey(command.command);
const title = t(`thread.composer.slash.commands.${commandKey}.title`, {
defaultValue: command.title,
});
const description = t(`thread.composer.slash.commands.${commandKey}.description`, {
defaultValue: command.description,
});
return (
<button
key={command.command}
type="button"
role="option"
aria-selected={selected}
onMouseEnter={() => onHover(index)}
onMouseDown={(e) => {
e.preventDefault();
onChoose(command);
}}
className={cn(
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
selected
? "bg-primary/10 text-foreground"
: "text-foreground/86 hover:bg-accent/55",
)}
>
<span
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-[10px] border",
selected
? "border-primary/25 bg-primary/12 text-primary"
: "border-border/65 bg-muted/45 text-muted-foreground",
)}
>
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-baseline gap-2">
<span className="font-mono text-[13px] font-semibold text-foreground">
{command.command}
</span>
{command.argHint ? (
<span className="font-mono text-[12px] text-muted-foreground">
{command.argHint}
</span>
) : null}
<span className="truncate text-[13px] font-medium">
{title}
</span>
</span>
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
{description}
</span>
</span>
</button>
);
})}
</div>
<div className="flex items-center gap-2 px-2 pt-1.5 text-[10.5px] text-muted-foreground/70">
<span>{t("thread.composer.slash.navigateHint")}</span>
<span>{t("thread.composer.slash.selectHint")}</span>
<span>{t("thread.composer.slash.closeHint")}</span>
</div>
</div>
);
}
interface AttachmentChipProps {
image: AttachedImage;
labelRemove: string;

View File

@ -1,4 +1,4 @@
import { PanelLeftOpen } from "lucide-react";
import { Menu, Moon, PanelLeftOpen, Settings, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@ -7,17 +7,66 @@ import { cn } from "@/lib/utils";
interface ThreadHeaderProps {
title: string;
onToggleSidebar: () => void;
onGoHome: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
onOpenSettings: () => void;
hideSidebarToggleOnDesktop?: boolean;
minimal?: boolean;
}
export function ThreadHeader({
title,
onToggleSidebar,
onGoHome,
theme,
onToggleTheme,
onOpenSettings,
hideSidebarToggleOnDesktop = false,
minimal = false,
}: ThreadHeaderProps) {
const { t } = useTranslation();
if (minimal) {
return (
<div className="relative z-10 flex h-11 items-center justify-between gap-3 px-3 py-2">
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleSidebar")}
onClick={onToggleSidebar}
className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
hideSidebarToggleOnDesktop && "lg:pointer-events-none lg:opacity-0",
)}
>
<Menu className="h-3.5 w-3.5" />
</Button>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleTheme")}
onClick={onToggleTheme}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.settings")}
onClick={onOpenSettings}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
<Settings className="h-4 w-4" />
</Button>
</div>
</div>
);
}
return (
<div className="relative z-10 flex items-center justify-between gap-3 px-3 py-2">
<div className="relative flex min-w-0 items-center gap-2">
@ -33,19 +82,34 @@ export function ThreadHeader({
>
<PanelLeftOpen className="h-3.5 w-3.5" />
</Button>
<button
type="button"
onClick={onGoHome}
className="flex min-w-0 items-center gap-2 rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent/35 hover:text-foreground"
>
<img
src="/brand/nanobot_icon.png"
alt=""
className="h-4 w-4 rounded-[5px] opacity-85"
aria-hidden
/>
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
</button>
</div>
</div>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleTheme")}
onClick={onToggleTheme}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.settings")}
onClick={onOpenSettings}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
<Settings className="h-4 w-4" />
</Button>
</div>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />

View File

@ -1,4 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
BarChart3,
BookOpen,
ChevronRight,
Code2,
LayoutGrid,
Lightbulb,
MoreHorizontal,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { AskUserPrompt } from "@/components/thread/AskUserPrompt";
@ -8,15 +17,21 @@ import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
import { ThreadViewport } from "@/components/thread/ThreadViewport";
import { useNanobotStream } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions";
import type { ChatSummary, UIMessage } from "@/lib/types";
import { listSlashCommands } from "@/lib/api";
import type { ChatSummary, SlashCommand, UIMessage } from "@/lib/types";
import { useClient } from "@/providers/ClientProvider";
interface ThreadShellProps {
session: ChatSummary | null;
title: string;
onToggleSidebar: () => void;
onGoHome: () => void;
onNewChat: () => Promise<string | null>;
onGoHome?: () => void;
onNewChat?: () => void;
onCreateChat?: () => Promise<string | null>;
onTurnEnd?: () => void;
theme?: "light" | "dark";
onToggleTheme?: () => void;
onOpenSettings?: () => void;
hideSidebarToggleOnDesktop?: boolean;
}
@ -28,22 +43,36 @@ function toModelBadgeLabel(modelName: string | null): string | null {
return leaf || trimmed;
}
const QUICK_ACTION_KEYS = [
{ key: "plan", icon: LayoutGrid, tone: "text-[#f25b8f]" },
{ key: "analyze", icon: BarChart3, tone: "text-[#4f9de8]" },
{ key: "brainstorm", icon: Lightbulb, tone: "text-[#53c59d]" },
{ key: "code", icon: Code2, tone: "text-[#eba45d]" },
{ key: "summarize", icon: BookOpen, tone: "text-[#a877e7]" },
{ key: "more", icon: MoreHorizontal, tone: "text-muted-foreground/65" },
] as const;
export function ThreadShell({
session,
title,
onToggleSidebar,
onGoHome,
onNewChat,
onCreateChat,
onTurnEnd,
theme = "light",
onToggleTheme = () => {},
onOpenSettings = () => {},
hideSidebarToggleOnDesktop = false,
}: ThreadShellProps) {
const { t } = useTranslation();
const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null;
const { messages: historical, loading } = useSessionHistory(historyKey);
const { client, modelName } = useClient();
const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey);
const { client, modelName, token } = useClient();
const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const pendingFirstRef = useRef<string | null>(null);
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
const lastCachedChatIdRef = useRef<string | null>(null);
const initial = useMemo(() => {
if (!chatId) return historical;
@ -56,7 +85,7 @@ export function ThreadShell({
setMessages,
streamError,
dismissStreamError,
} = useNanobotStream(chatId, initial);
} = useNanobotStream(chatId, initial, hasPendingToolCalls, onTurnEnd);
const showHeroComposer = messages.length === 0 && !loading;
const pendingAsk = useMemo(() => {
for (let index = messages.length - 1; index >= 0; index -= 1) {
@ -89,10 +118,24 @@ export function ThreadShell({
setMessages(historical);
}, [chatId, historical, setMessages]);
useEffect(() => {
if (!chatId) return;
useLayoutEffect(() => {
if (!chatId) {
lastCachedChatIdRef.current = null;
return;
}
if (loading) return;
// Skip the first cache write after a chat switch. During that render,
// `messages` can still belong to the previous chat until the stream hook
// resets its local state for the new session.
if (lastCachedChatIdRef.current !== chatId) {
lastCachedChatIdRef.current = chatId;
if (messages.length > 0) {
messageCacheRef.current.set(chatId, messages);
}
return;
}
messageCacheRef.current.set(chatId, messages);
}, [chatId, messages]);
}, [chatId, loading, messages]);
useEffect(() => {
if (!chatId) return;
@ -112,18 +155,115 @@ export function ThreadShell({
setBooting(false);
}, [chatId, client, setMessages]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const commands = await listSlashCommands(token);
if (!cancelled) setSlashCommands(commands);
} catch {
if (!cancelled) setSlashCommands([]);
}
})();
return () => {
cancelled = true;
};
}, [token]);
const handleWelcomeSend = useCallback(
async (content: string) => {
if (booting) return;
setBooting(true);
pendingFirstRef.current = content;
const newId = await onNewChat();
const newId = await onCreateChat?.();
if (!newId) {
pendingFirstRef.current = null;
setBooting(false);
}
},
[booting, onNewChat],
[booting, onCreateChat],
);
const handleQuickAction = useCallback(
(prompt: string) => {
if (session) {
send(prompt);
return;
}
void handleWelcomeSend(prompt);
},
[handleWelcomeSend, send, session],
);
const quickActions = (
<div className="mx-auto grid w-full max-w-[58rem] grid-cols-2 gap-3 pt-4 sm:grid-cols-3 lg:grid-cols-6 lg:gap-4">
{QUICK_ACTION_KEYS.map(({ key, icon: Icon, tone }) => {
const title = t(`thread.empty.quickActions.${key}.title`);
const prompt = t(`thread.empty.quickActions.${key}.prompt`);
return (
<button
key={key}
type="button"
onClick={() => handleQuickAction(prompt)}
disabled={booting || isStreaming}
className="group flex min-h-[136px] flex-col justify-between rounded-[20px] border border-black/[0.035] bg-card px-5 py-5 text-left shadow-[0_14px_34px_rgba(15,23,42,0.07)] transition-all hover:-translate-y-0.5 hover:shadow-[0_18px_42px_rgba(15,23,42,0.10)] disabled:pointer-events-none disabled:opacity-60 dark:border-white/[0.06] dark:shadow-[0_16px_34px_rgba(0,0,0,0.28)]"
>
<Icon className={`h-[18px] w-[18px] ${tone}`} strokeWidth={2} />
<span className="max-w-[7.5rem] text-[15px] font-medium leading-[1.28] tracking-[-0.01em] text-foreground/82">
{title}
</span>
<ChevronRight className="h-4 w-4 self-end text-muted-foreground/45 transition-colors group-hover:text-muted-foreground" />
</button>
);
})}
</div>
);
const composer = (
<>
{streamError ? (
<StreamErrorNotice
error={streamError}
onDismiss={dismissStreamError}
/>
) : null}
{pendingAsk ? (
<AskUserPrompt
question={pendingAsk.question}
buttons={pendingAsk.buttons}
onAnswer={send}
/>
) : null}
{session ? (
<ThreadComposer
onSend={send}
disabled={!chatId}
isStreaming={isStreaming}
placeholder={
showHeroComposer
? t("thread.composer.placeholderHero")
: t("thread.composer.placeholderThread")
}
modelLabel={toModelBadgeLabel(modelName)}
variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands}
/>
) : (
<ThreadComposer
onSend={handleWelcomeSend}
disabled={booting}
isStreaming={isStreaming}
placeholder={
booting
? t("thread.composer.placeholderOpening")
: t("thread.composer.placeholderHero")
}
modelLabel={toModelBadgeLabel(modelName)}
variant="hero"
/>
)}
{showHeroComposer ? quickActions : null}
</>
);
const emptyState = loading ? (
@ -131,20 +271,10 @@ export function ThreadShell({
{t("thread.loadingConversation")}
</div>
) : (
<div className="flex w-full max-w-[40rem] flex-col gap-2 text-left animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<div className="inline-flex items-center gap-2 text-[11px] font-medium text-muted-foreground">
<img
src="/brand/nanobot_icon.png"
alt=""
aria-hidden
draggable={false}
className="h-4 w-4 rounded-sm opacity-90"
/>
<span className="text-foreground/82">nanobot</span>
</div>
<p className="max-w-[28rem] text-[13px] leading-6 text-muted-foreground">
{t("thread.empty.description")}
</p>
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<h1 className="text-balance text-[40px] font-normal leading-tight tracking-[-0.045em] text-foreground sm:text-[48px]">
{t("thread.empty.greeting")}
</h1>
</div>
);
@ -153,55 +283,17 @@ export function ThreadShell({
<ThreadHeader
title={title}
onToggleSidebar={onToggleSidebar}
onGoHome={onGoHome}
theme={theme}
onToggleTheme={onToggleTheme}
onOpenSettings={onOpenSettings}
hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop}
minimal={!session && !loading}
/>
<ThreadViewport
messages={messages}
isStreaming={isStreaming}
emptyState={emptyState}
composer={
<>
{streamError ? (
<StreamErrorNotice
error={streamError}
onDismiss={dismissStreamError}
/>
) : null}
{pendingAsk ? (
<AskUserPrompt
question={pendingAsk.question}
buttons={pendingAsk.buttons}
onAnswer={send}
/>
) : null}
{session ? (
<ThreadComposer
onSend={send}
disabled={!chatId}
placeholder={
showHeroComposer
? t("thread.composer.placeholderHero")
: t("thread.composer.placeholderThread")
}
modelLabel={toModelBadgeLabel(modelName)}
variant={showHeroComposer ? "hero" : "thread"}
/>
) : (
<ThreadComposer
onSend={handleWelcomeSend}
disabled={booting}
placeholder={
booting
? t("thread.composer.placeholderOpening")
: t("thread.composer.placeholderHero")
}
modelLabel={toModelBadgeLabel(modelName)}
variant="hero"
/>
)}
</>
}
composer={composer}
/>
</section>
);

View File

@ -82,9 +82,9 @@ export function ThreadViewport({
</div>
</div>
) : (
<div className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col px-4">
<div className="flex w-full flex-1 justify-center pb-16 pt-14 md:pt-[3.5rem]">
<div className="flex w-full max-w-[40rem] flex-col gap-5">
<div className="mx-auto flex min-h-full w-full max-w-[72rem] flex-col px-4">
<div className="flex w-full flex-1 items-center justify-center pb-[7vh] pt-8">
<div className="flex w-full max-w-[58rem] flex-col gap-6">
{emptyState}
<div className="w-full">{composer}</div>
</div>

View File

@ -25,9 +25,9 @@
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.4375rem;
--sidebar: 0 0% 98%;
--sidebar: 0 0% 98.5%;
--sidebar-foreground: 0 0% 3.9%;
--sidebar-accent: 0 0% 96.1%;
--sidebar-accent: 0 0% 95.8%;
--sidebar-accent-foreground: 0 0% 9%;
--sidebar-border: 0 0% 89.8%;
}
@ -52,9 +52,9 @@
--border: 0 0% 18%;
--input: 0 0% 18%;
--ring: 0 0% 83.1%;
--sidebar: 0 0% 12%;
--sidebar: 0 0% 11.5%;
--sidebar-foreground: 0 0% 98%;
--sidebar-accent: 0 0% 16%;
--sidebar-accent: 0 0% 15.5%;
--sidebar-accent-foreground: 0 0% 98%;
--sidebar-border: 0 0% 18%;
}

View File

@ -37,6 +37,8 @@ export interface SendImage {
export function useNanobotStream(
chatId: string | null,
initialMessages: UIMessage[] = [],
hasPendingToolCalls = false,
onTurnEnd?: () => void,
): {
messages: UIMessage[];
isStreaming: boolean;
@ -51,9 +53,23 @@ export function useNanobotStream(
} {
const { client } = useClient();
const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
const [isStreaming, setIsStreaming] = useState(false);
/** If the last loaded message is a trace row (e.g. "Using 2 tools"),
* the model was still processing when the page loaded keep the
* loading spinner alive so the user sees the model is active. */
const initialStreaming = initialMessages.length > 0
? initialMessages[initialMessages.length - 1].kind === "trace"
: false;
const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls);
const [streamError, setStreamError] = useState<StreamError | null>(null);
const buffer = useRef<StreamBuffer | null>(null);
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
*
* When the model finishes a text segment and calls a tool, the server
* sends ``stream_end`` but the agent is still "thinking" while the tool
* executes. By deferring the flag reset by a short window (1 s) we keep
* the loading spinner alive across tool-call boundaries without needing
* backend changes. */
const streamEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return client.onError((err) => setStreamError(err));
@ -62,21 +78,43 @@ export function useNanobotStream(
const dismissStreamError = useCallback(() => setStreamError(null), []);
// Reset local state when switching chats. ``streamError`` is scoped to the
// send that triggered it, so a chat swap should wipe it out: a stale
// "Message too large" banner on a freshly-opened chat-B would confuse the
// user about which send actually failed (and in which chat).
useEffect(() => {
setMessages(initialMessages);
setIsStreaming(false);
setStreamError(null);
buffer.current = null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatId]);
// send that triggered it, so a chat swap should wipe it out: a stale
// "Message too large" banner on a freshly-opened chat-B would confuse the
// user about which send actually failed (and in which chat).
useEffect(() => {
setMessages(initialMessages);
// Check if the new chat's last message is a trace row — if so, the
// model may still be processing.
setIsStreaming(
initialMessages.length > 0
? initialMessages[initialMessages.length - 1].kind === "trace"
: false,
);
// Also consider hasPendingToolCalls from session history.
if (hasPendingToolCalls) {
setIsStreaming(true);
}
setStreamError(null);
buffer.current = null;
if (streamEndTimerRef.current !== null) {
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatId, initialMessages, hasPendingToolCalls]);
useEffect(() => {
if (!chatId) return;
const handle = (ev: InboundEvent) => {
// Any incoming event while the debounce timer is alive means the model
// is still working (e.g. tool result arrived, more text to stream).
// Cancel the pending "stream ended" timer so we don't hide the spinner.
if (streamEndTimerRef.current !== null) {
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}
if (ev.event === "delta") {
const id = buffer.current?.messageId ?? crypto.randomUUID();
if (!buffer.current) {
@ -103,18 +141,31 @@ export function useNanobotStream(
}
if (ev.event === "stream_end") {
if (!buffer.current) {
setIsStreaming(false);
return;
}
const finalId = buffer.current.messageId;
// stream_end only means the text segment finished — the model may
// still be executing tools. Do NOT reset isStreaming here; the
// definitive "turn is complete" signal is ``turn_end``.
if (!buffer.current) return;
buffer.current = null;
return;
}
if (ev.event === "turn_end") {
// Definitive signal that the turn is fully complete. Cancel any
// pending debounce timer and stop the loading indicator immediately.
if (streamEndTimerRef.current !== null) {
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}
setIsStreaming(false);
setMessages((prev) =>
prev.map((m) =>
m.id === finalId ? { ...m, isStreaming: false } : m,
),
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
);
onTurnEnd?.();
return;
}
if (ev.event === "session_updated") {
onTurnEnd?.();
return;
}
@ -157,7 +208,8 @@ export function useNanobotStream(
// flight, drop the placeholder so we don't render the text twice.
const activeId = buffer.current?.messageId;
buffer.current = null;
setIsStreaming(false);
// Do NOT reset isStreaming here — only ``turn_end`` signals that
// the full turn (all tool calls + final text) is complete.
setMessages((prev) => {
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
const content = ev.buttons?.length ? (ev.button_prompt ?? ev.text) : ev.text;
@ -183,8 +235,12 @@ export function useNanobotStream(
return () => {
unsub();
buffer.current = null;
if (streamEndTimerRef.current !== null) {
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}
};
}, [chatId, client]);
}, [chatId, client, onTurnEnd]);
const send = useCallback(
(content: string, images?: SendImage[]) => {
@ -205,6 +261,9 @@ export function useNanobotStream(
...(previews ? { images: previews } : {}),
},
]);
// Mark streaming immediately so the UI shows the loading indicator
// right away, before the first delta arrives from the server.
setIsStreaming(true);
const wireMedia = hasImages ? images!.map((i) => i.media) : undefined;
client.sendMessage(chatId, content, wireMedia);
},

View File

@ -61,6 +61,7 @@ export function useSessions(): {
chatId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
title: "",
preview: "",
},
...prev.filter((s) => s.key !== key),
@ -84,6 +85,9 @@ export function useSessionHistory(key: string | null): {
messages: UIMessage[];
loading: boolean;
error: string | null;
/** ``true`` when the last persisted assistant turn has ``tool_calls`` but no
* final text yet the model was still processing when the page loaded. */
hasPendingToolCalls: boolean;
} {
const { token } = useClient();
const [state, setState] = useState<{
@ -91,11 +95,13 @@ export function useSessionHistory(key: string | null): {
messages: UIMessage[];
loading: boolean;
error: string | null;
hasPendingToolCalls: boolean;
}>({
key: null,
messages: [],
loading: false,
error: null,
hasPendingToolCalls: false,
});
useEffect(() => {
@ -105,6 +111,7 @@ export function useSessionHistory(key: string | null): {
messages: [],
loading: false,
error: null,
hasPendingToolCalls: false,
});
return;
}
@ -116,6 +123,7 @@ export function useSessionHistory(key: string | null): {
messages: [],
loading: true,
error: null,
hasPendingToolCalls: false,
});
(async () => {
try {
@ -146,11 +154,21 @@ export function useSessionHistory(key: string | null): {
},
];
});
// Tool result rows can trail the assistant tool-call row while the turn
// is still running, so check the last conversational row.
const lastRaw = [...body.messages]
.reverse()
.find((m) => m.role === "user" || m.role === "assistant");
const hasPending =
lastRaw?.role === "assistant" &&
Array.isArray(lastRaw.tool_calls) &&
lastRaw.tool_calls.length > 0;
setState({
key,
messages: ui,
loading: false,
error: null,
hasPendingToolCalls: hasPending,
});
} catch (e) {
if (cancelled) return;
@ -162,6 +180,7 @@ export function useSessionHistory(key: string | null): {
messages: [],
loading: false,
error: null,
hasPendingToolCalls: false,
});
} else {
setState({
@ -169,6 +188,7 @@ export function useSessionHistory(key: string | null): {
messages: [],
loading: false,
error: (e as Error).message,
hasPendingToolCalls: false,
});
}
}
@ -179,19 +199,20 @@ export function useSessionHistory(key: string | null): {
}, [key, token]);
if (!key) {
return { messages: EMPTY_MESSAGES, loading: false, error: null };
return { messages: EMPTY_MESSAGES, loading: false, error: null, hasPendingToolCalls: false };
}
// Even before the effect above commits its loading state, never surface the
// previous session's payload for a brand-new key.
if (state.key !== key) {
return { messages: EMPTY_MESSAGES, loading: true, error: null };
return { messages: EMPTY_MESSAGES, loading: true, error: null, hasPendingToolCalls: false };
}
return {
messages: state.messages,
loading: state.loading,
error: state.error,
hasPendingToolCalls: state.hasPendingToolCalls,
};
}
@ -201,7 +222,7 @@ export function sessionTitle(
firstUserMessage?: string,
): string {
return deriveTitle(
firstUserMessage || session.preview,
session.title || firstUserMessage || session.preview,
i18n.t("chat.newChat"),
);
}

View File

@ -9,6 +9,18 @@
"title": "Couldn't reach nanobot",
"gatewayHint": "Make sure the gateway is running (`nanobot gateway`) and that this page is open on the same machine."
},
"auth": {
"title": "Authentication required",
"hint": "Enter the secret configured as tokenIssueSecret in your gateway config.",
"placeholder": "Password",
"submit": "Connect",
"invalid": "Invalid password. Try again."
},
"account": {
"section": "Account",
"logoutHint": "Disconnect this browser from the gateway.",
"logout": "Sign out"
},
"documentTitle": {
"base": "nanobot",
"chat": "{{title}} · nanobot"
@ -18,11 +30,19 @@
}
},
"sidebar": {
"navigation": "Sidebar navigation",
"globalActions": "Global actions",
"collapse": "Collapse sidebar",
"toggleTheme": "Toggle theme",
"home": "Home",
"newChat": "New chat",
"searchAria": "Search chats",
"searchPlaceholder": "Search chats",
"searchResults": "Results",
"noSearchResults": "No matching chats.",
"recent": "Recent",
"refreshSessions": "Refresh sessions",
"settings": "Settings",
"language": {
"label": "Language",
"ariaLabel": "Change language"
@ -34,7 +54,12 @@
"noSessions": "No sessions yet.",
"actions": "Chat actions for {{title}}",
"delete": "Delete",
"newChat": "New chat"
"newChat": "New chat",
"groups": {
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier"
}
},
"deleteConfirm": {
"title": "Delete “{{title}}”?",
@ -53,19 +78,100 @@
"thread": {
"loadingConversation": "Loading conversation…",
"empty": {
"description": "Ask questions, continue local work, or start a new thread."
"greeting": "What can I do for you?",
"quickActions": {
"plan": {
"title": "Create a project plan",
"prompt": "Create a concise project plan for what I should build next."
},
"analyze": {
"title": "Analyze this data",
"prompt": "Help me analyze this data and call out the most important patterns."
},
"brainstorm": {
"title": "Brainstorm ideas",
"prompt": "Brainstorm a few practical ideas and tradeoffs for this problem."
},
"code": {
"title": "Write code",
"prompt": "Help me write the code for this task, starting with the smallest useful change."
},
"summarize": {
"title": "Summarize this document",
"prompt": "Summarize this document and list the key takeaways."
},
"more": {
"title": "More",
"prompt": "Show me a few useful ways you can help in this workspace."
}
}
},
"header": {
"toggleSidebar": "Toggle sidebar"
"toggleSidebar": "Toggle sidebar",
"newChat": "Start a new chat",
"toggleTheme": "Toggle theme from header",
"settings": "Open settings"
},
"composer": {
"placeholderThread": "Type your message…",
"placeholderHero": "What's on your mind?",
"placeholderHero": "Ask anything...",
"placeholderOpening": "Opening a new chat…",
"placeholderStreaming": "Model is responding…",
"inputAria": "Message input",
"sendHint": "Enter to send · Shift+Enter for newline",
"send": "Send message",
"attachImage": "Attach image",
"tools": {
"search": "Search",
"reason": "Reason",
"deepResearch": "Deep research",
"voice": "Voice input"
},
"slash": {
"ariaLabel": "Slash commands",
"label": "commands",
"navigateHint": "↑↓ Navigate",
"selectHint": "Enter/Tab Select",
"closeHint": "Esc Close",
"commands": {
"new": {
"title": "New chat",
"description": "Stop the current task and start a fresh conversation."
},
"stop": {
"title": "Stop current task",
"description": "Cancel the active agent turn for this chat."
},
"restart": {
"title": "Restart nanobot",
"description": "Restart the bot process in place."
},
"status": {
"title": "Show status",
"description": "Display runtime, provider, and channel status."
},
"history": {
"title": "Show conversation history",
"description": "Print the last N persisted conversation messages."
},
"dream": {
"title": "Run Dream",
"description": "Manually trigger memory consolidation."
},
"dream_log": {
"title": "Show Dream log",
"description": "Show what the last Dream consolidation changed."
},
"dream_restore": {
"title": "Restore memory",
"description": "Revert memory to a previous Dream snapshot."
},
"help": {
"title": "Show help",
"description": "List available slash commands."
}
}
},
"encoding": "Encoding…",
"remove": "Remove attachment",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
@ -85,7 +191,9 @@
"assistantTyping": "Assistant is typing",
"toolSingle": "Using a tool",
"toolMany": "Used {{count}} tools",
"imageAttachment": "Image attachment"
"imageAttachment": "Image attachment",
"copyReply": "Copy reply",
"copiedReply": "Copied reply"
},
"lightbox": {
"title": "Image preview",

View File

@ -53,7 +53,34 @@
"thread": {
"loadingConversation": "Cargando conversación…",
"empty": {
"description": "Haz preguntas, continúa tu trabajo local o inicia un nuevo hilo."
"description": "Haz preguntas, continúa tu trabajo local o inicia un nuevo hilo.",
"greeting": "¿Qué puedo hacer por ti?",
"quickActions": {
"plan": {
"title": "Crear un plan de proyecto",
"prompt": "Crea un plan de proyecto conciso para lo que debería construir después."
},
"analyze": {
"title": "Analizar estos datos",
"prompt": "Ayúdame a analizar estos datos y destaca los patrones más importantes."
},
"brainstorm": {
"title": "Lluvia de ideas",
"prompt": "Propón algunas ideas prácticas y sus compensaciones para este problema."
},
"code": {
"title": "Escribir código",
"prompt": "Ayúdame a escribir el código para esta tarea, empezando por el cambio útil más pequeño."
},
"summarize": {
"title": "Resumir este documento",
"prompt": "Resume este documento y enumera las conclusiones clave."
},
"more": {
"title": "Más",
"prompt": "Muéstrame algunas formas útiles en las que puedes ayudar en este workspace."
}
}
},
"header": {
"toggleSidebar": "Mostrar u ocultar la barra lateral"
@ -62,6 +89,7 @@
"placeholderThread": "Escribe tu mensaje…",
"placeholderHero": "¿Qué tienes en mente?",
"placeholderOpening": "Abriendo un nuevo chat…",
"placeholderStreaming": "El modelo está respondiendo…",
"inputAria": "Entrada de mensaje",
"sendHint": "Enter para enviar · Shift+Enter para nueva línea",
"send": "Enviar mensaje",
@ -76,6 +104,51 @@
"decode_failed": "No se pudo decodificar esta imagen",
"too_large": "Imagen demasiado grande — prueba una más pequeña",
"io": "No se pudo leer este archivo"
},
"slash": {
"ariaLabel": "Comandos slash",
"label": "comandos",
"navigateHint": "↑↓ Navegar",
"selectHint": "Enter/Tab Insertar",
"closeHint": "Esc Cerrar",
"commands": {
"new": {
"title": "Nuevo chat",
"description": "Detiene la tarea actual e inicia una conversación nueva."
},
"stop": {
"title": "Detener tarea actual",
"description": "Cancela el turno activo del agent en este chat."
},
"restart": {
"title": "Reiniciar nanobot",
"description": "Reinicia el proceso del bot en el mismo lugar."
},
"status": {
"title": "Mostrar estado",
"description": "Muestra el estado del runtime, provider y channels."
},
"history": {
"title": "Mostrar historial",
"description": "Imprime los últimos N mensajes persistidos de la conversación."
},
"dream": {
"title": "Ejecutar Dream",
"description": "Activa manualmente la consolidación de memoria."
},
"dream_log": {
"title": "Mostrar registro de Dream",
"description": "Muestra qué cambió la última consolidación Dream."
},
"dream_restore": {
"title": "Restaurar memoria",
"description": "Revierte la memoria a una instantánea Dream anterior."
},
"help": {
"title": "Mostrar ayuda",
"description": "Lista los comandos slash disponibles."
}
}
}
},
"scrollToBottom": "Desplazarse al final"

View File

@ -53,7 +53,34 @@
"thread": {
"loadingConversation": "Chargement de la conversation…",
"empty": {
"description": "Posez des questions, poursuivez votre travail local ou démarrez un nouveau fil."
"description": "Posez des questions, poursuivez votre travail local ou démarrez un nouveau fil.",
"greeting": "Que puis-je faire pour vous ?",
"quickActions": {
"plan": {
"title": "Créer un plan de projet",
"prompt": "Créez un plan de projet concis pour ce que je devrais construire ensuite."
},
"analyze": {
"title": "Analyser ces données",
"prompt": "Aidez-moi à analyser ces données et à faire ressortir les tendances les plus importantes."
},
"brainstorm": {
"title": "Trouver des idées",
"prompt": "Proposez quelques idées pratiques et leurs compromis pour ce problème."
},
"code": {
"title": "Écrire du code",
"prompt": "Aidez-moi à écrire le code pour cette tâche, en commençant par le plus petit changement utile."
},
"summarize": {
"title": "Résumer ce document",
"prompt": "Résumez ce document et listez les points clés à retenir."
},
"more": {
"title": "Plus",
"prompt": "Montrez-moi quelques façons utiles dont vous pouvez maider dans cet espace de travail."
}
}
},
"header": {
"toggleSidebar": "Afficher ou masquer la barre latérale"
@ -62,6 +89,7 @@
"placeholderThread": "Saisissez votre message…",
"placeholderHero": "Quavez-vous en tête ?",
"placeholderOpening": "Ouverture dune nouvelle discussion…",
"placeholderStreaming": "Le modèle est en train de répondre…",
"inputAria": "Champ de message",
"sendHint": "Entrée pour envoyer · Maj+Entrée pour un retour à la ligne",
"send": "Envoyer le message",
@ -76,6 +104,51 @@
"decode_failed": "Impossible de décoder cette image",
"too_large": "Image trop grande — essayez-en une plus petite",
"io": "Impossible de lire ce fichier"
},
"slash": {
"ariaLabel": "Commandes slash",
"label": "commandes",
"navigateHint": "↑↓ Naviguer",
"selectHint": "Entrée/Tab Insérer",
"closeHint": "Échap Fermer",
"commands": {
"new": {
"title": "Nouvelle discussion",
"description": "Arrêter la tâche en cours et démarrer une nouvelle conversation."
},
"stop": {
"title": "Arrêter la tâche en cours",
"description": "Annuler le tour agent actif pour cette discussion."
},
"restart": {
"title": "Redémarrer nanobot",
"description": "Redémarrer le processus du bot sur place."
},
"status": {
"title": "Afficher létat",
"description": "Afficher létat du runtime, du provider et des channels."
},
"history": {
"title": "Afficher lhistorique",
"description": "Afficher les N derniers messages persistés de la conversation."
},
"dream": {
"title": "Lancer Dream",
"description": "Déclencher manuellement la consolidation de la mémoire."
},
"dream_log": {
"title": "Afficher le journal Dream",
"description": "Afficher ce que la dernière consolidation Dream a changé."
},
"dream_restore": {
"title": "Restaurer la mémoire",
"description": "Revenir à un instantané Dream précédent."
},
"help": {
"title": "Afficher laide",
"description": "Lister les commandes slash disponibles."
}
}
}
},
"scrollToBottom": "Faire défiler vers le bas"

View File

@ -53,7 +53,34 @@
"thread": {
"loadingConversation": "Memuat percakapan…",
"empty": {
"description": "Ajukan pertanyaan, lanjutkan pekerjaan lokal, atau mulai thread baru."
"description": "Ajukan pertanyaan, lanjutkan pekerjaan lokal, atau mulai thread baru.",
"greeting": "Apa yang bisa saya bantu?",
"quickActions": {
"plan": {
"title": "Buat rencana proyek",
"prompt": "Buat rencana proyek ringkas untuk apa yang sebaiknya saya bangun berikutnya."
},
"analyze": {
"title": "Analisis data ini",
"prompt": "Bantu saya menganalisis data ini dan soroti pola yang paling penting."
},
"brainstorm": {
"title": "Brainstorm ide",
"prompt": "Brainstorm beberapa ide praktis dan tradeoff untuk masalah ini."
},
"code": {
"title": "Tulis kode",
"prompt": "Bantu saya menulis kode untuk tugas ini, mulai dari perubahan berguna yang paling kecil."
},
"summarize": {
"title": "Ringkas dokumen ini",
"prompt": "Ringkas dokumen ini dan daftar poin-poin utamanya."
},
"more": {
"title": "Lainnya",
"prompt": "Tunjukkan beberapa cara berguna Anda dapat membantu di workspace ini."
}
}
},
"header": {
"toggleSidebar": "Tampilkan atau sembunyikan sidebar"
@ -62,6 +89,7 @@
"placeholderThread": "Ketik pesan Anda…",
"placeholderHero": "Apa yang sedang Anda pikirkan?",
"placeholderOpening": "Membuka obrolan baru…",
"placeholderStreaming": "Model sedang merespons…",
"inputAria": "Input pesan",
"sendHint": "Enter untuk kirim · Shift+Enter untuk baris baru",
"send": "Kirim pesan",
@ -76,6 +104,51 @@
"decode_failed": "Tidak dapat mendekode gambar ini",
"too_large": "Gambar terlalu besar — coba yang lebih kecil",
"io": "Tidak dapat membaca file ini"
},
"slash": {
"ariaLabel": "Perintah slash",
"label": "perintah",
"navigateHint": "↑↓ Pilih",
"selectHint": "Enter/Tab Sisipkan",
"closeHint": "Esc Tutup",
"commands": {
"new": {
"title": "Obrolan baru",
"description": "Hentikan tugas saat ini dan mulai percakapan baru."
},
"stop": {
"title": "Hentikan tugas saat ini",
"description": "Batalkan giliran agent yang sedang aktif di chat ini."
},
"restart": {
"title": "Mulai ulang nanobot",
"description": "Mulai ulang proses bot di tempat."
},
"status": {
"title": "Tampilkan status",
"description": "Tampilkan status runtime, provider, dan channel."
},
"history": {
"title": "Tampilkan riwayat",
"description": "Cetak N pesan percakapan tersimpan terbaru."
},
"dream": {
"title": "Jalankan Dream",
"description": "Picu konsolidasi memori secara manual."
},
"dream_log": {
"title": "Tampilkan log Dream",
"description": "Tampilkan perubahan dari konsolidasi Dream terakhir."
},
"dream_restore": {
"title": "Pulihkan memori",
"description": "Kembalikan memori ke snapshot Dream sebelumnya."
},
"help": {
"title": "Tampilkan bantuan",
"description": "Daftar perintah slash yang tersedia."
}
}
}
},
"scrollToBottom": "Gulir ke bawah"

View File

@ -53,7 +53,34 @@
"thread": {
"loadingConversation": "会話を読み込み中…",
"empty": {
"description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。"
"description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。",
"greeting": "何をお手伝いしましょうか?",
"quickActions": {
"plan": {
"title": "プロジェクト計画を作成",
"prompt": "次に作るものについて、簡潔なプロジェクト計画を作成してください。"
},
"analyze": {
"title": "このデータを分析",
"prompt": "このデータを分析し、最も重要なパターンを指摘してください。"
},
"brainstorm": {
"title": "アイデアを出す",
"prompt": "この問題について、実用的なアイデアとトレードオフをいくつか出してください。"
},
"code": {
"title": "コードを書く",
"prompt": "このタスクのコードを書くのを手伝ってください。まず最小限の有用な変更から始めてください。"
},
"summarize": {
"title": "この文書を要約",
"prompt": "この文書を要約し、重要なポイントを列挙してください。"
},
"more": {
"title": "その他",
"prompt": "このワークスペースであなたが手伝える便利な方法をいくつか見せてください。"
}
}
},
"header": {
"toggleSidebar": "サイドバーを切り替える"
@ -62,6 +89,7 @@
"placeholderThread": "メッセージを入力…",
"placeholderHero": "何を考えていますか?",
"placeholderOpening": "新しいチャットを開いています…",
"placeholderStreaming": "モデルが応答しています…",
"inputAria": "メッセージ入力欄",
"sendHint": "Enter で送信 · Shift+Enter で改行",
"send": "メッセージを送信",
@ -76,6 +104,51 @@
"decode_failed": "この画像をデコードできません",
"too_large": "画像が大きすぎます。小さいものを選んでください",
"io": "このファイルを読み込めません"
},
"slash": {
"ariaLabel": "スラッシュコマンド",
"label": "コマンド",
"navigateHint": "↑↓ 選択",
"selectHint": "Enter/Tab 入力",
"closeHint": "Esc 閉じる",
"commands": {
"new": {
"title": "新しいチャット",
"description": "現在のタスクを停止して、新しい会話を開始します。"
},
"stop": {
"title": "現在のタスクを停止",
"description": "このチャットで実行中の agent ターンをキャンセルします。"
},
"restart": {
"title": "nanobot を再起動",
"description": "bot プロセスをその場で再起動します。"
},
"status": {
"title": "ステータスを表示",
"description": "ランタイム、provider、channel の状態を表示します。"
},
"history": {
"title": "会話履歴を表示",
"description": "保存済みの直近 N 件の会話メッセージを表示します。"
},
"dream": {
"title": "Dream を実行",
"description": "メモリ統合を手動で開始します。"
},
"dream_log": {
"title": "Dream ログを表示",
"description": "直近の Dream 統合で変更された内容を表示します。"
},
"dream_restore": {
"title": "メモリを復元",
"description": "以前の Dream スナップショットへメモリを戻します。"
},
"help": {
"title": "ヘルプを表示",
"description": "利用可能なスラッシュコマンドを一覧表示します。"
}
}
}
},
"scrollToBottom": "一番下へスクロール"

View File

@ -53,7 +53,34 @@
"thread": {
"loadingConversation": "대화 불러오는 중…",
"empty": {
"description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다."
"description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다.",
"greeting": "무엇을 도와드릴까요?",
"quickActions": {
"plan": {
"title": "프로젝트 계획 만들기",
"prompt": "다음에 만들 것에 대한 간결한 프로젝트 계획을 작성해 주세요."
},
"analyze": {
"title": "이 데이터 분석하기",
"prompt": "이 데이터를 분석하고 가장 중요한 패턴을 짚어 주세요."
},
"brainstorm": {
"title": "아이디어 브레인스토밍",
"prompt": "이 문제에 대한 실용적인 아이디어와 트레이드오프를 몇 가지 제안해 주세요."
},
"code": {
"title": "코드 작성하기",
"prompt": "이 작업을 위한 코드를 작성해 주세요. 가장 작은 유용한 변경부터 시작해 주세요."
},
"summarize": {
"title": "문서 요약하기",
"prompt": "이 문서를 요약하고 핵심 내용을 정리해 주세요."
},
"more": {
"title": "더 보기",
"prompt": "이 워크스페이스에서 도와줄 수 있는 유용한 방법을 몇 가지 보여 주세요."
}
}
},
"header": {
"toggleSidebar": "사이드바 전환"
@ -62,6 +89,7 @@
"placeholderThread": "메시지를 입력하세요…",
"placeholderHero": "무슨 생각을 하고 있나요?",
"placeholderOpening": "새 채팅을 여는 중…",
"placeholderStreaming": "모델이 응답 중입니다…",
"inputAria": "메시지 입력",
"sendHint": "Enter로 전송 · Shift+Enter로 줄바꿈",
"send": "메시지 보내기",
@ -76,6 +104,51 @@
"decode_failed": "이 이미지를 디코딩할 수 없습니다",
"too_large": "이미지가 너무 큽니다. 더 작은 걸로 시도해 주세요",
"io": "이 파일을 읽을 수 없습니다"
},
"slash": {
"ariaLabel": "슬래시 명령",
"label": "명령",
"navigateHint": "↑↓ 선택",
"selectHint": "Enter/Tab 입력",
"closeHint": "Esc 닫기",
"commands": {
"new": {
"title": "새 채팅",
"description": "현재 작업을 중지하고 새 대화를 시작합니다."
},
"stop": {
"title": "현재 작업 중지",
"description": "이 채팅에서 실행 중인 agent 턴을 취소합니다."
},
"restart": {
"title": "nanobot 재시작",
"description": "bot 프로세스를 제자리에서 재시작합니다."
},
"status": {
"title": "상태 보기",
"description": "런타임, provider, channel 상태를 표시합니다."
},
"history": {
"title": "대화 기록 보기",
"description": "저장된 최근 N개의 대화 메시지를 출력합니다."
},
"dream": {
"title": "Dream 실행",
"description": "메모리 정리를 수동으로 트리거합니다."
},
"dream_log": {
"title": "Dream 로그 보기",
"description": "마지막 Dream 정리에서 변경된 내용을 표시합니다."
},
"dream_restore": {
"title": "메모리 복원",
"description": "이전 Dream 스냅샷으로 메모리를 되돌립니다."
},
"help": {
"title": "도움말 보기",
"description": "사용 가능한 슬래시 명령을 나열합니다."
}
}
}
},
"scrollToBottom": "맨 아래로 스크롤"

View File

@ -53,7 +53,34 @@
"thread": {
"loadingConversation": "Đang tải cuộc trò chuyện…",
"empty": {
"description": "Hãy đặt câu hỏi, tiếp tục công việc cục bộ hoặc bắt đầu một luồng mới."
"description": "Hãy đặt câu hỏi, tiếp tục công việc cục bộ hoặc bắt đầu một luồng mới.",
"greeting": "Tôi có thể giúp gì cho bạn?",
"quickActions": {
"plan": {
"title": "Tạo kế hoạch dự án",
"prompt": "Tạo một kế hoạch dự án ngắn gọn cho việc tôi nên xây dựng tiếp theo."
},
"analyze": {
"title": "Phân tích dữ liệu này",
"prompt": "Giúp tôi phân tích dữ liệu này và chỉ ra các mẫu quan trọng nhất."
},
"brainstorm": {
"title": "Động não ý tưởng",
"prompt": "Động não vài ý tưởng thực tế và các đánh đổi cho vấn đề này."
},
"code": {
"title": "Viết mã",
"prompt": "Giúp tôi viết mã cho nhiệm vụ này, bắt đầu từ thay đổi hữu ích nhỏ nhất."
},
"summarize": {
"title": "Tóm tắt tài liệu này",
"prompt": "Tóm tắt tài liệu này và liệt kê các ý chính."
},
"more": {
"title": "Thêm",
"prompt": "Cho tôi xem vài cách hữu ích mà bạn có thể giúp trong workspace này."
}
}
},
"header": {
"toggleSidebar": "Bật/tắt thanh bên"
@ -62,6 +89,7 @@
"placeholderThread": "Nhập tin nhắn…",
"placeholderHero": "Bạn đang nghĩ gì?",
"placeholderOpening": "Đang mở cuộc trò chuyện mới…",
"placeholderStreaming": "Mô hình đang trả lời…",
"inputAria": "Ô nhập tin nhắn",
"sendHint": "Enter để gửi · Shift+Enter để xuống dòng",
"send": "Gửi tin nhắn",
@ -76,6 +104,51 @@
"decode_failed": "Không thể giải mã ảnh này",
"too_large": "Ảnh quá lớn — hãy thử ảnh nhỏ hơn",
"io": "Không thể đọc tệp này"
},
"slash": {
"ariaLabel": "Lệnh slash",
"label": "lệnh",
"navigateHint": "↑↓ Chọn",
"selectHint": "Enter/Tab Chèn",
"closeHint": "Esc Đóng",
"commands": {
"new": {
"title": "Cuộc trò chuyện mới",
"description": "Dừng tác vụ hiện tại và bắt đầu một cuộc trò chuyện mới."
},
"stop": {
"title": "Dừng tác vụ hiện tại",
"description": "Hủy lượt agent đang chạy trong cuộc trò chuyện này."
},
"restart": {
"title": "Khởi động lại nanobot",
"description": "Khởi động lại tiến trình bot tại chỗ."
},
"status": {
"title": "Hiển thị trạng thái",
"description": "Hiển thị trạng thái runtime, provider và channel."
},
"history": {
"title": "Hiển thị lịch sử",
"description": "In N tin nhắn hội thoại đã lưu gần nhất."
},
"dream": {
"title": "Chạy Dream",
"description": "Kích hoạt thủ công quá trình hợp nhất bộ nhớ."
},
"dream_log": {
"title": "Hiển thị nhật ký Dream",
"description": "Hiển thị những gì lần hợp nhất Dream gần nhất đã thay đổi."
},
"dream_restore": {
"title": "Khôi phục bộ nhớ",
"description": "Đưa bộ nhớ về một snapshot Dream trước đó."
},
"help": {
"title": "Hiển thị trợ giúp",
"description": "Liệt kê các lệnh slash có sẵn."
}
}
}
},
"scrollToBottom": "Cuộn xuống cuối"

View File

@ -18,11 +18,19 @@
}
},
"sidebar": {
"navigation": "侧边栏导航",
"globalActions": "全局操作",
"collapse": "收起侧边栏",
"toggleTheme": "切换主题",
"home": "首页",
"newChat": "新建对话",
"searchAria": "搜索会话",
"searchPlaceholder": "搜索会话",
"searchResults": "搜索结果",
"noSearchResults": "没有匹配的会话。",
"recent": "最近对话",
"refreshSessions": "刷新会话",
"settings": "设置",
"language": {
"label": "语言",
"ariaLabel": "切换语言"
@ -34,7 +42,12 @@
"noSessions": "还没有会话。",
"actions": "“{{title}}” 的会话操作",
"delete": "删除",
"newChat": "新建对话"
"newChat": "新建对话",
"groups": {
"today": "今天",
"yesterday": "昨天",
"earlier": "更早"
}
},
"deleteConfirm": {
"title": "删除“{{title}}”?",
@ -53,19 +66,100 @@
"thread": {
"loadingConversation": "正在加载对话…",
"empty": {
"description": "可以提问、继续本地工作,或者开启一个新线程。"
"greeting": "我可以帮你做什么?",
"quickActions": {
"plan": {
"title": "创建项目计划",
"prompt": "帮我为接下来要做的事情写一份简洁的项目计划。"
},
"analyze": {
"title": "分析这些数据",
"prompt": "帮我分析这些数据,并指出最重要的模式。"
},
"brainstorm": {
"title": "头脑风暴想法",
"prompt": "围绕这个问题头脑风暴几个实用方案,并说明取舍。"
},
"code": {
"title": "编写代码",
"prompt": "帮我为这个任务写代码,先从最小可用改动开始。"
},
"summarize": {
"title": "总结这份文档",
"prompt": "帮我总结这份文档,并列出关键要点。"
},
"more": {
"title": "更多",
"prompt": "展示几个你在这个工作区里可以帮我的实用方式。"
}
}
},
"header": {
"toggleSidebar": "切换侧边栏"
"toggleSidebar": "切换侧边栏",
"newChat": "从顶部新建对话",
"toggleTheme": "从顶部切换主题",
"settings": "打开设置"
},
"composer": {
"placeholderThread": "输入消息…",
"placeholderHero": "你在想什么?",
"placeholderHero": "问任何问题...",
"placeholderOpening": "正在打开新对话…",
"placeholderStreaming": "模型正在回复…",
"inputAria": "消息输入框",
"sendHint": "Enter 发送 · Shift+Enter 换行",
"send": "发送消息",
"attachImage": "添加图片",
"tools": {
"search": "搜索",
"reason": "推理",
"deepResearch": "深度研究",
"voice": "语音输入"
},
"slash": {
"ariaLabel": "斜杠命令",
"label": "命令",
"navigateHint": "↑↓ 选择",
"selectHint": "Enter/Tab 填入",
"closeHint": "Esc 关闭",
"commands": {
"new": {
"title": "新建对话",
"description": "停止当前任务,并开始一个新的对话。"
},
"stop": {
"title": "停止当前任务",
"description": "取消这个对话中正在运行的 agent 回合。"
},
"restart": {
"title": "重启 nanobot",
"description": "原地重启 bot 进程。"
},
"status": {
"title": "查看状态",
"description": "显示运行时、provider 和 channel 状态。"
},
"history": {
"title": "查看对话历史",
"description": "打印最近 N 条已持久化的对话消息。"
},
"dream": {
"title": "运行 Dream",
"description": "手动触发记忆整理。"
},
"dream_log": {
"title": "查看 Dream 日志",
"description": "查看上一次 Dream 整理改变了什么。"
},
"dream_restore": {
"title": "恢复记忆",
"description": "将记忆恢复到之前的 Dream 快照。"
},
"help": {
"title": "查看帮助",
"description": "列出可用的斜杠命令。"
}
}
},
"encoding": "处理中…",
"remove": "移除附件",
"normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)",
@ -85,7 +179,9 @@
"assistantTyping": "助手正在输入",
"toolSingle": "正在使用工具",
"toolMany": "已使用 {{count}} 个工具",
"imageAttachment": "图片附件"
"imageAttachment": "图片附件",
"copyReply": "复制回复",
"copiedReply": "已复制回复"
},
"lightbox": {
"title": "图片预览",

View File

@ -53,7 +53,34 @@
"thread": {
"loadingConversation": "正在載入對話…",
"empty": {
"description": "你可以提問、延續本地工作,或是開始新的執行緒。"
"description": "你可以提問、延續本地工作,或是開始新的執行緒。",
"greeting": "我可以幫你做什麼?",
"quickActions": {
"plan": {
"title": "建立專案計畫",
"prompt": "幫我為接下來要做的事情寫一份簡潔的專案計畫。"
},
"analyze": {
"title": "分析這些資料",
"prompt": "幫我分析這些資料,並指出最重要的模式。"
},
"brainstorm": {
"title": "腦力激盪想法",
"prompt": "圍繞這個問題腦力激盪幾個實用方案,並說明取捨。"
},
"code": {
"title": "撰寫程式碼",
"prompt": "幫我為這個任務撰寫程式碼,先從最小可用改動開始。"
},
"summarize": {
"title": "總結這份文件",
"prompt": "幫我總結這份文件,並列出關鍵重點。"
},
"more": {
"title": "更多",
"prompt": "展示幾個你在這個工作區裡可以幫我的實用方式。"
}
}
},
"header": {
"toggleSidebar": "切換側邊欄"
@ -62,6 +89,7 @@
"placeholderThread": "輸入訊息…",
"placeholderHero": "你在想什麼?",
"placeholderOpening": "正在開啟新對話…",
"placeholderStreaming": "模型正在回覆…",
"inputAria": "訊息輸入框",
"sendHint": "Enter 送出 · Shift+Enter 換行",
"send": "送出訊息",
@ -76,6 +104,51 @@
"decode_failed": "無法解碼這張圖片",
"too_large": "圖片太大,請換一張小一點的",
"io": "無法讀取這個檔案"
},
"slash": {
"ariaLabel": "斜線命令",
"label": "命令",
"navigateHint": "↑↓ 選擇",
"selectHint": "Enter/Tab 填入",
"closeHint": "Esc 關閉",
"commands": {
"new": {
"title": "新增對話",
"description": "停止目前任務,並開始新的對話。"
},
"stop": {
"title": "停止目前任務",
"description": "取消這個對話中正在執行的 agent 回合。"
},
"restart": {
"title": "重新啟動 nanobot",
"description": "原地重新啟動 bot 進程。"
},
"status": {
"title": "查看狀態",
"description": "顯示執行環境、provider 和 channel 狀態。"
},
"history": {
"title": "查看對話歷史",
"description": "列印最近 N 則已持久化的對話訊息。"
},
"dream": {
"title": "執行 Dream",
"description": "手動觸發記憶整理。"
},
"dream_log": {
"title": "查看 Dream 日誌",
"description": "查看上一次 Dream 整理變更了什麼。"
},
"dream_restore": {
"title": "恢復記憶",
"description": "將記憶恢復到之前的 Dream 快照。"
},
"help": {
"title": "查看說明",
"description": "列出可用的斜線命令。"
}
}
}
},
"scrollToBottom": "捲動到底部"

View File

@ -1,4 +1,4 @@
import type { ChatSummary, SettingsPayload, SettingsUpdate } from "./types";
import type { ChatSummary, SettingsPayload, SettingsUpdate, SlashCommand } from "./types";
export class ApiError extends Error {
status: number;
@ -42,6 +42,7 @@ export async function listSessions(
key: string;
created_at: string | null;
updated_at: string | null;
title?: string;
preview?: string;
};
const body = await request<{ sessions: Row[] }>(
@ -53,6 +54,7 @@ export async function listSessions(
...splitKey(s.key),
createdAt: s.created_at,
updatedAt: s.updated_at,
title: s.title ?? "",
preview: s.preview ?? "",
}));
}
@ -112,6 +114,27 @@ export async function fetchSettings(
return request<SettingsPayload>(`${base}/api/settings`, token);
}
export async function listSlashCommands(
token: string,
base: string = "",
): Promise<SlashCommand[]> {
type Row = {
command: string;
title: string;
description: string;
icon: string;
arg_hint?: string;
};
const body = await request<{ commands: Row[] }>(`${base}/api/commands`, token);
return body.commands.map((command) => ({
command: command.command,
title: command.title,
description: command.description,
icon: command.icon,
argHint: command.arg_hint ?? "",
}));
}
export async function updateSettings(
token: string,
update: SettingsUpdate,

View File

@ -1,15 +1,51 @@
import type { BootstrapResponse } from "./types";
const SECRET_STORAGE_KEY = "nanobot-webui.bootstrap-secret";
/** Read a previously saved bootstrap secret from localStorage. */
export function loadSavedSecret(): string {
if (typeof window === "undefined") return "";
try {
return window.localStorage.getItem(SECRET_STORAGE_KEY) ?? "";
} catch {
return "";
}
}
/** Persist the bootstrap secret so page reloads don't re-prompt. */
export function saveSecret(secret: string): void {
try {
window.localStorage.setItem(SECRET_STORAGE_KEY, secret);
} catch {
// ignore storage errors (private mode, etc.)
}
}
/** Clear the saved bootstrap secret (sign out). */
export function clearSavedSecret(): void {
try {
window.localStorage.removeItem(SECRET_STORAGE_KEY);
} catch {
// ignore
}
}
/**
* Fetch a short-lived token + the WebSocket path from the gateway's
* ``/webui/bootstrap`` endpoint. Localhost-only on the server side.
* ``/webui/bootstrap`` endpoint.
*/
export async function fetchBootstrap(
baseUrl: string = "",
secret: string = "",
): Promise<BootstrapResponse> {
const headers: Record<string, string> = {};
if (secret) {
headers["X-Nanobot-Auth"] = secret;
}
const res = await fetch(`${baseUrl}/webui/bootstrap`, {
method: "GET",
credentials: "same-origin",
headers,
});
if (!res.ok) {
throw new Error(`bootstrap failed: HTTP ${res.status}`);

View File

@ -185,8 +185,8 @@ export class NanobotClient {
this.knownChats.add(chatId);
const frame: Outbound =
media && media.length > 0
? { type: "message", chat_id: chatId, content, media }
: { type: "message", chat_id: chatId, content };
? { type: "message", chat_id: chatId, content, media, webui: true }
: { type: "message", chat_id: chatId, content, webui: true };
this.queueSend(frame);
}

View File

@ -56,6 +56,7 @@ export interface ChatSummary {
chatId: string;
createdAt: string | null;
updatedAt: string | null;
title?: string;
preview: string;
}
@ -88,6 +89,14 @@ export interface SettingsUpdate {
provider?: string;
}
export interface SlashCommand {
command: string;
title: string;
description: string;
icon: string;
argHint?: string;
}
export type ConnectionStatus =
| "idle"
| "connecting"
@ -124,6 +133,8 @@ export type InboundEvent =
chat_id: string;
stream_id?: string;
}
| { event: "turn_end"; chat_id: string }
| { event: "session_updated"; chat_id: string }
| { event: "error"; chat_id?: string; detail?: string };
/** Base64-encoded image attached to an outbound ``message`` envelope.
@ -147,4 +158,7 @@ export type Outbound =
chat_id: string;
content: string;
media?: OutboundMedia[];
/** Marks messages sent by the embedded WebUI, without changing the
* generic websocket protocol for other clients. */
webui?: true;
};

View File

@ -1,6 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { deleteSession, fetchSessionMessages, updateSettings } from "@/lib/api";
import {
deleteSession,
fetchSessionMessages,
listSessions,
listSlashCommands,
updateSettings,
} from "@/lib/api";
describe("webui API helpers", () => {
beforeEach(() => {
@ -48,4 +54,61 @@ describe("webui API helpers", () => {
}),
);
});
it("maps generated session titles from the sessions list", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
sessions: [
{
key: "websocket:chat-1",
created_at: "2026-05-01T10:00:00",
updated_at: "2026-05-01T10:01:00",
title: "优化 WebUI 标题",
},
],
}),
} as Response);
await expect(listSessions("tok")).resolves.toMatchObject([
{
key: "websocket:chat-1",
title: "优化 WebUI 标题",
preview: "",
},
]);
});
it("maps slash command metadata from the commands endpoint", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
commands: [
{
command: "/history",
title: "Show conversation history",
description: "Print the last N messages.",
icon: "history",
arg_hint: "[n]",
},
],
}),
} as Response);
await expect(listSlashCommands("tok")).resolves.toEqual([
{
command: "/history",
title: "Show conversation history",
description: "Print the last N messages.",
icon: "history",
argHint: "[n]",
},
]);
expect(fetch).toHaveBeenCalledWith(
"/api/commands",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
});

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatSummary } from "@/lib/types";
@ -7,6 +7,7 @@ const connectSpy = vi.fn();
const refreshSpy = vi.fn();
const createChatSpy = vi.fn().mockResolvedValue("chat-1");
const deleteChatSpy = vi.fn();
const toggleThemeSpy = vi.fn();
let mockSessions: ChatSummary[] = [];
vi.mock("@/hooks/useSessions", async (importOriginal) => {
@ -34,7 +35,7 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
vi.mock("@/hooks/useTheme", () => ({
useTheme: () => ({
theme: "light" as const,
toggle: vi.fn(),
toggle: toggleThemeSpy,
}),
}));
@ -45,6 +46,9 @@ vi.mock("@/lib/bootstrap", () => ({
expires_in: 300,
}),
deriveWsUrl: vi.fn(() => "ws://test"),
loadSavedSecret: vi.fn(() => ""),
saveSecret: vi.fn(),
clearSavedSecret: vi.fn(),
}));
vi.mock("@/lib/nanobot-client", () => {
@ -74,6 +78,7 @@ describe("App layout", () => {
refreshSpy.mockReset();
createChatSpy.mockClear();
deleteChatSpy.mockReset();
toggleThemeSpy.mockReset();
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
@ -121,8 +126,11 @@ describe("App layout", () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
await waitFor(() =>
expect(screen.getByRole("button", { name: /^First chat$/ })).toBeInTheDocument(),
expect(
within(sidebar).getByRole("button", { name: /^First chat$/ }),
).toBeInTheDocument(),
);
fireEvent.pointerDown(screen.getByLabelText("Chat actions for First chat"), {
@ -140,14 +148,24 @@ describe("App layout", () => {
);
await waitFor(() =>
expect(
screen.getByRole("button", { name: /^Second chat$/ }),
within(sidebar).getByRole("button", { name: /^Second chat$/ }),
).toBeInTheDocument(),
);
expect(screen.queryByText('Delete “First chat”?')).not.toBeInTheDocument();
expect(document.body.style.pointerEvents).not.toBe("none");
}, 15_000);
it("opens the Cursor-style settings view from the sidebar", async () => {
it("opens the Cursor-style settings view from the header", async () => {
mockSessions = [
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "Existing chat",
},
];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
@ -180,10 +198,95 @@ describe("App layout", () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: "Settings" }));
fireEvent.click(screen.getByRole("button", { name: "Open settings" }));
expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument();
expect(screen.getByText("AI")).toBeInTheDocument();
expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument();
});
it("filters sidebar sessions through the lightweight search row", async () => {
mockSessions = [
{
key: "websocket:chat-alpha",
channel: "websocket",
chatId: "chat-alpha",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
preview: "Project planning notes",
},
{
key: "websocket:chat-beta",
channel: "websocket",
chatId: "chat-beta",
createdAt: "2026-04-15T10:00:00Z",
updatedAt: "2026-04-15T10:00:00Z",
preview: "Travel ideas",
},
];
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
expect(within(sidebar).getByText("Project planning notes")).toBeInTheDocument();
expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument();
fireEvent.change(screen.getByRole("textbox", { name: "Search chats" }), {
target: { value: "travel" },
});
expect(within(sidebar).queryByText("Project planning notes")).not.toBeInTheDocument();
expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument();
});
it("opens a blank start page without creating an empty chat", async () => {
mockSessions = [
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "Existing chat",
},
];
const matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query.includes("1024px"),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
vi.stubGlobal("matchMedia", matchMedia);
const { container } = render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: "Toggle theme from header" }));
expect(toggleThemeSpy).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" }));
const desktopAside = container.querySelector("aside.lg\\:block") as HTMLElement;
await waitFor(() => expect(desktopAside.style.width).toBe("0px"));
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Toggle sidebar" }));
await waitFor(() => expect(desktopAside.style.width).toBe("272px"));
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "New chat" }));
expect(createChatSpy).not.toHaveBeenCalled();
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Toggle theme from header" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Open settings" })).toBeInTheDocument();
expect(within(sidebar).getByText("Existing chat")).toBeInTheDocument();
});
});

View File

@ -4,6 +4,9 @@ import { describe, expect, it, vi } from "vitest";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { resources } from "@/i18n";
const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"];
describe("webui i18n", () => {
it("switches UI copy and document locale through the language switcher", async () => {
@ -41,4 +44,16 @@ describe("webui i18n", () => {
expect(screen.getByLabelText("メッセージ入力欄")).toBeInTheDocument();
});
it("keeps welcome quick actions localized for every registered locale", () => {
for (const resource of Object.values(resources)) {
const empty = resource.common.thread.empty;
expect(empty.greeting).toBeTruthy();
for (const key of QUICK_ACTION_KEYS) {
const action = empty.quickActions[key as keyof typeof empty.quickActions];
expect(action.title).toBeTruthy();
expect(action.prompt).toBeTruthy();
}
}
});
});

View File

@ -1,5 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MessageBubble } from "@/components/MessageBubble";
import type { UIMessage } from "@/lib/types";
@ -19,6 +19,44 @@ describe("MessageBubble", () => {
expect(row).toHaveClass("ml-auto", "flex");
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]");
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
});
it("copies completed assistant replies from the action row", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
const message: UIMessage = {
id: "a-copy",
role: "assistant",
content: "I can help with the next step.",
createdAt: Date.now(),
};
render(<MessageBubble message={message} />);
fireEvent.click(screen.getByRole("button", { name: "Copy reply" }));
expect(writeText).toHaveBeenCalledWith("I can help with the next step.");
await waitFor(() =>
expect(screen.getByRole("button", { name: "Copied reply" })).toBeInTheDocument(),
);
});
it("does not show copy actions for streaming placeholders", () => {
const message: UIMessage = {
id: "a-streaming",
role: "assistant",
content: "",
isStreaming: true,
createdAt: Date.now(),
};
render(<MessageBubble message={message} />);
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
});
it("renders trace messages as collapsible tool groups", () => {

View File

@ -116,7 +116,7 @@ describe("NanobotClient", () => {
// Attach is sent first because sendMessage adds to knownChats, which
// handleOpen re-attaches; then the queued message follows.
expect(lastSocket().sent).toContain(
JSON.stringify({ type: "message", chat_id: "chat-x", content: "hello" }),
JSON.stringify({ type: "message", chat_id: "chat-x", content: "hello", webui: true }),
);
});
@ -196,6 +196,7 @@ describe("NanobotClient", () => {
chat_id: "chat-x",
content: "look",
media: [{ data_url: "data:image/png;base64,AAAA", name: "shot.png" }],
webui: true,
});
});
@ -214,6 +215,7 @@ describe("NanobotClient", () => {
type: "message",
chat_id: "chat-x",
content: "hello",
webui: true,
});
});

View File

@ -1,7 +1,24 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import type { SlashCommand } from "@/lib/types";
const COMMANDS: SlashCommand[] = [
{
command: "/stop",
title: "Stop current task",
description: "Cancel the active agent turn.",
icon: "square",
},
{
command: "/history",
title: "Show conversation history",
description: "Print the last N persisted messages.",
icon: "history",
argHint: "[n]",
},
];
describe("ThreadComposer", () => {
it("renders a readonly hero model composer when provided", () => {
@ -9,15 +26,69 @@ describe("ThreadComposer", () => {
<ThreadComposer
onSend={vi.fn()}
modelLabel="claude-opus-4-5"
placeholder="What's on your mind?"
placeholder="Ask anything..."
variant="hero"
/>,
);
expect(screen.getByText("claude-opus-4-5")).toBeInTheDocument();
const input = screen.getByPlaceholderText("What's on your mind?");
expect(screen.queryByRole("button", { name: "Search" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reason" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Deep research" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Voice input" })).not.toBeInTheDocument();
const input = screen.getByPlaceholderText("Ask anything...");
expect(input).toBeInTheDocument();
expect(input.className).toContain("min-h-[96px]");
expect(input.parentElement?.className).toContain("max-w-[40rem]");
expect(input.className).toContain("min-h-[78px]");
expect(input.parentElement?.className).toContain("max-w-[58rem]");
});
it("keeps the thread composer compact while matching the hero style", () => {
render(
<ThreadComposer
onSend={vi.fn()}
modelLabel="gpt-4o"
placeholder="Type your message..."
/>,
);
expect(screen.getByText("gpt-4o")).toBeInTheDocument();
const input = screen.getByPlaceholderText("Type your message...");
expect(input.className).toContain("min-h-[50px]");
expect(input.parentElement?.className).toContain("max-w-[49.5rem]");
expect(input.parentElement?.className).toContain("rounded-[22px]");
expect(input.parentElement?.className).toContain("shadow-[0_12px_30px_rgba(15,23,42,0.07)]");
expect(screen.getByRole("button", { name: "Attach image" }).className).toContain("bg-card");
expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground");
});
it("opens a slash command palette and inserts the selected command", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
slashCommands={COMMANDS}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "/" } });
expect(screen.getByRole("listbox", { name: "Slash commands" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /\/stop/i })).toHaveAttribute(
"aria-selected",
"true",
);
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(screen.getByRole("option", { name: /\/history/i })).toHaveAttribute(
"aria-selected",
"true",
);
fireEvent.keyDown(input, { key: "Enter" });
expect(input).toHaveValue("/history ");
expect(onSend).not.toHaveBeenCalled();
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
});
});

View File

@ -86,6 +86,26 @@ describe("ThreadShell", () => {
);
});
it("does not navigate away when clicking the chat title", async () => {
const client = makeClient();
const onGoHome = vi.fn();
render(wrap(
client,
<ThreadShell
session={session("chat-title")}
title="Important conversation"
onToggleSidebar={() => {}}
onGoHome={onGoHome}
onNewChat={() => {}}
/>,
));
await waitFor(() => expect(screen.getByText("Important conversation")).toBeInTheDocument());
fireEvent.click(screen.getByText("Important conversation"));
expect(onGoHome).not.toHaveBeenCalled();
});
it("restores in-memory messages when switching away and back to a session", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");
@ -199,7 +219,67 @@ describe("ThreadShell", () => {
await waitFor(() => {
expect(screen.queryByText("delete me cleanly")).not.toBeInTheDocument();
});
expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument();
});
it("creates a chat only when the blank landing sends a first message", async () => {
const client = makeClient();
const onNewChat = vi.fn();
const onCreateChat = vi.fn().mockResolvedValue("chat-new");
render(
wrap(
client,
<ThreadShell
session={null}
title="nanobot"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
/>,
),
);
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "start for real" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1));
expect(onNewChat).not.toHaveBeenCalled();
});
it("sends quick action prompts from the empty thread landing", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");
render(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Write code" })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Write code" }));
await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith(
"chat-a",
"Help me write the code for this task, starting with the smallest useful change.",
undefined,
),
);
});
it("does not leak the previous thread when opening a brand-new chat", async () => {
@ -260,13 +340,232 @@ describe("ThreadShell", () => {
expect(screen.queryByText("old answer")).not.toBeInTheDocument();
await waitFor(() =>
expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument(),
expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument(),
);
const input = screen.getByPlaceholderText("What's on your mind?");
expect(input.className).toContain("min-h-[96px]");
const input = screen.getByPlaceholderText("Ask anything...");
expect(input.className).toContain("min-h-[78px]");
expect(screen.queryByText("old answer")).not.toBeInTheDocument();
});
it("does not cache optimistic messages under the next chat during a session switch", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-b");
const { rerender } = render(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "only in chat a" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith(
"chat-a",
"only in chat a",
undefined,
),
);
expect(screen.getByText("only in chat a")).toBeInTheDocument();
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-b")}
title="Chat chat-b"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
});
await waitFor(() => {
expect(screen.queryByText("only in chat a")).not.toBeInTheDocument();
});
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
});
expect(screen.getByText("only in chat a")).toBeInTheDocument();
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-b")}
title="Chat chat-b"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
});
await waitFor(() => {
expect(screen.queryByText("only in chat a")).not.toBeInTheDocument();
});
});
it("keeps live assistant replies after visiting the blank new-chat page", async () => {
const client = makeClient();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("websocket%3Achat-a/messages")) {
return httpJson({
key: "websocket:chat-a",
created_at: null,
updated_at: null,
// Simulate a stale history response that has not persisted the
// just-received assistant reply yet.
messages: [{ role: "user", content: "hello" }],
});
}
return {
ok: false,
status: 404,
json: async () => ({}),
};
}),
);
const { rerender } = render(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
await waitFor(() => expect(screen.getByText("hello")).toBeInTheDocument());
await act(async () => {
client._emitChat("chat-a", {
event: "message",
chat_id: "chat-a",
text: "live assistant reply",
});
});
expect(screen.getByText("live assistant reply")).toBeInTheDocument();
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={null}
title="nanobot"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
});
expect(screen.queryByText("live assistant reply")).not.toBeInTheDocument();
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
});
await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument());
});
it("does not open slash commands on the blank welcome page", async () => {
const client = makeClient();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/commands")) {
return httpJson({
commands: [
{
command: "/stop",
title: "Stop current task",
description: "Cancel the active agent turn.",
icon: "square",
},
],
});
}
return {
ok: false,
status: 404,
json: async () => ({}),
};
}),
);
render(
wrap(
client,
<ThreadShell
session={null}
title="nanobot"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
"/api/commands",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
));
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "/" },
});
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
});
it("surfaces a dismissible banner when the stream reports message_too_big", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");
@ -287,6 +586,7 @@ describe("ThreadShell", () => {
// No banner yet: only appears once the client emits a matching error.
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
await act(async () => {});
await act(async () => {
client._emitError({ kind: "message_too_big" });
});
@ -318,6 +618,7 @@ describe("ThreadShell", () => {
),
);
await act(async () => {});
await act(async () => {
client._emitError({ kind: "message_too_big" });
});

View File

@ -6,6 +6,8 @@ import { useNanobotStream } from "@/hooks/useNanobotStream";
import type { InboundEvent } from "@/lib/types";
import { ClientProvider } from "@/providers/ClientProvider";
const EMPTY_MESSAGES: import("@/lib/types").UIMessage[] = [];
function fakeClient() {
const handlers = new Map<string, Set<(ev: InboundEvent) => void>>();
return {
@ -51,9 +53,27 @@ function wrap(client: ReturnType<typeof fakeClient>["client"]) {
}
describe("useNanobotStream", () => {
it("starts in streaming mode when history shows pending tool calls", () => {
const fake = fakeClient();
const initialMessages = [{
id: "m1",
role: "assistant" as const,
content: "Using tools",
createdAt: Date.now(),
}];
const { result } = renderHook(
() => useNanobotStream("chat-p", initialMessages, true),
{
wrapper: wrap(fake.client),
},
);
expect(result.current.isStreaming).toBe(true);
});
it("collapses consecutive tool_hint frames into one trace row", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-t", []), {
const { result } = renderHook(() => useNanobotStream("chat-t", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
@ -95,7 +115,7 @@ describe("useNanobotStream", () => {
it("attaches assistant media_urls to complete messages", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-m", []), {
const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
@ -116,7 +136,7 @@ describe("useNanobotStream", () => {
it("keeps assistant buttons on complete messages", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-q", []), {
const { result } = renderHook(() => useNanobotStream("chat-q", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
@ -136,4 +156,79 @@ describe("useNanobotStream", () => {
["Short answer", "Detailed answer"],
]);
});
it("keeps streaming alive across stream_end and completes on turn_end", () => {
const fake = fakeClient();
const onTurnEnd = vi.fn();
const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES, false, onTurnEnd), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-s", {
event: "delta",
chat_id: "chat-s",
text: "Hello",
});
});
expect(result.current.isStreaming).toBe(true);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "Hello",
isStreaming: true,
});
act(() => {
fake.emit("chat-s", {
event: "stream_end",
chat_id: "chat-s",
});
});
expect(result.current.isStreaming).toBe(true);
expect(result.current.messages[0].isStreaming).toBe(true);
act(() => {
fake.emit("chat-s", {
event: "message",
chat_id: "chat-s",
text: "Hello world",
});
});
expect(result.current.isStreaming).toBe(true);
expect(result.current.messages.at(-1)).toMatchObject({
role: "assistant",
content: "Hello world",
});
act(() => {
fake.emit("chat-s", {
event: "turn_end",
chat_id: "chat-s",
});
});
expect(result.current.isStreaming).toBe(false);
expect(result.current.messages.every((message) => !message.isStreaming)).toBe(true);
expect(onTurnEnd).toHaveBeenCalledTimes(1);
});
it("refreshes session metadata when the server reports a session update", () => {
const fake = fakeClient();
const onTurnEnd = vi.fn();
renderHook(() => useNanobotStream("chat-title", EMPTY_MESSAGES, false, onTurnEnd), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-title", {
event: "session_updated",
chat_id: "chat-title",
});
});
expect(onTurnEnd).toHaveBeenCalledTimes(1);
});
});

View File

@ -170,6 +170,83 @@ describe("useSessions", () => {
]);
});
it("flags history with trailing assistant tool calls as still pending", async () => {
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
key: "websocket:chat-pending",
created_at: "2026-04-20T10:00:00Z",
updated_at: "2026-04-20T10:05:00Z",
messages: [
{
role: "assistant",
content: "Using 2 tools",
timestamp: "2026-04-20T10:00:01Z",
tool_calls: [{ id: "call-1" }],
},
],
});
const { result } = renderHook(() => useSessionHistory("websocket:chat-pending"), {
wrapper: wrap(fakeClient()),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasPendingToolCalls).toBe(true);
});
it("keeps pending when tool result rows trail assistant tool calls", async () => {
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
key: "websocket:chat-pending-tool-result",
created_at: "2026-04-20T10:00:00Z",
updated_at: "2026-04-20T10:05:00Z",
messages: [
{
role: "assistant",
content: "Using 1 tool",
timestamp: "2026-04-20T10:00:01Z",
tool_calls: [{ id: "call-1" }],
},
{
role: "tool",
content: "tool output",
timestamp: "2026-04-20T10:00:02Z",
tool_call_id: "call-1",
},
],
});
const { result } = renderHook(() => useSessionHistory("websocket:chat-pending-tool-result"), {
wrapper: wrap(fakeClient()),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasPendingToolCalls).toBe(true);
});
it("does not flag history as pending once the assistant turn has no tool calls", async () => {
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
key: "websocket:chat-done",
created_at: "2026-04-20T10:00:00Z",
updated_at: "2026-04-20T10:05:00Z",
messages: [
{
role: "assistant",
content: "All done",
timestamp: "2026-04-20T10:00:01Z",
},
],
});
const { result } = renderHook(() => useSessionHistory("websocket:chat-done"), {
wrapper: wrap(fakeClient()),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasPendingToolCalls).toBe(false);
});
it("keeps the session in the list when delete fails", async () => {
vi.mocked(api.listSessions).mockResolvedValue([
{