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