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:
|
def upgrade() -> None:
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### 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('id', sa.Uuid(), nullable=False),
|
||||||
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
sa.Column('url', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
sa.Column('gateway_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
sa.Column('token', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||||
sa.Column('gateway_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
sa.Column('main_session_key', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
sa.Column('gateway_main_session_key', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
sa.Column('workspace_root', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
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('created_at', sa.DateTime(), nullable=False),
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
sa.PrimaryKeyConstraint('id')
|
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_index(op.f('ix_boards_slug'), 'boards', ['slug'], unique=False)
|
||||||
op.create_table('users',
|
op.create_table('users',
|
||||||
sa.Column('id', sa.Uuid(), nullable=False),
|
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.core.config import settings
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.integrations.openclaw_gateway import (
|
from app.integrations.openclaw_gateway import (
|
||||||
GatewayConfig,
|
GatewayConfig as GatewayClientConfig,
|
||||||
OpenClawGatewayError,
|
OpenClawGatewayError,
|
||||||
ensure_session,
|
ensure_session,
|
||||||
send_message,
|
send_message,
|
||||||
@@ -22,6 +22,7 @@ from app.integrations.openclaw_gateway import (
|
|||||||
from app.models.agents import Agent
|
from app.models.agents import Agent
|
||||||
from app.models.activity_events import ActivityEvent
|
from app.models.activity_events import ActivityEvent
|
||||||
from app.models.boards import Board
|
from app.models.boards import Board
|
||||||
|
from app.models.gateways import Gateway
|
||||||
from app.schemas.agents import (
|
from app.schemas.agents import (
|
||||||
AgentCreate,
|
AgentCreate,
|
||||||
AgentDeleteConfirm,
|
AgentDeleteConfirm,
|
||||||
@@ -57,7 +58,7 @@ def _workspace_path(agent_name: str, workspace_root: str | None) -> str:
|
|||||||
if not workspace_root:
|
if not workspace_root:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail="Board gateway_workspace_root is required",
|
detail="Gateway workspace_root is required",
|
||||||
)
|
)
|
||||||
root = workspace_root.rstrip("/")
|
root = workspace_root.rstrip("/")
|
||||||
return f"{root}/workspace-{_slugify(agent_name)}"
|
return f"{root}/workspace-{_slugify(agent_name)}"
|
||||||
@@ -75,28 +76,41 @@ def _require_board(session: Session, board_id: UUID | str | None) -> Board:
|
|||||||
return board
|
return board
|
||||||
|
|
||||||
|
|
||||||
def _require_gateway_config(board: Board) -> GatewayConfig:
|
def _require_gateway(
|
||||||
if not board.gateway_url:
|
session: Session, board: Board
|
||||||
|
) -> tuple[Gateway, GatewayClientConfig]:
|
||||||
|
if not board.gateway_id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
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(
|
async def _ensure_gateway_session(
|
||||||
agent_name: str,
|
agent_name: str,
|
||||||
config: GatewayConfig,
|
config: GatewayClientConfig,
|
||||||
) -> tuple[str, str | None]:
|
) -> tuple[str, str | None]:
|
||||||
session_key = _build_session_key(agent_name)
|
session_key = _build_session_key(agent_name)
|
||||||
try:
|
try:
|
||||||
@@ -148,7 +162,7 @@ def _record_wakeup_failure(session: Session, agent: Agent, error: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def _send_wakeup_message(
|
async def _send_wakeup_message(
|
||||||
agent: Agent, config: GatewayConfig, verb: str = "provisioned"
|
agent: Agent, config: GatewayClientConfig, verb: str = "provisioned"
|
||||||
) -> None:
|
) -> None:
|
||||||
session_key = agent.openclaw_session_id or _build_session_key(agent.name)
|
session_key = agent.openclaw_session_id or _build_session_key(agent.name)
|
||||||
await ensure_session(session_key, config=config, label=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),
|
auth: AuthContext = Depends(require_admin_auth),
|
||||||
) -> Agent:
|
) -> Agent:
|
||||||
board = _require_board(session, payload.board_id)
|
board = _require_board(session, payload.board_id)
|
||||||
config = _require_gateway_config(board)
|
gateway, client_config = _require_gateway(session, board)
|
||||||
agent = Agent.model_validate(payload)
|
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"
|
agent.status = "provisioning"
|
||||||
raw_token = generate_agent_token()
|
raw_token = generate_agent_token()
|
||||||
agent.agent_token_hash = hash_agent_token(raw_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_confirm_token_hash = hash_agent_token(provision_token)
|
||||||
agent.provision_requested_at = datetime.utcnow()
|
agent.provision_requested_at = datetime.utcnow()
|
||||||
agent.provision_action = "provision"
|
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
|
agent.openclaw_session_id = session_key
|
||||||
session.add(agent)
|
session.add(agent)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -209,7 +230,7 @@ async def create_agent(
|
|||||||
session.commit()
|
session.commit()
|
||||||
try:
|
try:
|
||||||
await send_provisioning_message(
|
await send_provisioning_message(
|
||||||
agent, board, raw_token, provision_token, auth.user
|
agent, board, gateway, raw_token, provision_token, auth.user
|
||||||
)
|
)
|
||||||
record_activity(
|
record_activity(
|
||||||
session,
|
session,
|
||||||
@@ -254,6 +275,10 @@ async def update_agent(
|
|||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="status is controlled by agent heartbeat",
|
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:
|
if not updates:
|
||||||
return _with_computed_status(agent)
|
return _with_computed_status(agent)
|
||||||
if "board_id" in updates:
|
if "board_id" in updates:
|
||||||
@@ -267,10 +292,10 @@ async def update_agent(
|
|||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(agent)
|
session.refresh(agent)
|
||||||
board = _require_board(session, agent.board_id)
|
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)
|
session_key = agent.openclaw_session_id or _build_session_key(agent.name)
|
||||||
try:
|
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:
|
if not agent.openclaw_session_id:
|
||||||
agent.openclaw_session_id = session_key
|
agent.openclaw_session_id = session_key
|
||||||
session.add(agent)
|
session.add(agent)
|
||||||
@@ -291,7 +316,7 @@ async def update_agent(
|
|||||||
session.refresh(agent)
|
session.refresh(agent)
|
||||||
try:
|
try:
|
||||||
await send_update_message(
|
await send_update_message(
|
||||||
agent, board, raw_token, provision_token, auth.user
|
agent, board, gateway, raw_token, provision_token, auth.user
|
||||||
)
|
)
|
||||||
record_activity(
|
record_activity(
|
||||||
session,
|
session,
|
||||||
@@ -345,7 +370,7 @@ async def heartbeat_or_create_agent(
|
|||||||
if actor.actor_type == "agent":
|
if actor.actor_type == "agent":
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
board = _require_board(session, payload.board_id)
|
board = _require_board(session, payload.board_id)
|
||||||
config = _require_gateway_config(board)
|
gateway, client_config = _require_gateway(session, board)
|
||||||
agent = Agent(
|
agent = Agent(
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
status="provisioning",
|
status="provisioning",
|
||||||
@@ -358,7 +383,9 @@ async def heartbeat_or_create_agent(
|
|||||||
agent.provision_confirm_token_hash = hash_agent_token(provision_token)
|
agent.provision_confirm_token_hash = hash_agent_token(provision_token)
|
||||||
agent.provision_requested_at = datetime.utcnow()
|
agent.provision_requested_at = datetime.utcnow()
|
||||||
agent.provision_action = "provision"
|
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
|
agent.openclaw_session_id = session_key
|
||||||
session.add(agent)
|
session.add(agent)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -380,7 +407,7 @@ async def heartbeat_or_create_agent(
|
|||||||
session.commit()
|
session.commit()
|
||||||
try:
|
try:
|
||||||
await send_provisioning_message(
|
await send_provisioning_message(
|
||||||
agent, board, raw_token, provision_token, actor.user
|
agent, board, gateway, raw_token, provision_token, actor.user
|
||||||
)
|
)
|
||||||
record_activity(
|
record_activity(
|
||||||
session,
|
session,
|
||||||
@@ -410,9 +437,9 @@ async def heartbeat_or_create_agent(
|
|||||||
session.refresh(agent)
|
session.refresh(agent)
|
||||||
try:
|
try:
|
||||||
board = _require_board(session, str(agent.board_id) if agent.board_id else None)
|
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(
|
await send_provisioning_message(
|
||||||
agent, board, raw_token, provision_token, actor.user
|
agent, board, gateway, raw_token, provision_token, actor.user
|
||||||
)
|
)
|
||||||
record_activity(
|
record_activity(
|
||||||
session,
|
session,
|
||||||
@@ -428,8 +455,10 @@ async def heartbeat_or_create_agent(
|
|||||||
session.commit()
|
session.commit()
|
||||||
elif not agent.openclaw_session_id:
|
elif not agent.openclaw_session_id:
|
||||||
board = _require_board(session, str(agent.board_id) if agent.board_id else None)
|
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)
|
||||||
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
|
agent.openclaw_session_id = session_key
|
||||||
if session_error:
|
if session_error:
|
||||||
record_activity(
|
record_activity(
|
||||||
@@ -472,7 +501,7 @@ def delete_agent(
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
board = _require_board(session, str(agent.board_id) if agent.board_id else None)
|
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()
|
raw_token = generate_agent_token()
|
||||||
agent.delete_confirm_token_hash = hash_agent_token(raw_token)
|
agent.delete_confirm_token_hash = hash_agent_token(raw_token)
|
||||||
agent.delete_requested_at = datetime.utcnow()
|
agent.delete_requested_at = datetime.utcnow()
|
||||||
@@ -488,10 +517,10 @@ def delete_agent(
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
async def _gateway_cleanup_request() -> None:
|
async def _gateway_cleanup_request() -> None:
|
||||||
main_session = board.gateway_main_session_key
|
main_session = gateway.main_session_key
|
||||||
if not main_session:
|
if not main_session:
|
||||||
raise OpenClawGatewayError("Board gateway_main_session_key is required")
|
raise OpenClawGatewayError("Gateway main_session_key is required")
|
||||||
workspace_path = _workspace_path(agent.name, board.gateway_workspace_root)
|
workspace_path = _workspace_path(agent.name, gateway.workspace_root)
|
||||||
base_url = settings.base_url or "REPLACE_WITH_BASE_URL"
|
base_url = settings.base_url or "REPLACE_WITH_BASE_URL"
|
||||||
cleanup_message = (
|
cleanup_message = (
|
||||||
"Cleanup request for deleted agent.\n\n"
|
"Cleanup request for deleted agent.\n\n"
|
||||||
@@ -507,11 +536,11 @@ def delete_agent(
|
|||||||
" Body: {\"token\": \"" + raw_token + "\"}\n"
|
" Body: {\"token\": \"" + raw_token + "\"}\n"
|
||||||
"Reply NO_REPLY."
|
"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(
|
await send_message(
|
||||||
cleanup_message,
|
cleanup_message,
|
||||||
session_key=main_session,
|
session_key=main_session,
|
||||||
config=config,
|
config=client_config,
|
||||||
deliver=False,
|
deliver=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -549,7 +578,7 @@ def confirm_provision_agent(
|
|||||||
if agent.board_id is None:
|
if agent.board_id is None:
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||||
board = _require_board(session, str(agent.board_id))
|
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"
|
action = payload.action or agent.provision_action or "provision"
|
||||||
verb = "updated" if action == "update" else "provisioned"
|
verb = "updated" if action == "update" else "provisioned"
|
||||||
@@ -557,7 +586,7 @@ def confirm_provision_agent(
|
|||||||
try:
|
try:
|
||||||
import asyncio
|
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:
|
except OpenClawGatewayError as exc:
|
||||||
_record_wakeup_failure(session, agent, str(exc))
|
_record_wakeup_failure(session, agent, str(exc))
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from app.api.deps import (
|
|||||||
from app.core.auth import AuthContext
|
from app.core.auth import AuthContext
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.integrations.openclaw_gateway import (
|
from app.integrations.openclaw_gateway import (
|
||||||
GatewayConfig,
|
GatewayConfig as GatewayClientConfig,
|
||||||
OpenClawGatewayError,
|
OpenClawGatewayError,
|
||||||
delete_session,
|
delete_session,
|
||||||
ensure_session,
|
ensure_session,
|
||||||
@@ -26,6 +26,7 @@ from app.integrations.openclaw_gateway import (
|
|||||||
from app.models.activity_events import ActivityEvent
|
from app.models.activity_events import ActivityEvent
|
||||||
from app.models.agents import Agent
|
from app.models.agents import Agent
|
||||||
from app.models.boards import Board
|
from app.models.boards import Board
|
||||||
|
from app.models.gateways import Gateway
|
||||||
from app.models.tasks import Task
|
from app.models.tasks import Task
|
||||||
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
|
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"
|
return f"{AGENT_SESSION_PREFIX}:{_slugify(agent_name)}:main"
|
||||||
|
|
||||||
|
|
||||||
def _board_gateway_config(board: Board) -> GatewayConfig | None:
|
def _board_gateway(
|
||||||
if not board.gateway_url:
|
session: Session, board: Board
|
||||||
return None
|
) -> tuple[Gateway | None, GatewayClientConfig | None]:
|
||||||
if not board.gateway_main_session_key:
|
if not board.gateway_id:
|
||||||
|
return None, None
|
||||||
|
config = session.get(Gateway, board.gateway_id)
|
||||||
|
if config is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
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:
|
if agent.openclaw_session_id:
|
||||||
await delete_session(agent.openclaw_session_id, config=config)
|
await delete_session(agent.openclaw_session_id, config=client_config)
|
||||||
if not board.gateway_main_session_key:
|
main_session = config.main_session_key
|
||||||
raise OpenClawGatewayError("Board gateway_main_session_key is required")
|
workspace_root = config.workspace_root
|
||||||
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
|
|
||||||
workspace_path = f"{workspace_root.rstrip('/')}/workspace-{_slugify(agent.name)}"
|
workspace_path = f"{workspace_root.rstrip('/')}/workspace-{_slugify(agent.name)}"
|
||||||
cleanup_message = (
|
cleanup_message = (
|
||||||
"Cleanup request for deleted agent.\n\n"
|
"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"
|
"2) Delete any lingering session artifacts.\n"
|
||||||
"Reply NO_REPLY."
|
"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, deliver=False)
|
await send_message(
|
||||||
|
cleanup_message,
|
||||||
|
session_key=main_session,
|
||||||
|
config=client_config,
|
||||||
|
deliver=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[BoardRead])
|
@router.get("", response_model=list[BoardRead])
|
||||||
@@ -99,22 +118,16 @@ def create_board(
|
|||||||
auth: AuthContext = Depends(require_admin_auth),
|
auth: AuthContext = Depends(require_admin_auth),
|
||||||
) -> Board:
|
) -> Board:
|
||||||
data = payload.model_dump()
|
data = payload.model_dump()
|
||||||
if data.get("gateway_token") == "":
|
if not data.get("gateway_id"):
|
||||||
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail="gateway_main_session_key is required when gateway_url is set",
|
detail="gateway_id is required",
|
||||||
)
|
)
|
||||||
if not data.get("gateway_workspace_root"):
|
config = session.get(Gateway, data["gateway_id"])
|
||||||
|
if config is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail="gateway_workspace_root is required when gateway_url is set",
|
detail="gateway_id is invalid",
|
||||||
)
|
)
|
||||||
board = Board.model_validate(data)
|
board = Board.model_validate(data)
|
||||||
session.add(board)
|
session.add(board)
|
||||||
@@ -139,24 +152,24 @@ def update_board(
|
|||||||
auth: AuthContext = Depends(require_admin_auth),
|
auth: AuthContext = Depends(require_admin_auth),
|
||||||
) -> Board:
|
) -> Board:
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
if updates.get("gateway_token") == "":
|
if "gateway_id" in updates:
|
||||||
updates["gateway_token"] = None
|
if not updates.get("gateway_id"):
|
||||||
if updates.get("identity_template") == "":
|
raise HTTPException(
|
||||||
updates["identity_template"] = None
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
if updates.get("soul_template") == "":
|
detail="gateway_id is required",
|
||||||
updates["soul_template"] = None
|
)
|
||||||
|
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():
|
for key, value in updates.items():
|
||||||
setattr(board, key, value)
|
setattr(board, key, value)
|
||||||
if board.gateway_url:
|
if not board.gateway_id:
|
||||||
if not board.gateway_main_session_key:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail="gateway_main_session_key is required when gateway_url is set",
|
detail="gateway_id is required",
|
||||||
)
|
|
||||||
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",
|
|
||||||
)
|
)
|
||||||
session.add(board)
|
session.add(board)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -175,11 +188,11 @@ def delete_board(
|
|||||||
session.exec(select(Task.id).where(Task.board_id == board.id))
|
session.exec(select(Task.id).where(Task.board_id == board.id))
|
||||||
)
|
)
|
||||||
|
|
||||||
config = _board_gateway_config(board)
|
config, client_config = _board_gateway(session, board)
|
||||||
if config:
|
if config and client_config:
|
||||||
try:
|
try:
|
||||||
for agent in agents:
|
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:
|
except OpenClawGatewayError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
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 fastapi import APIRouter, Body, Depends, HTTPException, Query, status
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.api.deps import require_admin_auth
|
from app.core.auth import AuthContext, get_auth_context
|
||||||
from app.core.auth import AuthContext
|
|
||||||
from app.integrations.openclaw_gateway import (
|
from app.integrations.openclaw_gateway import (
|
||||||
GatewayConfig,
|
GatewayConfig as GatewayClientConfig,
|
||||||
OpenClawGatewayError,
|
OpenClawGatewayError,
|
||||||
ensure_session,
|
ensure_session,
|
||||||
get_chat_history,
|
get_chat_history,
|
||||||
@@ -20,19 +19,24 @@ from app.integrations.openclaw_gateway_protocol import (
|
|||||||
)
|
)
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models.boards import Board
|
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,
|
session: Session,
|
||||||
board_id: str | None,
|
board_id: str | None,
|
||||||
gateway_url: str | None,
|
gateway_url: str | None,
|
||||||
gateway_token: str | None,
|
gateway_token: str | None,
|
||||||
gateway_main_session_key: str | None,
|
gateway_main_session_key: str | None,
|
||||||
) -> tuple[Board | None, GatewayConfig, str | None]:
|
) -> tuple[Board | None, GatewayClientConfig, str | None]:
|
||||||
if gateway_url:
|
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:
|
if not board_id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
@@ -41,24 +45,53 @@ def _resolve_gateway_config(
|
|||||||
board = session.get(Board, board_id)
|
board = session.get(Board, board_id)
|
||||||
if board is None:
|
if board is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
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")
|
@router.get("/status")
|
||||||
async def gateway_status(
|
async def gateways_status(
|
||||||
board_id: str | None = Query(default=None),
|
board_id: str | None = Query(default=None),
|
||||||
gateway_url: str | None = Query(default=None),
|
gateway_url: str | None = Query(default=None),
|
||||||
gateway_token: str | None = Query(default=None),
|
gateway_token: str | None = Query(default=None),
|
||||||
gateway_main_session_key: str | None = Query(default=None),
|
gateway_main_session_key: str | None = Query(default=None),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
board, config, main_session = _resolve_gateway_config(
|
board, config, main_session = _resolve_gateway(
|
||||||
session,
|
session,
|
||||||
board_id,
|
board_id,
|
||||||
gateway_url,
|
gateway_url,
|
||||||
@@ -100,12 +133,12 @@ async def gateway_status(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/sessions")
|
@router.get("/sessions")
|
||||||
async def list_sessions(
|
async def list_gateway_sessions(
|
||||||
board_id: str | None = Query(default=None),
|
board_id: str | None = Query(default=None),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
board, config, main_session = _resolve_gateway_config(
|
board, config, main_session = _resolve_gateway(
|
||||||
session,
|
session,
|
||||||
board_id,
|
board_id,
|
||||||
None,
|
None,
|
||||||
@@ -144,9 +177,9 @@ async def get_gateway_session(
|
|||||||
session_id: str,
|
session_id: str,
|
||||||
board_id: str | None = Query(default=None),
|
board_id: str | None = Query(default=None),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
board, config, main_session = _resolve_gateway_config(
|
board, config, main_session = _resolve_gateway(
|
||||||
session,
|
session,
|
||||||
board_id,
|
board_id,
|
||||||
None,
|
None,
|
||||||
@@ -161,7 +194,6 @@ async def get_gateway_session(
|
|||||||
sessions_list = list(sessions.get("sessions") or [])
|
sessions_list = list(sessions.get("sessions") or [])
|
||||||
else:
|
else:
|
||||||
sessions_list = list(sessions or [])
|
sessions_list = list(sessions or [])
|
||||||
main_session = board.gateway_main_session_key
|
|
||||||
if main_session and not any(
|
if main_session and not any(
|
||||||
session.get("key") == main_session for session in sessions_list
|
session.get("key") == main_session for session in sessions_list
|
||||||
):
|
):
|
||||||
@@ -194,9 +226,9 @@ async def get_session_history(
|
|||||||
session_id: str,
|
session_id: str,
|
||||||
board_id: str | None = Query(default=None),
|
board_id: str | None = Query(default=None),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
_, config = _require_board_config(session, board_id)
|
_, config, _ = _require_gateway(session, board_id)
|
||||||
try:
|
try:
|
||||||
history = await get_chat_history(session_id, config=config)
|
history = await get_chat_history(session_id, config=config)
|
||||||
except OpenClawGatewayError as exc:
|
except OpenClawGatewayError as exc:
|
||||||
@@ -207,21 +239,20 @@ async def get_session_history(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/sessions/{session_id}/message")
|
@router.post("/sessions/{session_id}/message")
|
||||||
async def send_session_message(
|
async def send_gateway_session_message(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
payload: dict = Body(...),
|
payload: dict = Body(...),
|
||||||
board_id: str | None = Query(default=None),
|
board_id: str | None = Query(default=None),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
) -> dict[str, bool]:
|
) -> dict[str, bool]:
|
||||||
content = payload.get("content")
|
content = payload.get("content")
|
||||||
if not content:
|
if not content:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="content is required"
|
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:
|
try:
|
||||||
main_session = board.gateway_main_session_key
|
|
||||||
if main_session and session_id == main_session:
|
if main_session and session_id == main_session:
|
||||||
await ensure_session(main_session, config=config, label="Main Agent")
|
await ensure_session(main_session, config=config, label="Main Agent")
|
||||||
await send_message(content, session_key=session_id, config=config)
|
await send_message(content, session_key=session_id, config=config)
|
||||||
@@ -232,7 +263,7 @@ async def send_session_message(
|
|||||||
|
|
||||||
@router.get("/commands")
|
@router.get("/commands")
|
||||||
async def gateway_commands(
|
async def gateway_commands(
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"protocol_version": PROTOCOL_VERSION,
|
"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.auth import router as auth_router
|
||||||
from app.api.boards import router as boards_router
|
from app.api.boards import router as boards_router
|
||||||
from app.api.gateway import router as gateway_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.metrics import router as metrics_router
|
||||||
from app.api.tasks import router as tasks_router
|
from app.api.tasks import router as tasks_router
|
||||||
from app.api.users import router as users_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(agents_router)
|
||||||
api_v1.include_router(activity_router)
|
api_v1.include_router(activity_router)
|
||||||
api_v1.include_router(gateway_router)
|
api_v1.include_router(gateway_router)
|
||||||
|
api_v1.include_router(gateways_router)
|
||||||
api_v1.include_router(metrics_router)
|
api_v1.include_router(metrics_router)
|
||||||
api_v1.include_router(boards_router)
|
api_v1.include_router(boards_router)
|
||||||
api_v1.include_router(tasks_router)
|
api_v1.include_router(tasks_router)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from app.models.activity_events import ActivityEvent
|
from app.models.activity_events import ActivityEvent
|
||||||
from app.models.agents import Agent
|
from app.models.agents import Agent
|
||||||
from app.models.boards import Board
|
from app.models.boards import Board
|
||||||
|
from app.models.gateways import Gateway
|
||||||
from app.models.tasks import Task
|
from app.models.tasks import Task
|
||||||
from app.models.users import User
|
from app.models.users import User
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ __all__ = [
|
|||||||
"ActivityEvent",
|
"ActivityEvent",
|
||||||
"Agent",
|
"Agent",
|
||||||
"Board",
|
"Board",
|
||||||
|
"Gateway",
|
||||||
"Task",
|
"Task",
|
||||||
"User",
|
"User",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from sqlalchemy import Column, JSON
|
from sqlalchemy import Column, JSON, Text
|
||||||
from sqlmodel import Field, SQLModel
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
@@ -20,6 +20,8 @@ class Agent(SQLModel, table=True):
|
|||||||
heartbeat_config: dict[str, Any] | None = Field(
|
heartbeat_config: dict[str, Any] | None = Field(
|
||||||
default=None, sa_column=Column(JSON)
|
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_requested_at: datetime | None = Field(default=None)
|
||||||
provision_confirm_token_hash: str | None = Field(default=None, index=True)
|
provision_confirm_token_hash: str | None = Field(default=None, index=True)
|
||||||
provision_action: 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)
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
name: str
|
name: str
|
||||||
slug: str = Field(index=True)
|
slug: str = Field(index=True)
|
||||||
gateway_url: str | None = Field(default=None)
|
gateway_id: UUID | None = Field(default=None, foreign_key="gateways.id", index=True)
|
||||||
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)
|
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
updated_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.activity_events import ActivityEventRead
|
||||||
from app.schemas.agents import AgentCreate, AgentRead, AgentUpdate
|
from app.schemas.agents import AgentCreate, AgentRead, AgentUpdate
|
||||||
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
|
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.metrics import DashboardMetrics
|
||||||
from app.schemas.tasks import TaskCreate, TaskRead, TaskUpdate
|
from app.schemas.tasks import TaskCreate, TaskRead, TaskUpdate
|
||||||
from app.schemas.users import UserCreate, UserRead, UserUpdate
|
from app.schemas.users import UserCreate, UserRead, UserUpdate
|
||||||
@@ -13,6 +14,9 @@ __all__ = [
|
|||||||
"BoardCreate",
|
"BoardCreate",
|
||||||
"BoardRead",
|
"BoardRead",
|
||||||
"BoardUpdate",
|
"BoardUpdate",
|
||||||
|
"GatewayCreate",
|
||||||
|
"GatewayRead",
|
||||||
|
"GatewayUpdate",
|
||||||
"DashboardMetrics",
|
"DashboardMetrics",
|
||||||
"TaskCreate",
|
"TaskCreate",
|
||||||
"TaskRead",
|
"TaskRead",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class AgentBase(SQLModel):
|
|||||||
name: str
|
name: str
|
||||||
status: str = "provisioning"
|
status: str = "provisioning"
|
||||||
heartbeat_config: dict[str, Any] | None = None
|
heartbeat_config: dict[str, Any] | None = None
|
||||||
|
identity_template: str | None = None
|
||||||
|
soul_template: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AgentCreate(AgentBase):
|
class AgentCreate(AgentBase):
|
||||||
@@ -23,6 +25,8 @@ class AgentUpdate(SQLModel):
|
|||||||
name: str | None = None
|
name: str | None = None
|
||||||
status: str | None = None
|
status: str | None = None
|
||||||
heartbeat_config: dict[str, Any] | None = None
|
heartbeat_config: dict[str, Any] | None = None
|
||||||
|
identity_template: str | None = None
|
||||||
|
soul_template: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AgentRead(AgentBase):
|
class AgentRead(AgentBase):
|
||||||
|
|||||||
@@ -9,26 +9,17 @@ from sqlmodel import SQLModel
|
|||||||
class BoardBase(SQLModel):
|
class BoardBase(SQLModel):
|
||||||
name: str
|
name: str
|
||||||
slug: str
|
slug: str
|
||||||
gateway_url: str | None = None
|
gateway_id: UUID | 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
|
|
||||||
|
|
||||||
|
|
||||||
class BoardCreate(BoardBase):
|
class BoardCreate(BoardBase):
|
||||||
gateway_token: str | None = None
|
pass
|
||||||
|
|
||||||
|
|
||||||
class BoardUpdate(SQLModel):
|
class BoardUpdate(SQLModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
slug: str | None = None
|
slug: str | None = None
|
||||||
gateway_url: str | None = None
|
gateway_id: UUID | 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
|
|
||||||
|
|
||||||
|
|
||||||
class BoardRead(BoardBase):
|
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 jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
|
||||||
|
|
||||||
from app.core.config import settings
|
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.agents import Agent
|
||||||
from app.models.boards import Board
|
from app.models.boards import Board
|
||||||
|
from app.models.gateways import Gateway
|
||||||
from app.models.users import User
|
from app.models.users import User
|
||||||
|
|
||||||
TEMPLATE_FILES = [
|
TEMPLATE_FILES = [
|
||||||
@@ -99,18 +104,22 @@ def _workspace_path(agent_name: str, workspace_root: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _build_context(
|
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]:
|
) -> dict[str, str]:
|
||||||
if not board.gateway_workspace_root:
|
if not gateway.workspace_root:
|
||||||
raise ValueError("gateway_workspace_root is required")
|
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")
|
raise ValueError("gateway_main_session_key is required")
|
||||||
agent_id = str(agent.id)
|
agent_id = str(agent.id)
|
||||||
workspace_root = board.gateway_workspace_root
|
workspace_root = gateway.workspace_root
|
||||||
workspace_path = _workspace_path(agent.name, workspace_root)
|
workspace_path = _workspace_path(agent.name, workspace_root)
|
||||||
session_key = agent.openclaw_session_id or ""
|
session_key = agent.openclaw_session_id or ""
|
||||||
base_url = settings.base_url or "REPLACE_WITH_BASE_URL"
|
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 {
|
return {
|
||||||
"agent_name": agent.name,
|
"agent_name": agent.name,
|
||||||
"agent_id": agent_id,
|
"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] = {}
|
overrides: dict[str, str] = {}
|
||||||
if board.identity_template:
|
if agent.identity_template:
|
||||||
overrides["IDENTITY.md"] = board.identity_template
|
overrides["IDENTITY.md"] = agent.identity_template
|
||||||
if board.soul_template:
|
if agent.soul_template:
|
||||||
overrides["SOUL.md"] = board.soul_template
|
overrides["SOUL.md"] = agent.soul_template
|
||||||
templates = _read_templates(context, overrides=overrides)
|
templates = _read_templates(context, overrides=overrides)
|
||||||
return "".join(
|
return "".join(
|
||||||
_render_file_block(name, templates.get(name, "")) for name in TEMPLATE_FILES
|
_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(
|
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:
|
) -> str:
|
||||||
context = _build_context(agent, board, auth_token, user)
|
context = _build_context(agent, board, gateway, auth_token, user)
|
||||||
file_blocks = _build_file_blocks(context, board)
|
file_blocks = _build_file_blocks(context, agent)
|
||||||
heartbeat_snippet = json.dumps(
|
heartbeat_snippet = json.dumps(
|
||||||
{
|
{
|
||||||
"id": _agent_key(agent),
|
"id": _agent_key(agent),
|
||||||
@@ -190,10 +204,15 @@ def build_provisioning_message(
|
|||||||
|
|
||||||
|
|
||||||
def build_update_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:
|
) -> str:
|
||||||
context = _build_context(agent, board, auth_token, user)
|
context = _build_context(agent, board, gateway, auth_token, user)
|
||||||
file_blocks = _build_file_blocks(context, board)
|
file_blocks = _build_file_blocks(context, agent)
|
||||||
heartbeat_snippet = json.dumps(
|
heartbeat_snippet = json.dumps(
|
||||||
{
|
{
|
||||||
"id": _agent_key(agent),
|
"id": _agent_key(agent),
|
||||||
@@ -238,34 +257,48 @@ def build_update_message(
|
|||||||
async def send_provisioning_message(
|
async def send_provisioning_message(
|
||||||
agent: Agent,
|
agent: Agent,
|
||||||
board: Board,
|
board: Board,
|
||||||
|
gateway: Gateway,
|
||||||
auth_token: str,
|
auth_token: str,
|
||||||
confirm_token: str,
|
confirm_token: str,
|
||||||
user: User | None,
|
user: User | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not board.gateway_url:
|
if not gateway.url:
|
||||||
return
|
return
|
||||||
if not board.gateway_main_session_key:
|
if not gateway.main_session_key:
|
||||||
raise ValueError("gateway_main_session_key is required")
|
raise ValueError("gateway_main_session_key is required")
|
||||||
main_session = board.gateway_main_session_key
|
main_session = gateway.main_session_key
|
||||||
config = GatewayConfig(url=board.gateway_url, token=board.gateway_token)
|
client_config = GatewayClientConfig(
|
||||||
await ensure_session(main_session, config=config, label="Main Agent")
|
url=gateway.url, token=gateway.token
|
||||||
message = build_provisioning_message(agent, board, auth_token, confirm_token, user)
|
)
|
||||||
await send_message(message, session_key=main_session, config=config, deliver=False)
|
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(
|
async def send_update_message(
|
||||||
agent: Agent,
|
agent: Agent,
|
||||||
board: Board,
|
board: Board,
|
||||||
|
gateway: Gateway,
|
||||||
auth_token: str,
|
auth_token: str,
|
||||||
confirm_token: str,
|
confirm_token: str,
|
||||||
user: User | None,
|
user: User | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not board.gateway_url:
|
if not gateway.url:
|
||||||
return
|
return
|
||||||
if not board.gateway_main_session_key:
|
if not gateway.main_session_key:
|
||||||
raise ValueError("gateway_main_session_key is required")
|
raise ValueError("gateway_main_session_key is required")
|
||||||
main_session = board.gateway_main_session_key
|
main_session = gateway.main_session_key
|
||||||
config = GatewayConfig(url=board.gateway_url, token=board.gateway_token)
|
client_config = GatewayClientConfig(
|
||||||
await ensure_session(main_session, config=config, label="Main Agent")
|
url=gateway.url, token=gateway.token
|
||||||
message = build_update_message(agent, board, auth_token, confirm_token, user)
|
)
|
||||||
await send_message(message, session_key=main_session, config=config, deliver=False)
|
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();
|
const stringifiedParams = normalizedParams.toString();
|
||||||
|
|
||||||
return stringifiedParams.length > 0
|
return stringifiedParams.length > 0
|
||||||
? `/api/v1/gateway/status?${stringifiedParams}`
|
? `/api/v1/gateways/status?${stringifiedParams}`
|
||||||
: `/api/v1/gateway/status`;
|
: `/api/v1/gateways/status`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const gatewayStatusApiV1GatewayStatusGet = async (
|
export const gatewayStatusApiV1GatewayStatusGet = async (
|
||||||
@@ -100,7 +100,7 @@ export const gatewayStatusApiV1GatewayStatusGet = async (
|
|||||||
export const getGatewayStatusApiV1GatewayStatusGetQueryKey = (
|
export const getGatewayStatusApiV1GatewayStatusGetQueryKey = (
|
||||||
params?: GatewayStatusApiV1GatewayStatusGetParams,
|
params?: GatewayStatusApiV1GatewayStatusGetParams,
|
||||||
) => {
|
) => {
|
||||||
return [`/api/v1/gateway/status`, ...(params ? [params] : [])] as const;
|
return [`/api/v1/gateways/status`, ...(params ? [params] : [])] as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getGatewayStatusApiV1GatewayStatusGetQueryOptions = <
|
export const getGatewayStatusApiV1GatewayStatusGetQueryOptions = <
|
||||||
@@ -291,8 +291,8 @@ export const getListSessionsApiV1GatewaySessionsGetUrl = (
|
|||||||
const stringifiedParams = normalizedParams.toString();
|
const stringifiedParams = normalizedParams.toString();
|
||||||
|
|
||||||
return stringifiedParams.length > 0
|
return stringifiedParams.length > 0
|
||||||
? `/api/v1/gateway/sessions?${stringifiedParams}`
|
? `/api/v1/gateways/sessions?${stringifiedParams}`
|
||||||
: `/api/v1/gateway/sessions`;
|
: `/api/v1/gateways/sessions`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const listSessionsApiV1GatewaySessionsGet = async (
|
export const listSessionsApiV1GatewaySessionsGet = async (
|
||||||
@@ -311,7 +311,7 @@ export const listSessionsApiV1GatewaySessionsGet = async (
|
|||||||
export const getListSessionsApiV1GatewaySessionsGetQueryKey = (
|
export const getListSessionsApiV1GatewaySessionsGetQueryKey = (
|
||||||
params?: ListSessionsApiV1GatewaySessionsGetParams,
|
params?: ListSessionsApiV1GatewaySessionsGetParams,
|
||||||
) => {
|
) => {
|
||||||
return [`/api/v1/gateway/sessions`, ...(params ? [params] : [])] as const;
|
return [`/api/v1/gateways/sessions`, ...(params ? [params] : [])] as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getListSessionsApiV1GatewaySessionsGetQueryOptions = <
|
export const getListSessionsApiV1GatewaySessionsGetQueryOptions = <
|
||||||
@@ -503,8 +503,8 @@ export const getGetGatewaySessionApiV1GatewaySessionsSessionIdGetUrl = (
|
|||||||
const stringifiedParams = normalizedParams.toString();
|
const stringifiedParams = normalizedParams.toString();
|
||||||
|
|
||||||
return stringifiedParams.length > 0
|
return stringifiedParams.length > 0
|
||||||
? `/api/v1/gateway/sessions/${sessionId}?${stringifiedParams}`
|
? `/api/v1/gateways/sessions/${sessionId}?${stringifiedParams}`
|
||||||
: `/api/v1/gateway/sessions/${sessionId}`;
|
: `/api/v1/gateways/sessions/${sessionId}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getGatewaySessionApiV1GatewaySessionsSessionIdGet = async (
|
export const getGatewaySessionApiV1GatewaySessionsSessionIdGet = async (
|
||||||
@@ -526,7 +526,7 @@ export const getGetGatewaySessionApiV1GatewaySessionsSessionIdGetQueryKey = (
|
|||||||
params?: GetGatewaySessionApiV1GatewaySessionsSessionIdGetParams,
|
params?: GetGatewaySessionApiV1GatewaySessionsSessionIdGetParams,
|
||||||
) => {
|
) => {
|
||||||
return [
|
return [
|
||||||
`/api/v1/gateway/sessions/${sessionId}`,
|
`/api/v1/gateways/sessions/${sessionId}`,
|
||||||
...(params ? [params] : []),
|
...(params ? [params] : []),
|
||||||
] as const;
|
] as const;
|
||||||
};
|
};
|
||||||
@@ -777,8 +777,8 @@ export const getGetSessionHistoryApiV1GatewaySessionsSessionIdHistoryGetUrl = (
|
|||||||
const stringifiedParams = normalizedParams.toString();
|
const stringifiedParams = normalizedParams.toString();
|
||||||
|
|
||||||
return stringifiedParams.length > 0
|
return stringifiedParams.length > 0
|
||||||
? `/api/v1/gateway/sessions/${sessionId}/history?${stringifiedParams}`
|
? `/api/v1/gateways/sessions/${sessionId}/history?${stringifiedParams}`
|
||||||
: `/api/v1/gateway/sessions/${sessionId}/history`;
|
: `/api/v1/gateways/sessions/${sessionId}/history`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSessionHistoryApiV1GatewaySessionsSessionIdHistoryGet = async (
|
export const getSessionHistoryApiV1GatewaySessionsSessionIdHistoryGet = async (
|
||||||
@@ -804,7 +804,7 @@ export const getGetSessionHistoryApiV1GatewaySessionsSessionIdHistoryGetQueryKey
|
|||||||
params?: GetSessionHistoryApiV1GatewaySessionsSessionIdHistoryGetParams,
|
params?: GetSessionHistoryApiV1GatewaySessionsSessionIdHistoryGetParams,
|
||||||
) => {
|
) => {
|
||||||
return [
|
return [
|
||||||
`/api/v1/gateway/sessions/${sessionId}/history`,
|
`/api/v1/gateways/sessions/${sessionId}/history`,
|
||||||
...(params ? [params] : []),
|
...(params ? [params] : []),
|
||||||
] as const;
|
] as const;
|
||||||
};
|
};
|
||||||
@@ -1088,8 +1088,8 @@ export const getSendSessionMessageApiV1GatewaySessionsSessionIdMessagePostUrl =
|
|||||||
const stringifiedParams = normalizedParams.toString();
|
const stringifiedParams = normalizedParams.toString();
|
||||||
|
|
||||||
return stringifiedParams.length > 0
|
return stringifiedParams.length > 0
|
||||||
? `/api/v1/gateway/sessions/${sessionId}/message?${stringifiedParams}`
|
? `/api/v1/gateways/sessions/${sessionId}/message?${stringifiedParams}`
|
||||||
: `/api/v1/gateway/sessions/${sessionId}/message`;
|
: `/api/v1/gateways/sessions/${sessionId}/message`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendSessionMessageApiV1GatewaySessionsSessionIdMessagePost =
|
export const sendSessionMessageApiV1GatewaySessionsSessionIdMessagePost =
|
||||||
@@ -1257,7 +1257,7 @@ export type gatewayCommandsApiV1GatewayCommandsGetResponse =
|
|||||||
gatewayCommandsApiV1GatewayCommandsGetResponseSuccess;
|
gatewayCommandsApiV1GatewayCommandsGetResponseSuccess;
|
||||||
|
|
||||||
export const getGatewayCommandsApiV1GatewayCommandsGetUrl = () => {
|
export const getGatewayCommandsApiV1GatewayCommandsGetUrl = () => {
|
||||||
return `/api/v1/gateway/commands`;
|
return `/api/v1/gateways/commands`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const gatewayCommandsApiV1GatewayCommandsGet = async (
|
export const gatewayCommandsApiV1GatewayCommandsGet = async (
|
||||||
@@ -1273,7 +1273,7 @@ export const gatewayCommandsApiV1GatewayCommandsGet = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getGatewayCommandsApiV1GatewayCommandsGetQueryKey = () => {
|
export const getGatewayCommandsApiV1GatewayCommandsGetQueryKey = () => {
|
||||||
return [`/api/v1/gateway/commands`] as const;
|
return [`/api/v1/gateways/commands`] as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getGatewayCommandsApiV1GatewayCommandsGetQueryOptions = <
|
export const getGatewayCommandsApiV1GatewayCommandsGetQueryOptions = <
|
||||||
|
|||||||
@@ -8,10 +8,5 @@
|
|||||||
export interface BoardCreate {
|
export interface BoardCreate {
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
gateway_url?: string | null;
|
gateway_id?: 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,7 @@
|
|||||||
export interface BoardRead {
|
export interface BoardRead {
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
gateway_url?: string | null;
|
gateway_id?: string | null;
|
||||||
gateway_main_session_key?: string | null;
|
|
||||||
gateway_workspace_root?: string | null;
|
|
||||||
identity_template?: string | null;
|
|
||||||
soul_template?: string | null;
|
|
||||||
id: string;
|
id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|||||||
@@ -8,10 +8,5 @@
|
|||||||
export interface BoardUpdate {
|
export interface BoardUpdate {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
slug?: string | null;
|
slug?: string | null;
|
||||||
gateway_url?: string | null;
|
gateway_id?: 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
|||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { getApiBaseUrl } from "@/lib/api-base";
|
import { getApiBaseUrl } from "@/lib/api-base";
|
||||||
|
import {
|
||||||
|
DEFAULT_IDENTITY_TEMPLATE,
|
||||||
|
DEFAULT_SOUL_TEMPLATE,
|
||||||
|
} from "@/lib/agent-templates";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -28,6 +33,8 @@ type Agent = {
|
|||||||
every?: string;
|
every?: string;
|
||||||
target?: string;
|
target?: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
identity_template?: string | null;
|
||||||
|
soul_template?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Board = {
|
type Board = {
|
||||||
@@ -49,6 +56,10 @@ export default function EditAgentPage() {
|
|||||||
const [boardId, setBoardId] = useState("");
|
const [boardId, setBoardId] = useState("");
|
||||||
const [heartbeatEvery, setHeartbeatEvery] = useState("10m");
|
const [heartbeatEvery, setHeartbeatEvery] = useState("10m");
|
||||||
const [heartbeatTarget, setHeartbeatTarget] = useState("none");
|
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 [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -93,6 +104,10 @@ export default function EditAgentPage() {
|
|||||||
if (data.heartbeat_config?.target) {
|
if (data.heartbeat_config?.target) {
|
||||||
setHeartbeatTarget(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) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
setError(err instanceof Error ? err.message : "Something went wrong.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -146,6 +161,8 @@ export default function EditAgentPage() {
|
|||||||
every: heartbeatEvery.trim() || "10m",
|
every: heartbeatEvery.trim() || "10m",
|
||||||
target: heartbeatTarget,
|
target: heartbeatTarget,
|
||||||
},
|
},
|
||||||
|
identity_template: identityTemplate.trim() || null,
|
||||||
|
soul_template: soulTemplate.trim() || null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -201,7 +218,7 @@ export default function EditAgentPage() {
|
|||||||
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Agent name
|
Agent name <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={name}
|
value={name}
|
||||||
@@ -212,7 +229,7 @@ export default function EditAgentPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Board
|
Board <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={boardId}
|
value={boardId}
|
||||||
@@ -239,6 +256,36 @@ export default function EditAgentPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
Heartbeat settings
|
Heartbeat settings
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
|||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { getApiBaseUrl } from "@/lib/api-base";
|
import { getApiBaseUrl } from "@/lib/api-base";
|
||||||
|
import {
|
||||||
|
DEFAULT_IDENTITY_TEMPLATE,
|
||||||
|
DEFAULT_SOUL_TEMPLATE,
|
||||||
|
} from "@/lib/agent-templates";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -40,6 +45,10 @@ export default function NewAgentPage() {
|
|||||||
const [boardId, setBoardId] = useState<string>("");
|
const [boardId, setBoardId] = useState<string>("");
|
||||||
const [heartbeatEvery, setHeartbeatEvery] = useState("10m");
|
const [heartbeatEvery, setHeartbeatEvery] = useState("10m");
|
||||||
const [heartbeatTarget, setHeartbeatTarget] = useState("none");
|
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 [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -97,6 +106,8 @@ export default function NewAgentPage() {
|
|||||||
every: heartbeatEvery.trim() || "10m",
|
every: heartbeatEvery.trim() || "10m",
|
||||||
target: heartbeatTarget,
|
target: heartbeatTarget,
|
||||||
},
|
},
|
||||||
|
identity_template: identityTemplate.trim() || null,
|
||||||
|
soul_template: soulTemplate.trim() || null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -153,7 +164,7 @@ export default function NewAgentPage() {
|
|||||||
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Agent name
|
Agent name <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={name}
|
value={name}
|
||||||
@@ -164,7 +175,7 @@ export default function NewAgentPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Board
|
Board <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={boardId}
|
value={boardId}
|
||||||
@@ -191,6 +202,36 @@ export default function NewAgentPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
Heartbeat settings
|
Heartbeat settings
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export default function AgentsPage() {
|
|||||||
try {
|
try {
|
||||||
const token = await getToken();
|
const token = await getToken();
|
||||||
const response = await fetch(
|
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}` : "" } }
|
{ headers: { Authorization: token ? `Bearer ${token}` : "" } }
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -1,70 +1,45 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
||||||
|
import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
|
||||||
|
|
||||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { getApiBaseUrl } from "@/lib/api-base";
|
||||||
import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
|
|
||||||
|
|
||||||
const apiBase = getApiBaseUrl();
|
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 }}
|
type Gateway = {
|
||||||
|
id: string;
|
||||||
Creature: AI
|
name: string;
|
||||||
|
url: string;
|
||||||
Vibe: calm, precise, helpful
|
token?: string | null;
|
||||||
|
main_session_key: string;
|
||||||
Emoji: :gear:
|
workspace_root: string;
|
||||||
`;
|
skyll_enabled?: boolean;
|
||||||
|
};
|
||||||
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 validateGatewayUrl = (value: string) => {
|
const validateGatewayUrl = (value: string) => {
|
||||||
const trimmed = value.trim();
|
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) =>
|
const slugify = (value: string) =>
|
||||||
value
|
value
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -110,16 +74,21 @@ export default function EditBoardPage() {
|
|||||||
|
|
||||||
const [board, setBoard] = useState<Board | null>(null);
|
const [board, setBoard] = useState<Board | null>(null);
|
||||||
const [name, setName] = useState("");
|
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 [gatewayUrl, setGatewayUrl] = useState("");
|
||||||
const [gatewayToken, setGatewayToken] = useState("");
|
const [gatewayToken, setGatewayToken] = useState("");
|
||||||
const [gatewayMainSessionKey, setGatewayMainSessionKey] = useState("");
|
const [gatewayMainSessionKey, setGatewayMainSessionKey] = useState(
|
||||||
const [gatewayWorkspaceRoot, setGatewayWorkspaceRoot] = useState("");
|
DEFAULT_MAIN_SESSION_KEY
|
||||||
const [identityTemplate, setIdentityTemplate] = useState(
|
|
||||||
DEFAULT_IDENTITY_TEMPLATE
|
|
||||||
);
|
);
|
||||||
const [soulTemplate, setSoulTemplate] = useState(DEFAULT_SOUL_TEMPLATE);
|
const [gatewayWorkspaceRoot, setGatewayWorkspaceRoot] = useState(
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
DEFAULT_WORKSPACE_ROOT
|
||||||
const [error, setError] = useState<string | null>(null);
|
);
|
||||||
|
const [skyllEnabled, setSkyllEnabled] = useState(false);
|
||||||
|
|
||||||
const [gatewayUrlError, setGatewayUrlError] = useState<string | null>(null);
|
const [gatewayUrlError, setGatewayUrlError] = useState<string | null>(null);
|
||||||
const [gatewayCheckStatus, setGatewayCheckStatus] = useState<
|
const [gatewayCheckStatus, setGatewayCheckStatus] = useState<
|
||||||
"idle" | "checking" | "success" | "error"
|
"idle" | "checking" | "success" | "error"
|
||||||
@@ -128,6 +97,95 @@ export default function EditBoardPage() {
|
|||||||
null
|
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 runGatewayCheck = async () => {
|
||||||
const validationError = validateGatewayUrl(gatewayUrl);
|
const validationError = validateGatewayUrl(gatewayUrl);
|
||||||
setGatewayUrlError(validationError);
|
setGatewayUrlError(validationError);
|
||||||
@@ -151,12 +209,12 @@ export default function EditBoardPage() {
|
|||||||
params.set("gateway_main_session_key", gatewayMainSessionKey.trim());
|
params.set("gateway_main_session_key", gatewayMainSessionKey.trim());
|
||||||
}
|
}
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${apiBase}/api/v1/gateway/status?${params.toString()}`,
|
`${apiBase}/api/v1/gateways/status?${params.toString()}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: token ? `Bearer ${token}` : "",
|
Authorization: token ? `Bearer ${token}` : "",
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!response.ok || !data?.connected) {
|
if (!response.ok || !data?.connected) {
|
||||||
@@ -174,46 +232,33 @@ export default function EditBoardPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadBoard = async () => {
|
const handleGatewaySelection = (value: string) => {
|
||||||
if (!isSignedIn || !boardId) return;
|
if (value === "new") {
|
||||||
setIsLoading(true);
|
setCreateNewGateway(true);
|
||||||
setError(null);
|
setGatewayId("");
|
||||||
try {
|
setGatewayName("");
|
||||||
const token = await getToken();
|
setGatewayUrl("");
|
||||||
const response = await fetch(`${apiBase}/api/v1/boards/${boardId}`, {
|
setGatewayToken("");
|
||||||
headers: { Authorization: token ? `Bearer ${token}` : "" },
|
setGatewayMainSessionKey(DEFAULT_MAIN_SESSION_KEY);
|
||||||
});
|
setGatewayWorkspaceRoot(DEFAULT_WORKSPACE_ROOT);
|
||||||
if (!response.ok) {
|
setSkyllEnabled(false);
|
||||||
throw new Error("Unable to load board.");
|
return;
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
setCreateNewGateway(false);
|
||||||
|
setGatewayId(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadBoard();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [isSignedIn, boardId]);
|
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!isSignedIn || !boardId) return;
|
if (!isSignedIn || !boardId) return;
|
||||||
const trimmed = name.trim();
|
if (!name.trim()) {
|
||||||
if (!trimmed) {
|
|
||||||
setError("Board name is required.");
|
setError("Board name is required.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!createNewGateway && !gatewayId) {
|
||||||
|
setError("Select a gateway before saving.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const gatewayValidation = validateGatewayUrl(gatewayUrl);
|
const gatewayValidation = validateGatewayUrl(gatewayUrl);
|
||||||
setGatewayUrlError(gatewayValidation);
|
setGatewayUrlError(gatewayValidation);
|
||||||
if (gatewayValidation) {
|
if (gatewayValidation) {
|
||||||
@@ -221,34 +266,87 @@ export default function EditBoardPage() {
|
|||||||
setGatewayCheckMessage(gatewayValidation);
|
setGatewayCheckMessage(gatewayValidation);
|
||||||
return;
|
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);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const token = await getToken();
|
const token = await getToken();
|
||||||
const payload: Partial<Board> & { gateway_token?: string | null } = {
|
let configId = gatewayId;
|
||||||
name: trimmed,
|
|
||||||
slug: board?.slug ?? slugify(trimmed),
|
if (createNewGateway) {
|
||||||
gateway_url: gatewayUrl.trim() || null,
|
const gatewayResponse = await fetch(`${apiBase}/api/v1/gateways`, {
|
||||||
gateway_main_session_key: gatewayMainSessionKey.trim() || null,
|
method: "POST",
|
||||||
gateway_workspace_root: gatewayWorkspaceRoot.trim() || null,
|
headers: {
|
||||||
identity_template: identityTemplate.trim() || null,
|
"Content-Type": "application/json",
|
||||||
soul_template: soulTemplate.trim() || null,
|
Authorization: token ? `Bearer ${token}` : "",
|
||||||
};
|
},
|
||||||
if (gatewayToken.trim()) {
|
body: JSON.stringify({
|
||||||
payload.gateway_token = gatewayToken.trim();
|
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}`, {
|
const response = await fetch(`${apiBase}/api/v1/boards/${boardId}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: token ? `Bearer ${token}` : "",
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error("Unable to update board.");
|
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) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
setError(err instanceof Error ? err.message : "Something went wrong.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -278,10 +376,10 @@ export default function EditBoardPage() {
|
|||||||
<div className="border-b border-slate-200 bg-white px-8 py-6">
|
<div className="border-b border-slate-200 bg-white px-8 py-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-heading text-2xl font-semibold text-slate-900 tracking-tight">
|
<h1 className="font-heading text-2xl font-semibold text-slate-900 tracking-tight">
|
||||||
{board?.name ?? "Edit board"}
|
Edit board
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
Update the board identity and gateway connection.
|
Update board settings and gateway.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -289,31 +387,68 @@ export default function EditBoardPage() {
|
|||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
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>
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<div className="space-y-2">
|
||||||
Board identity
|
|
||||||
</p>
|
|
||||||
<div className="mt-4">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Board name <span className="text-red-500">*</span>
|
Board name <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(event) => setName(event.target.value)}
|
onChange={(event) => setName(event.target.value)}
|
||||||
placeholder="e.g. Product ops"
|
placeholder="Board name"
|
||||||
disabled={isLoading}
|
disabled={isLoading || !board}
|
||||||
className="mt-2"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
Gateway connection
|
Gateway details
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
{!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={gatewayName}
|
||||||
|
onChange={(event) => setGatewayName(event.target.value)}
|
||||||
|
placeholder="Primary gateway"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Gateway URL <span className="text-red-500">*</span>
|
Gateway URL <span className="text-red-500">*</span>
|
||||||
@@ -325,22 +460,20 @@ export default function EditBoardPage() {
|
|||||||
onBlur={runGatewayCheck}
|
onBlur={runGatewayCheck}
|
||||||
placeholder="ws://gateway:18789"
|
placeholder="ws://gateway:18789"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={`pr-12 ${
|
className={
|
||||||
gatewayUrlError
|
gatewayUrlError ? "border-red-500" : undefined
|
||||||
? "border-red-500 focus-visible:ring-red-500"
|
}
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={runGatewayCheck}
|
onClick={runGatewayCheck}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 transition hover:text-slate-600"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||||
aria-label="Check gateway connection"
|
aria-label="Check gateway connection"
|
||||||
>
|
>
|
||||||
{gatewayCheckStatus === "checking" ? (
|
{gatewayCheckStatus === "checking" ? (
|
||||||
<RefreshCcw className="h-4 w-4 animate-spin" />
|
<RefreshCcw className="h-4 w-4 animate-spin" />
|
||||||
) : gatewayCheckStatus === "success" ? (
|
) : gatewayCheckStatus === "success" ? (
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||||
) : gatewayCheckStatus === "error" ? (
|
) : gatewayCheckStatus === "error" ? (
|
||||||
<XCircle className="h-4 w-4 text-red-500" />
|
<XCircle className="h-4 w-4 text-red-500" />
|
||||||
) : (
|
) : (
|
||||||
@@ -352,16 +485,19 @@ export default function EditBoardPage() {
|
|||||||
<p className="text-xs text-red-500">{gatewayUrlError}</p>
|
<p className="text-xs text-red-500">{gatewayUrlError}</p>
|
||||||
) : gatewayCheckMessage ? (
|
) : gatewayCheckMessage ? (
|
||||||
<p
|
<p
|
||||||
className={`text-xs ${
|
className={
|
||||||
gatewayCheckStatus === "success"
|
gatewayCheckStatus === "success"
|
||||||
? "text-green-600"
|
? "text-xs text-emerald-600"
|
||||||
: "text-red-500"
|
: "text-xs text-red-500"
|
||||||
}`}
|
}
|
||||||
>
|
>
|
||||||
{gatewayCheckMessage}
|
{gatewayCheckMessage}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Gateway token
|
Gateway token
|
||||||
@@ -374,14 +510,6 @@ export default function EditBoardPage() {
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</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">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Main session key <span className="text-red-500">*</span>
|
Main session key <span className="text-red-500">*</span>
|
||||||
@@ -391,10 +519,13 @@ export default function EditBoardPage() {
|
|||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setGatewayMainSessionKey(event.target.value)
|
setGatewayMainSessionKey(event.target.value)
|
||||||
}
|
}
|
||||||
placeholder="agent:main:main"
|
placeholder={DEFAULT_MAIN_SESSION_KEY}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Workspace root <span className="text-red-500">*</span>
|
Workspace root <span className="text-red-500">*</span>
|
||||||
@@ -404,59 +535,36 @@ export default function EditBoardPage() {
|
|||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setGatewayWorkspaceRoot(event.target.value)
|
setGatewayWorkspaceRoot(event.target.value)
|
||||||
}
|
}
|
||||||
placeholder="~/.openclaw"
|
placeholder={DEFAULT_WORKSPACE_ROOT}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-3 rounded-lg border border-slate-200 px-4 py-3 text-sm text-slate-700">
|
||||||
</div>
|
<input
|
||||||
|
type="checkbox"
|
||||||
<div>
|
checked={skyllEnabled}
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
onChange={(event) => setSkyllEnabled(event.target.checked)}
|
||||||
Agent templates
|
className="h-4 w-4 rounded border-slate-300 text-slate-900"
|
||||||
</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]"
|
|
||||||
/>
|
/>
|
||||||
|
<span>Enable Skyll dynamic skills</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
{error ? <p className="text-sm text-red-500">{error}</p> : null}
|
||||||
<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="submit" disabled={isLoading}>
|
|
||||||
{isLoading ? "Saving…" : "Save changes"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
onClick={() => router.push(`/boards/${boardId}`)}
|
onClick={() => router.push(`/boards/${boardId}`)}
|
||||||
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
Back to board
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isLoading || !board}>
|
||||||
|
{isLoading ? "Saving…" : "Save changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,82 +1,45 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
||||||
|
import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
|
||||||
|
|
||||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { 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 }}
|
const apiBase = getApiBaseUrl();
|
||||||
|
|
||||||
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 Board = {
|
type Board = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
gateway_url?: string | null;
|
gateway_id?: 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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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 validateGatewayUrl = (value: string) => {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -105,19 +68,23 @@ const slugify = (value: string) =>
|
|||||||
export default function NewBoardPage() {
|
export default function NewBoardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { getToken, isSignedIn } = useAuth();
|
const { getToken, isSignedIn } = useAuth();
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
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 [gatewayUrl, setGatewayUrl] = useState("");
|
||||||
const [gatewayToken, setGatewayToken] = useState("");
|
const [gatewayToken, setGatewayToken] = useState("");
|
||||||
const [gatewayMainSessionKey, setGatewayMainSessionKey] =
|
const [gatewayMainSessionKey, setGatewayMainSessionKey] = useState(
|
||||||
useState("agent:main:main");
|
DEFAULT_MAIN_SESSION_KEY
|
||||||
const [gatewayWorkspaceRoot, setGatewayWorkspaceRoot] =
|
|
||||||
useState("~/.openclaw");
|
|
||||||
const [identityTemplate, setIdentityTemplate] = useState(
|
|
||||||
DEFAULT_IDENTITY_TEMPLATE
|
|
||||||
);
|
);
|
||||||
const [soulTemplate, setSoulTemplate] = useState(DEFAULT_SOUL_TEMPLATE);
|
const [gatewayWorkspaceRoot, setGatewayWorkspaceRoot] = useState(
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
DEFAULT_WORKSPACE_ROOT
|
||||||
const [error, setError] = useState<string | null>(null);
|
);
|
||||||
|
const [skyllEnabled, setSkyllEnabled] = useState(false);
|
||||||
|
|
||||||
const [gatewayUrlError, setGatewayUrlError] = useState<string | null>(null);
|
const [gatewayUrlError, setGatewayUrlError] = useState<string | null>(null);
|
||||||
const [gatewayCheckStatus, setGatewayCheckStatus] = useState<
|
const [gatewayCheckStatus, setGatewayCheckStatus] = useState<
|
||||||
"idle" | "checking" | "success" | "error"
|
"idle" | "checking" | "success" | "error"
|
||||||
@@ -126,6 +93,41 @@ export default function NewBoardPage() {
|
|||||||
null
|
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 runGatewayCheck = async () => {
|
||||||
const validationError = validateGatewayUrl(gatewayUrl);
|
const validationError = validateGatewayUrl(gatewayUrl);
|
||||||
setGatewayUrlError(validationError);
|
setGatewayUrlError(validationError);
|
||||||
@@ -145,13 +147,16 @@ export default function NewBoardPage() {
|
|||||||
if (gatewayToken.trim()) {
|
if (gatewayToken.trim()) {
|
||||||
params.set("gateway_token", gatewayToken.trim());
|
params.set("gateway_token", gatewayToken.trim());
|
||||||
}
|
}
|
||||||
|
if (gatewayMainSessionKey.trim()) {
|
||||||
|
params.set("gateway_main_session_key", gatewayMainSessionKey.trim());
|
||||||
|
}
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${apiBase}/api/v1/gateway/status?${params.toString()}`,
|
`${apiBase}/api/v1/gateways/status?${params.toString()}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: token ? `Bearer ${token}` : "",
|
Authorization: token ? `Bearer ${token}` : "",
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!response.ok || !data?.connected) {
|
if (!response.ok || !data?.connected) {
|
||||||
@@ -169,11 +174,29 @@ 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>) => {
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!isSignedIn) return;
|
if (!isSignedIn) return;
|
||||||
const trimmed = name.trim();
|
const trimmedName = name.trim();
|
||||||
if (!trimmed) return;
|
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);
|
const gatewayValidation = validateGatewayUrl(gatewayUrl);
|
||||||
setGatewayUrlError(gatewayValidation);
|
setGatewayUrlError(gatewayValidation);
|
||||||
if (gatewayValidation) {
|
if (gatewayValidation) {
|
||||||
@@ -181,28 +204,55 @@ export default function NewBoardPage() {
|
|||||||
setGatewayCheckMessage(gatewayValidation);
|
setGatewayCheckMessage(gatewayValidation);
|
||||||
return;
|
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);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const token = await getToken();
|
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> = {
|
const payload: Partial<Board> = {
|
||||||
name: trimmed,
|
name: trimmedName,
|
||||||
slug: slugify(trimmed),
|
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`, {
|
const response = await fetch(`${apiBase}/api/v1/boards`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -248,7 +298,7 @@ export default function NewBoardPage() {
|
|||||||
Create board
|
Create board
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,31 +306,72 @@ export default function NewBoardPage() {
|
|||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
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>
|
<div className="space-y-4">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
Board identity
|
<div className="space-y-2">
|
||||||
</p>
|
|
||||||
<div className="mt-4">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Board name <span className="text-red-500">*</span>
|
Board name <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(event) => setName(event.target.value)}
|
onChange={(event) => setName(event.target.value)}
|
||||||
placeholder="e.g. Product ops"
|
placeholder="e.g. Release operations"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="mt-2"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</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">
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
Gateway connection
|
Gateway details
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
{!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">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Gateway URL <span className="text-red-500">*</span>
|
Gateway URL <span className="text-red-500">*</span>
|
||||||
@@ -292,22 +383,20 @@ export default function NewBoardPage() {
|
|||||||
onBlur={runGatewayCheck}
|
onBlur={runGatewayCheck}
|
||||||
placeholder="ws://gateway:18789"
|
placeholder="ws://gateway:18789"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={`pr-12 ${
|
className={
|
||||||
gatewayUrlError
|
gatewayUrlError ? "border-red-500" : undefined
|
||||||
? "border-red-500 focus-visible:ring-red-500"
|
}
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={runGatewayCheck}
|
onClick={runGatewayCheck}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 transition hover:text-slate-600"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||||
aria-label="Check gateway connection"
|
aria-label="Check gateway connection"
|
||||||
>
|
>
|
||||||
{gatewayCheckStatus === "checking" ? (
|
{gatewayCheckStatus === "checking" ? (
|
||||||
<RefreshCcw className="h-4 w-4 animate-spin" />
|
<RefreshCcw className="h-4 w-4 animate-spin" />
|
||||||
) : gatewayCheckStatus === "success" ? (
|
) : gatewayCheckStatus === "success" ? (
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||||
) : gatewayCheckStatus === "error" ? (
|
) : gatewayCheckStatus === "error" ? (
|
||||||
<XCircle className="h-4 w-4 text-red-500" />
|
<XCircle className="h-4 w-4 text-red-500" />
|
||||||
) : (
|
) : (
|
||||||
@@ -319,16 +408,19 @@ export default function NewBoardPage() {
|
|||||||
<p className="text-xs text-red-500">{gatewayUrlError}</p>
|
<p className="text-xs text-red-500">{gatewayUrlError}</p>
|
||||||
) : gatewayCheckMessage ? (
|
) : gatewayCheckMessage ? (
|
||||||
<p
|
<p
|
||||||
className={`text-xs ${
|
className={
|
||||||
gatewayCheckStatus === "success"
|
gatewayCheckStatus === "success"
|
||||||
? "text-green-600"
|
? "text-xs text-emerald-600"
|
||||||
: "text-red-500"
|
: "text-xs text-red-500"
|
||||||
}`}
|
}
|
||||||
>
|
>
|
||||||
{gatewayCheckMessage}
|
{gatewayCheckMessage}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Gateway token
|
Gateway token
|
||||||
@@ -341,14 +433,6 @@ export default function NewBoardPage() {
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</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">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Main session key <span className="text-red-500">*</span>
|
Main session key <span className="text-red-500">*</span>
|
||||||
@@ -358,10 +442,13 @@ export default function NewBoardPage() {
|
|||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setGatewayMainSessionKey(event.target.value)
|
setGatewayMainSessionKey(event.target.value)
|
||||||
}
|
}
|
||||||
placeholder="agent:main:main"
|
placeholder={DEFAULT_MAIN_SESSION_KEY}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Workspace root <span className="text-red-500">*</span>
|
Workspace root <span className="text-red-500">*</span>
|
||||||
@@ -371,60 +458,67 @@ export default function NewBoardPage() {
|
|||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setGatewayWorkspaceRoot(event.target.value)
|
setGatewayWorkspaceRoot(event.target.value)
|
||||||
}
|
}
|
||||||
placeholder="~/.openclaw"
|
placeholder={DEFAULT_WORKSPACE_ROOT}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
<div>
|
) : selectedGateway ? (
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
Agent templates
|
<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>
|
||||||
|
<p className="mt-1 text-sm text-slate-900">
|
||||||
|
{selectedGateway.name}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
{selectedGateway.url}
|
||||||
</p>
|
</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>
|
||||||
<div className="space-y-2">
|
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<p className="text-xs font-semibold uppercase text-slate-500">
|
||||||
Soul template
|
Workspace root
|
||||||
</label>
|
</p>
|
||||||
<Textarea
|
<p className="mt-1 text-sm text-slate-900">
|
||||||
value={soulTemplate}
|
{selectedGateway.workspace_root}
|
||||||
onChange={(event) => setSoulTemplate(event.target.value)}
|
</p>
|
||||||
placeholder="Override SOUL.md for agents in this board."
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
className="min-h-[180px]"
|
{selectedGateway.main_session_key}
|
||||||
/>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Select a gateway or create a new one.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
{error ? <p className="text-sm text-red-500">{error}</p> : null}
|
||||||
<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}>
|
<Button type="submit" disabled={isLoading}>
|
||||||
{isLoading ? "Creating…" : "Create board"}
|
{isLoading ? "Creating…" : "Create board"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => router.push("/boards")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Back to boards
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ export default function BoardsPage() {
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-strong">{row.original.name}</p>
|
<p className="font-medium text-strong">{row.original.name}</p>
|
||||||
<p className="text-xs text-quiet">{row.original.slug}</p>
|
|
||||||
</div>
|
</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 Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -28,6 +28,18 @@ export function DashboardSidebar() {
|
|||||||
<BarChart3 className="h-4 w-4" />
|
<BarChart3 className="h-4 w-4" />
|
||||||
Dashboard
|
Dashboard
|
||||||
</Link>
|
</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
|
<Link
|
||||||
href="/boards"
|
href="/boards"
|
||||||
className={cn(
|
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