mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 16:38:49 +00:00
Merge remote-tracking branch 'origin/main' into nightly
This commit is contained in:
commit
22b3010bd0
@ -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 : '') || '';
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 ------------------------------------------------------
|
||||
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 ")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"] = ""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}]"
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
47
nanobot/utils/logging_bridge.py
Normal file
47
nanobot/utils/logging_bridge.py
Normal 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
|
||||
@ -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)."
|
||||
)
|
||||
|
||||
138
nanobot/utils/webui_titles.py
Normal file
138
nanobot/utils/webui_titles.py
Normal 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,
|
||||
)
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
79
tests/agent/test_runner_progress_deltas.py
Normal file
79
tests/agent/test_runner_progress_deltas.py
Normal 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()
|
||||
@ -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():
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 ────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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(
|
||||
|
||||
31
tests/cli/test_interactive_retry_wait.py
Normal file
31
tests/cli/test_interactive_retry_wait.py
Normal 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)]
|
||||
166
tests/cron/test_cron_persistence.py
Normal file
166
tests/cron/test_cron_persistence.py
Normal 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"]
|
||||
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 ---
|
||||
|
||||
120
tests/utils/test_workspace_violation_throttle.py
Normal file
120
tests/utils/test_workspace_violation_throttle.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -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"),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 m’aider dans cet espace de travail."
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "Afficher ou masquer la barre latérale"
|
||||
@ -62,6 +89,7 @@
|
||||
"placeholderThread": "Saisissez votre message…",
|
||||
"placeholderHero": "Qu’avez-vous en tête ?",
|
||||
"placeholderOpening": "Ouverture d’une 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 l’historique",
|
||||
"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 l’aide",
|
||||
"description": "Lister les commandes slash disponibles."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scrollToBottom": "Faire défiler vers le bas"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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": "一番下へスクロール"
|
||||
|
||||
@ -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": "맨 아래로 스크롤"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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": "图片预览",
|
||||
|
||||
@ -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": "捲動到底部"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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" });
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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([
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user