Compare commits
7 Commits
master
...
copilot/im
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9816a43f91 | ||
|
|
8c197ffbbd | ||
|
|
23fe9b869d | ||
|
|
d4f519c580 | ||
|
|
e99b41fa9a | ||
|
|
d72af04170 | ||
|
|
a8ab696a35 |
@@ -24,6 +24,7 @@ from app.models.boards import Board
|
||||
from app.models.tags import Tag
|
||||
from app.models.task_dependencies import TaskDependency
|
||||
from app.models.tasks import Task
|
||||
from app.schemas.agent_files import AgentFileUpdate
|
||||
from app.schemas.agents import (
|
||||
AgentCreate,
|
||||
AgentHeartbeat,
|
||||
@@ -1538,6 +1539,190 @@ async def update_agent_soul(
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/boards/{board_id}/agents/{agent_id}/files",
|
||||
response_model=list[dict[str, object]],
|
||||
tags=AGENT_BOARD_TAGS,
|
||||
summary="List agent files",
|
||||
description="List available agent markdown files from the gateway workspace.",
|
||||
operation_id="agent_board_list_files",
|
||||
openapi_extra=_agent_board_openapi_hints(
|
||||
intent="agent_files_list",
|
||||
when_to_use=[
|
||||
"Discover available agent files before reading",
|
||||
"Check which files can be edited",
|
||||
],
|
||||
routing_examples=[
|
||||
{
|
||||
"input": {
|
||||
"intent": "list agent configuration files",
|
||||
"required_privilege": "board_lead_or_same_actor",
|
||||
},
|
||||
"decision": "agent_board_list_files",
|
||||
}
|
||||
],
|
||||
side_effects=["No persisted side effects"],
|
||||
),
|
||||
)
|
||||
async def list_agent_files(
|
||||
agent_id: str,
|
||||
board: Board = BOARD_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> list[dict[str, object]]:
|
||||
"""List available agent files.
|
||||
|
||||
Allowed for board lead or for an agent listing its own files.
|
||||
"""
|
||||
_guard_board_access(agent_ctx, board)
|
||||
OpenClawAuthorizationPolicy.require_board_lead_or_same_actor(
|
||||
actor_agent=agent_ctx.agent,
|
||||
target_agent_id=agent_id,
|
||||
)
|
||||
coordination = GatewayCoordinationService(session)
|
||||
return await coordination.list_agent_files(
|
||||
board=board,
|
||||
target_agent_id=agent_id,
|
||||
correlation_id=f"files.list:{board.id}:{agent_id}",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/boards/{board_id}/agents/{agent_id}/files/{filename}",
|
||||
response_model=str,
|
||||
tags=AGENT_BOARD_TAGS,
|
||||
summary="Read agent file",
|
||||
description="Fetch content of a specific agent markdown file.",
|
||||
operation_id="agent_board_read_file",
|
||||
openapi_extra=_agent_board_openapi_hints(
|
||||
intent="agent_file_read",
|
||||
when_to_use=[
|
||||
"Read IDENTITY.md, BOOTSTRAP.md, or other agent configuration files",
|
||||
"Review current agent file content before editing",
|
||||
],
|
||||
routing_examples=[
|
||||
{
|
||||
"input": {
|
||||
"intent": "read agent configuration file",
|
||||
"required_privilege": "board_lead_or_same_actor",
|
||||
},
|
||||
"decision": "agent_board_read_file",
|
||||
}
|
||||
],
|
||||
side_effects=["No persisted side effects"],
|
||||
),
|
||||
)
|
||||
async def read_agent_file(
|
||||
agent_id: str,
|
||||
filename: str,
|
||||
board: Board = BOARD_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> str:
|
||||
"""Read content of an agent file.
|
||||
|
||||
Allowed for board lead or for an agent reading its own files.
|
||||
"""
|
||||
_guard_board_access(agent_ctx, board)
|
||||
OpenClawAuthorizationPolicy.require_board_lead_or_same_actor(
|
||||
actor_agent=agent_ctx.agent,
|
||||
target_agent_id=agent_id,
|
||||
)
|
||||
coordination = GatewayCoordinationService(session)
|
||||
return await coordination.get_agent_file(
|
||||
board=board,
|
||||
target_agent_id=agent_id,
|
||||
filename=filename,
|
||||
correlation_id=f"file.read:{board.id}:{agent_id}:{filename}",
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/boards/{board_id}/agents/{agent_id}/files/{filename}",
|
||||
response_model=OkResponse,
|
||||
tags=AGENT_LEAD_TAGS,
|
||||
summary="Update agent file",
|
||||
description=(
|
||||
"Write content to an agent markdown file and persist it for reprovisioning.\n\n"
|
||||
"Use this when agent configuration files need updates. While this endpoint can "
|
||||
"handle SOUL.md, prefer the dedicated SOUL endpoint for better semantic clarity "
|
||||
"and specialized SOUL operations."
|
||||
),
|
||||
operation_id="agent_lead_update_file",
|
||||
responses={
|
||||
200: {"description": "File updated"},
|
||||
403: {
|
||||
"model": LLMErrorResponse,
|
||||
"description": "Caller is not board lead",
|
||||
},
|
||||
404: {
|
||||
"model": LLMErrorResponse,
|
||||
"description": "Board or target agent not found",
|
||||
},
|
||||
422: {
|
||||
"model": LLMErrorResponse,
|
||||
"description": "File content is invalid or empty",
|
||||
},
|
||||
502: {
|
||||
"model": LLMErrorResponse,
|
||||
"description": "Gateway sync failed",
|
||||
},
|
||||
},
|
||||
openapi_extra={
|
||||
"x-llm-intent": "agent_file_authoring",
|
||||
"x-when-to-use": [
|
||||
"Update agent configuration files like IDENTITY.md, BOOTSTRAP.md, or AGENTS.md",
|
||||
"Import existing agent files into mission control",
|
||||
],
|
||||
"x-when-not-to-use": [
|
||||
"Prefer dedicated SOUL endpoint for SOUL.md (though this endpoint can handle it)",
|
||||
"Use task comments for transient guidance",
|
||||
],
|
||||
"x-required-actor": "board_lead",
|
||||
"x-prerequisites": [
|
||||
"Authenticated board lead",
|
||||
"Non-empty file content",
|
||||
"Target agent scoped to board",
|
||||
],
|
||||
"x-side-effects": [
|
||||
"Updates file content in gateway workspace",
|
||||
"Persists to database for certain files (IDENTITY.md, SOUL.md)",
|
||||
"Creates activity log entry",
|
||||
],
|
||||
"x-routing-policy": [
|
||||
"Use when updating agent configuration files",
|
||||
"Prefer dedicated SOUL endpoint for SOUL.md",
|
||||
],
|
||||
},
|
||||
)
|
||||
async def update_agent_file(
|
||||
agent_id: str,
|
||||
filename: str,
|
||||
payload: AgentFileUpdate,
|
||||
board: Board = BOARD_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> OkResponse:
|
||||
"""Update an agent file in gateway and optionally persist to DB.
|
||||
|
||||
Lead-only endpoint. Persists IDENTITY.md and SOUL.md for reprovisioning.
|
||||
"""
|
||||
_guard_board_access(agent_ctx, board)
|
||||
_require_board_lead(agent_ctx)
|
||||
|
||||
coordination = GatewayCoordinationService(session)
|
||||
await coordination.update_agent_file(
|
||||
board=board,
|
||||
target_agent_id=agent_id,
|
||||
filename=filename,
|
||||
content=payload.content,
|
||||
reason=payload.reason,
|
||||
actor_agent_id=agent_ctx.agent.id,
|
||||
correlation_id=f"file.write:{board.id}:{agent_id}:{filename}",
|
||||
)
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/boards/{board_id}/agents/{agent_id}",
|
||||
response_model=OkResponse,
|
||||
|
||||
73
backend/app/schemas/agent_files.py
Normal file
73
backend/app/schemas/agent_files.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Pydantic schemas for agent file operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import Field
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel._compat import SQLModelConfig
|
||||
|
||||
|
||||
class AgentFileRead(SQLModel):
|
||||
"""Response model for reading an agent file."""
|
||||
|
||||
model_config = SQLModelConfig(
|
||||
json_schema_extra={
|
||||
"x-llm-intent": "agent_file_content",
|
||||
"x-when-to-use": [
|
||||
"Retrieve content of an agent markdown file",
|
||||
"Read IDENTITY.md, SOUL.md, BOOTSTRAP.md, or other agent files",
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
name: str = Field(
|
||||
description="File name (e.g., IDENTITY.md, SOUL.md, BOOTSTRAP.md)",
|
||||
examples=["IDENTITY.md", "SOUL.md"],
|
||||
)
|
||||
content: str = Field(
|
||||
description="File content",
|
||||
examples=["# IDENTITY.md\n\n## Core\n- Name: Agent Name"],
|
||||
)
|
||||
|
||||
|
||||
class AgentFileUpdate(SQLModel):
|
||||
"""Request model for updating an agent file."""
|
||||
|
||||
model_config = SQLModelConfig(
|
||||
json_schema_extra={
|
||||
"x-llm-intent": "agent_file_update",
|
||||
"x-when-to-use": [
|
||||
"Update an agent markdown file",
|
||||
"Modify IDENTITY.md or other editable agent files",
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
content: str = Field(
|
||||
description="New file content",
|
||||
examples=["# IDENTITY.md\n\n## Core\n- Name: Updated Agent Name"],
|
||||
)
|
||||
reason: str | None = Field(
|
||||
default=None,
|
||||
description="Optional reason for the update",
|
||||
examples=["Updated agent role and communication style"],
|
||||
)
|
||||
|
||||
|
||||
class AgentFileListItem(SQLModel):
|
||||
"""Agent file list item."""
|
||||
|
||||
model_config = SQLModelConfig(
|
||||
json_schema_extra={
|
||||
"x-llm-intent": "agent_file_list_item",
|
||||
},
|
||||
)
|
||||
|
||||
name: str = Field(
|
||||
description="File name",
|
||||
examples=["IDENTITY.md", "SOUL.md", "BOOTSTRAP.md"],
|
||||
)
|
||||
editable: bool = Field(
|
||||
description="Whether the file can be edited via the API",
|
||||
examples=[True, False],
|
||||
)
|
||||
@@ -49,6 +49,16 @@ from app.services.openclaw.shared import GatewayAgentIdentity
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
# Files that can be edited via the agent file management API
|
||||
EDITABLE_AGENT_FILES = {
|
||||
"IDENTITY.md",
|
||||
"SOUL.md",
|
||||
"BOOTSTRAP.md",
|
||||
"AGENTS.md",
|
||||
"TOOLS.md",
|
||||
"HEARTBEAT.md",
|
||||
}
|
||||
|
||||
|
||||
class AbstractGatewayMessagingService(OpenClawDBService, ABC):
|
||||
"""Shared gateway messaging primitives with retry semantics."""
|
||||
@@ -408,6 +418,275 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
|
||||
actor_agent_id,
|
||||
)
|
||||
|
||||
async def list_agent_files(
|
||||
self,
|
||||
*,
|
||||
board: Board,
|
||||
target_agent_id: str,
|
||||
correlation_id: str | None = None,
|
||||
) -> list[dict[str, object]]:
|
||||
"""List available agent files from the gateway."""
|
||||
trace_id = GatewayDispatchService.resolve_trace_id(
|
||||
correlation_id, prefix="coord.files.list"
|
||||
)
|
||||
self.logger.log(
|
||||
TRACE_LEVEL,
|
||||
"gateway.coordination.files_list.start trace_id=%s board_id=%s target_agent_id=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
)
|
||||
target = await self._board_agent_or_404(board=board, agent_id=target_agent_id)
|
||||
_gateway, config = await GatewayDispatchService(
|
||||
self.session
|
||||
).require_gateway_config_for_board(board)
|
||||
try:
|
||||
|
||||
async def _do_list() -> object:
|
||||
return await openclaw_call(
|
||||
"agents.files.list",
|
||||
{"agentId": agent_key(target)},
|
||||
config=config,
|
||||
)
|
||||
|
||||
payload = await self._with_gateway_retry(_do_list)
|
||||
except (OpenClawGatewayError, TimeoutError) as exc:
|
||||
self.logger.error(
|
||||
"gateway.coordination.files_list.failed trace_id=%s board_id=%s "
|
||||
"target_agent_id=%s error=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
str(exc),
|
||||
)
|
||||
raise map_gateway_error_to_http_exception(GatewayOperation.FILES_LIST, exc) from exc
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
self.logger.critical(
|
||||
"gateway.coordination.files_list.failed_unexpected trace_id=%s board_id=%s "
|
||||
"target_agent_id=%s error_type=%s error=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
exc.__class__.__name__,
|
||||
str(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
# Parse the file list from gateway response
|
||||
if isinstance(payload, dict):
|
||||
files = payload.get("files", [])
|
||||
elif isinstance(payload, list):
|
||||
files = payload
|
||||
else:
|
||||
files = []
|
||||
|
||||
result = []
|
||||
if isinstance(files, list):
|
||||
for file in files:
|
||||
if isinstance(file, str):
|
||||
result.append({"name": file, "editable": file in EDITABLE_AGENT_FILES})
|
||||
elif isinstance(file, dict):
|
||||
name = file.get("name", "")
|
||||
if isinstance(name, str):
|
||||
result.append(
|
||||
{"name": name, "editable": name in EDITABLE_AGENT_FILES}
|
||||
)
|
||||
|
||||
self.logger.info(
|
||||
"gateway.coordination.files_list.success trace_id=%s board_id=%s target_agent_id=%s "
|
||||
"file_count=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
len(result),
|
||||
)
|
||||
return result
|
||||
|
||||
async def get_agent_file(
|
||||
self,
|
||||
*,
|
||||
board: Board,
|
||||
target_agent_id: str,
|
||||
filename: str,
|
||||
correlation_id: str | None = None,
|
||||
) -> str:
|
||||
"""Get content of an agent file from the gateway."""
|
||||
trace_id = GatewayDispatchService.resolve_trace_id(
|
||||
correlation_id, prefix="coord.file.read"
|
||||
)
|
||||
self.logger.log(
|
||||
TRACE_LEVEL,
|
||||
"gateway.coordination.file_read.start trace_id=%s board_id=%s target_agent_id=%s "
|
||||
"filename=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
filename,
|
||||
)
|
||||
target = await self._board_agent_or_404(board=board, agent_id=target_agent_id)
|
||||
_gateway, config = await GatewayDispatchService(
|
||||
self.session
|
||||
).require_gateway_config_for_board(board)
|
||||
try:
|
||||
|
||||
async def _do_get() -> object:
|
||||
return await openclaw_call(
|
||||
"agents.files.get",
|
||||
{"agentId": agent_key(target), "name": filename},
|
||||
config=config,
|
||||
)
|
||||
|
||||
payload = await self._with_gateway_retry(_do_get)
|
||||
except (OpenClawGatewayError, TimeoutError) as exc:
|
||||
self.logger.error(
|
||||
"gateway.coordination.file_read.failed trace_id=%s board_id=%s "
|
||||
"target_agent_id=%s filename=%s error=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
filename,
|
||||
str(exc),
|
||||
)
|
||||
raise map_gateway_error_to_http_exception(GatewayOperation.FILE_READ, exc) from exc
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
self.logger.critical(
|
||||
"gateway.coordination.file_read.failed_unexpected trace_id=%s board_id=%s "
|
||||
"target_agent_id=%s filename=%s error_type=%s error=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
filename,
|
||||
exc.__class__.__name__,
|
||||
str(exc),
|
||||
)
|
||||
raise
|
||||
content = self._gateway_file_content(payload)
|
||||
if content is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="Invalid gateway response",
|
||||
)
|
||||
self.logger.info(
|
||||
"gateway.coordination.file_read.success trace_id=%s board_id=%s target_agent_id=%s "
|
||||
"filename=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
filename,
|
||||
)
|
||||
return content
|
||||
|
||||
async def update_agent_file(
|
||||
self,
|
||||
*,
|
||||
board: Board,
|
||||
target_agent_id: str,
|
||||
filename: str,
|
||||
content: str,
|
||||
reason: str | None,
|
||||
actor_agent_id: UUID,
|
||||
correlation_id: str | None = None,
|
||||
) -> None:
|
||||
"""Update an agent file in the gateway and optionally persist to DB."""
|
||||
trace_id = GatewayDispatchService.resolve_trace_id(
|
||||
correlation_id, prefix="coord.file.write"
|
||||
)
|
||||
self.logger.log(
|
||||
TRACE_LEVEL,
|
||||
"gateway.coordination.file_write.start trace_id=%s board_id=%s target_agent_id=%s "
|
||||
"filename=%s actor_agent_id=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
filename,
|
||||
actor_agent_id,
|
||||
)
|
||||
target = await self._board_agent_or_404(board=board, agent_id=target_agent_id)
|
||||
normalized_content = content.strip()
|
||||
if not normalized_content:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="content is required",
|
||||
)
|
||||
|
||||
# Update database fields for specific files
|
||||
db_updated = False
|
||||
if filename == "SOUL.md":
|
||||
target.soul_template = normalized_content
|
||||
db_updated = True
|
||||
elif filename == "IDENTITY.md":
|
||||
target.identity_template = normalized_content
|
||||
db_updated = True
|
||||
|
||||
if db_updated:
|
||||
target.updated_at = utcnow()
|
||||
self.session.add(target)
|
||||
await self.session.commit()
|
||||
|
||||
_gateway, config = await GatewayDispatchService(
|
||||
self.session
|
||||
).require_gateway_config_for_board(board)
|
||||
try:
|
||||
|
||||
async def _do_set() -> object:
|
||||
return await openclaw_call(
|
||||
"agents.files.set",
|
||||
{
|
||||
"agentId": agent_key(target),
|
||||
"name": filename,
|
||||
"content": normalized_content,
|
||||
},
|
||||
config=config,
|
||||
)
|
||||
|
||||
await self._with_gateway_retry(_do_set)
|
||||
except (OpenClawGatewayError, TimeoutError) as exc:
|
||||
self.logger.error(
|
||||
"gateway.coordination.file_write.failed trace_id=%s board_id=%s "
|
||||
"target_agent_id=%s filename=%s actor_agent_id=%s error=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
filename,
|
||||
actor_agent_id,
|
||||
str(exc),
|
||||
)
|
||||
raise map_gateway_error_to_http_exception(GatewayOperation.FILE_WRITE, exc) from exc
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
self.logger.critical(
|
||||
"gateway.coordination.file_write.failed_unexpected trace_id=%s board_id=%s "
|
||||
"target_agent_id=%s filename=%s actor_agent_id=%s error_type=%s error=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
filename,
|
||||
actor_agent_id,
|
||||
exc.__class__.__name__,
|
||||
str(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
reason_text = (reason or "").strip()
|
||||
note = f"{filename} updated for {target.name}."
|
||||
if reason_text:
|
||||
note = f"{note} Reason: {reason_text}"
|
||||
record_activity(
|
||||
self.session,
|
||||
event_type="agent.file.updated",
|
||||
message=note,
|
||||
agent_id=actor_agent_id,
|
||||
)
|
||||
await self.session.commit()
|
||||
self.logger.info(
|
||||
"gateway.coordination.file_write.success trace_id=%s board_id=%s target_agent_id=%s "
|
||||
"filename=%s actor_agent_id=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
filename,
|
||||
actor_agent_id,
|
||||
)
|
||||
|
||||
async def ask_user_via_gateway_main(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -14,6 +14,9 @@ class GatewayOperation(str, Enum):
|
||||
NUDGE_AGENT = "nudge_agent"
|
||||
SOUL_READ = "soul_read"
|
||||
SOUL_WRITE = "soul_write"
|
||||
FILES_LIST = "files_list"
|
||||
FILE_READ = "file_read"
|
||||
FILE_WRITE = "file_write"
|
||||
ASK_USER_DISPATCH = "ask_user_dispatch"
|
||||
LEAD_MESSAGE_DISPATCH = "lead_message_dispatch"
|
||||
LEAD_BROADCAST_DISPATCH = "lead_broadcast_dispatch"
|
||||
@@ -42,6 +45,18 @@ _GATEWAY_ERROR_POLICIES: dict[GatewayOperation, GatewayErrorPolicy] = {
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail_template="Gateway SOUL update failed: {error}",
|
||||
),
|
||||
GatewayOperation.FILES_LIST: GatewayErrorPolicy(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail_template="Gateway files list failed: {error}",
|
||||
),
|
||||
GatewayOperation.FILE_READ: GatewayErrorPolicy(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail_template="Gateway file read failed: {error}",
|
||||
),
|
||||
GatewayOperation.FILE_WRITE: GatewayErrorPolicy(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail_template="Gateway file update failed: {error}",
|
||||
),
|
||||
GatewayOperation.ASK_USER_DISPATCH: GatewayErrorPolicy(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail_template="Gateway ask-user dispatch failed: {error}",
|
||||
|
||||
378
frontend/src/app/agents/[agentId]/files/page.tsx
Normal file
378
frontend/src/app/agents/[agentId]/files/page.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
import { useAuth } from "@/auth/clerk";
|
||||
|
||||
import { ApiError } from "@/api/mutator";
|
||||
import {
|
||||
type getAgentApiV1AgentsAgentIdGetResponse,
|
||||
useGetAgentApiV1AgentsAgentIdGet,
|
||||
} from "@/api/generated/agents/agents";
|
||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useOrganizationMembership } from "@/lib/use-organization-membership";
|
||||
import type { AgentRead } from "@/api/generated/model";
|
||||
|
||||
type AgentFile = {
|
||||
name: string;
|
||||
editable: boolean;
|
||||
};
|
||||
|
||||
export default function AgentFilesPage() {
|
||||
const { isSignedIn } = useAuth();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const agentIdParam = params?.agentId;
|
||||
const agentId = Array.isArray(agentIdParam) ? agentIdParam[0] : agentIdParam;
|
||||
|
||||
const { isAdmin } = useOrganizationMembership(isSignedIn);
|
||||
|
||||
const [files, setFiles] = useState<AgentFile[]>([]);
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [fileContent, setFileContent] = useState("");
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [importFileName, setImportFileName] = useState("");
|
||||
const [importFileContent, setImportFileContent] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const agentQuery = useGetAgentApiV1AgentsAgentIdGet<
|
||||
getAgentApiV1AgentsAgentIdGetResponse,
|
||||
ApiError
|
||||
>(agentId ?? "", {
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn && isAdmin && agentId),
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
|
||||
const agent: AgentRead | null =
|
||||
agentQuery.data?.status === 200 ? agentQuery.data.data : null;
|
||||
|
||||
const loadFiles = async () => {
|
||||
if (!agentId || !agent?.board_id) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/agent/boards/${agent.board_id}/agents/${agentId}/files`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load files: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
setFiles(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load files");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileClick = async (fileName: string) => {
|
||||
if (!agentId || !agent?.board_id) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/agent/boards/${agent.board_id}/agents/${agentId}/files/${fileName}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: "text/plain",
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load file: ${response.statusText}`);
|
||||
}
|
||||
const fileContent = await response.text();
|
||||
setSelectedFile(fileName);
|
||||
setFileContent(fileContent);
|
||||
setEditDialogOpen(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load file");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveFile = async () => {
|
||||
if (!agentId || !agent?.board_id || !selectedFile) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/agent/boards/${agent.board_id}/agents/${agentId}/files/${selectedFile}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: fileContent,
|
||||
reason: "Updated via Mission Control UI",
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to save file: ${response.statusText}`);
|
||||
}
|
||||
setEditDialogOpen(false);
|
||||
setSelectedFile(null);
|
||||
setFileContent("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save file");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportFile = async () => {
|
||||
if (!agentId || !agent?.board_id || !importFileName) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/agent/boards/${agent.board_id}/agents/${agentId}/files/${importFileName}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: importFileContent,
|
||||
reason: "Imported via Mission Control UI",
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to import file: ${response.statusText}`);
|
||||
}
|
||||
setImportDialogOpen(false);
|
||||
setImportFileName("");
|
||||
setImportFileContent("");
|
||||
await loadFiles();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to import file");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load files when component mounts
|
||||
useEffect(() => {
|
||||
if (agent?.board_id) {
|
||||
void loadFiles();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [agent?.board_id]);
|
||||
|
||||
return (
|
||||
<DashboardPageLayout
|
||||
signedOut={{
|
||||
message: "Sign in to manage agent files.",
|
||||
forceRedirectUrl: `/agents/${agentId}/files`,
|
||||
signUpForceRedirectUrl: `/agents/${agentId}/files`,
|
||||
}}
|
||||
title={agent?.name ? `${agent.name} - Files` : "Agent Files"}
|
||||
description="Manage and edit agent markdown files"
|
||||
isAdmin={isAdmin}
|
||||
adminOnlyMessage="Only organization owners and admins can manage agent files."
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Agent Files</h2>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
View and edit agent configuration files
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={() => setImportDialogOpen(true)}>
|
||||
Import File
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/agents/${agentId}`)}
|
||||
>
|
||||
Back to Agent
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-red-50 p-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading && files.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-slate-600">
|
||||
Loading files…
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="divide-y divide-slate-200">
|
||||
{files.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-sm text-slate-600">No files found</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Agent files will appear here once provisioned
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
files.map((file) => (
|
||||
<div
|
||||
key={file.name}
|
||||
className="flex items-center justify-between p-4 hover:bg-slate-50"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{file.name}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{file.editable ? "Editable" : "Read-only"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleFileClick(file.name)}
|
||||
>
|
||||
{file.editable ? "Edit" : "View"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit/View Dialog */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedFile &&
|
||||
files.find((f) => f.name === selectedFile)?.editable
|
||||
? "Edit"
|
||||
: "View"}{" "}
|
||||
{selectedFile}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedFile &&
|
||||
files.find((f) => f.name === selectedFile)?.editable
|
||||
? "Make changes to the file content below"
|
||||
: "This file is read-only"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Textarea
|
||||
value={fileContent}
|
||||
onChange={(e) => setFileContent(e.target.value)}
|
||||
className="min-h-[400px] font-mono text-sm"
|
||||
disabled={
|
||||
!selectedFile ||
|
||||
!files.find((f) => f.name === selectedFile)?.editable
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditDialogOpen(false);
|
||||
setSelectedFile(null);
|
||||
setFileContent("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{selectedFile &&
|
||||
files.find((f) => f.name === selectedFile)?.editable ? (
|
||||
<Button onClick={() => void handleSaveFile()} disabled={loading}>
|
||||
{loading ? "Saving…" : "Save Changes"}
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Import Dialog */}
|
||||
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Agent File</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload an existing agent markdown file to Mission Control
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
File name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={importFileName}
|
||||
onChange={(e) => setImportFileName(e.target.value)}
|
||||
placeholder="e.g., IDENTITY.md, SOUL.md, BOOTSTRAP.md"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Use standard OpenClaw file names (IDENTITY.md, SOUL.md, etc.)
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
File content <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
value={importFileContent}
|
||||
onChange={(e) => setImportFileContent(e.target.value)}
|
||||
className="min-h-[400px] font-mono text-sm"
|
||||
placeholder="Paste your agent file content here..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setImportDialogOpen(false);
|
||||
setImportFileName("");
|
||||
setImportFileContent("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleImportFile()}
|
||||
disabled={loading || !importFileName || !importFileContent}
|
||||
>
|
||||
{loading ? "Importing…" : "Import File"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -196,6 +196,14 @@ export default function AgentDetailPage() {
|
||||
Edit
|
||||
</Link>
|
||||
) : null}
|
||||
{agent ? (
|
||||
<Link
|
||||
href={`/agents/${agent.id}/files`}
|
||||
className="inline-flex h-10 items-center justify-center rounded-xl border border-[color:var(--border)] px-4 text-sm font-semibold text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
|
||||
>
|
||||
Files
|
||||
</Link>
|
||||
) : null}
|
||||
{agent ? (
|
||||
<Button variant="outline" onClick={() => setDeleteOpen(true)}>
|
||||
Delete
|
||||
|
||||
Reference in New Issue
Block a user