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:
2026-03-18 00:15:18 +00:00
committed by xcaliber
parent 115004a3ab
commit 7f191e18cd
22 changed files with 1625 additions and 77 deletions

View File

@@ -64,6 +64,8 @@ CREATE TABLE IF NOT EXISTS workflow_stages (
persona_id UUID REFERENCES personas(id) ON DELETE SET NULL,
assignment_team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
form_template JSONB NOT NULL DEFAULT '{}',
stage_mode TEXT NOT NULL DEFAULT 'chat_only'
CHECK (stage_mode IN ('chat_only', 'form_only', 'form_chat')),
history_mode TEXT NOT NULL DEFAULT 'full'
CHECK (history_mode IN ('full', 'summary', 'fresh')),
auto_transition BOOLEAN NOT NULL DEFAULT false,

View File

@@ -34,6 +34,8 @@ CREATE TABLE IF NOT EXISTS workflow_stages (
persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL,
assignment_team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
form_template TEXT NOT NULL DEFAULT '{}',
stage_mode TEXT NOT NULL DEFAULT 'chat_only'
CHECK (stage_mode IN ('chat_only', 'form_only', 'form_chat')),
history_mode TEXT NOT NULL DEFAULT 'full'
CHECK (history_mode IN ('full', 'summary', 'fresh')),
auto_transition INTEGER NOT NULL DEFAULT 0,

View File

@@ -1259,15 +1259,10 @@ func (h *CompletionHandler) conversationHasPersonaMessages(ctx context.Context,
// buildWorkflowFormHint loads the current workflow stage's form_template
// and returns a system message instructing the persona what to collect.
// Returns empty string if not a workflow or no template defined.
// v0.29.3: migrated from raw database.DB to stores, stage_mode aware.
func (h *CompletionHandler) buildWorkflowFormHint(ctx context.Context, channelID string) string {
var workflowID *string
var currentStage int
_ = database.DB.QueryRowContext(ctx, database.Q(`
SELECT workflow_id, COALESCE(current_stage, 0)
FROM channels WHERE id = $1 AND type = 'workflow'
`), channelID).Scan(&workflowID, &currentStage)
if workflowID == nil || *workflowID == "" {
ws, err := h.stores.Channels.GetWorkflowStatus(ctx, channelID)
if err != nil || ws == nil || ws.WorkflowID == nil || *ws.WorkflowID == "" {
return ""
}
@@ -1275,17 +1270,47 @@ func (h *CompletionHandler) buildWorkflowFormHint(ctx context.Context, channelID
return ""
}
stages, err := h.stores.Workflows.ListStages(ctx, *workflowID)
if err != nil || currentStage >= len(stages) {
stages, err := h.stores.Workflows.ListStages(ctx, *ws.WorkflowID)
if err != nil || ws.CurrentStage >= len(stages) {
return ""
}
stage := stages[ws.CurrentStage]
// v0.29.3: form_only stages don't use LLM — no hint needed
if stage.StageMode == models.StageModeFormOnly {
return ""
}
stage := stages[currentStage]
if len(stage.FormTemplate) == 0 || string(stage.FormTemplate) == "{}" {
return ""
}
// Parse form template to extract field descriptions
// v0.29.3: Try typed form template first
if tpl := models.ParseTypedFormTemplate(stage.FormTemplate); tpl != nil {
hint := "You are in workflow stage: " + stage.Name + ".\n\n"
if stage.StageMode == models.StageModeFormChat {
hint += "A form has been provided for the visitor to fill in structured data. " +
"You can assist them with questions about the form fields or discuss related topics.\n\n" +
"Form fields:\n"
} else {
hint += "Collect the following information from the user through natural conversation:\n\n"
}
for _, f := range tpl.Fields {
hint += "- " + f.Label
if f.Required {
hint += " (required)"
}
hint += "\n"
}
if stage.StageMode != models.StageModeFormChat {
hint += "\nWhen you have gathered all required information, call the workflow_advance tool " +
"with the collected data as a JSON object. Do not advance until all required fields are filled."
}
return hint
}
// Legacy format: map[string]interface{} with {description, required}
var fields map[string]interface{}
if err := json.Unmarshal(stage.FormTemplate, &fields); err != nil {
return ""

View File

@@ -123,10 +123,15 @@ func (h *WorkflowEntryHandler) StartVisitor(c *gin.Context) {
// Set session cookie (30 day expiry, matching v0.24.3)
c.SetCookie("sb_session", sessionToken, 30*24*60*60, "/", "", false, true)
// Redirect to chat — visitor lands on existing /w/:channelId
// Redirect to workflow page — visitor lands on existing /w/:channelId
stageMode := stages[0].StageMode
if stageMode == "" {
stageMode = "chat_only"
}
c.JSON(http.StatusCreated, gin.H{
"channel_id": ch.ID,
"session_id": sess.ID,
"redirect_to": "/w/" + ch.ID,
"stage_mode": stageMode,
})
}

View 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
}

View File

@@ -192,8 +192,8 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) {
nextStageDef := stages[nextStage]
// Bind next persona
if nextStageDef.PersonaID != nil {
// Bind next persona (skip for form_only — no LLM needed)
if nextStageDef.StageMode != models.StageModeFormOnly && nextStageDef.PersonaID != nil {
alreadyIn, _ := h.stores.Channels.IsParticipant(ctx, channelID, "persona", *nextStageDef.PersonaID)
if !alreadyIn {
_ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{

View File

@@ -188,6 +188,13 @@ func (h *WorkflowHandler) CreateStage(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "history_mode must be full, summary, or fresh"})
return
}
if st.StageMode == "" {
st.StageMode = models.StageModeChatOnly
}
if !models.ValidStageModes[st.StageMode] {
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be chat_only, form_only, or form_chat"})
return
}
if st.Ordinal == 0 {
existing, _ := h.stores.Workflows.ListStages(c.Request.Context(), st.WorkflowID)
st.Ordinal = len(existing)
@@ -213,6 +220,10 @@ func (h *WorkflowHandler) UpdateStage(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "history_mode must be full, summary, or fresh"})
return
}
if st.StageMode != "" && !models.ValidStageModes[st.StageMode] {
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be chat_only, form_only, or form_chat"})
return
}
if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update stage"})
return

View File

@@ -1272,6 +1272,11 @@ func main() {
wfComp.SetFilterChain(filterChain)
wfComp.SetRunner(starlarkRunner) // v0.29.2: extension tool dispatch
wfAPI.POST("/:id/completions", wfComp.Complete)
// v0.29.3: Workflow form endpoints
wfForms := handlers.NewWorkflowFormHandler(stores, starlarkRunner, hub)
wfAPI.GET("/:id/form", wfForms.GetFormTemplate)
wfAPI.POST("/:id/form-submit", wfForms.SubmitForm)
}
// Workflow visitor entry (v0.26.3) — public start endpoint

View File

@@ -33,6 +33,7 @@ const (
ExtPermDBWrite = "db.write"
ExtPermAPIHTTP = "api.http"
ExtPermProviderComplete = "provider.complete" // v0.29.1: LLM completion calls
ExtPermFormValidate = "forms.validate" // v0.29.3: form validation hooks
)
// ValidExtensionPermissions is the set of recognized permission keys.
@@ -44,6 +45,7 @@ var ValidExtensionPermissions = map[string]bool{
ExtPermDBWrite: true,
ExtPermAPIHTTP: true,
ExtPermProviderComplete: true,
ExtPermFormValidate: true,
}
// ── Extension Permission Model ───────────────

View File

@@ -2,6 +2,9 @@ package models
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"time"
)
@@ -54,12 +57,261 @@ type WorkflowStage struct {
PersonaID *string `json:"persona_id,omitempty"`
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
FormTemplate json.RawMessage `json:"form_template"`
StageMode string `json:"stage_mode"` // chat_only | form_only | form_chat
HistoryMode string `json:"history_mode"` // full | summary | fresh
AutoTransition bool `json:"auto_transition"`
TransitionRules json.RawMessage `json:"transition_rules"`
CreatedAt time.Time `json:"created_at"`
}
// ── Stage Mode Constants ────────────────────
const (
StageModeChatOnly = "chat_only"
StageModeFormOnly = "form_only"
StageModeFormChat = "form_chat"
)
// ValidStageModes is the set of valid stage_mode values.
var ValidStageModes = map[string]bool{
StageModeChatOnly: true,
StageModeFormOnly: true,
StageModeFormChat: true,
}
// ── Typed Form Template ─────────────────────
// TypedFormTemplate is the structured form_template schema (v0.29.3).
type TypedFormTemplate struct {
Fields []FormField `json:"fields"`
Hooks *FormHooks `json:"hooks,omitempty"`
}
// FormField is a single field in a typed form template.
type FormField struct {
Key string `json:"key"`
Type string `json:"type"` // text | email | select | number | date | textarea | checkbox | file
Label string `json:"label"`
Placeholder string `json:"placeholder,omitempty"`
Required bool `json:"required,omitempty"`
Default interface{} `json:"default,omitempty"`
Options []FormOption `json:"options,omitempty"`
Validation *FormValidation `json:"validation,omitempty"`
}
// FormOption is a choice for select fields.
type FormOption struct {
Value string `json:"value"`
Label string `json:"label"`
}
// FormValidation holds type-specific validation rules.
type FormValidation struct {
MinLength *int `json:"min_length,omitempty"` // text, textarea, email
MaxLength *int `json:"max_length,omitempty"` // text, textarea, email
Pattern string `json:"pattern,omitempty"` // text, email (regex)
Min *float64 `json:"min,omitempty"` // number
Max *float64 `json:"max,omitempty"` // number
Step *float64 `json:"step,omitempty"` // number
MinDate string `json:"min_date,omitempty"` // date (YYYY-MM-DD)
MaxDate string `json:"max_date,omitempty"` // date (YYYY-MM-DD)
Accept string `json:"accept,omitempty"` // file (MIME types)
MaxSize *int64 `json:"max_size_bytes,omitempty"` // file
}
// FormHooks wires Starlark validation/submission hooks for a stage's form.
type FormHooks struct {
PackageID string `json:"package_id"`
Validate string `json:"validate,omitempty"` // entry point name
OnSubmit string `json:"on_submit,omitempty"` // entry point name
}
// ValidFormFieldTypes is the set of recognized field types.
var ValidFormFieldTypes = map[string]bool{
"text": true, "email": true, "select": true, "number": true,
"date": true, "textarea": true, "checkbox": true, "file": true,
}
// ParseTypedFormTemplate parses a raw JSON form_template into the typed schema.
// Returns nil if the template is empty, legacy format, or not a typed template.
func ParseTypedFormTemplate(raw json.RawMessage) *TypedFormTemplate {
if len(raw) == 0 || string(raw) == "{}" || string(raw) == "null" {
return nil
}
var tpl TypedFormTemplate
if err := json.Unmarshal(raw, &tpl); err != nil {
return nil
}
if len(tpl.Fields) == 0 {
return nil
}
// Verify this is actually the typed format (first field must have key+type)
if tpl.Fields[0].Key == "" || tpl.Fields[0].Type == "" {
return nil
}
return &tpl
}
// FieldError is a validation error for a specific form field.
type FieldError struct {
Key string `json:"key"`
Message string `json:"message"`
}
// ValidateFormData validates submitted data against a typed form template.
func ValidateFormData(tpl *TypedFormTemplate, data map[string]interface{}) []FieldError {
var errs []FieldError
for _, f := range tpl.Fields {
val, present := data[f.Key]
// Required check
if f.Required && (!present || val == nil || val == "") {
errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " is required"})
continue
}
if !present || val == nil {
continue
}
switch f.Type {
case "text", "textarea":
errs = append(errs, validateString(f, val)...)
case "email":
errs = append(errs, validateEmail(f, val)...)
case "number":
errs = append(errs, validateNumber(f, val)...)
case "date":
errs = append(errs, validateDate(f, val)...)
case "select":
errs = append(errs, validateSelect(f, val)...)
case "checkbox":
if _, ok := val.(bool); !ok {
errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " must be true or false"})
}
}
}
return errs
}
func validateString(f FormField, val interface{}) []FieldError {
s, ok := val.(string)
if !ok {
return []FieldError{{Key: f.Key, Message: f.Label + " must be text"}}
}
var errs []FieldError
v := f.Validation
if v == nil {
return nil
}
if v.MinLength != nil && len(s) < *v.MinLength {
errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at least %d characters", f.Label, *v.MinLength)})
}
if v.MaxLength != nil && len(s) > *v.MaxLength {
errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at most %d characters", f.Label, *v.MaxLength)})
}
if v.Pattern != "" {
if re, err := regexp.Compile(v.Pattern); err == nil && !re.MatchString(s) {
errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " does not match the required pattern"})
}
}
return errs
}
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
func validateEmail(f FormField, val interface{}) []FieldError {
s, ok := val.(string)
if !ok {
return []FieldError{{Key: f.Key, Message: f.Label + " must be text"}}
}
if !emailRe.MatchString(s) {
return []FieldError{{Key: f.Key, Message: f.Label + " must be a valid email address"}}
}
var errs []FieldError
if f.Validation != nil {
if f.Validation.Pattern != "" {
if re, err := regexp.Compile(f.Validation.Pattern); err == nil && !re.MatchString(s) {
errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " does not match the required pattern"})
}
}
}
return errs
}
func validateNumber(f FormField, val interface{}) []FieldError {
var n float64
switch v := val.(type) {
case float64:
n = v
case int:
n = float64(v)
case json.Number:
var err error
n, err = v.Float64()
if err != nil {
return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}}
}
case string:
return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}}
default:
return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}}
}
var errs []FieldError
if f.Validation != nil {
if f.Validation.Min != nil && n < *f.Validation.Min {
errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at least %g", f.Label, *f.Validation.Min)})
}
if f.Validation.Max != nil && n > *f.Validation.Max {
errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at most %g", f.Label, *f.Validation.Max)})
}
}
return errs
}
func validateDate(f FormField, val interface{}) []FieldError {
s, ok := val.(string)
if !ok {
return []FieldError{{Key: f.Key, Message: f.Label + " must be a date string (YYYY-MM-DD)"}}
}
_, err := time.Parse("2006-01-02", s)
if err != nil {
return []FieldError{{Key: f.Key, Message: f.Label + " must be a valid date (YYYY-MM-DD)"}}
}
if f.Validation != nil {
if f.Validation.MinDate != "" {
if minD, e := time.Parse("2006-01-02", f.Validation.MinDate); e == nil {
if d, _ := time.Parse("2006-01-02", s); d.Before(minD) {
return []FieldError{{Key: f.Key, Message: fmt.Sprintf("%s must be on or after %s", f.Label, f.Validation.MinDate)}}
}
}
}
if f.Validation.MaxDate != "" {
if maxD, e := time.Parse("2006-01-02", f.Validation.MaxDate); e == nil {
if d, _ := time.Parse("2006-01-02", s); d.After(maxD) {
return []FieldError{{Key: f.Key, Message: fmt.Sprintf("%s must be on or before %s", f.Label, f.Validation.MaxDate)}}
}
}
}
}
return nil
}
func validateSelect(f FormField, val interface{}) []FieldError {
s, ok := val.(string)
if !ok {
return []FieldError{{Key: f.Key, Message: f.Label + " must be a string"}}
}
if len(f.Options) == 0 {
return nil
}
for _, o := range f.Options {
if strings.EqualFold(o.Value, s) {
return nil
}
}
return []FieldError{{Key: f.Key, Message: f.Label + " is not a valid option"}}
}
// ── Workflow Version (immutable snapshot) ───
// WorkflowVersion is an immutable snapshot of a workflow definition

View File

@@ -594,6 +594,11 @@ type WorkflowPageData struct {
ChannelDescription string
SessionID string
SessionName string
StageMode string // chat_only | form_only | form_chat
StageName string
FormTemplateJSON string // typed form template JSON (empty if chat_only)
TotalStages int
CurrentStage int
}
// WorkflowLandingPageData is passed to workflow-landing.html.
@@ -610,8 +615,9 @@ type WorkflowLandingPageData struct {
}
PersonaName string
PersonaIcon string
StageCount int
ResumeURL string // non-empty if visitor has an active session
StageCount int
FirstStageMode string // chat_only | form_only | form_chat (v0.29.3)
ResumeURL string // non-empty if visitor has an active session
}
// RenderWorkflow renders the workflow entry page for anonymous session visitors.
@@ -643,6 +649,31 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc {
}
}
// Load workflow stage info for form rendering (v0.29.3)
var stageMode, stageName, formTplJSON string
var totalStages, currentStage int
stageMode = "chat_only" // default
if e.stores.Channels != nil && channelID != "" {
ws, wsErr := e.stores.Channels.GetWorkflowStatus(c.Request.Context(), channelID)
if wsErr == nil && ws != nil && ws.WorkflowID != nil {
currentStage = ws.CurrentStage
if stages, sErr := e.stores.Workflows.ListStages(c.Request.Context(), *ws.WorkflowID); sErr == nil && len(stages) > 0 {
totalStages = len(stages)
if currentStage < len(stages) {
stg := stages[currentStage]
stageMode = stg.StageMode
stageName = stg.Name
if stageMode == "" {
stageMode = "chat_only"
}
if stageMode != "chat_only" {
formTplJSON = string(stg.FormTemplate)
}
}
}
}
}
instanceName, _, _ := e.loadBranding()
e.Render(c, "workflow.html", PageData{
@@ -654,6 +685,11 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc {
ChannelDescription: description,
SessionID: sessionID,
SessionName: sessionName,
StageMode: stageMode,
StageName: stageName,
FormTemplateJSON: formTplJSON,
TotalStages: totalStages,
CurrentStage: currentStage,
},
})
}
@@ -704,13 +740,19 @@ func (e *Engine) RenderWorkflowLanding() gin.HandlerFunc {
_ = json.Unmarshal(wf.Branding, &data.Branding)
}
// Load stages for count + first persona info
// Load stages for count + first persona info + stage mode
stages, _ := e.stores.Workflows.ListStages(ctx, wf.ID)
data.StageCount = len(stages)
if len(stages) > 0 && stages[0].PersonaID != nil {
if p, err := e.stores.Personas.GetByID(ctx, *stages[0].PersonaID); err == nil {
data.PersonaName = p.Name
data.PersonaIcon = p.Icon
if len(stages) > 0 {
data.FirstStageMode = stages[0].StageMode
if data.FirstStageMode == "" {
data.FirstStageMode = "chat_only"
}
if stages[0].PersonaID != nil {
if p, err := e.stores.Personas.GetByID(ctx, *stages[0].PersonaID); err == nil {
data.PersonaName = p.Name
data.PersonaIcon = p.Icon
}
}
}

View File

@@ -145,15 +145,19 @@
<p class="wf-description">{{.Data.Description}}</p>
{{end}}
{{if .Data.PersonaName}}
{{if and .Data.PersonaName (ne .Data.FirstStageMode "form_only")}}
<div class="wf-persona">
<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>
{{end}}
</div>
{{end}}
<button class="wf-start-btn" id="startBtn" onclick="startWorkflow()">
Start
{{if eq .Data.FirstStageMode "form_only"}}Fill Out Form{{else}}Start{{end}}
</button>
{{if .Data.ResumeURL}}

View File

@@ -24,6 +24,77 @@
.wf-header h1 { font-size: 18px; font-weight: 600; }
.wf-header p { font-size: 13px; color: var(--text-2); margin-top: 4px; }
.wf-stage-info {
padding: 8px 24px; font-size: 12px; color: var(--text-2);
background: var(--bg-surface); border-bottom: 1px solid var(--border);
display: flex; justify-content: space-between; align-items: center;
}
.wf-progress {
display: flex; gap: 4px;
}
.wf-progress-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--border);
}
.wf-progress-dot.active { background: var(--accent); }
.wf-progress-dot.done { background: var(--text-3); }
/* ── Form ───────────────────────── */
.wf-form {
flex: 1; overflow-y: auto; padding: 24px;
max-width: 640px; margin: 0 auto; width: 100%;
}
.wf-form-field { margin-bottom: 16px; }
.wf-form-field label {
display: block; font-size: 13px; font-weight: 600;
margin-bottom: 4px; color: var(--text);
}
.wf-form-field label .required { color: var(--danger, #e74c3c); margin-left: 2px; }
.wf-form-field input, .wf-form-field select, .wf-form-field textarea {
width: 100%; padding: 8px 12px; font-size: 14px;
background: var(--input-bg); color: var(--text);
border: 1px solid var(--border); border-radius: 6px;
font-family: var(--font);
}
.wf-form-field input:focus, .wf-form-field select:focus, .wf-form-field textarea:focus {
outline: none; border-color: var(--accent);
}
.wf-form-field textarea { resize: vertical; min-height: 80px; }
.wf-form-field .field-error {
font-size: 12px; color: var(--danger, #e74c3c); margin-top: 4px;
}
.wf-form-field.has-error input,
.wf-form-field.has-error select,
.wf-form-field.has-error textarea {
border-color: var(--danger, #e74c3c);
}
.wf-form-field .field-hint {
font-size: 12px; color: var(--text-3); margin-top: 2px;
}
.wf-form-check {
display: flex; align-items: center; gap: 8px;
}
.wf-form-check input[type="checkbox"] {
width: auto; margin: 0;
}
.wf-form-submit {
margin-top: 24px;
}
.wf-form-submit button {
background: var(--accent); color: #fff; border: none; border-radius: 8px;
padding: 10px 24px; font-weight: 600; cursor: pointer; font-size: 14px;
}
.wf-form-submit button:hover { background: var(--accent-hover); }
.wf-form-submit button:disabled { opacity: 0.5; cursor: default; }
.wf-form-success {
text-align: center; padding: 40px 24px;
}
.wf-form-success h3 { font-size: 18px; margin-bottom: 8px; }
.wf-form-success p { color: var(--text-2); }
/* ── Chat ───────────────────────── */
.wf-chat {
flex: 1; overflow-y: auto; padding: 16px 24px;
}
@@ -57,6 +128,12 @@
padding: 8px 24px; font-size: 12px; color: var(--text-3);
border-top: 1px solid var(--border); background: var(--bg);
}
/* ── Form+Chat layout ───────────── */
.wf-body-split { display: flex; flex: 1; overflow: hidden; }
.wf-body-split .wf-form { flex: 1; border-right: 1px solid var(--border); overflow-y: auto; }
.wf-body-split .wf-chat-col { flex: 1; display: flex; flex-direction: column; }
.wf-body-split .wf-chat { flex: 1; }
</style>
<script>
(function() {
@@ -77,47 +154,251 @@
{{if .Data.ChannelDescription}}<p>{{.Data.ChannelDescription}}</p>{{end}}
</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">
<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>
{{end}}
<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>
<script>
(function() {
const BASE = '{{.BasePath}}';
const CHAN_ID = '{{.Data.ChannelID}}';
const BASE = '{{.BasePath}}';
const CHAN_ID = '{{.Data.ChannelID}}';
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');
const inputEl = document.getElementById('chatInput');
const sendBtn = document.getElementById('sendBtn');
let sending = false;
var FORM_TPL = null;
try { FORM_TPL = JSON.parse('{{.Data.FormTemplateJSON}}' || 'null'); } catch(e) {}
// ── 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) {
const div = document.createElement('div');
if (!chatEl) return;
var div = document.createElement('div');
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>';
chatEl.appendChild(div);
chatEl.scrollTop = chatEl.scrollHeight;
}
function escHtml(s) {
const d = document.createElement('div');
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function escAttr(s) {
return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
window.sendMessage = async function() {
const text = inputEl.value.trim();
if (!inputEl || !sendBtn) return;
var text = inputEl.value.trim();
if (!text || sending) return;
sending = true;
sendBtn.disabled = true;
@@ -125,20 +406,19 @@
addMessage('user', text, '{{.Data.SessionName}}');
// The completion endpoint persists the user message and generates the response
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel_id: CHAN_ID, content: text, stream: false }),
});
if (resp.ok) {
const data = await resp.json();
var data = await resp.json();
if (data.content) {
addMessage('assistant', data.content, data.persona_name || 'Assistant');
}
} else {
const err = await resp.json().catch(() => ({}));
var err = await resp.json().catch(function() { return {}; });
addMessage('assistant', 'Error: ' + (err.error || resp.statusText));
}
} catch(e) {
@@ -151,10 +431,12 @@
};
// Auto-resize textarea
inputEl.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 160) + 'px';
});
if (inputEl) {
inputEl.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 160) + 'px';
});
}
})();
</script>
</body>

View File

@@ -218,20 +218,24 @@ func (s *WorkflowStore) queryWorkflows(ctx context.Context, q string, args ...in
func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStage) error {
formTpl := jsonOrEmpty(st.FormTemplate)
transRules := jsonOrEmpty(st.TransitionRules)
stageMode := st.StageMode
if stageMode == "" {
stageMode = models.StageModeChatOnly
}
return DB.QueryRowContext(ctx, `
INSERT INTO workflow_stages (workflow_id, ordinal, name, persona_id, assignment_team_id,
form_template, history_mode, auto_transition, transition_rules)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
form_template, stage_mode, history_mode, auto_transition, transition_rules)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, created_at`,
st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
formTpl, st.HistoryMode, st.AutoTransition, transRules,
formTpl, stageMode, st.HistoryMode, st.AutoTransition, transRules,
).Scan(&st.ID, &st.CreatedAt)
}
func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id,
form_template, history_mode, auto_transition, transition_rules, created_at
form_template, stage_mode, history_mode, auto_transition, transition_rules, created_at
FROM workflow_stages WHERE workflow_id = $1
ORDER BY ordinal ASC`, workflowID)
if err != nil {
@@ -243,8 +247,8 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
var st models.WorkflowStage
var formTpl, transRules []byte
if err := rows.Scan(&st.ID, &st.WorkflowID, &st.Ordinal, &st.Name,
&st.PersonaID, &st.AssignmentTeamID, &formTpl, &st.HistoryMode,
&st.AutoTransition, &transRules, &st.CreatedAt); err != nil {
&st.PersonaID, &st.AssignmentTeamID, &formTpl, &st.StageMode,
&st.HistoryMode, &st.AutoTransition, &transRules, &st.CreatedAt); err != nil {
return nil, err
}
st.FormTemplate = formTpl
@@ -257,13 +261,17 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStage) error {
formTpl := jsonOrEmpty(st.FormTemplate)
transRules := jsonOrEmpty(st.TransitionRules)
stageMode := st.StageMode
if stageMode == "" {
stageMode = models.StageModeChatOnly
}
_, err := DB.ExecContext(ctx, `
UPDATE workflow_stages
SET ordinal = $2, name = $3, persona_id = $4, assignment_team_id = $5,
form_template = $6, history_mode = $7, auto_transition = $8, transition_rules = $9
form_template = $6, stage_mode = $7, history_mode = $8, auto_transition = $9, transition_rules = $10
WHERE id = $1`,
st.ID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
formTpl, st.HistoryMode, st.AutoTransition, transRules)
formTpl, stageMode, st.HistoryMode, st.AutoTransition, transRules)
return err
}

View File

@@ -153,12 +153,16 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag
st.CreatedAt = time.Now().UTC()
formTpl := jsonOrEmpty(st.FormTemplate)
transRules := jsonOrEmpty(st.TransitionRules)
stageMode := st.StageMode
if stageMode == "" {
stageMode = models.StageModeChatOnly
}
_, err := DB.ExecContext(ctx, `
INSERT INTO workflow_stages (id, workflow_id, ordinal, name, persona_id, assignment_team_id,
form_template, history_mode, auto_transition, transition_rules, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
form_template, stage_mode, history_mode, auto_transition, transition_rules, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
st.ID, st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
formTpl, st.HistoryMode, boolToInt(st.AutoTransition), transRules,
formTpl, stageMode, st.HistoryMode, boolToInt(st.AutoTransition), transRules,
st.CreatedAt.Format(time.RFC3339))
return err
}
@@ -166,7 +170,7 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag
func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id,
form_template, history_mode, auto_transition, transition_rules, created_at
form_template, stage_mode, history_mode, auto_transition, transition_rules, created_at
FROM workflow_stages WHERE workflow_id = ?
ORDER BY ordinal ASC`, workflowID)
if err != nil {
@@ -179,8 +183,8 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
var formTpl, transRules string
var autoTrans int
if err := rows.Scan(&stg.ID, &stg.WorkflowID, &stg.Ordinal, &stg.Name,
&stg.PersonaID, &stg.AssignmentTeamID, &formTpl, &stg.HistoryMode,
&autoTrans, &transRules, st(&stg.CreatedAt)); err != nil {
&stg.PersonaID, &stg.AssignmentTeamID, &formTpl, &stg.StageMode,
&stg.HistoryMode, &autoTrans, &transRules, st(&stg.CreatedAt)); err != nil {
return nil, err
}
stg.FormTemplate = json.RawMessage(formTpl)
@@ -194,13 +198,17 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStage) error {
formTpl := jsonOrEmpty(st.FormTemplate)
transRules := jsonOrEmpty(st.TransitionRules)
stageMode := st.StageMode
if stageMode == "" {
stageMode = models.StageModeChatOnly
}
_, err := DB.ExecContext(ctx, `
UPDATE workflow_stages
SET ordinal = ?, name = ?, persona_id = ?, assignment_team_id = ?,
form_template = ?, history_mode = ?, auto_transition = ?, transition_rules = ?
form_template = ?, stage_mode = ?, history_mode = ?, auto_transition = ?, transition_rules = ?
WHERE id = ?`,
st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
formTpl, st.HistoryMode, boolToInt(st.AutoTransition), transRules, st.ID)
formTpl, stageMode, st.HistoryMode, boolToInt(st.AutoTransition), transRules, st.ID)
return err
}

View File

@@ -136,11 +136,17 @@ func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionCont
CreateWorkflowAssignment(ctx, t.stores, channelID, nextStage, *nextStageDef.AssignmentTeamID)
}
msg := fmt.Sprintf("Advanced to stage %d: %s", nextStage, nextStageDef.Name)
if nextStageDef.StageMode == models.StageModeFormOnly {
msg += " (form-only stage — visitor will fill out a form, no chat needed)"
}
result, _ := json.Marshal(map[string]interface{}{
"status": "active",
"current_stage": nextStage,
"stage_name": nextStageDef.Name,
"message": fmt.Sprintf("Advanced to stage %d: %s", nextStage, nextStageDef.Name),
"stage_mode": nextStageDef.StageMode,
"message": msg,
})
return string(result), nil
}