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

@@ -121,3 +121,32 @@
/* DnD stage reorder feedback */
.wf-stage-row.dragging { opacity: 0.4; }
.wf-stage-row.drag-over { border-top: 2px solid var(--accent); }
/* ── v0.29.3: Form builder (admin) ──── */
.wf-field-row {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 4px;
background: var(--bg-surface);
flex-wrap: wrap;
}
.wf-field-row:hover { background: var(--bg-raised); }
.wf-field-row.dragging { opacity: 0.4; }
.wf-field-row.drag-over { border-top: 2px solid var(--accent); }
.wf-field-row input[type="text"],
.wf-field-row select {
font-size: 12px;
padding: 4px 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
}
.wf-field-row .wf-fb-del { opacity: 0; transition: opacity 0.15s; }
.wf-field-row:hover .wf-fb-del { opacity: 1; }
.badge-accent { background: var(--accent-dim); color: var(--accent); }

View File

@@ -69,8 +69,22 @@
'<textarea id="wfStagePrompt" rows="3" style="width:100%"></textarea></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:1"><label>Form Template (JSON)</label>' +
'<textarea id="wfStageForm" rows="3" style="width:100%;font-family:monospace;font-size:12px" placeholder=\'{"name":{"description":"Full name","required":true}}\'></textarea></div>' +
'<div class="form-group" style="flex:1"><label>Stage Mode</label>' +
'<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 class="form-row" style="margin-top:8px;gap:8px">' +
'<button class="btn-small btn-primary" id="wfSaveStageBtn">Save Stage</button>' +
@@ -129,6 +143,7 @@
var target = document.getElementById('adminDynamic');
if (target && !document.getElementById('wfAdminList')) {
target.innerHTML = SCAFFOLD_HTML;
_wfWireFormBuilder();
}
const list = document.getElementById('wfAdminList');
@@ -247,11 +262,13 @@
personaLabel = p ? '<span class="badge">' + esc((p.icon ? p.icon + ' ' : '') + p.name) + '</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 + '">' +
'<span class="wf-stage-grip">⠿</span>' +
'<span class="wf-stage-ord">' + i + '</span>' +
'<span class="wf-stage-name">' + esc(s.name) + '</span>' +
personaLabel +
personaLabel + modeLabel +
'<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 btn-danger" data-action="_wfDeleteStage" data-args=\'' + JSON.stringify([s.id]) + '\'>×</button>' +
@@ -384,7 +401,10 @@
document.getElementById('wfStageHistory').value = 'full';
document.getElementById('wfStagePersona').value = '';
document.getElementById('wfStagePrompt').value = '';
document.getElementById('wfStageMode').value = 'chat_only';
document.getElementById('wfStageForm').value = '';
document.getElementById('wfFormFields').innerHTML = '';
_wfUpdateFormBuilderVisibility();
await _wfLoadPersonaSelect('');
document.getElementById('wfStageEditor').style.display = '';
};
@@ -399,11 +419,32 @@
var name = document.getElementById('wfStageName').value.trim();
if (!name) { UI.toast('Stage name required', 'error'); return; }
var formRaw = document.getElementById('wfStageForm').value.trim();
var stageMode = document.getElementById('wfStageMode').value;
var formTemplate = null;
if (formRaw) {
try { formTemplate = JSON.parse(formRaw); }
catch (e) { UI.toast('Invalid JSON in form template', 'error'); return; }
if (stageMode === 'form_only' || stageMode === 'form_chat') {
// Build from visual builder or JSON textarea
var jsonToggle = document.getElementById('wfFormJsonToggle');
if (jsonToggle && jsonToggle.checked) {
var formRaw = document.getElementById('wfStageForm').value.trim();
if (formRaw) {
try { formTemplate = JSON.parse(formRaw); }
catch (e) { UI.toast('Invalid JSON in form template', 'error'); return; }
}
} else {
formTemplate = _wfBuildFormTemplateFromUI();
}
if (!formTemplate || !formTemplate.fields || formTemplate.fields.length === 0) {
UI.toast('Form modes require at least one form field', 'error');
return;
}
} else {
// chat_only: still allow optional legacy form template
var formRaw = document.getElementById('wfStageForm').value.trim();
if (formRaw) {
try { formTemplate = JSON.parse(formRaw); }
catch (e) { UI.toast('Invalid JSON in form template', 'error'); return; }
}
}
var personaId = document.getElementById('wfStagePersona').value || null;
@@ -411,6 +452,7 @@
var data = {
name: name,
history_mode: document.getElementById('wfStageHistory').value,
stage_mode: stageMode,
persona_id: personaId,
system_prompt: document.getElementById('wfStagePrompt').value.trim() || undefined,
form_template: formTemplate,
@@ -446,10 +488,16 @@
_editingStageId = stageId;
document.getElementById('wfStageName').value = stage.name;
document.getElementById('wfStageHistory').value = stage.history_mode || 'full';
document.getElementById('wfStageMode').value = stage.stage_mode || 'chat_only';
await _wfLoadPersonaSelect(stage.persona_id || '');
document.getElementById('wfStagePrompt').value = stage.system_prompt || '';
document.getElementById('wfStageForm').value =
stage.form_template ? JSON.stringify(stage.form_template, null, 2) : '';
// Populate form builder from existing template
var tpl = stage.form_template;
document.getElementById('wfStageForm').value = tpl ? JSON.stringify(tpl, null, 2) : '';
_wfPopulateFormBuilder(tpl);
_wfUpdateFormBuilderVisibility();
document.getElementById('wfStageEditor').style.display = '';
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
@@ -468,3 +516,215 @@
}
});
// ── Form Builder Helpers ────────────────
var FIELD_TYPES = ['text', 'email', 'number', 'date', 'textarea', 'select', 'checkbox', 'file'];
// Show/hide the form builder based on stage_mode
function _wfUpdateFormBuilderVisibility() {
var mode = document.getElementById('wfStageMode').value;
var wrap = document.getElementById('wfFormBuilderWrap');
if (wrap) wrap.style.display = (mode === 'form_only' || mode === 'form_chat') ? '' : 'none';
}
// Build a single field row element
function _wfCreateFieldRow(field) {
field = field || { key: '', type: 'text', label: '', required: false };
var row = document.createElement('div');
row.className = 'wf-field-row';
row.draggable = true;
row.innerHTML =
'<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';
}
};
}
}

View File

@@ -78,6 +78,16 @@
return this._post(`/api/v1/channels/${channelId}/workflow/reject`, { reason });
};
// ── Forms ────────────────────────────────
API.getWorkflowForm = function(channelId) {
return this._get(`/api/v1/w/${channelId}/form`);
};
API.submitWorkflowForm = function(channelId, data) {
return this._post(`/api/v1/w/${channelId}/form-submit`, data);
};
// ── Assignments ─────────────────────────
API.listMyAssignments = function() {