refactor(trigger): name CLI trigger source as local

maintainer edit: cron is also a trigger source, so keep the new CLI-delivered source explicitly named as local trigger across backend, WebUI, docs, and tests.
This commit is contained in:
chengyongru
2026-07-02 13:32:46 +08:00
committed by Xubin Ren
parent 1ed2c9a213
commit 2ebf5c4972
40 changed files with 353 additions and 253 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ If a local `nanobot agent` session can already answer normally, you can also ask
|---|---|---|
| Open the bundled browser UI | [`webui.md`](./webui.md) | WebUI on port `8765`, chat workspace, Apps, Skills, Automations, and settings |
| Connect Telegram, Discord, WeChat, Slack, and other apps | [`chat-apps.md`](./chat-apps.md) | A gateway-backed chat channel with access control |
| Use slash commands and automations | [`chat-commands.md`](./chat-commands.md) | Pairing, model presets, external triggers, heartbeat tasks, and chat-side controls |
| Use slash commands and automations | [`chat-commands.md`](./chat-commands.md) | Pairing, model presets, local triggers, heartbeat tasks, and chat-side controls |
| Generate images | [`image-generation.md`](./image-generation.md) | Image provider config, WebUI image mode, and artifact behavior |
| Run several isolated bots | [`multiple-instances.md`](./multiple-instances.md) | Separate configs, workspaces, ports, and sessions |
| Deploy outside a terminal | [`deployment.md`](./deployment.md) | Docker, systemd user services, and macOS LaunchAgent setup |
+3 -3
View File
@@ -16,8 +16,8 @@ These commands work inside chat channels and interactive agent sessions:
| `/dream-restore` | List recent Dream memory versions |
| `/dream-restore <sha>` | Restore memory to the state before a specific change |
| `/skill` | List enabled skills and their descriptions |
| `/trigger` | Show external trigger usage |
| `/trigger <name>` | Create a named local external trigger for the current chat/session |
| `/trigger` | Show local trigger usage |
| `/trigger <name>` | Create a named local trigger for the current chat/session |
| `/pairing` | List pending pairing requests |
| `/pairing approve <code>` | Approve a pairing code |
| `/pairing deny <code>` | Deny a pending pairing request |
@@ -57,7 +57,7 @@ To switch presets for future turns:
Preset names come from the top-level `modelPresets` config. Switching is runtime-only: it does not rewrite `config.json`, and an in-progress turn keeps using the model it started with. See [Configuration: Model presets](./configuration.md#model-presets) for setup details.
## External Triggers
## Local triggers
Use `/trigger <name>` when a local script or another service should be able to
send a message into the current chat/session later. A name is required; plain
+1 -1
View File
@@ -13,7 +13,7 @@ Use this page when you know what you want to run and need the command shape. For
| Send one test message | `nanobot agent -m "Hello!"` | First proof that install, config, provider, model, and workspace all work |
| Chat in the terminal | `nanobot agent` | Interactive local chat; exit with `exit`, `/exit`, `:q`, or `Ctrl+D` |
| Use WebUI or chat apps | `nanobot gateway` | Keep this terminal running, or use `nanobot gateway --background` |
| Deliver a local external trigger | `nanobot trigger <id> "message"` | Created first with `/trigger <name>` in the target chat/session |
| Deliver a local trigger | `nanobot trigger <id> "message"` | Created first with `/trigger <name>` in the target chat/session |
| Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` |
| Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` |
| Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat |
+2 -2
View File
@@ -123,7 +123,7 @@ Tools are discovered automatically from built-in modules and plugin entry points
- shell execution with configurable sandboxing;
- web search and web fetch with SSRF checks;
- MCP servers;
- cron reminders, external triggers, and heartbeat tasks;
- cron reminders, local triggers, and heartbeat tasks;
- image generation;
- subagents and runtime self-inspection.
@@ -143,7 +143,7 @@ User-created reminders use the same cron service but are not the same as the
protected heartbeat system job. They run as scheduled turns in their origin
chat/session and normally deliver the result back to that channel.
External triggers are also session-bound, but they do not have their own
Local triggers are also session-bound, but they do not have their own
schedule. Create one from the target chat with `/trigger <name>`, then call
`nanobot trigger <id> "<message>"` when a local script or external service wants
nanobot to respond in that session. Webhook servers, third-party auth, and
+4 -4
View File
@@ -56,7 +56,7 @@ Enter `tokenIssueSecret` when the WebUI asks for a password.
| Composer | Send text, images, voice input, slash commands, and `@` mentions for Apps or MCP presets |
| Apps | Install, test, update, and use local CLI App adapters and MCP presets |
| Skills | Inspect available built-in and workspace skills before relying on them |
| Automations | Review, search, run, pause, edit, and delete scheduled and external-trigger agent turns |
| Automations | Review, search, run, pause, edit, and delete scheduled and local-trigger agent turns |
| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options |
## Chat Workspace
@@ -125,7 +125,7 @@ There are two user-facing automation types:
- Scheduled automations, created by the agent's cron tool, run at a time,
interval, or cron expression.
- External triggers, created with `/trigger <name>`, run when you call a local
- Local triggers, created with `/trigger <name>`, run when you call a local
command such as `nanobot trigger trg_8K4P2Q9X "Review PR #4502"`.
If a GitHub webhook, CI system, or another service should wake nanobot up, keep
@@ -148,7 +148,7 @@ Use the Automations view to:
- Sort by next run, last run, updated time, or name.
- Run scheduled automations now.
- Pause or resume, rename, or delete user-created automations.
- Copy the CLI command for external triggers.
- Copy the CLI command for local triggers.
- Inspect protected system automations without changing them.
Search accepts plain text and field filters such as `name:backup`,
@@ -159,7 +159,7 @@ An automation without a linked chat cannot be enabled or run from the WebUI,
because nanobot would not know where to deliver the scheduled turn. Recreate it
from the target chat or channel so the automation has complete context.
External triggers do not have a WebUI "Run now" action because each run needs a
Local triggers do not have a WebUI "Run now" action because each run needs a
message. Use the copied `nanobot trigger ...` command and replace `"message"`
with the content that should be delivered.
+3 -3
View File
@@ -68,7 +68,7 @@ class ChannelManager:
*,
session_manager: "SessionManager | None" = None,
cron_service: Any | None = None,
external_trigger_store: Any | None = None,
local_trigger_store: Any | None = None,
webui_runtime_model_name: Callable[[], str | None] | None = None,
webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None,
webui_static_dist: bool = True,
@@ -79,7 +79,7 @@ class ChannelManager:
self.bus = bus
self._session_manager = session_manager
self._cron_service = cron_service
self._external_trigger_store = external_trigger_store
self._local_trigger_store = local_trigger_store
self._webui_runtime_model_name = webui_runtime_model_name
self._webui_cron_pending_job_ids = webui_cron_pending_job_ids
self._webui_static_dist = webui_static_dist
@@ -141,7 +141,7 @@ class ChannelManager:
runtime_surface=self._webui_runtime_surface,
runtime_capabilities_overrides=self._webui_runtime_capabilities,
cron_service=self._cron_service,
external_trigger_store=self._external_trigger_store,
local_trigger_store=self._local_trigger_store,
cron_pending_job_ids=self._webui_cron_pending_job_ids,
logger=logger,
)
+10 -10
View File
@@ -772,8 +772,8 @@ def trigger(
config: str | None = typer.Option(None, "--config", "-c", help="Config file path"),
):
"""Deliver a local trigger message to its bound chat session."""
from nanobot.triggers.store import (
ExternalTriggerStore,
from nanobot.triggers.local_store import (
LocalTriggerStore,
TriggerDisabledError,
TriggerNotFoundError,
TriggerStoreError,
@@ -781,7 +781,7 @@ def trigger(
runtime_config = _load_runtime_config(config, workspace)
content = _read_trigger_cli_message(message)
store = ExternalTriggerStore(runtime_config.workspace_path)
store = LocalTriggerStore(runtime_config.workspace_path)
try:
delivery = store.enqueue(trigger_id, content)
except (TriggerNotFoundError, TriggerDisabledError) as exc:
@@ -909,8 +909,8 @@ def _run_gateway(
from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.session.manager import SessionManager
from nanobot.session.webui_turns import WebuiTurnCoordinator
from nanobot.triggers.runner import run_external_trigger_queue
from nanobot.triggers.store import ExternalTriggerStore
from nanobot.triggers.local_runner import run_local_trigger_queue
from nanobot.triggers.local_store import LocalTriggerStore
from nanobot.webui.token_usage import TokenUsageHook
port = port if port is not None else config.gateway.port
@@ -933,7 +933,7 @@ def _run_gateway(
# Create cron service with workspace-scoped store
cron_store_path = config.workspace_path / "cron" / "jobs.json"
cron = CronService(cron_store_path)
trigger_store = ExternalTriggerStore(config.workspace_path)
trigger_store = LocalTriggerStore(config.workspace_path)
# Create agent with cron service
agent = AgentLoop.from_config(
@@ -954,7 +954,7 @@ def _run_gateway(
sessions=session_manager,
schedule_background=lambda coro: agent._schedule_background(coro),
).subscribe(runtime_events)
agent.external_trigger_store = trigger_store
agent.local_trigger_store = trigger_store
from nanobot.bus.events import OutboundMessage
from nanobot.session.keys import session_key_for_channel
@@ -1151,7 +1151,7 @@ def _run_gateway(
bus,
session_manager=session_manager,
cron_service=cron,
external_trigger_store=trigger_store,
local_trigger_store=trigger_store,
webui_runtime_model_name=_webui_runtime_model_name,
webui_cron_pending_job_ids=getattr(agent, "pending_cron_job_ids_for_session", None),
webui_static_dist=webui_static_dist,
@@ -1295,8 +1295,8 @@ def _run_gateway(
asyncio.create_task(agent.run(), name="nanobot-agent-loop"),
asyncio.create_task(channels.start_all(), name="nanobot-channels"),
asyncio.create_task(
run_external_trigger_queue(store=trigger_store, bus=bus),
name="nanobot-external-triggers",
run_local_trigger_queue(store=trigger_store, bus=bus),
name="nanobot-local-triggers",
),
]
if health_server_enabled:
+3 -3
View File
@@ -740,7 +740,7 @@ async def cmd_trigger(ctx: CommandContext) -> OutboundMessage:
metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"},
)
from nanobot.triggers.store import ExternalTriggerStore
from nanobot.triggers.local_store import LocalTriggerStore
loop = ctx.loop
workspace = getattr(loop, "workspace", None)
@@ -749,9 +749,9 @@ async def cmd_trigger(ctx: CommandContext) -> OutboundMessage:
if workspace is None:
raise RuntimeError("workspace unavailable for trigger creation")
store = getattr(loop, "external_trigger_store", None)
store = getattr(loop, "local_trigger_store", None)
if store is None:
store = ExternalTriggerStore(workspace)
store = LocalTriggerStore(workspace)
trigger = store.create(
name=name,
+5 -3
View File
@@ -56,9 +56,9 @@ def automation_history_overrides_for_spec(
def _automation_specs() -> tuple[AutomationTurnSpec, ...]:
# Source modules import the generic helpers above, so keep spec loading lazy.
from nanobot.cron.session_turns import CRON_AUTOMATION_SPEC
from nanobot.triggers.session_turns import EXTERNAL_TRIGGER_AUTOMATION_SPEC
from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_AUTOMATION_SPEC
return (CRON_AUTOMATION_SPEC, EXTERNAL_TRIGGER_AUTOMATION_SPEC)
return (CRON_AUTOMATION_SPEC, LOCAL_TRIGGER_AUTOMATION_SPEC)
def automation_history_overrides(
@@ -87,4 +87,6 @@ def is_automation_history_message(message: Mapping[str, Any] | None) -> bool:
def is_automation_kind(value: Any) -> bool:
return isinstance(value, str) and any(spec.kind == value for spec in _automation_specs())
return isinstance(value, str) and (
value == "trigger" or any(spec.kind == value for spec in _automation_specs())
)
+6 -6
View File
@@ -1,16 +1,16 @@
"""Local external trigger support."""
"""Local trigger support."""
from nanobot.triggers.store import (
ExternalTriggerStore,
from nanobot.triggers.local_store import (
LocalTriggerStore,
TriggerDisabledError,
TriggerNotFoundError,
TriggerStoreError,
)
from nanobot.triggers.types import ExternalTrigger, TriggerDelivery, TriggerRunRecord
from nanobot.triggers.local_types import LocalTrigger, TriggerDelivery, TriggerRunRecord
__all__ = [
"ExternalTrigger",
"ExternalTriggerStore",
"LocalTrigger",
"LocalTriggerStore",
"TriggerDelivery",
"TriggerDisabledError",
"TriggerNotFoundError",
@@ -1,4 +1,4 @@
"""Gateway delivery loop for local external triggers."""
"""Gateway delivery loop for local triggers."""
from __future__ import annotations
@@ -10,21 +10,21 @@ from loguru import logger
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.triggers.session_turns import EXTERNAL_TRIGGER_META
from nanobot.triggers.store import ExternalTriggerStore
from nanobot.triggers.types import ExternalTrigger, TriggerDelivery
from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_META
from nanobot.triggers.local_store import LocalTriggerStore
from nanobot.triggers.local_types import LocalTrigger, TriggerDelivery
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
async def run_external_trigger_queue(
async def run_local_trigger_queue(
*,
store: ExternalTriggerStore,
store: LocalTriggerStore,
bus: MessageBus,
poll_interval_s: float = 0.5,
batch_size: int = 20,
) -> None:
"""Poll local trigger deliveries and publish them as normal inbound messages."""
logger.info("External trigger queue started")
logger.info("Local trigger queue started")
recovered = store.recover_processing_deliveries()
if recovered:
logger.warning(
@@ -80,7 +80,7 @@ class _TerminalDeliveryError(RuntimeError):
async def _publish_delivery(
store: ExternalTriggerStore,
store: LocalTriggerStore,
bus: MessageBus,
delivery: TriggerDelivery,
) -> None:
@@ -107,9 +107,9 @@ async def _publish_delivery(
)
def _delivery_metadata(trigger: ExternalTrigger, delivery: TriggerDelivery) -> dict[str, Any]:
def _delivery_metadata(trigger: LocalTrigger, delivery: TriggerDelivery) -> dict[str, Any]:
metadata = dict(trigger.origin_metadata or {})
metadata[EXTERNAL_TRIGGER_META] = {
metadata[LOCAL_TRIGGER_META] = {
"trigger_id": trigger.id,
"trigger_name": trigger.name,
"delivery_id": delivery.id,
@@ -118,7 +118,7 @@ def _delivery_metadata(trigger: ExternalTrigger, delivery: TriggerDelivery) -> d
if trigger.channel == "websocket":
metadata.pop(WEBUI_TURN_METADATA_KEY, None)
metadata[WEBUI_TURN_METADATA_KEY] = f"trigger:{trigger.id}:{uuid.uuid4().hex}"
source: dict[str, str] = {"kind": "trigger"}
source: dict[str, str] = {"kind": "local_trigger"}
if trigger.name:
source["label"] = trigger.name
metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] = source
+51
View File
@@ -0,0 +1,51 @@
"""Shared metadata helpers for local trigger session turns."""
from __future__ import annotations
from typing import Any, Mapping
from nanobot.session.automation_turns import (
AutomationTurnSpec,
automation_history_overrides_for_spec,
automation_trigger,
)
LOCAL_TRIGGER_META = "_local_trigger"
def _local_trigger_history_text(trigger: Mapping[str, Any]) -> str:
name = trigger.get("trigger_name")
trigger_id = trigger.get("trigger_id")
label = name if isinstance(name, str) and name.strip() else trigger_id
return (
f"Local trigger received: {label}"
if isinstance(label, str) and label.strip()
else "Local trigger received"
)
LOCAL_TRIGGER_AUTOMATION_SPEC = AutomationTurnSpec(
kind="local_trigger",
trigger_meta_key=LOCAL_TRIGGER_META,
history_fields={
"trigger_id": "trigger_id",
"trigger_name": "trigger_name",
"trigger_delivery_id": "delivery_id",
},
text_builder=_local_trigger_history_text,
)
def local_trigger(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None:
"""Return structured local trigger metadata when present."""
return automation_trigger(metadata, LOCAL_TRIGGER_AUTOMATION_SPEC)
def local_trigger_history_overrides(
metadata: Mapping[str, Any] | None,
) -> tuple[str | None, dict[str, Any]]:
"""Return session-history text/metadata overrides for a local trigger turn."""
return automation_history_overrides_for_spec(
metadata,
LOCAL_TRIGGER_AUTOMATION_SPEC,
)
@@ -14,7 +14,7 @@ from typing import Any
from filelock import FileLock
from loguru import logger
from nanobot.triggers.types import ExternalTrigger, TriggerDelivery, TriggerRunRecord
from nanobot.triggers.local_types import LocalTrigger, TriggerDelivery, TriggerRunRecord
_TRIGGER_ID_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
_MAX_RUN_HISTORY = 20
@@ -34,7 +34,7 @@ class TriggerDisabledError(TriggerStoreError):
"""Raised when a trigger is disabled."""
class ExternalTriggerStore:
class LocalTriggerStore:
"""Persistent local triggers for one workspace."""
def __init__(self, workspace_path: Path):
@@ -55,8 +55,8 @@ class ExternalTriggerStore:
session_key: str,
sender_id: str = "trigger",
origin_metadata: dict[str, Any] | None = None,
) -> ExternalTrigger:
"""Create a new session-bound external trigger."""
) -> LocalTrigger:
"""Create a new session-bound local trigger."""
clean_name = _clean_name(name)
channel = channel.strip()
chat_id = chat_id.strip()
@@ -70,7 +70,7 @@ class ExternalTriggerStore:
triggers = self._load_triggers_unlocked()
existing_ids = {trigger.id for trigger in triggers}
trigger_id = _new_trigger_id(existing_ids)
trigger = ExternalTrigger(
trigger = LocalTrigger(
id=trigger_id,
name=clean_name,
enabled=True,
@@ -86,7 +86,7 @@ class ExternalTriggerStore:
self._save_triggers_unlocked(triggers)
return trigger
def list_triggers(self, *, include_disabled: bool = False) -> list[ExternalTrigger]:
def list_triggers(self, *, include_disabled: bool = False) -> list[LocalTrigger]:
"""List triggers in this workspace."""
self._ensure_dirs()
with self._lock:
@@ -100,7 +100,7 @@ class ExternalTriggerStore:
session_key: str,
*,
include_disabled: bool = True,
) -> list[ExternalTrigger]:
) -> list[LocalTrigger]:
"""List triggers bound to one session key."""
return [
trigger
@@ -108,13 +108,13 @@ class ExternalTriggerStore:
if trigger.session_key == session_key
]
def get(self, trigger_id: str) -> ExternalTrigger | None:
def get(self, trigger_id: str) -> LocalTrigger | None:
"""Return one trigger by ID."""
self._ensure_dirs()
with self._lock:
return self._find_unlocked(self._load_triggers_unlocked(), trigger_id)
def enable(self, trigger_id: str, *, enabled: bool) -> ExternalTrigger | None:
def enable(self, trigger_id: str, *, enabled: bool) -> LocalTrigger | None:
"""Enable or disable a trigger."""
self._ensure_dirs()
with self._lock:
@@ -127,7 +127,7 @@ class ExternalTriggerStore:
self._save_triggers_unlocked(triggers)
return trigger
def update(self, trigger_id: str, *, name: str | None = None) -> ExternalTrigger | None:
def update(self, trigger_id: str, *, name: str | None = None) -> LocalTrigger | None:
"""Update mutable trigger fields."""
self._ensure_dirs()
with self._lock:
@@ -267,13 +267,13 @@ class ExternalTriggerStore:
self.processing_dir.mkdir(parents=True, exist_ok=True)
self.failed_dir.mkdir(parents=True, exist_ok=True)
def _load_triggers_unlocked(self) -> list[ExternalTrigger]:
def _load_triggers_unlocked(self) -> list[LocalTrigger]:
if not self.store_path.exists():
return []
try:
data = json.loads(self.store_path.read_text(encoding="utf-8"))
return [
ExternalTrigger.from_dict(raw)
LocalTrigger.from_dict(raw)
for raw in data.get("triggers", [])
if isinstance(raw, dict)
]
@@ -288,7 +288,7 @@ class ExternalTriggerStore:
"as a .corrupt-<ts> backup"
) from exc
def _save_triggers_unlocked(self, triggers: list[ExternalTrigger]) -> None:
def _save_triggers_unlocked(self, triggers: list[LocalTrigger]) -> None:
payload = {
"version": 1,
"triggers": [trigger.to_dict() for trigger in triggers],
@@ -297,9 +297,9 @@ class ExternalTriggerStore:
@staticmethod
def _find_unlocked(
triggers: list[ExternalTrigger],
triggers: list[LocalTrigger],
trigger_id: str,
) -> ExternalTrigger | None:
) -> LocalTrigger | None:
return next((trigger for trigger in triggers if trigger.id == trigger_id), None)
def _move_bad_delivery_unlocked(self, path: Path) -> None:
@@ -356,7 +356,7 @@ def _new_trigger_id(existing_ids: set[str]) -> str:
def _clean_name(name: str) -> str:
stripped = " ".join(name.strip().split())
return (stripped or "External trigger")[:120]
return (stripped or "Local trigger")[:120]
def _now_ms() -> int:
@@ -1,4 +1,4 @@
"""Persistent types for local external triggers."""
"""Persistent types for local triggers."""
from __future__ import annotations
@@ -40,7 +40,7 @@ class TriggerRunRecord:
@dataclass
class ExternalTrigger:
class LocalTrigger:
"""A session-bound local trigger."""
id: str
@@ -59,7 +59,7 @@ class ExternalTrigger:
run_history: list[TriggerRunRecord] = field(default_factory=list)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ExternalTrigger":
def from_dict(cls, data: dict[str, Any]) -> "LocalTrigger":
history = [
record if isinstance(record, TriggerRunRecord) else TriggerRunRecord.from_dict(record)
for record in data.get("runHistory", data.get("run_history", []))
-51
View File
@@ -1,51 +0,0 @@
"""Shared metadata helpers for local external trigger session turns."""
from __future__ import annotations
from typing import Any, Mapping
from nanobot.session.automation_turns import (
AutomationTurnSpec,
automation_history_overrides_for_spec,
automation_trigger,
)
EXTERNAL_TRIGGER_META = "_external_trigger"
def _external_trigger_history_text(trigger: Mapping[str, Any]) -> str:
name = trigger.get("trigger_name")
trigger_id = trigger.get("trigger_id")
label = name if isinstance(name, str) and name.strip() else trigger_id
return (
f"External trigger received: {label}"
if isinstance(label, str) and label.strip()
else "External trigger received"
)
EXTERNAL_TRIGGER_AUTOMATION_SPEC = AutomationTurnSpec(
kind="trigger",
trigger_meta_key=EXTERNAL_TRIGGER_META,
history_fields={
"trigger_id": "trigger_id",
"trigger_name": "trigger_name",
"trigger_delivery_id": "delivery_id",
},
text_builder=_external_trigger_history_text,
)
def external_trigger(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None:
"""Return structured external trigger metadata when present."""
return automation_trigger(metadata, EXTERNAL_TRIGGER_AUTOMATION_SPEC)
def external_trigger_history_overrides(
metadata: Mapping[str, Any] | None,
) -> tuple[str | None, dict[str, Any]]:
"""Return session-history text/metadata overrides for an external trigger turn."""
return automation_history_overrides_for_spec(
metadata,
EXTERNAL_TRIGGER_AUTOMATION_SPEC,
)
+4 -4
View File
@@ -26,7 +26,7 @@ class GatewayServices:
workspaces: WebUIWorkspaceController
session_manager: Any | None
cron_service: Any | None
external_trigger_store: Any | None
local_trigger_store: Any | None
cron_pending_job_ids: Callable[[str], set[str]] | None
@@ -43,7 +43,7 @@ def build_gateway_services(
runtime_capabilities_overrides: dict[str, Any] | None,
disabled_skills: set[str] | None = None,
cron_service: Any | None = None,
external_trigger_store: Any | None = None,
local_trigger_store: Any | None = None,
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
logger: Any = default_logger,
) -> GatewayServices:
@@ -72,7 +72,7 @@ def build_gateway_services(
skills_workspace_path=workspace_path,
disabled_skills=disabled_skills,
cron_service=cron_service,
external_trigger_store=external_trigger_store,
local_trigger_store=local_trigger_store,
cron_pending_job_ids=cron_pending_job_ids,
log=logger,
)
@@ -84,6 +84,6 @@ def build_gateway_services(
workspaces=workspaces,
session_manager=session_manager,
cron_service=cron_service,
external_trigger_store=external_trigger_store,
local_trigger_store=local_trigger_store,
cron_pending_job_ids=cron_pending_job_ids,
)
+19 -19
View File
@@ -8,9 +8,9 @@ from typing import Any, Protocol
from nanobot.cron.types import CronJob
from nanobot.session.automation_turns import is_automation_history_message
from nanobot.session.manager import _message_preview_text
from nanobot.triggers.types import ExternalTrigger
from nanobot.triggers.local_types import LocalTrigger
AutomationJob = CronJob | ExternalTrigger
AutomationJob = CronJob | LocalTrigger
class _CronServiceLike(Protocol):
@@ -24,15 +24,15 @@ class _CronServiceLike(Protocol):
) -> list[CronJob]: ...
class _ExternalTriggerStoreLike(Protocol):
def list_triggers(self, *, include_disabled: bool = False) -> list[ExternalTrigger]: ...
class _LocalTriggerStoreLike(Protocol):
def list_triggers(self, *, include_disabled: bool = False) -> list[LocalTrigger]: ...
def list_for_session(
self,
session_key: str,
*,
include_disabled: bool = True,
) -> list[ExternalTrigger]: ...
) -> list[LocalTrigger]: ...
class _SessionManagerLike(Protocol):
@@ -43,7 +43,7 @@ def session_automation_jobs(
cron_service: _CronServiceLike | None,
session_key: str,
*,
external_trigger_store: _ExternalTriggerStoreLike | None = None,
local_trigger_store: _LocalTriggerStoreLike | None = None,
) -> list[AutomationJob]:
"""Return user automations attached to the WebUI session."""
jobs: list[AutomationJob] = []
@@ -54,9 +54,9 @@ def session_automation_jobs(
include_disabled=True,
)
)
if external_trigger_store is not None:
if local_trigger_store is not None:
jobs.extend(
external_trigger_store.list_for_session(
local_trigger_store.list_for_session(
session_key,
include_disabled=True,
)
@@ -68,7 +68,7 @@ def session_automations_payload(
cron_service: _CronServiceLike | None,
session_key: str,
*,
external_trigger_store: _ExternalTriggerStoreLike | None = None,
local_trigger_store: _LocalTriggerStoreLike | None = None,
pending_job_ids: Collection[str] | None = None,
) -> dict[str, Any]:
"""Return user-created automation jobs attached to a WebUI session."""
@@ -77,7 +77,7 @@ def session_automations_payload(
session_automation_jobs(
cron_service,
session_key,
external_trigger_store=external_trigger_store,
local_trigger_store=local_trigger_store,
),
pending_job_ids=pending_job_ids,
)
@@ -87,7 +87,7 @@ def session_automations_payload(
def all_automations_payload(
cron_service: _CronServiceLike | None,
*,
external_trigger_store: _ExternalTriggerStoreLike | None = None,
local_trigger_store: _LocalTriggerStoreLike | None = None,
session_manager: _SessionManagerLike | None = None,
pending_job_ids: Collection[str] | None = None,
) -> dict[str, Any]:
@@ -95,8 +95,8 @@ def all_automations_payload(
jobs: list[AutomationJob] = []
if cron_service is not None:
jobs.extend(cron_service.list_jobs(include_disabled=True))
if external_trigger_store is not None:
jobs.extend(external_trigger_store.list_triggers(include_disabled=True))
if local_trigger_store is not None:
jobs.extend(local_trigger_store.list_triggers(include_disabled=True))
return {
"jobs": serialize_automation_jobs(
jobs,
@@ -132,7 +132,7 @@ def _serialize_job(
include_details: bool = False,
session_manager: _SessionManagerLike | None = None,
) -> dict[str, Any]:
if isinstance(job, ExternalTrigger):
if isinstance(job, LocalTrigger):
return _serialize_trigger(
job,
include_details=include_details,
@@ -187,7 +187,7 @@ def _serialize_job(
def _serialize_trigger(
trigger: ExternalTrigger,
trigger: LocalTrigger,
*,
include_details: bool = False,
session_manager: _SessionManagerLike | None = None,
@@ -197,16 +197,16 @@ def _serialize_trigger(
"id": trigger.id,
"name": trigger.name,
"enabled": trigger.enabled,
"kind": "external_trigger",
"kind": "local_trigger",
"schedule": {
"kind": "external",
"kind": "local",
"at_ms": None,
"every_ms": None,
"expr": None,
"tz": None,
},
"payload": {
"kind": "external_trigger",
"kind": "local_trigger",
"message": command,
"command": command,
},
@@ -273,7 +273,7 @@ def _origin_payload(
def _trigger_origin_payload(
trigger: ExternalTrigger,
trigger: LocalTrigger,
session_manager: _SessionManagerLike | None,
) -> dict[str, Any] | None:
channel = trigger.channel
+24 -24
View File
@@ -26,7 +26,7 @@ from websockets.http11 import Response
from nanobot.command.builtin import builtin_command_palette
from nanobot.cron.session_turns import is_bound_cron_job
from nanobot.cron.types import CronJob, CronSchedule
from nanobot.triggers.types import ExternalTrigger
from nanobot.triggers.local_types import LocalTrigger
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload
from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload
@@ -90,7 +90,7 @@ if TYPE_CHECKING:
from nanobot.bus.queue import MessageBus
from nanobot.cron.service import CronService
from nanobot.session.manager import SessionManager
from nanobot.triggers.store import ExternalTriggerStore
from nanobot.triggers.local_store import LocalTriggerStore
def _decode_api_key(raw_key: str) -> str | None:
@@ -155,7 +155,7 @@ class GatewayHTTPHandler:
skills_workspace_path: Path,
disabled_skills: set[str] | None = None,
cron_service: CronService | None = None,
external_trigger_store: ExternalTriggerStore | None = None,
local_trigger_store: LocalTriggerStore | None = None,
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
log: Any = logger,
) -> None:
@@ -170,7 +170,7 @@ class GatewayHTTPHandler:
self.skills_workspace_path = skills_workspace_path
self.disabled_skills = disabled_skills or set()
self.cron_service = cron_service
self.external_trigger_store = external_trigger_store
self.local_trigger_store = local_trigger_store
self.cron_pending_job_ids = cron_pending_job_ids
self._log = log
self._runtime_surface = runtime_surface
@@ -494,7 +494,7 @@ class GatewayHTTPHandler:
session_automations_payload(
self.cron_service,
decoded_key,
external_trigger_store=self.external_trigger_store,
local_trigger_store=self.local_trigger_store,
pending_job_ids=pending_job_ids,
)
)
@@ -514,7 +514,7 @@ class GatewayHTTPHandler:
automation_jobs = session_automation_jobs(
self.cron_service,
decoded_key,
external_trigger_store=self.external_trigger_store,
local_trigger_store=self.local_trigger_store,
)
if automation_jobs and delete_automations not in {"1", "true", "yes"}:
return _http_json_response(
@@ -526,9 +526,9 @@ class GatewayHTTPHandler:
)
if automation_jobs:
for job in automation_jobs:
if isinstance(job, ExternalTrigger):
if self.external_trigger_store is not None:
self.external_trigger_store.delete(job.id)
if isinstance(job, LocalTrigger):
if self.local_trigger_store is not None:
self.local_trigger_store.delete(job.id)
elif self.cron_service is not None:
self.cron_service.remove_job(job.id)
deleted = self.session_manager.delete_session(decoded_key)
@@ -567,7 +567,7 @@ class GatewayHTTPHandler:
return _http_json_response(
all_automations_payload(
self.cron_service,
external_trigger_store=self.external_trigger_store,
local_trigger_store=self.local_trigger_store,
session_manager=self.session_manager,
pending_job_ids=self._pending_cron_job_ids_for_all(),
)
@@ -580,16 +580,16 @@ class GatewayHTTPHandler:
) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
if self.cron_service is None and self.external_trigger_store is None:
if self.cron_service is None and self.local_trigger_store is None:
return _http_error(503, "automation service unavailable")
query = _parse_query(request.path)
job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip()
if not job_id:
return _http_error(400, "missing automation id")
trigger = self.external_trigger_store.get(job_id) if self.external_trigger_store else None
trigger = self.local_trigger_store.get(job_id) if self.local_trigger_store else None
if trigger is not None:
return self._handle_external_trigger_action(request, action, trigger)
return self._handle_local_trigger_action(request, action, trigger)
if self.cron_service is None:
return _http_error(404, "automation not found")
@@ -638,34 +638,34 @@ class GatewayHTTPHandler:
return self._handle_webui_automations(request)
def _handle_external_trigger_action(
def _handle_local_trigger_action(
self,
request: WsRequest,
action: str,
trigger: ExternalTrigger,
trigger: LocalTrigger,
) -> Response:
if self.external_trigger_store is None:
if self.local_trigger_store is None:
return _http_error(503, "trigger service unavailable")
if action == "enable":
if self.external_trigger_store.enable(trigger.id, enabled=True) is None:
if self.local_trigger_store.enable(trigger.id, enabled=True) is None:
return _http_error(404, "automation not found")
elif action == "disable":
if self.external_trigger_store.enable(trigger.id, enabled=False) is None:
if self.local_trigger_store.enable(trigger.id, enabled=False) is None:
return _http_error(404, "automation not found")
elif action == "delete":
if not self.external_trigger_store.delete(trigger.id):
if not self.local_trigger_store.delete(trigger.id):
return _http_error(404, "automation not found")
elif action == "run":
return _http_error(409, "external trigger requires a CLI message")
return _http_error(409, "local trigger requires a CLI message")
elif action == "update":
values = _automation_values_from_request(request)
if values is None:
return _http_error(400, "invalid automation update payload")
parsed = _parse_external_trigger_update(values)
parsed = _parse_local_trigger_update(values)
if isinstance(parsed, str):
return _http_error(400, parsed)
if parsed:
if self.external_trigger_store.update(trigger.id, **parsed) is None:
if self.local_trigger_store.update(trigger.id, **parsed) is None:
return _http_error(404, "automation not found")
else:
return _http_error(404, "unknown automation action")
@@ -884,7 +884,7 @@ def _parse_automation_update(
return update
def _parse_external_trigger_update(values: dict[str, Any]) -> dict[str, Any] | str:
def _parse_local_trigger_update(values: dict[str, Any]) -> dict[str, Any] | str:
update: dict[str, Any] = {}
if "name" in values:
raw_name = values.get("name")
@@ -896,7 +896,7 @@ def _parse_external_trigger_update(values: dict[str, Any]) -> dict[str, Any] | s
update["name"] = name
forbidden = [key for key in ("message", "schedule") if key in values]
if forbidden:
return "external trigger updates only support name"
return "local trigger updates only support name"
return update
+6 -6
View File
@@ -34,7 +34,7 @@ from nanobot.session.webui_turns import (
clean_generated_title,
maybe_generate_webui_title,
)
from nanobot.triggers.session_turns import EXTERNAL_TRIGGER_META
from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_META
from nanobot.utils.llm_runtime import LLMRuntime
@@ -118,7 +118,7 @@ def test_persist_cron_turn_uses_distinct_history_marker(tmp_path: Path) -> None:
assert message["cron_prompt_ref"] == prompt_ref
def test_persist_external_trigger_turn_uses_hidden_automation_marker(tmp_path: Path) -> None:
def test_persist_local_trigger_turn_uses_hidden_automation_marker(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
session = loop.sessions.get_or_create("websocket:auto")
@@ -129,7 +129,7 @@ def test_persist_external_trigger_turn_uses_hidden_automation_marker(tmp_path: P
chat_id="auto",
content="Review PR #4502",
metadata={
EXTERNAL_TRIGGER_META: {
LOCAL_TRIGGER_META: {
"trigger_id": "trg_123",
"trigger_name": "PR review",
"delivery_id": "tdel_456",
@@ -142,15 +142,15 @@ def test_persist_external_trigger_turn_uses_hidden_automation_marker(tmp_path: P
assert persisted is True
message = session.messages[-1]
assert message["content"] == "External trigger received: PR review"
assert message["content"] == "Local trigger received: PR review"
assert "Review PR #4502" not in message["content"]
assert message[AUTOMATION_HISTORY_META] == {
"kind": "trigger",
"kind": "local_trigger",
"trigger_id": "trg_123",
"trigger_name": "PR review",
"trigger_delivery_id": "tdel_456",
}
assert EXTERNAL_TRIGGER_META not in message
assert LOCAL_TRIGGER_META not in message
assert message["trigger_id"] == "trg_123"
assert message["trigger_name"] == "PR review"
assert message["trigger_delivery_id"] == "tdel_456"
+2 -2
View File
@@ -2996,8 +2996,8 @@ def test_handle_webui_thread_get_does_not_backfill_trigger_internal_prompt(
session = sessions.get_or_create(key)
session.add_message(
"user",
"External trigger received: PR review",
**{AUTOMATION_HISTORY_META: {"kind": "trigger", "trigger_id": "trg_123"}},
"Local trigger received: PR review",
**{AUTOMATION_HISTORY_META: {"kind": "local_trigger", "trigger_id": "trg_123"}},
)
session.add_message("assistant", "PR #4502 已经开始 review。")
sessions.save(session)
+18 -18
View File
@@ -20,7 +20,7 @@ from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
from nanobot.session.keys import UNIFIED_SESSION_KEY
from nanobot.session.manager import Session, SessionManager
from nanobot.triggers.store import ExternalTriggerStore
from nanobot.triggers.local_store import LocalTriggerStore
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
_PORT = 29900
@@ -47,7 +47,7 @@ def _make_handler(
workspace_path: Path | None = None,
runtime_model_name: Any | None = None,
cron_service: CronService | None = None,
external_trigger_store: ExternalTriggerStore | None = None,
local_trigger_store: LocalTriggerStore | None = None,
cron_pending_job_ids: Any | None = None,
) -> GatewayServices:
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
@@ -63,7 +63,7 @@ def _make_handler(
runtime_surface="browser",
runtime_capabilities_overrides=None,
cron_service=cron_service,
external_trigger_store=external_trigger_store,
local_trigger_store=local_trigger_store,
cron_pending_job_ids=cron_pending_job_ids,
)
@@ -77,7 +77,7 @@ def _ch(
port: int = _PORT,
runtime_model_name: Any | None = None,
cron_service: CronService | None = None,
external_trigger_store: ExternalTriggerStore | None = None,
local_trigger_store: LocalTriggerStore | None = None,
cron_pending_job_ids: Any | None = None,
**extra: Any,
) -> WebSocketChannel:
@@ -97,7 +97,7 @@ def _ch(
workspace_path=workspace_path,
runtime_model_name=runtime_model_name,
cron_service=cron_service,
external_trigger_store=external_trigger_store,
local_trigger_store=local_trigger_store,
cron_pending_job_ids=cron_pending_job_ids,
)
return WebSocketChannel(cfg, bus, gateway=gateway)
@@ -325,12 +325,12 @@ async def test_session_automations_route_ignores_unified_owner(
@pytest.mark.asyncio
async def test_session_automations_route_lists_external_triggers(
async def test_session_automations_route_lists_local_triggers(
bus: MagicMock, tmp_path: Path
) -> None:
port = _free_port()
base_url = f"http://127.0.0.1:{port}"
trigger_store = ExternalTriggerStore(tmp_path)
trigger_store = LocalTriggerStore(tmp_path)
trigger = trigger_store.create(
name="PR review",
channel="websocket",
@@ -340,7 +340,7 @@ async def test_session_automations_route_lists_external_triggers(
channel = _ch(
bus,
session_manager=_seed_session(tmp_path, key="websocket:abc"),
external_trigger_store=trigger_store,
local_trigger_store=trigger_store,
port=port,
)
server_task = asyncio.create_task(channel.start())
@@ -359,9 +359,9 @@ async def test_session_automations_route_lists_external_triggers(
body = resp.json()
assert [job["id"] for job in body["jobs"]] == [trigger.id]
job = body["jobs"][0]
assert job["kind"] == "external_trigger"
assert job["schedule"]["kind"] == "external"
assert job["payload"]["kind"] == "external_trigger"
assert job["kind"] == "local_trigger"
assert job["schedule"]["kind"] == "local"
assert job["payload"]["kind"] == "local_trigger"
assert job["payload"]["command"] == f'nanobot trigger {trigger.id} "message"'
finally:
await channel.stop()
@@ -1130,12 +1130,12 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
@pytest.mark.asyncio
async def test_webui_automations_route_manages_external_triggers(
async def test_webui_automations_route_manages_local_triggers(
bus: MagicMock, tmp_path: Path
) -> None:
port = _free_port()
base_url = f"http://127.0.0.1:{port}"
trigger_store = ExternalTriggerStore(tmp_path)
trigger_store = LocalTriggerStore(tmp_path)
trigger = trigger_store.create(
name="PR review",
channel="websocket",
@@ -1145,7 +1145,7 @@ async def test_webui_automations_route_manages_external_triggers(
channel = _ch(
bus,
session_manager=_seed_session(tmp_path, key="websocket:abc"),
external_trigger_store=trigger_store,
local_trigger_store=trigger_store,
port=port,
)
server_task = asyncio.create_task(channel.start())
@@ -1158,7 +1158,7 @@ async def test_webui_automations_route_manages_external_triggers(
listed = await _http_get(f"{base_url}/api/webui/automations", headers=auth)
assert listed.status_code == 200
by_id = {job["id"]: job for job in listed.json()["jobs"]}
assert by_id[trigger.id]["kind"] == "external_trigger"
assert by_id[trigger.id]["kind"] == "local_trigger"
assert by_id[trigger.id]["trigger"]["command"] == f'nanobot trigger {trigger.id} "message"'
disabled = await _http_get(
@@ -1251,14 +1251,14 @@ async def test_session_delete_blocks_when_bound_automation_exists(
@pytest.mark.asyncio
async def test_session_delete_blocks_and_cascades_external_triggers(
async def test_session_delete_blocks_and_cascades_local_triggers(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
port = _free_port()
base_url = f"http://127.0.0.1:{port}"
sm = _seed_session(tmp_path, key="websocket:doomed")
trigger_store = ExternalTriggerStore(tmp_path)
trigger_store = LocalTriggerStore(tmp_path)
trigger = trigger_store.create(
name="PR review",
channel="websocket",
@@ -1268,7 +1268,7 @@ async def test_session_delete_blocks_and_cascades_external_triggers(
channel = _ch(
bus,
session_manager=sm,
external_trigger_store=trigger_store,
local_trigger_store=trigger_store,
port=port,
)
server_task = asyncio.create_task(channel.start())
+2 -2
View File
@@ -2520,14 +2520,14 @@ def test_trigger_cli_queues_message_in_workspace(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
from nanobot.triggers.store import ExternalTriggerStore
from nanobot.triggers.local_store import LocalTriggerStore
config_file = _write_instance_config(tmp_path)
config = Config()
config.agents.defaults.workspace = str(tmp_path / "workspace")
_patch_cli_command_runtime(monkeypatch, config)
store = ExternalTriggerStore(config.workspace_path)
store = LocalTriggerStore(config.workspace_path)
trigger = store.create(
name="Review hook",
channel="websocket",
+5 -5
View File
@@ -8,15 +8,15 @@ import pytest
from nanobot.bus.events import InboundMessage
from nanobot.command.builtin import build_help_text, register_builtin_commands
from nanobot.command.router import CommandContext, CommandRouter
from nanobot.triggers.store import ExternalTriggerStore
from nanobot.triggers.local_store import LocalTriggerStore
@pytest.mark.asyncio
async def test_trigger_command_creates_session_bound_local_trigger(tmp_path: Path) -> None:
router = CommandRouter()
register_builtin_commands(router)
store = ExternalTriggerStore(tmp_path)
loop = SimpleNamespace(workspace=tmp_path, external_trigger_store=store)
store = LocalTriggerStore(tmp_path)
loop = SimpleNamespace(workspace=tmp_path, local_trigger_store=store)
msg = InboundMessage(
channel="websocket",
sender_id="user",
@@ -49,8 +49,8 @@ async def test_trigger_command_creates_session_bound_local_trigger(tmp_path: Pat
async def test_trigger_command_without_name_returns_usage_only(tmp_path: Path) -> None:
router = CommandRouter()
register_builtin_commands(router)
store = ExternalTriggerStore(tmp_path)
loop = SimpleNamespace(workspace=tmp_path, external_trigger_store=store)
store = LocalTriggerStore(tmp_path)
loop = SimpleNamespace(workspace=tmp_path, local_trigger_store=store)
msg = InboundMessage(
channel="websocket",
sender_id="user",
@@ -7,13 +7,13 @@ from pathlib import Path
import pytest
from nanobot.bus.events import InboundMessage
from nanobot.triggers.runner import run_external_trigger_queue
from nanobot.triggers.store import ExternalTriggerStore, TriggerDisabledError
from nanobot.triggers.local_runner import run_local_trigger_queue
from nanobot.triggers.local_store import LocalTriggerStore, TriggerDisabledError
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
def test_trigger_store_allows_multiple_triggers_per_session(tmp_path: Path) -> None:
store = ExternalTriggerStore(tmp_path)
store = LocalTriggerStore(tmp_path)
first = store.create(
name="PR review",
@@ -36,7 +36,7 @@ def test_trigger_store_allows_multiple_triggers_per_session(tmp_path: Path) -> N
def test_enqueue_rejects_disabled_trigger(tmp_path: Path) -> None:
store = ExternalTriggerStore(tmp_path)
store = LocalTriggerStore(tmp_path)
trigger = store.create(
name="Disabled",
channel="telegram",
@@ -50,7 +50,7 @@ def test_enqueue_rejects_disabled_trigger(tmp_path: Path) -> None:
def test_recover_processing_deliveries_requeues_claimed_delivery(tmp_path: Path) -> None:
store = ExternalTriggerStore(tmp_path)
store = LocalTriggerStore(tmp_path)
trigger = store.create(
name="PR review",
channel="websocket",
@@ -63,9 +63,9 @@ def test_recover_processing_deliveries_requeues_claimed_delivery(tmp_path: Path)
assert len(claimed) == 1
assert claimed[0].path is not None
assert claimed[0].path.parent.name == "processing"
assert ExternalTriggerStore(tmp_path).claim_deliveries() == []
assert LocalTriggerStore(tmp_path).claim_deliveries() == []
restarted = ExternalTriggerStore(tmp_path)
restarted = LocalTriggerStore(tmp_path)
assert restarted.recover_processing_deliveries() == 1
reclaimed = restarted.claim_deliveries()
@@ -77,8 +77,8 @@ def test_recover_processing_deliveries_requeues_claimed_delivery(tmp_path: Path)
@pytest.mark.asyncio
async def test_external_trigger_queue_publishes_bound_inbound_message(tmp_path: Path) -> None:
store = ExternalTriggerStore(tmp_path)
async def test_local_trigger_queue_publishes_bound_inbound_message(tmp_path: Path) -> None:
store = LocalTriggerStore(tmp_path)
trigger = store.create(
name="PR review",
channel="websocket",
@@ -94,7 +94,7 @@ async def test_external_trigger_queue_publishes_bound_inbound_message(tmp_path:
published.append(msg)
task = asyncio.create_task(
run_external_trigger_queue(store=store, bus=_Bus(), poll_interval_s=0.01)
run_local_trigger_queue(store=store, bus=_Bus(), poll_interval_s=0.01)
)
try:
for _ in range(100):
@@ -116,10 +116,10 @@ async def test_external_trigger_queue_publishes_bound_inbound_message(tmp_path:
assert msg.metadata[WEBUI_TURN_METADATA_KEY].startswith(f"trigger:{trigger.id}:")
assert msg.metadata[WEBUI_TURN_METADATA_KEY] != "old-turn"
assert msg.metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == {
"kind": "trigger",
"kind": "local_trigger",
"label": "PR review",
}
assert msg.metadata["_external_trigger"]["trigger_id"] == trigger.id
assert msg.metadata["_local_trigger"]["trigger_id"] == trigger.id
stored = store.get(trigger.id)
assert stored is not None
@@ -129,10 +129,10 @@ async def test_external_trigger_queue_publishes_bound_inbound_message(tmp_path:
@pytest.mark.asyncio
async def test_external_trigger_queue_recovers_processing_delivery_on_start(
async def test_local_trigger_queue_recovers_processing_delivery_on_start(
tmp_path: Path,
) -> None:
store = ExternalTriggerStore(tmp_path)
store = LocalTriggerStore(tmp_path)
trigger = store.create(
name="PR review",
channel="websocket",
@@ -147,9 +147,9 @@ async def test_external_trigger_queue_recovers_processing_delivery_on_start(
async def publish_inbound(self, msg: InboundMessage) -> None:
published.append(msg)
restarted = ExternalTriggerStore(tmp_path)
restarted = LocalTriggerStore(tmp_path)
task = asyncio.create_task(
run_external_trigger_queue(store=restarted, bus=_Bus(), poll_interval_s=0.01)
run_local_trigger_queue(store=restarted, bus=_Bus(), poll_interval_s=0.01)
)
try:
for _ in range(100):
@@ -163,5 +163,5 @@ async def test_external_trigger_queue_recovers_processing_delivery_on_start(
assert len(published) == 1
assert published[0].content == "Review PR #4591"
assert published[0].metadata["_external_trigger"]["trigger_id"] == trigger.id
assert published[0].metadata["_local_trigger"]["trigger_id"] == trigger.id
assert restarted.claim_deliveries() == []
+19 -1
View File
@@ -473,7 +473,25 @@ def test_replay_reused_turn_id_after_turn_end_starts_new_turn(tmp_path, monkeypa
assert msgs[2]["source"] == {"kind": "cron", "label": "drink water"}
def test_replay_preserves_trigger_source_metadata(tmp_path, monkeypatch) -> None:
def test_replay_preserves_local_trigger_source_metadata(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-local-trigger-source"
append_transcript_object(
key,
{
"event": "message",
"chat_id": "t-local-trigger-source",
"text": "PR #4502 review started.",
"source": {"kind": "local_trigger", "label": "PR review"},
},
)
msgs = replay_transcript_to_ui_messages(read_transcript_lines(key))
assert msgs[0]["source"] == {"kind": "local_trigger", "label": "PR review"}
def test_replay_preserves_legacy_trigger_source_metadata(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-trigger-source"
append_transcript_object(
+2 -2
View File
@@ -94,8 +94,8 @@ def test_webui_session_list_skips_trigger_internal_user_preview(tmp_path: Path)
session = manager.get_or_create("websocket:trigger-preview")
session.add_message(
"user",
"External trigger received: PR review",
**{AUTOMATION_HISTORY_META: {"kind": "trigger", "trigger_id": "trg_123"}},
"Local trigger received: PR review",
**{AUTOMATION_HISTORY_META: {"kind": "local_trigger", "trigger_id": "trg_123"}},
)
session.add_message("assistant", "PR #4502 已经开始 review。")
manager.save(session)
+4 -4
View File
@@ -122,8 +122,8 @@ function formatAutomationSchedule(
})
: t("deleteConfirm.schedule.cron", { expr: job.schedule.expr });
}
if (job.schedule.kind === "external" || job.payload.kind === "external_trigger") {
return t("deleteConfirm.schedule.external", { defaultValue: "External trigger" });
if (job.schedule.kind === "local" || job.payload.kind === "local_trigger") {
return t("deleteConfirm.schedule.local", { defaultValue: "Local trigger" });
}
return t("deleteConfirm.schedule.unknown");
}
@@ -134,8 +134,8 @@ function formatAutomationNextRun(
locale: string,
): string {
if (!job.enabled) return t("deleteConfirm.next.disabled");
if (job.schedule.kind === "external" || job.payload.kind === "external_trigger") {
return t("deleteConfirm.next.external", { defaultValue: "Waiting for trigger" });
if (job.schedule.kind === "local" || job.payload.kind === "local_trigger") {
return t("deleteConfirm.next.local", { defaultValue: "Waiting for trigger" });
}
const next = job.state.next_run_at_ms;
if (!next) return t("deleteConfirm.next.none");
+6 -1
View File
@@ -167,7 +167,12 @@ export function MessageBubble({
const reasoning = message.role === "assistant" ? message.reasoning ?? "" : "";
const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming);
const hasReasoning = reasoning.length > 0 || reasoningStreaming;
const automationSourceLabel = message.source?.kind === "cron" || message.source?.kind === "trigger"
const automationSourceKind = message.source?.kind;
const automationSourceLabel = (
automationSourceKind === "cron"
|| automationSourceKind === "local_trigger"
|| automationSourceKind === "trigger"
)
? (message.source.label?.trim() || t("message.automationSourceFallback"))
: "";
const automationTriggeredLabel = t("message.automationTriggered");
+27 -27
View File
@@ -3756,10 +3756,10 @@ function AutomationDetailPanel({
: null;
const created = job.created_at_ms ? fmtDateTime(job.created_at_ms, locale) : null;
const updated = job.updated_at_ms ? fmtDateTime(job.updated_at_ms, locale) : null;
const externalTrigger = isExternalTriggerAutomation(job);
const localTrigger = isLocalTriggerAutomation(job);
const triggerCommand = automationTriggerCommand(job);
const message = automationDetailText(job, tx);
const messageLabel = externalTrigger
const messageLabel = localTrigger
? tx("settings.automations.fields.command", "Command")
: tx("settings.automations.fields.message", "Message");
const schedule = formatAutomationSchedule(job, locale, tx);
@@ -3807,7 +3807,7 @@ function AutomationDetailPanel({
<div className="text-[11px] font-medium leading-none text-muted-foreground/75">
{messageLabel}
</div>
{externalTrigger && triggerCommand ? (
{localTrigger && triggerCommand ? (
<Button
type="button"
variant="ghost"
@@ -3833,7 +3833,7 @@ function AutomationDetailPanel({
<div
className={cn(
"mt-3 whitespace-pre-wrap break-words text-[13px] leading-6 text-foreground/85",
externalTrigger && "font-mono text-[12.5px]",
localTrigger && "font-mono text-[12.5px]",
!messageExpanded && messageNeedsExpansion && "line-clamp-6",
)}
>
@@ -3940,8 +3940,8 @@ function AutomationActionGroup({
t(key, { defaultValue: fallback, ...(values ?? {}) });
const canManage = !job.protected;
const hasLinkedChat = Boolean(job.origin);
const externalTrigger = isExternalTriggerAutomation(job);
const canRun = canManage && hasLinkedChat && job.enabled && !job.state.pending && !externalTrigger;
const localTrigger = isLocalTriggerAutomation(job);
const canRun = canManage && hasLinkedChat && job.enabled && !job.state.pending && !localTrigger;
const toggleAction: AutomationAction = job.enabled ? "disable" : "enable";
const canToggle = canManage && (job.enabled || hasLinkedChat);
const toggleBusy = actionKey === `${toggleAction}:${job.id}`;
@@ -3963,7 +3963,7 @@ function AutomationActionGroup({
>
<Pencil className="h-4 w-4" aria-hidden />
</AppsActionButton>
{!externalTrigger ? (
{!localTrigger ? (
<AppsActionButton
ariaLabel={tx("settings.automations.runNow", "Run now")}
busy={actionKey === `run:${job.id}`}
@@ -4095,7 +4095,7 @@ function AutomationEditDialog({
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(values ?? {}) });
const [draft, setDraft] = useState<AutomationEditDraft>(() => automationDraftFromJob(null));
const externalTrigger = isExternalTriggerAutomation(job);
const localTrigger = isLocalTriggerAutomation(job);
useEffect(() => {
setDraft(automationDraftFromJob(job));
@@ -4145,7 +4145,7 @@ function AutomationEditDialog({
/>
</label>
{!externalTrigger ? (
{!localTrigger ? (
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground">
{tx("settings.automations.fields.message", "Message")}
@@ -4158,7 +4158,7 @@ function AutomationEditDialog({
</label>
) : null}
{!externalTrigger ? (
{!localTrigger ? (
<div className="space-y-2">
<span className="text-[12px] font-medium text-muted-foreground">
{tx("settings.automations.fields.scheduleType", "Schedule type")}
@@ -4176,7 +4176,7 @@ function AutomationEditDialog({
</div>
) : null}
{!externalTrigger && draft.scheduleKind === "every" ? (
{!localTrigger && draft.scheduleKind === "every" ? (
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_10rem]">
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground">
@@ -4217,7 +4217,7 @@ function AutomationEditDialog({
</div>
) : null}
{!externalTrigger && draft.scheduleKind === "cron" ? (
{!localTrigger && draft.scheduleKind === "cron" ? (
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_12rem]">
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground">
@@ -4244,7 +4244,7 @@ function AutomationEditDialog({
</div>
) : null}
{!externalTrigger && draft.scheduleKind === "at" ? (
{!localTrigger && draft.scheduleKind === "at" ? (
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground">
{tx("settings.automations.fields.runAt", "Run at")}
@@ -4340,11 +4340,11 @@ function AutomationDeleteDialog({
);
}
function isExternalTriggerAutomation(job: SessionAutomationJob | null): boolean {
function isLocalTriggerAutomation(job: SessionAutomationJob | null): boolean {
if (!job) return false;
return job.kind === "external_trigger"
|| job.payload.kind === "external_trigger"
|| job.schedule.kind === "external";
return job.kind === "local_trigger"
|| job.payload.kind === "local_trigger"
|| job.schedule.kind === "local";
}
function automationTriggerCommand(job: SessionAutomationJob): string {
@@ -4355,8 +4355,8 @@ function automationSummary(
job: SessionAutomationJob,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string {
if (isExternalTriggerAutomation(job)) {
return automationTriggerCommand(job) || tx("settings.automations.externalTrigger", "External trigger");
if (isLocalTriggerAutomation(job)) {
return automationTriggerCommand(job) || tx("settings.automations.localTrigger", "Local trigger");
}
return job.payload.message || tx("settings.automations.systemTask", "System-managed automation");
}
@@ -4379,7 +4379,7 @@ function automationStatusKey(
if (job.state.pending) return "running";
if (!job.enabled) return "paused";
if (job.state.last_status === "error") return "failed";
if (isExternalTriggerAutomation(job)) return "active";
if (isLocalTriggerAutomation(job)) return "active";
if (job.delete_after_run && !job.state.next_run_at_ms && job.state.last_status === "ok") {
return "completed";
}
@@ -4443,7 +4443,7 @@ function automationEditDraftError(
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string | null {
if (!draft.name.trim()) return tx("settings.automations.validation.nameRequired", "Name is required.");
if (isExternalTriggerAutomation(job)) return null;
if (isLocalTriggerAutomation(job)) return null;
if (!draft.message.trim()) {
return tx("settings.automations.validation.messageRequired", "Message is required.");
}
@@ -4473,7 +4473,7 @@ function automationUpdatePayloadFromDraft(
job: SessionAutomationJob | null,
): AutomationUpdatePayload | string {
const name = draft.name.trim();
if (isExternalTriggerAutomation(job)) {
if (isLocalTriggerAutomation(job)) {
if (!name) return "invalid";
return { name };
}
@@ -4604,7 +4604,7 @@ function automationSearchParts(
job.payload.message,
job.payload.command,
job.trigger?.command,
isExternalTriggerAutomation(job) ? "trigger external" : null,
isLocalTriggerAutomation(job) ? "trigger local" : null,
...scheduleParts,
automationStatusKey(job),
...originParts,
@@ -4794,8 +4794,8 @@ function formatAutomationSchedule(
})
: tx("settings.automations.schedule.cron", "Cron {{expr}}", { expr: job.schedule.expr });
}
if (isExternalTriggerAutomation(job)) {
return tx("settings.automations.schedule.external", "External trigger");
if (isLocalTriggerAutomation(job)) {
return tx("settings.automations.schedule.local", "Local trigger");
}
return tx("settings.automations.schedule.custom", "Custom schedule");
}
@@ -4851,8 +4851,8 @@ function formatAutomationNext(
): string {
if (!job.enabled) return tx("settings.automations.next.paused", "Paused");
if (job.state.pending) return tx("settings.automations.next.pending", "Running now");
if (isExternalTriggerAutomation(job)) {
return tx("settings.automations.next.external", "Waiting for trigger");
if (isLocalTriggerAutomation(job)) {
return tx("settings.automations.next.local", "Waiting for trigger");
}
if (!job.state.next_run_at_ms) return tx("settings.automations.next.none", "No next run");
return relativeTime(job.state.next_run_at_ms);
@@ -152,6 +152,9 @@ function AutomationRow({ job, now }: { job: SessionAutomationJob; now: number })
function formatSchedule(job: SessionAutomationJob, t: TFunction) {
const locale = currentLocale();
if (isLocalTriggerAutomation(job)) {
return t("thread.sessionInfo.schedule.local");
}
if (job.schedule.kind === "at" && job.schedule.at_ms) {
return t("thread.sessionInfo.schedule.at", { time: fmtDateTime(job.schedule.at_ms, locale) });
}
@@ -179,6 +182,9 @@ function formatNextRun(job: SessionAutomationJob, t: TFunction, now: number) {
if (job.state.pending) {
return { label: t("thread.sessionInfo.next.pending"), title: "" };
}
if (isLocalTriggerAutomation(job)) {
return { label: t("thread.sessionInfo.next.local"), title: "" };
}
const next = job.state.next_run_at_ms;
if (!next) {
return { label: t("thread.sessionInfo.next.none"), title: "" };
@@ -189,6 +195,12 @@ function formatNextRun(job: SessionAutomationJob, t: TFunction, now: number) {
};
}
function isLocalTriggerAutomation(job: SessionAutomationJob): boolean {
return job.kind === "local_trigger"
|| job.payload.kind === "local_trigger"
|| job.schedule.kind === "local";
}
function relativeTimeFrom(value: number, now: number, locale: string): string {
let delta = (value - now) / 1000;
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
+7
View File
@@ -493,6 +493,7 @@
"emptyHint": "Create one from where it should run so nanobot keeps the right context.",
"oneShot": "One-time",
"systemTask": "System-managed automation",
"localTrigger": "Local trigger",
"labels": {
"schedule": "Schedule",
"next": "Next",
@@ -551,11 +552,13 @@
"weekdaysAt": "Weekdays at {{time}}",
"hourlyAt": "Hourly at :{{minute}}",
"hourlyWindow": "Hourly {{start}}-{{end}} at :{{minute}}",
"local": "Local trigger",
"custom": "Custom schedule"
},
"next": {
"paused": "Paused",
"pending": "Running now",
"local": "Waiting for trigger",
"none": "No next run"
},
"message": {
@@ -692,11 +695,13 @@
"every": "Every {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"local": "Local trigger",
"unknown": "Custom schedule"
},
"next": {
"label": "Next: {{time}}",
"disabled": "Paused",
"local": "Waiting for trigger",
"none": "No next run"
}
},
@@ -791,12 +796,14 @@
"every": "Every {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"local": "Local trigger",
"unknown": "Custom schedule"
},
"next": {
"label": "{{time}}",
"pending": "Runs shortly",
"disabled": "Paused",
"local": "Waiting for trigger",
"none": "No next run"
}
},
+7
View File
@@ -493,6 +493,7 @@
"emptyHint": "Créala desde donde debe ejecutarse para que nanobot conserve el contexto correcto.",
"oneShot": "Una vez",
"systemTask": "Automatización administrada por el sistema",
"localTrigger": "Activador local",
"labels": {
"schedule": "Programación",
"next": "Siguiente",
@@ -551,11 +552,13 @@
"weekdaysAt": "Días laborables a las {{time}}",
"hourlyAt": "Cada hora en :{{minute}}",
"hourlyWindow": "Cada hora {{start}}-{{end}} en :{{minute}}",
"local": "Activador local",
"custom": "Programación personalizada"
},
"next": {
"paused": "Pausada",
"pending": "Ejecutándose ahora",
"local": "Esperando activador",
"none": "Sin próxima ejecución"
},
"message": {
@@ -692,11 +695,13 @@
"every": "Cada {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"local": "Activador local",
"unknown": "Programación personalizada"
},
"next": {
"label": "Siguiente: {{time}}",
"disabled": "Pausada",
"local": "Esperando activador",
"none": "Sin próxima ejecución"
}
},
@@ -791,12 +796,14 @@
"every": "Cada {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"local": "Activador local",
"unknown": "Programación personalizada"
},
"next": {
"label": "Siguiente {{time}}",
"pending": "Se ejecutará pronto",
"disabled": "En pausa",
"local": "Esperando activador",
"none": "Sin próxima ejecución"
}
},
+7
View File
@@ -493,6 +493,7 @@
"emptyHint": "Créez-la depuis son point d'exécution pour que nanobot conserve le bon contexte.",
"oneShot": "Ponctuelle",
"systemTask": "Automatisation gérée par le système",
"localTrigger": "Déclencheur local",
"labels": {
"schedule": "Planning",
"next": "Prochaine",
@@ -551,11 +552,13 @@
"weekdaysAt": "Jours ouvrés à {{time}}",
"hourlyAt": "Toutes les heures à :{{minute}}",
"hourlyWindow": "Toutes les heures {{start}}-{{end}} à :{{minute}}",
"local": "Déclencheur local",
"custom": "Planning personnalisé"
},
"next": {
"paused": "En pause",
"pending": "En cours dexécution",
"local": "En attente du déclencheur",
"none": "Aucune prochaine exécution"
},
"message": {
@@ -692,11 +695,13 @@
"every": "Tous les {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"local": "Déclencheur local",
"unknown": "Planification personnalisée"
},
"next": {
"label": "Prochaine exécution : {{time}}",
"disabled": "En pause",
"local": "En attente du déclencheur",
"none": "Aucune prochaine exécution"
}
},
@@ -791,12 +796,14 @@
"every": "Toutes les {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"local": "Déclencheur local",
"unknown": "Planification personnalisée"
},
"next": {
"label": "Prochaine {{time}}",
"pending": "Exécution imminente",
"disabled": "En pause",
"local": "En attente du déclencheur",
"none": "Aucune prochaine exécution"
}
},
+7
View File
@@ -493,6 +493,7 @@
"emptyHint": "Buat dari tempat tugas ini berjalan agar nanobot menyimpan konteks yang tepat.",
"oneShot": "Satu kali",
"systemTask": "Automasi yang dikelola sistem",
"localTrigger": "Pemicu lokal",
"labels": {
"schedule": "Jadwal",
"next": "Berikutnya",
@@ -551,11 +552,13 @@
"weekdaysAt": "Hari kerja pukul {{time}}",
"hourlyAt": "Setiap jam pada :{{minute}}",
"hourlyWindow": "Setiap jam {{start}}-{{end}} pada :{{minute}}",
"local": "Pemicu lokal",
"custom": "Jadwal khusus"
},
"next": {
"paused": "Dijeda",
"pending": "Sedang berjalan",
"local": "Menunggu pemicu",
"none": "Tidak ada jadwal berikutnya"
},
"message": {
@@ -692,11 +695,13 @@
"every": "Setiap {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"local": "Pemicu lokal",
"unknown": "Jadwal khusus"
},
"next": {
"label": "Berikutnya: {{time}}",
"disabled": "Dijeda",
"local": "Menunggu pemicu",
"none": "Tidak ada jadwal berikutnya"
}
},
@@ -791,12 +796,14 @@
"every": "Setiap {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"local": "Pemicu lokal",
"unknown": "Jadwal khusus"
},
"next": {
"label": "Berikutnya {{time}}",
"pending": "Segera berjalan",
"disabled": "Dijeda",
"local": "Menunggu pemicu",
"none": "Tidak ada jadwal berikutnya"
}
},
+7
View File
@@ -493,6 +493,7 @@
"emptyHint": "実行元から作成すると、nanobot が正しいコンテキストを保持できます。",
"oneShot": "一回限り",
"systemTask": "システム管理の自動タスク",
"localTrigger": "ローカルトリガー",
"labels": {
"schedule": "スケジュール",
"next": "次回",
@@ -551,11 +552,13 @@
"weekdaysAt": "平日 {{time}}",
"hourlyAt": "毎時 :{{minute}}",
"hourlyWindow": "{{start}}-{{end}} の毎時 :{{minute}}",
"local": "ローカルトリガー",
"custom": "カスタムスケジュール"
},
"next": {
"paused": "一時停止",
"pending": "実行中",
"local": "トリガー待ち",
"none": "次回実行なし"
},
"message": {
@@ -692,11 +695,13 @@
"every": "{{duration}} ごと",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"local": "ローカルトリガー",
"unknown": "カスタムスケジュール"
},
"next": {
"label": "次回: {{time}}",
"disabled": "一時停止中",
"local": "トリガー待ち",
"none": "次回実行なし"
}
},
@@ -791,12 +796,14 @@
"every": "{{duration}}ごと",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"local": "ローカルトリガー",
"unknown": "カスタムスケジュール"
},
"next": {
"label": "次回 {{time}}",
"pending": "まもなく実行",
"disabled": "一時停止",
"local": "トリガー待ち",
"none": "次回実行なし"
}
},
+7
View File
@@ -493,6 +493,7 @@
"emptyHint": "실행될 위치에서 만들면 nanobot이 올바른 컨텍스트를 유지합니다.",
"oneShot": "일회성",
"systemTask": "시스템 관리 자동화",
"localTrigger": "로컬 트리거",
"labels": {
"schedule": "일정",
"next": "다음",
@@ -551,11 +552,13 @@
"weekdaysAt": "평일 {{time}}",
"hourlyAt": "매시간 :{{minute}}",
"hourlyWindow": "{{start}}-{{end}} 사이 매시간 :{{minute}}",
"local": "로컬 트리거",
"custom": "사용자 지정 일정"
},
"next": {
"paused": "일시 중지",
"pending": "실행 중",
"local": "트리거 대기 중",
"none": "다음 실행 없음"
},
"message": {
@@ -692,11 +695,13 @@
"every": "{{duration}}마다",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"local": "로컬 트리거",
"unknown": "사용자 지정 일정"
},
"next": {
"label": "다음: {{time}}",
"disabled": "일시 중지됨",
"local": "트리거 대기 중",
"none": "다음 실행 없음"
}
},
@@ -791,12 +796,14 @@
"every": "{{duration}}마다",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"local": "로컬 트리거",
"unknown": "사용자 지정 일정"
},
"next": {
"label": "다음 {{time}}",
"pending": "곧 실행됨",
"disabled": "일시 중지됨",
"local": "트리거 대기 중",
"none": "다음 실행 없음"
}
},
+7
View File
@@ -493,6 +493,7 @@
"emptyHint": "Tạo từ nơi tác vụ sẽ chạy để nanobot giữ đúng ngữ cảnh.",
"oneShot": "Một lần",
"systemTask": "Tự động hóa do hệ thống quản lý",
"localTrigger": "Trình kích hoạt cục bộ",
"labels": {
"schedule": "Lịch",
"next": "Tiếp theo",
@@ -551,11 +552,13 @@
"weekdaysAt": "Ngày làm việc lúc {{time}}",
"hourlyAt": "Mỗi giờ tại :{{minute}}",
"hourlyWindow": "Mỗi giờ {{start}}-{{end}} tại :{{minute}}",
"local": "Trình kích hoạt cục bộ",
"custom": "Lịch tùy chỉnh"
},
"next": {
"paused": "Đã tạm dừng",
"pending": "Đang chạy",
"local": "Đang chờ kích hoạt",
"none": "Không có lần chạy tiếp theo"
},
"message": {
@@ -692,11 +695,13 @@
"every": "Mỗi {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"local": "Trình kích hoạt cục bộ",
"unknown": "Lịch tùy chỉnh"
},
"next": {
"label": "Tiếp theo: {{time}}",
"disabled": "Đã tạm dừng",
"local": "Đang chờ kích hoạt",
"none": "Không có lần chạy tiếp theo"
}
},
@@ -791,12 +796,14 @@
"every": "Mỗi {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"local": "Trình kích hoạt cục bộ",
"unknown": "Lịch tùy chỉnh"
},
"next": {
"label": "Tiếp theo {{time}}",
"pending": "Sắp chạy",
"disabled": "Đã tạm dừng",
"local": "Đang chờ kích hoạt",
"none": "Không có lần chạy tiếp theo"
}
},
+7
View File
@@ -493,6 +493,7 @@
"emptyHint": "请从它应该运行的来源处创建,这样 nanobot 才能保留正确上下文。",
"oneShot": "一次性",
"systemTask": "系统管理的自动任务",
"localTrigger": "本地触发器",
"labels": {
"schedule": "计划",
"next": "下次",
@@ -551,11 +552,13 @@
"weekdaysAt": "工作日 {{time}}",
"hourlyAt": "每小时第 {{minute}} 分钟",
"hourlyWindow": "{{start}}-{{end}} 点每小时第 {{minute}} 分钟",
"local": "本地触发器",
"custom": "自定义计划"
},
"next": {
"paused": "已暂停",
"pending": "正在运行",
"local": "等待触发",
"none": "没有下次运行"
},
"message": {
@@ -692,11 +695,13 @@
"every": "每 {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"local": "本地触发器",
"unknown": "自定义计划"
},
"next": {
"label": "下次:{{time}}",
"disabled": "已暂停",
"local": "等待触发",
"none": "没有下次运行"
}
},
@@ -791,12 +796,14 @@
"every": "每 {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"local": "本地触发器",
"unknown": "自定义计划"
},
"next": {
"label": "下次 {{time}}",
"pending": "即将执行",
"disabled": "已暂停",
"local": "等待触发",
"none": "没有下次运行"
}
},
+7
View File
@@ -493,6 +493,7 @@
"emptyHint": "請從它應該執行的來源處建立,這樣 nanobot 才能保留正確上下文。",
"oneShot": "一次性",
"systemTask": "系統管理的自動任務",
"localTrigger": "本機觸發器",
"labels": {
"schedule": "排程",
"next": "下次",
@@ -551,11 +552,13 @@
"weekdaysAt": "工作日 {{time}}",
"hourlyAt": "每小時第 {{minute}} 分鐘",
"hourlyWindow": "{{start}}-{{end}} 點每小時第 {{minute}} 分鐘",
"local": "本機觸發器",
"custom": "自訂排程"
},
"next": {
"paused": "已暫停",
"pending": "正在執行",
"local": "等待觸發",
"none": "沒有下次執行"
},
"message": {
@@ -692,11 +695,13 @@
"every": "每 {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"local": "本機觸發器",
"unknown": "自訂計畫"
},
"next": {
"label": "下次:{{time}}",
"disabled": "已暫停",
"local": "等待觸發",
"none": "沒有下次執行"
}
},
@@ -791,12 +796,14 @@
"every": "每 {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"local": "本機觸發器",
"unknown": "自訂計畫"
},
"next": {
"label": "下次 {{time}}",
"pending": "即將執行",
"disabled": "已暫停",
"local": "等待觸發",
"none": "沒有下次執行"
}
},
+4 -4
View File
@@ -32,7 +32,7 @@ export interface UIMediaAttachment {
name?: string;
}
export interface UIMessageSource { kind: "cron" | "trigger" | string; label?: string; }
export interface UIMessageSource { kind: "cron" | "local_trigger" | "trigger" | string; label?: string; }
export interface UIMessage {
id: string;
@@ -104,9 +104,9 @@ export interface SessionAutomationJob {
delete_after_run?: boolean;
created_at_ms?: number | null;
updated_at_ms?: number | null;
kind?: "external_trigger" | "cron" | string;
kind?: "local_trigger" | "cron" | string;
schedule: {
kind: "at" | "every" | "cron" | "external" | string;
kind: "at" | "every" | "cron" | "local" | string;
at_ms?: number | null;
every_ms?: number | null;
expr?: string | null;
@@ -114,7 +114,7 @@ export interface SessionAutomationJob {
};
payload: {
message: string;
kind?: "agent_turn" | "system_event" | "external_trigger" | string;
kind?: "agent_turn" | "system_event" | "local_trigger" | string;
command?: string;
};
state: {