diff --git a/.gitignore b/.gitignore index edc0cbe..422e4bf 100644 --- a/.gitignore +++ b/.gitignore @@ -252,4 +252,6 @@ uv.lock .pypirc .deployment/ -.playwright-cli/ \ No newline at end of file +.playwright-cli/ +/ +but/ diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ce98500..ebf2582 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,21 @@ All notable changes to this project will be documented in this file. ## Unreleased +### v0.10.29 (dev) + +- **Dialog HITL** — New HITL type for interactive content review via chat interface. When a `CaseNodeUpdate` payload contains `supervaizer_dialog`, the workbench renders a conversation UI instead of a fixed form. Supports iterative refinement through LLM-powered feedback loops. Fields: `content` (JSON string), `content_type` (email/text/code), `objective`, `instructions`, `messages` (conversation history), `confirm_label`. + - Template: `dialog_renderer.html` with `render_hitl_dialog` and `render_dialog_confirmed` macros + - JS: `submitDialogMessage(caseId, message)` and `confirmDialog(caseId)` in `workbench-form.js` + - Route: `workbench_routes.py` detects `supervaizer_dialog` in AWAITING case payloads + +- **Local mode URL fixes** — `supervaizer start --local --port N` now correctly shows localhost URLs everywhere: CLI output, admin interface logs, storage, and uvicorn. Fixed by resolving `Server.__init__` defaults from env vars at call time (not class definition time) and ensuring CLI-provided values take precedence over `.env` file values. + +- **Local mode event skipping** — `account_service.send_event()` returns a no-op `ApiSuccess` when `SUPERVAIZER_LOCAL_MODE=true`, preventing HTTP errors against the SaaS API during local development. + +- **Agent parameter env pre-fill** — In local mode, the workbench auto-loads `.env` values into agent parameter fields with green `.env` badge indicators. Secret fields show a masked placeholder; non-secret fields display the value. Backend falls back to env values for empty fields on job submission. + +- **HTMX polling guard** — Monitor template suppresses `hx-trigger` polling when a HITL dialog or form is active, preventing DOM overwrites while users interact with forms. + ### v0.10.27 - **Agent Workbench** — Full-featured testing interface for agents directly from the admin panel. Four-zone layout with agent parameters, job control, execution monitor, and live console log. Supports starting/stopping jobs, real-time case and step tracking via HTMX polling, and Human-in-the-Loop (HITL) form rendering and submission. Job history panel lists all past executions with status badges. diff --git a/src/supervaizer/account_service.py b/src/supervaizer/account_service.py index 26d3c00..fd10fbd 100644 --- a/src/supervaizer/account_service.py +++ b/src/supervaizer/account_service.py @@ -14,7 +14,7 @@ import httpx -from supervaizer.common import ApiError, ApiResult, ApiSuccess, log +from supervaizer.common import ApiError, ApiResult, ApiSuccess, is_local_mode, log logger = logging.getLogger("httpx") # Enable httpx debug logging (optional - uncomment for transport-level debugging) @@ -57,6 +57,13 @@ def send_event( Tested in tests/test_account_service.py """ + # In local mode, skip sending events to the SaaS API entirely. + if is_local_mode(): + log.debug(f"[Send event] Local mode — skipping {event.type.name}") + return ApiSuccess( + message=f"Event {event.type.name} skipped (local mode)", detail=None + ) + headers = account.api_headers payload = event.payload url_event = ( diff --git a/src/supervaizer/admin/routes.py b/src/supervaizer/admin/routes.py index 5d23925..fe40716 100644 --- a/src/supervaizer/admin/routes.py +++ b/src/supervaizer/admin/routes.py @@ -1223,12 +1223,17 @@ async def execute_console_command( return {"status": "error", "message": str(e)} # Include workbench sub-router - from supervaizer.admin.workbench_routes import create_workbench_routes + from supervaizer.admin.workbench_routes import ( + create_workbench_routes, + create_workbench_ws_routes, + ) router.include_router( create_workbench_routes(), dependencies=[Depends(verify_admin_access)], ) + # WebSocket routes are mounted separately — WS can't use APIKeyHeader auth + router.include_router(create_workbench_ws_routes()) return router diff --git a/src/supervaizer/admin/static/js/workbench-form.js b/src/supervaizer/admin/static/js/workbench-form.js index 80fb58c..f1d3bb8 100644 --- a/src/supervaizer/admin/static/js/workbench-form.js +++ b/src/supervaizer/admin/static/js/workbench-form.js @@ -18,7 +18,7 @@ class WorkbenchForm { this._getFields = config.getFields || null; if (config.onJobStarted) this.onJobStarted = config.onJobStarted; if (config.onError) this.onError = config.onError; - this._pollInterval = null; + this._ws = null; } getApiKey() { @@ -122,6 +122,35 @@ class WorkbenchForm { } } + async pollJob() { + if (!this.activeJobId) return; + const btn = document.getElementById('btn-poll'); + if (btn) { + btn.disabled = true; + btn.classList.add('opacity-50'); + } + try { + const response = await fetch(`${this.basePath}/jobs/${this.activeJobId}/poll`, { + method: 'POST', + headers: { 'X-API-Key': this.getApiKey() }, + }); + const result = await response.json(); + if (!response.ok) { + this.onError(result.detail || 'Poll failed'); + } else { + this.onError(''); + this.refreshMonitor(true); + } + } catch (e) { + this.onError(`Network error: ${e.message}`); + } finally { + if (btn) { + btn.disabled = false; + btn.classList.remove('opacity-50'); + } + } + } + async stopJob(jobId) { const targetJobId = jobId || this.activeJobId; if (!targetJobId) return; @@ -157,20 +186,22 @@ class WorkbenchForm { } } - async submitHitlAnswer(caseId, formElement) { - const answer = {}; - new FormData(formElement).forEach((value, key) => { - answer[key] = value; - }); - // Handle checkboxes that aren't in FormData when unchecked - formElement.querySelectorAll('input[type="checkbox"]').forEach(cb => { - if (!(cb.name in answer)) { - answer[cb.name] = false; - } else { - answer[cb.name] = true; - } + /** Disable/enable all HITL buttons in the monitor to prevent double-clicks. */ + _setHitlButtonsDisabled(disabled) { + const container = document.getElementById(this.monitorContainerId); + if (!container) return; + container.querySelectorAll('button').forEach(btn => { + btn.disabled = disabled; + if (disabled) btn.classList.add('opacity-50', 'cursor-not-allowed'); + else btn.classList.remove('opacity-50', 'cursor-not-allowed'); }); + } + /** Shared POST to /cases/{caseId}/answer endpoint. */ + async _postAnswer(caseId, answerPayload) { + if (!this.activeJobId || this._submitting) return null; + this._submitting = true; + this._setHitlButtonsDisabled(true); try { const response = await fetch( `${this.basePath}/jobs/${this.activeJobId}/cases/${caseId}/answer`, @@ -180,43 +211,95 @@ class WorkbenchForm { 'Content-Type': 'application/json', 'X-API-Key': this.getApiKey(), }, - body: JSON.stringify({ answer }), + body: JSON.stringify({ answer: answerPayload }), }, ); const result = await response.json(); if (!response.ok) { this.onError(result.detail || result.message || 'Failed to submit answer'); - } else { - // Remove the form so polling resumes, then refresh immediately - formElement.remove(); - this.refreshMonitor(); + this._setHitlButtonsDisabled(false); + return null; } + this.onError(''); + this.refreshMonitor(true); + return result; } catch (e) { this.onError(`Network error: ${e.message}`); + this._setHitlButtonsDisabled(false); + return null; + } finally { + this._submitting = false; } } - /** Show/hide Stop and Status buttons based on active job. */ + async submitHitlAnswer(caseId, formElement) { + const answer = {}; + new FormData(formElement).forEach((value, key) => { + answer[key] = value; + }); + // Handle checkboxes that aren't in FormData when unchecked + formElement.querySelectorAll('input[type="checkbox"]').forEach(cb => { + if (!(cb.name in answer)) { + answer[cb.name] = false; + } else { + answer[cb.name] = true; + } + }); + + const result = await this._postAnswer(caseId, answer); + if (result) { + formElement.remove(); + } + } + + async submitDialogMessage(caseId, message) { + if (!message) return; + await this._postAnswer(caseId, { action: 'message', text: message }); + } + + async confirmDialog(caseId) { + await this._postAnswer(caseId, { action: 'confirm' }); + } + + /** Show/hide Stop, Poll, and Status buttons based on active job. */ _updateButtons(terminal) { const stop = document.getElementById('btn-stop'); const status = document.getElementById('btn-status'); + const poll = document.getElementById('btn-poll'); if (this.activeJobId && !terminal) { if (stop) stop.classList.remove('hidden'); if (status) status.classList.remove('hidden'); + if (poll) poll.classList.remove('hidden'); } else { if (stop) stop.classList.add('hidden'); + if (poll) poll.classList.add('hidden'); if (status) status.classList.remove('hidden'); } } - /** Fetch and render the monitor partial for the active job. */ - refreshMonitor() { + /** True when a HITL dialog or form is visible in the monitor. */ + _hasActiveHitl() { + const container = document.getElementById(this.monitorContainerId); + if (!container) return false; + return !!( + container.querySelector('form[id^="hitl-form-"]') || + container.querySelector('[id^="hitl-dialog-"]') + ); + } + + /** Fetch and render the monitor partial for the active job. + * @param {boolean} force - bypass HITL guard (used after dialog submit/confirm) + */ + refreshMonitor(force) { if (!this.activeJobId) return; const container = document.getElementById(this.monitorContainerId); if (!container) return; + // Don't overwrite while user interacts with HITL (unless forced) + if (!force && this._hasActiveHitl()) return; const url = `${this.basePath}/jobs/${this.activeJobId}`; const apiKey = this.getApiKey(); const headers = apiKey ? { 'X-API-Key': apiKey } : {}; + const self = this; fetch(url, { headers }) .then(r => r.text()) .then(html => { @@ -224,46 +307,109 @@ class WorkbenchForm { if (typeof Alpine !== 'undefined') { Alpine.initTree(container); } + // Disconnect WebSocket when job reaches terminal state + const partial = container.querySelector('#workbench-monitor-partial'); + if (partial && partial.dataset.jobTerminal === 'true') { + self.disconnectWebSocket(); + self._updateButtons(true); + } }) .catch(() => {}); } - /** Load monitor partial and start polling. Stops when job reaches terminal state. */ + /** Check if the monitor shows a terminal job state. */ + _isJobTerminal() { + var container = document.getElementById(this.monitorContainerId); + if (!container) return false; + var partial = container.querySelector('#workbench-monitor-partial'); + return partial && partial.dataset.jobTerminal === 'true'; + } + + /** Connect a WebSocket that pushes typed signals when state changes. */ + connectWebSocket(jobId) { + // Don't connect for terminal jobs + if (this._isJobTerminal()) return; + this.disconnectWebSocket(); + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${location.host}${this.basePath}/jobs/${jobId}/ws`; + this._ws = new WebSocket(wsUrl); + const self = this; + this._ws.onmessage = (event) => { + const msg = event.data; + if (msg === 'terminal') { + self.refreshMonitor(true); + self._refreshConsole(); + self._refreshJobsList(); + self.disconnectWebSocket(); + self._updateButtons(true); + } else if (msg === 'monitor' || msg === 'refresh') { + self.refreshMonitor(); + } else if (msg === 'console') { + self._refreshConsole(); + } else if (msg === 'jobs') { + self._refreshJobsList(); + } else if (msg === 'ping') { + self._ws.send('pong'); + } + }; + this._ws.onclose = () => { + // Only reconnect if job is still active and we didn't intentionally disconnect + if (self.activeJobId && self._ws && !self._isJobTerminal()) { + self._ws = null; + setTimeout(() => { + if (self.activeJobId && !self._isJobTerminal()) { + self.connectWebSocket(jobId); + } + }, 3000); + } + }; + this._ws.onerror = () => {}; + } + + /** Fetch and swap console log entries. */ + _refreshConsole() { + var el = document.getElementById('console-log-container'); + if (!el) return; + var url = el.dataset.url; + if (!url) return; + fetch(url).then(r => r.text()).then(html => { + el.innerHTML = html; + el.scrollTop = el.scrollHeight; + if (typeof applyConsoleFilter === 'function') applyConsoleFilter(); + }).catch(() => {}); + } + + /** Fetch and swap job history list. */ + _refreshJobsList() { + var el = document.getElementById('jobs-list-container'); + if (!el) return; + var url = el.dataset.url; + if (!url) return; + fetch(url).then(r => r.text()).then(html => { + el.innerHTML = html; + }).catch(() => {}); + } + + /** Close the WebSocket connection. */ + disconnectWebSocket() { + if (this._ws) { + const ws = this._ws; + this._ws = null; + ws.close(); + } + } + + /** Load monitor partial and connect WebSocket for live updates. */ onJobStarted(result) { const container = document.getElementById(this.monitorContainerId); if (!container) return; - const url = `${this.basePath}/jobs/${result.id}`; - const apiKey = this.getApiKey(); - const self = this; this._updateButtons(false); - const loadMonitor = () => { - // Don't overwrite the monitor while user is filling a HITL form - if (container.querySelector('form[id^="hitl-form-"]')) return; - - const headers = apiKey ? { 'X-API-Key': apiKey } : {}; - fetch(url, { headers }) - .then(r => r.text()) - .then(html => { - container.innerHTML = html; - // Re-initialize Alpine on swapped content - if (typeof Alpine !== 'undefined') { - Alpine.initTree(container); - } - // Stop polling once job is terminal - const partial = container.querySelector('#workbench-monitor-partial'); - if (partial && partial.dataset.jobTerminal === 'true') { - if (self._pollInterval) { - clearInterval(self._pollInterval); - self._pollInterval = null; - } - self._updateButtons(true); - } - }) - .catch(() => {}); - }; - loadMonitor(); - if (this._pollInterval) clearInterval(this._pollInterval); - this._pollInterval = setInterval(loadMonitor, 2000); + // Initial load + this.refreshMonitor(); + this._refreshConsole(); + this._refreshJobsList(); + // Connect WebSocket — pushes monitor, console, and jobs updates + this.connectWebSocket(result.id); } /** Show error in errors container. Override in template if needed. */ diff --git a/src/supervaizer/admin/templates/components/dialog_renderer.html b/src/supervaizer/admin/templates/components/dialog_renderer.html new file mode 100644 index 0000000..8075fbe --- /dev/null +++ b/src/supervaizer/admin/templates/components/dialog_renderer.html @@ -0,0 +1,184 @@ +{# Dialog HITL renderer — chat-like interface for content review #} + +{% macro render_hitl_dialog(case_id, dialog) %} +{% set content_raw = dialog.get("content", "") %} +{% set content_type = dialog.get("content_type", "text") %} +{% set objective = dialog.get("objective", "") %} +{% set messages = dialog.get("messages", []) %} +{% set confirm_label = dialog.get("confirm_label", "Approve") %} + +
+ {# Header #} +
+ + + + Review & Respond +
+ + {# Objective #} + {% if objective %} +
{{ objective }}
+ {% endif %} + + {# Content preview card #} + {% if content_raw %} +
+ {% if content_type == "email" %} + {# Email: render subject + HTML body via Alpine #} + + + {% elif content_type == "code" %} +
{{ content_raw }}
+ {% else %} +
{{ content_raw }}
+ {% endif %} +
+ {% endif %} + + {# Chat messages #} +
+ {% if messages %} + {% for msg in messages %} + {% set role = msg.get("role", "assistant") %} + {% set text = msg.get("text", "") %} + {% if role == "user" %} +
+
+ {{ text }} +
+
+ {% else %} +
+
+ {{ text }} +
+
+ {% endif %} + {% endfor %} + {% else %} +
No messages yet.
+ {% endif %} +
+ + {# Input + action buttons #} +
+
+ + +
+ +
+
+{% endmacro %} + + +{# Confirmed content display — shown after dialog is approved #} +{% macro render_dialog_confirmed(content_raw, content_type) %} +
+
+ + + Approved Content + +
+ {% if content_type == "email" %} +
+ + +
+ {% else %} +
{{ content_raw }}
+ {% endif %} +
+{% endmacro %} diff --git a/src/supervaizer/admin/templates/components/field_renderer.html b/src/supervaizer/admin/templates/components/field_renderer.html index e2bca5f..9bb29af 100644 --- a/src/supervaizer/admin/templates/components/field_renderer.html +++ b/src/supervaizer/admin/templates/components/field_renderer.html @@ -14,14 +14,15 @@ #} {% macro render_field(field, prefix="field_") %} -
-