feat(gateways): Introduce gateway model and update agent provisioning templates
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
"""Rename gateways to gateway.
|
||||
|
||||
Revision ID: 4b2a5e2dbb6e
|
||||
Revises: c1c8b3b9f4d1
|
||||
Create Date: 2026-02-04 18:20:00.000000
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
revision = "4b2a5e2dbb6e"
|
||||
down_revision = "c1c8b3b9f4d1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
tables = set(inspector.get_table_names())
|
||||
|
||||
if "gateway_configs" in tables and "gateways" not in tables:
|
||||
op.rename_table("gateway_configs", "gateways")
|
||||
tables.discard("gateway_configs")
|
||||
tables.add("gateways")
|
||||
|
||||
if "boards" in tables:
|
||||
columns = {col["name"] for col in inspector.get_columns("boards")}
|
||||
with op.batch_alter_table("boards") as batch:
|
||||
if "gateway_config_id" in columns and "gateway_id" not in columns:
|
||||
batch.alter_column(
|
||||
"gateway_config_id",
|
||||
new_column_name="gateway_id",
|
||||
existing_type=sa.Uuid(),
|
||||
)
|
||||
elif "gateway_id" not in columns:
|
||||
batch.add_column(sa.Column("gateway_id", sa.Uuid(), nullable=True))
|
||||
for legacy_col in (
|
||||
"gateway_url",
|
||||
"gateway_token",
|
||||
"gateway_main_session_key",
|
||||
"gateway_workspace_root",
|
||||
):
|
||||
if legacy_col in columns:
|
||||
batch.drop_column(legacy_col)
|
||||
|
||||
indexes = {index["name"] for index in inspector.get_indexes("boards")}
|
||||
if "ix_boards_gateway_id" not in indexes:
|
||||
op.create_index(
|
||||
op.f("ix_boards_gateway_id"), "boards", ["gateway_id"], unique=False
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
tables = set(inspector.get_table_names())
|
||||
|
||||
if "boards" in tables:
|
||||
columns = {col["name"] for col in inspector.get_columns("boards")}
|
||||
with op.batch_alter_table("boards") as batch:
|
||||
if "gateway_id" in columns and "gateway_config_id" not in columns:
|
||||
batch.alter_column(
|
||||
"gateway_id",
|
||||
new_column_name="gateway_config_id",
|
||||
existing_type=sa.Uuid(),
|
||||
)
|
||||
if "gateway_url" not in columns:
|
||||
batch.add_column(
|
||||
sa.Column("gateway_url", sqlmodel.sql.sqltypes.AutoString(), nullable=True)
|
||||
)
|
||||
if "gateway_token" not in columns:
|
||||
batch.add_column(
|
||||
sa.Column("gateway_token", sqlmodel.sql.sqltypes.AutoString(), nullable=True)
|
||||
)
|
||||
if "gateway_main_session_key" not in columns:
|
||||
batch.add_column(
|
||||
sa.Column(
|
||||
"gateway_main_session_key",
|
||||
sqlmodel.sql.sqltypes.AutoString(),
|
||||
nullable=True,
|
||||
)
|
||||
)
|
||||
if "gateway_workspace_root" not in columns:
|
||||
batch.add_column(
|
||||
sa.Column(
|
||||
"gateway_workspace_root",
|
||||
sqlmodel.sql.sqltypes.AutoString(),
|
||||
nullable=True,
|
||||
)
|
||||
)
|
||||
|
||||
if "gateways" in tables and "gateway_configs" not in tables:
|
||||
op.rename_table("gateways", "gateway_configs")
|
||||
@@ -21,20 +21,29 @@ depends_on = None
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('boards',
|
||||
op.create_table(
|
||||
'gateway_configs',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('gateway_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('gateway_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('gateway_main_session_key', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('gateway_workspace_root', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('identity_template', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('soul_template', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('url', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('token', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('main_session_key', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('workspace_root', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('boards',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('gateway_config_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['gateway_config_id'], ['gateway_configs.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_boards_gateway_config_id'), 'boards', ['gateway_config_id'], unique=False)
|
||||
op.create_index(op.f('ix_boards_slug'), 'boards', ['slug'], unique=False)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Add gateway skyll flag and agent templates.
|
||||
|
||||
Revision ID: c1c8b3b9f4d1
|
||||
Revises: 939a1d2dc607
|
||||
Create Date: 2026-02-04 22:18:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "c1c8b3b9f4d1"
|
||||
down_revision = "939a1d2dc607"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
tables = set(inspector.get_table_names())
|
||||
created_gateways = False
|
||||
if "gateways" not in tables and "gateway_configs" not in tables:
|
||||
op.create_table(
|
||||
"gateways",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("url", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("token", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column(
|
||||
"main_session_key", sqlmodel.sql.sqltypes.AutoString(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"workspace_root", sqlmodel.sql.sqltypes.AutoString(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"skyll_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
tables.add("gateways")
|
||||
created_gateways = True
|
||||
if "gateways" in tables and not created_gateways:
|
||||
existing_columns = {
|
||||
column["name"] for column in inspector.get_columns("gateways")
|
||||
}
|
||||
if "skyll_enabled" in existing_columns:
|
||||
pass
|
||||
else:
|
||||
op.add_column(
|
||||
"gateways",
|
||||
sa.Column(
|
||||
"skyll_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
op.alter_column("gateways", "skyll_enabled", server_default=None)
|
||||
elif "gateways" in tables and created_gateways:
|
||||
op.alter_column("gateways", "skyll_enabled", server_default=None)
|
||||
elif "gateway_configs" in tables:
|
||||
existing_columns = {
|
||||
column["name"] for column in inspector.get_columns("gateway_configs")
|
||||
}
|
||||
if "skyll_enabled" in existing_columns:
|
||||
pass
|
||||
else:
|
||||
op.add_column(
|
||||
"gateway_configs",
|
||||
sa.Column(
|
||||
"skyll_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
op.alter_column("gateway_configs", "skyll_enabled", server_default=None)
|
||||
op.add_column(
|
||||
"agents",
|
||||
sa.Column("identity_template", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"agents",
|
||||
sa.Column("soul_template", sa.Text(), nullable=True),
|
||||
)
|
||||
def downgrade() -> None:
|
||||
op.drop_column("agents", "soul_template")
|
||||
op.drop_column("agents", "identity_template")
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
tables = set(inspector.get_table_names())
|
||||
if "gateways" in tables:
|
||||
op.drop_column("gateways", "skyll_enabled")
|
||||
elif "gateway_configs" in tables:
|
||||
op.drop_column("gateway_configs", "skyll_enabled")
|
||||
@@ -14,7 +14,7 @@ from app.core.auth import AuthContext
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_session
|
||||
from app.integrations.openclaw_gateway import (
|
||||
GatewayConfig,
|
||||
GatewayConfig as GatewayClientConfig,
|
||||
OpenClawGatewayError,
|
||||
ensure_session,
|
||||
send_message,
|
||||
@@ -22,6 +22,7 @@ from app.integrations.openclaw_gateway import (
|
||||
from app.models.agents import Agent
|
||||
from app.models.activity_events import ActivityEvent
|
||||
from app.models.boards import Board
|
||||
from app.models.gateways import Gateway
|
||||
from app.schemas.agents import (
|
||||
AgentCreate,
|
||||
AgentDeleteConfirm,
|
||||
@@ -57,7 +58,7 @@ def _workspace_path(agent_name: str, workspace_root: str | None) -> str:
|
||||
if not workspace_root:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Board gateway_workspace_root is required",
|
||||
detail="Gateway workspace_root is required",
|
||||
)
|
||||
root = workspace_root.rstrip("/")
|
||||
return f"{root}/workspace-{_slugify(agent_name)}"
|
||||
@@ -75,28 +76,41 @@ def _require_board(session: Session, board_id: UUID | str | None) -> Board:
|
||||
return board
|
||||
|
||||
|
||||
def _require_gateway_config(board: Board) -> GatewayConfig:
|
||||
if not board.gateway_url:
|
||||
def _require_gateway(
|
||||
session: Session, board: Board
|
||||
) -> tuple[Gateway, GatewayClientConfig]:
|
||||
if not board.gateway_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Board gateway_url is required",
|
||||
detail="Board gateway_id is required",
|
||||
)
|
||||
if not board.gateway_main_session_key:
|
||||
gateway = session.get(Gateway, board.gateway_id)
|
||||
if gateway is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Board gateway_main_session_key is required",
|
||||
detail="Board gateway_id is invalid",
|
||||
)
|
||||
if not board.gateway_workspace_root:
|
||||
if not gateway.main_session_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Board gateway_workspace_root is required",
|
||||
detail="Gateway main_session_key is required",
|
||||
)
|
||||
return GatewayConfig(url=board.gateway_url, token=board.gateway_token)
|
||||
if not gateway.url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Gateway url is required",
|
||||
)
|
||||
if not gateway.workspace_root:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Gateway workspace_root is required",
|
||||
)
|
||||
return gateway, GatewayClientConfig(url=gateway.url, token=gateway.token)
|
||||
|
||||
|
||||
async def _ensure_gateway_session(
|
||||
agent_name: str,
|
||||
config: GatewayConfig,
|
||||
config: GatewayClientConfig,
|
||||
) -> tuple[str, str | None]:
|
||||
session_key = _build_session_key(agent_name)
|
||||
try:
|
||||
@@ -148,7 +162,7 @@ def _record_wakeup_failure(session: Session, agent: Agent, error: str) -> None:
|
||||
|
||||
|
||||
async def _send_wakeup_message(
|
||||
agent: Agent, config: GatewayConfig, verb: str = "provisioned"
|
||||
agent: Agent, config: GatewayClientConfig, verb: str = "provisioned"
|
||||
) -> None:
|
||||
session_key = agent.openclaw_session_id or _build_session_key(agent.name)
|
||||
await ensure_session(session_key, config=config, label=agent.name)
|
||||
@@ -176,8 +190,13 @@ async def create_agent(
|
||||
auth: AuthContext = Depends(require_admin_auth),
|
||||
) -> Agent:
|
||||
board = _require_board(session, payload.board_id)
|
||||
config = _require_gateway_config(board)
|
||||
agent = Agent.model_validate(payload)
|
||||
gateway, client_config = _require_gateway(session, board)
|
||||
data = payload.model_dump()
|
||||
if data.get("identity_template") == "":
|
||||
data["identity_template"] = None
|
||||
if data.get("soul_template") == "":
|
||||
data["soul_template"] = None
|
||||
agent = Agent.model_validate(data)
|
||||
agent.status = "provisioning"
|
||||
raw_token = generate_agent_token()
|
||||
agent.agent_token_hash = hash_agent_token(raw_token)
|
||||
@@ -187,7 +206,9 @@ async def create_agent(
|
||||
agent.provision_confirm_token_hash = hash_agent_token(provision_token)
|
||||
agent.provision_requested_at = datetime.utcnow()
|
||||
agent.provision_action = "provision"
|
||||
session_key, session_error = await _ensure_gateway_session(agent.name, config)
|
||||
session_key, session_error = await _ensure_gateway_session(
|
||||
agent.name, client_config
|
||||
)
|
||||
agent.openclaw_session_id = session_key
|
||||
session.add(agent)
|
||||
session.commit()
|
||||
@@ -209,7 +230,7 @@ async def create_agent(
|
||||
session.commit()
|
||||
try:
|
||||
await send_provisioning_message(
|
||||
agent, board, raw_token, provision_token, auth.user
|
||||
agent, board, gateway, raw_token, provision_token, auth.user
|
||||
)
|
||||
record_activity(
|
||||
session,
|
||||
@@ -254,6 +275,10 @@ async def update_agent(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="status is controlled by agent heartbeat",
|
||||
)
|
||||
if updates.get("identity_template") == "":
|
||||
updates["identity_template"] = None
|
||||
if updates.get("soul_template") == "":
|
||||
updates["soul_template"] = None
|
||||
if not updates:
|
||||
return _with_computed_status(agent)
|
||||
if "board_id" in updates:
|
||||
@@ -267,10 +292,10 @@ async def update_agent(
|
||||
session.commit()
|
||||
session.refresh(agent)
|
||||
board = _require_board(session, agent.board_id)
|
||||
config = _require_gateway_config(board)
|
||||
gateway, client_config = _require_gateway(session, board)
|
||||
session_key = agent.openclaw_session_id or _build_session_key(agent.name)
|
||||
try:
|
||||
await ensure_session(session_key, config=config, label=agent.name)
|
||||
await ensure_session(session_key, config=client_config, label=agent.name)
|
||||
if not agent.openclaw_session_id:
|
||||
agent.openclaw_session_id = session_key
|
||||
session.add(agent)
|
||||
@@ -291,7 +316,7 @@ async def update_agent(
|
||||
session.refresh(agent)
|
||||
try:
|
||||
await send_update_message(
|
||||
agent, board, raw_token, provision_token, auth.user
|
||||
agent, board, gateway, raw_token, provision_token, auth.user
|
||||
)
|
||||
record_activity(
|
||||
session,
|
||||
@@ -345,7 +370,7 @@ async def heartbeat_or_create_agent(
|
||||
if actor.actor_type == "agent":
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
board = _require_board(session, payload.board_id)
|
||||
config = _require_gateway_config(board)
|
||||
gateway, client_config = _require_gateway(session, board)
|
||||
agent = Agent(
|
||||
name=payload.name,
|
||||
status="provisioning",
|
||||
@@ -358,7 +383,9 @@ async def heartbeat_or_create_agent(
|
||||
agent.provision_confirm_token_hash = hash_agent_token(provision_token)
|
||||
agent.provision_requested_at = datetime.utcnow()
|
||||
agent.provision_action = "provision"
|
||||
session_key, session_error = await _ensure_gateway_session(agent.name, config)
|
||||
session_key, session_error = await _ensure_gateway_session(
|
||||
agent.name, client_config
|
||||
)
|
||||
agent.openclaw_session_id = session_key
|
||||
session.add(agent)
|
||||
session.commit()
|
||||
@@ -380,7 +407,7 @@ async def heartbeat_or_create_agent(
|
||||
session.commit()
|
||||
try:
|
||||
await send_provisioning_message(
|
||||
agent, board, raw_token, provision_token, actor.user
|
||||
agent, board, gateway, raw_token, provision_token, actor.user
|
||||
)
|
||||
record_activity(
|
||||
session,
|
||||
@@ -410,9 +437,9 @@ async def heartbeat_or_create_agent(
|
||||
session.refresh(agent)
|
||||
try:
|
||||
board = _require_board(session, str(agent.board_id) if agent.board_id else None)
|
||||
config = _require_gateway_config(board)
|
||||
gateway, client_config = _require_gateway(session, board)
|
||||
await send_provisioning_message(
|
||||
agent, board, raw_token, provision_token, actor.user
|
||||
agent, board, gateway, raw_token, provision_token, actor.user
|
||||
)
|
||||
record_activity(
|
||||
session,
|
||||
@@ -428,8 +455,10 @@ async def heartbeat_or_create_agent(
|
||||
session.commit()
|
||||
elif not agent.openclaw_session_id:
|
||||
board = _require_board(session, str(agent.board_id) if agent.board_id else None)
|
||||
config = _require_gateway_config(board)
|
||||
session_key, session_error = await _ensure_gateway_session(agent.name, config)
|
||||
gateway, client_config = _require_gateway(session, board)
|
||||
session_key, session_error = await _ensure_gateway_session(
|
||||
agent.name, client_config
|
||||
)
|
||||
agent.openclaw_session_id = session_key
|
||||
if session_error:
|
||||
record_activity(
|
||||
@@ -472,7 +501,7 @@ def delete_agent(
|
||||
return {"ok": True}
|
||||
|
||||
board = _require_board(session, str(agent.board_id) if agent.board_id else None)
|
||||
config = _require_gateway_config(board)
|
||||
gateway, client_config = _require_gateway(session, board)
|
||||
raw_token = generate_agent_token()
|
||||
agent.delete_confirm_token_hash = hash_agent_token(raw_token)
|
||||
agent.delete_requested_at = datetime.utcnow()
|
||||
@@ -488,10 +517,10 @@ def delete_agent(
|
||||
session.commit()
|
||||
|
||||
async def _gateway_cleanup_request() -> None:
|
||||
main_session = board.gateway_main_session_key
|
||||
main_session = gateway.main_session_key
|
||||
if not main_session:
|
||||
raise OpenClawGatewayError("Board gateway_main_session_key is required")
|
||||
workspace_path = _workspace_path(agent.name, board.gateway_workspace_root)
|
||||
raise OpenClawGatewayError("Gateway main_session_key is required")
|
||||
workspace_path = _workspace_path(agent.name, gateway.workspace_root)
|
||||
base_url = settings.base_url or "REPLACE_WITH_BASE_URL"
|
||||
cleanup_message = (
|
||||
"Cleanup request for deleted agent.\n\n"
|
||||
@@ -507,11 +536,11 @@ def delete_agent(
|
||||
" Body: {\"token\": \"" + raw_token + "\"}\n"
|
||||
"Reply NO_REPLY."
|
||||
)
|
||||
await ensure_session(main_session, config=config, label="Main Agent")
|
||||
await ensure_session(main_session, config=client_config, label="Main Agent")
|
||||
await send_message(
|
||||
cleanup_message,
|
||||
session_key=main_session,
|
||||
config=config,
|
||||
config=client_config,
|
||||
deliver=False,
|
||||
)
|
||||
|
||||
@@ -549,7 +578,7 @@ def confirm_provision_agent(
|
||||
if agent.board_id is None:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
board = _require_board(session, str(agent.board_id))
|
||||
config = _require_gateway_config(board)
|
||||
_, client_config = _require_gateway(session, board)
|
||||
|
||||
action = payload.action or agent.provision_action or "provision"
|
||||
verb = "updated" if action == "update" else "provisioned"
|
||||
@@ -557,7 +586,7 @@ def confirm_provision_agent(
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
asyncio.run(_send_wakeup_message(agent, config, verb=verb))
|
||||
asyncio.run(_send_wakeup_message(agent, client_config, verb=verb))
|
||||
except OpenClawGatewayError as exc:
|
||||
_record_wakeup_failure(session, agent, str(exc))
|
||||
session.commit()
|
||||
|
||||
@@ -17,7 +17,7 @@ from app.api.deps import (
|
||||
from app.core.auth import AuthContext
|
||||
from app.db.session import get_session
|
||||
from app.integrations.openclaw_gateway import (
|
||||
GatewayConfig,
|
||||
GatewayConfig as GatewayClientConfig,
|
||||
OpenClawGatewayError,
|
||||
delete_session,
|
||||
ensure_session,
|
||||
@@ -26,6 +26,7 @@ from app.integrations.openclaw_gateway import (
|
||||
from app.models.activity_events import ActivityEvent
|
||||
from app.models.agents import Agent
|
||||
from app.models.boards import Board
|
||||
from app.models.gateways import Gateway
|
||||
from app.models.tasks import Task
|
||||
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
|
||||
|
||||
@@ -43,31 +44,44 @@ def _build_session_key(agent_name: str) -> str:
|
||||
return f"{AGENT_SESSION_PREFIX}:{_slugify(agent_name)}:main"
|
||||
|
||||
|
||||
def _board_gateway_config(board: Board) -> GatewayConfig | None:
|
||||
if not board.gateway_url:
|
||||
return None
|
||||
if not board.gateway_main_session_key:
|
||||
def _board_gateway(
|
||||
session: Session, board: Board
|
||||
) -> tuple[Gateway | None, GatewayClientConfig | None]:
|
||||
if not board.gateway_id:
|
||||
return None, None
|
||||
config = session.get(Gateway, board.gateway_id)
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Board gateway_main_session_key is required",
|
||||
detail="Board gateway_id is invalid",
|
||||
)
|
||||
if not board.gateway_workspace_root:
|
||||
if not config.main_session_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Board gateway_workspace_root is required",
|
||||
detail="Gateway main_session_key is required",
|
||||
)
|
||||
return GatewayConfig(url=board.gateway_url, token=board.gateway_token)
|
||||
if not config.url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Gateway url is required",
|
||||
)
|
||||
if not config.workspace_root:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Gateway workspace_root is required",
|
||||
)
|
||||
return config, GatewayClientConfig(url=config.url, token=config.token)
|
||||
|
||||
|
||||
async def _cleanup_agent_on_gateway(agent: Agent, board: Board, config: GatewayConfig) -> None:
|
||||
async def _cleanup_agent_on_gateway(
|
||||
agent: Agent,
|
||||
config: Gateway,
|
||||
client_config: GatewayClientConfig,
|
||||
) -> None:
|
||||
if agent.openclaw_session_id:
|
||||
await delete_session(agent.openclaw_session_id, config=config)
|
||||
if not board.gateway_main_session_key:
|
||||
raise OpenClawGatewayError("Board gateway_main_session_key is required")
|
||||
if not board.gateway_workspace_root:
|
||||
raise OpenClawGatewayError("Board gateway_workspace_root is required")
|
||||
main_session = board.gateway_main_session_key
|
||||
workspace_root = board.gateway_workspace_root
|
||||
await delete_session(agent.openclaw_session_id, config=client_config)
|
||||
main_session = config.main_session_key
|
||||
workspace_root = config.workspace_root
|
||||
workspace_path = f"{workspace_root.rstrip('/')}/workspace-{_slugify(agent.name)}"
|
||||
cleanup_message = (
|
||||
"Cleanup request for deleted agent.\n\n"
|
||||
@@ -80,8 +94,13 @@ async def _cleanup_agent_on_gateway(agent: Agent, board: Board, config: GatewayC
|
||||
"2) Delete any lingering session artifacts.\n"
|
||||
"Reply NO_REPLY."
|
||||
)
|
||||
await ensure_session(main_session, config=config, label="Main Agent")
|
||||
await send_message(cleanup_message, session_key=main_session, config=config, deliver=False)
|
||||
await ensure_session(main_session, config=client_config, label="Main Agent")
|
||||
await send_message(
|
||||
cleanup_message,
|
||||
session_key=main_session,
|
||||
config=client_config,
|
||||
deliver=False,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[BoardRead])
|
||||
@@ -99,23 +118,17 @@ def create_board(
|
||||
auth: AuthContext = Depends(require_admin_auth),
|
||||
) -> Board:
|
||||
data = payload.model_dump()
|
||||
if data.get("gateway_token") == "":
|
||||
data["gateway_token"] = None
|
||||
if data.get("identity_template") == "":
|
||||
data["identity_template"] = None
|
||||
if data.get("soul_template") == "":
|
||||
data["soul_template"] = None
|
||||
if data.get("gateway_url"):
|
||||
if not data.get("gateway_main_session_key"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="gateway_main_session_key is required when gateway_url is set",
|
||||
)
|
||||
if not data.get("gateway_workspace_root"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="gateway_workspace_root is required when gateway_url is set",
|
||||
)
|
||||
if not data.get("gateway_id"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="gateway_id is required",
|
||||
)
|
||||
config = session.get(Gateway, data["gateway_id"])
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="gateway_id is invalid",
|
||||
)
|
||||
board = Board.model_validate(data)
|
||||
session.add(board)
|
||||
session.commit()
|
||||
@@ -139,25 +152,25 @@ def update_board(
|
||||
auth: AuthContext = Depends(require_admin_auth),
|
||||
) -> Board:
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
if updates.get("gateway_token") == "":
|
||||
updates["gateway_token"] = None
|
||||
if updates.get("identity_template") == "":
|
||||
updates["identity_template"] = None
|
||||
if updates.get("soul_template") == "":
|
||||
updates["soul_template"] = None
|
||||
if "gateway_id" in updates:
|
||||
if not updates.get("gateway_id"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="gateway_id is required",
|
||||
)
|
||||
config = session.get(Gateway, updates["gateway_id"])
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="gateway_id is invalid",
|
||||
)
|
||||
for key, value in updates.items():
|
||||
setattr(board, key, value)
|
||||
if board.gateway_url:
|
||||
if not board.gateway_main_session_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="gateway_main_session_key is required when gateway_url is set",
|
||||
)
|
||||
if not board.gateway_workspace_root:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="gateway_workspace_root is required when gateway_url is set",
|
||||
)
|
||||
if not board.gateway_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="gateway_id is required",
|
||||
)
|
||||
session.add(board)
|
||||
session.commit()
|
||||
session.refresh(board)
|
||||
@@ -175,11 +188,11 @@ def delete_board(
|
||||
session.exec(select(Task.id).where(Task.board_id == board.id))
|
||||
)
|
||||
|
||||
config = _board_gateway_config(board)
|
||||
if config:
|
||||
config, client_config = _board_gateway(session, board)
|
||||
if config and client_config:
|
||||
try:
|
||||
for agent in agents:
|
||||
asyncio.run(_cleanup_agent_on_gateway(agent, board, config))
|
||||
asyncio.run(_cleanup_agent_on_gateway(agent, config, client_config))
|
||||
except OpenClawGatewayError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
|
||||
@@ -3,10 +3,9 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.api.deps import require_admin_auth
|
||||
from app.core.auth import AuthContext
|
||||
from app.core.auth import AuthContext, get_auth_context
|
||||
from app.integrations.openclaw_gateway import (
|
||||
GatewayConfig,
|
||||
GatewayConfig as GatewayClientConfig,
|
||||
OpenClawGatewayError,
|
||||
ensure_session,
|
||||
get_chat_history,
|
||||
@@ -20,19 +19,24 @@ from app.integrations.openclaw_gateway_protocol import (
|
||||
)
|
||||
from app.db.session import get_session
|
||||
from app.models.boards import Board
|
||||
from app.models.gateways import Gateway
|
||||
|
||||
router = APIRouter(prefix="/gateway", tags=["gateway"])
|
||||
router = APIRouter(prefix="/gateways", tags=["gateways"])
|
||||
|
||||
|
||||
def _resolve_gateway_config(
|
||||
def _resolve_gateway(
|
||||
session: Session,
|
||||
board_id: str | None,
|
||||
gateway_url: str | None,
|
||||
gateway_token: str | None,
|
||||
gateway_main_session_key: str | None,
|
||||
) -> tuple[Board | None, GatewayConfig, str | None]:
|
||||
) -> tuple[Board | None, GatewayClientConfig, str | None]:
|
||||
if gateway_url:
|
||||
return None, GatewayConfig(url=gateway_url, token=gateway_token), gateway_main_session_key
|
||||
return (
|
||||
None,
|
||||
GatewayClientConfig(url=gateway_url, token=gateway_token),
|
||||
gateway_main_session_key,
|
||||
)
|
||||
if not board_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
@@ -41,24 +45,53 @@ def _resolve_gateway_config(
|
||||
board = session.get(Board, board_id)
|
||||
if board is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
|
||||
if not board.gateway_url:
|
||||
if not board.gateway_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Board gateway_url is required",
|
||||
detail="Board gateway_id is required",
|
||||
)
|
||||
return board, GatewayConfig(url=board.gateway_url, token=board.gateway_token), board.gateway_main_session_key
|
||||
gateway = session.get(Gateway, board.gateway_id)
|
||||
if gateway is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Board gateway_id is invalid",
|
||||
)
|
||||
if not gateway.url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Gateway url is required",
|
||||
)
|
||||
return (
|
||||
board,
|
||||
GatewayClientConfig(url=gateway.url, token=gateway.token),
|
||||
gateway.main_session_key,
|
||||
)
|
||||
|
||||
|
||||
def _require_gateway(
|
||||
session: Session, board_id: str | None
|
||||
) -> tuple[Board, GatewayClientConfig, str | None]:
|
||||
board, config, main_session = _resolve_gateway(
|
||||
session, board_id, None, None, None
|
||||
)
|
||||
if board is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="board_id is required",
|
||||
)
|
||||
return board, config, main_session
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def gateway_status(
|
||||
async def gateways_status(
|
||||
board_id: str | None = Query(default=None),
|
||||
gateway_url: str | None = Query(default=None),
|
||||
gateway_token: str | None = Query(default=None),
|
||||
gateway_main_session_key: str | None = Query(default=None),
|
||||
session: Session = Depends(get_session),
|
||||
auth: AuthContext = Depends(require_admin_auth),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> dict[str, object]:
|
||||
board, config, main_session = _resolve_gateway_config(
|
||||
board, config, main_session = _resolve_gateway(
|
||||
session,
|
||||
board_id,
|
||||
gateway_url,
|
||||
@@ -100,12 +133,12 @@ async def gateway_status(
|
||||
|
||||
|
||||
@router.get("/sessions")
|
||||
async def list_sessions(
|
||||
async def list_gateway_sessions(
|
||||
board_id: str | None = Query(default=None),
|
||||
session: Session = Depends(get_session),
|
||||
auth: AuthContext = Depends(require_admin_auth),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> dict[str, object]:
|
||||
board, config, main_session = _resolve_gateway_config(
|
||||
board, config, main_session = _resolve_gateway(
|
||||
session,
|
||||
board_id,
|
||||
None,
|
||||
@@ -144,9 +177,9 @@ async def get_gateway_session(
|
||||
session_id: str,
|
||||
board_id: str | None = Query(default=None),
|
||||
session: Session = Depends(get_session),
|
||||
auth: AuthContext = Depends(require_admin_auth),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> dict[str, object]:
|
||||
board, config, main_session = _resolve_gateway_config(
|
||||
board, config, main_session = _resolve_gateway(
|
||||
session,
|
||||
board_id,
|
||||
None,
|
||||
@@ -161,7 +194,6 @@ async def get_gateway_session(
|
||||
sessions_list = list(sessions.get("sessions") or [])
|
||||
else:
|
||||
sessions_list = list(sessions or [])
|
||||
main_session = board.gateway_main_session_key
|
||||
if main_session and not any(
|
||||
session.get("key") == main_session for session in sessions_list
|
||||
):
|
||||
@@ -194,9 +226,9 @@ async def get_session_history(
|
||||
session_id: str,
|
||||
board_id: str | None = Query(default=None),
|
||||
session: Session = Depends(get_session),
|
||||
auth: AuthContext = Depends(require_admin_auth),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> dict[str, object]:
|
||||
_, config = _require_board_config(session, board_id)
|
||||
_, config, _ = _require_gateway(session, board_id)
|
||||
try:
|
||||
history = await get_chat_history(session_id, config=config)
|
||||
except OpenClawGatewayError as exc:
|
||||
@@ -207,21 +239,20 @@ async def get_session_history(
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/message")
|
||||
async def send_session_message(
|
||||
async def send_gateway_session_message(
|
||||
session_id: str,
|
||||
payload: dict = Body(...),
|
||||
board_id: str | None = Query(default=None),
|
||||
session: Session = Depends(get_session),
|
||||
auth: AuthContext = Depends(require_admin_auth),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> dict[str, bool]:
|
||||
content = payload.get("content")
|
||||
if not content:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="content is required"
|
||||
)
|
||||
board, config = _require_board_config(session, board_id)
|
||||
board, config, main_session = _require_gateway(session, board_id)
|
||||
try:
|
||||
main_session = board.gateway_main_session_key
|
||||
if main_session and session_id == main_session:
|
||||
await ensure_session(main_session, config=config, label="Main Agent")
|
||||
await send_message(content, session_key=session_id, config=config)
|
||||
@@ -232,7 +263,7 @@ async def send_session_message(
|
||||
|
||||
@router.get("/commands")
|
||||
async def gateway_commands(
|
||||
auth: AuthContext = Depends(require_admin_auth),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
|
||||
323
backend/app/api/gateways.py
Normal file
323
backend/app/api/gateways.py
Normal file
@@ -0,0 +1,323 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.auth import AuthContext, get_auth_context
|
||||
from app.db.session import get_session
|
||||
from app.integrations.openclaw_gateway import (
|
||||
GatewayConfig as GatewayClientConfig,
|
||||
OpenClawGatewayError,
|
||||
ensure_session,
|
||||
send_message,
|
||||
)
|
||||
from app.models.gateways import Gateway
|
||||
from app.schemas.gateways import GatewayCreate, GatewayRead, GatewayUpdate
|
||||
|
||||
router = APIRouter(prefix="/gateways", tags=["gateways"])
|
||||
|
||||
SKYLL_ENABLE_MESSAGE = """
|
||||
To re-enable this “dynamic Skyll installs” capability in the future, you just need to restore the skyll broker skill folder into OpenClaw’s shared skills directory.
|
||||
|
||||
Exact steps (copy/paste)
|
||||
0) Overwrite any existing skyll install
|
||||
rm -rf ~/.openclaw/skills/skyll
|
||||
|
||||
1) Put the skyll skill in the shared skills dir
|
||||
mkdir -p ~/.openclaw/skills
|
||||
Create the folder:
|
||||
|
||||
mkdir -p ~/.openclaw/skills/skyll/scripts
|
||||
2) Create ~/.openclaw/skills/skyll/SKILL.md
|
||||
cat > ~/.openclaw/skills/skyll/SKILL.md <<'EOF'
|
||||
---
|
||||
name: skyll
|
||||
description: Dynamically discover and install AgentSkills from the Skyll (skills.sh) ecosystem using api.skyll.app. Use when the user requests a capability that is missing from the currently installed skills, or when you need a specialized workflow/tool integration and want to fetch a high-quality SKILL.md on demand.
|
||||
---
|
||||
|
||||
# Skyll skill broker (dynamic skill install)
|
||||
|
||||
This skill helps you discover and materialize third-party AgentSkills into OpenClaw skills folders so they become available to the agent.
|
||||
|
||||
## Safety model (important)
|
||||
|
||||
Skills fetched from Skyll are untrusted content.
|
||||
|
||||
Rules:
|
||||
- Prefer installing into the shared skills dir (~/.openclaw/skills/<skill-id>/) so other agents can discover it automatically.
|
||||
- If you want per-agent isolation, install into that agent’s workspace skills/ instead.
|
||||
- Default to confirm-before-write unless the user explicitly opts into auto-install.
|
||||
- Before using a newly-installed skill, skim its SKILL.md to ensure it’s relevant and does not instruct dangerous actions.
|
||||
- Do not run arbitrary scripts downloaded with a skill unless you understand them and the user asked you to.
|
||||
|
||||
## Procedure
|
||||
|
||||
1) Search:
|
||||
node {baseDir}/scripts/skyll_install.js --query "..." --limit 8 --dry-run
|
||||
|
||||
2) Install (pick 1 result):
|
||||
node {baseDir}/scripts/skyll_install.js --query "..." --pick 1
|
||||
|
||||
3) Refresh:
|
||||
- If it doesn’t show up immediately, start a new session (or wait for the skills watcher).
|
||||
|
||||
Notes:
|
||||
- Default install location is ~/.openclaw/skills/<id>/ (shared across agents on this host).
|
||||
- Use the script --out-dir {workspace}/skills for per-agent installs.
|
||||
EOF
|
||||
3) Create ~/.openclaw/skills/skyll/scripts/skyll_install.js
|
||||
cat > ~/.openclaw/skills/skyll/scripts/skyll_install.js <<'EOF'
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import process from "node:process";
|
||||
|
||||
const SKYLL_BASE = process.env.SKYLL_BASE_URL || "https://api.skyll.app";
|
||||
const DEFAULT_LIMIT = 8;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
query: null,
|
||||
limit: DEFAULT_LIMIT,
|
||||
pick: 1,
|
||||
includeReferences: false,
|
||||
includeRaw: true,
|
||||
includeContent: true,
|
||||
dryRun: false,
|
||||
outDir: null,
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--query") args.query = argv[++i];
|
||||
else if (a === "--limit") args.limit = Number(argv[++i]);
|
||||
else if (a === "--pick") args.pick = Number(argv[++i]);
|
||||
else if (a === "--include-references") args.includeReferences = true;
|
||||
else if (a === "--include-raw") args.includeRaw = true;
|
||||
else if (a === "--no-include-raw") args.includeRaw = false;
|
||||
else if (a === "--include-content") args.includeContent = true;
|
||||
else if (a === "--no-include-content") args.includeContent = false;
|
||||
else if (a === "--dry-run") args.dryRun = true;
|
||||
else if (a === "--out-dir") args.outDir = argv[++i];
|
||||
else if (a === "--help" || a === "-h") args.help = true;
|
||||
else throw new Error(`Unknown arg: ${a}`);
|
||||
}
|
||||
|
||||
if (args.help) return args;
|
||||
if (!args.query || !args.query.trim()) throw new Error("--query is required");
|
||||
if (!Number.isFinite(args.limit) || args.limit < 1 || args.limit > 50) throw new Error("--limit must be 1..50");
|
||||
if (!Number.isFinite(args.pick) || args.pick < 1) throw new Error("--pick must be >= 1");
|
||||
return args;
|
||||
}
|
||||
|
||||
async function postJson(url, body) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`HTTP ${res.status} from ${url}: ${text.slice(0, 500)}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function ensureDir(p) {
|
||||
await fs.mkdir(p, { recursive: true });
|
||||
}
|
||||
|
||||
async function writeFileSafe(filePath, content) {
|
||||
await ensureDir(path.dirname(filePath));
|
||||
await fs.writeFile(filePath, content, "utf8");
|
||||
}
|
||||
|
||||
function sanitizeSkillId(id) {
|
||||
return id.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 80);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
if (args.help) {
|
||||
console.log("Usage: skyll_install.js --query \"...\" [--dry-run] [--pick 1] [--out-dir PATH] [--include-references]");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const req = {
|
||||
query: args.query,
|
||||
limit: args.limit,
|
||||
include_content: args.includeContent,
|
||||
include_raw: args.includeRaw,
|
||||
include_references: args.includeReferences,
|
||||
};
|
||||
|
||||
const resp = await postJson(`${SKYLL_BASE}/search`, req);
|
||||
const skills = resp.skills || [];
|
||||
|
||||
if (!skills.length) {
|
||||
console.log(JSON.stringify({ query: resp.query, count: resp.count ?? 0, skills: [] }, null, 2));
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = skills.map((s, idx) => ({
|
||||
rank: idx + 1,
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
source: s.source,
|
||||
version: s.version ?? null,
|
||||
install_count: s.install_count ?? 0,
|
||||
allowed_tools: s.allowed_tools ?? null,
|
||||
description: s.description ?? null,
|
||||
refs: s.refs,
|
||||
fetch_error: s.fetch_error ?? null,
|
||||
}));
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log(JSON.stringify({ query: resp.query, count: resp.count ?? skills.length, skills: summary }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const pickIdx = args.pick - 1;
|
||||
if (pickIdx < 0 || pickIdx >= skills.length) throw new Error(`--pick ${args.pick} out of range (1..${skills.length})`);
|
||||
|
||||
const chosen = skills[pickIdx];
|
||||
const skillId = sanitizeSkillId(chosen.id);
|
||||
|
||||
const sharedDefault = path.join(os.homedir(), ".openclaw", "skills");
|
||||
const skillsRoot = args.outDir ? path.resolve(args.outDir) : sharedDefault;
|
||||
const destDir = path.join(skillsRoot, skillId);
|
||||
|
||||
const skillMd = chosen.raw_content || chosen.content;
|
||||
if (!skillMd) throw new Error("Chosen skill has no SKILL.md content (content/raw_content missing)");
|
||||
|
||||
await ensureDir(destDir);
|
||||
await writeFileSafe(path.join(destDir, "SKILL.md"), skillMd);
|
||||
|
||||
if (Array.isArray(chosen.references) && chosen.references.length) {
|
||||
for (const ref of chosen.references) {
|
||||
const rel = ref.path || ref.name || ref.filename;
|
||||
const content = ref.content;
|
||||
if (!rel || typeof content !== "string") continue;
|
||||
const safeRel = String(rel).replace(/^\\/+/, "");
|
||||
await writeFileSafe(path.join(destDir, safeRel), content);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ installed: true, query: resp.query, chosen: summary[pickIdx], destDir }, null, 2));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(String(err?.stack || err));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
EOF
|
||||
chmod +x ~/.openclaw/skills/skyll/scripts/skyll_install.js
|
||||
4) Verify OpenClaw sees it
|
||||
Start a new session (or restart gateway), then run:
|
||||
|
||||
openclaw skills list --eligible | grep -i skyll
|
||||
""".strip()
|
||||
|
||||
|
||||
async def _send_skyll_enable_message(gateway: Gateway) -> None:
|
||||
if not gateway.url:
|
||||
raise OpenClawGatewayError("Gateway url is required")
|
||||
if not gateway.main_session_key:
|
||||
raise OpenClawGatewayError("gateway main_session_key is required")
|
||||
client_config = GatewayClientConfig(url=gateway.url, token=gateway.token)
|
||||
await ensure_session(
|
||||
gateway.main_session_key, config=client_config, label="Main Agent"
|
||||
)
|
||||
await send_message(
|
||||
SKYLL_ENABLE_MESSAGE,
|
||||
session_key=gateway.main_session_key,
|
||||
config=client_config,
|
||||
deliver=False,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[GatewayRead])
|
||||
def list_gateways(
|
||||
session: Session = Depends(get_session),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> list[Gateway]:
|
||||
return list(session.exec(select(Gateway)))
|
||||
|
||||
|
||||
@router.post("", response_model=GatewayRead)
|
||||
async def create_gateway(
|
||||
payload: GatewayCreate,
|
||||
session: Session = Depends(get_session),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> Gateway:
|
||||
data = payload.model_dump()
|
||||
if data.get("token") == "":
|
||||
data["token"] = None
|
||||
gateway = Gateway.model_validate(data)
|
||||
session.add(gateway)
|
||||
session.commit()
|
||||
session.refresh(gateway)
|
||||
if gateway.skyll_enabled:
|
||||
try:
|
||||
await _send_skyll_enable_message(gateway)
|
||||
except OpenClawGatewayError:
|
||||
pass
|
||||
return gateway
|
||||
|
||||
|
||||
@router.get("/{gateway_id}", response_model=GatewayRead)
|
||||
def get_gateway(
|
||||
gateway_id: UUID,
|
||||
session: Session = Depends(get_session),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> Gateway:
|
||||
gateway = session.get(Gateway, gateway_id)
|
||||
if gateway is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
|
||||
return gateway
|
||||
|
||||
|
||||
@router.patch("/{gateway_id}", response_model=GatewayRead)
|
||||
async def update_gateway(
|
||||
gateway_id: UUID,
|
||||
payload: GatewayUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> Gateway:
|
||||
gateway = session.get(Gateway, gateway_id)
|
||||
if gateway is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
|
||||
previous_skyll_enabled = gateway.skyll_enabled
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
if updates.get("token") == "":
|
||||
updates["token"] = None
|
||||
for key, value in updates.items():
|
||||
setattr(gateway, key, value)
|
||||
session.add(gateway)
|
||||
session.commit()
|
||||
session.refresh(gateway)
|
||||
if not previous_skyll_enabled and gateway.skyll_enabled:
|
||||
try:
|
||||
await _send_skyll_enable_message(gateway)
|
||||
except OpenClawGatewayError:
|
||||
pass
|
||||
return gateway
|
||||
|
||||
|
||||
@router.delete("/{gateway_id}")
|
||||
def delete_gateway(
|
||||
gateway_id: UUID,
|
||||
session: Session = Depends(get_session),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> dict[str, bool]:
|
||||
gateway = session.get(Gateway, gateway_id)
|
||||
if gateway is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
|
||||
session.delete(gateway)
|
||||
session.commit()
|
||||
return {"ok": True}
|
||||
@@ -8,6 +8,7 @@ from app.api.agents import router as agents_router
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.boards import router as boards_router
|
||||
from app.api.gateway import router as gateway_router
|
||||
from app.api.gateways import router as gateways_router
|
||||
from app.api.metrics import router as metrics_router
|
||||
from app.api.tasks import router as tasks_router
|
||||
from app.api.users import router as users_router
|
||||
@@ -55,6 +56,7 @@ api_v1.include_router(auth_router)
|
||||
api_v1.include_router(agents_router)
|
||||
api_v1.include_router(activity_router)
|
||||
api_v1.include_router(gateway_router)
|
||||
api_v1.include_router(gateways_router)
|
||||
api_v1.include_router(metrics_router)
|
||||
api_v1.include_router(boards_router)
|
||||
api_v1.include_router(tasks_router)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from app.models.activity_events import ActivityEvent
|
||||
from app.models.agents import Agent
|
||||
from app.models.boards import Board
|
||||
from app.models.gateways import Gateway
|
||||
from app.models.tasks import Task
|
||||
from app.models.users import User
|
||||
|
||||
@@ -8,6 +9,7 @@ __all__ = [
|
||||
"ActivityEvent",
|
||||
"Agent",
|
||||
"Board",
|
||||
"Gateway",
|
||||
"Task",
|
||||
"User",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import Column, JSON
|
||||
from sqlalchemy import Column, JSON, Text
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ class Agent(SQLModel, table=True):
|
||||
heartbeat_config: dict[str, Any] | None = Field(
|
||||
default=None, sa_column=Column(JSON)
|
||||
)
|
||||
identity_template: str | None = Field(default=None, sa_column=Column(Text))
|
||||
soul_template: str | None = Field(default=None, sa_column=Column(Text))
|
||||
provision_requested_at: datetime | None = Field(default=None)
|
||||
provision_confirm_token_hash: str | None = Field(default=None, index=True)
|
||||
provision_action: str | None = Field(default=None, index=True)
|
||||
|
||||
@@ -14,11 +14,6 @@ class Board(TenantScoped, table=True):
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
name: str
|
||||
slug: str = Field(index=True)
|
||||
gateway_url: str | None = Field(default=None)
|
||||
gateway_token: str | None = Field(default=None)
|
||||
gateway_main_session_key: str | None = Field(default=None)
|
||||
gateway_workspace_root: str | None = Field(default=None)
|
||||
identity_template: str | None = Field(default=None)
|
||||
soul_template: str | None = Field(default=None)
|
||||
gateway_id: UUID | None = Field(default=None, foreign_key="gateways.id", index=True)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
20
backend/app/models/gateways.py
Normal file
20
backend/app/models/gateways.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class Gateway(SQLModel, table=True):
|
||||
__tablename__ = "gateways"
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
name: str
|
||||
url: str
|
||||
token: str | None = Field(default=None)
|
||||
main_session_key: str
|
||||
workspace_root: str
|
||||
skyll_enabled: bool = Field(default=False)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
@@ -1,6 +1,7 @@
|
||||
from app.schemas.activity_events import ActivityEventRead
|
||||
from app.schemas.agents import AgentCreate, AgentRead, AgentUpdate
|
||||
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
|
||||
from app.schemas.gateways import GatewayCreate, GatewayRead, GatewayUpdate
|
||||
from app.schemas.metrics import DashboardMetrics
|
||||
from app.schemas.tasks import TaskCreate, TaskRead, TaskUpdate
|
||||
from app.schemas.users import UserCreate, UserRead, UserUpdate
|
||||
@@ -13,6 +14,9 @@ __all__ = [
|
||||
"BoardCreate",
|
||||
"BoardRead",
|
||||
"BoardUpdate",
|
||||
"GatewayCreate",
|
||||
"GatewayRead",
|
||||
"GatewayUpdate",
|
||||
"DashboardMetrics",
|
||||
"TaskCreate",
|
||||
"TaskRead",
|
||||
|
||||
@@ -12,6 +12,8 @@ class AgentBase(SQLModel):
|
||||
name: str
|
||||
status: str = "provisioning"
|
||||
heartbeat_config: dict[str, Any] | None = None
|
||||
identity_template: str | None = None
|
||||
soul_template: str | None = None
|
||||
|
||||
|
||||
class AgentCreate(AgentBase):
|
||||
@@ -23,6 +25,8 @@ class AgentUpdate(SQLModel):
|
||||
name: str | None = None
|
||||
status: str | None = None
|
||||
heartbeat_config: dict[str, Any] | None = None
|
||||
identity_template: str | None = None
|
||||
soul_template: str | None = None
|
||||
|
||||
|
||||
class AgentRead(AgentBase):
|
||||
|
||||
@@ -9,26 +9,17 @@ from sqlmodel import SQLModel
|
||||
class BoardBase(SQLModel):
|
||||
name: str
|
||||
slug: str
|
||||
gateway_url: str | None = None
|
||||
gateway_main_session_key: str | None = None
|
||||
gateway_workspace_root: str | None = None
|
||||
identity_template: str | None = None
|
||||
soul_template: str | None = None
|
||||
gateway_id: UUID | None = None
|
||||
|
||||
|
||||
class BoardCreate(BoardBase):
|
||||
gateway_token: str | None = None
|
||||
pass
|
||||
|
||||
|
||||
class BoardUpdate(SQLModel):
|
||||
name: str | None = None
|
||||
slug: str | None = None
|
||||
gateway_url: str | None = None
|
||||
gateway_token: str | None = None
|
||||
gateway_main_session_key: str | None = None
|
||||
gateway_workspace_root: str | None = None
|
||||
identity_template: str | None = None
|
||||
soul_template: str | None = None
|
||||
gateway_id: UUID | None = None
|
||||
|
||||
|
||||
class BoardRead(BoardBase):
|
||||
|
||||
34
backend/app/schemas/gateways.py
Normal file
34
backend/app/schemas/gateways.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class GatewayBase(SQLModel):
|
||||
name: str
|
||||
url: str
|
||||
main_session_key: str
|
||||
workspace_root: str
|
||||
skyll_enabled: bool = False
|
||||
|
||||
|
||||
class GatewayCreate(GatewayBase):
|
||||
token: str | None = None
|
||||
|
||||
|
||||
class GatewayUpdate(SQLModel):
|
||||
name: str | None = None
|
||||
url: str | None = None
|
||||
token: str | None = None
|
||||
main_session_key: str | None = None
|
||||
workspace_root: str | None = None
|
||||
skyll_enabled: bool | None = None
|
||||
|
||||
|
||||
class GatewayRead(GatewayBase):
|
||||
id: UUID
|
||||
token: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -9,9 +9,14 @@ from uuid import uuid4
|
||||
from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
|
||||
|
||||
from app.core.config import settings
|
||||
from app.integrations.openclaw_gateway import GatewayConfig, ensure_session, send_message
|
||||
from app.integrations.openclaw_gateway import (
|
||||
GatewayConfig as GatewayClientConfig,
|
||||
ensure_session,
|
||||
send_message,
|
||||
)
|
||||
from app.models.agents import Agent
|
||||
from app.models.boards import Board
|
||||
from app.models.gateways import Gateway
|
||||
from app.models.users import User
|
||||
|
||||
TEMPLATE_FILES = [
|
||||
@@ -99,18 +104,22 @@ def _workspace_path(agent_name: str, workspace_root: str) -> str:
|
||||
|
||||
|
||||
def _build_context(
|
||||
agent: Agent, board: Board, auth_token: str, user: User | None
|
||||
agent: Agent,
|
||||
board: Board,
|
||||
gateway: Gateway,
|
||||
auth_token: str,
|
||||
user: User | None,
|
||||
) -> dict[str, str]:
|
||||
if not board.gateway_workspace_root:
|
||||
if not gateway.workspace_root:
|
||||
raise ValueError("gateway_workspace_root is required")
|
||||
if not board.gateway_main_session_key:
|
||||
if not gateway.main_session_key:
|
||||
raise ValueError("gateway_main_session_key is required")
|
||||
agent_id = str(agent.id)
|
||||
workspace_root = board.gateway_workspace_root
|
||||
workspace_root = gateway.workspace_root
|
||||
workspace_path = _workspace_path(agent.name, workspace_root)
|
||||
session_key = agent.openclaw_session_id or ""
|
||||
base_url = settings.base_url or "REPLACE_WITH_BASE_URL"
|
||||
main_session_key = board.gateway_main_session_key
|
||||
main_session_key = gateway.main_session_key
|
||||
return {
|
||||
"agent_name": agent.name,
|
||||
"agent_id": agent_id,
|
||||
@@ -130,12 +139,12 @@ def _build_context(
|
||||
}
|
||||
|
||||
|
||||
def _build_file_blocks(context: dict[str, str], board: Board) -> str:
|
||||
def _build_file_blocks(context: dict[str, str], agent: Agent) -> str:
|
||||
overrides: dict[str, str] = {}
|
||||
if board.identity_template:
|
||||
overrides["IDENTITY.md"] = board.identity_template
|
||||
if board.soul_template:
|
||||
overrides["SOUL.md"] = board.soul_template
|
||||
if agent.identity_template:
|
||||
overrides["IDENTITY.md"] = agent.identity_template
|
||||
if agent.soul_template:
|
||||
overrides["SOUL.md"] = agent.soul_template
|
||||
templates = _read_templates(context, overrides=overrides)
|
||||
return "".join(
|
||||
_render_file_block(name, templates.get(name, "")) for name in TEMPLATE_FILES
|
||||
@@ -143,10 +152,15 @@ def _build_file_blocks(context: dict[str, str], board: Board) -> str:
|
||||
|
||||
|
||||
def build_provisioning_message(
|
||||
agent: Agent, board: Board, auth_token: str, confirm_token: str, user: User | None
|
||||
agent: Agent,
|
||||
board: Board,
|
||||
gateway: Gateway,
|
||||
auth_token: str,
|
||||
confirm_token: str,
|
||||
user: User | None,
|
||||
) -> str:
|
||||
context = _build_context(agent, board, auth_token, user)
|
||||
file_blocks = _build_file_blocks(context, board)
|
||||
context = _build_context(agent, board, gateway, auth_token, user)
|
||||
file_blocks = _build_file_blocks(context, agent)
|
||||
heartbeat_snippet = json.dumps(
|
||||
{
|
||||
"id": _agent_key(agent),
|
||||
@@ -190,10 +204,15 @@ def build_provisioning_message(
|
||||
|
||||
|
||||
def build_update_message(
|
||||
agent: Agent, board: Board, auth_token: str, confirm_token: str, user: User | None
|
||||
agent: Agent,
|
||||
board: Board,
|
||||
gateway: Gateway,
|
||||
auth_token: str,
|
||||
confirm_token: str,
|
||||
user: User | None,
|
||||
) -> str:
|
||||
context = _build_context(agent, board, auth_token, user)
|
||||
file_blocks = _build_file_blocks(context, board)
|
||||
context = _build_context(agent, board, gateway, auth_token, user)
|
||||
file_blocks = _build_file_blocks(context, agent)
|
||||
heartbeat_snippet = json.dumps(
|
||||
{
|
||||
"id": _agent_key(agent),
|
||||
@@ -238,34 +257,48 @@ def build_update_message(
|
||||
async def send_provisioning_message(
|
||||
agent: Agent,
|
||||
board: Board,
|
||||
gateway: Gateway,
|
||||
auth_token: str,
|
||||
confirm_token: str,
|
||||
user: User | None,
|
||||
) -> None:
|
||||
if not board.gateway_url:
|
||||
if not gateway.url:
|
||||
return
|
||||
if not board.gateway_main_session_key:
|
||||
if not gateway.main_session_key:
|
||||
raise ValueError("gateway_main_session_key is required")
|
||||
main_session = board.gateway_main_session_key
|
||||
config = GatewayConfig(url=board.gateway_url, token=board.gateway_token)
|
||||
await ensure_session(main_session, config=config, label="Main Agent")
|
||||
message = build_provisioning_message(agent, board, auth_token, confirm_token, user)
|
||||
await send_message(message, session_key=main_session, config=config, deliver=False)
|
||||
main_session = gateway.main_session_key
|
||||
client_config = GatewayClientConfig(
|
||||
url=gateway.url, token=gateway.token
|
||||
)
|
||||
await ensure_session(main_session, config=client_config, label="Main Agent")
|
||||
message = build_provisioning_message(
|
||||
agent, board, gateway, auth_token, confirm_token, user
|
||||
)
|
||||
await send_message(
|
||||
message, session_key=main_session, config=client_config, deliver=False
|
||||
)
|
||||
|
||||
|
||||
async def send_update_message(
|
||||
agent: Agent,
|
||||
board: Board,
|
||||
gateway: Gateway,
|
||||
auth_token: str,
|
||||
confirm_token: str,
|
||||
user: User | None,
|
||||
) -> None:
|
||||
if not board.gateway_url:
|
||||
if not gateway.url:
|
||||
return
|
||||
if not board.gateway_main_session_key:
|
||||
if not gateway.main_session_key:
|
||||
raise ValueError("gateway_main_session_key is required")
|
||||
main_session = board.gateway_main_session_key
|
||||
config = GatewayConfig(url=board.gateway_url, token=board.gateway_token)
|
||||
await ensure_session(main_session, config=config, label="Main Agent")
|
||||
message = build_update_message(agent, board, auth_token, confirm_token, user)
|
||||
await send_message(message, session_key=main_session, config=config, deliver=False)
|
||||
main_session = gateway.main_session_key
|
||||
client_config = GatewayClientConfig(
|
||||
url=gateway.url, token=gateway.token
|
||||
)
|
||||
await ensure_session(main_session, config=client_config, label="Main Agent")
|
||||
message = build_update_message(
|
||||
agent, board, gateway, auth_token, confirm_token, user
|
||||
)
|
||||
await send_message(
|
||||
message, session_key=main_session, config=client_config, deliver=False
|
||||
)
|
||||
|
||||
@@ -80,8 +80,8 @@ export const getGatewayStatusApiV1GatewayStatusGetUrl = (
|
||||
const stringifiedParams = normalizedParams.toString();
|
||||
|
||||
return stringifiedParams.length > 0
|
||||
? `/api/v1/gateway/status?${stringifiedParams}`
|
||||
: `/api/v1/gateway/status`;
|
||||
? `/api/v1/gateways/status?${stringifiedParams}`
|
||||
: `/api/v1/gateways/status`;
|
||||
};
|
||||
|
||||
export const gatewayStatusApiV1GatewayStatusGet = async (
|
||||
@@ -100,7 +100,7 @@ export const gatewayStatusApiV1GatewayStatusGet = async (
|
||||
export const getGatewayStatusApiV1GatewayStatusGetQueryKey = (
|
||||
params?: GatewayStatusApiV1GatewayStatusGetParams,
|
||||
) => {
|
||||
return [`/api/v1/gateway/status`, ...(params ? [params] : [])] as const;
|
||||
return [`/api/v1/gateways/status`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGatewayStatusApiV1GatewayStatusGetQueryOptions = <
|
||||
@@ -291,8 +291,8 @@ export const getListSessionsApiV1GatewaySessionsGetUrl = (
|
||||
const stringifiedParams = normalizedParams.toString();
|
||||
|
||||
return stringifiedParams.length > 0
|
||||
? `/api/v1/gateway/sessions?${stringifiedParams}`
|
||||
: `/api/v1/gateway/sessions`;
|
||||
? `/api/v1/gateways/sessions?${stringifiedParams}`
|
||||
: `/api/v1/gateways/sessions`;
|
||||
};
|
||||
|
||||
export const listSessionsApiV1GatewaySessionsGet = async (
|
||||
@@ -311,7 +311,7 @@ export const listSessionsApiV1GatewaySessionsGet = async (
|
||||
export const getListSessionsApiV1GatewaySessionsGetQueryKey = (
|
||||
params?: ListSessionsApiV1GatewaySessionsGetParams,
|
||||
) => {
|
||||
return [`/api/v1/gateway/sessions`, ...(params ? [params] : [])] as const;
|
||||
return [`/api/v1/gateways/sessions`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getListSessionsApiV1GatewaySessionsGetQueryOptions = <
|
||||
@@ -503,8 +503,8 @@ export const getGetGatewaySessionApiV1GatewaySessionsSessionIdGetUrl = (
|
||||
const stringifiedParams = normalizedParams.toString();
|
||||
|
||||
return stringifiedParams.length > 0
|
||||
? `/api/v1/gateway/sessions/${sessionId}?${stringifiedParams}`
|
||||
: `/api/v1/gateway/sessions/${sessionId}`;
|
||||
? `/api/v1/gateways/sessions/${sessionId}?${stringifiedParams}`
|
||||
: `/api/v1/gateways/sessions/${sessionId}`;
|
||||
};
|
||||
|
||||
export const getGatewaySessionApiV1GatewaySessionsSessionIdGet = async (
|
||||
@@ -526,7 +526,7 @@ export const getGetGatewaySessionApiV1GatewaySessionsSessionIdGetQueryKey = (
|
||||
params?: GetGatewaySessionApiV1GatewaySessionsSessionIdGetParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v1/gateway/sessions/${sessionId}`,
|
||||
`/api/v1/gateways/sessions/${sessionId}`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
@@ -777,8 +777,8 @@ export const getGetSessionHistoryApiV1GatewaySessionsSessionIdHistoryGetUrl = (
|
||||
const stringifiedParams = normalizedParams.toString();
|
||||
|
||||
return stringifiedParams.length > 0
|
||||
? `/api/v1/gateway/sessions/${sessionId}/history?${stringifiedParams}`
|
||||
: `/api/v1/gateway/sessions/${sessionId}/history`;
|
||||
? `/api/v1/gateways/sessions/${sessionId}/history?${stringifiedParams}`
|
||||
: `/api/v1/gateways/sessions/${sessionId}/history`;
|
||||
};
|
||||
|
||||
export const getSessionHistoryApiV1GatewaySessionsSessionIdHistoryGet = async (
|
||||
@@ -804,7 +804,7 @@ export const getGetSessionHistoryApiV1GatewaySessionsSessionIdHistoryGetQueryKey
|
||||
params?: GetSessionHistoryApiV1GatewaySessionsSessionIdHistoryGetParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v1/gateway/sessions/${sessionId}/history`,
|
||||
`/api/v1/gateways/sessions/${sessionId}/history`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
@@ -1088,8 +1088,8 @@ export const getSendSessionMessageApiV1GatewaySessionsSessionIdMessagePostUrl =
|
||||
const stringifiedParams = normalizedParams.toString();
|
||||
|
||||
return stringifiedParams.length > 0
|
||||
? `/api/v1/gateway/sessions/${sessionId}/message?${stringifiedParams}`
|
||||
: `/api/v1/gateway/sessions/${sessionId}/message`;
|
||||
? `/api/v1/gateways/sessions/${sessionId}/message?${stringifiedParams}`
|
||||
: `/api/v1/gateways/sessions/${sessionId}/message`;
|
||||
};
|
||||
|
||||
export const sendSessionMessageApiV1GatewaySessionsSessionIdMessagePost =
|
||||
@@ -1257,7 +1257,7 @@ export type gatewayCommandsApiV1GatewayCommandsGetResponse =
|
||||
gatewayCommandsApiV1GatewayCommandsGetResponseSuccess;
|
||||
|
||||
export const getGatewayCommandsApiV1GatewayCommandsGetUrl = () => {
|
||||
return `/api/v1/gateway/commands`;
|
||||
return `/api/v1/gateways/commands`;
|
||||
};
|
||||
|
||||
export const gatewayCommandsApiV1GatewayCommandsGet = async (
|
||||
@@ -1273,7 +1273,7 @@ export const gatewayCommandsApiV1GatewayCommandsGet = async (
|
||||
};
|
||||
|
||||
export const getGatewayCommandsApiV1GatewayCommandsGetQueryKey = () => {
|
||||
return [`/api/v1/gateway/commands`] as const;
|
||||
return [`/api/v1/gateways/commands`] as const;
|
||||
};
|
||||
|
||||
export const getGatewayCommandsApiV1GatewayCommandsGetQueryOptions = <
|
||||
|
||||
@@ -8,10 +8,5 @@
|
||||
export interface BoardCreate {
|
||||
name: string;
|
||||
slug: string;
|
||||
gateway_url?: string | null;
|
||||
gateway_main_session_key?: string | null;
|
||||
gateway_workspace_root?: string | null;
|
||||
identity_template?: string | null;
|
||||
soul_template?: string | null;
|
||||
gateway_token?: string | null;
|
||||
gateway_id?: string | null;
|
||||
}
|
||||
|
||||
@@ -8,11 +8,7 @@
|
||||
export interface BoardRead {
|
||||
name: string;
|
||||
slug: string;
|
||||
gateway_url?: string | null;
|
||||
gateway_main_session_key?: string | null;
|
||||
gateway_workspace_root?: string | null;
|
||||
identity_template?: string | null;
|
||||
soul_template?: string | null;
|
||||
gateway_id?: string | null;
|
||||
id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
@@ -8,10 +8,5 @@
|
||||
export interface BoardUpdate {
|
||||
name?: string | null;
|
||||
slug?: string | null;
|
||||
gateway_url?: string | null;
|
||||
gateway_token?: string | null;
|
||||
gateway_main_session_key?: string | null;
|
||||
gateway_workspace_root?: string | null;
|
||||
identity_template?: string | null;
|
||||
soul_template?: string | null;
|
||||
gateway_id?: string | null;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,12 @@ import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getApiBaseUrl } from "@/lib/api-base";
|
||||
import {
|
||||
DEFAULT_IDENTITY_TEMPLATE,
|
||||
DEFAULT_SOUL_TEMPLATE,
|
||||
} from "@/lib/agent-templates";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -28,6 +33,8 @@ type Agent = {
|
||||
every?: string;
|
||||
target?: string;
|
||||
} | null;
|
||||
identity_template?: string | null;
|
||||
soul_template?: string | null;
|
||||
};
|
||||
|
||||
type Board = {
|
||||
@@ -49,6 +56,10 @@ export default function EditAgentPage() {
|
||||
const [boardId, setBoardId] = useState("");
|
||||
const [heartbeatEvery, setHeartbeatEvery] = useState("10m");
|
||||
const [heartbeatTarget, setHeartbeatTarget] = useState("none");
|
||||
const [identityTemplate, setIdentityTemplate] = useState(
|
||||
DEFAULT_IDENTITY_TEMPLATE
|
||||
);
|
||||
const [soulTemplate, setSoulTemplate] = useState(DEFAULT_SOUL_TEMPLATE);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -93,6 +104,10 @@ export default function EditAgentPage() {
|
||||
if (data.heartbeat_config?.target) {
|
||||
setHeartbeatTarget(data.heartbeat_config.target);
|
||||
}
|
||||
setIdentityTemplate(
|
||||
data.identity_template?.trim() || DEFAULT_IDENTITY_TEMPLATE
|
||||
);
|
||||
setSoulTemplate(data.soul_template?.trim() || DEFAULT_SOUL_TEMPLATE);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
||||
} finally {
|
||||
@@ -146,6 +161,8 @@ export default function EditAgentPage() {
|
||||
every: heartbeatEvery.trim() || "10m",
|
||||
target: heartbeatTarget,
|
||||
},
|
||||
identity_template: identityTemplate.trim() || null,
|
||||
soul_template: soulTemplate.trim() || null,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -201,7 +218,7 @@ export default function EditAgentPage() {
|
||||
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Agent name
|
||||
Agent name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
@@ -212,7 +229,7 @@ export default function EditAgentPage() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Board
|
||||
Board <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Select
|
||||
value={boardId}
|
||||
@@ -239,6 +256,36 @@ export default function EditAgentPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Agent persona
|
||||
</p>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Identity template
|
||||
</label>
|
||||
<Textarea
|
||||
value={identityTemplate}
|
||||
onChange={(event) => setIdentityTemplate(event.target.value)}
|
||||
rows={8}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Soul template
|
||||
</label>
|
||||
<Textarea
|
||||
value={soulTemplate}
|
||||
onChange={(event) => setSoulTemplate(event.target.value)}
|
||||
rows={10}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Heartbeat settings
|
||||
|
||||
@@ -9,7 +9,12 @@ import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getApiBaseUrl } from "@/lib/api-base";
|
||||
import {
|
||||
DEFAULT_IDENTITY_TEMPLATE,
|
||||
DEFAULT_SOUL_TEMPLATE,
|
||||
} from "@/lib/agent-templates";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -40,6 +45,10 @@ export default function NewAgentPage() {
|
||||
const [boardId, setBoardId] = useState<string>("");
|
||||
const [heartbeatEvery, setHeartbeatEvery] = useState("10m");
|
||||
const [heartbeatTarget, setHeartbeatTarget] = useState("none");
|
||||
const [identityTemplate, setIdentityTemplate] = useState(
|
||||
DEFAULT_IDENTITY_TEMPLATE
|
||||
);
|
||||
const [soulTemplate, setSoulTemplate] = useState(DEFAULT_SOUL_TEMPLATE);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -97,6 +106,8 @@ export default function NewAgentPage() {
|
||||
every: heartbeatEvery.trim() || "10m",
|
||||
target: heartbeatTarget,
|
||||
},
|
||||
identity_template: identityTemplate.trim() || null,
|
||||
soul_template: soulTemplate.trim() || null,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -153,7 +164,7 @@ export default function NewAgentPage() {
|
||||
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Agent name
|
||||
Agent name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
@@ -164,7 +175,7 @@ export default function NewAgentPage() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Board
|
||||
Board <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Select
|
||||
value={boardId}
|
||||
@@ -191,6 +202,36 @@ export default function NewAgentPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Agent persona
|
||||
</p>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Identity template
|
||||
</label>
|
||||
<Textarea
|
||||
value={identityTemplate}
|
||||
onChange={(event) => setIdentityTemplate(event.target.value)}
|
||||
rows={8}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Soul template
|
||||
</label>
|
||||
<Textarea
|
||||
value={soulTemplate}
|
||||
onChange={(event) => setSoulTemplate(event.target.value)}
|
||||
rows={10}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Heartbeat settings
|
||||
|
||||
@@ -171,7 +171,7 @@ export default function AgentsPage() {
|
||||
try {
|
||||
const token = await getToken();
|
||||
const response = await fetch(
|
||||
`${apiBase}/api/v1/gateway/status?board_id=${boardId}`,
|
||||
`${apiBase}/api/v1/gateways/status?board_id=${boardId}`,
|
||||
{ headers: { Authorization: token ? `Bearer ${token}` : "" } }
|
||||
);
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,70 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
||||
import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
|
||||
|
||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { getApiBaseUrl } from "@/lib/api-base";
|
||||
import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
|
||||
|
||||
const apiBase = getApiBaseUrl();
|
||||
|
||||
const DEFAULT_IDENTITY_TEMPLATE = `# IDENTITY.md
|
||||
const DEFAULT_MAIN_SESSION_KEY = "agent:main:main";
|
||||
const DEFAULT_WORKSPACE_ROOT = "~/.openclaw";
|
||||
|
||||
Name: {{ agent_name }}
|
||||
type Board = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
gateway_id?: string | null;
|
||||
};
|
||||
|
||||
Agent ID: {{ agent_id }}
|
||||
|
||||
Creature: AI
|
||||
|
||||
Vibe: calm, precise, helpful
|
||||
|
||||
Emoji: :gear:
|
||||
`;
|
||||
|
||||
const DEFAULT_SOUL_TEMPLATE = `# SOUL.md
|
||||
|
||||
_You're not a chatbot. You're becoming someone._
|
||||
|
||||
## Core Truths
|
||||
|
||||
**Be genuinely helpful, not performatively helpful.** Skip the "Great question!" and "I'd be happy to help!" -- just help. Actions speak louder than filler words.
|
||||
|
||||
**Have opinions.** You're allowed to disagree, prefer things, find stuff amusing or boring. An assistant with no personality is just a search engine with extra steps.
|
||||
|
||||
**Be resourceful before asking.** Try to figure it out. Read the file. Check the context. Search for it. _Then_ ask if you're stuck. The goal is to come back with answers, not questions.
|
||||
|
||||
**Earn trust through competence.** Your human gave you access to their stuff. Don't make them regret it. Be careful with external actions (emails, tweets, anything public). Be bold with internal ones (reading, organizing, learning).
|
||||
|
||||
**Remember you're a guest.** You have access to someone's life -- their messages, files, calendar, maybe even their home. That's intimacy. Treat it with respect.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Private things stay private. Period.
|
||||
- When in doubt, ask before acting externally.
|
||||
- Never send half-baked replies to messaging surfaces.
|
||||
- You're not the user's voice -- be careful in group chats.
|
||||
|
||||
## Vibe
|
||||
|
||||
Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good.
|
||||
|
||||
## Continuity
|
||||
|
||||
Each session, you wake up fresh. These files _are_ your memory. Read them. Update them. They're how you persist.
|
||||
|
||||
If you change this file, tell the user -- it's your soul, and they should know.
|
||||
|
||||
---
|
||||
|
||||
_This file is yours to evolve. As you learn who you are, update it._
|
||||
`;
|
||||
type Gateway = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
token?: string | null;
|
||||
main_session_key: string;
|
||||
workspace_root: string;
|
||||
skyll_enabled?: boolean;
|
||||
};
|
||||
|
||||
const validateGatewayUrl = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
@@ -83,17 +58,6 @@ const validateGatewayUrl = (value: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
type Board = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
gateway_url?: string | null;
|
||||
gateway_main_session_key?: string | null;
|
||||
gateway_workspace_root?: string | null;
|
||||
identity_template?: string | null;
|
||||
soul_template?: string | null;
|
||||
};
|
||||
|
||||
const slugify = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
@@ -110,16 +74,21 @@ export default function EditBoardPage() {
|
||||
|
||||
const [board, setBoard] = useState<Board | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [gateways, setGateways] = useState<Gateway[]>([]);
|
||||
const [gatewayId, setGatewayId] = useState<string>("");
|
||||
const [createNewGateway, setCreateNewGateway] = useState(false);
|
||||
|
||||
const [gatewayName, setGatewayName] = useState("");
|
||||
const [gatewayUrl, setGatewayUrl] = useState("");
|
||||
const [gatewayToken, setGatewayToken] = useState("");
|
||||
const [gatewayMainSessionKey, setGatewayMainSessionKey] = useState("");
|
||||
const [gatewayWorkspaceRoot, setGatewayWorkspaceRoot] = useState("");
|
||||
const [identityTemplate, setIdentityTemplate] = useState(
|
||||
DEFAULT_IDENTITY_TEMPLATE
|
||||
const [gatewayMainSessionKey, setGatewayMainSessionKey] = useState(
|
||||
DEFAULT_MAIN_SESSION_KEY
|
||||
);
|
||||
const [soulTemplate, setSoulTemplate] = useState(DEFAULT_SOUL_TEMPLATE);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [gatewayWorkspaceRoot, setGatewayWorkspaceRoot] = useState(
|
||||
DEFAULT_WORKSPACE_ROOT
|
||||
);
|
||||
const [skyllEnabled, setSkyllEnabled] = useState(false);
|
||||
|
||||
const [gatewayUrlError, setGatewayUrlError] = useState<string | null>(null);
|
||||
const [gatewayCheckStatus, setGatewayCheckStatus] = useState<
|
||||
"idle" | "checking" | "success" | "error"
|
||||
@@ -128,6 +97,95 @@ export default function EditBoardPage() {
|
||||
null
|
||||
);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectedGateway = useMemo(
|
||||
() => gateways.find((gateway) => gateway.id === gatewayId) || null,
|
||||
[gateways, gatewayId]
|
||||
);
|
||||
|
||||
const loadGateways = async () => {
|
||||
if (!isSignedIn) return;
|
||||
const token = await getToken();
|
||||
const response = await fetch(`${apiBase}/api/v1/gateways`, {
|
||||
headers: { Authorization: token ? `Bearer ${token}` : "" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to load gateways.");
|
||||
}
|
||||
const data = (await response.json()) as Gateway[];
|
||||
setGateways(data);
|
||||
return data;
|
||||
};
|
||||
|
||||
const loadGatewayDetails = async (configId: string) => {
|
||||
const token = await getToken();
|
||||
const response = await fetch(`${apiBase}/api/v1/gateways/${configId}`, {
|
||||
headers: { Authorization: token ? `Bearer ${token}` : "" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to load gateway.");
|
||||
}
|
||||
const config = (await response.json()) as Gateway;
|
||||
setGatewayName(config.name || "");
|
||||
setGatewayUrl(config.url || "");
|
||||
setGatewayToken(config.token || "");
|
||||
setGatewayMainSessionKey(
|
||||
config.main_session_key || DEFAULT_MAIN_SESSION_KEY
|
||||
);
|
||||
setGatewayWorkspaceRoot(config.workspace_root || DEFAULT_WORKSPACE_ROOT);
|
||||
setSkyllEnabled(Boolean(config.skyll_enabled));
|
||||
};
|
||||
|
||||
const loadBoard = async () => {
|
||||
if (!isSignedIn || !boardId) return;
|
||||
try {
|
||||
const token = await getToken();
|
||||
const response = await fetch(`${apiBase}/api/v1/boards/${boardId}`, {
|
||||
headers: { Authorization: token ? `Bearer ${token}` : "" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to load board.");
|
||||
}
|
||||
const data = (await response.json()) as Board;
|
||||
setBoard(data);
|
||||
setName(data.name ?? "");
|
||||
if (data.gateway_id) {
|
||||
setGatewayId(data.gateway_id);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadBoard();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [boardId, isSignedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSignedIn) return;
|
||||
loadGateways()
|
||||
.then((configs) => {
|
||||
if (!gatewayId && configs.length > 0) {
|
||||
setGatewayId(configs[0].id);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSignedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSignedIn || !gatewayId || createNewGateway) return;
|
||||
loadGatewayDetails(gatewayId).catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gatewayId, createNewGateway]);
|
||||
|
||||
const runGatewayCheck = async () => {
|
||||
const validationError = validateGatewayUrl(gatewayUrl);
|
||||
setGatewayUrlError(validationError);
|
||||
@@ -151,12 +209,12 @@ export default function EditBoardPage() {
|
||||
params.set("gateway_main_session_key", gatewayMainSessionKey.trim());
|
||||
}
|
||||
const response = await fetch(
|
||||
`${apiBase}/api/v1/gateway/status?${params.toString()}`,
|
||||
`${apiBase}/api/v1/gateways/status?${params.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.connected) {
|
||||
@@ -174,46 +232,33 @@ export default function EditBoardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadBoard = async () => {
|
||||
if (!isSignedIn || !boardId) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = await getToken();
|
||||
const response = await fetch(`${apiBase}/api/v1/boards/${boardId}`, {
|
||||
headers: { Authorization: token ? `Bearer ${token}` : "" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to load board.");
|
||||
}
|
||||
const data = (await response.json()) as Board;
|
||||
setBoard(data);
|
||||
setName(data.name);
|
||||
setGatewayUrl(data.gateway_url ?? "");
|
||||
setGatewayMainSessionKey(data.gateway_main_session_key ?? "agent:main:main");
|
||||
setGatewayWorkspaceRoot(data.gateway_workspace_root ?? "~/.openclaw");
|
||||
setIdentityTemplate(data.identity_template ?? DEFAULT_IDENTITY_TEMPLATE);
|
||||
setSoulTemplate(data.soul_template ?? DEFAULT_SOUL_TEMPLATE);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
const handleGatewaySelection = (value: string) => {
|
||||
if (value === "new") {
|
||||
setCreateNewGateway(true);
|
||||
setGatewayId("");
|
||||
setGatewayName("");
|
||||
setGatewayUrl("");
|
||||
setGatewayToken("");
|
||||
setGatewayMainSessionKey(DEFAULT_MAIN_SESSION_KEY);
|
||||
setGatewayWorkspaceRoot(DEFAULT_WORKSPACE_ROOT);
|
||||
setSkyllEnabled(false);
|
||||
return;
|
||||
}
|
||||
setCreateNewGateway(false);
|
||||
setGatewayId(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadBoard();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSignedIn, boardId]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!isSignedIn || !boardId) return;
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
if (!name.trim()) {
|
||||
setError("Board name is required.");
|
||||
return;
|
||||
}
|
||||
if (!createNewGateway && !gatewayId) {
|
||||
setError("Select a gateway before saving.");
|
||||
return;
|
||||
}
|
||||
const gatewayValidation = validateGatewayUrl(gatewayUrl);
|
||||
setGatewayUrlError(gatewayValidation);
|
||||
if (gatewayValidation) {
|
||||
@@ -221,34 +266,87 @@ export default function EditBoardPage() {
|
||||
setGatewayCheckMessage(gatewayValidation);
|
||||
return;
|
||||
}
|
||||
if (!gatewayName.trim()) {
|
||||
setError("Gateway name is required.");
|
||||
return;
|
||||
}
|
||||
if (!gatewayMainSessionKey.trim()) {
|
||||
setError("Main session key is required.");
|
||||
return;
|
||||
}
|
||||
if (!gatewayWorkspaceRoot.trim()) {
|
||||
setError("Workspace root is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = await getToken();
|
||||
const payload: Partial<Board> & { gateway_token?: string | null } = {
|
||||
name: trimmed,
|
||||
slug: board?.slug ?? slugify(trimmed),
|
||||
gateway_url: gatewayUrl.trim() || null,
|
||||
gateway_main_session_key: gatewayMainSessionKey.trim() || null,
|
||||
gateway_workspace_root: gatewayWorkspaceRoot.trim() || null,
|
||||
identity_template: identityTemplate.trim() || null,
|
||||
soul_template: soulTemplate.trim() || null,
|
||||
};
|
||||
if (gatewayToken.trim()) {
|
||||
payload.gateway_token = gatewayToken.trim();
|
||||
let configId = gatewayId;
|
||||
|
||||
if (createNewGateway) {
|
||||
const gatewayResponse = await fetch(`${apiBase}/api/v1/gateways`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: gatewayName.trim(),
|
||||
url: gatewayUrl.trim(),
|
||||
token: gatewayToken.trim() || null,
|
||||
main_session_key: gatewayMainSessionKey.trim(),
|
||||
workspace_root: gatewayWorkspaceRoot.trim(),
|
||||
skyll_enabled: skyllEnabled,
|
||||
}),
|
||||
});
|
||||
if (!gatewayResponse.ok) {
|
||||
throw new Error("Unable to create gateway.");
|
||||
}
|
||||
const createdGateway = (await gatewayResponse.json()) as Gateway;
|
||||
configId = createdGateway.id;
|
||||
} else if (gatewayId) {
|
||||
const gatewayResponse = await fetch(
|
||||
`${apiBase}/api/v1/gateways/${gatewayId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: gatewayName.trim(),
|
||||
url: gatewayUrl.trim(),
|
||||
token: gatewayToken.trim() || null,
|
||||
main_session_key: gatewayMainSessionKey.trim(),
|
||||
workspace_root: gatewayWorkspaceRoot.trim(),
|
||||
skyll_enabled: skyllEnabled,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!gatewayResponse.ok) {
|
||||
throw new Error("Unable to update gateway.");
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBase}/api/v1/boards/${boardId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
slug: slugify(name.trim()),
|
||||
gateway_id: configId || null,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to update board.");
|
||||
}
|
||||
router.push(`/boards/${boardId}`);
|
||||
const updated = (await response.json()) as Board;
|
||||
router.push(`/boards/${updated.id}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
||||
} finally {
|
||||
@@ -278,10 +376,10 @@ export default function EditBoardPage() {
|
||||
<div className="border-b border-slate-200 bg-white px-8 py-6">
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl font-semibold text-slate-900 tracking-tight">
|
||||
{board?.name ?? "Edit board"}
|
||||
Edit board
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Update the board identity and gateway connection.
|
||||
Update board settings and gateway.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -289,174 +387,184 @@ export default function EditBoardPage() {
|
||||
<div className="p-8">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm space-y-6"
|
||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Board identity
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Board name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="e.g. Product ops"
|
||||
disabled={isLoading}
|
||||
className="mt-2"
|
||||
placeholder="Board name"
|
||||
disabled={isLoading || !board}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Select
|
||||
value={createNewGateway ? "new" : gatewayId}
|
||||
onValueChange={handleGatewaySelection}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a gateway" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{gateways.map((config) => (
|
||||
<SelectItem key={config.id} value={config.id}>
|
||||
{config.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="new">+ Create new gateway</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Gateway connection
|
||||
</p>
|
||||
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway URL <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Gateway details
|
||||
</p>
|
||||
{!createNewGateway && selectedGateway ? (
|
||||
<span className="text-xs text-slate-500">
|
||||
{selectedGateway.url}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={gatewayUrl}
|
||||
onChange={(event) => setGatewayUrl(event.target.value)}
|
||||
onBlur={runGatewayCheck}
|
||||
placeholder="ws://gateway:18789"
|
||||
value={gatewayName}
|
||||
onChange={(event) => setGatewayName(event.target.value)}
|
||||
placeholder="Primary gateway"
|
||||
disabled={isLoading}
|
||||
className={`pr-12 ${
|
||||
gatewayUrlError
|
||||
? "border-red-500 focus-visible:ring-red-500"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runGatewayCheck}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 transition hover:text-slate-600"
|
||||
aria-label="Check gateway connection"
|
||||
>
|
||||
{gatewayCheckStatus === "checking" ? (
|
||||
<RefreshCcw className="h-4 w-4 animate-spin" />
|
||||
) : gatewayCheckStatus === "success" ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : gatewayCheckStatus === "error" ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{gatewayUrlError ? (
|
||||
<p className="text-xs text-red-500">{gatewayUrlError}</p>
|
||||
) : gatewayCheckMessage ? (
|
||||
<p
|
||||
className={`text-xs ${
|
||||
gatewayCheckStatus === "success"
|
||||
? "text-green-600"
|
||||
: "text-red-500"
|
||||
}`}
|
||||
>
|
||||
{gatewayCheckMessage}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway URL <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={gatewayUrl}
|
||||
onChange={(event) => setGatewayUrl(event.target.value)}
|
||||
onBlur={runGatewayCheck}
|
||||
placeholder="ws://gateway:18789"
|
||||
disabled={isLoading}
|
||||
className={
|
||||
gatewayUrlError ? "border-red-500" : undefined
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runGatewayCheck}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
aria-label="Check gateway connection"
|
||||
>
|
||||
{gatewayCheckStatus === "checking" ? (
|
||||
<RefreshCcw className="h-4 w-4 animate-spin" />
|
||||
) : gatewayCheckStatus === "success" ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
) : gatewayCheckStatus === "error" ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{gatewayUrlError ? (
|
||||
<p className="text-xs text-red-500">{gatewayUrlError}</p>
|
||||
) : gatewayCheckMessage ? (
|
||||
<p
|
||||
className={
|
||||
gatewayCheckStatus === "success"
|
||||
? "text-xs text-emerald-600"
|
||||
: "text-xs text-red-500"
|
||||
}
|
||||
>
|
||||
{gatewayCheckMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway token
|
||||
</label>
|
||||
<Input
|
||||
value={gatewayToken}
|
||||
onChange={(event) => setGatewayToken(event.target.value)}
|
||||
onBlur={runGatewayCheck}
|
||||
placeholder="Bearer token"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway token
|
||||
</label>
|
||||
<Input
|
||||
value={gatewayToken}
|
||||
onChange={(event) => setGatewayToken(event.target.value)}
|
||||
onBlur={runGatewayCheck}
|
||||
placeholder="Bearer token"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Main session key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={gatewayMainSessionKey}
|
||||
onChange={(event) =>
|
||||
setGatewayMainSessionKey(event.target.value)
|
||||
}
|
||||
placeholder={DEFAULT_MAIN_SESSION_KEY}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Workspace root <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={gatewayWorkspaceRoot}
|
||||
onChange={(event) =>
|
||||
setGatewayWorkspaceRoot(event.target.value)
|
||||
}
|
||||
placeholder={DEFAULT_WORKSPACE_ROOT}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-lg border border-slate-200 px-4 py-3 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skyllEnabled}
|
||||
onChange={(event) => setSkyllEnabled(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-slate-300 text-slate-900"
|
||||
/>
|
||||
<span>Enable Skyll dynamic skills</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Agent defaults
|
||||
</p>
|
||||
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Main session key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={gatewayMainSessionKey}
|
||||
onChange={(event) =>
|
||||
setGatewayMainSessionKey(event.target.value)
|
||||
}
|
||||
placeholder="agent:main:main"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Workspace root <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={gatewayWorkspaceRoot}
|
||||
onChange={(event) =>
|
||||
setGatewayWorkspaceRoot(event.target.value)
|
||||
}
|
||||
placeholder="~/.openclaw"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <p className="text-sm text-red-500">{error}</p> : null}
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Agent templates
|
||||
</p>
|
||||
<div className="mt-4 grid gap-6 lg:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Identity template
|
||||
</label>
|
||||
<Textarea
|
||||
value={identityTemplate}
|
||||
onChange={(event) => setIdentityTemplate(event.target.value)}
|
||||
placeholder="Override IDENTITY.md for agents in this board."
|
||||
className="min-h-[180px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Soul template
|
||||
</label>
|
||||
<Textarea
|
||||
value={soulTemplate}
|
||||
onChange={(event) => setSoulTemplate(event.target.value)}
|
||||
placeholder="Override SOUL.md for agents in this board."
|
||||
className="min-h-[180px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => router.push(`/boards/${boardId}`)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Back to board
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading || !board}>
|
||||
{isLoading ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,82 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
||||
import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
|
||||
|
||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { getApiBaseUrl } from "@/lib/api-base";
|
||||
import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
|
||||
|
||||
const DEFAULT_IDENTITY_TEMPLATE = `# IDENTITY.md
|
||||
const DEFAULT_MAIN_SESSION_KEY = "agent:main:main";
|
||||
const DEFAULT_WORKSPACE_ROOT = "~/.openclaw";
|
||||
|
||||
Name: {{ agent_name }}
|
||||
|
||||
Agent ID: {{ agent_id }}
|
||||
|
||||
Creature: AI
|
||||
|
||||
Vibe: calm, precise, helpful
|
||||
|
||||
Emoji: :gear:
|
||||
`;
|
||||
|
||||
const DEFAULT_SOUL_TEMPLATE = `# SOUL.md
|
||||
|
||||
_You're not a chatbot. You're becoming someone._
|
||||
|
||||
## Core Truths
|
||||
|
||||
**Be genuinely helpful, not performatively helpful.** Skip the "Great question!" and "I'd be happy to help!" -- just help. Actions speak louder than filler words.
|
||||
|
||||
**Have opinions.** You're allowed to disagree, prefer things, find stuff amusing or boring. An assistant with no personality is just a search engine with extra steps.
|
||||
|
||||
**Be resourceful before asking.** Try to figure it out. Read the file. Check the context. Search for it. _Then_ ask if you're stuck. The goal is to come back with answers, not questions.
|
||||
|
||||
**Earn trust through competence.** Your human gave you access to their stuff. Don't make them regret it. Be careful with external actions (emails, tweets, anything public). Be bold with internal ones (reading, organizing, learning).
|
||||
|
||||
**Remember you're a guest.** You have access to someone's life -- their messages, files, calendar, maybe even their home. That's intimacy. Treat it with respect.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Private things stay private. Period.
|
||||
- When in doubt, ask before acting externally.
|
||||
- Never send half-baked replies to messaging surfaces.
|
||||
- You're not the user's voice -- be careful in group chats.
|
||||
|
||||
## Vibe
|
||||
|
||||
Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good.
|
||||
|
||||
## Continuity
|
||||
|
||||
Each session, you wake up fresh. These files _are_ your memory. Read them. Update them. They're how you persist.
|
||||
|
||||
If you change this file, tell the user -- it's your soul, and they should know.
|
||||
|
||||
---
|
||||
|
||||
_This file is yours to evolve. As you learn who you are, update it._
|
||||
`;
|
||||
const apiBase = getApiBaseUrl();
|
||||
|
||||
type Board = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
gateway_url?: string | null;
|
||||
gateway_token?: string | null;
|
||||
gateway_main_session_key?: string | null;
|
||||
gateway_workspace_root?: string | null;
|
||||
identity_template?: string | null;
|
||||
soul_template?: string | null;
|
||||
gateway_id?: string | null;
|
||||
};
|
||||
|
||||
const apiBase = getApiBaseUrl();
|
||||
type Gateway = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
token?: string | null;
|
||||
main_session_key: string;
|
||||
workspace_root: string;
|
||||
skyll_enabled?: boolean;
|
||||
};
|
||||
|
||||
const validateGatewayUrl = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
@@ -105,19 +68,23 @@ const slugify = (value: string) =>
|
||||
export default function NewBoardPage() {
|
||||
const router = useRouter();
|
||||
const { getToken, isSignedIn } = useAuth();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [gateways, setGateways] = useState<Gateway[]>([]);
|
||||
const [gatewayId, setGatewayId] = useState<string>("");
|
||||
const [createNewGateway, setCreateNewGateway] = useState(false);
|
||||
|
||||
const [gatewayName, setGatewayName] = useState("");
|
||||
const [gatewayUrl, setGatewayUrl] = useState("");
|
||||
const [gatewayToken, setGatewayToken] = useState("");
|
||||
const [gatewayMainSessionKey, setGatewayMainSessionKey] =
|
||||
useState("agent:main:main");
|
||||
const [gatewayWorkspaceRoot, setGatewayWorkspaceRoot] =
|
||||
useState("~/.openclaw");
|
||||
const [identityTemplate, setIdentityTemplate] = useState(
|
||||
DEFAULT_IDENTITY_TEMPLATE
|
||||
const [gatewayMainSessionKey, setGatewayMainSessionKey] = useState(
|
||||
DEFAULT_MAIN_SESSION_KEY
|
||||
);
|
||||
const [soulTemplate, setSoulTemplate] = useState(DEFAULT_SOUL_TEMPLATE);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [gatewayWorkspaceRoot, setGatewayWorkspaceRoot] = useState(
|
||||
DEFAULT_WORKSPACE_ROOT
|
||||
);
|
||||
const [skyllEnabled, setSkyllEnabled] = useState(false);
|
||||
|
||||
const [gatewayUrlError, setGatewayUrlError] = useState<string | null>(null);
|
||||
const [gatewayCheckStatus, setGatewayCheckStatus] = useState<
|
||||
"idle" | "checking" | "success" | "error"
|
||||
@@ -126,6 +93,41 @@ export default function NewBoardPage() {
|
||||
null
|
||||
);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectedGateway = useMemo(
|
||||
() => gateways.find((gateway) => gateway.id === gatewayId) || null,
|
||||
[gateways, gatewayId]
|
||||
);
|
||||
|
||||
const loadGateways = async () => {
|
||||
if (!isSignedIn) return;
|
||||
try {
|
||||
const token = await getToken();
|
||||
const response = await fetch(`${apiBase}/api/v1/gateways`, {
|
||||
headers: { Authorization: token ? `Bearer ${token}` : "" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to load gateways.");
|
||||
}
|
||||
const data = (await response.json()) as Gateway[];
|
||||
setGateways(data);
|
||||
if (data.length === 0) {
|
||||
setCreateNewGateway(true);
|
||||
} else if (!createNewGateway && !gatewayId) {
|
||||
setGatewayId(data[0].id);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadGateways();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSignedIn]);
|
||||
|
||||
const runGatewayCheck = async () => {
|
||||
const validationError = validateGatewayUrl(gatewayUrl);
|
||||
setGatewayUrlError(validationError);
|
||||
@@ -145,13 +147,16 @@ export default function NewBoardPage() {
|
||||
if (gatewayToken.trim()) {
|
||||
params.set("gateway_token", gatewayToken.trim());
|
||||
}
|
||||
if (gatewayMainSessionKey.trim()) {
|
||||
params.set("gateway_main_session_key", gatewayMainSessionKey.trim());
|
||||
}
|
||||
const response = await fetch(
|
||||
`${apiBase}/api/v1/gateway/status?${params.toString()}`,
|
||||
`${apiBase}/api/v1/gateways/status?${params.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.connected) {
|
||||
@@ -169,40 +174,85 @@ export default function NewBoardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGatewaySelection = (value: string) => {
|
||||
if (value === "new") {
|
||||
setCreateNewGateway(true);
|
||||
setGatewayId("");
|
||||
return;
|
||||
}
|
||||
setCreateNewGateway(false);
|
||||
setGatewayId(value);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!isSignedIn) return;
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
const gatewayValidation = validateGatewayUrl(gatewayUrl);
|
||||
setGatewayUrlError(gatewayValidation);
|
||||
if (gatewayValidation) {
|
||||
setGatewayCheckStatus("error");
|
||||
setGatewayCheckMessage(gatewayValidation);
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
setError("Board name is required.");
|
||||
return;
|
||||
}
|
||||
if (!createNewGateway && !gatewayId) {
|
||||
setError("Select a gateway before creating a board.");
|
||||
return;
|
||||
}
|
||||
if (createNewGateway) {
|
||||
const gatewayValidation = validateGatewayUrl(gatewayUrl);
|
||||
setGatewayUrlError(gatewayValidation);
|
||||
if (gatewayValidation) {
|
||||
setGatewayCheckStatus("error");
|
||||
setGatewayCheckMessage(gatewayValidation);
|
||||
return;
|
||||
}
|
||||
if (!gatewayName.trim()) {
|
||||
setError("Gateway name is required.");
|
||||
return;
|
||||
}
|
||||
if (!gatewayMainSessionKey.trim()) {
|
||||
setError("Main session key is required.");
|
||||
return;
|
||||
}
|
||||
if (!gatewayWorkspaceRoot.trim()) {
|
||||
setError("Workspace root is required.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = await getToken();
|
||||
let configId = gatewayId;
|
||||
|
||||
if (createNewGateway) {
|
||||
const gatewayResponse = await fetch(`${apiBase}/api/v1/gateways`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: gatewayName.trim(),
|
||||
url: gatewayUrl.trim(),
|
||||
token: gatewayToken.trim() || null,
|
||||
main_session_key: gatewayMainSessionKey.trim(),
|
||||
workspace_root: gatewayWorkspaceRoot.trim(),
|
||||
skyll_enabled: skyllEnabled,
|
||||
}),
|
||||
});
|
||||
if (!gatewayResponse.ok) {
|
||||
throw new Error("Unable to create gateway.");
|
||||
}
|
||||
const createdGateway = (await gatewayResponse.json()) as Gateway;
|
||||
configId = createdGateway.id;
|
||||
}
|
||||
|
||||
const payload: Partial<Board> = {
|
||||
name: trimmed,
|
||||
slug: slugify(trimmed),
|
||||
name: trimmedName,
|
||||
slug: slugify(trimmedName),
|
||||
gateway_id: configId || null,
|
||||
};
|
||||
if (gatewayUrl.trim()) payload.gateway_url = gatewayUrl.trim();
|
||||
if (gatewayToken.trim()) payload.gateway_token = gatewayToken.trim();
|
||||
if (gatewayMainSessionKey.trim()) {
|
||||
payload.gateway_main_session_key = gatewayMainSessionKey.trim();
|
||||
}
|
||||
if (gatewayWorkspaceRoot.trim()) {
|
||||
payload.gateway_workspace_root = gatewayWorkspaceRoot.trim();
|
||||
}
|
||||
if (identityTemplate.trim()) {
|
||||
payload.identity_template = identityTemplate.trim();
|
||||
}
|
||||
if (soulTemplate.trim()) {
|
||||
payload.soul_template = soulTemplate.trim();
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBase}/api/v1/boards`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -248,7 +298,7 @@ export default function NewBoardPage() {
|
||||
Create board
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Configure the workflow space and gateway defaults for this board.
|
||||
Boards organize tasks and agents by mission context.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,175 +306,219 @@ export default function NewBoardPage() {
|
||||
<div className="p-8">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm space-y-6"
|
||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Board identity
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Board name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="e.g. Product ops"
|
||||
disabled={isLoading}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Gateway connection
|
||||
</p>
|
||||
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway URL <span className="text-red-500">*</span>
|
||||
Board name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={gatewayUrl}
|
||||
onChange={(event) => setGatewayUrl(event.target.value)}
|
||||
onBlur={runGatewayCheck}
|
||||
placeholder="ws://gateway:18789"
|
||||
disabled={isLoading}
|
||||
className={`pr-12 ${
|
||||
gatewayUrlError
|
||||
? "border-red-500 focus-visible:ring-red-500"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runGatewayCheck}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 transition hover:text-slate-600"
|
||||
aria-label="Check gateway connection"
|
||||
>
|
||||
{gatewayCheckStatus === "checking" ? (
|
||||
<RefreshCcw className="h-4 w-4 animate-spin" />
|
||||
) : gatewayCheckStatus === "success" ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : gatewayCheckStatus === "error" ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="e.g. Release operations"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Select
|
||||
value={createNewGateway ? "new" : gatewayId}
|
||||
onValueChange={handleGatewaySelection}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a gateway" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{gateways.map((config) => (
|
||||
<SelectItem key={config.id} value={config.id}>
|
||||
{config.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="new">+ Create new gateway</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Gateway details
|
||||
</p>
|
||||
{!createNewGateway && selectedGateway ? (
|
||||
<span className="text-xs text-slate-500">
|
||||
{selectedGateway.url}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{createNewGateway ? (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={gatewayName}
|
||||
onChange={(event) => setGatewayName(event.target.value)}
|
||||
placeholder="Primary gateway"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway URL <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={gatewayUrl}
|
||||
onChange={(event) => setGatewayUrl(event.target.value)}
|
||||
onBlur={runGatewayCheck}
|
||||
placeholder="ws://gateway:18789"
|
||||
disabled={isLoading}
|
||||
className={
|
||||
gatewayUrlError ? "border-red-500" : undefined
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runGatewayCheck}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
aria-label="Check gateway connection"
|
||||
>
|
||||
{gatewayCheckStatus === "checking" ? (
|
||||
<RefreshCcw className="h-4 w-4 animate-spin" />
|
||||
) : gatewayCheckStatus === "success" ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
) : gatewayCheckStatus === "error" ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{gatewayUrlError ? (
|
||||
<p className="text-xs text-red-500">{gatewayUrlError}</p>
|
||||
) : gatewayCheckMessage ? (
|
||||
<p
|
||||
className={
|
||||
gatewayCheckStatus === "success"
|
||||
? "text-xs text-emerald-600"
|
||||
: "text-xs text-red-500"
|
||||
}
|
||||
>
|
||||
{gatewayCheckMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{gatewayUrlError ? (
|
||||
<p className="text-xs text-red-500">{gatewayUrlError}</p>
|
||||
) : gatewayCheckMessage ? (
|
||||
<p
|
||||
className={`text-xs ${
|
||||
gatewayCheckStatus === "success"
|
||||
? "text-green-600"
|
||||
: "text-red-500"
|
||||
}`}
|
||||
>
|
||||
{gatewayCheckMessage}
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway token
|
||||
</label>
|
||||
<Input
|
||||
value={gatewayToken}
|
||||
onChange={(event) => setGatewayToken(event.target.value)}
|
||||
onBlur={runGatewayCheck}
|
||||
placeholder="Bearer token"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Main session key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={gatewayMainSessionKey}
|
||||
onChange={(event) =>
|
||||
setGatewayMainSessionKey(event.target.value)
|
||||
}
|
||||
placeholder={DEFAULT_MAIN_SESSION_KEY}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Workspace root <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={gatewayWorkspaceRoot}
|
||||
onChange={(event) =>
|
||||
setGatewayWorkspaceRoot(event.target.value)
|
||||
}
|
||||
placeholder={DEFAULT_WORKSPACE_ROOT}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-lg border border-slate-200 px-4 py-3 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skyllEnabled}
|
||||
onChange={(event) => setSkyllEnabled(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-slate-300 text-slate-900"
|
||||
/>
|
||||
<span>Enable Skyll dynamic skills</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedGateway ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<p className="text-xs font-semibold uppercase text-slate-500">
|
||||
Gateway
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-1 text-sm text-slate-900">
|
||||
{selectedGateway.name}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{selectedGateway.url}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<p className="text-xs font-semibold uppercase text-slate-500">
|
||||
Workspace root
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-900">
|
||||
{selectedGateway.workspace_root}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{selectedGateway.main_session_key}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway token
|
||||
</label>
|
||||
<Input
|
||||
value={gatewayToken}
|
||||
onChange={(event) => setGatewayToken(event.target.value)}
|
||||
onBlur={runGatewayCheck}
|
||||
placeholder="Bearer token"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">
|
||||
Select a gateway or create a new one.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Agent defaults
|
||||
</p>
|
||||
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Main session key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={gatewayMainSessionKey}
|
||||
onChange={(event) =>
|
||||
setGatewayMainSessionKey(event.target.value)
|
||||
}
|
||||
placeholder="agent:main:main"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Workspace root <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={gatewayWorkspaceRoot}
|
||||
onChange={(event) =>
|
||||
setGatewayWorkspaceRoot(event.target.value)
|
||||
}
|
||||
placeholder="~/.openclaw"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <p className="text-sm text-red-500">{error}</p> : null}
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Agent templates
|
||||
</p>
|
||||
<div className="mt-4 grid gap-6 lg:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Identity template
|
||||
</label>
|
||||
<Textarea
|
||||
value={identityTemplate}
|
||||
onChange={(event) => setIdentityTemplate(event.target.value)}
|
||||
placeholder="Override IDENTITY.md for agents in this board."
|
||||
className="min-h-[180px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Soul template
|
||||
</label>
|
||||
<Textarea
|
||||
value={soulTemplate}
|
||||
onChange={(event) => setSoulTemplate(event.target.value)}
|
||||
placeholder="Override SOUL.md for agents in this board."
|
||||
className="min-h-[180px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => router.push("/boards")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "Creating…" : "Create board"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/boards")}
|
||||
type="button"
|
||||
>
|
||||
Back to boards
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,6 @@ export default function BoardsPage() {
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium text-strong">{row.original.name}</p>
|
||||
<p className="text-xs text-quiet">{row.original.slug}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
368
frontend/src/app/gateways/[gatewayId]/edit/page.tsx
Normal file
368
frontend/src/app/gateways/[gatewayId]/edit/page.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
||||
import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
|
||||
|
||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getApiBaseUrl } from "@/lib/api-base";
|
||||
|
||||
const apiBase = getApiBaseUrl();
|
||||
|
||||
const DEFAULT_MAIN_SESSION_KEY = "agent:main:main";
|
||||
const DEFAULT_WORKSPACE_ROOT = "~/.openclaw";
|
||||
|
||||
const validateGatewayUrl = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "Gateway URL is required.";
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
|
||||
return "Gateway URL must start with ws:// or wss://.";
|
||||
}
|
||||
if (!url.port) {
|
||||
return "Gateway URL must include an explicit port.";
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return "Enter a valid gateway URL including port.";
|
||||
}
|
||||
};
|
||||
|
||||
type Gateway = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
token?: string | null;
|
||||
main_session_key: string;
|
||||
workspace_root: string;
|
||||
skyll_enabled?: boolean;
|
||||
};
|
||||
|
||||
export default function EditGatewayPage() {
|
||||
const { getToken, isSignedIn } = useAuth();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const gatewayIdParam = params?.gatewayId;
|
||||
const gatewayId = Array.isArray(gatewayIdParam)
|
||||
? gatewayIdParam[0]
|
||||
: gatewayIdParam;
|
||||
|
||||
const [gateway, setGateway] = useState<Gateway | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [gatewayUrl, setGatewayUrl] = useState("");
|
||||
const [gatewayToken, setGatewayToken] = useState("");
|
||||
const [mainSessionKey, setMainSessionKey] = useState(
|
||||
DEFAULT_MAIN_SESSION_KEY
|
||||
);
|
||||
const [workspaceRoot, setWorkspaceRoot] = useState(DEFAULT_WORKSPACE_ROOT);
|
||||
const [skyllEnabled, setSkyllEnabled] = useState(false);
|
||||
|
||||
const [gatewayUrlError, setGatewayUrlError] = useState<string | null>(null);
|
||||
const [gatewayCheckStatus, setGatewayCheckStatus] = useState<
|
||||
"idle" | "checking" | "success" | "error"
|
||||
>("idle");
|
||||
const [gatewayCheckMessage, setGatewayCheckMessage] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSignedIn || !gatewayId) return;
|
||||
const loadGateway = async () => {
|
||||
try {
|
||||
const token = await getToken();
|
||||
const response = await fetch(
|
||||
`${apiBase}/api/v1/gateways/${gatewayId}`,
|
||||
{
|
||||
headers: { Authorization: token ? `Bearer ${token}` : "" },
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to load gateway.");
|
||||
}
|
||||
const data = (await response.json()) as Gateway;
|
||||
setGateway(data);
|
||||
setName(data.name ?? "");
|
||||
setGatewayUrl(data.url ?? "");
|
||||
setGatewayToken(data.token ?? "");
|
||||
setMainSessionKey(data.main_session_key ?? DEFAULT_MAIN_SESSION_KEY);
|
||||
setWorkspaceRoot(data.workspace_root ?? DEFAULT_WORKSPACE_ROOT);
|
||||
setSkyllEnabled(Boolean(data.skyll_enabled));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
||||
}
|
||||
};
|
||||
loadGateway();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gatewayId, isSignedIn]);
|
||||
|
||||
const runGatewayCheck = async () => {
|
||||
const validationError = validateGatewayUrl(gatewayUrl);
|
||||
setGatewayUrlError(validationError);
|
||||
if (validationError) {
|
||||
setGatewayCheckStatus("error");
|
||||
setGatewayCheckMessage(validationError);
|
||||
return;
|
||||
}
|
||||
if (!isSignedIn) return;
|
||||
setGatewayCheckStatus("checking");
|
||||
setGatewayCheckMessage(null);
|
||||
try {
|
||||
const token = await getToken();
|
||||
const params = new URLSearchParams({
|
||||
gateway_url: gatewayUrl.trim(),
|
||||
});
|
||||
if (gatewayToken.trim()) {
|
||||
params.set("gateway_token", gatewayToken.trim());
|
||||
}
|
||||
if (mainSessionKey.trim()) {
|
||||
params.set("gateway_main_session_key", mainSessionKey.trim());
|
||||
}
|
||||
const response = await fetch(
|
||||
`${apiBase}/api/v1/gateways/status?${params.toString()}`,
|
||||
{
|
||||
headers: { Authorization: token ? `Bearer ${token}` : "" },
|
||||
}
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.connected) {
|
||||
setGatewayCheckStatus("error");
|
||||
setGatewayCheckMessage(data?.error ?? "Unable to reach gateway.");
|
||||
return;
|
||||
}
|
||||
setGatewayCheckStatus("success");
|
||||
setGatewayCheckMessage("Gateway reachable.");
|
||||
} catch (err) {
|
||||
setGatewayCheckStatus("error");
|
||||
setGatewayCheckMessage(
|
||||
err instanceof Error ? err.message : "Unable to reach gateway."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!isSignedIn || !gatewayId) return;
|
||||
|
||||
if (!name.trim()) {
|
||||
setError("Gateway name is required.");
|
||||
return;
|
||||
}
|
||||
const gatewayValidation = validateGatewayUrl(gatewayUrl);
|
||||
setGatewayUrlError(gatewayValidation);
|
||||
if (gatewayValidation) {
|
||||
setGatewayCheckStatus("error");
|
||||
setGatewayCheckMessage(gatewayValidation);
|
||||
return;
|
||||
}
|
||||
if (!mainSessionKey.trim()) {
|
||||
setError("Main session key is required.");
|
||||
return;
|
||||
}
|
||||
if (!workspaceRoot.trim()) {
|
||||
setError("Workspace root is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = await getToken();
|
||||
const response = await fetch(`${apiBase}/api/v1/gateways/${gatewayId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
url: gatewayUrl.trim(),
|
||||
token: gatewayToken.trim() || null,
|
||||
main_session_key: mainSessionKey.trim(),
|
||||
workspace_root: workspaceRoot.trim(),
|
||||
skyll_enabled: skyllEnabled,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to update gateway.");
|
||||
}
|
||||
const updated = (await response.json()) as Gateway;
|
||||
setGateway(updated);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<SignedOut>
|
||||
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
|
||||
<p className="text-sm text-slate-600">Sign in to edit a gateway.</p>
|
||||
<SignInButton
|
||||
mode="modal"
|
||||
forceRedirectUrl={`/gateways/${gatewayId}/edit`}
|
||||
>
|
||||
<Button className="mt-4">Sign in</Button>
|
||||
</SignInButton>
|
||||
</div>
|
||||
</div>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<DashboardSidebar />
|
||||
<main className="flex-1 overflow-y-auto bg-slate-50">
|
||||
<div className="border-b border-slate-200 bg-white px-8 py-6">
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl font-semibold text-slate-900 tracking-tight">
|
||||
{gateway ? `Edit gateway — ${gateway.name}` : "Edit gateway"}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Update connection settings for this OpenClaw gateway.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Primary gateway"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway URL <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={gatewayUrl}
|
||||
onChange={(event) => setGatewayUrl(event.target.value)}
|
||||
onBlur={runGatewayCheck}
|
||||
placeholder="ws://gateway:18789"
|
||||
disabled={isLoading}
|
||||
className={gatewayUrlError ? "border-red-500" : undefined}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runGatewayCheck}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
aria-label="Check gateway connection"
|
||||
>
|
||||
{gatewayCheckStatus === "checking" ? (
|
||||
<RefreshCcw className="h-4 w-4 animate-spin" />
|
||||
) : gatewayCheckStatus === "success" ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
) : gatewayCheckStatus === "error" ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{gatewayUrlError ? (
|
||||
<p className="text-xs text-red-500">{gatewayUrlError}</p>
|
||||
) : gatewayCheckMessage ? (
|
||||
<p
|
||||
className={
|
||||
gatewayCheckStatus === "success"
|
||||
? "text-xs text-emerald-600"
|
||||
: "text-xs text-red-500"
|
||||
}
|
||||
>
|
||||
{gatewayCheckMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway token
|
||||
</label>
|
||||
<Input
|
||||
value={gatewayToken}
|
||||
onChange={(event) => setGatewayToken(event.target.value)}
|
||||
onBlur={runGatewayCheck}
|
||||
placeholder="Bearer token"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Main session key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={mainSessionKey}
|
||||
onChange={(event) => setMainSessionKey(event.target.value)}
|
||||
placeholder={DEFAULT_MAIN_SESSION_KEY}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Workspace root <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={workspaceRoot}
|
||||
onChange={(event) => setWorkspaceRoot(event.target.value)}
|
||||
placeholder={DEFAULT_WORKSPACE_ROOT}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-lg border border-slate-200 px-4 py-3 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skyllEnabled}
|
||||
onChange={(event) => setSkyllEnabled(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-slate-300 text-slate-900"
|
||||
/>
|
||||
<span>Enable Skyll dynamic skills</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-red-500">{error}</p> : null}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => router.push("/gateways")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</SignedIn>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
324
frontend/src/app/gateways/new/page.tsx
Normal file
324
frontend/src/app/gateways/new/page.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
||||
import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
|
||||
|
||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getApiBaseUrl } from "@/lib/api-base";
|
||||
|
||||
const apiBase = getApiBaseUrl();
|
||||
|
||||
const DEFAULT_MAIN_SESSION_KEY = "agent:main:main";
|
||||
const DEFAULT_WORKSPACE_ROOT = "~/.openclaw";
|
||||
|
||||
const validateGatewayUrl = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "Gateway URL is required.";
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
|
||||
return "Gateway URL must start with ws:// or wss://.";
|
||||
}
|
||||
if (!url.port) {
|
||||
return "Gateway URL must include an explicit port.";
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return "Enter a valid gateway URL including port.";
|
||||
}
|
||||
};
|
||||
|
||||
export default function NewGatewayPage() {
|
||||
const { getToken, isSignedIn } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [gatewayUrl, setGatewayUrl] = useState("");
|
||||
const [gatewayToken, setGatewayToken] = useState("");
|
||||
const [mainSessionKey, setMainSessionKey] = useState(
|
||||
DEFAULT_MAIN_SESSION_KEY
|
||||
);
|
||||
const [workspaceRoot, setWorkspaceRoot] = useState(DEFAULT_WORKSPACE_ROOT);
|
||||
const [skyllEnabled, setSkyllEnabled] = useState(false);
|
||||
|
||||
const [gatewayUrlError, setGatewayUrlError] = useState<string | null>(null);
|
||||
const [gatewayCheckStatus, setGatewayCheckStatus] = useState<
|
||||
"idle" | "checking" | "success" | "error"
|
||||
>("idle");
|
||||
const [gatewayCheckMessage, setGatewayCheckMessage] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setGatewayCheckStatus("idle");
|
||||
setGatewayCheckMessage(null);
|
||||
}, [gatewayToken]);
|
||||
|
||||
const runGatewayCheck = async () => {
|
||||
const validationError = validateGatewayUrl(gatewayUrl);
|
||||
setGatewayUrlError(validationError);
|
||||
if (validationError) {
|
||||
setGatewayCheckStatus("error");
|
||||
setGatewayCheckMessage(validationError);
|
||||
return;
|
||||
}
|
||||
if (!isSignedIn) return;
|
||||
setGatewayCheckStatus("checking");
|
||||
setGatewayCheckMessage(null);
|
||||
try {
|
||||
const token = await getToken();
|
||||
const params = new URLSearchParams({
|
||||
gateway_url: gatewayUrl.trim(),
|
||||
});
|
||||
if (gatewayToken.trim()) {
|
||||
params.set("gateway_token", gatewayToken.trim());
|
||||
}
|
||||
if (mainSessionKey.trim()) {
|
||||
params.set("gateway_main_session_key", mainSessionKey.trim());
|
||||
}
|
||||
const response = await fetch(
|
||||
`${apiBase}/api/v1/gateways/status?${params.toString()}`,
|
||||
{
|
||||
headers: { Authorization: token ? `Bearer ${token}` : "" },
|
||||
}
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.connected) {
|
||||
setGatewayCheckStatus("error");
|
||||
setGatewayCheckMessage(data?.error ?? "Unable to reach gateway.");
|
||||
return;
|
||||
}
|
||||
setGatewayCheckStatus("success");
|
||||
setGatewayCheckMessage("Gateway reachable.");
|
||||
} catch (err) {
|
||||
setGatewayCheckStatus("error");
|
||||
setGatewayCheckMessage(
|
||||
err instanceof Error ? err.message : "Unable to reach gateway."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!isSignedIn) return;
|
||||
|
||||
if (!name.trim()) {
|
||||
setError("Gateway name is required.");
|
||||
return;
|
||||
}
|
||||
const gatewayValidation = validateGatewayUrl(gatewayUrl);
|
||||
setGatewayUrlError(gatewayValidation);
|
||||
if (gatewayValidation) {
|
||||
setGatewayCheckStatus("error");
|
||||
setGatewayCheckMessage(gatewayValidation);
|
||||
return;
|
||||
}
|
||||
if (!mainSessionKey.trim()) {
|
||||
setError("Main session key is required.");
|
||||
return;
|
||||
}
|
||||
if (!workspaceRoot.trim()) {
|
||||
setError("Workspace root is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = await getToken();
|
||||
const response = await fetch(`${apiBase}/api/v1/gateways`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
url: gatewayUrl.trim(),
|
||||
token: gatewayToken.trim() || null,
|
||||
main_session_key: mainSessionKey.trim(),
|
||||
workspace_root: workspaceRoot.trim(),
|
||||
skyll_enabled: skyllEnabled,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to create gateway.");
|
||||
}
|
||||
const created = (await response.json()) as { id: string };
|
||||
router.push(`/gateways/${created.id}/edit`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<SignedOut>
|
||||
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
|
||||
<p className="text-sm text-slate-600">Sign in to create a gateway.</p>
|
||||
<SignInButton mode="modal" forceRedirectUrl="/gateways/new">
|
||||
<Button className="mt-4">Sign in</Button>
|
||||
</SignInButton>
|
||||
</div>
|
||||
</div>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<DashboardSidebar />
|
||||
<main className="flex-1 overflow-y-auto bg-slate-50">
|
||||
<div className="border-b border-slate-200 bg-white px-8 py-6">
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl font-semibold text-slate-900 tracking-tight">
|
||||
Create gateway
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Configure an OpenClaw gateway for mission control.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Primary gateway"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway URL <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={gatewayUrl}
|
||||
onChange={(event) => setGatewayUrl(event.target.value)}
|
||||
onBlur={runGatewayCheck}
|
||||
placeholder="ws://gateway:18789"
|
||||
disabled={isLoading}
|
||||
className={gatewayUrlError ? "border-red-500" : undefined}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runGatewayCheck}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
aria-label="Check gateway connection"
|
||||
>
|
||||
{gatewayCheckStatus === "checking" ? (
|
||||
<RefreshCcw className="h-4 w-4 animate-spin" />
|
||||
) : gatewayCheckStatus === "success" ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
) : gatewayCheckStatus === "error" ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{gatewayUrlError ? (
|
||||
<p className="text-xs text-red-500">{gatewayUrlError}</p>
|
||||
) : gatewayCheckMessage ? (
|
||||
<p
|
||||
className={
|
||||
gatewayCheckStatus === "success"
|
||||
? "text-xs text-emerald-600"
|
||||
: "text-xs text-red-500"
|
||||
}
|
||||
>
|
||||
{gatewayCheckMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway token
|
||||
</label>
|
||||
<Input
|
||||
value={gatewayToken}
|
||||
onChange={(event) => setGatewayToken(event.target.value)}
|
||||
onBlur={runGatewayCheck}
|
||||
placeholder="Bearer token"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Main session key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={mainSessionKey}
|
||||
onChange={(event) => setMainSessionKey(event.target.value)}
|
||||
placeholder={DEFAULT_MAIN_SESSION_KEY}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Workspace root <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={workspaceRoot}
|
||||
onChange={(event) => setWorkspaceRoot(event.target.value)}
|
||||
placeholder={DEFAULT_WORKSPACE_ROOT}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-lg border border-slate-200 px-4 py-3 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skyllEnabled}
|
||||
onChange={(event) => setSkyllEnabled(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-slate-300 text-slate-900"
|
||||
/>
|
||||
<span>Enable Skyll dynamic skills</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-red-500">{error}</p> : null}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => router.push("/gateways")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "Creating…" : "Create gateway"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</SignedIn>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
326
frontend/src/app/gateways/page.tsx
Normal file
326
frontend/src/app/gateways/page.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
||||
import {
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { getApiBaseUrl } from "@/lib/api-base";
|
||||
|
||||
const apiBase = getApiBaseUrl();
|
||||
|
||||
type Gateway = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
token?: string | null;
|
||||
main_session_key: string;
|
||||
workspace_root: string;
|
||||
skyll_enabled?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
const truncate = (value?: string | null, max = 24) => {
|
||||
if (!value) return "—";
|
||||
if (value.length <= max) return value;
|
||||
return `${value.slice(0, max)}…`;
|
||||
};
|
||||
|
||||
const formatTimestamp = (value?: string | null) => {
|
||||
if (!value) return "—";
|
||||
const date = new Date(`${value}${value.endsWith("Z") ? "" : "Z"}`);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return date.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
export default function GatewaysPage() {
|
||||
const { getToken, isSignedIn } = useAuth();
|
||||
|
||||
const [gateways, setGateways] = useState<Gateway[]>([]);
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "name", desc: false },
|
||||
]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<Gateway | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const sortedGateways = useMemo(() => [...gateways], [gateways]);
|
||||
|
||||
const loadGateways = async () => {
|
||||
if (!isSignedIn) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = await getToken();
|
||||
const response = await fetch(`${apiBase}/api/v1/gateways`, {
|
||||
headers: { Authorization: token ? `Bearer ${token}` : "" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to load gateways.");
|
||||
}
|
||||
const data = (await response.json()) as Gateway[];
|
||||
setGateways(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadGateways();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSignedIn]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget || !isSignedIn) return;
|
||||
setIsDeleting(true);
|
||||
setDeleteError(null);
|
||||
try {
|
||||
const token = await getToken();
|
||||
const response = await fetch(
|
||||
`${apiBase}/api/v1/gateways/${deleteTarget.id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Authorization: token ? `Bearer ${token}` : "" },
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to delete gateway.");
|
||||
}
|
||||
setGateways((prev) => prev.filter((item) => item.id !== deleteTarget.id));
|
||||
setDeleteTarget(null);
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : "Something went wrong.");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo<ColumnDef<Gateway>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Gateway",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{row.original.name}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{truncate(row.original.url, 36)}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "main_session_key",
|
||||
header: "Main session",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">
|
||||
{truncate(row.original.main_session_key, 24)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "workspace_root",
|
||||
header: "Workspace root",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">
|
||||
{truncate(row.original.workspace_root, 28)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "skyll_enabled",
|
||||
header: "Skyll",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">
|
||||
{row.original.skyll_enabled ? "Enabled" : "Off"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: "Updated",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">
|
||||
{formatTimestamp(row.original.updated_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" asChild size="sm">
|
||||
<Link href={`/gateways/${row.original.id}/edit`}>Edit</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTarget(row.original)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: sortedGateways,
|
||||
columns,
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<SignedOut>
|
||||
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
|
||||
<p className="text-sm text-slate-600">Sign in to view gateways.</p>
|
||||
<SignInButton mode="modal" forceRedirectUrl="/gateways">
|
||||
<Button className="mt-4">Sign in</Button>
|
||||
</SignInButton>
|
||||
</div>
|
||||
</div>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<DashboardSidebar />
|
||||
<main className="flex-1 overflow-y-auto bg-slate-50">
|
||||
<div className="border-b border-slate-200 bg-white px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl font-semibold text-slate-900 tracking-tight">
|
||||
Gateways
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Manage OpenClaw gateway connections used by boards.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/gateways/new">Create gateway</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="border-b border-slate-200 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-slate-900">All gateways</p>
|
||||
{isLoading ? (
|
||||
<span className="text-xs text-slate-500">Loading…</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500">
|
||||
{gateways.length} total
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-left text-sm">
|
||||
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th key={header.id} className="px-6 py-3 font-medium">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id} className="hover:bg-slate-50">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-6 py-4">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!isLoading && gateways.length === 0 ? (
|
||||
<div className="px-6 py-10 text-center text-sm text-slate-500">
|
||||
No gateways yet. Create your first gateway to connect boards.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <p className="mt-4 text-sm text-red-500">{error}</p> : null}
|
||||
</div>
|
||||
</main>
|
||||
</SignedIn>
|
||||
|
||||
<Dialog open={Boolean(deleteTarget)} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete gateway?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This removes the gateway connection from Mission Control. Boards
|
||||
using it will need a new gateway assigned.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{deleteError ? (
|
||||
<p className="text-sm text-red-500">{deleteError}</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleDelete} disabled={isDeleting}>
|
||||
{isDeleting ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { BarChart3, Bot, LayoutGrid } from "lucide-react";
|
||||
import { BarChart3, Bot, LayoutGrid, Network } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -28,6 +28,18 @@ export function DashboardSidebar() {
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href="/gateways"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
|
||||
pathname.startsWith("/gateways")
|
||||
? "bg-blue-100 text-blue-800 font-medium"
|
||||
: "hover:bg-slate-100"
|
||||
)}
|
||||
>
|
||||
<Network className="h-4 w-4" />
|
||||
Gateways
|
||||
</Link>
|
||||
<Link
|
||||
href="/boards"
|
||||
className={cn(
|
||||
|
||||
50
frontend/src/lib/agent-templates.ts
Normal file
50
frontend/src/lib/agent-templates.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export const DEFAULT_IDENTITY_TEMPLATE = `# IDENTITY.md
|
||||
|
||||
Name: {{ agent_name }}
|
||||
|
||||
Agent ID: {{ agent_id }}
|
||||
|
||||
Creature: AI
|
||||
|
||||
Vibe: calm, precise, helpful
|
||||
|
||||
Emoji: :gear:
|
||||
`;
|
||||
|
||||
export const DEFAULT_SOUL_TEMPLATE = `# SOUL.md
|
||||
|
||||
_You're not a chatbot. You're becoming someone._
|
||||
|
||||
## Core Truths
|
||||
|
||||
**Be genuinely helpful, not performatively helpful.** Skip the "Great question!" and "I'd be happy to help!" -- just help. Actions speak louder than filler words.
|
||||
|
||||
**Have opinions.** You're allowed to disagree, prefer things, find stuff amusing or boring. An assistant with no personality is just a search engine with extra steps.
|
||||
|
||||
**Be resourceful before asking.** Try to figure it out. Read the file. Check the context. Search for it. _Then_ ask if you're stuck. The goal is to come back with answers, not questions.
|
||||
|
||||
**Earn trust through competence.** Your human gave you access to their stuff. Don't make them regret it. Be careful with external actions (emails, tweets, anything public). Be bold with internal ones (reading, organizing, learning).
|
||||
|
||||
**Remember you're a guest.** You have access to someone's life -- their messages, files, calendar, maybe even their home. That's intimacy. Treat it with respect.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Private things stay private. Period.
|
||||
- When in doubt, ask before acting externally.
|
||||
- Never send half-baked replies to messaging surfaces.
|
||||
- You're not the user's voice -- be careful in group chats.
|
||||
|
||||
## Vibe
|
||||
|
||||
Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good.
|
||||
|
||||
## Continuity
|
||||
|
||||
Each session, you wake up fresh. These files _are_ your memory. Read them. Update them. They're how you persist.
|
||||
|
||||
If you change this file, tell the user -- it's your soul, and they should know.
|
||||
|
||||
---
|
||||
|
||||
_This file is yours to evolve. As you learn who you are, update it._
|
||||
`;
|
||||
Reference in New Issue
Block a user