| | |
| | |
| | |
| | |
| | |
| |
|
| | """ |
| | Web interface for OpenEnv environments. |
| | |
| | This module provides a web-based interface for interacting with OpenEnv environments, |
| | including a two-pane layout for HumanAgent interaction and state observation. |
| | """ |
| |
|
| | from __future__ import annotations |
| |
|
| | import json |
| | import time |
| | from dataclasses import asdict, dataclass |
| | from typing import Any, Dict, List, Optional, Type |
| | from datetime import datetime |
| |
|
| | from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request |
| | from fastapi.responses import HTMLResponse, FileResponse |
| | from fastapi.staticfiles import StaticFiles |
| | from pydantic import BaseModel |
| |
|
| | from .interfaces import Environment |
| | from .types import Action, Observation, State, EnvironmentMetadata |
| |
|
| |
|
| | def load_environment_metadata(env: Environment, env_name: Optional[str] = None) -> EnvironmentMetadata: |
| | """ |
| | Load environment metadata including README content. |
| | |
| | Args: |
| | env: The environment instance |
| | env_name: Optional environment name for README file lookup |
| | |
| | Returns: |
| | EnvironmentMetadata with loaded information |
| | """ |
| | |
| | if hasattr(env, 'get_metadata'): |
| | return env.get_metadata() |
| | |
| | |
| | metadata = EnvironmentMetadata( |
| | name=env_name or env.__class__.__name__, |
| | description=f"{env.__class__.__name__} environment", |
| | version="1.0.0" |
| | ) |
| | |
| | |
| | readme_content = _load_readme_from_filesystem(env_name) |
| | if readme_content: |
| | metadata.readme_content = readme_content |
| | |
| | return metadata |
| |
|
| |
|
| | def _load_readme_from_filesystem(env_name: Optional[str]) -> Optional[str]: |
| | """ |
| | Load README content from the filesystem. |
| | |
| | Tries multiple locations: |
| | 1. Container filesystem: /app/README.md |
| | 2. Local development: src/envs/{env_name}/README.md |
| | 3. Environment variable: ENV_README_PATH |
| | """ |
| | import os |
| | from pathlib import Path |
| | |
| | |
| | container_readme = Path("/app/README.md") |
| | if container_readme.exists(): |
| | try: |
| | return container_readme.read_text(encoding='utf-8') |
| | except Exception: |
| | pass |
| | |
| | |
| | custom_path = os.environ.get("ENV_README_PATH") |
| | if custom_path and Path(custom_path).exists(): |
| | try: |
| | return Path(custom_path).read_text(encoding='utf-8') |
| | except Exception: |
| | pass |
| | |
| | |
| | if env_name: |
| | local_readme = Path(f"src/envs/{env_name}/README.md") |
| | if local_readme.exists(): |
| | try: |
| | return local_readme.read_text(encoding='utf-8') |
| | except Exception: |
| | pass |
| | |
| | return None |
| |
|
| |
|
| | @dataclass |
| | class ActionLog: |
| | """Log entry for an action taken.""" |
| | timestamp: str |
| | action: Dict[str, Any] |
| | observation: Dict[str, Any] |
| | reward: Optional[float] |
| | done: bool |
| | step_count: int |
| |
|
| |
|
| | @dataclass |
| | class EpisodeState: |
| | """Current episode state for the web interface.""" |
| | episode_id: Optional[str] |
| | step_count: int |
| | current_observation: Optional[Dict[str, Any]] |
| | action_logs: List[ActionLog] |
| | is_reset: bool = True |
| |
|
| |
|
| | class WebInterfaceManager: |
| | """Manages the web interface for an environment.""" |
| | |
| | def __init__( |
| | self, |
| | env: Environment, |
| | action_cls: Type[Action], |
| | observation_cls: Type[Observation], |
| | metadata: Optional[EnvironmentMetadata] = None, |
| | ): |
| | self.env = env |
| | self.action_cls = action_cls |
| | self.observation_cls = observation_cls |
| | self.metadata = metadata or EnvironmentMetadata( |
| | name=env.__class__.__name__, |
| | description=f"{env.__class__.__name__} environment" |
| | ) |
| | self.episode_state = EpisodeState( |
| | episode_id=None, |
| | step_count=0, |
| | current_observation=None, |
| | action_logs=[] |
| | ) |
| | self.connected_clients: List[WebSocket] = [] |
| | |
| | async def connect_websocket(self, websocket: WebSocket): |
| | """Connect a new WebSocket client.""" |
| | await websocket.accept() |
| | self.connected_clients.append(websocket) |
| | |
| | |
| | await self._send_state_update() |
| | |
| | async def disconnect_websocket(self, websocket: WebSocket): |
| | """Disconnect a WebSocket client.""" |
| | if websocket in self.connected_clients: |
| | self.connected_clients.remove(websocket) |
| | |
| | async def _send_state_update(self): |
| | """Send current state to all connected clients.""" |
| | if not self.connected_clients: |
| | return |
| | |
| | state_data = { |
| | "type": "state_update", |
| | "episode_state": asdict(self.episode_state) |
| | } |
| | |
| | |
| | disconnected_clients = [] |
| | for client in self.connected_clients: |
| | try: |
| | await client.send_text(json.dumps(state_data)) |
| | except: |
| | disconnected_clients.append(client) |
| | |
| | |
| | for client in disconnected_clients: |
| | self.connected_clients.remove(client) |
| | |
| | async def reset_environment(self) -> Dict[str, Any]: |
| | """Reset the environment and update state.""" |
| | observation = self.env.reset() |
| | state = self.env.state |
| | |
| | |
| | self.episode_state.episode_id = state.episode_id |
| | self.episode_state.step_count = 0 |
| | self.episode_state.current_observation = asdict(observation) |
| | self.episode_state.action_logs = [] |
| | self.episode_state.is_reset = True |
| | |
| | |
| | await self._send_state_update() |
| | |
| | return { |
| | "observation": asdict(observation), |
| | "reward": observation.reward, |
| | "done": observation.done, |
| | } |
| | |
| | async def step_environment(self, action_data: Dict[str, Any]) -> Dict[str, Any]: |
| | """Execute a step in the environment and update state.""" |
| | |
| | action = self._deserialize_action(action_data) |
| | |
| | |
| | observation = self.env.step(action) |
| | state = self.env.state |
| | |
| | |
| | action_log = ActionLog( |
| | timestamp=datetime.now().isoformat(), |
| | action=asdict(action), |
| | observation=asdict(observation), |
| | reward=observation.reward, |
| | done=observation.done, |
| | step_count=state.step_count |
| | ) |
| | |
| | |
| | self.episode_state.episode_id = state.episode_id |
| | self.episode_state.step_count = state.step_count |
| | self.episode_state.current_observation = asdict(observation) |
| | self.episode_state.action_logs.append(action_log) |
| | self.episode_state.is_reset = False |
| | |
| | |
| | await self._send_state_update() |
| | |
| | return { |
| | "observation": asdict(observation), |
| | "reward": observation.reward, |
| | "done": observation.done, |
| | } |
| | |
| | def get_state(self) -> Dict[str, Any]: |
| | """Get current environment state.""" |
| | state = self.env.state |
| | return asdict(state) |
| | |
| | def _deserialize_action(self, action_data: Dict[str, Any]) -> Action: |
| | """Convert JSON dict to Action instance.""" |
| | metadata = action_data.pop("metadata", {}) |
| | |
| | |
| | processed_data = {} |
| | for key, value in action_data.items(): |
| | if key == "tokens" and isinstance(value, (list, str)): |
| | |
| | if isinstance(value, str): |
| | |
| | try: |
| | import json |
| | value = json.loads(value) |
| | except: |
| | |
| | value = [] |
| | if isinstance(value, list): |
| | import torch |
| | processed_data[key] = torch.tensor(value, dtype=torch.long) |
| | else: |
| | processed_data[key] = value |
| | elif key == "action_id" and isinstance(value, str): |
| | |
| | try: |
| | processed_data[key] = int(value) |
| | except ValueError: |
| | |
| | processed_data[key] = value |
| | else: |
| | processed_data[key] = value |
| | |
| | action = self.action_cls(**processed_data) |
| | action.metadata = metadata |
| | return action |
| |
|
| |
|
| | def create_web_interface_app( |
| | env: Environment, |
| | action_cls: Type[Action], |
| | observation_cls: Type[Observation], |
| | env_name: Optional[str] = None, |
| | ) -> FastAPI: |
| | """ |
| | Create a FastAPI application with web interface for the given environment. |
| | |
| | Args: |
| | env: The Environment instance to serve |
| | action_cls: The Action subclass this environment expects |
| | observation_cls: The Observation subclass this environment returns |
| | env_name: Optional environment name for README loading |
| | |
| | Returns: |
| | FastAPI application instance with web interface |
| | """ |
| | from .http_server import create_fastapi_app |
| | |
| | |
| | app = create_fastapi_app(env, action_cls, observation_cls) |
| | |
| | |
| | metadata = load_environment_metadata(env, env_name) |
| | |
| | |
| | web_manager = WebInterfaceManager(env, action_cls, observation_cls, metadata) |
| | |
| | |
| | @app.get("/web", response_class=HTMLResponse) |
| | async def web_interface(): |
| | """Serve the web interface.""" |
| | return get_web_interface_html(action_cls, web_manager.metadata) |
| | |
| | @app.get("/web/metadata") |
| | async def web_metadata(): |
| | """Get environment metadata.""" |
| | return asdict(web_manager.metadata) |
| | |
| | @app.websocket("/ws") |
| | async def websocket_endpoint(websocket: WebSocket): |
| | """WebSocket endpoint for real-time updates.""" |
| | await web_manager.connect_websocket(websocket) |
| | try: |
| | while True: |
| | |
| | await websocket.receive_text() |
| | except WebSocketDisconnect: |
| | await web_manager.disconnect_websocket(websocket) |
| | |
| | @app.post("/web/reset") |
| | async def web_reset(): |
| | """Reset endpoint for web interface.""" |
| | return await web_manager.reset_environment() |
| | |
| | @app.post("/web/step") |
| | async def web_step(request: Dict[str, Any]): |
| | """Step endpoint for web interface.""" |
| | |
| | if "message" in request: |
| | message = request["message"] |
| | |
| | action = web_manager.env.message_to_action(message) |
| | action_data = {"tokens": action.tokens.tolist()} |
| | else: |
| | action_data = request.get("action", {}) |
| | |
| | return await web_manager.step_environment(action_data) |
| | |
| | @app.get("/web/state") |
| | async def web_state(): |
| | """State endpoint for web interface.""" |
| | return web_manager.get_state() |
| | |
| | return app |
| |
|
| |
|
| | def get_web_interface_html(action_cls: Type[Action], metadata: Optional[EnvironmentMetadata] = None) -> str: |
| | """Generate the HTML for the web interface.""" |
| | |
| | |
| | is_chat_env = False |
| | if hasattr(action_cls, '__dataclass_fields__'): |
| | for field_name, field_info in action_cls.__dataclass_fields__.items(): |
| | if field_name == 'tokens' and hasattr(field_info.type, '__name__') and 'Tensor' in field_info.type.__name__: |
| | is_chat_env = True |
| | break |
| | |
| | |
| | action_fields = _extract_action_fields(action_cls) |
| | |
| | return f""" |
| | <!DOCTYPE html> |
| | <html lang="en"> |
| | <head> |
| | <meta charset="UTF-8"> |
| | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| | <title>OpenEnv Web Interface</title> |
| | <style> |
| | * {{ |
| | margin: 0; |
| | padding: 0; |
| | box-sizing: border-box; |
| | }} |
| | |
| | body {{ |
| | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
| | background-color: #f5f5f5; |
| | height: 100vh; |
| | overflow: hidden; |
| | }} |
| | |
| | .container {{ |
| | display: flex; |
| | height: 100vh; |
| | }} |
| | |
| | .left-pane {{ |
| | width: 50%; |
| | background: white; |
| | border-right: 1px solid #e0e0e0; |
| | display: flex; |
| | flex-direction: column; |
| | }} |
| | |
| | .right-pane {{ |
| | width: 50%; |
| | background: #fafafa; |
| | display: flex; |
| | flex-direction: column; |
| | }} |
| | |
| | .pane-header {{ |
| | padding: 20px; |
| | border-bottom: 1px solid #e0e0e0; |
| | background: #f8f9fa; |
| | font-weight: 600; |
| | font-size: 16px; |
| | }} |
| | |
| | .pane-content {{ |
| | flex: 1; |
| | padding: 20px; |
| | overflow-y: auto; |
| | }} |
| | |
| | .action-form {{ |
| | background: white; |
| | border: 1px solid #e0e0e0; |
| | border-radius: 8px; |
| | padding: 20px; |
| | margin-bottom: 20px; |
| | }} |
| | |
| | .form-group {{ |
| | margin-bottom: 15px; |
| | }} |
| | |
| | .form-group label {{ |
| | display: block; |
| | margin-bottom: 5px; |
| | font-weight: 500; |
| | color: #333; |
| | }} |
| | |
| | .form-group input, .form-group textarea {{ |
| | width: 100%; |
| | padding: 8px 12px; |
| | border: 1px solid #ddd; |
| | border-radius: 4px; |
| | font-size: 14px; |
| | }} |
| | |
| | .form-group input:focus, .form-group textarea:focus {{ |
| | outline: none; |
| | border-color: #007bff; |
| | box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); |
| | }} |
| | |
| | .btn {{ |
| | background: #007bff; |
| | color: white; |
| | border: none; |
| | padding: 10px 20px; |
| | border-radius: 4px; |
| | cursor: pointer; |
| | font-size: 14px; |
| | margin-right: 10px; |
| | margin-bottom: 10px; |
| | }} |
| | |
| | .btn:hover {{ |
| | background: #0056b3; |
| | }} |
| | |
| | .btn:disabled {{ |
| | background: #6c757d; |
| | cursor: not-allowed; |
| | }} |
| | |
| | .btn-secondary {{ |
| | background: #6c757d; |
| | }} |
| | |
| | .btn-secondary:hover {{ |
| | background: #545b62; |
| | }} |
| | |
| | .state-display {{ |
| | background: white; |
| | border: 1px solid #e0e0e0; |
| | border-radius: 8px; |
| | padding: 15px; |
| | margin-bottom: 20px; |
| | }} |
| | |
| | .state-item {{ |
| | margin-bottom: 8px; |
| | }} |
| | |
| | .state-label {{ |
| | font-weight: 500; |
| | color: #666; |
| | }} |
| | |
| | .state-value {{ |
| | color: #333; |
| | font-family: monospace; |
| | }} |
| | |
| | .logs-container {{ |
| | background: white; |
| | border: 1px solid #e0e0e0; |
| | border-radius: 8px; |
| | padding: 15px; |
| | max-height: 400px; |
| | overflow-y: auto; |
| | }} |
| | |
| | .log-entry {{ |
| | border-bottom: 1px solid #f0f0f0; |
| | padding: 10px 0; |
| | }} |
| | |
| | .log-entry:last-child {{ |
| | border-bottom: none; |
| | }} |
| | |
| | .log-timestamp {{ |
| | font-size: 12px; |
| | color: #666; |
| | margin-bottom: 5px; |
| | }} |
| | |
| | .log-action {{ |
| | background: #e3f2fd; |
| | padding: 8px; |
| | border-radius: 4px; |
| | margin-bottom: 5px; |
| | font-family: monospace; |
| | font-size: 12px; |
| | }} |
| | |
| | .log-observation {{ |
| | background: #f3e5f5; |
| | padding: 8px; |
| | border-radius: 4px; |
| | font-family: monospace; |
| | font-size: 12px; |
| | }} |
| | |
| | .log-reward {{ |
| | font-weight: 600; |
| | color: #28a745; |
| | }} |
| | |
| | .log-done {{ |
| | font-weight: 600; |
| | color: #dc3545; |
| | }} |
| | |
| | .status-indicator {{ |
| | display: inline-block; |
| | width: 8px; |
| | height: 8px; |
| | border-radius: 50%; |
| | margin-right: 8px; |
| | }} |
| | |
| | .status-connected {{ |
| | background: #28a745; |
| | }} |
| | |
| | .status-disconnected {{ |
| | background: #dc3545; |
| | }} |
| | |
| | .json-display {{ |
| | background: #f8f9fa; |
| | border: 1px solid #e9ecef; |
| | border-radius: 4px; |
| | padding: 10px; |
| | font-family: monospace; |
| | font-size: 12px; |
| | white-space: pre-wrap; |
| | max-height: 200px; |
| | overflow-y: auto; |
| | }} |
| | |
| | /* Chat Interface Styles */ |
| | .chat-interface {{ |
| | background: white; |
| | border: 1px solid #e0e0e0; |
| | border-radius: 8px; |
| | padding: 20px; |
| | margin-bottom: 20px; |
| | }} |
| | |
| | .chat-messages {{ |
| | background: #f8f9fa; |
| | border: 1px solid #e0e0e0; |
| | border-radius: 8px; |
| | padding: 15px; |
| | margin-bottom: 15px; |
| | max-height: 400px; |
| | overflow-y: auto; |
| | }} |
| | |
| | .chat-message {{ |
| | margin-bottom: 15px; |
| | padding: 10px; |
| | border-radius: 8px; |
| | }} |
| | |
| | .chat-message:last-child {{ |
| | margin-bottom: 0; |
| | }} |
| | |
| | .chat-message.user {{ |
| | background: #e3f2fd; |
| | margin-left: 20px; |
| | }} |
| | |
| | .chat-message.assistant {{ |
| | background: #f3e5f5; |
| | margin-right: 20px; |
| | }} |
| | |
| | .chat-message.system {{ |
| | background: #e8f5e8; |
| | font-style: italic; |
| | }} |
| | |
| | .message-role {{ |
| | font-weight: 600; |
| | font-size: 12px; |
| | color: #666; |
| | margin-bottom: 5px; |
| | }} |
| | |
| | .message-content {{ |
| | font-size: 14px; |
| | line-height: 1.4; |
| | }} |
| | |
| | .chat-input-container {{ |
| | border-top: 1px solid #e0e0e0; |
| | padding-top: 15px; |
| | }} |
| | |
| | .role-selector {{ |
| | margin-bottom: 10px; |
| | }} |
| | |
| | .role-selector label {{ |
| | font-weight: 500; |
| | margin-right: 10px; |
| | }} |
| | |
| | .role-selector select {{ |
| | padding: 5px 10px; |
| | border: 1px solid #ddd; |
| | border-radius: 4px; |
| | }} |
| | |
| | .message-input {{ |
| | display: flex; |
| | gap: 10px; |
| | align-items: flex-end; |
| | }} |
| | |
| | .message-input textarea {{ |
| | flex: 1; |
| | padding: 10px; |
| | border: 1px solid #ddd; |
| | border-radius: 4px; |
| | resize: vertical; |
| | font-family: inherit; |
| | }} |
| | |
| | .message-input textarea:focus {{ |
| | outline: none; |
| | border-color: #007bff; |
| | box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); |
| | }} |
| | |
| | /* Instructions Section Styles */ |
| | .instructions-section {{ |
| | background: white; |
| | border: 1px solid #e0e0e0; |
| | border-radius: 8px; |
| | padding: 20px; |
| | margin-bottom: 20px; |
| | }} |
| | |
| | .instructions-header {{ |
| | display: flex; |
| | justify-content: space-between; |
| | align-items: center; |
| | margin-bottom: 15px; |
| | }} |
| | |
| | .instructions-title {{ |
| | font-size: 18px; |
| | font-weight: 600; |
| | color: #333; |
| | margin: 0; |
| | }} |
| | |
| | .instructions-toggle {{ |
| | background: #f8f9fa; |
| | border: 1px solid #dee2e6; |
| | border-radius: 4px; |
| | padding: 5px 10px; |
| | cursor: pointer; |
| | font-size: 12px; |
| | color: #6c757d; |
| | }} |
| | |
| | .instructions-toggle:hover {{ |
| | background: #e9ecef; |
| | }} |
| | |
| | .instructions-content {{ |
| | display: none; |
| | max-height: 400px; |
| | overflow-y: auto; |
| | border-top: 1px solid #e0e0e0; |
| | padding-top: 15px; |
| | }} |
| | |
| | .instructions-content.expanded {{ |
| | display: block; |
| | }} |
| | |
| | .instructions-content h1, |
| | .instructions-content h2, |
| | .instructions-content h3 {{ |
| | color: #333; |
| | margin-top: 20px; |
| | margin-bottom: 10px; |
| | }} |
| | |
| | .instructions-content h1 {{ |
| | font-size: 24px; |
| | border-bottom: 2px solid #007bff; |
| | padding-bottom: 10px; |
| | }} |
| | |
| | .instructions-content h2 {{ |
| | font-size: 20px; |
| | }} |
| | |
| | .instructions-content h3 {{ |
| | font-size: 16px; |
| | }} |
| | |
| | .instructions-content p {{ |
| | margin-bottom: 10px; |
| | line-height: 1.6; |
| | }} |
| | |
| | .instructions-content code {{ |
| | background: #f8f9fa; |
| | padding: 2px 4px; |
| | border-radius: 3px; |
| | font-family: monospace; |
| | font-size: 14px; |
| | }} |
| | |
| | .instructions-content pre {{ |
| | background: #f8f9fa; |
| | border: 1px solid #e9ecef; |
| | border-radius: 4px; |
| | padding: 15px; |
| | overflow-x: auto; |
| | margin: 10px 0; |
| | }} |
| | |
| | .instructions-content pre code {{ |
| | background: none; |
| | padding: 0; |
| | }} |
| | |
| | .instructions-content ul, |
| | .instructions-content ol {{ |
| | margin: 10px 0; |
| | padding-left: 20px; |
| | }} |
| | |
| | .instructions-content li {{ |
| | margin-bottom: 5px; |
| | }} |
| | |
| | .instructions-content table {{ |
| | border-collapse: collapse; |
| | width: 100%; |
| | margin: 15px 0; |
| | }} |
| | |
| | .instructions-content th, |
| | .instructions-content td {{ |
| | border: 1px solid #dee2e6; |
| | padding: 8px 12px; |
| | text-align: left; |
| | }} |
| | |
| | .instructions-content th {{ |
| | background: #f8f9fa; |
| | font-weight: 600; |
| | }} |
| | |
| | /* Enhanced Form Styles */ |
| | .help-text {{ |
| | display: block; |
| | margin-top: 5px; |
| | font-size: 12px; |
| | color: #6c757d; |
| | font-style: italic; |
| | }} |
| | |
| | .form-group label {{ |
| | font-weight: 500; |
| | color: #333; |
| | margin-bottom: 5px; |
| | }} |
| | |
| | .form-group select {{ |
| | width: 100%; |
| | padding: 8px 12px; |
| | border: 1px solid #ddd; |
| | border-radius: 4px; |
| | font-size: 14px; |
| | background-color: white; |
| | }} |
| | |
| | .form-group select:focus {{ |
| | outline: none; |
| | border-color: #007bff; |
| | box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); |
| | }} |
| | |
| | .form-group textarea {{ |
| | width: 100%; |
| | padding: 8px 12px; |
| | border: 1px solid #ddd; |
| | border-radius: 4px; |
| | font-size: 14px; |
| | font-family: inherit; |
| | resize: vertical; |
| | }} |
| | |
| | .form-group textarea:focus {{ |
| | outline: none; |
| | border-color: #007bff; |
| | box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); |
| | }} |
| | |
| | .form-group input[type="number"] {{ |
| | width: 100%; |
| | padding: 8px 12px; |
| | border: 1px solid #ddd; |
| | border-radius: 4px; |
| | font-size: 14px; |
| | }} |
| | |
| | .form-group input[type="number"]:focus {{ |
| | outline: none; |
| | border-color: #007bff; |
| | box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); |
| | }} |
| | |
| | .form-group input[type="text"]:focus {{ |
| | outline: none; |
| | border-color: #007bff; |
| | box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); |
| | }} |
| | |
| | .required-indicator {{ |
| | color: #dc3545; |
| | font-weight: bold; |
| | }} |
| | |
| | .form-group .field-description {{ |
| | font-size: 11px; |
| | color: #666; |
| | margin-top: 2px; |
| | font-style: italic; |
| | }} |
| | </style> |
| | </head> |
| | <body> |
| | <div class="container"> |
| | <!-- Left Pane: HumanAgent Interface --> |
| | <div class="left-pane"> |
| | <div class="pane-header"> |
| | <span class="status-indicator status-disconnected" id="connection-status"></span> |
| | HumanAgent Interface |
| | </div> |
| | <div class="pane-content"> |
| | <!-- Instructions Section --> |
| | {_generate_instructions_section(metadata)} |
| | |
| | <!-- Action Form or Chat Interface --> |
| | {_generate_action_interface(action_fields, is_chat_env)} |
| | |
| | <!-- Control Buttons --> |
| | <div style="margin-bottom: 20px;"> |
| | <button class="btn btn-secondary" id="reset-btn">Reset Environment</button> |
| | <button class="btn btn-secondary" id="state-btn">Get State</button> |
| | </div> |
| | |
| | <!-- Current State Display --> |
| | <div class="state-display"> |
| | <h3>Current State</h3> |
| | <div id="current-state"> |
| | <div class="state-item"> |
| | <span class="state-label">Status:</span> |
| | <span class="state-value" id="env-status">Not initialized</span> |
| | </div> |
| | <div class="state-item"> |
| | <span class="state-label">Episode ID:</span> |
| | <span class="state-value" id="episode-id">-</span> |
| | </div> |
| | <div class="state-item"> |
| | <span class="state-label">Step Count:</span> |
| | <span class="state-value" id="step-count">0</span> |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| | |
| | <!-- Right Pane: State Observer --> |
| | <div class="right-pane"> |
| | <div class="pane-header"> |
| | State Observer |
| | </div> |
| | <div class="pane-content"> |
| | <!-- Current Observation --> |
| | <div class="state-display"> |
| | <h3>Current Observation</h3> |
| | <div id="current-observation" class="json-display"> |
| | No observation yet |
| | </div> |
| | </div> |
| | |
| | <!-- Action Logs --> |
| | <div class="logs-container"> |
| | <h3>Action History</h3> |
| | <div id="action-logs"> |
| | No actions taken yet |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| | |
| | <script> |
| | class OpenEnvWebInterface {{ |
| | constructor() {{ |
| | this.ws = null; |
| | this.isConnected = false; |
| | this.init(); |
| | }} |
| | |
| | init() {{ |
| | this.connectWebSocket(); |
| | this.setupEventListeners(); |
| | }} |
| | |
| | connectWebSocket() {{ |
| | const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; |
| | const wsUrl = `${{protocol}}//${{window.location.host}}/ws`; |
| | |
| | this.ws = new WebSocket(wsUrl); |
| | |
| | this.ws.onopen = () => {{ |
| | this.isConnected = true; |
| | this.updateConnectionStatus(true); |
| | console.log('WebSocket connected'); |
| | }}; |
| | |
| | this.ws.onmessage = (event) => {{ |
| | const data = JSON.parse(event.data); |
| | if (data.type === 'state_update') {{ |
| | this.updateUI(data.episode_state); |
| | }} |
| | }}; |
| | |
| | this.ws.onclose = () => {{ |
| | this.isConnected = false; |
| | this.updateConnectionStatus(false); |
| | console.log('WebSocket disconnected'); |
| | // Attempt to reconnect after 3 seconds |
| | setTimeout(() => this.connectWebSocket(), 3000); |
| | }}; |
| | |
| | this.ws.onerror = (error) => {{ |
| | console.error('WebSocket error:', error); |
| | }}; |
| | }} |
| | |
| | setupEventListeners() {{ |
| | // Instructions toggle |
| | const instructionsToggle = document.getElementById('instructions-toggle'); |
| | const instructionsContent = document.getElementById('instructions-content'); |
| | if (instructionsToggle && instructionsContent) {{ |
| | instructionsToggle.addEventListener('click', () => {{ |
| | instructionsContent.classList.toggle('expanded'); |
| | instructionsToggle.textContent = instructionsContent.classList.contains('expanded') |
| | ? 'Hide Instructions' : 'Show Instructions'; |
| | }}); |
| | }} |
| | |
| | // Check if this is a chat environment |
| | const isChatEnv = document.getElementById('chat-messages') !== null; |
| | |
| | if (isChatEnv) {{ |
| | // Chat environment event listeners |
| | document.getElementById('send-message-btn').addEventListener('click', () => {{ |
| | this.sendMessage(); |
| | }}); |
| | |
| | // Send message on Enter (but allow Shift+Enter for new lines) |
| | document.getElementById('message-input').addEventListener('keydown', (e) => {{ |
| | if (e.key === 'Enter' && !e.shiftKey) {{ |
| | e.preventDefault(); |
| | this.sendMessage(); |
| | }} |
| | }}); |
| | }} else {{ |
| | // Traditional action form submission |
| | const actionForm = document.getElementById('action-form'); |
| | if (actionForm) {{ |
| | actionForm.addEventListener('submit', (e) => {{ |
| | e.preventDefault(); |
| | this.submitAction(); |
| | }}); |
| | }} |
| | }} |
| | |
| | // Reset button |
| | document.getElementById('reset-btn').addEventListener('click', () => {{ |
| | this.resetEnvironment(); |
| | }}); |
| | |
| | // State button |
| | document.getElementById('state-btn').addEventListener('click', () => {{ |
| | this.getState(); |
| | }}); |
| | }} |
| | |
| | async sendMessage() {{ |
| | const messageInput = document.getElementById('message-input'); |
| | const roleSelect = document.getElementById('message-role'); |
| | const message = messageInput.value.trim(); |
| | const role = roleSelect.value; |
| | |
| | if (!message) {{ |
| | return; |
| | }} |
| | |
| | // Add message to chat display immediately |
| | this.addMessageToChat(role, message); |
| | |
| | // Clear input |
| | messageInput.value = ''; |
| | |
| | try {{ |
| | // Send message to server to convert to action and step |
| | const response = await fetch('/web/step', {{ |
| | method: 'POST', |
| | headers: {{ 'Content-Type': 'application/json' }}, |
| | body: JSON.stringify({{ |
| | message: {{ |
| | role: role, |
| | content: message |
| | }} |
| | }}) |
| | }}); |
| | |
| | if (!response.ok) {{ |
| | throw new Error(`HTTP error! status: ${{response.status}}`); |
| | }} |
| | |
| | const result = await response.json(); |
| | console.log('Message sent:', result); |
| | }} catch (error) {{ |
| | console.error('Error sending message:', error); |
| | alert('Error sending message: ' + error.message); |
| | }} |
| | }} |
| | |
| | addMessageToChat(role, content) {{ |
| | const chatMessages = document.getElementById('chat-messages'); |
| | const messageDiv = document.createElement('div'); |
| | messageDiv.className = `chat-message ${{role}}`; |
| | |
| | messageDiv.innerHTML = ` |
| | <div class="message-role">${{role.charAt(0).toUpperCase() + role.slice(1)}}</div> |
| | <div class="message-content">${{content}}</div> |
| | `; |
| | |
| | chatMessages.appendChild(messageDiv); |
| | chatMessages.scrollTop = chatMessages.scrollHeight; |
| | }} |
| | |
| | async submitAction() {{ |
| | const formData = new FormData(document.getElementById('action-form')); |
| | const action = {{}}; |
| | |
| | // Collect form data |
| | for (const [key, value] of formData.entries()) {{ |
| | if (value !== '') {{ |
| | // Handle tensor fields (tokens) - convert comma-separated string to array |
| | if (key === 'tokens') {{ |
| | try {{ |
| | action[key] = value.split(',').map(x => parseInt(x.trim())).filter(x => !isNaN(x)); |
| | }} catch (e) {{ |
| | console.error('Error parsing tokens:', e); |
| | action[key] = []; |
| | }} |
| | }} else {{ |
| | action[key] = value; |
| | }} |
| | }} |
| | }} |
| | |
| | try {{ |
| | const response = await fetch('/web/step', {{ |
| | method: 'POST', |
| | headers: {{ 'Content-Type': 'application/json' }}, |
| | body: JSON.stringify({{ action }}) |
| | }}); |
| | |
| | if (!response.ok) {{ |
| | throw new Error(`HTTP error! status: ${{response.status}}`); |
| | }} |
| | |
| | const result = await response.json(); |
| | console.log('Step result:', result); |
| | }} catch (error) {{ |
| | console.error('Error submitting action:', error); |
| | alert('Error submitting action: ' + error.message); |
| | }} |
| | }} |
| | |
| | async resetEnvironment() {{ |
| | try {{ |
| | const response = await fetch('/web/reset', {{ |
| | method: 'POST', |
| | headers: {{ 'Content-Type': 'application/json' }} |
| | }}); |
| | |
| | if (!response.ok) {{ |
| | throw new Error(`HTTP error! status: ${{response.status}}`); |
| | }} |
| | |
| | const result = await response.json(); |
| | console.log('Reset result:', result); |
| | }} catch (error) {{ |
| | console.error('Error resetting environment:', error); |
| | alert('Error resetting environment: ' + error.message); |
| | }} |
| | }} |
| | |
| | async getState() {{ |
| | try {{ |
| | const response = await fetch('/web/state'); |
| | const state = await response.json(); |
| | console.log('Current state:', state); |
| | alert('Current state: ' + JSON.stringify(state, null, 2)); |
| | }} catch (error) {{ |
| | console.error('Error getting state:', error); |
| | alert('Error getting state: ' + error.message); |
| | }} |
| | }} |
| | |
| | updateConnectionStatus(connected) {{ |
| | const indicator = document.getElementById('connection-status'); |
| | if (connected) {{ |
| | indicator.className = 'status-indicator status-connected'; |
| | }} else {{ |
| | indicator.className = 'status-indicator status-disconnected'; |
| | }} |
| | }} |
| | |
| | updateUI(episodeState) {{ |
| | // Check if this is a chat environment |
| | const isChatEnv = document.getElementById('chat-messages') !== null; |
| | |
| | // Update current state |
| | document.getElementById('env-status').textContent = |
| | episodeState.is_reset ? 'Reset' : 'Running'; |
| | document.getElementById('episode-id').textContent = |
| | episodeState.episode_id || '-'; |
| | document.getElementById('step-count').textContent = |
| | episodeState.step_count.toString(); |
| | |
| | if (isChatEnv) {{ |
| | // Update chat interface |
| | this.updateChatInterface(episodeState); |
| | }} else {{ |
| | // Update traditional observation display |
| | const observationDiv = document.getElementById('current-observation'); |
| | if (episodeState.current_observation) {{ |
| | observationDiv.textContent = JSON.stringify( |
| | episodeState.current_observation, null, 2 |
| | ); |
| | }} else {{ |
| | observationDiv.textContent = 'No observation yet'; |
| | }} |
| | }} |
| | |
| | // Update action logs |
| | const logsDiv = document.getElementById('action-logs'); |
| | if (episodeState.action_logs.length === 0) {{ |
| | logsDiv.innerHTML = 'No actions taken yet'; |
| | }} else {{ |
| | logsDiv.innerHTML = episodeState.action_logs.map(log => ` |
| | <div class="log-entry"> |
| | <div class="log-timestamp">${{log.timestamp}} (Step ${{log.step_count}})</div> |
| | <div class="log-action">Action: ${{JSON.stringify(log.action, null, 2)}}</div> |
| | <div class="log-observation">Observation: ${{JSON.stringify(log.observation, null, 2)}}</div> |
| | <div> |
| | <span class="log-reward">Reward: ${{log.reward !== null ? log.reward : 'None'}}</span> |
| | ${{log.done ? '<span class="log-done">DONE</span>' : ''}} |
| | </div> |
| | </div> |
| | `).join(''); |
| | }} |
| | }} |
| | |
| | updateChatInterface(episodeState) {{ |
| | const chatMessages = document.getElementById('chat-messages'); |
| | if (!chatMessages) return; |
| | |
| | // Clear existing messages (except system message) |
| | const systemMessage = chatMessages.querySelector('.chat-message.system'); |
| | chatMessages.innerHTML = ''; |
| | if (systemMessage) {{ |
| | chatMessages.appendChild(systemMessage); |
| | }} |
| | |
| | // Add messages from current observation |
| | if (episodeState.current_observation && episodeState.current_observation.messages) {{ |
| | episodeState.current_observation.messages.forEach(msg => {{ |
| | this.addMessageToChat(msg.role, msg.content); |
| | }}); |
| | }} |
| | }} |
| | }} |
| | |
| | // Initialize the web interface when the page loads |
| | document.addEventListener('DOMContentLoaded', () => {{ |
| | new OpenEnvWebInterface(); |
| | }}); |
| | </script> |
| | </body> |
| | </html> |
| | """.replace('{_generate_action_form_fields(action_fields)}', _generate_action_form_fields(action_fields)) |
| |
|
| |
|
| | def _generate_instructions_section(metadata: Optional[EnvironmentMetadata]) -> str: |
| | """Generate the instructions section with environment documentation.""" |
| | if not metadata or not metadata.readme_content: |
| | return '' |
| | |
| | |
| | import re |
| | html_content = _markdown_to_html(metadata.readme_content) |
| | |
| | return f''' |
| | <!-- Instructions Section --> |
| | <div class="instructions-section"> |
| | <div class="instructions-header"> |
| | <h3 class="instructions-title">{metadata.name}</h3> |
| | <button class="instructions-toggle" id="instructions-toggle">Show Instructions</button> |
| | </div> |
| | <div class="instructions-content" id="instructions-content"> |
| | <div class="instructions-readme"> |
| | {html_content} |
| | </div> |
| | </div> |
| | </div> |
| | ''' |
| |
|
| |
|
| | def _extract_action_fields(action_cls: Type[Action]) -> List[Dict[str, Any]]: |
| | """Extract enhanced field metadata from Action class for form generation.""" |
| | import typing |
| | from typing import get_origin, get_args |
| | |
| | action_fields = [] |
| | if not hasattr(action_cls, '__dataclass_fields__'): |
| | return action_fields |
| | |
| | for field_name, field_info in action_cls.__dataclass_fields__.items(): |
| | if field_name == 'metadata': |
| | continue |
| | |
| | field_type = field_info.type |
| | field_metadata = _extract_field_metadata(field_name, field_info) |
| | |
| | |
| | input_type = _determine_input_type(field_type) |
| | |
| | |
| | is_required = field_info.default is field_info.default_factory |
| | |
| | action_fields.append({ |
| | 'name': field_name, |
| | 'type': input_type, |
| | 'required': is_required, |
| | 'description': field_metadata.get('description', ''), |
| | 'default_value': field_metadata.get('default_value'), |
| | 'choices': field_metadata.get('choices', []), |
| | 'min_value': field_metadata.get('min_value'), |
| | 'max_value': field_metadata.get('max_value'), |
| | 'placeholder': field_metadata.get('placeholder', ''), |
| | 'help_text': field_metadata.get('help_text', ''), |
| | }) |
| | |
| | return action_fields |
| |
|
| |
|
| | def _extract_field_metadata(field_name: str, field_info) -> Dict[str, Any]: |
| | """Extract metadata from dataclass field including docstring and type hints.""" |
| | import typing |
| | from typing import get_origin, get_args, Literal, Union, Optional |
| | |
| | metadata = {} |
| | |
| | |
| | if hasattr(field_info, 'metadata') and field_info.metadata: |
| | |
| | for meta in field_info.metadata: |
| | if isinstance(meta, dict): |
| | metadata.update(meta) |
| | |
| | |
| | field_type = field_info.type |
| | origin = get_origin(field_type) |
| | |
| | |
| | if origin is Literal: |
| | args = get_args(field_type) |
| | metadata['choices'] = list(args) |
| | |
| | |
| | if origin is Union: |
| | args = get_args(field_type) |
| | if len(args) == 2 and type(None) in args: |
| | |
| | non_none_type = args[0] if args[1] is type(None) else args[1] |
| | metadata['optional'] = True |
| | |
| | if get_origin(non_none_type) is Literal: |
| | metadata['choices'] = list(get_args(non_none_type)) |
| | else: |
| | |
| | metadata['choices'] = [str(arg) for arg in args if arg is not type(None)] |
| | |
| | |
| | if field_type in (int, float): |
| | |
| | if 'count' in field_name.lower() or 'num' in field_name.lower(): |
| | metadata['min_value'] = 0 |
| | if 'id' in field_name.lower(): |
| | metadata['min_value'] = 0 |
| | |
| | |
| | if 'message' in field_name.lower(): |
| | metadata['placeholder'] = f'Enter {field_name.replace("_", " ")}...' |
| | elif 'code' in field_name.lower(): |
| | metadata['placeholder'] = 'Enter Python code here...' |
| | elif 'tokens' in field_name.lower(): |
| | metadata['placeholder'] = 'Enter comma-separated token IDs (e.g., 1,2,3,4,5)' |
| | else: |
| | metadata['placeholder'] = f'Enter {field_name.replace("_", " ")}...' |
| | |
| | |
| | if 'action_id' in field_name.lower(): |
| | metadata['help_text'] = 'The action ID to execute in the environment' |
| | elif 'game_name' in field_name.lower(): |
| | metadata['help_text'] = 'Name of the game or environment' |
| | elif 'tokens' in field_name.lower(): |
| | metadata['help_text'] = 'Token IDs as a comma-separated list of integers' |
| | elif 'code' in field_name.lower(): |
| | metadata['help_text'] = 'Python code to execute in the environment' |
| | elif 'message' in field_name.lower(): |
| | metadata['help_text'] = 'Text message to send' |
| | |
| | return metadata |
| |
|
| |
|
| | def _determine_input_type(field_type) -> str: |
| | """Determine the appropriate HTML input type for a field type.""" |
| | import typing |
| | from typing import get_origin, get_args, Literal, Union |
| | |
| | |
| | if field_type == str: |
| | return "text" |
| | elif field_type == int: |
| | return "number" |
| | elif field_type == float: |
| | return "number" |
| | elif field_type == bool: |
| | return "checkbox" |
| | |
| | |
| | origin = get_origin(field_type) |
| | |
| | if origin is Literal: |
| | return "select" |
| | elif origin is Union: |
| | args = get_args(field_type) |
| | if len(args) == 2 and type(None) in args: |
| | |
| | non_none_type = args[0] if args[1] is type(None) else args[1] |
| | return _determine_input_type(non_none_type) |
| | elif all(isinstance(arg, str) for arg in args if arg is not type(None)): |
| | return "select" |
| | else: |
| | return "text" |
| | elif hasattr(field_type, '__name__') and 'Tensor' in field_type.__name__: |
| | return "tensor" |
| | else: |
| | return "text" |
| |
|
| |
|
| | def _markdown_to_html(markdown: str) -> str: |
| | """Convert basic markdown to HTML for README display.""" |
| | import html |
| | import re |
| | |
| | |
| | html_content = html.escape(markdown) |
| | |
| | |
| | html_content = re.sub(r'^# (.*?)$', r'<h1>\1</h1>', html_content, flags=re.MULTILINE) |
| | html_content = re.sub(r'^## (.*?)$', r'<h2>\1</h2>', html_content, flags=re.MULTILINE) |
| | html_content = re.sub(r'^### (.*?)$', r'<h3>\1</h3>', html_content, flags=re.MULTILINE) |
| | |
| | |
| | html_content = re.sub(r'```(.*?)\n(.*?)\n```', r'<pre><code>\2</code></pre>', html_content, flags=re.DOTALL) |
| | html_content = re.sub(r'`([^`]+)`', r'<code>\1</code>', html_content) |
| | |
| | |
| | html_content = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', html_content) |
| | html_content = re.sub(r'\*(.*?)\*', r'<em>\1</em>', html_content) |
| | |
| | |
| | html_content = re.sub(r'^- (.*?)$', r'<li>\1</li>', html_content, flags=re.MULTILINE) |
| | html_content = re.sub(r'(<li>.*</li>)', r'<ul>\1</ul>', html_content, flags=re.DOTALL) |
| | |
| | |
| | html_content = html_content.replace('\n', '<br>') |
| | |
| | return html_content |
| |
|
| |
|
| | def _generate_action_interface(action_fields: List[Dict[str, Any]], is_chat_env: bool) -> str: |
| | """Generate either a chat interface or action form based on environment type.""" |
| | if is_chat_env: |
| | return _generate_chat_interface() |
| | else: |
| | return _generate_action_form(action_fields) |
| |
|
| | def _generate_chat_interface() -> str: |
| | """Generate a chat-style interface for chat environments.""" |
| | return ''' |
| | <!-- Chat Interface --> |
| | <div class="chat-interface"> |
| | <h3>Chat Interface</h3> |
| | <div class="chat-messages" id="chat-messages"> |
| | <div class="chat-message system"> |
| | <div class="message-role">System</div> |
| | <div class="message-content">Chat environment ready. Send a message to start the conversation.</div> |
| | </div> |
| | </div> |
| | <div class="chat-input-container"> |
| | <div class="role-selector"> |
| | <label for="message-role">Role:</label> |
| | <select id="message-role"> |
| | <option value="user">User</option> |
| | <option value="assistant">Assistant</option> |
| | </select> |
| | </div> |
| | <div class="message-input"> |
| | <textarea id="message-input" placeholder="Type your message here..." rows="3"></textarea> |
| | <button class="btn" id="send-message-btn">Send Message</button> |
| | </div> |
| | </div> |
| | </div> |
| | ''' |
| |
|
| | def _generate_action_form(action_fields: List[Dict[str, Any]]) -> str: |
| | """Generate a traditional action form for non-chat environments.""" |
| | return f''' |
| | <!-- Action Form --> |
| | <div class="action-form"> |
| | <h3>Take Action</h3> |
| | <form id="action-form"> |
| | {_generate_action_form_fields(action_fields)} |
| | <button type="submit" class="btn" id="step-btn">Step</button> |
| | </form> |
| | </div> |
| | ''' |
| |
|
| | def _generate_action_form_fields(action_fields: List[Dict[str, Any]]) -> str: |
| | """Generate HTML form fields for action input with enhanced metadata.""" |
| | if not action_fields: |
| | return '<p>No action fields available</p>' |
| | |
| | fields_html = [] |
| | for field in action_fields: |
| | field_html = _generate_single_field(field) |
| | fields_html.append(field_html) |
| | |
| | return '\n'.join(fields_html) |
| |
|
| |
|
| | def _generate_single_field(field: Dict[str, Any]) -> str: |
| | """Generate HTML for a single form field with enhanced metadata.""" |
| | field_name = field['name'] |
| | field_type = field['type'] |
| | required = field['required'] |
| | placeholder = field.get('placeholder', '') |
| | help_text = field.get('help_text', '') |
| | choices = field.get('choices', []) |
| | min_value = field.get('min_value') |
| | max_value = field.get('max_value') |
| | default_value = field.get('default_value') |
| | |
| | |
| | label_text = field_name.replace('_', ' ').title() |
| | if required: |
| | label_text += ' <span style="color: red;">*</span>' |
| | |
| | |
| | input_attrs = [] |
| | if required: |
| | input_attrs.append('required') |
| | if placeholder: |
| | input_attrs.append(f'placeholder="{placeholder}"') |
| | if min_value is not None: |
| | input_attrs.append(f'min="{min_value}"') |
| | if max_value is not None: |
| | input_attrs.append(f'max="{max_value}"') |
| | if default_value is not None: |
| | input_attrs.append(f'value="{default_value}"') |
| | |
| | attrs_str = ' '.join(input_attrs) |
| | |
| | if field_type == 'checkbox': |
| | return f''' |
| | <div class="form-group"> |
| | <label> |
| | <input type="checkbox" name="{field_name}" value="true" {attrs_str}> |
| | {label_text} |
| | </label> |
| | {f'<small class="help-text">{help_text}</small>' if help_text else ''} |
| | </div> |
| | ''' |
| | |
| | elif field_type == 'select': |
| | options_html = [] |
| | if not required: |
| | options_html.append(f'<option value="">-- Select {label_text} --</option>') |
| | |
| | for choice in choices: |
| | selected = 'selected' if str(choice) == str(default_value) else '' |
| | options_html.append(f'<option value="{choice}" {selected}>{choice}</option>') |
| | |
| | return f''' |
| | <div class="form-group"> |
| | <label for="{field_name}">{label_text}:</label> |
| | <select name="{field_name}" id="{field_name}" {attrs_str}> |
| | {''.join(options_html)} |
| | </select> |
| | {f'<small class="help-text">{help_text}</small>' if help_text else ''} |
| | </div> |
| | ''' |
| | |
| | elif field_type == 'tensor': |
| | return f''' |
| | <div class="form-group"> |
| | <label for="{field_name}">{label_text} (comma-separated integers):</label> |
| | <input type="text" name="{field_name}" id="{field_name}" {attrs_str}> |
| | <small class="help-text">{help_text or 'Enter token IDs as comma-separated integers (e.g., 1,2,3,4,5)'}</small> |
| | </div> |
| | ''' |
| | |
| | elif field_type == 'text' and ('message' in field_name.lower() or 'code' in field_name.lower()): |
| | return f''' |
| | <div class="form-group"> |
| | <label for="{field_name}">{label_text}:</label> |
| | <textarea name="{field_name}" id="{field_name}" rows="3" {attrs_str}></textarea> |
| | {f'<small class="help-text">{help_text}</small>' if help_text else ''} |
| | </div> |
| | ''' |
| | |
| | else: |
| | return f''' |
| | <div class="form-group"> |
| | <label for="{field_name}">{label_text}:</label> |
| | <input type="{field_type}" name="{field_name}" id="{field_name}" {attrs_str}> |
| | {f'<small class="help-text">{help_text}</small>' if help_text else ''} |
| | </div> |
| | ''' |
| |
|