Changeset 0.29.3 (#198)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
@@ -104,8 +104,17 @@ Cascades: deletes all stages, versions, and active instances.
|
|||||||
|
|
||||||
## Stages
|
## Stages
|
||||||
|
|
||||||
Ordered steps within a workflow. Each stage has a driving persona and
|
Ordered steps within a workflow. Each stage has a driving persona,
|
||||||
optional human assignment team.
|
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
|
### List Stages
|
||||||
|
|
||||||
@@ -129,25 +138,64 @@ POST /workflows/:id/stages
|
|||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"persona_id": "uuid|null",
|
"persona_id": "uuid|null",
|
||||||
"assignment_team_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",
|
"history_mode": "full|summary|fresh",
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"transition_rules": { "auto_assign": "round_robin" }
|
"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,
|
`history_mode`: what chat history the next stage sees. `full` = complete,
|
||||||
`summary` = utility-role summary, `fresh` = clean slate.
|
`summary` = utility-role summary, `fresh` = clean slate.
|
||||||
|
|
||||||
`form_template`: injected into the completion system prompt as guidance
|
`form_template`: when `stage_mode` is `form_only` or `form_chat`, the
|
||||||
for what the persona should collect. Not rendered as UI — the LLM is
|
template uses a typed schema (see below). For `chat_only` stages, legacy
|
||||||
told what to ask for.
|
freeform templates are still supported as guidance for the LLM.
|
||||||
|
|
||||||
`transition_rules.auto_assign`: `round_robin` assigns to team members
|
`transition_rules.auto_assign`: `round_robin` assigns to team members
|
||||||
in rotation. Null = unassigned (manual claim).
|
in rotation. Null = unassigned (manual claim).
|
||||||
|
|
||||||
**Auth:** `workflow.create` permission required.
|
**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
|
### Update Stage
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -188,6 +236,7 @@ Sets ordinals based on array position.
|
|||||||
"workflow_id": "uuid",
|
"workflow_id": "uuid",
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "Collect Info",
|
"name": "Collect Info",
|
||||||
|
"stage_mode": "chat_only",
|
||||||
"persona_id": "uuid|null",
|
"persona_id": "uuid|null",
|
||||||
"assignment_team_id": "uuid|null",
|
"assignment_team_id": "uuid|null",
|
||||||
"form_template": {},
|
"form_template": {},
|
||||||
@@ -432,6 +481,64 @@ POST /api/v1/w/:id/completions
|
|||||||
These use `AuthOrSession` middleware — the session cookie identifies
|
These use `AuthOrSession` middleware — the session cookie identifies
|
||||||
the visitor without requiring a JWT.
|
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
|
## Staleness Sweep
|
||||||
|
|
||||||
Background goroutine. Checks active workflow instances for staleness
|
Background goroutine. Checks active workflow instances for staleness
|
||||||
@@ -470,3 +577,4 @@ before chaining.
|
|||||||
| `workflow.completed` | Workflow finished |
|
| `workflow.completed` | Workflow finished |
|
||||||
| `workflow.assigned` | New assignment created |
|
| `workflow.assigned` | New assignment created |
|
||||||
| `workflow.claimed` | Assignment claimed by user |
|
| `workflow.claimed` | Assignment claimed by user |
|
||||||
|
| `workflow.form_submitted` | Form data submitted for a form stage |
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ CREATE TABLE IF NOT EXISTS workflow_stages (
|
|||||||
persona_id UUID REFERENCES personas(id) ON DELETE SET NULL,
|
persona_id UUID REFERENCES personas(id) ON DELETE SET NULL,
|
||||||
assignment_team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
|
assignment_team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
|
||||||
form_template JSONB NOT NULL DEFAULT '{}',
|
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'
|
history_mode TEXT NOT NULL DEFAULT 'full'
|
||||||
CHECK (history_mode IN ('full', 'summary', 'fresh')),
|
CHECK (history_mode IN ('full', 'summary', 'fresh')),
|
||||||
auto_transition BOOLEAN NOT NULL DEFAULT false,
|
auto_transition BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ CREATE TABLE IF NOT EXISTS workflow_stages (
|
|||||||
persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL,
|
persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL,
|
||||||
assignment_team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
|
assignment_team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
|
||||||
form_template TEXT NOT NULL DEFAULT '{}',
|
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'
|
history_mode TEXT NOT NULL DEFAULT 'full'
|
||||||
CHECK (history_mode IN ('full', 'summary', 'fresh')),
|
CHECK (history_mode IN ('full', 'summary', 'fresh')),
|
||||||
auto_transition INTEGER NOT NULL DEFAULT 0,
|
auto_transition INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|||||||
@@ -1259,15 +1259,10 @@ func (h *CompletionHandler) conversationHasPersonaMessages(ctx context.Context,
|
|||||||
// buildWorkflowFormHint loads the current workflow stage's form_template
|
// buildWorkflowFormHint loads the current workflow stage's form_template
|
||||||
// and returns a system message instructing the persona what to collect.
|
// and returns a system message instructing the persona what to collect.
|
||||||
// Returns empty string if not a workflow or no template defined.
|
// 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 {
|
func (h *CompletionHandler) buildWorkflowFormHint(ctx context.Context, channelID string) string {
|
||||||
var workflowID *string
|
ws, err := h.stores.Channels.GetWorkflowStatus(ctx, channelID)
|
||||||
var currentStage int
|
if err != nil || ws == nil || ws.WorkflowID == nil || *ws.WorkflowID == "" {
|
||||||
_ = 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 == "" {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1275,17 +1270,47 @@ func (h *CompletionHandler) buildWorkflowFormHint(ctx context.Context, channelID
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
stages, err := h.stores.Workflows.ListStages(ctx, *workflowID)
|
stages, err := h.stores.Workflows.ListStages(ctx, *ws.WorkflowID)
|
||||||
if err != nil || currentStage >= len(stages) {
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
stage := stages[currentStage]
|
|
||||||
if len(stage.FormTemplate) == 0 || string(stage.FormTemplate) == "{}" {
|
if len(stage.FormTemplate) == 0 || string(stage.FormTemplate) == "{}" {
|
||||||
return ""
|
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{}
|
var fields map[string]interface{}
|
||||||
if err := json.Unmarshal(stage.FormTemplate, &fields); err != nil {
|
if err := json.Unmarshal(stage.FormTemplate, &fields); err != nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -123,10 +123,15 @@ func (h *WorkflowEntryHandler) StartVisitor(c *gin.Context) {
|
|||||||
// Set session cookie (30 day expiry, matching v0.24.3)
|
// Set session cookie (30 day expiry, matching v0.24.3)
|
||||||
c.SetCookie("sb_session", sessionToken, 30*24*60*60, "/", "", false, true)
|
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{
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
"channel_id": ch.ID,
|
"channel_id": ch.ID,
|
||||||
"session_id": sess.ID,
|
"session_id": sess.ID,
|
||||||
"redirect_to": "/w/" + ch.ID,
|
"redirect_to": "/w/" + ch.ID,
|
||||||
|
"stage_mode": stageMode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
287
server/handlers/workflow_forms.go
Normal file
287
server/handlers/workflow_forms.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -192,8 +192,8 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) {
|
|||||||
|
|
||||||
nextStageDef := stages[nextStage]
|
nextStageDef := stages[nextStage]
|
||||||
|
|
||||||
// Bind next persona
|
// Bind next persona (skip for form_only — no LLM needed)
|
||||||
if nextStageDef.PersonaID != nil {
|
if nextStageDef.StageMode != models.StageModeFormOnly && nextStageDef.PersonaID != nil {
|
||||||
alreadyIn, _ := h.stores.Channels.IsParticipant(ctx, channelID, "persona", *nextStageDef.PersonaID)
|
alreadyIn, _ := h.stores.Channels.IsParticipant(ctx, channelID, "persona", *nextStageDef.PersonaID)
|
||||||
if !alreadyIn {
|
if !alreadyIn {
|
||||||
_ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{
|
_ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{
|
||||||
|
|||||||
@@ -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"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "history_mode must be full, summary, or fresh"})
|
||||||
return
|
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 {
|
if st.Ordinal == 0 {
|
||||||
existing, _ := h.stores.Workflows.ListStages(c.Request.Context(), st.WorkflowID)
|
existing, _ := h.stores.Workflows.ListStages(c.Request.Context(), st.WorkflowID)
|
||||||
st.Ordinal = len(existing)
|
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"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "history_mode must be full, summary, or fresh"})
|
||||||
return
|
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 {
|
if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update stage"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update stage"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1272,6 +1272,11 @@ func main() {
|
|||||||
wfComp.SetFilterChain(filterChain)
|
wfComp.SetFilterChain(filterChain)
|
||||||
wfComp.SetRunner(starlarkRunner) // v0.29.2: extension tool dispatch
|
wfComp.SetRunner(starlarkRunner) // v0.29.2: extension tool dispatch
|
||||||
wfAPI.POST("/:id/completions", wfComp.Complete)
|
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
|
// Workflow visitor entry (v0.26.3) — public start endpoint
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const (
|
|||||||
ExtPermDBWrite = "db.write"
|
ExtPermDBWrite = "db.write"
|
||||||
ExtPermAPIHTTP = "api.http"
|
ExtPermAPIHTTP = "api.http"
|
||||||
ExtPermProviderComplete = "provider.complete" // v0.29.1: LLM completion calls
|
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.
|
// ValidExtensionPermissions is the set of recognized permission keys.
|
||||||
@@ -44,6 +45,7 @@ var ValidExtensionPermissions = map[string]bool{
|
|||||||
ExtPermDBWrite: true,
|
ExtPermDBWrite: true,
|
||||||
ExtPermAPIHTTP: true,
|
ExtPermAPIHTTP: true,
|
||||||
ExtPermProviderComplete: true,
|
ExtPermProviderComplete: true,
|
||||||
|
ExtPermFormValidate: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Extension Permission Model ───────────────
|
// ── Extension Permission Model ───────────────
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,12 +57,261 @@ type WorkflowStage struct {
|
|||||||
PersonaID *string `json:"persona_id,omitempty"`
|
PersonaID *string `json:"persona_id,omitempty"`
|
||||||
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
||||||
FormTemplate json.RawMessage `json:"form_template"`
|
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
|
HistoryMode string `json:"history_mode"` // full | summary | fresh
|
||||||
AutoTransition bool `json:"auto_transition"`
|
AutoTransition bool `json:"auto_transition"`
|
||||||
TransitionRules json.RawMessage `json:"transition_rules"`
|
TransitionRules json.RawMessage `json:"transition_rules"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
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) ───
|
// ── Workflow Version (immutable snapshot) ───
|
||||||
|
|
||||||
// WorkflowVersion is an immutable snapshot of a workflow definition
|
// WorkflowVersion is an immutable snapshot of a workflow definition
|
||||||
|
|||||||
@@ -594,6 +594,11 @@ type WorkflowPageData struct {
|
|||||||
ChannelDescription string
|
ChannelDescription string
|
||||||
SessionID string
|
SessionID string
|
||||||
SessionName 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.
|
// WorkflowLandingPageData is passed to workflow-landing.html.
|
||||||
@@ -610,8 +615,9 @@ type WorkflowLandingPageData struct {
|
|||||||
}
|
}
|
||||||
PersonaName string
|
PersonaName string
|
||||||
PersonaIcon string
|
PersonaIcon string
|
||||||
StageCount int
|
StageCount int
|
||||||
ResumeURL string // non-empty if visitor has an active session
|
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.
|
// 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()
|
instanceName, _, _ := e.loadBranding()
|
||||||
|
|
||||||
e.Render(c, "workflow.html", PageData{
|
e.Render(c, "workflow.html", PageData{
|
||||||
@@ -654,6 +685,11 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc {
|
|||||||
ChannelDescription: description,
|
ChannelDescription: description,
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
SessionName: sessionName,
|
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)
|
_ = 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)
|
stages, _ := e.stores.Workflows.ListStages(ctx, wf.ID)
|
||||||
data.StageCount = len(stages)
|
data.StageCount = len(stages)
|
||||||
if len(stages) > 0 && stages[0].PersonaID != nil {
|
if len(stages) > 0 {
|
||||||
if p, err := e.stores.Personas.GetByID(ctx, *stages[0].PersonaID); err == nil {
|
data.FirstStageMode = stages[0].StageMode
|
||||||
data.PersonaName = p.Name
|
if data.FirstStageMode == "" {
|
||||||
data.PersonaIcon = p.Icon
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -145,15 +145,19 @@
|
|||||||
<p class="wf-description">{{.Data.Description}}</p>
|
<p class="wf-description">{{.Data.Description}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .Data.PersonaName}}
|
{{if and .Data.PersonaName (ne .Data.FirstStageMode "form_only")}}
|
||||||
<div class="wf-persona">
|
<div class="wf-persona">
|
||||||
<div class="wf-persona-icon">{{if .Data.PersonaIcon}}{{.Data.PersonaIcon}}{{else}}🤖{{end}}</div>
|
<div class="wf-persona-icon">{{if .Data.PersonaIcon}}{{.Data.PersonaIcon}}{{else}}🤖{{end}}</div>
|
||||||
|
{{if eq .Data.FirstStageMode "form_chat"}}
|
||||||
|
<span>Fill out a form and chat with <strong>{{.Data.PersonaName}}</strong></span>
|
||||||
|
{{else}}
|
||||||
<span>You'll be chatting with <strong>{{.Data.PersonaName}}</strong></span>
|
<span>You'll be chatting with <strong>{{.Data.PersonaName}}</strong></span>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<button class="wf-start-btn" id="startBtn" onclick="startWorkflow()">
|
<button class="wf-start-btn" id="startBtn" onclick="startWorkflow()">
|
||||||
Start
|
{{if eq .Data.FirstStageMode "form_only"}}Fill Out Form{{else}}Start{{end}}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{{if .Data.ResumeURL}}
|
{{if .Data.ResumeURL}}
|
||||||
|
|||||||
@@ -24,6 +24,77 @@
|
|||||||
.wf-header h1 { font-size: 18px; font-weight: 600; }
|
.wf-header h1 { font-size: 18px; font-weight: 600; }
|
||||||
.wf-header p { font-size: 13px; color: var(--text-2); margin-top: 4px; }
|
.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 {
|
.wf-chat {
|
||||||
flex: 1; overflow-y: auto; padding: 16px 24px;
|
flex: 1; overflow-y: auto; padding: 16px 24px;
|
||||||
}
|
}
|
||||||
@@ -57,6 +128,12 @@
|
|||||||
padding: 8px 24px; font-size: 12px; color: var(--text-3);
|
padding: 8px 24px; font-size: 12px; color: var(--text-3);
|
||||||
border-top: 1px solid var(--border); background: var(--bg);
|
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; }
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
@@ -77,47 +154,251 @@
|
|||||||
{{if .Data.ChannelDescription}}<p>{{.Data.ChannelDescription}}</p>{{end}}
|
{{if .Data.ChannelDescription}}<p>{{.Data.ChannelDescription}}</p>{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="wf-chat" id="chatMessages"></div>
|
{{if .Data.StageName}}
|
||||||
|
<div class="wf-stage-info">
|
||||||
|
<span>{{.Data.StageName}}</span>
|
||||||
|
{{if gt .Data.TotalStages 1}}
|
||||||
|
<div class="wf-progress" id="progressDots"></div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- form_only: form only -->
|
||||||
|
<!-- form_chat: split form + chat -->
|
||||||
|
<!-- chat_only: chat only (original behavior) -->
|
||||||
|
|
||||||
|
{{if eq .Data.StageMode "form_only"}}
|
||||||
|
<div class="wf-form" id="formArea"></div>
|
||||||
|
{{else if eq .Data.StageMode "form_chat"}}
|
||||||
|
<div class="wf-body-split">
|
||||||
|
<div class="wf-form" id="formArea"></div>
|
||||||
|
<div class="wf-chat-col">
|
||||||
|
<div class="wf-chat" id="chatMessages"></div>
|
||||||
|
<div class="wf-input">
|
||||||
|
<textarea id="chatInput" placeholder="Type a message…" rows="1"
|
||||||
|
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage()}"></textarea>
|
||||||
|
<button id="sendBtn" onclick="sendMessage()">Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="wf-chat" id="chatMessages"></div>
|
||||||
<div class="wf-input">
|
<div class="wf-input">
|
||||||
<textarea id="chatInput" placeholder="Type a message…" rows="1"
|
<textarea id="chatInput" placeholder="Type a message…" rows="1"
|
||||||
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage()}"></textarea>
|
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage()}"></textarea>
|
||||||
<button id="sendBtn" onclick="sendMessage()">Send</button>
|
<button id="sendBtn" onclick="sendMessage()">Send</button>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="wf-session-info">
|
<div class="wf-session-info">
|
||||||
Chatting as <strong>{{.Data.SessionName}}</strong>
|
{{if eq .Data.StageMode "form_only"}}Submitting as{{else}}Chatting as{{end}} <strong>{{.Data.SessionName}}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
const BASE = '{{.BasePath}}';
|
const BASE = '{{.BasePath}}';
|
||||||
const CHAN_ID = '{{.Data.ChannelID}}';
|
const CHAN_ID = '{{.Data.ChannelID}}';
|
||||||
const SESSION_ID = '{{.Data.SessionID}}';
|
const SESSION_ID = '{{.Data.SessionID}}';
|
||||||
|
const STAGE_MODE = '{{.Data.StageMode}}';
|
||||||
|
const TOTAL_STAGES = {{.Data.TotalStages}};
|
||||||
|
const CURRENT_STAGE = {{.Data.CurrentStage}};
|
||||||
|
|
||||||
const chatEl = document.getElementById('chatMessages');
|
var FORM_TPL = null;
|
||||||
const inputEl = document.getElementById('chatInput');
|
try { FORM_TPL = JSON.parse('{{.Data.FormTemplateJSON}}' || 'null'); } catch(e) {}
|
||||||
const sendBtn = document.getElementById('sendBtn');
|
|
||||||
let sending = false;
|
// ── Progress dots ──────────────────
|
||||||
|
(function() {
|
||||||
|
var dotsEl = document.getElementById('progressDots');
|
||||||
|
if (!dotsEl || TOTAL_STAGES <= 1) return;
|
||||||
|
for (var i = 0; i < TOTAL_STAGES; i++) {
|
||||||
|
var dot = document.createElement('div');
|
||||||
|
dot.className = 'wf-progress-dot';
|
||||||
|
if (i < CURRENT_STAGE) dot.className += ' done';
|
||||||
|
if (i === CURRENT_STAGE) dot.className += ' active';
|
||||||
|
dotsEl.appendChild(dot);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Form rendering ─────────────────
|
||||||
|
if ((STAGE_MODE === 'form_only' || STAGE_MODE === 'form_chat') && FORM_TPL && FORM_TPL.fields) {
|
||||||
|
var formArea = document.getElementById('formArea');
|
||||||
|
renderForm(formArea, FORM_TPL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderForm(container, tpl) {
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < tpl.fields.length; i++) {
|
||||||
|
var f = tpl.fields[i];
|
||||||
|
html += renderField(f);
|
||||||
|
}
|
||||||
|
html += '<div class="wf-form-submit"><button id="formSubmitBtn" onclick="submitForm()">Submit</button></div>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderField(f) {
|
||||||
|
var req = f.required ? '<span class="required">*</span>' : '';
|
||||||
|
var ph = f.placeholder ? ' placeholder="' + escAttr(f.placeholder) + '"' : '';
|
||||||
|
var inner = '';
|
||||||
|
|
||||||
|
switch (f.type) {
|
||||||
|
case 'text':
|
||||||
|
case 'email':
|
||||||
|
case 'date':
|
||||||
|
inner = '<input type="' + f.type + '" id="ff_' + f.key + '"' + ph + '>';
|
||||||
|
break;
|
||||||
|
case 'number':
|
||||||
|
var attrs = ph;
|
||||||
|
if (f.validation) {
|
||||||
|
if (f.validation.min != null) attrs += ' min="' + f.validation.min + '"';
|
||||||
|
if (f.validation.max != null) attrs += ' max="' + f.validation.max + '"';
|
||||||
|
if (f.validation.step != null) attrs += ' step="' + f.validation.step + '"';
|
||||||
|
}
|
||||||
|
inner = '<input type="number" id="ff_' + f.key + '"' + attrs + '>';
|
||||||
|
break;
|
||||||
|
case 'textarea':
|
||||||
|
inner = '<textarea id="ff_' + f.key + '"' + ph + ' rows="4"></textarea>';
|
||||||
|
break;
|
||||||
|
case 'select':
|
||||||
|
var opts = '<option value="">— Select —</option>';
|
||||||
|
if (f.options) {
|
||||||
|
for (var j = 0; j < f.options.length; j++) {
|
||||||
|
opts += '<option value="' + escAttr(f.options[j].value) + '">' + escHtml(f.options[j].label) + '</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inner = '<select id="ff_' + f.key + '">' + opts + '</select>';
|
||||||
|
break;
|
||||||
|
case 'checkbox':
|
||||||
|
return '<div class="wf-form-field" data-key="' + f.key + '">' +
|
||||||
|
'<div class="wf-form-check">' +
|
||||||
|
'<input type="checkbox" id="ff_' + f.key + '">' +
|
||||||
|
'<label for="ff_' + f.key + '">' + escHtml(f.label) + req + '</label>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="field-error" id="err_' + f.key + '"></div>' +
|
||||||
|
'</div>';
|
||||||
|
case 'file':
|
||||||
|
var accept = (f.validation && f.validation.accept) ? ' accept="' + escAttr(f.validation.accept) + '"' : '';
|
||||||
|
inner = '<input type="file" id="ff_' + f.key + '"' + accept + '>';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
inner = '<input type="text" id="ff_' + f.key + '"' + ph + '>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<div class="wf-form-field" data-key="' + f.key + '">' +
|
||||||
|
'<label for="ff_' + f.key + '">' + escHtml(f.label) + req + '</label>' +
|
||||||
|
inner +
|
||||||
|
'<div class="field-error" id="err_' + f.key + '"></div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.submitForm = async function() {
|
||||||
|
if (!FORM_TPL || !FORM_TPL.fields) return;
|
||||||
|
var btn = document.getElementById('formSubmitBtn');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
|
||||||
|
// Clear previous errors
|
||||||
|
document.querySelectorAll('.wf-form-field').forEach(function(el) {
|
||||||
|
el.classList.remove('has-error');
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.field-error').forEach(function(el) {
|
||||||
|
el.textContent = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect form data
|
||||||
|
var data = {};
|
||||||
|
for (var i = 0; i < FORM_TPL.fields.length; i++) {
|
||||||
|
var f = FORM_TPL.fields[i];
|
||||||
|
var el = document.getElementById('ff_' + f.key);
|
||||||
|
if (!el) continue;
|
||||||
|
if (f.type === 'checkbox') {
|
||||||
|
data[f.key] = el.checked;
|
||||||
|
} else if (f.type === 'number') {
|
||||||
|
data[f.key] = el.value !== '' ? parseFloat(el.value) : null;
|
||||||
|
} else {
|
||||||
|
data[f.key] = el.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var resp = await fetch(BASE + '/api/v1/w/' + CHAN_ID + '/form-submit', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ data: data }),
|
||||||
|
});
|
||||||
|
var result = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
if (result.errors) {
|
||||||
|
showErrors(result.errors);
|
||||||
|
} else if (result.error) {
|
||||||
|
alert(result.error);
|
||||||
|
}
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success
|
||||||
|
if (result.status === 'advanced' || result.status === 'completed') {
|
||||||
|
showFormSuccess(result.status === 'completed'
|
||||||
|
? 'Thank you! Your submission is complete.'
|
||||||
|
: 'Submitted! Moving to the next step...');
|
||||||
|
if (result.status === 'advanced') {
|
||||||
|
setTimeout(function() { window.location.reload(); }, 1500);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showFormSuccess('Your response has been submitted.');
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
alert('Network error: ' + e.message);
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function showErrors(errors) {
|
||||||
|
for (var i = 0; i < errors.length; i++) {
|
||||||
|
var e = errors[i];
|
||||||
|
var errEl = document.getElementById('err_' + e.key);
|
||||||
|
if (errEl) errEl.textContent = e.message;
|
||||||
|
var fieldEl = document.querySelector('.wf-form-field[data-key="' + e.key + '"]');
|
||||||
|
if (fieldEl) fieldEl.classList.add('has-error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFormSuccess(msg) {
|
||||||
|
var formArea = document.getElementById('formArea');
|
||||||
|
if (formArea) {
|
||||||
|
formArea.innerHTML = '<div class="wf-form-success"><h3>Submitted</h3><p>' + escHtml(msg) + '</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chat ───────────────────────────
|
||||||
|
var chatEl = document.getElementById('chatMessages');
|
||||||
|
var inputEl = document.getElementById('chatInput');
|
||||||
|
var sendBtn = document.getElementById('sendBtn');
|
||||||
|
var sending = false;
|
||||||
|
|
||||||
function addMessage(role, content, name) {
|
function addMessage(role, content, name) {
|
||||||
const div = document.createElement('div');
|
if (!chatEl) return;
|
||||||
|
var div = document.createElement('div');
|
||||||
div.className = 'message ' + role;
|
div.className = 'message ' + role;
|
||||||
const meta = name ? '<div class="meta">' + escHtml(name) + '</div>' : '';
|
var meta = name ? '<div class="meta">' + escHtml(name) + '</div>' : '';
|
||||||
div.innerHTML = meta + '<div class="content">' + escHtml(content) + '</div>';
|
div.innerHTML = meta + '<div class="content">' + escHtml(content) + '</div>';
|
||||||
chatEl.appendChild(div);
|
chatEl.appendChild(div);
|
||||||
chatEl.scrollTop = chatEl.scrollHeight;
|
chatEl.scrollTop = chatEl.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escHtml(s) {
|
function escHtml(s) {
|
||||||
const d = document.createElement('div');
|
var d = document.createElement('div');
|
||||||
d.textContent = s;
|
d.textContent = s;
|
||||||
return d.innerHTML;
|
return d.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escAttr(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
}
|
||||||
|
|
||||||
window.sendMessage = async function() {
|
window.sendMessage = async function() {
|
||||||
const text = inputEl.value.trim();
|
if (!inputEl || !sendBtn) return;
|
||||||
|
var text = inputEl.value.trim();
|
||||||
if (!text || sending) return;
|
if (!text || sending) return;
|
||||||
sending = true;
|
sending = true;
|
||||||
sendBtn.disabled = true;
|
sendBtn.disabled = true;
|
||||||
@@ -125,20 +406,19 @@
|
|||||||
|
|
||||||
addMessage('user', text, '{{.Data.SessionName}}');
|
addMessage('user', text, '{{.Data.SessionName}}');
|
||||||
|
|
||||||
// The completion endpoint persists the user message and generates the response
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(BASE + '/api/v1/w/' + CHAN_ID + '/completions', {
|
var resp = await fetch(BASE + '/api/v1/w/' + CHAN_ID + '/completions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ channel_id: CHAN_ID, content: text, stream: false }),
|
body: JSON.stringify({ channel_id: CHAN_ID, content: text, stream: false }),
|
||||||
});
|
});
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const data = await resp.json();
|
var data = await resp.json();
|
||||||
if (data.content) {
|
if (data.content) {
|
||||||
addMessage('assistant', data.content, data.persona_name || 'Assistant');
|
addMessage('assistant', data.content, data.persona_name || 'Assistant');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const err = await resp.json().catch(() => ({}));
|
var err = await resp.json().catch(function() { return {}; });
|
||||||
addMessage('assistant', 'Error: ' + (err.error || resp.statusText));
|
addMessage('assistant', 'Error: ' + (err.error || resp.statusText));
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
@@ -151,10 +431,12 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Auto-resize textarea
|
// Auto-resize textarea
|
||||||
inputEl.addEventListener('input', function() {
|
if (inputEl) {
|
||||||
this.style.height = 'auto';
|
inputEl.addEventListener('input', function() {
|
||||||
this.style.height = Math.min(this.scrollHeight, 160) + 'px';
|
this.style.height = 'auto';
|
||||||
});
|
this.style.height = Math.min(this.scrollHeight, 160) + 'px';
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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 {
|
func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStage) error {
|
||||||
formTpl := jsonOrEmpty(st.FormTemplate)
|
formTpl := jsonOrEmpty(st.FormTemplate)
|
||||||
transRules := jsonOrEmpty(st.TransitionRules)
|
transRules := jsonOrEmpty(st.TransitionRules)
|
||||||
|
stageMode := st.StageMode
|
||||||
|
if stageMode == "" {
|
||||||
|
stageMode = models.StageModeChatOnly
|
||||||
|
}
|
||||||
return DB.QueryRowContext(ctx, `
|
return DB.QueryRowContext(ctx, `
|
||||||
INSERT INTO workflow_stages (workflow_id, ordinal, name, persona_id, assignment_team_id,
|
INSERT INTO workflow_stages (workflow_id, 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)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING id, created_at`,
|
RETURNING id, created_at`,
|
||||||
st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
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)
|
).Scan(&st.ID, &st.CreatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error) {
|
func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error) {
|
||||||
rows, err := DB.QueryContext(ctx, `
|
rows, err := DB.QueryContext(ctx, `
|
||||||
SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id,
|
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
|
FROM workflow_stages WHERE workflow_id = $1
|
||||||
ORDER BY ordinal ASC`, workflowID)
|
ORDER BY ordinal ASC`, workflowID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -243,8 +247,8 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
|
|||||||
var st models.WorkflowStage
|
var st models.WorkflowStage
|
||||||
var formTpl, transRules []byte
|
var formTpl, transRules []byte
|
||||||
if err := rows.Scan(&st.ID, &st.WorkflowID, &st.Ordinal, &st.Name,
|
if err := rows.Scan(&st.ID, &st.WorkflowID, &st.Ordinal, &st.Name,
|
||||||
&st.PersonaID, &st.AssignmentTeamID, &formTpl, &st.HistoryMode,
|
&st.PersonaID, &st.AssignmentTeamID, &formTpl, &st.StageMode,
|
||||||
&st.AutoTransition, &transRules, &st.CreatedAt); err != nil {
|
&st.HistoryMode, &st.AutoTransition, &transRules, &st.CreatedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
st.FormTemplate = formTpl
|
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 {
|
func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStage) error {
|
||||||
formTpl := jsonOrEmpty(st.FormTemplate)
|
formTpl := jsonOrEmpty(st.FormTemplate)
|
||||||
transRules := jsonOrEmpty(st.TransitionRules)
|
transRules := jsonOrEmpty(st.TransitionRules)
|
||||||
|
stageMode := st.StageMode
|
||||||
|
if stageMode == "" {
|
||||||
|
stageMode = models.StageModeChatOnly
|
||||||
|
}
|
||||||
_, err := DB.ExecContext(ctx, `
|
_, err := DB.ExecContext(ctx, `
|
||||||
UPDATE workflow_stages
|
UPDATE workflow_stages
|
||||||
SET ordinal = $2, name = $3, persona_id = $4, assignment_team_id = $5,
|
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`,
|
WHERE id = $1`,
|
||||||
st.ID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -153,12 +153,16 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag
|
|||||||
st.CreatedAt = time.Now().UTC()
|
st.CreatedAt = time.Now().UTC()
|
||||||
formTpl := jsonOrEmpty(st.FormTemplate)
|
formTpl := jsonOrEmpty(st.FormTemplate)
|
||||||
transRules := jsonOrEmpty(st.TransitionRules)
|
transRules := jsonOrEmpty(st.TransitionRules)
|
||||||
|
stageMode := st.StageMode
|
||||||
|
if stageMode == "" {
|
||||||
|
stageMode = models.StageModeChatOnly
|
||||||
|
}
|
||||||
_, err := DB.ExecContext(ctx, `
|
_, err := DB.ExecContext(ctx, `
|
||||||
INSERT INTO workflow_stages (id, workflow_id, ordinal, name, persona_id, assignment_team_id,
|
INSERT INTO workflow_stages (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)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
st.ID, st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
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))
|
st.CreatedAt.Format(time.RFC3339))
|
||||||
return err
|
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) {
|
func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error) {
|
||||||
rows, err := DB.QueryContext(ctx, `
|
rows, err := DB.QueryContext(ctx, `
|
||||||
SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id,
|
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 = ?
|
FROM workflow_stages WHERE workflow_id = ?
|
||||||
ORDER BY ordinal ASC`, workflowID)
|
ORDER BY ordinal ASC`, workflowID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -179,8 +183,8 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
|
|||||||
var formTpl, transRules string
|
var formTpl, transRules string
|
||||||
var autoTrans int
|
var autoTrans int
|
||||||
if err := rows.Scan(&stg.ID, &stg.WorkflowID, &stg.Ordinal, &stg.Name,
|
if err := rows.Scan(&stg.ID, &stg.WorkflowID, &stg.Ordinal, &stg.Name,
|
||||||
&stg.PersonaID, &stg.AssignmentTeamID, &formTpl, &stg.HistoryMode,
|
&stg.PersonaID, &stg.AssignmentTeamID, &formTpl, &stg.StageMode,
|
||||||
&autoTrans, &transRules, st(&stg.CreatedAt)); err != nil {
|
&stg.HistoryMode, &autoTrans, &transRules, st(&stg.CreatedAt)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
stg.FormTemplate = json.RawMessage(formTpl)
|
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 {
|
func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStage) error {
|
||||||
formTpl := jsonOrEmpty(st.FormTemplate)
|
formTpl := jsonOrEmpty(st.FormTemplate)
|
||||||
transRules := jsonOrEmpty(st.TransitionRules)
|
transRules := jsonOrEmpty(st.TransitionRules)
|
||||||
|
stageMode := st.StageMode
|
||||||
|
if stageMode == "" {
|
||||||
|
stageMode = models.StageModeChatOnly
|
||||||
|
}
|
||||||
_, err := DB.ExecContext(ctx, `
|
_, err := DB.ExecContext(ctx, `
|
||||||
UPDATE workflow_stages
|
UPDATE workflow_stages
|
||||||
SET ordinal = ?, name = ?, persona_id = ?, assignment_team_id = ?,
|
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 = ?`,
|
WHERE id = ?`,
|
||||||
st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -136,11 +136,17 @@ func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionCont
|
|||||||
CreateWorkflowAssignment(ctx, t.stores, channelID, nextStage, *nextStageDef.AssignmentTeamID)
|
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{}{
|
result, _ := json.Marshal(map[string]interface{}{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"current_stage": nextStage,
|
"current_stage": nextStage,
|
||||||
"stage_name": nextStageDef.Name,
|
"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
|
return string(result), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,3 +121,32 @@
|
|||||||
/* DnD stage reorder feedback */
|
/* DnD stage reorder feedback */
|
||||||
.wf-stage-row.dragging { opacity: 0.4; }
|
.wf-stage-row.dragging { opacity: 0.4; }
|
||||||
.wf-stage-row.drag-over { border-top: 2px solid var(--accent); }
|
.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); }
|
||||||
|
|||||||
@@ -69,8 +69,22 @@
|
|||||||
'<textarea id="wfStagePrompt" rows="3" style="width:100%"></textarea></div>' +
|
'<textarea id="wfStagePrompt" rows="3" style="width:100%"></textarea></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="form-row" style="gap:12px;margin-top:8px">' +
|
'<div class="form-row" style="gap:12px;margin-top:8px">' +
|
||||||
'<div class="form-group" style="flex:1"><label>Form Template (JSON)</label>' +
|
'<div class="form-group" style="flex:1"><label>Stage Mode</label>' +
|
||||||
'<textarea id="wfStageForm" rows="3" style="width:100%;font-family:monospace;font-size:12px" placeholder=\'{"name":{"description":"Full name","required":true}}\'></textarea></div>' +
|
'<select id="wfStageMode" style="width:100%">' +
|
||||||
|
'<option value="chat_only">Chat Only</option>' +
|
||||||
|
'<option value="form_only">Form Only (no AI)</option>' +
|
||||||
|
'<option value="form_chat">Form + Chat</option>' +
|
||||||
|
'</select></div>' +
|
||||||
|
'</div>' +
|
||||||
|
|
||||||
|
'<div id="wfFormBuilderWrap" style="display:none;margin-top:8px">' +
|
||||||
|
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">' +
|
||||||
|
'<label style="margin:0;font-weight:600">Form Fields</label>' +
|
||||||
|
'<label class="toggle-label" style="font-size:12px"><input type="checkbox" id="wfFormJsonToggle"><span class="toggle-track"></span><span>Show JSON</span></label>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div id="wfFormFields"></div>' +
|
||||||
|
'<button class="btn-small" id="wfAddFieldBtn" style="margin-top:4px">+ Add Field</button>' +
|
||||||
|
'<textarea id="wfStageForm" rows="4" style="display:none;width:100%;font-family:monospace;font-size:12px;margin-top:6px" placeholder=\'{"fields":[{"key":"name","type":"text","label":"Full Name","required":true}]}\'></textarea>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="form-row" style="margin-top:8px;gap:8px">' +
|
'<div class="form-row" style="margin-top:8px;gap:8px">' +
|
||||||
'<button class="btn-small btn-primary" id="wfSaveStageBtn">Save Stage</button>' +
|
'<button class="btn-small btn-primary" id="wfSaveStageBtn">Save Stage</button>' +
|
||||||
@@ -129,6 +143,7 @@
|
|||||||
var target = document.getElementById('adminDynamic');
|
var target = document.getElementById('adminDynamic');
|
||||||
if (target && !document.getElementById('wfAdminList')) {
|
if (target && !document.getElementById('wfAdminList')) {
|
||||||
target.innerHTML = SCAFFOLD_HTML;
|
target.innerHTML = SCAFFOLD_HTML;
|
||||||
|
_wfWireFormBuilder();
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = document.getElementById('wfAdminList');
|
const list = document.getElementById('wfAdminList');
|
||||||
@@ -247,11 +262,13 @@
|
|||||||
personaLabel = p ? '<span class="badge">' + esc((p.icon ? p.icon + ' ' : '') + p.name) + '</span>' :
|
personaLabel = p ? '<span class="badge">' + esc((p.icon ? p.icon + ' ' : '') + p.name) + '</span>' :
|
||||||
'<span class="badge">persona</span>';
|
'<span class="badge">persona</span>';
|
||||||
}
|
}
|
||||||
|
var modeLabel = s.stage_mode && s.stage_mode !== 'chat_only'
|
||||||
|
? '<span class="badge badge-accent">' + esc(s.stage_mode.replace('_', ' ')) + '</span>' : '';
|
||||||
html += '<div class="wf-stage-row" draggable="true" data-id="' + s.id + '" data-idx="' + i + '">' +
|
html += '<div class="wf-stage-row" draggable="true" data-id="' + s.id + '" data-idx="' + i + '">' +
|
||||||
'<span class="wf-stage-grip">⠿</span>' +
|
'<span class="wf-stage-grip">⠿</span>' +
|
||||||
'<span class="wf-stage-ord">' + i + '</span>' +
|
'<span class="wf-stage-ord">' + i + '</span>' +
|
||||||
'<span class="wf-stage-name">' + esc(s.name) + '</span>' +
|
'<span class="wf-stage-name">' + esc(s.name) + '</span>' +
|
||||||
personaLabel +
|
personaLabel + modeLabel +
|
||||||
'<span class="badge">' + (s.history_mode || 'full') + '</span>' +
|
'<span class="badge">' + (s.history_mode || 'full') + '</span>' +
|
||||||
'<button class="btn-small" data-action="_wfEditStage" data-args=\'' + JSON.stringify([s.id]) + '\'>Edit</button>' +
|
'<button class="btn-small" data-action="_wfEditStage" data-args=\'' + JSON.stringify([s.id]) + '\'>Edit</button>' +
|
||||||
'<button class="btn-small btn-danger" data-action="_wfDeleteStage" data-args=\'' + JSON.stringify([s.id]) + '\'>×</button>' +
|
'<button class="btn-small btn-danger" data-action="_wfDeleteStage" data-args=\'' + JSON.stringify([s.id]) + '\'>×</button>' +
|
||||||
@@ -384,7 +401,10 @@
|
|||||||
document.getElementById('wfStageHistory').value = 'full';
|
document.getElementById('wfStageHistory').value = 'full';
|
||||||
document.getElementById('wfStagePersona').value = '';
|
document.getElementById('wfStagePersona').value = '';
|
||||||
document.getElementById('wfStagePrompt').value = '';
|
document.getElementById('wfStagePrompt').value = '';
|
||||||
|
document.getElementById('wfStageMode').value = 'chat_only';
|
||||||
document.getElementById('wfStageForm').value = '';
|
document.getElementById('wfStageForm').value = '';
|
||||||
|
document.getElementById('wfFormFields').innerHTML = '';
|
||||||
|
_wfUpdateFormBuilderVisibility();
|
||||||
await _wfLoadPersonaSelect('');
|
await _wfLoadPersonaSelect('');
|
||||||
document.getElementById('wfStageEditor').style.display = '';
|
document.getElementById('wfStageEditor').style.display = '';
|
||||||
};
|
};
|
||||||
@@ -399,11 +419,32 @@
|
|||||||
var name = document.getElementById('wfStageName').value.trim();
|
var name = document.getElementById('wfStageName').value.trim();
|
||||||
if (!name) { UI.toast('Stage name required', 'error'); return; }
|
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;
|
var formTemplate = null;
|
||||||
if (formRaw) {
|
|
||||||
try { formTemplate = JSON.parse(formRaw); }
|
if (stageMode === 'form_only' || stageMode === 'form_chat') {
|
||||||
catch (e) { UI.toast('Invalid JSON in form template', 'error'); return; }
|
// 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;
|
var personaId = document.getElementById('wfStagePersona').value || null;
|
||||||
@@ -411,6 +452,7 @@
|
|||||||
var data = {
|
var data = {
|
||||||
name: name,
|
name: name,
|
||||||
history_mode: document.getElementById('wfStageHistory').value,
|
history_mode: document.getElementById('wfStageHistory').value,
|
||||||
|
stage_mode: stageMode,
|
||||||
persona_id: personaId,
|
persona_id: personaId,
|
||||||
system_prompt: document.getElementById('wfStagePrompt').value.trim() || undefined,
|
system_prompt: document.getElementById('wfStagePrompt').value.trim() || undefined,
|
||||||
form_template: formTemplate,
|
form_template: formTemplate,
|
||||||
@@ -446,10 +488,16 @@
|
|||||||
_editingStageId = stageId;
|
_editingStageId = stageId;
|
||||||
document.getElementById('wfStageName').value = stage.name;
|
document.getElementById('wfStageName').value = stage.name;
|
||||||
document.getElementById('wfStageHistory').value = stage.history_mode || 'full';
|
document.getElementById('wfStageHistory').value = stage.history_mode || 'full';
|
||||||
|
document.getElementById('wfStageMode').value = stage.stage_mode || 'chat_only';
|
||||||
await _wfLoadPersonaSelect(stage.persona_id || '');
|
await _wfLoadPersonaSelect(stage.persona_id || '');
|
||||||
document.getElementById('wfStagePrompt').value = stage.system_prompt || '';
|
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 = '';
|
document.getElementById('wfStageEditor').style.display = '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
UI.toast('Failed: ' + e.message, 'error');
|
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 =
|
||||||
|
'<span class="wf-stage-grip" style="cursor:grab">⠿</span>' +
|
||||||
|
'<input type="text" class="wf-fb-key" value="' + esc(field.key || '') + '" placeholder="key" style="width:80px">' +
|
||||||
|
'<select class="wf-fb-type" style="width:90px">' +
|
||||||
|
FIELD_TYPES.map(function(t) {
|
||||||
|
return '<option value="' + t + '"' + (t === field.type ? ' selected' : '') + '>' + t + '</option>';
|
||||||
|
}).join('') +
|
||||||
|
'</select>' +
|
||||||
|
'<input type="text" class="wf-fb-label" value="' + esc(field.label || '') + '" placeholder="Label" style="flex:1">' +
|
||||||
|
'<label class="toggle-label" style="font-size:12px;white-space:nowrap"><input type="checkbox" class="wf-fb-required"' +
|
||||||
|
(field.required ? ' checked' : '') + '><span class="toggle-track"></span><span>Req</span></label>' +
|
||||||
|
'<button class="btn-small btn-danger wf-fb-del" style="padding:2px 6px">×</button>';
|
||||||
|
|
||||||
|
// 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 = '<input type="text" class="wf-fb-opts-input" placeholder="option1, option2, ..." style="width:100%;font-size:12px" value="' +
|
||||||
|
esc((field.options || []).map(function(o) { return typeof o === 'string' ? o : o.value || o.label || ''; }).join(', ')) + '">';
|
||||||
|
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 = '<input type="text" class="wf-fb-opts-input" placeholder="option1, option2, ..." style="width:100%;font-size:12px" value="' +
|
||||||
|
esc((field.options || []).map(function(o) { return typeof o === 'string' ? o : o.value || o.label || ''; }).join(', ')) + '">';
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,16 @@
|
|||||||
return this._post(`/api/v1/channels/${channelId}/workflow/reject`, { reason });
|
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 ─────────────────────────
|
// ── Assignments ─────────────────────────
|
||||||
|
|
||||||
API.listMyAssignments = function() {
|
API.listMyAssignments = function() {
|
||||||
|
|||||||
Reference in New Issue
Block a user