mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 05:18:49 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c018c3fb6a |
@@ -49,7 +49,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: nanobot Version
|
label: nanobot Version
|
||||||
description: Run `nanobot --version` or `pip show nanobot-ai`
|
description: Run `nanobot --version` or `pip show nanobot-ai`
|
||||||
placeholder: e.g., 0.1.5
|
placeholder: e.g., 0.2.0
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
@@ -214,10 +214,9 @@ nanobot agent
|
|||||||
- Want to run nanobot in chat apps like Telegram, Discord, WeChat or Feishu? See [Chat Apps](./docs/chat-apps.md)
|
- Want to run nanobot in chat apps like Telegram, Discord, WeChat or Feishu? See [Chat Apps](./docs/chat-apps.md)
|
||||||
- Want Docker or Linux service deployment? See [Deployment](./docs/deployment.md)
|
- Want Docker or Linux service deployment? See [Deployment](./docs/deployment.md)
|
||||||
|
|
||||||
## 🧪 WebUI (Development)
|
## 🌐 WebUI
|
||||||
|
|
||||||
> [!NOTE]
|
The WebUI ships **inside the published wheel** — no extra build step. Just enable the WebSocket channel and open it in your browser.
|
||||||
> The WebUI development workflow currently requires a source checkout and is not yet shipped together with the official packaged release. See [WebUI Document](./webui/README.md) for full WebUI development docs and build steps.
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
||||||
@@ -235,13 +234,12 @@ nanobot agent
|
|||||||
nanobot gateway
|
nanobot gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. Start the webui dev server**
|
**3. Open the WebUI**
|
||||||
|
|
||||||
```bash
|
Visit [`http://127.0.0.1:8765`](http://127.0.0.1:8765) in your browser. To open it from another device on your LAN, see [WebUI docs → LAN access](./webui/README.md#access-from-another-device-lan).
|
||||||
cd webui
|
|
||||||
bun install
|
> [!TIP]
|
||||||
bun run dev
|
> Working on the WebUI itself? Check out [`webui/README.md`](./webui/README.md) for the Vite dev server (HMR) workflow.
|
||||||
```
|
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Start here for setup, everyday usage, and deployment.
|
|||||||
| Agent social network | [`agent-social-network.md`](./agent-social-network.md) | Join external agent communities from nanobot |
|
| Agent social network | [`agent-social-network.md`](./agent-social-network.md) | Join external agent communities from nanobot |
|
||||||
| Configuration | [`configuration.md`](./configuration.md) | Providers, tools, channels, MCP, and runtime settings |
|
| Configuration | [`configuration.md`](./configuration.md) | Providers, tools, channels, MCP, and runtime settings |
|
||||||
| Image generation | [`image-generation.md`](./image-generation.md) | Configure image providers, WebUI image mode, and generated artifacts |
|
| Image generation | [`image-generation.md`](./image-generation.md) | Configure image providers, WebUI image mode, and generated artifacts |
|
||||||
|
| WebUI | [`../webui/README.md`](../webui/README.md) | Open the bundled browser UI; LAN access; Vite dev server for contributors |
|
||||||
| Multiple instances | [`multiple-instances.md`](./multiple-instances.md) | Run isolated bots with separate configs and workspaces |
|
| Multiple instances | [`multiple-instances.md`](./multiple-instances.md) | Run isolated bots with separate configs and workspaces |
|
||||||
| CLI reference | [`cli-reference.md`](./cli-reference.md) | Core CLI commands and common entrypoints |
|
| CLI reference | [`cli-reference.md`](./cli-reference.md) | Core CLI commands and common entrypoints |
|
||||||
| In-chat commands | [`chat-commands.md`](./chat-commands.md) | Slash commands and periodic task behavior |
|
| In-chat commands | [`chat-commands.md`](./chat-commands.md) | Slash commands and periodic task behavior |
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
"""Hatch build hook that bundles the webui (Vite) into nanobot/web/dist.
|
||||||
|
|
||||||
|
Triggered automatically by `python -m build` (and any other hatch-driven build)
|
||||||
|
so published wheels and sdists ship a fresh webui without requiring developers
|
||||||
|
to remember `cd webui && bun run build` beforehand.
|
||||||
|
|
||||||
|
Behaviour:
|
||||||
|
|
||||||
|
- Skips for editable installs (`pip install -e .`). Editable mode is for Python
|
||||||
|
development; webui contributors use `cd webui && bun run dev` (Vite HMR) and
|
||||||
|
do not need a packaged `dist/`.
|
||||||
|
- No-op when `webui/package.json` is absent (e.g. installing from an sdist that
|
||||||
|
already contains a prebuilt `nanobot/web/dist/`).
|
||||||
|
- Skips when `NANOBOT_SKIP_WEBUI_BUILD=1` is set.
|
||||||
|
- Skips when `nanobot/web/dist/index.html` already exists, unless
|
||||||
|
`NANOBOT_FORCE_WEBUI_BUILD=1` is set.
|
||||||
|
- Uses `bun` when available, otherwise falls back to `npm`. The chosen tool
|
||||||
|
performs `install` followed by `run build`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||||
|
|
||||||
|
|
||||||
|
class WebUIBuildHook(BuildHookInterface):
|
||||||
|
PLUGIN_NAME = "webui-build"
|
||||||
|
|
||||||
|
def initialize(self, version: str, build_data: dict) -> None: # noqa: D401
|
||||||
|
root = Path(self.root)
|
||||||
|
webui_dir = root / "webui"
|
||||||
|
package_json = webui_dir / "package.json"
|
||||||
|
dist_dir = root / "nanobot" / "web" / "dist"
|
||||||
|
index_html = dist_dir / "index.html"
|
||||||
|
|
||||||
|
# `pip install -e .` builds an editable wheel; skip the (slow) webui
|
||||||
|
# bundle since editable installs target Python development and webui
|
||||||
|
# work uses `bun run dev` instead.
|
||||||
|
if self.target_name == "wheel" and version == "editable":
|
||||||
|
self.app.display_info(
|
||||||
|
"[webui-build] skipped for editable install "
|
||||||
|
"(use `cd webui && bun run build` to bundle webui manually)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if os.environ.get("NANOBOT_SKIP_WEBUI_BUILD") == "1":
|
||||||
|
self.app.display_info("[webui-build] skipped via NANOBOT_SKIP_WEBUI_BUILD=1")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not package_json.is_file():
|
||||||
|
self.app.display_info(
|
||||||
|
"[webui-build] no webui/ source tree, assuming prebuilt nanobot/web/dist/"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
force = os.environ.get("NANOBOT_FORCE_WEBUI_BUILD") == "1"
|
||||||
|
if index_html.is_file() and not force:
|
||||||
|
self.app.display_info(
|
||||||
|
f"[webui-build] reusing existing build at {dist_dir} "
|
||||||
|
"(set NANOBOT_FORCE_WEBUI_BUILD=1 to rebuild)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
runner = self._pick_runner()
|
||||||
|
if runner is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"[webui-build] neither `bun` nor `npm` is available on PATH; "
|
||||||
|
"install one or set NANOBOT_SKIP_WEBUI_BUILD=1 to bypass."
|
||||||
|
)
|
||||||
|
|
||||||
|
self.app.display_info(f"[webui-build] using {runner} to build webui")
|
||||||
|
self._run([runner, "install"], cwd=webui_dir)
|
||||||
|
self._run([runner, "run", "build"], cwd=webui_dir)
|
||||||
|
|
||||||
|
if not index_html.is_file():
|
||||||
|
raise RuntimeError(
|
||||||
|
f"[webui-build] build finished but {index_html} is missing; "
|
||||||
|
"check webui/vite.config.ts outDir."
|
||||||
|
)
|
||||||
|
self.app.display_info(f"[webui-build] webui ready at {dist_dir}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _pick_runner() -> str | None:
|
||||||
|
for candidate in ("bun", "npm"):
|
||||||
|
if shutil.which(candidate):
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _run(self, cmd: list[str], *, cwd: Path) -> None:
|
||||||
|
self.app.display_info(f"[webui-build] $ {' '.join(cmd)} (cwd={cwd})")
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, cwd=cwd, check=True)
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"[webui-build] command failed ({exc.returncode}): {' '.join(cmd)}"
|
||||||
|
) from exc
|
||||||
+1
-1
@@ -21,7 +21,7 @@ def _resolve_version() -> str:
|
|||||||
return _pkg_version("nanobot-ai")
|
return _pkg_version("nanobot-ai")
|
||||||
except PackageNotFoundError:
|
except PackageNotFoundError:
|
||||||
# Source checkouts often import nanobot without installed dist-info.
|
# Source checkouts often import nanobot without installed dist-info.
|
||||||
return _read_pyproject_version() or "0.1.5.post3"
|
return _read_pyproject_version() or "0.2.0"
|
||||||
|
|
||||||
|
|
||||||
__version__ = _resolve_version()
|
__version__ = _resolve_version()
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ class ContextBuilder:
|
|||||||
skill_names: list[str] | None = None,
|
skill_names: list[str] | None = None,
|
||||||
channel: str | None = None,
|
channel: str | None = None,
|
||||||
session_summary: str | None = None,
|
session_summary: str | None = None,
|
||||||
session_key: str | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build the system prompt from identity, bootstrap files, memory, and skills."""
|
"""Build the system prompt from identity, bootstrap files, memory, and skills."""
|
||||||
parts = [self._get_identity(channel=channel)]
|
parts = [self._get_identity(channel=channel)]
|
||||||
@@ -74,29 +73,8 @@ class ContextBuilder:
|
|||||||
if session_summary:
|
if session_summary:
|
||||||
parts.append(f"[Archived Context Summary]\n\n{session_summary}")
|
parts.append(f"[Archived Context Summary]\n\n{session_summary}")
|
||||||
|
|
||||||
# Inject P2P collaboration hint for task-scoped sessions
|
|
||||||
if session_key and session_key.startswith("task:"):
|
|
||||||
parts.append(self._p2p_collaboration_hint())
|
|
||||||
|
|
||||||
return "\n\n---\n\n".join(parts)
|
return "\n\n---\n\n".join(parts)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _p2p_collaboration_hint() -> str:
|
|
||||||
return (
|
|
||||||
"# Multi-Agent Collaboration\n\n"
|
|
||||||
"You are part of a decentralized agent network. You can:\n"
|
|
||||||
"- Use `broadcast_task` to announce subtasks and collect BIDs\n"
|
|
||||||
"- Use `dispatch_task` to assign tasks to specific agents\n"
|
|
||||||
"- Use `poll_task_result` to check task status\n"
|
|
||||||
"- Use `report_user` to deliver final results to the user\n"
|
|
||||||
"- Use `finalize_task` to terminate tasks\n\n"
|
|
||||||
"Rules:\n"
|
|
||||||
"- Never block waiting for results. Dispatch and continue.\n"
|
|
||||||
"- If a task times out, decide whether to retry, failover, or report partial.\n"
|
|
||||||
"- Respect the user's INTERRUPT messages — they have highest priority.\n"
|
|
||||||
"- You are currently in a task-scoped session; focus on the delegated task."
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_identity(self, channel: str | None = None) -> str:
|
def _get_identity(self, channel: str | None = None) -> str:
|
||||||
"""Get the core identity section."""
|
"""Get the core identity section."""
|
||||||
workspace_path = str(self.workspace.expanduser().resolve())
|
workspace_path = str(self.workspace.expanduser().resolve())
|
||||||
@@ -176,7 +154,6 @@ class ContextBuilder:
|
|||||||
sender_id: str | None = None,
|
sender_id: str | None = None,
|
||||||
session_summary: str | None = None,
|
session_summary: str | None = None,
|
||||||
session_metadata: Mapping[str, Any] | None = None,
|
session_metadata: Mapping[str, Any] | None = None,
|
||||||
session_key: str | None = None,
|
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Build the complete message list for an LLM call."""
|
"""Build the complete message list for an LLM call."""
|
||||||
extra = goal_state_runtime_lines(session_metadata)
|
extra = goal_state_runtime_lines(session_metadata)
|
||||||
@@ -198,7 +175,7 @@ class ContextBuilder:
|
|||||||
else:
|
else:
|
||||||
merged = user_content + [{"type": "text", "text": runtime_ctx}]
|
merged = user_content + [{"type": "text", "text": runtime_ctx}]
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": self.build_system_prompt(skill_names, channel=channel, session_summary=session_summary, session_key=session_key)},
|
{"role": "system", "content": self.build_system_prompt(skill_names, channel=channel, session_summary=session_summary)},
|
||||||
*history,
|
*history,
|
||||||
]
|
]
|
||||||
if messages[-1].get("role") == current_role:
|
if messages[-1].get("role") == current_role:
|
||||||
|
|||||||
@@ -24,14 +24,6 @@ from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRun
|
|||||||
from nanobot.agent.subagent import SubagentManager
|
from nanobot.agent.subagent import SubagentManager
|
||||||
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
|
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
|
||||||
from nanobot.agent.tools.message import MessageTool
|
from nanobot.agent.tools.message import MessageTool
|
||||||
from nanobot.agent.tools.p2p import (
|
|
||||||
BroadcastTaskTool,
|
|
||||||
CheckAggregationTool,
|
|
||||||
DispatchTaskTool,
|
|
||||||
FinalizeTaskTool,
|
|
||||||
PollTaskResultTool,
|
|
||||||
ReportUserTool,
|
|
||||||
)
|
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.agent.tools.self import MyTool
|
from nanobot.agent.tools.self import MyTool
|
||||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
@@ -193,7 +185,6 @@ class AgentLoop:
|
|||||||
model_preset: str | None = None,
|
model_preset: str | None = None,
|
||||||
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
|
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
|
||||||
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
|
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
|
||||||
p2p_shell: Any | None = None,
|
|
||||||
):
|
):
|
||||||
from nanobot.config.schema import ToolsConfig
|
from nanobot.config.schema import ToolsConfig
|
||||||
|
|
||||||
@@ -201,7 +192,6 @@ class AgentLoop:
|
|||||||
defaults = AgentDefaults()
|
defaults = AgentDefaults()
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self.channels_config = channels_config
|
self.channels_config = channels_config
|
||||||
self.p2p_shell = p2p_shell
|
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
self._provider_snapshot_loader = provider_snapshot_loader
|
self._provider_snapshot_loader = provider_snapshot_loader
|
||||||
self._preset_snapshot_loader = preset_snapshot_loader
|
self._preset_snapshot_loader = preset_snapshot_loader
|
||||||
@@ -473,22 +463,6 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
registered.append("my")
|
registered.append("my")
|
||||||
|
|
||||||
# Register P2P tools if enabled
|
|
||||||
if self.p2p_shell:
|
|
||||||
self.tools.register(DispatchTaskTool(shell=self.p2p_shell))
|
|
||||||
self.tools.register(PollTaskResultTool(shell=self.p2p_shell))
|
|
||||||
self.tools.register(BroadcastTaskTool(shell=self.p2p_shell))
|
|
||||||
self.tools.register(CheckAggregationTool(shell=self.p2p_shell))
|
|
||||||
self.tools.register(
|
|
||||||
ReportUserTool(
|
|
||||||
send_callback=self.bus.publish_outbound,
|
|
||||||
default_channel=getattr(self.channels_config, "default_channel", ""),
|
|
||||||
default_chat_id=getattr(self.channels_config, "default_chat_id", ""),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.tools.register(FinalizeTaskTool(shell=self.p2p_shell, session_manager=self.sessions))
|
|
||||||
registered.append("p2p")
|
|
||||||
|
|
||||||
logger.info("Registered {} tools: {}", len(registered), registered)
|
logger.info("Registered {} tools: {}", len(registered), registered)
|
||||||
|
|
||||||
async def _connect_mcp(self) -> None:
|
async def _connect_mcp(self) -> None:
|
||||||
|
|||||||
@@ -1,328 +0,0 @@
|
|||||||
"""P2P tools for inter-agent task dispatch and coordination."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Awaitable, Callable
|
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
|
||||||
|
|
||||||
|
|
||||||
class DispatchTaskTool(Tool):
|
|
||||||
"""Asynchronously dispatch a task to another agent. Non-blocking."""
|
|
||||||
|
|
||||||
def __init__(self, shell: "P2PShell"):
|
|
||||||
self._shell = shell
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "dispatch_task"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return (
|
|
||||||
"Dispatch a task to a specific target agent. Returns immediately with a receipt. "
|
|
||||||
"The target agent will process the task independently. Use poll_task_result later to check completion. "
|
|
||||||
"Do NOT block waiting for results."
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parameters(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"to": {"type": "string", "description": "Target agent ID"},
|
|
||||||
"task_description": {"type": "string", "description": "Clear description of the task"},
|
|
||||||
"parent_task_id": {"type": "string", "description": "Parent task ID for ancestry tracking"},
|
|
||||||
"deadline_seconds": {"type": "integer", "default": 300, "description": "Task deadline in seconds"},
|
|
||||||
"allow_redelegation": {"type": "boolean", "default": True, "description": "Whether the target may re-delegate"},
|
|
||||||
},
|
|
||||||
"required": ["to", "task_description"],
|
|
||||||
}
|
|
||||||
|
|
||||||
async def execute(
|
|
||||||
self,
|
|
||||||
to: str,
|
|
||||||
task_description: str,
|
|
||||||
parent_task_id: str | None = None,
|
|
||||||
deadline_seconds: int = 300,
|
|
||||||
allow_redelegation: bool = True,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> str:
|
|
||||||
result = self._shell.dispatch(
|
|
||||||
to=to,
|
|
||||||
parent_task_id=parent_task_id,
|
|
||||||
description=task_description,
|
|
||||||
deadline_seconds=deadline_seconds,
|
|
||||||
allow_redelegation=allow_redelegation,
|
|
||||||
)
|
|
||||||
if result.get("status") == "rejected":
|
|
||||||
return f"Error: dispatch rejected — {result.get('reason', 'unknown')}"
|
|
||||||
if result.get("status") == "circuit_open":
|
|
||||||
failover = result.get("failover_to")
|
|
||||||
return f"Error: circuit open for {to}. Failover candidate: {failover or 'none'}"
|
|
||||||
return (
|
|
||||||
f"Dispatched to {to}. Task ID: {result.get('task_id')}. "
|
|
||||||
f"Depth: {result.get('depth', 0)}."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PollTaskResultTool(Tool):
|
|
||||||
"""Poll the status of a previously dispatched task."""
|
|
||||||
|
|
||||||
def __init__(self, shell: "P2PShell"):
|
|
||||||
self._shell = shell
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "poll_task_result"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return (
|
|
||||||
"Check the current status of a task you previously dispatched. "
|
|
||||||
"Returns completed, pending, timeout, failed, or not_found. "
|
|
||||||
"Call this proactively — do not wait for automatic notifications."
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parameters(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"task_id": {"type": "string", "description": "Task ID returned by dispatch_task"},
|
|
||||||
},
|
|
||||||
"required": ["task_id"],
|
|
||||||
}
|
|
||||||
|
|
||||||
async def execute(self, task_id: str, **kwargs: Any) -> str:
|
|
||||||
result = self._shell.poll(task_id)
|
|
||||||
status = result.get("status")
|
|
||||||
if status == "not_found":
|
|
||||||
return f"Task {task_id} not found."
|
|
||||||
if status == "pending":
|
|
||||||
return f"Task {task_id} is pending (elapsed {result.get('elapsed', '?')}s)."
|
|
||||||
if status == "timeout":
|
|
||||||
return f"Task {task_id} timed out after {result.get('elapsed', '?')}s."
|
|
||||||
if status in ("completed", "failed", "aborted"):
|
|
||||||
from_agent = result.get("from", "unknown")
|
|
||||||
content = result.get("result", "")
|
|
||||||
preview = content[:500] + "..." if len(content) > 500 else content
|
|
||||||
return f"Task {task_id} is {status} (from {from_agent}).\n\n{preview}"
|
|
||||||
return f"Task {task_id} status: {status}"
|
|
||||||
|
|
||||||
|
|
||||||
class BroadcastTaskTool(Tool):
|
|
||||||
"""Broadcast subtasks to discover capable agents."""
|
|
||||||
|
|
||||||
def __init__(self, shell: "P2PShell"):
|
|
||||||
self._shell = shell
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "broadcast_task"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return (
|
|
||||||
"Announce subtasks to the agent network to collect BIDs. "
|
|
||||||
"Returns immediately. Use check_aggregation later to see which agents responded. "
|
|
||||||
"Each subtask should include a capability hint for matching."
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parameters(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"task_id": {"type": "string", "description": "Your task identifier"},
|
|
||||||
"subtasks": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"subtask_id": {"type": "string"},
|
|
||||||
"description": {"type": "string"},
|
|
||||||
"capability": {"type": "string", "description": "Required capability, e.g. 'web_search'"},
|
|
||||||
"budget_seconds": {"type": "integer", "default": 300},
|
|
||||||
},
|
|
||||||
"required": ["subtask_id", "description", "capability"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"aggregation_timeout": {"type": "integer", "default": 30, "description": "Seconds to wait for BIDs"},
|
|
||||||
},
|
|
||||||
"required": ["task_id", "subtasks"],
|
|
||||||
}
|
|
||||||
|
|
||||||
async def execute(
|
|
||||||
self,
|
|
||||||
task_id: str,
|
|
||||||
subtasks: list[dict[str, Any]],
|
|
||||||
aggregation_timeout: int = 30,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> str:
|
|
||||||
result = self._shell.broadcast(task_id, subtasks, aggregation_timeout)
|
|
||||||
invited = result.get("invited", 0)
|
|
||||||
return f"Broadcast opened for {task_id}. Invited {invited} agent(s). Use check_aggregation to collect BIDs."
|
|
||||||
|
|
||||||
|
|
||||||
class CheckAggregationTool(Tool):
|
|
||||||
"""Check the status of a broadcast aggregation window."""
|
|
||||||
|
|
||||||
def __init__(self, shell: "P2PShell"):
|
|
||||||
self._shell = shell
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "check_aggregation"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return (
|
|
||||||
"Check whether a previously broadcast task has collected enough BIDs or timed out. "
|
|
||||||
"Returns the list of responding agents and their bids, or a pending status with counts."
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parameters(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"task_id": {"type": "string", "description": "Task ID used in broadcast_task"},
|
|
||||||
},
|
|
||||||
"required": ["task_id"],
|
|
||||||
}
|
|
||||||
|
|
||||||
async def execute(self, task_id: str, **kwargs: Any) -> str:
|
|
||||||
result = self._shell.check_aggregation(task_id)
|
|
||||||
status = result.get("status")
|
|
||||||
if status == "no_window":
|
|
||||||
return f"No broadcast window found for {task_id}."
|
|
||||||
if status == "pending":
|
|
||||||
received = result.get("received", 0)
|
|
||||||
expected = result.get("expected", "?")
|
|
||||||
remaining = result.get("seconds_remaining", 0)
|
|
||||||
return (
|
|
||||||
f"Aggregation pending for {task_id}: "
|
|
||||||
f"{received}/{expected} received, {remaining}s remaining."
|
|
||||||
)
|
|
||||||
if status == "closed":
|
|
||||||
entries = result.get("entries", [])
|
|
||||||
lines = [f"Aggregation closed for {task_id} ({result.get('reason', '')}):", ""]
|
|
||||||
for e in entries:
|
|
||||||
agent = e.get("from", "unknown")
|
|
||||||
sub = e.get("subtask_id", "")
|
|
||||||
lines.append(f"- {agent} bid for {sub}")
|
|
||||||
return "\n".join(lines)
|
|
||||||
return f"Unknown aggregation status for {task_id}: {status}"
|
|
||||||
|
|
||||||
|
|
||||||
class ReportUserTool(Tool):
|
|
||||||
"""Deliver a final answer to the user."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
|
|
||||||
default_channel: str = "",
|
|
||||||
default_chat_id: str = "",
|
|
||||||
):
|
|
||||||
self._send_callback = send_callback
|
|
||||||
self._default_channel = default_channel
|
|
||||||
self._default_chat_id = default_chat_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "report_user"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return (
|
|
||||||
"Report the final answer to the user. Use this when you have gathered enough results. "
|
|
||||||
"Status 'partial' means some subtasks are incomplete — list them in pending_items."
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parameters(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"final_answer": {"type": "string", "description": "Complete answer for the user"},
|
|
||||||
"status": {"type": "string", "enum": ["success", "partial", "failed"]},
|
|
||||||
"pending_items": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {"type": "string"},
|
|
||||||
"description": "Incomplete items when status is partial",
|
|
||||||
},
|
|
||||||
"task_summary": {"type": "string", "description": "Optional brief summary"},
|
|
||||||
},
|
|
||||||
"required": ["final_answer", "status"],
|
|
||||||
}
|
|
||||||
|
|
||||||
async def execute(
|
|
||||||
self,
|
|
||||||
final_answer: str,
|
|
||||||
status: str,
|
|
||||||
pending_items: list[str] | None = None,
|
|
||||||
task_summary: str = "",
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> str:
|
|
||||||
if not self._send_callback:
|
|
||||||
return "Error: report_user not configured (no send callback)"
|
|
||||||
|
|
||||||
parts = [final_answer]
|
|
||||||
if pending_items:
|
|
||||||
parts.append(f"\n\nPending items:\n" + "\n".join(f"- {i}" for i in pending_items))
|
|
||||||
if task_summary:
|
|
||||||
parts.append(f"\n\nSummary: {task_summary}")
|
|
||||||
|
|
||||||
content = "\n".join(parts)
|
|
||||||
msg = OutboundMessage(
|
|
||||||
channel=self._default_channel,
|
|
||||||
chat_id=self._default_chat_id,
|
|
||||||
content=content,
|
|
||||||
)
|
|
||||||
await self._send_callback(msg)
|
|
||||||
return f"Reported to user (status={status})."
|
|
||||||
|
|
||||||
|
|
||||||
class FinalizeTaskTool(Tool):
|
|
||||||
"""Force-finalize a task and close its sessions."""
|
|
||||||
|
|
||||||
def __init__(self, shell: "P2PShell", session_manager: "SessionManager | None" = None):
|
|
||||||
self._shell = shell
|
|
||||||
self._session_manager = session_manager
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "finalize_task"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return (
|
|
||||||
"Terminate a task and all its subtasks. Use when the user says 'stop', "
|
|
||||||
"or when a task is fundamentally blocked. outcome can be completed, failed, or aborted."
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parameters(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"task_id": {"type": "string"},
|
|
||||||
"outcome": {"type": "string", "enum": ["completed", "failed", "aborted"]},
|
|
||||||
"reason": {"type": "string", "description": "Why the task was finalized"},
|
|
||||||
},
|
|
||||||
"required": ["task_id", "outcome"],
|
|
||||||
}
|
|
||||||
|
|
||||||
async def execute(
|
|
||||||
self,
|
|
||||||
task_id: str,
|
|
||||||
outcome: str,
|
|
||||||
reason: str = "",
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> str:
|
|
||||||
self._shell.finalize(task_id, outcome, reason)
|
|
||||||
if self._session_manager:
|
|
||||||
self._session_manager.finalize_task_session(task_id)
|
|
||||||
return f"Task {task_id} finalized with outcome={outcome}."
|
|
||||||
@@ -75,7 +75,6 @@ class SafeFileHistory(FileHistory):
|
|||||||
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner
|
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner
|
||||||
from nanobot.config.paths import get_workspace_path, is_default_workspace
|
from nanobot.config.paths import get_workspace_path, is_default_workspace
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.p2p.shell import P2PShell
|
|
||||||
from nanobot.utils.helpers import sync_workspace_templates
|
from nanobot.utils.helpers import sync_workspace_templates
|
||||||
from nanobot.utils.restart import (
|
from nanobot.utils.restart import (
|
||||||
consume_restart_notice_from_env,
|
consume_restart_notice_from_env,
|
||||||
@@ -93,17 +92,6 @@ app = typer.Typer(
|
|||||||
console = Console()
|
console = Console()
|
||||||
EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
|
EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
|
||||||
|
|
||||||
|
|
||||||
def _resolve_p2p(config: Config) -> P2PShell | None:
|
|
||||||
"""Resolve P2P config and create the stateless P2P shell."""
|
|
||||||
mb_cfg = config.mailbox
|
|
||||||
if not mb_cfg.enabled:
|
|
||||||
return None
|
|
||||||
return P2PShell(
|
|
||||||
agent_id=mb_cfg.agent_id,
|
|
||||||
mailboxes_root=mb_cfg.mailboxes_root,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CLI input: prompt_toolkit for editing, paste, history, and display
|
# CLI input: prompt_toolkit for editing, paste, history, and display
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -593,13 +581,10 @@ def serve(
|
|||||||
sync_workspace_templates(runtime_config.workspace_path)
|
sync_workspace_templates(runtime_config.workspace_path)
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
session_manager = SessionManager(runtime_config.workspace_path)
|
session_manager = SessionManager(runtime_config.workspace_path)
|
||||||
p2p_shell = _resolve_p2p(runtime_config)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
agent_loop = AgentLoop.from_config(
|
agent_loop = AgentLoop.from_config(
|
||||||
runtime_config, bus,
|
runtime_config, bus,
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
p2p_shell=p2p_shell,
|
|
||||||
image_generation_provider_configs={
|
image_generation_provider_configs={
|
||||||
"openrouter": runtime_config.providers.openrouter,
|
"openrouter": runtime_config.providers.openrouter,
|
||||||
"aihubmix": runtime_config.providers.aihubmix,
|
"aihubmix": runtime_config.providers.aihubmix,
|
||||||
@@ -705,8 +690,6 @@ def _run_gateway(
|
|||||||
cron_store_path = config.workspace_path / "cron" / "jobs.json"
|
cron_store_path = config.workspace_path / "cron" / "jobs.json"
|
||||||
cron = CronService(cron_store_path)
|
cron = CronService(cron_store_path)
|
||||||
|
|
||||||
p2p_shell = _resolve_p2p(config)
|
|
||||||
|
|
||||||
# Create agent with cron service
|
# Create agent with cron service
|
||||||
agent = AgentLoop.from_config(
|
agent = AgentLoop.from_config(
|
||||||
config, bus,
|
config, bus,
|
||||||
@@ -726,7 +709,6 @@ def _run_gateway(
|
|||||||
preset,
|
preset,
|
||||||
),
|
),
|
||||||
provider_signature=provider_snapshot.signature,
|
provider_signature=provider_snapshot.signature,
|
||||||
p2p_shell=p2p_shell,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from nanobot.agent.loop import UNIFIED_SESSION_KEY
|
from nanobot.agent.loop import UNIFIED_SESSION_KEY
|
||||||
@@ -939,8 +921,6 @@ def _run_gateway(
|
|||||||
interval_s=hb_cfg.interval_s,
|
interval_s=hb_cfg.interval_s,
|
||||||
enabled=hb_cfg.enabled,
|
enabled=hb_cfg.enabled,
|
||||||
timezone=config.agents.defaults.timezone,
|
timezone=config.agents.defaults.timezone,
|
||||||
p2p_shell=p2p_shell,
|
|
||||||
bus=bus,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if channels.enabled_channels:
|
if channels.enabled_channels:
|
||||||
@@ -1103,8 +1083,6 @@ def agent(
|
|||||||
cron_store_path = config.workspace_path / "cron" / "jobs.json"
|
cron_store_path = config.workspace_path / "cron" / "jobs.json"
|
||||||
cron = CronService(cron_store_path)
|
cron = CronService(cron_store_path)
|
||||||
|
|
||||||
p2p_shell = _resolve_p2p(config)
|
|
||||||
|
|
||||||
if logs:
|
if logs:
|
||||||
logger.enable("nanobot")
|
logger.enable("nanobot")
|
||||||
else:
|
else:
|
||||||
@@ -1114,7 +1092,6 @@ def agent(
|
|||||||
agent_loop = AgentLoop.from_config(
|
agent_loop = AgentLoop.from_config(
|
||||||
config, bus,
|
config, bus,
|
||||||
cron_service=cron,
|
cron_service=cron,
|
||||||
p2p_shell=p2p_shell,
|
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
console.print(f"[red]Error: {exc}[/red]")
|
console.print(f"[red]Error: {exc}[/red]")
|
||||||
@@ -1312,7 +1289,6 @@ def agent(
|
|||||||
console.print("\nGoodbye!")
|
console.print("\nGoodbye!")
|
||||||
break
|
break
|
||||||
finally:
|
finally:
|
||||||
pass
|
|
||||||
agent_loop.stop()
|
agent_loop.stop()
|
||||||
outbound_task.cancel()
|
outbound_task.cancel()
|
||||||
await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
|
await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
|
||||||
|
|||||||
@@ -282,19 +282,6 @@ class ToolsConfig(Base):
|
|||||||
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
|
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
|
||||||
|
|
||||||
|
|
||||||
class P2PConfig(Base):
|
|
||||||
"""P2P collaboration network configuration."""
|
|
||||||
|
|
||||||
enabled: bool = False
|
|
||||||
agent_id: str = ""
|
|
||||||
description: str = ""
|
|
||||||
capabilities: list[str] = Field(default_factory=list)
|
|
||||||
allow_from: list[str] = Field(default_factory=lambda: ["*"])
|
|
||||||
max_concurrent_tasks: int = 3
|
|
||||||
poll_interval: float = 5.0
|
|
||||||
mailboxes_root: str = "~/.nanobot/mailboxes"
|
|
||||||
|
|
||||||
|
|
||||||
class Config(BaseSettings):
|
class Config(BaseSettings):
|
||||||
"""Root configuration for nanobot."""
|
"""Root configuration for nanobot."""
|
||||||
|
|
||||||
@@ -308,7 +295,6 @@ class Config(BaseSettings):
|
|||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
validation_alias=AliasChoices("modelPresets", "model_presets"),
|
validation_alias=AliasChoices("modelPresets", "model_presets"),
|
||||||
)
|
)
|
||||||
mailbox: P2PConfig = Field(default_factory=P2PConfig)
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _validate_model_preset(self) -> "Config":
|
def _validate_model_preset(self) -> "Config":
|
||||||
|
|||||||
@@ -60,8 +60,6 @@ class HeartbeatService:
|
|||||||
interval_s: int = 30 * 60,
|
interval_s: int = 30 * 60,
|
||||||
enabled: bool = True,
|
enabled: bool = True,
|
||||||
timezone: str | None = None,
|
timezone: str | None = None,
|
||||||
p2p_shell: Any | None = None,
|
|
||||||
bus: Any | None = None,
|
|
||||||
):
|
):
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
@@ -71,11 +69,8 @@ class HeartbeatService:
|
|||||||
self.interval_s = interval_s
|
self.interval_s = interval_s
|
||||||
self.enabled = enabled
|
self.enabled = enabled
|
||||||
self.timezone = timezone
|
self.timezone = timezone
|
||||||
self.p2p_shell = p2p_shell
|
|
||||||
self.bus = bus
|
|
||||||
self._running = False
|
self._running = False
|
||||||
self._task: asyncio.Task | None = None
|
self._task: asyncio.Task | None = None
|
||||||
self._last_inbox_scan: float = 0.0
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def heartbeat_file(self) -> Path:
|
def heartbeat_file(self) -> Path:
|
||||||
@@ -190,32 +185,6 @@ class HeartbeatService:
|
|||||||
"""Execute a single heartbeat tick."""
|
"""Execute a single heartbeat tick."""
|
||||||
from nanobot.utils.evaluator import evaluate_response
|
from nanobot.utils.evaluator import evaluate_response
|
||||||
|
|
||||||
# --- P2P inbox scan ---
|
|
||||||
if self.p2p_shell and self.bus:
|
|
||||||
try:
|
|
||||||
new_msgs = self.p2p_shell.scan_new_inbox(since=self._last_inbox_scan)
|
|
||||||
if new_msgs:
|
|
||||||
self._last_inbox_scan = time.time()
|
|
||||||
from nanobot.bus.events import InboundMessage
|
|
||||||
for msg in new_msgs:
|
|
||||||
await self.bus.publish_inbound(
|
|
||||||
InboundMessage(
|
|
||||||
channel="p2p",
|
|
||||||
sender_id=msg.get("from", "unknown"),
|
|
||||||
chat_id=msg.get("task_id", ""),
|
|
||||||
content=msg.get("payload", {}).get("description", ""),
|
|
||||||
metadata={"p2p_msg": msg},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Heartbeat: injected P2P task {} from {}",
|
|
||||||
msg.get("task_id", ""),
|
|
||||||
msg.get("from", "unknown"),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Heartbeat P2P scan failed")
|
|
||||||
|
|
||||||
# --- Legacy heartbeat file check ---
|
|
||||||
content = self._read_heartbeat_file()
|
content = self._read_heartbeat_file()
|
||||||
if not content:
|
if not content:
|
||||||
logger.debug("Heartbeat: HEARTBEAT.md missing or empty")
|
logger.debug("Heartbeat: HEARTBEAT.md missing or empty")
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
"""P2P inter-agent coordination layer."""
|
|
||||||
|
|
||||||
from nanobot.p2p.shell import P2PShell
|
|
||||||
|
|
||||||
__all__ = ["P2PShell"]
|
|
||||||
@@ -1,426 +0,0 @@
|
|||||||
"""P2P shell: filesystem-backed inter-agent coordination.
|
|
||||||
|
|
||||||
All state is stored in the mailbox filesystem; this class is stateless.
|
|
||||||
Restarting the gateway restores all task state by scanning files.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Literal
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
|
|
||||||
class P2PShell:
|
|
||||||
"""Stateless P2P coordination shell backed by the mailbox filesystem."""
|
|
||||||
|
|
||||||
def __init__(self, agent_id: str, mailboxes_root: str):
|
|
||||||
self.agent_id = agent_id
|
|
||||||
self.root = Path(mailboxes_root).expanduser()
|
|
||||||
self.inbox = self.root / agent_id / "inbox"
|
|
||||||
self.processed = self.root / agent_id / "processed"
|
|
||||||
self.links_dir = self.root / "_links"
|
|
||||||
self.windows_dir = self.root / "_windows"
|
|
||||||
|
|
||||||
for d in (self.inbox, self.processed, self.links_dir, self.windows_dir):
|
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Discovery
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def discover(self, capability: str, top_k: int = 3) -> list[dict[str, Any]]:
|
|
||||||
"""Read _registry.json and return candidates matching capability."""
|
|
||||||
registry = self._load_json(self.root / "_registry.json", default={})
|
|
||||||
candidates: list[dict[str, Any]] = []
|
|
||||||
for aid, info in registry.items():
|
|
||||||
if aid == self.agent_id:
|
|
||||||
continue
|
|
||||||
caps = info.get("capabilities", [])
|
|
||||||
if capability.lower() in " ".join(caps).lower():
|
|
||||||
candidates.append({"agent_id": aid, **info})
|
|
||||||
# Sort: idle first, then by current task load
|
|
||||||
candidates.sort(key=lambda x: (x.get("status") != "idle", x.get("current_tasks", 0)))
|
|
||||||
return candidates[:top_k]
|
|
||||||
|
|
||||||
def heartbeat(self, description: str, capabilities: list[str]) -> None:
|
|
||||||
"""Write self state into the shared _registry.json."""
|
|
||||||
registry = self._load_json(self.root / "_registry.json", default={})
|
|
||||||
registry[self.agent_id] = {
|
|
||||||
"description": description,
|
|
||||||
"capabilities": capabilities,
|
|
||||||
"status": "idle",
|
|
||||||
"last_heartbeat": int(time.time()),
|
|
||||||
"endpoint": "",
|
|
||||||
}
|
|
||||||
self._atomic_write(self.root / "_registry.json", registry)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Task dispatch
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def dispatch(
|
|
||||||
self,
|
|
||||||
to: str,
|
|
||||||
parent_task_id: str | None,
|
|
||||||
description: str,
|
|
||||||
deadline_seconds: int = 300,
|
|
||||||
allow_redelegation: bool = True,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Write a task into the target agent's inbox and return a receipt."""
|
|
||||||
task_id = (
|
|
||||||
f"{parent_task_id}.{int(time.time())}"
|
|
||||||
if parent_task_id
|
|
||||||
else f"root_{int(time.time())}"
|
|
||||||
)
|
|
||||||
|
|
||||||
depth = self._get_depth(parent_task_id) if parent_task_id else 0
|
|
||||||
if depth >= 3:
|
|
||||||
return {"status": "rejected", "reason": "max_depth_exceeded"}
|
|
||||||
|
|
||||||
if parent_task_id and self._is_ancestor(to, parent_task_id):
|
|
||||||
return {"status": "rejected", "reason": "ancestry_loop"}
|
|
||||||
|
|
||||||
if not self._circuit_allow(to):
|
|
||||||
failover = self._find_failover(to)
|
|
||||||
return {"status": "circuit_open", "failover_to": failover}
|
|
||||||
|
|
||||||
target_inbox = self.root / to / "inbox"
|
|
||||||
target_inbox.mkdir(parents=True, exist_ok=True)
|
|
||||||
if list(target_inbox.glob(f"task_{task_id}_from_{self.agent_id}_*.json")):
|
|
||||||
return {"status": "dispatched", "task_id": task_id, "note": "cached"}
|
|
||||||
|
|
||||||
ancestry = (
|
|
||||||
(self._get_ancestry(parent_task_id) + [self.agent_id])
|
|
||||||
if parent_task_id
|
|
||||||
else [self.agent_id]
|
|
||||||
)
|
|
||||||
|
|
||||||
msg: dict[str, Any] = {
|
|
||||||
"version": "p2p/v1",
|
|
||||||
"type": "task_dispatch",
|
|
||||||
"from": self.agent_id,
|
|
||||||
"to": to,
|
|
||||||
"task_id": task_id,
|
|
||||||
"ancestry": ancestry,
|
|
||||||
"depth": depth + 1,
|
|
||||||
"payload": {
|
|
||||||
"description": description,
|
|
||||||
"allow_redelegation": allow_redelegation,
|
|
||||||
},
|
|
||||||
"deadline": int(time.time()) + deadline_seconds,
|
|
||||||
"timestamp": int(time.time()),
|
|
||||||
}
|
|
||||||
|
|
||||||
path = target_inbox / f"task_{task_id}_from_{self.agent_id}_{os.urandom(4).hex()}.json"
|
|
||||||
self._atomic_write(path, msg)
|
|
||||||
logger.info("P2P dispatch: {} -> {} (task_id={})", self.agent_id, to, task_id)
|
|
||||||
return {"status": "dispatched", "task_id": task_id, "depth": depth + 1}
|
|
||||||
|
|
||||||
def poll(self, task_id: str) -> dict[str, Any]:
|
|
||||||
"""Scan inbox/processed and return task status."""
|
|
||||||
# Check processed results first
|
|
||||||
results = list(self.processed.glob(f"result_{task_id}_from_*.json"))
|
|
||||||
if results:
|
|
||||||
data = self._load_json(results[0])
|
|
||||||
payload = data.get("payload", {})
|
|
||||||
return {
|
|
||||||
"status": payload.get("outcome", "completed"),
|
|
||||||
"result": payload.get("content", ""),
|
|
||||||
"from": data["from"],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check inbox for results (not yet moved to processed)
|
|
||||||
inbox_results = list(self.inbox.glob(f"result_{task_id}_from_*.json"))
|
|
||||||
if inbox_results:
|
|
||||||
data = self._load_json(inbox_results[0])
|
|
||||||
payload = data.get("payload", {})
|
|
||||||
return {
|
|
||||||
"status": payload.get("outcome", "completed"),
|
|
||||||
"result": payload.get("content", ""),
|
|
||||||
"from": data["from"],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check inbox for pending task dispatches
|
|
||||||
pending = list(self.inbox.glob(f"task_{task_id}_from_*.json"))
|
|
||||||
if pending:
|
|
||||||
data = self._load_json(pending[0])
|
|
||||||
deadline = data.get("deadline", 0)
|
|
||||||
elapsed = int(time.time() - data["timestamp"])
|
|
||||||
if time.time() > deadline:
|
|
||||||
return {"status": "timeout", "elapsed": elapsed}
|
|
||||||
return {"status": "pending", "elapsed": elapsed}
|
|
||||||
|
|
||||||
return {"status": "not_found"}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Aggregation (broadcast + check)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def broadcast(
|
|
||||||
self,
|
|
||||||
task_id: str,
|
|
||||||
subtasks: list[dict[str, Any]],
|
|
||||||
aggregation_timeout: int = 30,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Write bid requests to candidate agents and create a window descriptor."""
|
|
||||||
targets: list[tuple[str, str]] = [] # (subtask_id, agent_id)
|
|
||||||
for sub in subtasks:
|
|
||||||
caps = sub.get("capability", "")
|
|
||||||
found = self.discover(caps, top_k=3)
|
|
||||||
targets.extend([(sub["subtask_id"], a["agent_id"]) for a in found])
|
|
||||||
|
|
||||||
for subtask_id, target in targets:
|
|
||||||
msg: dict[str, Any] = {
|
|
||||||
"version": "p2p/v1",
|
|
||||||
"type": "bid_request",
|
|
||||||
"from": self.agent_id,
|
|
||||||
"to": target,
|
|
||||||
"task_id": task_id,
|
|
||||||
"subtask_id": subtask_id,
|
|
||||||
"payload": sub,
|
|
||||||
"deadline": int(time.time()) + aggregation_timeout,
|
|
||||||
"timestamp": int(time.time()),
|
|
||||||
}
|
|
||||||
target_inbox = self.root / target / "inbox"
|
|
||||||
target_inbox.mkdir(parents=True, exist_ok=True)
|
|
||||||
path = target_inbox / f"bid_{task_id}_{subtask_id}_from_{self.agent_id}.json"
|
|
||||||
self._atomic_write(path, msg)
|
|
||||||
|
|
||||||
window: dict[str, Any] = {
|
|
||||||
"task_id": task_id,
|
|
||||||
"mode": "bid",
|
|
||||||
"expected": len(targets),
|
|
||||||
"deadline": int(time.time()) + aggregation_timeout,
|
|
||||||
"created_at": int(time.time()),
|
|
||||||
}
|
|
||||||
self._atomic_write(self.windows_dir / f"{task_id}.json", window)
|
|
||||||
logger.info(
|
|
||||||
"P2P broadcast: {} invited {} agents for task_id={}",
|
|
||||||
self.agent_id,
|
|
||||||
len(targets),
|
|
||||||
task_id,
|
|
||||||
)
|
|
||||||
return {"status": "bidding_opened", "task_id": task_id, "invited": len(targets)}
|
|
||||||
|
|
||||||
def check_aggregation(self, task_id: str) -> dict[str, Any]:
|
|
||||||
"""Lazily check aggregation status by scanning files."""
|
|
||||||
window_path = self.windows_dir / f"{task_id}.json"
|
|
||||||
if not window_path.exists():
|
|
||||||
return {"status": "no_window"}
|
|
||||||
|
|
||||||
window = self._load_json(window_path)
|
|
||||||
mode = window.get("mode", "bid")
|
|
||||||
deadline = window.get("deadline", 0)
|
|
||||||
|
|
||||||
pattern = f"{mode}_{task_id}_*_from_*.json"
|
|
||||||
entries: list[dict[str, Any]] = []
|
|
||||||
for f in self.inbox.glob(pattern):
|
|
||||||
data = self._load_json(f)
|
|
||||||
entries.append(
|
|
||||||
{
|
|
||||||
"from": data.get("from", ""),
|
|
||||||
"subtask_id": data.get("subtask_id", ""),
|
|
||||||
"payload": data.get("payload", {}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
is_timeout = time.time() > deadline
|
|
||||||
is_full = window.get("expected") and len(entries) >= window["expected"]
|
|
||||||
|
|
||||||
if is_timeout or is_full:
|
|
||||||
self._atomic_write(
|
|
||||||
self.processed / f"window_{task_id}.json",
|
|
||||||
{**window, "closed_at": int(time.time()), "received": len(entries)},
|
|
||||||
)
|
|
||||||
window_path.unlink(missing_ok=True)
|
|
||||||
return {
|
|
||||||
"status": "closed",
|
|
||||||
"mode": mode,
|
|
||||||
"entries": entries,
|
|
||||||
"reason": "timeout" if is_timeout else "full",
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "pending",
|
|
||||||
"received": len(entries),
|
|
||||||
"expected": window.get("expected"),
|
|
||||||
"seconds_remaining": max(0, deadline - int(time.time())),
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Result reporting
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def report_result(
|
|
||||||
self,
|
|
||||||
to: str,
|
|
||||||
task_id: str,
|
|
||||||
outcome: Literal["completed", "failed", "aborted"],
|
|
||||||
content: str,
|
|
||||||
callback: dict[str, Any] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Worker calls this to write a result into the manager's inbox."""
|
|
||||||
msg: dict[str, Any] = {
|
|
||||||
"version": "p2p/v1",
|
|
||||||
"type": "result",
|
|
||||||
"from": self.agent_id,
|
|
||||||
"to": to,
|
|
||||||
"task_id": task_id,
|
|
||||||
"payload": {"outcome": outcome, "content": content},
|
|
||||||
"timestamp": int(time.time()),
|
|
||||||
}
|
|
||||||
if callback:
|
|
||||||
msg["callback"] = callback
|
|
||||||
target_inbox = self.root / to / "inbox"
|
|
||||||
target_inbox.mkdir(parents=True, exist_ok=True)
|
|
||||||
path = target_inbox / f"result_{task_id}_from_{self.agent_id}_{os.urandom(4).hex()}.json"
|
|
||||||
self._atomic_write(path, msg)
|
|
||||||
logger.info("P2P result: {} -> {} (task_id={}, outcome={})", self.agent_id, to, task_id, outcome)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Finalization
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def finalize(self, task_id: str, outcome: str, reason: str = "") -> None:
|
|
||||||
"""Move all task files from inbox to processed and mark outcome."""
|
|
||||||
for src in list(self.inbox.glob(f"*{task_id}*")):
|
|
||||||
data = self._load_json(src)
|
|
||||||
data.setdefault("payload", {})
|
|
||||||
data["payload"]["outcome"] = outcome
|
|
||||||
data["payload"]["reason"] = reason
|
|
||||||
dst = self.processed / src.name
|
|
||||||
self._atomic_write(dst, data)
|
|
||||||
src.unlink(missing_ok=True)
|
|
||||||
logger.info("P2P finalize: task_id={} outcome={}", task_id, outcome)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Circuit breaker
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _circuit_allow(self, to: str) -> bool:
|
|
||||||
link = self._load_json(
|
|
||||||
self.links_dir / f"{to}.json",
|
|
||||||
default={"failures": 0, "last_failure": 0, "open": False},
|
|
||||||
)
|
|
||||||
if not link.get("open"):
|
|
||||||
return True
|
|
||||||
backoff = 300 * (2 ** max(0, link.get("failures", 0) - 3))
|
|
||||||
if time.time() - link.get("last_failure", 0) > backoff:
|
|
||||||
link["open"] = False
|
|
||||||
self._atomic_write(self.links_dir / f"{to}.json", link)
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def record_failure(self, to: str) -> None:
|
|
||||||
link = self._load_json(
|
|
||||||
self.links_dir / f"{to}.json",
|
|
||||||
default={"failures": 0, "last_failure": 0, "open": False},
|
|
||||||
)
|
|
||||||
link["failures"] = link.get("failures", 0) + 1
|
|
||||||
link["last_failure"] = int(time.time())
|
|
||||||
if link["failures"] >= 3:
|
|
||||||
link["open"] = True
|
|
||||||
self._atomic_write(self.links_dir / f"{to}.json", link)
|
|
||||||
|
|
||||||
def record_success(self, to: str) -> None:
|
|
||||||
link = self._load_json(
|
|
||||||
self.links_dir / f"{to}.json",
|
|
||||||
default={"failures": 0, "last_failure": 0, "open": False},
|
|
||||||
)
|
|
||||||
link["failures"] = 0
|
|
||||||
link["open"] = False
|
|
||||||
self._atomic_write(self.links_dir / f"{to}.json", link)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Inbox scanning (for HeartbeatService)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def scan_inbox(self) -> list[dict[str, Any]]:
|
|
||||||
"""Return all task_dispatch messages currently in inbox."""
|
|
||||||
messages: list[dict[str, Any]] = []
|
|
||||||
for f in sorted(self.inbox.glob("task_*_from_*.json"), key=lambda p: p.stat().st_mtime):
|
|
||||||
data = self._load_json(f)
|
|
||||||
# Skip expired tasks
|
|
||||||
if time.time() > data.get("deadline", 0):
|
|
||||||
continue
|
|
||||||
data["_filename"] = f.name
|
|
||||||
messages.append(data)
|
|
||||||
return messages
|
|
||||||
|
|
||||||
def scan_new_inbox(self, since: float | None = None) -> list[dict[str, Any]]:
|
|
||||||
"""Return inbox messages newer than the given timestamp."""
|
|
||||||
messages: list[dict[str, Any]] = []
|
|
||||||
for f in self.inbox.glob("task_*_from_*.json"):
|
|
||||||
mtime = f.stat().st_mtime
|
|
||||||
if since is not None and mtime <= since:
|
|
||||||
continue
|
|
||||||
data = self._load_json(f)
|
|
||||||
if time.time() > data.get("deadline", 0):
|
|
||||||
continue
|
|
||||||
data["_filename"] = f.name
|
|
||||||
data["_mtime"] = mtime
|
|
||||||
messages.append(data)
|
|
||||||
return sorted(messages, key=lambda x: x.get("_mtime", 0))
|
|
||||||
|
|
||||||
def mark_processed(self, filename: str) -> None:
|
|
||||||
"""Move a single inbox file to processed."""
|
|
||||||
src = self.inbox / filename
|
|
||||||
if not src.exists():
|
|
||||||
return
|
|
||||||
dst = self.processed / filename
|
|
||||||
try:
|
|
||||||
import shutil
|
|
||||||
shutil.move(str(src), str(dst))
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Failed to mark processed: {}", filename)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _load_json(self, path: Path, default: Any | None = None) -> Any:
|
|
||||||
if not path.exists():
|
|
||||||
return default if default is not None else {}
|
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
def _atomic_write(self, path: Path, data: dict[str, Any]) -> None:
|
|
||||||
tmp = path.with_suffix(".tmp")
|
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
||||||
tmp.rename(path)
|
|
||||||
|
|
||||||
def _get_depth(self, task_id: str) -> int:
|
|
||||||
return task_id.count(".")
|
|
||||||
|
|
||||||
def _is_ancestor(self, agent_id: str, parent_task_id: str) -> bool:
|
|
||||||
for f in list(self.processed.glob(f"*{parent_task_id}*")) + list(
|
|
||||||
self.inbox.glob(f"*{parent_task_id}*")
|
|
||||||
):
|
|
||||||
data = self._load_json(f)
|
|
||||||
if agent_id in data.get("ancestry", []):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _get_ancestry(self, task_id: str) -> list[str]:
|
|
||||||
for f in list(self.processed.glob(f"*{task_id}*")) + list(
|
|
||||||
self.inbox.glob(f"*{task_id}*")
|
|
||||||
):
|
|
||||||
data = self._load_json(f)
|
|
||||||
return data.get("ancestry", [])
|
|
||||||
return []
|
|
||||||
|
|
||||||
def _find_failover(self, to: str) -> str | None:
|
|
||||||
registry = self._load_json(self.root / "_registry.json", default={})
|
|
||||||
target_caps = registry.get(to, {}).get("capabilities", [])
|
|
||||||
for aid, info in registry.items():
|
|
||||||
if aid == to:
|
|
||||||
continue
|
|
||||||
if any(c in info.get("capabilities", []) for c in target_caps):
|
|
||||||
return aid
|
|
||||||
return None
|
|
||||||
@@ -8,7 +8,7 @@ from contextlib import suppress
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -581,36 +581,6 @@ class SessionManager:
|
|||||||
return self._session_payload(repaired)
|
return self._session_payload(repaired)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_or_create_task_session(
|
|
||||||
self,
|
|
||||||
base_key: str,
|
|
||||||
task_id: str,
|
|
||||||
role: Literal["manager", "worker"] = "worker",
|
|
||||||
) -> Session:
|
|
||||||
"""Get or create an isolated session for a specific task.
|
|
||||||
|
|
||||||
Key format: task:{base_key}:{task_id}:{role}
|
|
||||||
Example: task:slack:C123:root_qml:manager
|
|
||||||
"""
|
|
||||||
task_key = f"task:{base_key}:{task_id}:{role}"
|
|
||||||
return self.get_or_create(task_key)
|
|
||||||
|
|
||||||
def list_task_sessions(self, base_key: str) -> list[Session]:
|
|
||||||
"""List all task-scoped sessions for a given base key."""
|
|
||||||
prefix = f"task:{base_key}:"
|
|
||||||
return [
|
|
||||||
session for key, session in self._cache.items()
|
|
||||||
if key.startswith(prefix)
|
|
||||||
]
|
|
||||||
|
|
||||||
def finalize_task_session(self, task_id: str) -> None:
|
|
||||||
"""Mark a task session as finalized (read-only) by setting metadata."""
|
|
||||||
prefix = f"task:"
|
|
||||||
for key, session in list(self._cache.items()):
|
|
||||||
if f":{task_id}:" in key and key.startswith(prefix):
|
|
||||||
session.metadata["finalized"] = True
|
|
||||||
self.save(session)
|
|
||||||
|
|
||||||
def list_sessions(self) -> list[dict[str, Any]]:
|
def list_sessions(self) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
List all sessions.
|
List all sessions.
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
---
|
|
||||||
name: create-instance
|
|
||||||
description: "Create a new nanobot instance with separate config and workspace. Use when the user wants to set up a new bot, create a new instance for a different channel, persona, or purpose. Triggers on: create instance, new bot, set up bot, add bot, create telegram/discord/feishu/slack/wechat/wecom/dingtalk/qq/email/matrix/msteams/whatsapp bot, multi-instance setup, inter-agent communication."
|
|
||||||
---
|
|
||||||
|
|
||||||
# Create Instance
|
|
||||||
|
|
||||||
Set up a new nanobot instance with its own config and workspace.
|
|
||||||
|
|
||||||
## Steps
|
|
||||||
|
|
||||||
1. **Collect information** (ask one at a time if not already provided):
|
|
||||||
- **Instance name** (required): short identifier, e.g. `telegram-bot`, `work-slack`
|
|
||||||
- **Channel type** (required): see table below
|
|
||||||
- **Model** (optional): LLM model, defaults to current instance
|
|
||||||
|
|
||||||
2. **Do NOT collect secrets** in the chat (API keys, bot tokens). API keys are automatically inherited from the current instance via `--inherit-config`. Channel-specific tokens must be filled in manually after creation.
|
|
||||||
|
|
||||||
3. **Run the creation script**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python <skill-dir>/scripts/create_instance.py --name <name> --channel <channel> --inherit-config <current-config>
|
|
||||||
```
|
|
||||||
|
|
||||||
- `<skill-dir>` — the directory containing this SKILL.md
|
|
||||||
- `<current-config>` — current instance's config path, typically `~/.nanobot/config.json`
|
|
||||||
- Optional: `--model <model>`, `--config-dir <path>`
|
|
||||||
|
|
||||||
**Exec tool constraints:**
|
|
||||||
- Use forward-slash paths (works on all platforms)
|
|
||||||
- Do not wrap paths in quotes
|
|
||||||
- Do not use `cd`; pass the full script path directly
|
|
||||||
|
|
||||||
4. **Report results** to the user:
|
|
||||||
- Config and workspace paths (script outputs them)
|
|
||||||
- Required fields to fill in (script lists them)
|
|
||||||
- Start command: `nanobot gateway --config <config-path>`
|
|
||||||
|
|
||||||
## Available Channels
|
|
||||||
|
|
||||||
| Channel | Key | Required Fields |
|
|
||||||
|---------|-----|-----------------|
|
|
||||||
| Telegram | `telegram` | token |
|
|
||||||
| Discord | `discord` | token |
|
|
||||||
| Feishu / Lark | `feishu` | app_id, app_secret |
|
|
||||||
| DingTalk | `dingtalk` | client_id, client_secret |
|
|
||||||
| Slack | `slack` | bot_token, app_token |
|
|
||||||
| WeCom | `wecom` | bot_id, secret |
|
|
||||||
| WeChat OA | `weixin` | token |
|
|
||||||
| WhatsApp | `whatsapp` | bridge_token |
|
|
||||||
| QQ | `qq` | app_id, secret |
|
|
||||||
| Email | `email` | imap_host, imap_username, imap_password, smtp_host, smtp_username, smtp_password, from_address |
|
|
||||||
| Matrix | `matrix` | user_id, password or access_token |
|
|
||||||
| MS Teams | `msteams` | app_id, app_password, tenant_id |
|
|
||||||
| MoChat | `mochat` | claw_token |
|
|
||||||
| WebSocket | `websocket` | token |
|
|
||||||
|
|
||||||
For detailed channel configuration including optional fields, see `references/channels.md`.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
- **"Unknown channel"**: Channel name must match the Key column exactly. Run the script without arguments to see usage.
|
|
||||||
- **"Config already exists"**: Use a different `--name` or `--config-dir` to create in a new location.
|
|
||||||
- **Port conflicts**: The script auto-assigns free ports for gateway and API if defaults are in use.
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
# Channel Configuration Reference
|
|
||||||
|
|
||||||
Detailed configuration for each supported channel.
|
|
||||||
|
|
||||||
## Field Types
|
|
||||||
|
|
||||||
- **Required**: defaults to empty string `""`, must be filled in before the instance can start
|
|
||||||
- **Optional**: has a sensible default, can be customized
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## telegram
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `token` — Bot token from @BotFather
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `proxy` — HTTP proxy URL
|
|
||||||
- `group_policy` — `"open"` (all messages) or `"mention"` (default, only when @mentioned)
|
|
||||||
- `streaming` — Enable streaming responses (default: true)
|
|
||||||
- `reply_to_message` — Reply to the triggering message (default: false)
|
|
||||||
- `react_emoji` — Emoji for "thinking" reaction (default: `"eyes"`)
|
|
||||||
- `inline_keyboards` — Enable inline keyboard buttons (default: false)
|
|
||||||
|
|
||||||
## discord
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `token` — Bot token from Discord Developer Portal
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `allow_channels` — Restrict to specific channel IDs
|
|
||||||
- `group_policy` — `"mention"` (default) or `"open"`
|
|
||||||
- `streaming` — Enable streaming (default: true)
|
|
||||||
- `proxy` — HTTP proxy URL
|
|
||||||
- `intents` — Discord gateway intents (default: 37377)
|
|
||||||
- `read_receipt_emoji` — Emoji for read receipt
|
|
||||||
- `working_emoji` — Emoji for "working" indicator
|
|
||||||
|
|
||||||
## feishu
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `app_id` — Feishu app ID
|
|
||||||
- `app_secret` — Feishu app secret
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `encrypt_key` — Event encryption key
|
|
||||||
- `verification_token` — Event verification token
|
|
||||||
- `domain` — `"feishu"` (default) or `"lark"`
|
|
||||||
- `group_policy` — `"mention"` (default) or `"open"`
|
|
||||||
- `streaming` — Enable streaming (default: true)
|
|
||||||
|
|
||||||
## dingtalk
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `client_id` — DingTalk app client ID
|
|
||||||
- `client_secret` — DingTalk app client secret
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `allow_from` — Allowed user IDs
|
|
||||||
|
|
||||||
## slack
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `bot_token` — Bot OAuth token (`xoxb-...`)
|
|
||||||
- `app_token` — App-level token (`xapp-...`)
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `mode` — `"socket"` (default, Socket Mode) or `"webhook"`
|
|
||||||
- `reply_in_thread` — Reply in thread (default: true)
|
|
||||||
- `react_emoji` — "thinking" emoji (default: `"eyes"`)
|
|
||||||
- `done_emoji` — "done" emoji (default: `"white_check_mark"`)
|
|
||||||
- `group_policy` — `"mention"` (default) or `"open"`
|
|
||||||
- `dm.enabled` — Enable DM support
|
|
||||||
- `dm.policy` — DM policy
|
|
||||||
- `dm.allow_from` — Allowed DM users
|
|
||||||
|
|
||||||
## wecom
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `bot_id` — WeCom bot ID
|
|
||||||
- `secret` — WeCom bot secret
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `allow_from` — Allowed users
|
|
||||||
- `welcome_message` — Welcome message for new chats
|
|
||||||
|
|
||||||
## weixin
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `token` — WeChat Official Account token
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `base_url` — API base URL
|
|
||||||
- `cdn_base_url` — CDN base URL
|
|
||||||
- `state_dir` — State persistence directory
|
|
||||||
- `poll_timeout` — Long polling timeout
|
|
||||||
|
|
||||||
## whatsapp
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `bridge_token` — WhatsApp bridge token (auto-generated if absent)
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `bridge_url` — Bridge WebSocket URL (default: `"ws://localhost:3001"`)
|
|
||||||
- `group_policy` — `"open"` (default) or `"mention"`
|
|
||||||
|
|
||||||
## qq
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `app_id` — QQ bot app ID
|
|
||||||
- `secret` — QQ bot secret
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `msg_format` — `"plain"` or `"markdown"`
|
|
||||||
- `ack_message` — Acknowledgment message text
|
|
||||||
- `media_dir` — Media file directory
|
|
||||||
|
|
||||||
## email
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `imap_host` — IMAP server hostname
|
|
||||||
- `imap_username` — IMAP login username
|
|
||||||
- `imap_password` — IMAP login password
|
|
||||||
- `smtp_host` — SMTP server hostname
|
|
||||||
- `smtp_username` — SMTP login username
|
|
||||||
- `smtp_password` — SMTP login password
|
|
||||||
- `from_address` — Sender email address
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `imap_port` — IMAP port (default: 993)
|
|
||||||
- `smtp_port` — SMTP port (default: 587)
|
|
||||||
- `imap_use_ssl` — Use SSL for IMAP (default: true)
|
|
||||||
- `smtp_use_tls` — Use TLS for SMTP (default: true)
|
|
||||||
- `poll_interval_seconds` — Polling interval (default: 30)
|
|
||||||
- `mark_seen` — Mark emails as read (default: true)
|
|
||||||
- `max_body_chars` — Max email body length (default: 12000)
|
|
||||||
- `subject_prefix` — Reply subject prefix (default: `"Re: "`)
|
|
||||||
- `verify_dkim` — Verify DKIM signatures (default: true)
|
|
||||||
- `verify_spf` — Verify SPF records (default: true)
|
|
||||||
- `allowed_attachment_types` — Allowed file extensions
|
|
||||||
- `max_attachment_size` — Max attachment size in bytes
|
|
||||||
- `consent_granted` — Must be set to `true` for the channel to start (default: false)
|
|
||||||
- `auto_reply_enabled` — Enable auto-reply (default: true)
|
|
||||||
|
|
||||||
## matrix
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `user_id` — Matrix user ID (e.g. `@bot:matrix.org`)
|
|
||||||
- `password` or `access_token` — Login password OR access token
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `homeserver` — Homeserver URL (default: `"https://matrix.org"`)
|
|
||||||
- `device_id` — Device ID
|
|
||||||
- `e2eeEnabled` — Enable end-to-end encryption (default: true)
|
|
||||||
- `group_policy` — `"open"`, `"mention"`, or `"allowlist"`
|
|
||||||
- `streaming` — Enable streaming (default: false)
|
|
||||||
- `max_media_bytes` — Max media file size (default: 20MB)
|
|
||||||
|
|
||||||
## msteams
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `app_id` — Azure AD app ID
|
|
||||||
- `app_password` — Azure AD app password/secret
|
|
||||||
- `tenant_id` — Azure AD tenant ID
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `host` — Listen host (default: `"0.0.0.0"`)
|
|
||||||
- `port` — Listen port (default: 3978)
|
|
||||||
- `reply_in_thread` — Reply in thread (default: true)
|
|
||||||
- `validate_inbound_auth` — Validate incoming auth (default: true)
|
|
||||||
|
|
||||||
## mochat
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `claw_token` — MoChat Claw token
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `base_url` — API base URL
|
|
||||||
- `socket_url` — WebSocket URL
|
|
||||||
- `refresh_interval_ms` — Refresh interval in ms
|
|
||||||
- `watch_timeout_ms` — Watch timeout in ms
|
|
||||||
|
|
||||||
## websocket
|
|
||||||
|
|
||||||
Built-in WebSocket channel for programmatic access.
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `token` — Authentication token (enabled by default; set `websocket_requires_token: false` to disable)
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `host` — Listen host (default: `"127.0.0.1"`)
|
|
||||||
- `port` — Listen port (default: 8765)
|
|
||||||
- `allow_from` — Allowed origins (default: `["*"]`)
|
|
||||||
- `streaming` — Enable streaming (default: true)
|
|
||||||
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Create a new nanobot instance with a dedicated config and workspace.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
create_instance.py --name <name> --channel <channel> [--model <model>] [--config-dir <dir>]
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
create_instance.py --name telegram-bot --channel telegram
|
|
||||||
create_instance.py --name discord-bot --channel discord --model deepseek/deepseek-chat
|
|
||||||
create_instance.py --name my-bot --channel telegram --config-dir ~/.nanobot-custom
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import socket
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_name(name: str) -> str:
|
|
||||||
"""Normalize and validate instance name."""
|
|
||||||
name = name.strip().lower()
|
|
||||||
name = re.sub(r"[^a-z0-9-]", "-", name)
|
|
||||||
name = re.sub(r"-{2,}", "-", name)
|
|
||||||
name = name.strip("-")
|
|
||||||
if not name:
|
|
||||||
print("[ERROR] Instance name must contain at least one letter or digit.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
if len(name) > 64:
|
|
||||||
print(f"[ERROR] Instance name too long ({len(name)} chars, max 64).", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
return name
|
|
||||||
|
|
||||||
|
|
||||||
def _get_available_channels() -> list[str]:
|
|
||||||
"""Get list of available channel names without importing channel classes."""
|
|
||||||
from nanobot.channels.registry import discover_channel_names
|
|
||||||
|
|
||||||
return discover_channel_names()
|
|
||||||
|
|
||||||
|
|
||||||
def _run_onboard(config_path: Path, workspace: Path) -> None:
|
|
||||||
"""Create skeleton config + workspace using nanobot's programmatic API."""
|
|
||||||
from nanobot.cli.commands import _onboard_plugins
|
|
||||||
from nanobot.config.loader import save_config, set_config_path
|
|
||||||
from nanobot.config.paths import get_workspace_path
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
from nanobot.utils.helpers import sync_workspace_templates
|
|
||||||
|
|
||||||
config = Config()
|
|
||||||
config.agents.defaults.workspace = str(workspace)
|
|
||||||
set_config_path(config_path)
|
|
||||||
save_config(config, config_path)
|
|
||||||
_onboard_plugins(config_path)
|
|
||||||
|
|
||||||
workspace_path = get_workspace_path(config.workspace_path)
|
|
||||||
if not workspace_path.exists():
|
|
||||||
workspace_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
sync_workspace_templates(workspace_path)
|
|
||||||
|
|
||||||
|
|
||||||
def _patch_config(
|
|
||||||
config_path: Path,
|
|
||||||
*,
|
|
||||||
channel: str,
|
|
||||||
workspace: Path,
|
|
||||||
model: str | None,
|
|
||||||
name: str | None = None,
|
|
||||||
inherit_config_path: Path | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Patch the generated config: enable channel, set workspace, optionally set model."""
|
|
||||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
# Inherit providers and model from current instance
|
|
||||||
if inherit_config_path and inherit_config_path.exists():
|
|
||||||
try:
|
|
||||||
src = json.loads(inherit_config_path.read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
# Inherit providers (API keys, api_base, etc.)
|
|
||||||
src_providers = src.get("providers", {})
|
|
||||||
if src_providers:
|
|
||||||
data.setdefault("providers", {})
|
|
||||||
for key, val in src_providers.items():
|
|
||||||
if isinstance(val, dict) and val.get("apiKey"):
|
|
||||||
data["providers"][key] = val
|
|
||||||
|
|
||||||
# Inherit model if not explicitly overridden
|
|
||||||
if not model:
|
|
||||||
parent_model = src.get("agents", {}).get("defaults", {}).get("model")
|
|
||||||
if parent_model:
|
|
||||||
model = parent_model
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"[WARN] Could not inherit from {inherit_config_path}: {exc}", file=sys.stderr)
|
|
||||||
|
|
||||||
# Set workspace and model
|
|
||||||
data.setdefault("agents", {}).setdefault("defaults", {})
|
|
||||||
data["agents"]["defaults"]["workspace"] = str(workspace)
|
|
||||||
if model:
|
|
||||||
data["agents"]["defaults"]["model"] = model
|
|
||||||
|
|
||||||
# Enable the target channel
|
|
||||||
channels = data.setdefault("channels", {})
|
|
||||||
if channel in channels and isinstance(channels[channel], dict):
|
|
||||||
channels[channel]["enabled"] = True
|
|
||||||
else:
|
|
||||||
channels[channel] = {"enabled": True}
|
|
||||||
|
|
||||||
# Auto-assign ports if defaults are already in use
|
|
||||||
_assign_free_ports(data)
|
|
||||||
|
|
||||||
# Validate with Pydantic, then save
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
|
|
||||||
Config.model_validate(data)
|
|
||||||
config_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def _is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
|
|
||||||
"""Check if a port is already in use."""
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
||||||
try:
|
|
||||||
s.bind((host, port))
|
|
||||||
return False
|
|
||||||
except OSError:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _find_free_port(start: int, host: str = "127.0.0.1", max_tries: int = 100) -> int:
|
|
||||||
"""Find the first free port starting from `start`."""
|
|
||||||
for port in range(start, start + max_tries):
|
|
||||||
if not _is_port_in_use(port, host):
|
|
||||||
return port
|
|
||||||
# OS-level fallback: ask the kernel for an ephemeral port
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
||||||
s.bind((host, 0))
|
|
||||||
return s.getsockname()[1]
|
|
||||||
|
|
||||||
|
|
||||||
def _assign_free_ports(data: dict) -> None:
|
|
||||||
"""If default gateway or API ports are in use, assign free ones."""
|
|
||||||
from nanobot.config.schema import ApiConfig, GatewayConfig
|
|
||||||
|
|
||||||
defaults = [
|
|
||||||
("gateway", GatewayConfig()),
|
|
||||||
("api", ApiConfig()),
|
|
||||||
]
|
|
||||||
for key, default_cfg in defaults:
|
|
||||||
section = data.setdefault(key, {})
|
|
||||||
port = section.get("port", default_cfg.port)
|
|
||||||
host = section.get("host", default_cfg.host)
|
|
||||||
if _is_port_in_use(port, host):
|
|
||||||
section["port"] = _find_free_port(port + 1, host)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_channel_required_fields(channel: str) -> list[str]:
|
|
||||||
"""Inspect a channel's default config and list fields that are empty strings."""
|
|
||||||
try:
|
|
||||||
from nanobot.channels.registry import load_channel_class
|
|
||||||
|
|
||||||
cls = load_channel_class(channel)
|
|
||||||
default = cls.default_config()
|
|
||||||
return sorted(k for k, v in default.items() if isinstance(v, str) and v == "" and k != "enabled")
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"[WARN] Could not inspect channel '{channel}' defaults: {exc}", file=sys.stderr)
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Create a new nanobot instance.",
|
|
||||||
)
|
|
||||||
parser.add_argument("--name", required=True, help="Instance name (e.g. telegram-bot)")
|
|
||||||
parser.add_argument("--channel", required=True, help="Channel type (e.g. telegram, discord)")
|
|
||||||
parser.add_argument("--model", default=None, help="LLM model (default: same as current instance)")
|
|
||||||
parser.add_argument(
|
|
||||||
"--config-dir",
|
|
||||||
default=None,
|
|
||||||
help="Config directory (default: ~/.nanobot-{name})",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--inherit-config",
|
|
||||||
default=None,
|
|
||||||
help="Path to current instance's config.json to copy API keys from",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# Validate name
|
|
||||||
name = _validate_name(args.name)
|
|
||||||
|
|
||||||
# Validate channel
|
|
||||||
available = _get_available_channels()
|
|
||||||
if args.channel not in available:
|
|
||||||
print(f"[ERROR] Unknown channel: {args.channel}", file=sys.stderr)
|
|
||||||
print(f"Available channels: {', '.join(sorted(available))}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Resolve paths
|
|
||||||
home = Path.home()
|
|
||||||
config_dir = Path(args.config_dir).expanduser().resolve() if args.config_dir else home / f".nanobot-{name}"
|
|
||||||
config_path = config_dir / "config.json"
|
|
||||||
workspace = config_dir / "workspace"
|
|
||||||
|
|
||||||
# Check for duplicate
|
|
||||||
if config_path.exists():
|
|
||||||
print(f"[ERROR] Config already exists at {config_path}", file=sys.stderr)
|
|
||||||
print("Delete it first or use a different --config-dir.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"Creating instance '{name}'...")
|
|
||||||
print(f" Config dir: {config_dir}")
|
|
||||||
print(f" Workspace: {workspace}")
|
|
||||||
print(f" Channel: {args.channel}")
|
|
||||||
if args.model:
|
|
||||||
print(f" Model: {args.model}")
|
|
||||||
|
|
||||||
# Run onboard
|
|
||||||
_run_onboard(config_path, workspace)
|
|
||||||
|
|
||||||
# Patch config
|
|
||||||
inherit_path = Path(args.inherit_config).expanduser().resolve() if args.inherit_config else None
|
|
||||||
_patch_config(
|
|
||||||
config_path,
|
|
||||||
channel=args.channel,
|
|
||||||
workspace=workspace,
|
|
||||||
model=args.model,
|
|
||||||
name=name,
|
|
||||||
inherit_config_path=inherit_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Report
|
|
||||||
print(f"\n[OK] Instance '{name}' created successfully.")
|
|
||||||
print(f" Config: {config_path}")
|
|
||||||
print(f" Workspace: {workspace}")
|
|
||||||
|
|
||||||
# List fields the user needs to fill in
|
|
||||||
required_fields = _get_channel_required_fields(args.channel)
|
|
||||||
if required_fields:
|
|
||||||
print(f"\n[IMPORTANT] Edit {config_path} and fill in these fields:")
|
|
||||||
for field in required_fields:
|
|
||||||
print(f" - channels.{args.channel}.{field}")
|
|
||||||
|
|
||||||
print(f"\nTo start the instance:")
|
|
||||||
print(f" nanobot gateway --config {config_path}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Embedded web UI assets.
|
"""Embedded web UI assets.
|
||||||
|
|
||||||
The ``dist/`` subdirectory is populated by ``cd webui && bun run build`` and
|
The ``dist/`` subdirectory holds the production WebUI bundle served by the
|
||||||
is shipped in the wheel; it stays empty in source checkouts until that command
|
gateway. It is shipped inside the published wheel and is rebuilt automatically
|
||||||
has been run.
|
by the ``webui-build`` Hatch hook during ``python -m build``. In an editable
|
||||||
|
source checkout it stays empty until you run ``cd webui && bun run build``
|
||||||
|
(or use the Vite dev server at ``cd webui && bun run dev``).
|
||||||
"""
|
"""
|
||||||
|
|||||||
+13
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nanobot-ai"
|
name = "nanobot-ai"
|
||||||
version = "0.1.5.post3"
|
version = "0.2.0"
|
||||||
description = "A lightweight personal AI assistant framework"
|
description = "A lightweight personal AI assistant framework"
|
||||||
readme = { file = "README.md", content-type = "text/markdown" }
|
readme = { file = "README.md", content-type = "text/markdown" }
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
@@ -121,12 +121,22 @@ build-backend = "hatchling.build"
|
|||||||
[tool.hatch.metadata]
|
[tool.hatch.metadata]
|
||||||
allow-direct-references = true
|
allow-direct-references = true
|
||||||
|
|
||||||
|
[tool.hatch.build.hooks.custom]
|
||||||
|
# Implementation lives in the conventional `hatch_build.py` at the repo root.
|
||||||
|
|
||||||
[tool.hatch.build]
|
[tool.hatch.build]
|
||||||
include = [
|
include = [
|
||||||
"nanobot/**/*.py",
|
"nanobot/**/*.py",
|
||||||
"nanobot/templates/**/*.md",
|
"nanobot/templates/**/*.md",
|
||||||
"nanobot/skills/**/*.md",
|
"nanobot/skills/**/*.md",
|
||||||
"nanobot/skills/**/*.sh",
|
"nanobot/skills/**/*.sh",
|
||||||
|
"nanobot/web/dist/**/*",
|
||||||
|
]
|
||||||
|
# nanobot/web/dist/ is produced by `cd webui && bun run build` and is
|
||||||
|
# git-ignored. List it as an artifact so hatch ships it in both wheel and
|
||||||
|
# sdist even though VCS does not track it.
|
||||||
|
artifacts = [
|
||||||
|
"nanobot/web/dist/**/*",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
@@ -141,7 +151,9 @@ packages = ["nanobot"]
|
|||||||
[tool.hatch.build.targets.sdist]
|
[tool.hatch.build.targets.sdist]
|
||||||
include = [
|
include = [
|
||||||
"nanobot/",
|
"nanobot/",
|
||||||
|
"nanobot/web/dist/",
|
||||||
"bridge/",
|
"bridge/",
|
||||||
|
"hatch_build.py",
|
||||||
"README.md",
|
"README.md",
|
||||||
"LICENSE",
|
"LICENSE",
|
||||||
"THIRD_PARTY_NOTICES.md",
|
"THIRD_PARTY_NOTICES.md",
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
"""Tests for nanobot/skills/create-instance/scripts/create_instance.py."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
SCRIPT = Path(__file__).parent.parent.parent / "nanobot" / "skills" / "create-instance" / "scripts" / "create_instance.py"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def tmp_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
||||||
"""Point HOME at a temp dir so nanobot writes configs there."""
|
|
||||||
monkeypatch.setenv("HOME", str(tmp_path))
|
|
||||||
monkeypatch.delenv("NANOBOT_CONFIG", raising=False)
|
|
||||||
return tmp_path
|
|
||||||
|
|
||||||
|
|
||||||
def _run_script(*args: str, cwd: Path | None = None) -> subprocess.CompletedProcess:
|
|
||||||
"""Run create_instance.py as a subprocess."""
|
|
||||||
return subprocess.run(
|
|
||||||
[sys.executable, str(SCRIPT), *args],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
encoding="utf-8",
|
|
||||||
errors="replace",
|
|
||||||
cwd=cwd,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestValidation:
|
|
||||||
"""Argument validation tests."""
|
|
||||||
|
|
||||||
def test_missing_required_args_exits_with_error(self) -> None:
|
|
||||||
result = _run_script()
|
|
||||||
assert result.returncode != 0
|
|
||||||
|
|
||||||
def test_invalid_channel_exits_with_error(self, tmp_home: Path) -> None:
|
|
||||||
result = _run_script("--name", "test", "--channel", "nonexistent_channel")
|
|
||||||
assert result.returncode != 0
|
|
||||||
assert "nonexistent_channel" in result.stderr or "nonexistent_channel" in result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreateInstance:
|
|
||||||
"""End-to-end instance creation tests."""
|
|
||||||
|
|
||||||
def test_creates_config_and_workspace(self, tmp_home: Path) -> None:
|
|
||||||
config_dir = tmp_home / ".nanobot-test"
|
|
||||||
result = _run_script(
|
|
||||||
"--name", "test-bot",
|
|
||||||
"--channel", "telegram",
|
|
||||||
"--config-dir", str(config_dir),
|
|
||||||
)
|
|
||||||
assert result.returncode == 0, result.stderr
|
|
||||||
|
|
||||||
config_path = config_dir / "config.json"
|
|
||||||
assert config_path.exists(), f"Config not created at {config_path}"
|
|
||||||
|
|
||||||
workspace = config_dir / "workspace"
|
|
||||||
assert workspace.exists(), f"Workspace not created at {workspace}"
|
|
||||||
|
|
||||||
def test_config_has_channel_enabled(self, tmp_home: Path) -> None:
|
|
||||||
config_dir = tmp_home / ".nanobot-test"
|
|
||||||
result = _run_script(
|
|
||||||
"--name", "test-bot",
|
|
||||||
"--channel", "telegram",
|
|
||||||
"--config-dir", str(config_dir),
|
|
||||||
)
|
|
||||||
assert result.returncode == 0, result.stderr
|
|
||||||
|
|
||||||
data = json.loads((config_dir / "config.json").read_text(encoding="utf-8"))
|
|
||||||
assert data["channels"]["telegram"]["enabled"] is True
|
|
||||||
|
|
||||||
def test_config_workspace_path_set(self, tmp_home: Path) -> None:
|
|
||||||
config_dir = tmp_home / ".nanobot-test"
|
|
||||||
result = _run_script(
|
|
||||||
"--name", "test-bot",
|
|
||||||
"--channel", "telegram",
|
|
||||||
"--config-dir", str(config_dir),
|
|
||||||
)
|
|
||||||
assert result.returncode == 0, result.stderr
|
|
||||||
|
|
||||||
data = json.loads((config_dir / "config.json").read_text(encoding="utf-8"))
|
|
||||||
ws = data["agents"]["defaults"]["workspace"]
|
|
||||||
assert str(config_dir / "workspace") in ws or "workspace" in ws
|
|
||||||
|
|
||||||
def test_model_override(self, tmp_home: Path) -> None:
|
|
||||||
config_dir = tmp_home / ".nanobot-test"
|
|
||||||
result = _run_script(
|
|
||||||
"--name", "test-bot",
|
|
||||||
"--channel", "telegram",
|
|
||||||
"--model", "deepseek/deepseek-chat",
|
|
||||||
"--config-dir", str(config_dir),
|
|
||||||
)
|
|
||||||
assert result.returncode == 0, result.stderr
|
|
||||||
|
|
||||||
data = json.loads((config_dir / "config.json").read_text(encoding="utf-8"))
|
|
||||||
assert data["agents"]["defaults"]["model"] == "deepseek/deepseek-chat"
|
|
||||||
|
|
||||||
def test_rejects_duplicate_instance(self, tmp_home: Path) -> None:
|
|
||||||
config_dir = tmp_home / ".nanobot-test"
|
|
||||||
result1 = _run_script(
|
|
||||||
"--name", "test-bot",
|
|
||||||
"--channel", "telegram",
|
|
||||||
"--config-dir", str(config_dir),
|
|
||||||
)
|
|
||||||
assert result1.returncode == 0
|
|
||||||
|
|
||||||
result2 = _run_script(
|
|
||||||
"--name", "test-bot",
|
|
||||||
"--channel", "telegram",
|
|
||||||
"--config-dir", str(config_dir),
|
|
||||||
)
|
|
||||||
assert result2.returncode != 0
|
|
||||||
|
|
||||||
def test_port_reassigned_when_default_in_use(self, tmp_home: Path) -> None:
|
|
||||||
"""When default gateway port is occupied, script should pick a different one."""
|
|
||||||
config_dir = tmp_home / ".nanobot-test"
|
|
||||||
|
|
||||||
# Bind to the default gateway port to simulate a running instance
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as blocker:
|
|
||||||
blocker.bind(("127.0.0.1", 18790))
|
|
||||||
blocker.listen(1)
|
|
||||||
|
|
||||||
result = _run_script(
|
|
||||||
"--name", "test-bot",
|
|
||||||
"--channel", "telegram",
|
|
||||||
"--config-dir", str(config_dir),
|
|
||||||
)
|
|
||||||
assert result.returncode == 0, result.stderr
|
|
||||||
|
|
||||||
data = json.loads((config_dir / "config.json").read_text(encoding="utf-8"))
|
|
||||||
assert data["gateway"]["port"] != 18790
|
|
||||||
|
|
||||||
def test_inherits_api_key_from_current_instance(self, tmp_home: Path) -> None:
|
|
||||||
"""API keys from --inherit-config should be copied to new instance."""
|
|
||||||
# Create a fake "current instance" config with an API key
|
|
||||||
src_dir = tmp_home / ".nanobot-current"
|
|
||||||
src_dir.mkdir()
|
|
||||||
src_config = src_dir / "config.json"
|
|
||||||
src_config.write_text(json.dumps({
|
|
||||||
"providers": {
|
|
||||||
"anthropic": {"apiKey": "sk-test-key-12345"},
|
|
||||||
"deepseek": {"apiKey": "dsk-another-key"},
|
|
||||||
"openai": {}, # no key, should not be copied
|
|
||||||
},
|
|
||||||
}), encoding="utf-8")
|
|
||||||
|
|
||||||
config_dir = tmp_home / ".nanobot-new"
|
|
||||||
result = _run_script(
|
|
||||||
"--name", "new-bot",
|
|
||||||
"--channel", "telegram",
|
|
||||||
"--config-dir", str(config_dir),
|
|
||||||
"--inherit-config", str(src_config),
|
|
||||||
)
|
|
||||||
assert result.returncode == 0, result.stderr
|
|
||||||
|
|
||||||
data = json.loads((config_dir / "config.json").read_text(encoding="utf-8"))
|
|
||||||
providers = data.get("providers", {})
|
|
||||||
assert providers.get("anthropic", {}).get("apiKey") == "sk-test-key-12345"
|
|
||||||
assert providers.get("deepseek", {}).get("apiKey") == "dsk-another-key"
|
|
||||||
# openai had no key, so it should not be in the new config's providers
|
|
||||||
assert providers.get("openai", {}).get("apiKey") is None
|
|
||||||
+16
-19
@@ -8,15 +8,11 @@ on the same port.
|
|||||||
For the project overview, install guide, and general docs map, see the root
|
For the project overview, install guide, and general docs map, see the root
|
||||||
[`README.md`](../README.md).
|
[`README.md`](../README.md).
|
||||||
|
|
||||||
## Current status
|
## Just want to use the WebUI?
|
||||||
|
|
||||||
> [!NOTE]
|
If you installed nanobot via `pip install nanobot-ai`, the WebUI is **already bundled** in the wheel. Enable the WebSocket channel in `~/.nanobot/config.json` and run `nanobot gateway` — see the root [`README.md`](../README.md#-webui) for the 3-step setup. You do **not** need anything in this directory.
|
||||||
> The standalone WebUI development workflow currently requires a source
|
|
||||||
> checkout.
|
This `webui/` tree is for people **hacking on the WebUI itself** (UI changes, new components, styling, etc.).
|
||||||
>
|
|
||||||
> WebUI changes in the GitHub repository may land before they are included in
|
|
||||||
> the next packaged release, so source installs and published package versions
|
|
||||||
> are not yet guaranteed to move in lockstep.
|
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -25,7 +21,7 @@ webui/ source tree (this directory)
|
|||||||
nanobot/web/dist/ build output served by the gateway
|
nanobot/web/dist/ build output served by the gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
## Develop from source
|
## Develop the WebUI (Vite HMR)
|
||||||
|
|
||||||
### 1. Install nanobot from source
|
### 1. Install nanobot from source
|
||||||
|
|
||||||
@@ -35,6 +31,8 @@ From the repository root:
|
|||||||
pip install -e .
|
pip install -e .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> Editable installs intentionally **skip** the WebUI bundle step — Vite HMR is faster than rebuilding `dist/` on every change.
|
||||||
|
|
||||||
### 2. Enable the WebSocket channel
|
### 2. Enable the WebSocket channel
|
||||||
|
|
||||||
In `~/.nanobot/config.json`:
|
In `~/.nanobot/config.json`:
|
||||||
@@ -63,8 +61,7 @@ bun run dev
|
|||||||
|
|
||||||
Then open `http://127.0.0.1:5173`.
|
Then open `http://127.0.0.1:5173`.
|
||||||
|
|
||||||
By default, the dev server proxies `/api`, `/webui`, `/auth`, and WebSocket
|
By default the dev server proxies `/api`, `/webui`, `/auth`, and WebSocket traffic to `http://127.0.0.1:8765`.
|
||||||
traffic to `http://127.0.0.1:8765`.
|
|
||||||
|
|
||||||
If your gateway listens on a non-default port, point the dev server at it:
|
If your gateway listens on a non-default port, point the dev server at it:
|
||||||
|
|
||||||
@@ -74,7 +71,7 @@ NANOBOT_API_URL=http://127.0.0.1:9000 bun run dev
|
|||||||
|
|
||||||
### Access from another device (LAN)
|
### 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`:
|
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
|
```json
|
||||||
{
|
{
|
||||||
@@ -91,20 +88,20 @@ To use the webui from another device on the same network, set `host` to `"0.0.0.
|
|||||||
|
|
||||||
The gateway will refuse to start if `host` is `"0.0.0.0"` and neither `token` nor `tokenIssueSecret` is set.
|
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.
|
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
|
## Build for packaged runtime
|
||||||
|
|
||||||
|
You usually do not need to run this by hand: `python -m build` invokes the WebUI build automatically when packaging the wheel.
|
||||||
|
|
||||||
|
If you want to preview the production bundle locally without rebuilding the wheel:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd webui
|
cd webui
|
||||||
bun run build
|
bun run build # writes to ../nanobot/web/dist
|
||||||
```
|
```
|
||||||
|
|
||||||
This writes the production assets to `../nanobot/web/dist`, which is the
|
The gateway picks up the new bundle on the next restart.
|
||||||
directory served by `nanobot gateway` and bundled into the Python wheel.
|
|
||||||
|
|
||||||
If you are cutting a release, run the build before packaging so the published
|
|
||||||
wheel contains the current WebUI assets.
|
|
||||||
|
|
||||||
## Test
|
## Test
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,11 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.1.6",
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"i18next": "^26.0.6",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-i18next": "^17.0.4",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-syntax-highlighter": "^15.6.1",
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
@@ -506,8 +508,12 @@
|
|||||||
|
|
||||||
"highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
|
"highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
|
||||||
|
|
||||||
|
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
|
||||||
|
|
||||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||||
|
|
||||||
|
"i18next": ["i18next@26.2.0", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA=="],
|
||||||
|
|
||||||
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
|
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
|
||||||
|
|
||||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||||
@@ -718,6 +724,8 @@
|
|||||||
|
|
||||||
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
|
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
|
||||||
|
|
||||||
|
"react-i18next": ["react-i18next@17.0.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw=="],
|
||||||
|
|
||||||
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||||
|
|
||||||
"react-markdown": ["react-markdown@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw=="],
|
"react-markdown": ["react-markdown@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw=="],
|
||||||
@@ -860,6 +868,8 @@
|
|||||||
|
|
||||||
"vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="],
|
"vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="],
|
||||||
|
|
||||||
|
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
|
||||||
|
|
||||||
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
|
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
|
||||||
|
|
||||||
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
|
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": ["node"]
|
"types": ["node", "vite/client"]
|
||||||
},
|
},
|
||||||
"exclude": ["src/tests/**"]
|
"exclude": ["src/tests/**"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user