From 7f191e18cdbb196e47ec49eec21d2f6f29127495 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Wed, 18 Mar 2026 00:15:18 +0000 Subject: [PATCH] Changeset 0.29.3 (#198) Co-authored-by: Jeffrey Smith Co-committed-by: Jeffrey Smith --- VERSION | 2 +- docs/ICD/workflows.md | 120 ++++++- packages/icd-test-runner/js/crud/workflows.js | 200 +++++++++++ server/database/migrations/018_workflows.sql | 2 + .../migrations/sqlite/018_workflows.sql | 2 + server/handlers/completion.go | 49 ++- server/handlers/workflow_entry.go | 7 +- server/handlers/workflow_forms.go | 287 ++++++++++++++++ server/handlers/workflow_instances.go | 4 +- server/handlers/workflows.go | 11 + server/main.go | 5 + server/models/models_extension_perm.go | 2 + server/models/workflow.go | 252 ++++++++++++++ server/pages/pages.go | 56 ++- server/pages/templates/workflow-landing.html | 8 +- server/pages/templates/workflow.html | 322 ++++++++++++++++-- server/store/postgres/workflows.go | 24 +- server/store/sqlite/workflows.go | 24 +- server/tools/workflow.go | 8 +- src/css/workflow.css | 29 ++ src/js/workflow-admin.js | 278 ++++++++++++++- src/js/workflow-api.js | 10 + 22 files changed, 1625 insertions(+), 77 deletions(-) create mode 100644 server/handlers/workflow_forms.go diff --git a/VERSION b/VERSION index 20f0687..5540b6e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.29.2 +0.29.3 diff --git a/docs/ICD/workflows.md b/docs/ICD/workflows.md index 82d5b8b..ac59fb5 100644 --- a/docs/ICD/workflows.md +++ b/docs/ICD/workflows.md @@ -104,8 +104,17 @@ Cascades: deletes all stages, versions, and active instances. ## Stages -Ordered steps within a workflow. Each stage has a driving persona and -optional human assignment team. +Ordered steps within a workflow. Each stage has a driving persona, +optional human assignment team, and a **stage mode** that controls +how data is collected. + +### Stage Modes (v0.29.3) + +| Mode | Description | +|------|-------------| +| `chat_only` | Default. Persona drives the conversation. | +| `form_only` | UI-rendered form, no LLM. Visitor fills out fields. | +| `form_chat` | Both form and chat available. Persona assists alongside the form. | ### List Stages @@ -129,25 +138,64 @@ POST /workflows/:id/stages "ordinal": 0, "persona_id": "uuid|null", "assignment_team_id": "uuid|null", - "form_template": { "fields": ["name", "email", "issue"] }, + "stage_mode": "chat_only|form_only|form_chat", + "form_template": { "fields": [...] }, "history_mode": "full|summary|fresh", "auto_transition": false, "transition_rules": { "auto_assign": "round_robin" } } ``` +`stage_mode`: controls data collection method. Default: `chat_only`. +`form_only` and `form_chat` require a typed `form_template` with fields. + `history_mode`: what chat history the next stage sees. `full` = complete, `summary` = utility-role summary, `fresh` = clean slate. -`form_template`: injected into the completion system prompt as guidance -for what the persona should collect. Not rendered as UI — the LLM is -told what to ask for. +`form_template`: when `stage_mode` is `form_only` or `form_chat`, the +template uses a typed schema (see below). For `chat_only` stages, legacy +freeform templates are still supported as guidance for the LLM. `transition_rules.auto_assign`: `round_robin` assigns to team members in rotation. Null = unassigned (manual claim). **Auth:** `workflow.create` permission required. +### Typed Form Template (v0.29.3) + +```json +{ + "fields": [ + { "key": "name", "type": "text", "label": "Full Name", "required": true, "validation": { "min_length": 2 } }, + { "key": "email", "type": "email", "label": "Email", "required": true }, + { "key": "dept", "type": "select", "label": "Department", "options": [ + { "value": "eng", "label": "Engineering" }, + { "value": "sales", "label": "Sales" } + ]}, + { "key": "notes", "type": "textarea", "label": "Additional Notes" } + ], + "hooks": { + "package_id": "uuid", + "validate": "on_validate", + "on_submit": "on_form_submit" + } +} +``` + +**Field types:** `text`, `email`, `number`, `date`, `textarea`, `select`, `checkbox`, `file`. + +**Validation rules** (in `validation` object per field): +- `min_length`, `max_length` — string length bounds +- `pattern` — regex pattern for text/email fields +- `min`, `max` — numeric bounds for number fields +- `min_date`, `max_date` — date range bounds (YYYY-MM-DD format) + +**Hooks:** optional Starlark hooks via extension packages. +- `validate`: called before form submission is accepted; can return field errors +- `on_submit`: fire-and-forget hook after successful submission + +Requires `forms.validate` extension permission. + ### Update Stage ``` @@ -188,6 +236,7 @@ Sets ordinals based on array position. "workflow_id": "uuid", "ordinal": 0, "name": "Collect Info", + "stage_mode": "chat_only", "persona_id": "uuid|null", "assignment_team_id": "uuid|null", "form_template": {}, @@ -432,6 +481,64 @@ POST /api/v1/w/:id/completions These use `AuthOrSession` middleware — the session cookie identifies the visitor without requiring a JWT. +## Form Submission (v0.29.3) + +For stages with `stage_mode` of `form_only` or `form_chat`, visitors +submit structured form data through dedicated endpoints. + +### Get Form Template + +``` +GET /api/v1/w/:id/form +``` + +Returns the current stage's typed form template and submission status. + +```json +{ + "stage_mode": "form_only", + "stage_name": "Contact Info", + "form_template": { "fields": [...] }, + "submitted": false +} +``` + +**Auth:** `AuthOrSession` (JWT or session cookie). + +### Submit Form + +``` +POST /api/v1/w/:id/form-submit +``` + +```json +{ + "name": "Jane Doe", + "email": "jane@example.com", + "dept": "eng" +} +``` + +Validates data against the typed form template. Returns field-level +errors on validation failure: + +```json +{ + "error": "validation failed", + "errors": [ + { "key": "email", "message": "invalid email format" }, + { "key": "name", "message": "required field missing" } + ] +} +``` + +On success, merges data into `stage_data`. For `form_only` stages with +`auto_transition=true`, auto-advances to the next stage. + +Emits `workflow.form_submitted` WebSocket event on success. + +**Auth:** `AuthOrSession` (JWT or session cookie). + ## Staleness Sweep Background goroutine. Checks active workflow instances for staleness @@ -470,3 +577,4 @@ before chaining. | `workflow.completed` | Workflow finished | | `workflow.assigned` | New assignment created | | `workflow.claimed` | Assignment claimed by user | +| `workflow.form_submitted` | Form data submitted for a form stage | diff --git a/packages/icd-test-runner/js/crud/workflows.js b/packages/icd-test-runner/js/crud/workflows.js index 844a917..240d592 100644 --- a/packages/icd-test-runner/js/crud/workflows.js +++ b/packages/icd-test-runner/js/crud/workflows.js @@ -399,5 +399,205 @@ } })(); + // ── Form Submission Lifecycle (v0.29.3) ────────────────── + // Tests typed form_template with stage_mode, form validation, + // and the /w/:id/form-submit endpoint. + if (T.user.role === 'admin') { + await (async function () { + var fwfId = null; + var fwfSlug = testTag + '-form-wf'; + var fChannelId = null; + + await T.test('crud', 'workflows', 'form: create form_only workflow', async function () { + var wf = await T.apiPost('/workflows', { + name: testTag + ' Form WF', + slug: fwfSlug, + entry_mode: 'public_link' + }); + T.assertShape(wf, T.S.workflow, 'form workflow'); + fwfId = wf.id; + T.registerCleanup(function () { if (fwfId) return T.safeDelete('/workflows/' + fwfId); }); + + await T.apiPost('/workflows/' + fwfId + '/stages', { + name: 'Contact Info', + ordinal: 0, + history_mode: 'full', + stage_mode: 'form_only', + form_template: { + fields: [ + { key: 'name', type: 'text', label: 'Full Name', required: true, validation: { min_length: 2 } }, + { key: 'email', type: 'email', label: 'Email', required: true }, + { key: 'dept', type: 'select', label: 'Department', options: [ + { value: 'eng', label: 'Engineering' }, + { value: 'sales', label: 'Sales' } + ]} + ] + } + }); + + await T.apiPost('/workflows/' + fwfId + '/stages', { + name: 'Done', ordinal: 1, history_mode: 'full', stage_mode: 'chat_only' + }); + + await T.apiPatch('/workflows/' + fwfId, { is_active: true }); + await T.apiPost('/workflows/' + fwfId + '/publish', {}); + }); + + if (fwfId) { + await T.test('crud', 'workflows', 'form: start instance', async function () { + var inst = await T.apiPost('/workflows/' + fwfId + '/start', {}); + fChannelId = inst.channel_id || inst.id; + T.assert(fChannelId, 'no channel_id in form start'); + T.registerCleanup(function () { if (fChannelId) return T.safeDelete('/channels/' + fChannelId); }); + }); + + if (fChannelId) { + await T.test('crud', 'workflows', 'form: GET /w/:id/form (template)', async function () { + var d = await T.sessionGet('/w/' + fChannelId + '/form'); + T.assert(d._status === 200, 'expected 200, got ' + d._status); + T.assertHasKey(d, 'stage_mode', 'form response'); + T.assert(d.stage_mode === 'form_only', 'expected form_only, got ' + d.stage_mode); + T.assertHasKey(d, 'form_template', 'form response'); + T.assert(d.form_template.fields && d.form_template.fields.length === 3, + 'expected 3 fields, got ' + (d.form_template.fields ? d.form_template.fields.length : 0)); + }); + + await T.test('crud', 'workflows', 'form: submit missing required → 400', async function () { + var d = await T.sessionPost('/w/' + fChannelId + '/form-submit', { + dept: 'eng' + }); + T.assert(d._status === 400, 'expected 400, got ' + d._status); + T.assertHasKey(d, 'errors', 'validation response'); + T.assert(Array.isArray(d.errors) && d.errors.length > 0, 'expected field errors'); + }); + + await T.test('crud', 'workflows', 'form: submit invalid email → 400', async function () { + var d = await T.sessionPost('/w/' + fChannelId + '/form-submit', { + name: 'Test User', + email: 'not-an-email' + }); + T.assert(d._status === 400, 'expected 400, got ' + d._status); + T.assertHasKey(d, 'errors', 'validation response'); + var emailErr = d.errors.find(function (e) { return e.key === 'email'; }); + T.assert(emailErr, 'expected email validation error'); + }); + + await T.test('crud', 'workflows', 'form: submit valid → 200', async function () { + var d = await T.sessionPost('/w/' + fChannelId + '/form-submit', { + name: 'ICD Test User', + email: 'test@icd.local', + dept: 'eng' + }); + T.assert(d._status === 200, 'expected 200, got ' + d._status); + }); + + await T.test('crud', 'workflows', 'form: verify stage_data after submit', async function () { + var d = await T.apiGet('/channels/' + fChannelId + '/workflow/status'); + T.assertShape(d, T.S.workflowStatus, 'status'); + // stage_data should contain submitted form values + if (d.stage_data) { + var sd = typeof d.stage_data === 'string' ? JSON.parse(d.stage_data) : d.stage_data; + T.assert(sd.name === 'ICD Test User' || sd.form_name === 'ICD Test User', + 'stage_data should contain form values'); + } + }); + } + + await T.test('crud', 'workflows', 'form: cleanup', async function () { + if (fChannelId) { await T.safeDelete('/channels/' + fChannelId); fChannelId = null; } + if (fwfId) { await T.safeDelete('/workflows/' + fwfId); fwfId = null; } + }); + } + })(); + } + + // ── Cross-Visitor Isolation (v0.29.3) ───────────────────── + // Two instances of the same workflow should have isolated stage_data + // and one visitor cannot access the other's form endpoint. + if (T.user.role === 'admin') { + await (async function () { + var xwfId = null; + var xwfSlug = testTag + '-xvisitor'; + var chA = null, chB = null; + + await T.test('crud', 'workflows', 'xvisitor: setup form_only workflow', async function () { + var wf = await T.apiPost('/workflows', { + name: testTag + ' XVisitor', + slug: xwfSlug, + entry_mode: 'public_link' + }); + xwfId = wf.id; + T.registerCleanup(function () { if (xwfId) return T.safeDelete('/workflows/' + xwfId); }); + + await T.apiPost('/workflows/' + xwfId + '/stages', { + name: 'Form', ordinal: 0, stage_mode: 'form_only', history_mode: 'full', + form_template: { + fields: [{ key: 'name', type: 'text', label: 'Name', required: true }] + } + }); + await T.apiPost('/workflows/' + xwfId + '/stages', { + name: 'Done', ordinal: 1, history_mode: 'full' + }); + await T.apiPatch('/workflows/' + xwfId, { is_active: true }); + await T.apiPost('/workflows/' + xwfId + '/publish', {}); + }); + + if (xwfId) { + await T.test('crud', 'workflows', 'xvisitor: start two instances', async function () { + var a = await T.apiPost('/workflows/' + xwfId + '/start', {}); + chA = a.channel_id || a.id; + T.assert(chA, 'no channel A'); + T.registerCleanup(function () { if (chA) return T.safeDelete('/channels/' + chA); }); + + var b = await T.apiPost('/workflows/' + xwfId + '/start', {}); + chB = b.channel_id || b.id; + T.assert(chB, 'no channel B'); + T.registerCleanup(function () { if (chB) return T.safeDelete('/channels/' + chB); }); + + T.assert(chA !== chB, 'channels should be different'); + }); + + if (chA && chB) { + await T.test('crud', 'workflows', 'xvisitor: submit different data to each', async function () { + // Submit to A (admin token) + var dA = await T.apiPost('/channels/' + chA + '/workflow/advance', { + data: { name: 'Alice' } + }); + T.assert(typeof dA === 'object', 'advance A'); + + // Submit to B + var dB = await T.apiPost('/channels/' + chB + '/workflow/advance', { + data: { name: 'Bob' } + }); + T.assert(typeof dB === 'object', 'advance B'); + }); + + await T.test('crud', 'workflows', 'xvisitor: verify stage_data isolated', async function () { + var stA = await T.apiGet('/channels/' + chA + '/workflow/status'); + var stB = await T.apiGet('/channels/' + chB + '/workflow/status'); + + // Parse stage_data + var sdA = stA.stage_data; + if (typeof sdA === 'string') sdA = JSON.parse(sdA); + var sdB = stB.stage_data; + if (typeof sdB === 'string') sdB = JSON.parse(sdB); + + // A should have Alice, B should have Bob + var aName = sdA && (sdA.name || sdA.form_name); + var bName = sdB && (sdB.name || sdB.form_name); + T.assert(aName === 'Alice', 'channel A stage_data should have Alice, got ' + aName); + T.assert(bName === 'Bob', 'channel B stage_data should have Bob, got ' + bName); + }); + } + + await T.test('crud', 'workflows', 'xvisitor: cleanup', async function () { + if (chA) { await T.safeDelete('/channels/' + chA); chA = null; } + if (chB) { await T.safeDelete('/channels/' + chB); chB = null; } + if (xwfId) { await T.safeDelete('/workflows/' + xwfId); xwfId = null; } + }); + } + })(); + } + }; })(); diff --git a/server/database/migrations/018_workflows.sql b/server/database/migrations/018_workflows.sql index 68d6427..bc29f24 100644 --- a/server/database/migrations/018_workflows.sql +++ b/server/database/migrations/018_workflows.sql @@ -64,6 +64,8 @@ CREATE TABLE IF NOT EXISTS workflow_stages ( persona_id UUID REFERENCES personas(id) ON DELETE SET NULL, assignment_team_id UUID REFERENCES teams(id) ON DELETE SET NULL, form_template JSONB NOT NULL DEFAULT '{}', + stage_mode TEXT NOT NULL DEFAULT 'chat_only' + CHECK (stage_mode IN ('chat_only', 'form_only', 'form_chat')), history_mode TEXT NOT NULL DEFAULT 'full' CHECK (history_mode IN ('full', 'summary', 'fresh')), auto_transition BOOLEAN NOT NULL DEFAULT false, diff --git a/server/database/migrations/sqlite/018_workflows.sql b/server/database/migrations/sqlite/018_workflows.sql index a056b21..552054b 100644 --- a/server/database/migrations/sqlite/018_workflows.sql +++ b/server/database/migrations/sqlite/018_workflows.sql @@ -34,6 +34,8 @@ CREATE TABLE IF NOT EXISTS workflow_stages ( persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL, assignment_team_id TEXT REFERENCES teams(id) ON DELETE SET NULL, form_template TEXT NOT NULL DEFAULT '{}', + stage_mode TEXT NOT NULL DEFAULT 'chat_only' + CHECK (stage_mode IN ('chat_only', 'form_only', 'form_chat')), history_mode TEXT NOT NULL DEFAULT 'full' CHECK (history_mode IN ('full', 'summary', 'fresh')), auto_transition INTEGER NOT NULL DEFAULT 0, diff --git a/server/handlers/completion.go b/server/handlers/completion.go index bc7af31..e0f8ea5 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -1259,15 +1259,10 @@ func (h *CompletionHandler) conversationHasPersonaMessages(ctx context.Context, // buildWorkflowFormHint loads the current workflow stage's form_template // and returns a system message instructing the persona what to collect. // Returns empty string if not a workflow or no template defined. +// v0.29.3: migrated from raw database.DB to stores, stage_mode aware. func (h *CompletionHandler) buildWorkflowFormHint(ctx context.Context, channelID string) string { - var workflowID *string - var currentStage int - _ = database.DB.QueryRowContext(ctx, database.Q(` - SELECT workflow_id, COALESCE(current_stage, 0) - FROM channels WHERE id = $1 AND type = 'workflow' - `), channelID).Scan(&workflowID, ¤tStage) - - if workflowID == nil || *workflowID == "" { + ws, err := h.stores.Channels.GetWorkflowStatus(ctx, channelID) + if err != nil || ws == nil || ws.WorkflowID == nil || *ws.WorkflowID == "" { return "" } @@ -1275,17 +1270,47 @@ func (h *CompletionHandler) buildWorkflowFormHint(ctx context.Context, channelID return "" } - stages, err := h.stores.Workflows.ListStages(ctx, *workflowID) - if err != nil || currentStage >= len(stages) { + stages, err := h.stores.Workflows.ListStages(ctx, *ws.WorkflowID) + if err != nil || ws.CurrentStage >= len(stages) { + return "" + } + + stage := stages[ws.CurrentStage] + + // v0.29.3: form_only stages don't use LLM — no hint needed + if stage.StageMode == models.StageModeFormOnly { return "" } - stage := stages[currentStage] if len(stage.FormTemplate) == 0 || string(stage.FormTemplate) == "{}" { return "" } - // Parse form template to extract field descriptions + // v0.29.3: Try typed form template first + if tpl := models.ParseTypedFormTemplate(stage.FormTemplate); tpl != nil { + hint := "You are in workflow stage: " + stage.Name + ".\n\n" + if stage.StageMode == models.StageModeFormChat { + hint += "A form has been provided for the visitor to fill in structured data. " + + "You can assist them with questions about the form fields or discuss related topics.\n\n" + + "Form fields:\n" + } else { + hint += "Collect the following information from the user through natural conversation:\n\n" + } + for _, f := range tpl.Fields { + hint += "- " + f.Label + if f.Required { + hint += " (required)" + } + hint += "\n" + } + if stage.StageMode != models.StageModeFormChat { + hint += "\nWhen you have gathered all required information, call the workflow_advance tool " + + "with the collected data as a JSON object. Do not advance until all required fields are filled." + } + return hint + } + + // Legacy format: map[string]interface{} with {description, required} var fields map[string]interface{} if err := json.Unmarshal(stage.FormTemplate, &fields); err != nil { return "" diff --git a/server/handlers/workflow_entry.go b/server/handlers/workflow_entry.go index 119b809..b36b91b 100644 --- a/server/handlers/workflow_entry.go +++ b/server/handlers/workflow_entry.go @@ -123,10 +123,15 @@ func (h *WorkflowEntryHandler) StartVisitor(c *gin.Context) { // Set session cookie (30 day expiry, matching v0.24.3) c.SetCookie("sb_session", sessionToken, 30*24*60*60, "/", "", false, true) - // Redirect to chat — visitor lands on existing /w/:channelId + // Redirect to workflow page — visitor lands on existing /w/:channelId + stageMode := stages[0].StageMode + if stageMode == "" { + stageMode = "chat_only" + } c.JSON(http.StatusCreated, gin.H{ "channel_id": ch.ID, "session_id": sess.ID, "redirect_to": "/w/" + ch.ID, + "stage_mode": stageMode, }) } diff --git a/server/handlers/workflow_forms.go b/server/handlers/workflow_forms.go new file mode 100644 index 0000000..32e5384 --- /dev/null +++ b/server/handlers/workflow_forms.go @@ -0,0 +1,287 @@ +package handlers + +import ( + "context" + "encoding/json" + "log" + "net/http" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/events" + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/sandbox" + "git.gobha.me/xcaliber/chat-switchboard/store" + "git.gobha.me/xcaliber/chat-switchboard/tools" + + "go.starlark.net/starlark" +) + +// ── Workflow Form Handler ───────────────────── +// Handles typed form submission for workflow stages. + +type WorkflowFormHandler struct { + stores store.Stores + runner *sandbox.Runner + hub *events.Hub +} + +func NewWorkflowFormHandler(stores store.Stores, runner *sandbox.Runner, hub *events.Hub) *WorkflowFormHandler { + return &WorkflowFormHandler{stores: stores, runner: runner, hub: hub} +} + +// GetFormTemplate returns the current stage's typed form template. +// GET /api/v1/w/:id/form +func (h *WorkflowFormHandler) GetFormTemplate(c *gin.Context) { + ctx := c.Request.Context() + channelID := c.Param("id") + + ws, err := h.stores.Channels.GetWorkflowStatus(ctx, channelID) + if err != nil || ws == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"}) + return + } + if ws.WorkflowID == nil || *ws.WorkflowID == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "not a workflow channel"}) + return + } + + stages, err := h.stores.Workflows.ListStages(ctx, *ws.WorkflowID) + if err != nil || ws.CurrentStage >= len(stages) { + c.JSON(http.StatusNotFound, gin.H{"error": "stage not found"}) + return + } + + stage := stages[ws.CurrentStage] + tpl := models.ParseTypedFormTemplate(stage.FormTemplate) + + // Check if form was already submitted for this stage + var formSubmitted bool + if len(ws.StageData) > 0 { + var sd map[string]interface{} + if json.Unmarshal(ws.StageData, &sd) == nil { + _, formSubmitted = sd["_form_submitted"] + } + } + + c.JSON(http.StatusOK, gin.H{ + "stage_name": stage.Name, + "stage_mode": stage.StageMode, + "form_template": tpl, + "form_submitted": formSubmitted, + "current_stage": ws.CurrentStage, + "status": ws.Status, + }) +} + +// SubmitForm handles form data submission for a workflow stage. +// POST /api/v1/w/:id/form-submit +func (h *WorkflowFormHandler) SubmitForm(c *gin.Context) { + ctx := c.Request.Context() + channelID := c.Param("id") + + // 1. Verify workflow channel + ws, err := h.stores.Channels.GetWorkflowStatus(ctx, channelID) + if err != nil || ws == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"}) + return + } + if ws.Status != "active" { + c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is " + ws.Status + ", cannot submit form"}) + return + } + if ws.WorkflowID == nil || *ws.WorkflowID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "not a workflow channel"}) + return + } + + // 2. Load stage definition + stages, err := h.stores.Workflows.ListStages(ctx, *ws.WorkflowID) + if err != nil || ws.CurrentStage >= len(stages) { + c.JSON(http.StatusBadRequest, gin.H{"error": "stage not found"}) + return + } + stage := stages[ws.CurrentStage] + + if stage.StageMode == models.StageModeChatOnly { + c.JSON(http.StatusBadRequest, gin.H{"error": "this stage does not accept form submissions"}) + return + } + + // 3. Parse typed form template + tpl := models.ParseTypedFormTemplate(stage.FormTemplate) + if tpl == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "stage has no typed form template"}) + return + } + + // 4. Parse submitted data (flat fields per ICD spec) + var formData map[string]interface{} + if err := c.ShouldBindJSON(&formData); err != nil || len(formData) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "form data is required"}) + return + } + + // 5. Go-level validation + if errs := models.ValidateFormData(tpl, formData); len(errs) > 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation failed", "errors": errs}) + return + } + + // 6. Starlark validate hook (if configured) + if tpl.Hooks != nil && tpl.Hooks.PackageID != "" && tpl.Hooks.Validate != "" { + if hookErrs := h.runValidateHook(c, tpl.Hooks, channelID, formData, ws.StageData, stage.Name); hookErrs != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation failed", "errors": hookErrs}) + return + } + } + + // 7. Merge into stage_data + formData["_form_submitted"] = true + dataJSON, _ := json.Marshal(formData) + mergedData := tools.MergeWorkflowStageData(ctx, h.stores.Channels, channelID, dataJSON) + + // 8. Starlark on_submit hook (fire-and-forget) + if tpl.Hooks != nil && tpl.Hooks.PackageID != "" && tpl.Hooks.OnSubmit != "" { + go h.runOnSubmitHook(tpl.Hooks, channelID, formData, json.RawMessage(mergedData), stage.Name) + } + + // 9. Auto-advance if form_only + auto_transition + if stage.StageMode == models.StageModeFormOnly && stage.AutoTransition { + nextStage := ws.CurrentStage + 1 + if nextStage >= len(stages) { + // Complete + _ = h.stores.Channels.CompleteWorkflow(ctx, channelID, nextStage, json.RawMessage(mergedData)) + tools.CreateWorkflowStageNote(ctx, h.stores, channelID, ws.CurrentStage, dataJSON, "") + h.emitFormEvent(channelID, "workflow.completed", ws.CurrentStage) + c.JSON(http.StatusOK, gin.H{"status": "completed", "current_stage": nextStage}) + return + } + _ = h.stores.Channels.AdvanceWorkflowStage(ctx, channelID, nextStage, json.RawMessage(mergedData)) + tools.CreateWorkflowStageNote(ctx, h.stores, channelID, ws.CurrentStage, dataJSON, "") + h.emitFormEvent(channelID, "workflow.advanced", nextStage) + c.JSON(http.StatusOK, gin.H{"status": "advanced", "current_stage": nextStage, "stage": stages[nextStage]}) + return + } + + // 10. Not auto-advancing — persist stage_data and confirm submission + _ = h.stores.Channels.AdvanceWorkflowStage(ctx, channelID, ws.CurrentStage, json.RawMessage(mergedData)) + tools.CreateWorkflowStageNote(ctx, h.stores, channelID, ws.CurrentStage, dataJSON, "") + h.emitFormEvent(channelID, "workflow.form_submitted", ws.CurrentStage) + c.JSON(http.StatusOK, gin.H{"status": "submitted", "current_stage": ws.CurrentStage}) +} + +// ── Starlark Hooks ────────────────────────── + +func (h *WorkflowFormHandler) runValidateHook(c *gin.Context, hooks *models.FormHooks, channelID string, data map[string]interface{}, stageData json.RawMessage, stageName string) []models.FieldError { + if h.runner == nil { + return nil + } + + pkg, err := h.stores.Packages.Get(c.Request.Context(), hooks.PackageID) + if err != nil || pkg == nil { + log.Printf("[workflow-forms] validate hook: package %s not found", hooks.PackageID) + return nil + } + + ctxDict := starlark.NewDict(4) + _ = ctxDict.SetKey(starlark.String("data"), jsonToStarlark(data)) + _ = ctxDict.SetKey(starlark.String("stage_data"), starlark.String(string(stageData))) + _ = ctxDict.SetKey(starlark.String("stage_name"), starlark.String(stageName)) + _ = ctxDict.SetKey(starlark.String("channel_id"), starlark.String(channelID)) + + val, _, err := h.runner.CallEntryPoint(c.Request.Context(), pkg, hooks.Validate, + starlark.Tuple{ctxDict}, nil, nil) + if err != nil { + log.Printf("[workflow-forms] validate hook error: %v", err) + return nil + } + + return parseValidationResult(val) +} + +func (h *WorkflowFormHandler) runOnSubmitHook(hooks *models.FormHooks, channelID string, data map[string]interface{}, stageData json.RawMessage, stageName string) { + if h.runner == nil { + return + } + + ctx := context.Background() + pkg, err := h.stores.Packages.Get(ctx, hooks.PackageID) + if err != nil || pkg == nil { + return + } + + ctxDict := starlark.NewDict(4) + _ = ctxDict.SetKey(starlark.String("data"), jsonToStarlark(data)) + _ = ctxDict.SetKey(starlark.String("stage_data"), starlark.String(string(stageData))) + _ = ctxDict.SetKey(starlark.String("stage_name"), starlark.String(stageName)) + _ = ctxDict.SetKey(starlark.String("channel_id"), starlark.String(channelID)) + + _, _, err = h.runner.CallEntryPoint(ctx, pkg, hooks.OnSubmit, + starlark.Tuple{ctxDict}, nil, nil) + if err != nil { + log.Printf("[workflow-forms] on_submit hook error: %v", err) + } +} + +// ── Helpers ───────────────────────────────── + +func (h *WorkflowFormHandler) emitFormEvent(channelID, label string, stage int) { + if h.hub == nil { + return + } + payload, _ := json.Marshal(map[string]interface{}{ + "channel_id": channelID, + "stage": stage, + }) + ctx := context.Background() + pids, err := h.stores.Channels.ListUserParticipantIDs(ctx, channelID, "") + if err != nil { + return + } + evt := events.Event{Label: label, Payload: payload} + for _, uid := range pids { + h.hub.SendToUser(uid, evt) + } +} + +// parseValidationResult extracts field errors from a Starlark return value. +// Expected: None (pass) or {"errors": [{"key": "...", "message": "..."}, ...]} +func parseValidationResult(val starlark.Value) []models.FieldError { + if val == nil || val == starlark.None { + return nil + } + d, ok := val.(*starlark.Dict) + if !ok { + return nil + } + errVal, found, _ := d.Get(starlark.String("errors")) + if !found { + return nil + } + list, ok := errVal.(*starlark.List) + if !ok { + return nil + } + var errs []models.FieldError + iter := list.Iterate() + defer iter.Done() + var item starlark.Value + for iter.Next(&item) { + ed, ok := item.(*starlark.Dict) + if !ok { + continue + } + keyVal, _, _ := ed.Get(starlark.String("key")) + msgVal, _, _ := ed.Get(starlark.String("message")) + k, _ := keyVal.(starlark.String) + m, _ := msgVal.(starlark.String) + if k != "" && m != "" { + errs = append(errs, models.FieldError{Key: string(k), Message: string(m)}) + } + } + if len(errs) == 0 { + return nil + } + return errs +} diff --git a/server/handlers/workflow_instances.go b/server/handlers/workflow_instances.go index d73c88f..1fa8578 100644 --- a/server/handlers/workflow_instances.go +++ b/server/handlers/workflow_instances.go @@ -192,8 +192,8 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) { nextStageDef := stages[nextStage] - // Bind next persona - if nextStageDef.PersonaID != nil { + // Bind next persona (skip for form_only — no LLM needed) + if nextStageDef.StageMode != models.StageModeFormOnly && nextStageDef.PersonaID != nil { alreadyIn, _ := h.stores.Channels.IsParticipant(ctx, channelID, "persona", *nextStageDef.PersonaID) if !alreadyIn { _ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{ diff --git a/server/handlers/workflows.go b/server/handlers/workflows.go index e894112..ce83afd 100644 --- a/server/handlers/workflows.go +++ b/server/handlers/workflows.go @@ -188,6 +188,13 @@ func (h *WorkflowHandler) CreateStage(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "history_mode must be full, summary, or fresh"}) return } + if st.StageMode == "" { + st.StageMode = models.StageModeChatOnly + } + if !models.ValidStageModes[st.StageMode] { + c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be chat_only, form_only, or form_chat"}) + return + } if st.Ordinal == 0 { existing, _ := h.stores.Workflows.ListStages(c.Request.Context(), st.WorkflowID) st.Ordinal = len(existing) @@ -213,6 +220,10 @@ func (h *WorkflowHandler) UpdateStage(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "history_mode must be full, summary, or fresh"}) return } + if st.StageMode != "" && !models.ValidStageModes[st.StageMode] { + c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be chat_only, form_only, or form_chat"}) + return + } if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update stage"}) return diff --git a/server/main.go b/server/main.go index 25cd575..0568d17 100644 --- a/server/main.go +++ b/server/main.go @@ -1272,6 +1272,11 @@ func main() { wfComp.SetFilterChain(filterChain) wfComp.SetRunner(starlarkRunner) // v0.29.2: extension tool dispatch wfAPI.POST("/:id/completions", wfComp.Complete) + + // v0.29.3: Workflow form endpoints + wfForms := handlers.NewWorkflowFormHandler(stores, starlarkRunner, hub) + wfAPI.GET("/:id/form", wfForms.GetFormTemplate) + wfAPI.POST("/:id/form-submit", wfForms.SubmitForm) } // Workflow visitor entry (v0.26.3) — public start endpoint diff --git a/server/models/models_extension_perm.go b/server/models/models_extension_perm.go index d38b893..6c28339 100644 --- a/server/models/models_extension_perm.go +++ b/server/models/models_extension_perm.go @@ -33,6 +33,7 @@ const ( ExtPermDBWrite = "db.write" ExtPermAPIHTTP = "api.http" ExtPermProviderComplete = "provider.complete" // v0.29.1: LLM completion calls + ExtPermFormValidate = "forms.validate" // v0.29.3: form validation hooks ) // ValidExtensionPermissions is the set of recognized permission keys. @@ -44,6 +45,7 @@ var ValidExtensionPermissions = map[string]bool{ ExtPermDBWrite: true, ExtPermAPIHTTP: true, ExtPermProviderComplete: true, + ExtPermFormValidate: true, } // ── Extension Permission Model ─────────────── diff --git a/server/models/workflow.go b/server/models/workflow.go index 620ef56..4f2e0aa 100644 --- a/server/models/workflow.go +++ b/server/models/workflow.go @@ -2,6 +2,9 @@ package models import ( "encoding/json" + "fmt" + "regexp" + "strings" "time" ) @@ -54,12 +57,261 @@ type WorkflowStage struct { PersonaID *string `json:"persona_id,omitempty"` AssignmentTeamID *string `json:"assignment_team_id,omitempty"` FormTemplate json.RawMessage `json:"form_template"` + StageMode string `json:"stage_mode"` // chat_only | form_only | form_chat HistoryMode string `json:"history_mode"` // full | summary | fresh AutoTransition bool `json:"auto_transition"` TransitionRules json.RawMessage `json:"transition_rules"` CreatedAt time.Time `json:"created_at"` } +// ── Stage Mode Constants ──────────────────── + +const ( + StageModeChatOnly = "chat_only" + StageModeFormOnly = "form_only" + StageModeFormChat = "form_chat" +) + +// ValidStageModes is the set of valid stage_mode values. +var ValidStageModes = map[string]bool{ + StageModeChatOnly: true, + StageModeFormOnly: true, + StageModeFormChat: true, +} + +// ── Typed Form Template ───────────────────── + +// TypedFormTemplate is the structured form_template schema (v0.29.3). +type TypedFormTemplate struct { + Fields []FormField `json:"fields"` + Hooks *FormHooks `json:"hooks,omitempty"` +} + +// FormField is a single field in a typed form template. +type FormField struct { + Key string `json:"key"` + Type string `json:"type"` // text | email | select | number | date | textarea | checkbox | file + Label string `json:"label"` + Placeholder string `json:"placeholder,omitempty"` + Required bool `json:"required,omitempty"` + Default interface{} `json:"default,omitempty"` + Options []FormOption `json:"options,omitempty"` + Validation *FormValidation `json:"validation,omitempty"` +} + +// FormOption is a choice for select fields. +type FormOption struct { + Value string `json:"value"` + Label string `json:"label"` +} + +// FormValidation holds type-specific validation rules. +type FormValidation struct { + MinLength *int `json:"min_length,omitempty"` // text, textarea, email + MaxLength *int `json:"max_length,omitempty"` // text, textarea, email + Pattern string `json:"pattern,omitempty"` // text, email (regex) + Min *float64 `json:"min,omitempty"` // number + Max *float64 `json:"max,omitempty"` // number + Step *float64 `json:"step,omitempty"` // number + MinDate string `json:"min_date,omitempty"` // date (YYYY-MM-DD) + MaxDate string `json:"max_date,omitempty"` // date (YYYY-MM-DD) + Accept string `json:"accept,omitempty"` // file (MIME types) + MaxSize *int64 `json:"max_size_bytes,omitempty"` // file +} + +// FormHooks wires Starlark validation/submission hooks for a stage's form. +type FormHooks struct { + PackageID string `json:"package_id"` + Validate string `json:"validate,omitempty"` // entry point name + OnSubmit string `json:"on_submit,omitempty"` // entry point name +} + +// ValidFormFieldTypes is the set of recognized field types. +var ValidFormFieldTypes = map[string]bool{ + "text": true, "email": true, "select": true, "number": true, + "date": true, "textarea": true, "checkbox": true, "file": true, +} + +// ParseTypedFormTemplate parses a raw JSON form_template into the typed schema. +// Returns nil if the template is empty, legacy format, or not a typed template. +func ParseTypedFormTemplate(raw json.RawMessage) *TypedFormTemplate { + if len(raw) == 0 || string(raw) == "{}" || string(raw) == "null" { + return nil + } + var tpl TypedFormTemplate + if err := json.Unmarshal(raw, &tpl); err != nil { + return nil + } + if len(tpl.Fields) == 0 { + return nil + } + // Verify this is actually the typed format (first field must have key+type) + if tpl.Fields[0].Key == "" || tpl.Fields[0].Type == "" { + return nil + } + return &tpl +} + +// FieldError is a validation error for a specific form field. +type FieldError struct { + Key string `json:"key"` + Message string `json:"message"` +} + +// ValidateFormData validates submitted data against a typed form template. +func ValidateFormData(tpl *TypedFormTemplate, data map[string]interface{}) []FieldError { + var errs []FieldError + for _, f := range tpl.Fields { + val, present := data[f.Key] + + // Required check + if f.Required && (!present || val == nil || val == "") { + errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " is required"}) + continue + } + if !present || val == nil { + continue + } + + switch f.Type { + case "text", "textarea": + errs = append(errs, validateString(f, val)...) + case "email": + errs = append(errs, validateEmail(f, val)...) + case "number": + errs = append(errs, validateNumber(f, val)...) + case "date": + errs = append(errs, validateDate(f, val)...) + case "select": + errs = append(errs, validateSelect(f, val)...) + case "checkbox": + if _, ok := val.(bool); !ok { + errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " must be true or false"}) + } + } + } + return errs +} + +func validateString(f FormField, val interface{}) []FieldError { + s, ok := val.(string) + if !ok { + return []FieldError{{Key: f.Key, Message: f.Label + " must be text"}} + } + var errs []FieldError + v := f.Validation + if v == nil { + return nil + } + if v.MinLength != nil && len(s) < *v.MinLength { + errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at least %d characters", f.Label, *v.MinLength)}) + } + if v.MaxLength != nil && len(s) > *v.MaxLength { + errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at most %d characters", f.Label, *v.MaxLength)}) + } + if v.Pattern != "" { + if re, err := regexp.Compile(v.Pattern); err == nil && !re.MatchString(s) { + errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " does not match the required pattern"}) + } + } + return errs +} + +var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`) + +func validateEmail(f FormField, val interface{}) []FieldError { + s, ok := val.(string) + if !ok { + return []FieldError{{Key: f.Key, Message: f.Label + " must be text"}} + } + if !emailRe.MatchString(s) { + return []FieldError{{Key: f.Key, Message: f.Label + " must be a valid email address"}} + } + var errs []FieldError + if f.Validation != nil { + if f.Validation.Pattern != "" { + if re, err := regexp.Compile(f.Validation.Pattern); err == nil && !re.MatchString(s) { + errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " does not match the required pattern"}) + } + } + } + return errs +} + +func validateNumber(f FormField, val interface{}) []FieldError { + var n float64 + switch v := val.(type) { + case float64: + n = v + case int: + n = float64(v) + case json.Number: + var err error + n, err = v.Float64() + if err != nil { + return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}} + } + case string: + return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}} + default: + return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}} + } + var errs []FieldError + if f.Validation != nil { + if f.Validation.Min != nil && n < *f.Validation.Min { + errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at least %g", f.Label, *f.Validation.Min)}) + } + if f.Validation.Max != nil && n > *f.Validation.Max { + errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at most %g", f.Label, *f.Validation.Max)}) + } + } + return errs +} + +func validateDate(f FormField, val interface{}) []FieldError { + s, ok := val.(string) + if !ok { + return []FieldError{{Key: f.Key, Message: f.Label + " must be a date string (YYYY-MM-DD)"}} + } + _, err := time.Parse("2006-01-02", s) + if err != nil { + return []FieldError{{Key: f.Key, Message: f.Label + " must be a valid date (YYYY-MM-DD)"}} + } + if f.Validation != nil { + if f.Validation.MinDate != "" { + if minD, e := time.Parse("2006-01-02", f.Validation.MinDate); e == nil { + if d, _ := time.Parse("2006-01-02", s); d.Before(minD) { + return []FieldError{{Key: f.Key, Message: fmt.Sprintf("%s must be on or after %s", f.Label, f.Validation.MinDate)}} + } + } + } + if f.Validation.MaxDate != "" { + if maxD, e := time.Parse("2006-01-02", f.Validation.MaxDate); e == nil { + if d, _ := time.Parse("2006-01-02", s); d.After(maxD) { + return []FieldError{{Key: f.Key, Message: fmt.Sprintf("%s must be on or before %s", f.Label, f.Validation.MaxDate)}} + } + } + } + } + return nil +} + +func validateSelect(f FormField, val interface{}) []FieldError { + s, ok := val.(string) + if !ok { + return []FieldError{{Key: f.Key, Message: f.Label + " must be a string"}} + } + if len(f.Options) == 0 { + return nil + } + for _, o := range f.Options { + if strings.EqualFold(o.Value, s) { + return nil + } + } + return []FieldError{{Key: f.Key, Message: f.Label + " is not a valid option"}} +} + // ── Workflow Version (immutable snapshot) ─── // WorkflowVersion is an immutable snapshot of a workflow definition diff --git a/server/pages/pages.go b/server/pages/pages.go index 15ad904..3cd2e46 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -594,6 +594,11 @@ type WorkflowPageData struct { ChannelDescription string SessionID string SessionName string + StageMode string // chat_only | form_only | form_chat + StageName string + FormTemplateJSON string // typed form template JSON (empty if chat_only) + TotalStages int + CurrentStage int } // WorkflowLandingPageData is passed to workflow-landing.html. @@ -610,8 +615,9 @@ type WorkflowLandingPageData struct { } PersonaName string PersonaIcon string - StageCount int - ResumeURL string // non-empty if visitor has an active session + StageCount int + FirstStageMode string // chat_only | form_only | form_chat (v0.29.3) + ResumeURL string // non-empty if visitor has an active session } // RenderWorkflow renders the workflow entry page for anonymous session visitors. @@ -643,6 +649,31 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc { } } + // Load workflow stage info for form rendering (v0.29.3) + var stageMode, stageName, formTplJSON string + var totalStages, currentStage int + stageMode = "chat_only" // default + if e.stores.Channels != nil && channelID != "" { + ws, wsErr := e.stores.Channels.GetWorkflowStatus(c.Request.Context(), channelID) + if wsErr == nil && ws != nil && ws.WorkflowID != nil { + currentStage = ws.CurrentStage + if stages, sErr := e.stores.Workflows.ListStages(c.Request.Context(), *ws.WorkflowID); sErr == nil && len(stages) > 0 { + totalStages = len(stages) + if currentStage < len(stages) { + stg := stages[currentStage] + stageMode = stg.StageMode + stageName = stg.Name + if stageMode == "" { + stageMode = "chat_only" + } + if stageMode != "chat_only" { + formTplJSON = string(stg.FormTemplate) + } + } + } + } + } + instanceName, _, _ := e.loadBranding() e.Render(c, "workflow.html", PageData{ @@ -654,6 +685,11 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc { ChannelDescription: description, SessionID: sessionID, SessionName: sessionName, + StageMode: stageMode, + StageName: stageName, + FormTemplateJSON: formTplJSON, + TotalStages: totalStages, + CurrentStage: currentStage, }, }) } @@ -704,13 +740,19 @@ func (e *Engine) RenderWorkflowLanding() gin.HandlerFunc { _ = json.Unmarshal(wf.Branding, &data.Branding) } - // Load stages for count + first persona info + // Load stages for count + first persona info + stage mode stages, _ := e.stores.Workflows.ListStages(ctx, wf.ID) data.StageCount = len(stages) - if len(stages) > 0 && stages[0].PersonaID != nil { - if p, err := e.stores.Personas.GetByID(ctx, *stages[0].PersonaID); err == nil { - data.PersonaName = p.Name - data.PersonaIcon = p.Icon + if len(stages) > 0 { + data.FirstStageMode = stages[0].StageMode + if data.FirstStageMode == "" { + data.FirstStageMode = "chat_only" + } + if stages[0].PersonaID != nil { + if p, err := e.stores.Personas.GetByID(ctx, *stages[0].PersonaID); err == nil { + data.PersonaName = p.Name + data.PersonaIcon = p.Icon + } } } diff --git a/server/pages/templates/workflow-landing.html b/server/pages/templates/workflow-landing.html index 441379b..bf54bab 100644 --- a/server/pages/templates/workflow-landing.html +++ b/server/pages/templates/workflow-landing.html @@ -145,15 +145,19 @@

{{.Data.Description}}

{{end}} - {{if .Data.PersonaName}} + {{if and .Data.PersonaName (ne .Data.FirstStageMode "form_only")}}
{{if .Data.PersonaIcon}}{{.Data.PersonaIcon}}{{else}}🤖{{end}}
+ {{if eq .Data.FirstStageMode "form_chat"}} + Fill out a form and chat with {{.Data.PersonaName}} + {{else}} You'll be chatting with {{.Data.PersonaName}} + {{end}}
{{end}} {{if .Data.ResumeURL}} diff --git a/server/pages/templates/workflow.html b/server/pages/templates/workflow.html index 6c9055e..842cae4 100644 --- a/server/pages/templates/workflow.html +++ b/server/pages/templates/workflow.html @@ -24,6 +24,77 @@ .wf-header h1 { font-size: 18px; font-weight: 600; } .wf-header p { font-size: 13px; color: var(--text-2); margin-top: 4px; } + .wf-stage-info { + padding: 8px 24px; font-size: 12px; color: var(--text-2); + background: var(--bg-surface); border-bottom: 1px solid var(--border); + display: flex; justify-content: space-between; align-items: center; + } + .wf-progress { + display: flex; gap: 4px; + } + .wf-progress-dot { + width: 8px; height: 8px; border-radius: 50%; + background: var(--border); + } + .wf-progress-dot.active { background: var(--accent); } + .wf-progress-dot.done { background: var(--text-3); } + + /* ── Form ───────────────────────── */ + .wf-form { + flex: 1; overflow-y: auto; padding: 24px; + max-width: 640px; margin: 0 auto; width: 100%; + } + .wf-form-field { margin-bottom: 16px; } + .wf-form-field label { + display: block; font-size: 13px; font-weight: 600; + margin-bottom: 4px; color: var(--text); + } + .wf-form-field label .required { color: var(--danger, #e74c3c); margin-left: 2px; } + .wf-form-field input, .wf-form-field select, .wf-form-field textarea { + width: 100%; padding: 8px 12px; font-size: 14px; + background: var(--input-bg); color: var(--text); + border: 1px solid var(--border); border-radius: 6px; + font-family: var(--font); + } + .wf-form-field input:focus, .wf-form-field select:focus, .wf-form-field textarea:focus { + outline: none; border-color: var(--accent); + } + .wf-form-field textarea { resize: vertical; min-height: 80px; } + .wf-form-field .field-error { + font-size: 12px; color: var(--danger, #e74c3c); margin-top: 4px; + } + .wf-form-field.has-error input, + .wf-form-field.has-error select, + .wf-form-field.has-error textarea { + border-color: var(--danger, #e74c3c); + } + .wf-form-field .field-hint { + font-size: 12px; color: var(--text-3); margin-top: 2px; + } + .wf-form-check { + display: flex; align-items: center; gap: 8px; + } + .wf-form-check input[type="checkbox"] { + width: auto; margin: 0; + } + + .wf-form-submit { + margin-top: 24px; + } + .wf-form-submit button { + background: var(--accent); color: #fff; border: none; border-radius: 8px; + padding: 10px 24px; font-weight: 600; cursor: pointer; font-size: 14px; + } + .wf-form-submit button:hover { background: var(--accent-hover); } + .wf-form-submit button:disabled { opacity: 0.5; cursor: default; } + + .wf-form-success { + text-align: center; padding: 40px 24px; + } + .wf-form-success h3 { font-size: 18px; margin-bottom: 8px; } + .wf-form-success p { color: var(--text-2); } + + /* ── Chat ───────────────────────── */ .wf-chat { flex: 1; overflow-y: auto; padding: 16px 24px; } @@ -57,6 +128,12 @@ padding: 8px 24px; font-size: 12px; color: var(--text-3); border-top: 1px solid var(--border); background: var(--bg); } + + /* ── Form+Chat layout ───────────── */ + .wf-body-split { display: flex; flex: 1; overflow: hidden; } + .wf-body-split .wf-form { flex: 1; border-right: 1px solid var(--border); overflow-y: auto; } + .wf-body-split .wf-chat-col { flex: 1; display: flex; flex-direction: column; } + .wf-body-split .wf-chat { flex: 1; } diff --git a/server/store/postgres/workflows.go b/server/store/postgres/workflows.go index 61ccbc8..285c69f 100644 --- a/server/store/postgres/workflows.go +++ b/server/store/postgres/workflows.go @@ -218,20 +218,24 @@ func (s *WorkflowStore) queryWorkflows(ctx context.Context, q string, args ...in func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStage) error { formTpl := jsonOrEmpty(st.FormTemplate) transRules := jsonOrEmpty(st.TransitionRules) + stageMode := st.StageMode + if stageMode == "" { + stageMode = models.StageModeChatOnly + } return DB.QueryRowContext(ctx, ` INSERT INTO workflow_stages (workflow_id, ordinal, name, persona_id, assignment_team_id, - form_template, history_mode, auto_transition, transition_rules) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + form_template, stage_mode, history_mode, auto_transition, transition_rules) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at`, st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID, - formTpl, st.HistoryMode, st.AutoTransition, transRules, + formTpl, stageMode, st.HistoryMode, st.AutoTransition, transRules, ).Scan(&st.ID, &st.CreatedAt) } func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error) { rows, err := DB.QueryContext(ctx, ` SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id, - form_template, history_mode, auto_transition, transition_rules, created_at + form_template, stage_mode, history_mode, auto_transition, transition_rules, created_at FROM workflow_stages WHERE workflow_id = $1 ORDER BY ordinal ASC`, workflowID) if err != nil { @@ -243,8 +247,8 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo var st models.WorkflowStage var formTpl, transRules []byte if err := rows.Scan(&st.ID, &st.WorkflowID, &st.Ordinal, &st.Name, - &st.PersonaID, &st.AssignmentTeamID, &formTpl, &st.HistoryMode, - &st.AutoTransition, &transRules, &st.CreatedAt); err != nil { + &st.PersonaID, &st.AssignmentTeamID, &formTpl, &st.StageMode, + &st.HistoryMode, &st.AutoTransition, &transRules, &st.CreatedAt); err != nil { return nil, err } st.FormTemplate = formTpl @@ -257,13 +261,17 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStage) error { formTpl := jsonOrEmpty(st.FormTemplate) transRules := jsonOrEmpty(st.TransitionRules) + stageMode := st.StageMode + if stageMode == "" { + stageMode = models.StageModeChatOnly + } _, err := DB.ExecContext(ctx, ` UPDATE workflow_stages SET ordinal = $2, name = $3, persona_id = $4, assignment_team_id = $5, - form_template = $6, history_mode = $7, auto_transition = $8, transition_rules = $9 + form_template = $6, stage_mode = $7, history_mode = $8, auto_transition = $9, transition_rules = $10 WHERE id = $1`, st.ID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID, - formTpl, st.HistoryMode, st.AutoTransition, transRules) + formTpl, stageMode, st.HistoryMode, st.AutoTransition, transRules) return err } diff --git a/server/store/sqlite/workflows.go b/server/store/sqlite/workflows.go index b35a8b4..6048e00 100644 --- a/server/store/sqlite/workflows.go +++ b/server/store/sqlite/workflows.go @@ -153,12 +153,16 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag st.CreatedAt = time.Now().UTC() formTpl := jsonOrEmpty(st.FormTemplate) transRules := jsonOrEmpty(st.TransitionRules) + stageMode := st.StageMode + if stageMode == "" { + stageMode = models.StageModeChatOnly + } _, err := DB.ExecContext(ctx, ` INSERT INTO workflow_stages (id, workflow_id, ordinal, name, persona_id, assignment_team_id, - form_template, history_mode, auto_transition, transition_rules, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + form_template, stage_mode, history_mode, auto_transition, transition_rules, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, st.ID, st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID, - formTpl, st.HistoryMode, boolToInt(st.AutoTransition), transRules, + formTpl, stageMode, st.HistoryMode, boolToInt(st.AutoTransition), transRules, st.CreatedAt.Format(time.RFC3339)) return err } @@ -166,7 +170,7 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error) { rows, err := DB.QueryContext(ctx, ` SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id, - form_template, history_mode, auto_transition, transition_rules, created_at + form_template, stage_mode, history_mode, auto_transition, transition_rules, created_at FROM workflow_stages WHERE workflow_id = ? ORDER BY ordinal ASC`, workflowID) if err != nil { @@ -179,8 +183,8 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo var formTpl, transRules string var autoTrans int if err := rows.Scan(&stg.ID, &stg.WorkflowID, &stg.Ordinal, &stg.Name, - &stg.PersonaID, &stg.AssignmentTeamID, &formTpl, &stg.HistoryMode, - &autoTrans, &transRules, st(&stg.CreatedAt)); err != nil { + &stg.PersonaID, &stg.AssignmentTeamID, &formTpl, &stg.StageMode, + &stg.HistoryMode, &autoTrans, &transRules, st(&stg.CreatedAt)); err != nil { return nil, err } stg.FormTemplate = json.RawMessage(formTpl) @@ -194,13 +198,17 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStage) error { formTpl := jsonOrEmpty(st.FormTemplate) transRules := jsonOrEmpty(st.TransitionRules) + stageMode := st.StageMode + if stageMode == "" { + stageMode = models.StageModeChatOnly + } _, err := DB.ExecContext(ctx, ` UPDATE workflow_stages SET ordinal = ?, name = ?, persona_id = ?, assignment_team_id = ?, - form_template = ?, history_mode = ?, auto_transition = ?, transition_rules = ? + form_template = ?, stage_mode = ?, history_mode = ?, auto_transition = ?, transition_rules = ? WHERE id = ?`, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID, - formTpl, st.HistoryMode, boolToInt(st.AutoTransition), transRules, st.ID) + formTpl, stageMode, st.HistoryMode, boolToInt(st.AutoTransition), transRules, st.ID) return err } diff --git a/server/tools/workflow.go b/server/tools/workflow.go index afcb72c..a398790 100644 --- a/server/tools/workflow.go +++ b/server/tools/workflow.go @@ -136,11 +136,17 @@ func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionCont CreateWorkflowAssignment(ctx, t.stores, channelID, nextStage, *nextStageDef.AssignmentTeamID) } + msg := fmt.Sprintf("Advanced to stage %d: %s", nextStage, nextStageDef.Name) + if nextStageDef.StageMode == models.StageModeFormOnly { + msg += " (form-only stage — visitor will fill out a form, no chat needed)" + } + result, _ := json.Marshal(map[string]interface{}{ "status": "active", "current_stage": nextStage, "stage_name": nextStageDef.Name, - "message": fmt.Sprintf("Advanced to stage %d: %s", nextStage, nextStageDef.Name), + "stage_mode": nextStageDef.StageMode, + "message": msg, }) return string(result), nil } diff --git a/src/css/workflow.css b/src/css/workflow.css index bab6dbc..4d5ce43 100644 --- a/src/css/workflow.css +++ b/src/css/workflow.css @@ -121,3 +121,32 @@ /* DnD stage reorder feedback */ .wf-stage-row.dragging { opacity: 0.4; } .wf-stage-row.drag-over { border-top: 2px solid var(--accent); } + +/* ── v0.29.3: Form builder (admin) ──── */ + +.wf-field-row { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: 6px; + margin-bottom: 4px; + background: var(--bg-surface); + flex-wrap: wrap; +} +.wf-field-row:hover { background: var(--bg-raised); } +.wf-field-row.dragging { opacity: 0.4; } +.wf-field-row.drag-over { border-top: 2px solid var(--accent); } +.wf-field-row input[type="text"], +.wf-field-row select { + font-size: 12px; + padding: 4px 6px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--text); +} +.wf-field-row .wf-fb-del { opacity: 0; transition: opacity 0.15s; } +.wf-field-row:hover .wf-fb-del { opacity: 1; } +.badge-accent { background: var(--accent-dim); color: var(--accent); } diff --git a/src/js/workflow-admin.js b/src/js/workflow-admin.js index 3ba82eb..6d021ae 100644 --- a/src/js/workflow-admin.js +++ b/src/js/workflow-admin.js @@ -69,8 +69,22 @@ '' + '' + '
' + - '
' + - '
' + + '
' + + '
' + + '
' + + + '' + '
' + '' + @@ -129,6 +143,7 @@ var target = document.getElementById('adminDynamic'); if (target && !document.getElementById('wfAdminList')) { target.innerHTML = SCAFFOLD_HTML; + _wfWireFormBuilder(); } const list = document.getElementById('wfAdminList'); @@ -247,11 +262,13 @@ personaLabel = p ? '' + esc((p.icon ? p.icon + ' ' : '') + p.name) + '' : 'persona'; } + var modeLabel = s.stage_mode && s.stage_mode !== 'chat_only' + ? '' + esc(s.stage_mode.replace('_', ' ')) + '' : ''; html += '
' + '' + '' + i + '' + '' + esc(s.name) + '' + - personaLabel + + personaLabel + modeLabel + '' + (s.history_mode || 'full') + '' + '' + '' + @@ -384,7 +401,10 @@ document.getElementById('wfStageHistory').value = 'full'; document.getElementById('wfStagePersona').value = ''; document.getElementById('wfStagePrompt').value = ''; + document.getElementById('wfStageMode').value = 'chat_only'; document.getElementById('wfStageForm').value = ''; + document.getElementById('wfFormFields').innerHTML = ''; + _wfUpdateFormBuilderVisibility(); await _wfLoadPersonaSelect(''); document.getElementById('wfStageEditor').style.display = ''; }; @@ -399,11 +419,32 @@ var name = document.getElementById('wfStageName').value.trim(); if (!name) { UI.toast('Stage name required', 'error'); return; } - var formRaw = document.getElementById('wfStageForm').value.trim(); + var stageMode = document.getElementById('wfStageMode').value; var formTemplate = null; - if (formRaw) { - try { formTemplate = JSON.parse(formRaw); } - catch (e) { UI.toast('Invalid JSON in form template', 'error'); return; } + + if (stageMode === 'form_only' || stageMode === 'form_chat') { + // Build from visual builder or JSON textarea + var jsonToggle = document.getElementById('wfFormJsonToggle'); + if (jsonToggle && jsonToggle.checked) { + var formRaw = document.getElementById('wfStageForm').value.trim(); + if (formRaw) { + try { formTemplate = JSON.parse(formRaw); } + catch (e) { UI.toast('Invalid JSON in form template', 'error'); return; } + } + } else { + formTemplate = _wfBuildFormTemplateFromUI(); + } + if (!formTemplate || !formTemplate.fields || formTemplate.fields.length === 0) { + UI.toast('Form modes require at least one form field', 'error'); + return; + } + } else { + // chat_only: still allow optional legacy form template + var formRaw = document.getElementById('wfStageForm').value.trim(); + if (formRaw) { + try { formTemplate = JSON.parse(formRaw); } + catch (e) { UI.toast('Invalid JSON in form template', 'error'); return; } + } } var personaId = document.getElementById('wfStagePersona').value || null; @@ -411,6 +452,7 @@ var data = { name: name, history_mode: document.getElementById('wfStageHistory').value, + stage_mode: stageMode, persona_id: personaId, system_prompt: document.getElementById('wfStagePrompt').value.trim() || undefined, form_template: formTemplate, @@ -446,10 +488,16 @@ _editingStageId = stageId; document.getElementById('wfStageName').value = stage.name; document.getElementById('wfStageHistory').value = stage.history_mode || 'full'; + document.getElementById('wfStageMode').value = stage.stage_mode || 'chat_only'; await _wfLoadPersonaSelect(stage.persona_id || ''); document.getElementById('wfStagePrompt').value = stage.system_prompt || ''; - document.getElementById('wfStageForm').value = - stage.form_template ? JSON.stringify(stage.form_template, null, 2) : ''; + + // Populate form builder from existing template + var tpl = stage.form_template; + document.getElementById('wfStageForm').value = tpl ? JSON.stringify(tpl, null, 2) : ''; + _wfPopulateFormBuilder(tpl); + _wfUpdateFormBuilderVisibility(); + document.getElementById('wfStageEditor').style.display = ''; } catch (e) { UI.toast('Failed: ' + e.message, 'error'); @@ -468,3 +516,215 @@ } }); + // ── Form Builder Helpers ──────────────── + + var FIELD_TYPES = ['text', 'email', 'number', 'date', 'textarea', 'select', 'checkbox', 'file']; + + // Show/hide the form builder based on stage_mode + function _wfUpdateFormBuilderVisibility() { + var mode = document.getElementById('wfStageMode').value; + var wrap = document.getElementById('wfFormBuilderWrap'); + if (wrap) wrap.style.display = (mode === 'form_only' || mode === 'form_chat') ? '' : 'none'; + } + + // Build a single field row element + function _wfCreateFieldRow(field) { + field = field || { key: '', type: 'text', label: '', required: false }; + var row = document.createElement('div'); + row.className = 'wf-field-row'; + row.draggable = true; + row.innerHTML = + '' + + '' + + '' + + '' + + '' + + ''; + + // Delete handler + row.querySelector('.wf-fb-del').onclick = function() { + row.remove(); + _wfSyncFormJSON(); + }; + + // Type change: show options editor for select + row.querySelector('.wf-fb-type').onchange = function() { + var optWrap = row.querySelector('.wf-fb-options'); + if (this.value === 'select') { + if (!optWrap) { + optWrap = document.createElement('div'); + optWrap.className = 'wf-fb-options'; + optWrap.style.cssText = 'margin:4px 0 0 28px;font-size:12px'; + optWrap.innerHTML = ''; + row.appendChild(optWrap); + } + optWrap.style.display = ''; + } else if (optWrap) { + optWrap.style.display = 'none'; + } + _wfSyncFormJSON(); + }; + + // If select type, add options input + if (field.type === 'select') { + var optWrap = document.createElement('div'); + optWrap.className = 'wf-fb-options'; + optWrap.style.cssText = 'margin:4px 0 0 28px;font-size:12px'; + optWrap.innerHTML = ''; + row.appendChild(optWrap); + } + + // Sync JSON on any input change + row.querySelectorAll('input, select').forEach(function(el) { + el.addEventListener('change', _wfSyncFormJSON); + el.addEventListener('input', _wfSyncFormJSON); + }); + + // DnD within field list + row.addEventListener('dragstart', function(e) { + row.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', ''); + _wfDragFieldSrc = row; + }); + row.addEventListener('dragend', function() { + row.classList.remove('dragging'); + document.querySelectorAll('.wf-field-row').forEach(function(r) { r.classList.remove('drag-over'); }); + _wfDragFieldSrc = null; + }); + row.addEventListener('dragover', function(e) { + e.preventDefault(); + if (row !== _wfDragFieldSrc) row.classList.add('drag-over'); + }); + row.addEventListener('dragleave', function() { row.classList.remove('drag-over'); }); + row.addEventListener('drop', function(e) { + e.preventDefault(); + row.classList.remove('drag-over'); + if (!_wfDragFieldSrc || _wfDragFieldSrc === row) return; + var container = document.getElementById('wfFormFields'); + var rows = Array.from(container.querySelectorAll('.wf-field-row')); + var from = rows.indexOf(_wfDragFieldSrc); + var to = rows.indexOf(row); + if (from < to) { + row.parentNode.insertBefore(_wfDragFieldSrc, row.nextSibling); + } else { + row.parentNode.insertBefore(_wfDragFieldSrc, row); + } + _wfSyncFormJSON(); + }); + + return row; + } + + var _wfDragFieldSrc = null; + + // Populate the form builder from an existing template object + function _wfPopulateFormBuilder(tpl) { + var container = document.getElementById('wfFormFields'); + if (!container) return; + container.innerHTML = ''; + + if (!tpl || !tpl.fields || !Array.isArray(tpl.fields)) return; + + tpl.fields.forEach(function(f) { + // Support both typed objects and legacy string-only fields + if (typeof f === 'string') { + f = { key: f, type: 'text', label: f, required: false }; + } + container.appendChild(_wfCreateFieldRow(f)); + }); + } + + // Build a typed form_template from the visual builder + function _wfBuildFormTemplateFromUI() { + var container = document.getElementById('wfFormFields'); + if (!container) return null; + var rows = container.querySelectorAll('.wf-field-row'); + if (rows.length === 0) return null; + + var fields = []; + rows.forEach(function(row) { + var key = row.querySelector('.wf-fb-key').value.trim(); + var type = row.querySelector('.wf-fb-type').value; + var label = row.querySelector('.wf-fb-label').value.trim(); + var required = row.querySelector('.wf-fb-required').checked; + if (!key) return; // skip empty key fields + + var field = { key: key, type: type, label: label || key, required: required }; + + // Parse select options + if (type === 'select') { + var optsInput = row.querySelector('.wf-fb-opts-input'); + if (optsInput && optsInput.value.trim()) { + field.options = optsInput.value.split(',').map(function(s) { + s = s.trim(); + return { value: s, label: s }; + }).filter(function(o) { return o.value; }); + } + } + fields.push(field); + }); + + return fields.length > 0 ? { fields: fields } : null; + } + + // Sync visual builder → JSON textarea (one-way, when builder is active) + function _wfSyncFormJSON() { + var jsonToggle = document.getElementById('wfFormJsonToggle'); + if (!jsonToggle) return; + var tpl = _wfBuildFormTemplateFromUI(); + document.getElementById('wfStageForm').value = tpl ? JSON.stringify(tpl, null, 2) : ''; + } + + // Wire form builder controls (called once after scaffold injection) + function _wfWireFormBuilder() { + var modeSelect = document.getElementById('wfStageMode'); + if (modeSelect) { + modeSelect.onchange = _wfUpdateFormBuilderVisibility; + } + + var addBtn = document.getElementById('wfAddFieldBtn'); + if (addBtn) { + addBtn.onclick = function() { + var container = document.getElementById('wfFormFields'); + if (container) { + container.appendChild(_wfCreateFieldRow()); + _wfSyncFormJSON(); + } + }; + } + + var jsonToggle = document.getElementById('wfFormJsonToggle'); + if (jsonToggle) { + jsonToggle.onchange = function() { + var textarea = document.getElementById('wfStageForm'); + if (this.checked) { + // Sync builder → JSON before showing + _wfSyncFormJSON(); + textarea.style.display = ''; + } else { + // Sync JSON → builder + var raw = textarea.value.trim(); + if (raw) { + try { + var tpl = JSON.parse(raw); + _wfPopulateFormBuilder(tpl); + } catch (e) { + UI.toast('Invalid JSON — fix before switching to visual mode', 'error'); + this.checked = true; + return; + } + } + textarea.style.display = 'none'; + } + }; + } + } + diff --git a/src/js/workflow-api.js b/src/js/workflow-api.js index e111b7b..02cf024 100644 --- a/src/js/workflow-api.js +++ b/src/js/workflow-api.js @@ -78,6 +78,16 @@ return this._post(`/api/v1/channels/${channelId}/workflow/reject`, { reason }); }; + // ── Forms ──────────────────────────────── + + API.getWorkflowForm = function(channelId) { + return this._get(`/api/v1/w/${channelId}/form`); + }; + + API.submitWorkflowForm = function(channelId, data) { + return this._post(`/api/v1/w/${channelId}/form-submit`, data); + }; + // ── Assignments ───────────────────────── API.listMyAssignments = function() {