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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user