diff --git a/CHANGELOG.md b/CHANGELOG.md index 490e3b3ca..35b6eb7fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Profiles tab in the Web UI: browse, search, create (from template with live + preview, or from scratch via a schema-driven form), edit, clone, and delete + agent profiles over the profile management APIs, with validate-before-save + surfacing bounded findings and the truncation-marker contract (#510) +- MiniMax Code (`mcode`) provider with per-terminal authentication and profile + isolation, model and MCP configuration, multi-turn TUI orchestration, + supervisor/worker E2E coverage, and provider documentation (#624) +- Oh My Pi (`omp`) provider with additive native configuration, profile MCP extension wiring, lifecycle detection, and supervisor/worker orchestration support (#559) +- Add the official xAI Grok Build CLI as the `grok_cli` provider, including + isolated per-terminal MCP configuration, native hard tool restrictions, + multi-turn TUI support, orchestration e2e coverage, and provider docs. - async run submit, discovery, and live event following (#505) (#525) - rewrite the cao tui front door in Rust (#547) diff --git a/docs/web-ui.md b/docs/web-ui.md index 814f861f4..d75c1a8e7 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -85,7 +85,7 @@ Then open the same URLs (localhost:5173 or localhost:9889) in your local browser ## Features -Manage sessions, spawn agents, create scheduled flows, configure agent directories, and interact with live terminals — all from the browser. Includes live status badges, an inbox for agent-to-agent messaging, output viewer, and provider auto-detection. +Manage sessions, spawn agents, browse and author agent profiles (create from templates or from scratch, edit, clone, delete — with server-side validation before every write), create scheduled flows, configure agent directories, and interact with live terminals — all from the browser. Includes live status badges, an inbox for agent-to-agent messaging, output viewer, and provider auto-detection. ## Related diff --git a/web/README.md b/web/README.md index af0db7ed7..abfa89dbd 100644 --- a/web/README.md +++ b/web/README.md @@ -65,6 +65,10 @@ In development mode, Vite proxies API requests (`/sessions`, `/terminals`, `/age The main dashboard showing all active sessions with their terminals. Provides session creation (provider + agent profile selection), session deletion, and real-time terminal status via polling. Clicking a terminal opens it in the Terminal View. +### Profiles (`ProfilesPanel.tsx`) + +Master-detail browser for agent profiles over the profile management APIs. One catalog fetch on mount (no polling) plus a 300 ms-debounced, server-ranked search rendered in server order. The detail pane shows source, provider, model, tags, capabilities, and a `duplicated_in` shadowing warning. Local-store profiles support Edit (raw source via `/source`, placeholders unresolved), Clone, and Delete (type-to-confirm); read-only sources offer "Clone to customise". Creation runs through `ProfileCreateModal`; every write is validated first, with error findings blocking and warnings allowed through. + ### Agents (`AgentPanel.tsx`) Lists all discovered agent profiles from all configured directories (built-in, local store, provider-specific, custom). Shows profile name, description, and source label. Supports launching agents directly with provider and working directory selection. @@ -99,8 +103,11 @@ Full PTY terminal access via WebSocket (`/terminals/{id}/ws`). Uses xterm.js for | `InboxPanel.tsx` | Displays agent-to-agent messages queued in a terminal's inbox | | `OutputViewer.tsx` | Extracts and displays the last assistant response from a terminal | | `StatusBadge.tsx` | Color-coded terminal status indicator (idle, processing, completed, error) | -| `ConfirmModal.tsx` | Reusable confirmation dialog for destructive actions | -| `CustomSelect.tsx` | Styled dropdown select component | +| `ConfirmModal.tsx` | Reusable confirmation dialog for destructive actions; optional type-to-confirm gate (`confirmationText`) | +| `CustomSelect.tsx` | Styled dropdown select component; menu portaled to `document.body` (never clipped by modal scroll containers, flips upward when short on space) | +| `ProfileCreateModal.tsx` | Schema-driven profile creation: from-template (debounced live preview) and from-scratch (form generated from the profile JSON-Schema) | +| `ProfileEditorModal.tsx` | Raw source editor for edit (PUT, name fixed) and clone (POST under a new name) | +| `ValidationFindings.tsx` | Renders bounded validation findings with severity bullets and the omission-marker contract | | `ErrorBoundary.tsx` | React error boundary with fallback UI | ## Project Structure @@ -115,6 +122,10 @@ web/ │ ├── index.css # Tailwind CSS imports │ ├── components/ │ │ ├── DashboardHome.tsx +│ │ ├── ProfilesPanel.tsx +│ │ ├── ProfileCreateModal.tsx +│ │ ├── ProfileEditorModal.tsx +│ │ ├── ValidationFindings.tsx │ │ ├── AgentPanel.tsx │ │ ├── FlowsPanel.tsx │ │ ├── SettingsPanel.tsx @@ -129,7 +140,12 @@ web/ │ ├── setup.ts │ ├── api.test.ts │ ├── store.test.ts -│ └── components.test.tsx +│ ├── components.test.tsx +│ ├── profiles-panel.test.tsx +│ ├── profile-create-modal.test.tsx +│ ├── profile-editor.test.tsx +│ ├── validation-findings.test.tsx +│ └── custom-select.test.tsx ├── vite.config.ts ├── tailwind.config.js ├── tsconfig.json diff --git a/web/src/App.tsx b/web/src/App.tsx index 5cc82d55c..9dde3bab3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,16 +6,22 @@ import { DashboardHome } from './components/DashboardHome' import { AgentPanel } from './components/AgentPanel' import { FlowsPanel } from './components/FlowsPanel' import { MemoryPanel } from './components/MemoryPanel' +import { ProfilesPanel } from './components/ProfilesPanel' import { SettingsPanel } from './components/SettingsPanel' import { WorkflowsPanel } from './components/WorkflowsPanel' import { CaoMark } from './components/CaoMark' -import { Bot, Home, Clock, Settings, Brain, Workflow, CheckCircle, XCircle, Info, Wifi, WifiOff } from 'lucide-react' +import { Bot, Home, Clock, Settings, Brain, Workflow, CheckCircle, XCircle, Info, Wifi, WifiOff, Package } from 'lucide-react' -type TabKey = 'home' | 'agents' | 'flows' | 'settings' | 'memory' | 'workflows' +type TabKey = 'home' | 'profiles' | 'agents' | 'flows' | 'settings' | 'memory' | 'workflows' -// Workflows + Memory appended last so Alt+N numbering of existing tabs never shifts +// Profiles sits between Home and Agents (#510): browsing/authoring profiles +// precedes launching agents, and AgentPanel stays the launch picker. This was +// a one-time Alt+N renumbering of the tabs after it; Workflows + Memory remain +// appended last (Memory is conditional, so keeping it last stops the numbering +// of the always-visible tabs shifting with the memory backend's status). const TABS: { key: TabKey; label: string; icon: React.ReactNode }[] = [ { key: 'home', label: 'Home', icon: }, + { key: 'profiles', label: 'Profiles', icon: }, { key: 'agents', label: 'Agents', icon: }, { key: 'flows', label: 'Flows', icon: }, { key: 'settings', label: 'Settings', icon: }, @@ -143,6 +149,7 @@ export default function App() { Loading...}> {tab === 'home' && setTab(t as TabKey)} />} + {tab === 'profiles' && } {tab === 'agents' && } {tab === 'flows' && } {tab === 'settings' && } diff --git a/web/src/api.ts b/web/src/api.ts index 45dcd15d7..5821963c3 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -105,6 +105,77 @@ export interface AgentProfileInfo { duplicated_in?: string[] } +/** + * One row from `GET /agents/profiles/search`. The backend contract + * (services/profile_search.py RESULT_FIELDS) is metadata-only: the profile + * prompt body is never returned. `score` = `coverage` + a BM25 tie-break + * fraction below 1, so descending score always agrees with the server's + * result order — the client must preserve that order, never re-sort. + */ +export interface ProfileSearchResult { + name: string + description: string + capabilities: string[] + tags: string[] + role: string + source: AgentProfileSource + coverage: number + score: number +} + +/** + * Parsed profile from `GET /agents/profiles/{name}`. This response is + * *resolved* (env-var placeholders substituted) — fine for display, but an + * editor must load `GET /agents/profiles/{name}/source` instead so a save + * never persists resolved secrets. Only the fields the detail pane renders + * are declared; the endpoint returns the full model with nulls excluded. + */ +export interface AgentProfileDetail { + name: string + description: string + provider?: string + model?: string + role?: string + tags?: string[] + capabilities?: string[] +} + +/** One scaffold template from `GET /agents/profiles/templates`. `name` is `category/name`. */ +export interface TemplateSummary { + name: string + description: string +} + +/** + * One finding from the profile validator, shared by + * `POST /agents/profiles/validate` and the write routes' `warnings`. + */ +export interface ProfileValidationMessage { + severity: 'error' | 'warning' + message: string + path?: string | null +} + +export interface ProfileValidationResponse { + valid: boolean + messages: ProfileValidationMessage[] +} + +/** + * Outcome of a profile create or replace. `warnings` carries advisory + * findings that did not block the write; error findings reject with 400 + * (detail shape `{message, errors}`) and never reach here. + */ +export interface ProfileWriteResponse { + name: string + warnings: ProfileValidationMessage[] +} + +export interface TemplatePreview { + template: string + content: string +} + export interface AgentDirsSettings { agent_dirs: Record extra_dirs: string[] @@ -394,6 +465,62 @@ export interface RunSummaryRow { export const api = { // Agent Profiles & Providers listProfiles: () => fetchJSON('/agents/profiles'), + // Server-ranked search. Result order is the relevance ranking — render as-is. + searchProfiles: (q: string, limit?: number) => + fetchJSON(`/agents/profiles/search?q=${encodeURIComponent(q)}${limit ? `&limit=${limit}` : ''}`), + getProfile: (name: string) => fetchJSON(`/agents/profiles/${encodeURIComponent(name)}`), + // Profile authoring (issue #510). + getProfileSchema: () => fetchJSON>('/agents/profiles/schema'), + listProfileTemplates: () => fetchJSON('/agents/profiles/templates'), + // The template identifier is `category/name` and travels as two path + // segments — the backend route is declared as + // `/templates/{category}/{name}/schema` — so the slash must NOT be encoded. + getTemplateSchema: (template: string) => + fetchJSON>(`/agents/profiles/templates/${template.split('/').map(encodeURIComponent).join('/')}/schema`), + previewTemplate: (template: string, config: Record) => + fetchJSON('/agents/profiles/templates/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ template, config }), + // Authoring calls run the full validator server-side; the 10s + // default turns a slow round-trip into a phantom 'Validation failed'. + timeoutMs: 30000 + }), + validateProfile: (content: string) => + fetchJSON('/agents/profiles/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content }), + // Authoring calls run the full validator server-side; the 10s + // default turns a slow round-trip into a phantom 'Validation failed'. + timeoutMs: 30000 + }), + createProfile: (name: string, content: string) => + fetchJSON('/agents/profiles', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, content }), + // Authoring calls run the full validator server-side; the 10s + // default turns a slow round-trip into a phantom 'Validation failed'. + timeoutMs: 30000 + }), + // The authoring read: returns the document exactly as stored, with env-var + // placeholders intact. An editor MUST read from here — the parsed + // GET /agents/profiles/{name} is resolved, and round-tripping it through a + // write would persist resolved secrets into a plaintext profile. + getProfileSource: (name: string) => + fetchJSON<{ name: string; content: string }>(`/agents/profiles/${encodeURIComponent(name)}/source`), + replaceProfile: (name: string, content: string) => + fetchJSON(`/agents/profiles/${encodeURIComponent(name)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content }), + // Authoring calls run the full validator server-side; the 10s + // default turns a slow round-trip into a phantom 'Validation failed'. + timeoutMs: 30000 + }), + deleteProfile: (name: string) => + fetchJSON(`/agents/profiles/${encodeURIComponent(name)}`, { method: 'DELETE' }), listProviders: () => fetchJSON('/agents/providers'), // Settings diff --git a/web/src/components/ConfirmModal.tsx b/web/src/components/ConfirmModal.tsx index 367761cc8..76043dc22 100644 --- a/web/src/components/ConfirmModal.tsx +++ b/web/src/components/ConfirmModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { AlertTriangle, Loader2, X } from 'lucide-react' interface ConfirmModalProps { @@ -10,6 +10,13 @@ interface ConfirmModalProps { cancelLabel?: string variant?: 'danger' | 'warning' loading?: boolean + /** + * Type-to-confirm gate. When set, an input is shown and the confirm button + * stays disabled until the user types this exact string (e.g. the name of + * the thing being deleted). Optional and additive: callers that omit it get + * the original confirm-on-click behavior. + */ + confirmationText?: string onConfirm: () => void onCancel: () => void } @@ -23,14 +30,20 @@ export function ConfirmModal({ cancelLabel = 'Cancel', variant = 'danger', loading = false, + confirmationText, onConfirm, onCancel, }: ConfirmModalProps) { const cancelRef = useRef(null) + const [typed, setTyped] = useState('') useEffect(() => { - if (open) cancelRef.current?.focus() - }, [open]) + if (open) { + cancelRef.current?.focus() + // Reset per open so a previous confirmation never carries over. + setTyped('') + } + }, [open, confirmationText]) useEffect(() => { if (!open) return @@ -80,6 +93,25 @@ export function ConfirmModal({ )} + {/* Type-to-confirm gate */} + {confirmationText !== undefined && ( +
+ + setTyped(e.target.value)} + className="w-full px-3 py-2 bg-gray-950 border border-gray-700 rounded-lg text-sm text-gray-200 font-mono placeholder-gray-600 focus:outline-none focus:border-red-600" + /> +
+ )} + {/* Actions */}
- {open && ( -
+ {open && createPortal( +
{groups.map((group, gi) => (
{group.label && ( @@ -107,7 +163,8 @@ export function CustomSelect({ value, onChange, options, placeholder = 'Select.. {options.length === 0 && (
No options available
)} -
+
, + document.body, )}
) diff --git a/web/src/components/DashboardHome.tsx b/web/src/components/DashboardHome.tsx index ff7c44c77..97530c82e 100644 --- a/web/src/components/DashboardHome.tsx +++ b/web/src/components/DashboardHome.tsx @@ -266,7 +266,11 @@ export function DashboardHome({ onNavigate }: { onNavigate: (tab: string) => voi
-
+
+ {/* Quick Actions */} diff --git a/web/src/components/ProfileCreateModal.tsx b/web/src/components/ProfileCreateModal.tsx new file mode 100644 index 000000000..3e20fb816 --- /dev/null +++ b/web/src/components/ProfileCreateModal.tsx @@ -0,0 +1,772 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { api, ApiError, TemplateSummary, ProfileValidationMessage, ProviderInfo } from '../api' +import { ValidationFindings } from './ValidationFindings' +import { CustomSelect, SelectOption } from './CustomSelect' +import { AlertTriangle, ChevronDown, ChevronRight, Loader2, Package, X } from 'lucide-react' + +/** Debounce for the template live preview, matching the search box contract. */ +export const PREVIEW_DEBOUNCE_MS = 300 + +/** + * Frontmatter fields shown directly in the from-scratch form. Everything else + * in the server schema renders inside the "Advanced" expander, in schema + * order. The list is a display-priority hint only — the set of fields and + * their types always come from `GET /agents/profiles/schema`. + */ +const PRIMARY_FIELDS = ['name', 'description', 'provider', 'model', 'tags', 'capabilities'] + +/** + * Datalist suggestions for the `role` field: the built-in tool-bundle roles + * from constants.py ROLE_TOOL_DEFAULTS. Deliberately suggestions, not a + * closed select — settings.json custom roles are legal, and a datalist can + * never block a valid value if this list goes stale. + */ +const ROLE_SUGGESTIONS = ['supervisor', 'developer', 'reviewer'] + +type JSONSchemaProp = { + type?: string + enum?: string[] + description?: string + default?: unknown + minimum?: number + pattern?: string +} + +/** + * Serialize form values into YAML frontmatter. Values are emitted as JSON, + * which YAML accepts verbatim (flow style), so no YAML library is needed and + * object-valued fields round-trip exactly what the JSON editor validated. + */ +export function buildFrontmatter(values: Record): string { + const lines = Object.entries(values) + .filter(([, v]) => v !== undefined && v !== null && v !== '') + .map(([k, v]) => `${k}: ${JSON.stringify(v)}`) + return `---\n${lines.join('\n')}\n---\n` +} + +/** + * Rewrite the frontmatter `name:` in a rendered template document so it + * matches the storage name the user chose. The backend rejects a create where + * the two disagree (`_validate_profile_for_write`), and templates ship a + * fixed example name. Only the first `name:` line inside the leading + * frontmatter block is touched; the markdown body is never modified. + */ +export function rewriteFrontmatterName(content: string, name: string): string { + const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) + if (!m) return content + // Replacement FUNCTIONS, not strings: String.replace interprets $-patterns + // ($&, $', $1) in a replacement STRING, which corrupts a typed name + // containing them and — worse — mangles any document whose frontmatter + // legally contains such text (e.g. description: costs $& fees), because the + // whole updated block is itself passed through a replace. A function + // replacement is inserted verbatim. \r?\n keeps CRLF documents (e.g. a + // Windows-authored profile being cloned) from silently no-opping the + // rewrite and then failing the server's name-match check. + const updated = m[1].replace(/^name:.*$/m, () => `name: ${JSON.stringify(name)}`) + return content.replace(m[0], () => `---\n${updated}\n---`) +} + +/** Extract the frontmatter `name:` value from a rendered document, if any. */ +export function extractFrontmatterName(content: string): string | null { + // Match the frontmatter block first and search name: within it -- an + // unbounded [\s\S]*? scan continues into the markdown body when the + // frontmatter has no name:, matching a body line like 'name: decoy' + // (same bounding rewriteFrontmatterName already uses). + const block = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) + if (!block) return null + const m = block[1].match(/^name:\s*["']?([^"'\r\n]+)["']?\s*$/m) + return m ? m[1].trim() : null +} + +function FieldLabel({ name, required, description }: { name: string; required?: boolean; description?: string }) { + return ( + + ) +} + +const inputClass = + 'w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-emerald-600' + +/** + * One schema-driven field. Strings with enums render as selects; booleans as + * checkboxes; integers as number inputs; arrays of strings as comma-separated + * inputs; object-valued fields as validated JSON editors (per #510, object + * fields get JSON editors, not bespoke widgets). + */ +function SchemaField({ + name, + schema, + required, + value, + jsonErrors, + hasError, + selectOptions, + suggestions, + onChange, +}: { + name: string + schema: JSONSchemaProp + required?: boolean + value: unknown + jsonErrors: Record + /** Server validation reported an error finding rooted at this field. */ + hasError?: boolean + /** + * Field-specific widget overrides on top of the schema-driven renderer. + * selectOptions turns an open string field into a closed select (used for + * provider, whose value space is the live provider registry); suggestions + * attaches a datalist to a text input, keeping free entry (used for role). + */ + selectOptions?: SelectOption[] + suggestions?: string[] + onChange: (name: string, value: unknown) => void +}) { + const id = `field-${name}` + // A red boundary marks the control the validation error points at. The + // finding path is dotted with the frontmatter key first (e.g. + // 'mcpServers.docs.url'), so the whole field is outlined even when the + // error is nested inside it. + // border-2 + !important red + ring: visibly thick and immune to losing the + // border-color specificity contest against inputClass's border-gray-700. + const errClass = hasError ? ' border-2 !border-red-500 ring-2 ring-red-500/30' : '' + if (selectOptions) { + return ( +
+ + onChange(name, v || undefined)} + options={[{ value: '', label: '(unset)' }, ...selectOptions]} + invalid={hasError} + /> +
+ ) + } + if (schema.enum) { + return ( +
+ + onChange(name, v || undefined)} + options={[{ value: '', label: '(unset)' }, ...schema.enum.map(opt => ({ value: opt, label: opt }))]} + invalid={hasError} + /> +
+ ) + } + if (schema.type === 'boolean') { + return ( +
+ onChange(name, e.target.checked ? true : undefined)} + className="rounded bg-gray-900 border-gray-700" + /> + +
+ ) + } + if (schema.type === 'integer' || schema.type === 'number') { + return ( +
+ + onChange(name, e.target.value === '' ? undefined : Number(e.target.value))} + className={inputClass + errClass} + /> +
+ ) + } + if (schema.type === 'array') { + return ( +
+ + { + const items = e.target.value.split(',').map(s => s.trim()).filter(Boolean) + onChange(name, items.length ? items : undefined) + }} + className={inputClass + errClass} + /> +
+ ) + } + if (schema.type === 'object') { + const err = jsonErrors[name] + return ( +
+ +