Changeset 0.30.2 (#201)

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
2026-03-18 15:10:11 +00:00
committed by xcaliber
parent 8fee53e440
commit 7b0b6eb061
22 changed files with 1217 additions and 47 deletions

View File

@@ -296,6 +296,66 @@ const Switchboard = {
};
};
// ── Workflow ─────────────────────────────────────────
// v0.30.2: Workflow stage surface management.
/**
* Mount a workflow stage surface into a container.
* Delegates to WorkflowSurfaces registry.
*
* @param {HTMLElement} container — mount target
* @param {object} opts — { channelId, stageMode, surfacePkgId?, formTemplate?, ... }
* @returns {object|null} — mounted surface instance
*/
sw.workflow = function (container, opts) {
if (typeof WorkflowSurfaces === 'undefined') {
console.error('[Switchboard] WorkflowSurfaces not available');
return null;
}
const _opts = opts || {};
return WorkflowSurfaces.mount(container, _opts.stageMode || 'chat_only', _opts);
};
/**
* Register a custom stage surface.
* @param {string} name — surface identifier
* @param {Function} factory — (container, ctx) => { mount(), unmount() }
*/
sw.workflow.registerSurface = function (name, factory) {
if (typeof WorkflowSurfaces !== 'undefined') {
WorkflowSurfaces.register(name, factory);
}
};
/**
* Get workflow instance context (status, stage data, etc.).
* @param {string} channelId
* @returns {Promise<object>}
*/
sw.workflow.getContext = async function (channelId) {
return sw.api.get('/api/v1/channels/' + channelId + '/workflow/status');
};
/**
* Advance a workflow stage.
* @param {string} channelId
* @param {object} [data] — stage data to merge
* @returns {Promise<object>}
*/
sw.workflow.advance = async function (channelId, data) {
return sw.api.post('/api/v1/channels/' + channelId + '/workflow/advance', { data: data || {} });
};
/**
* Reject a workflow stage (go back).
* @param {string} channelId
* @param {string} reason
* @returns {Promise<object>}
*/
sw.workflow.reject = async function (channelId, reason) {
return sw.api.post('/api/v1/channels/' + channelId + '/workflow/reject', { reason: reason });
};
// ── Pipe/Filter Pipeline ─────────────────────────────
// CS1: registration + introspection. CS2: execution engine
// wired into chat.js, ui-core.js, ui-format.js.

View File

@@ -39,6 +39,7 @@
'<label class="toggle-label"><input type="checkbox" id="wfEditActive"><span class="toggle-track"></span><span>Active</span></label>' +
'<button class="btn-small btn-primary" id="wfSaveBtn">Save Changes</button>' +
'<button class="btn-small" id="wfPublishBtn">Publish</button>' +
'<button class="btn-small" id="wfExportBtn">Export .pkg</button>' +
'<button class="btn-small btn-danger" id="wfDeleteBtn" style="margin-left:auto">Delete</button>' +
'</div>' +
'</div>' +
@@ -74,6 +75,7 @@
'<option value="chat_only">Chat Only</option>' +
'<option value="form_only">Form Only (no AI)</option>' +
'<option value="form_chat">Form + Chat</option>' +
'<option value="review">Review (Human)</option>' +
'</select></div>' +
'</div>' +
@@ -167,7 +169,10 @@
let html = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">' +
'<span class="text-muted">' + wfs.length + ' workflow(s)</span>' +
'<button class="btn-small btn-primary" data-action="_wfCreate">+ New</button></div>';
'<div style="display:flex;gap:6px">' +
'<button class="btn-small" data-action="_wfImportPkg">Import .pkg</button>' +
'<button class="btn-small btn-primary" data-action="_wfCreate">+ New</button>' +
'</div></div>';
html += '<table class="admin-table"><thead><tr>' +
'<th>Name</th><th>Slug</th><th>Entry</th><th>Active</th><th>Version</th>' +
'</tr></thead><tbody>';
@@ -262,8 +267,12 @@
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>' : '';
var modeLabel = '';
if (s.stage_mode === 'review') {
modeLabel = '<span class="badge badge-warn">review</span>';
} else if (s.stage_mode && s.stage_mode !== 'chat_only') {
modeLabel = '<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>' +
@@ -380,6 +389,11 @@
}
};
document.getElementById('wfExportBtn').onclick = function() {
if (!_currentWf) return;
API.exportWorkflowPkg(_currentWf.id);
};
document.getElementById('wfDeleteBtn').onclick = async function() {
if (!_currentWf) return;
if (!confirm('Delete workflow "' + _currentWf.name + '"? This cannot be undone.')) return;
@@ -504,6 +518,36 @@
}
});
// ── Import .pkg ──────────────────────────
sb.register('_wfImportPkg', function() {
var input = document.createElement('input');
input.type = 'file';
input.accept = '.pkg,.zip';
input.onchange = async function() {
if (!input.files || !input.files[0]) return;
var formData = new FormData();
formData.append('file', input.files[0]);
try {
var resp = await fetch((window.__BASE__ || '') + '/api/v1/admin/packages/install', {
method: 'POST',
headers: API._authHeaders ? { 'Authorization': API._authHeaders()['Authorization'] } : {},
body: formData,
});
var result = await resp.json();
if (!resp.ok) {
UI.toast('Import failed: ' + (result.error || 'unknown'), 'error');
return;
}
UI.toast('Workflow imported: ' + (result.title || result.id), 'success');
_loadAdminWorkflows();
} catch (e) {
UI.toast('Import error: ' + e.message, 'error');
}
};
input.click();
});
sb.register('_wfDeleteStage', async function(stageId) {
if (!_currentWf) return;
if (!confirm('Delete this stage?')) return;

View File

@@ -88,6 +88,18 @@
return this._post(`/api/v1/w/${channelId}/form-submit`, data);
};
// ── Workflow Packages (v0.30.2) ─────────
API.exportWorkflowPkg = function(workflowId) {
// Trigger download via hidden link (binary response)
const a = document.createElement('a');
a.href = (window.__BASE__ || '') + `/api/v1/admin/workflows/${workflowId}/export`;
a.download = '';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
// ── Assignments ─────────────────────────
API.listMyAssignments = function() {

481
src/js/workflow-surfaces.js Normal file
View File

@@ -0,0 +1,481 @@
// ==========================================
// Chat Switchboard — Workflow Surfaces
// ==========================================
// v0.30.2: Stage surface registry + built-in surface implementations.
// Surfaces are composable UI components that render workflow stages.
//
// Built-in surfaces: form, chat, review
// Custom surfaces can be registered via WorkflowSurfaces.register().
//
// Uses createComponentRegistry + componentMixin pattern from ui-primitives.js.
// ==========================================
'use strict';
const WorkflowSurfaces = {
...createComponentRegistry('WorkflowSurface'),
_types: new Map(),
/**
* Register a stage surface type.
* @param {string} name — surface identifier (e.g. 'form', 'chat', 'review')
* @param {Function} factory — (container, ctx) => surface instance with { mount(), unmount() }
*/
register(name, factory) {
this._types.set(name, factory);
},
/**
* Get a registered surface factory.
* @param {string} name
* @returns {Function|null}
*/
get(name) {
return this._types.get(name) || null;
},
/**
* Mount the appropriate surface for a stage mode.
* @param {HTMLElement} container — mount target
* @param {string} stageMode — chat_only | form_only | form_chat | review
* @param {object} ctx — { channelId, formTemplate, sessionId, basePath, sessionName, ... }
* @returns {object|null} — mounted surface instance
*/
mount(container, stageMode, ctx) {
if (!container) return null;
// Map stage_mode to surface type(s)
switch (stageMode) {
case 'form_only': {
const factory = this._types.get('form');
if (!factory) return null;
const surface = factory(container, ctx);
surface.mount();
return surface;
}
case 'chat_only': {
const factory = this._types.get('chat');
if (!factory) return null;
const surface = factory(container, ctx);
surface.mount();
return surface;
}
case 'form_chat': {
const formFactory = this._types.get('form');
const chatFactory = this._types.get('chat');
if (!formFactory || !chatFactory) return null;
// Build split layout
container.innerHTML = '';
const splitWrap = document.createElement('div');
splitWrap.className = 'wf-body-split';
splitWrap.style.cssText = 'display:flex;flex:1;overflow:hidden;height:100%';
const formCol = document.createElement('div');
formCol.className = 'wf-form';
formCol.style.cssText = 'flex:1;border-right:1px solid var(--border);overflow-y:auto;padding:24px';
const chatCol = document.createElement('div');
chatCol.className = 'wf-chat-col';
chatCol.style.cssText = 'flex:1;display:flex;flex-direction:column';
splitWrap.appendChild(formCol);
splitWrap.appendChild(chatCol);
container.appendChild(splitWrap);
const formSurface = formFactory(formCol, ctx);
const chatSurface = chatFactory(chatCol, ctx);
formSurface.mount();
chatSurface.mount();
return {
mount() {},
unmount() {
formSurface.unmount();
chatSurface.unmount();
},
};
}
case 'review': {
const factory = this._types.get('review');
if (!factory) return null;
const surface = factory(container, ctx);
surface.mount();
return surface;
}
default: {
// Try custom surface
const factory = this._types.get(stageMode);
if (factory) {
const surface = factory(container, ctx);
surface.mount();
return surface;
}
console.warn('[WorkflowSurfaces] Unknown stage mode:', stageMode);
return null;
}
}
},
};
// ── Built-in: Form Surface ──────────────────────
WorkflowSurfaces.register('form', function FormSurface(container, ctx) {
const tpl = ctx.formTemplate;
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>';
}
async function submitForm() {
if (!tpl || !tpl.fields) return;
var btn = container.querySelector('#formSubmitBtn');
if (btn) btn.disabled = true;
container.querySelectorAll('.wf-form-field').forEach(el => el.classList.remove('has-error'));
container.querySelectorAll('.field-error').forEach(el => { el.textContent = ''; });
var data = {};
for (var i = 0; i < tpl.fields.length; i++) {
var f = tpl.fields[i];
var el = container.querySelector('#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 basePath = ctx.basePath || '';
var resp = await fetch(basePath + '/api/v1/w/' + ctx.channelId + '/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) {
for (var i = 0; i < result.errors.length; i++) {
var e = result.errors[i];
var errEl = container.querySelector('#err_' + e.key);
if (errEl) errEl.textContent = e.message;
var fieldEl = container.querySelector('.wf-form-field[data-key="' + e.key + '"]');
if (fieldEl) fieldEl.classList.add('has-error');
}
} else if (result.error) {
alert(result.error);
}
if (btn) btn.disabled = false;
return;
}
if (result.status === 'advanced' || result.status === 'completed') {
container.innerHTML = '<div class="wf-form-success"><h3>Submitted</h3><p>' +
_escHtml(result.status === 'completed' ? 'Thank you! Your submission is complete.' : 'Submitted! Moving to the next step...') +
'</p></div>';
if (result.status === 'advanced') {
setTimeout(function() { window.location.reload(); }, 1500);
}
} else {
container.innerHTML = '<div class="wf-form-success"><h3>Submitted</h3><p>Your response has been submitted.</p></div>';
}
} catch(e) {
alert('Network error: ' + e.message);
if (btn) btn.disabled = false;
}
}
return {
mount() {
if (!tpl || !tpl.fields || tpl.fields.length === 0) {
container.innerHTML = '<div class="wf-form-success"><p>No form fields configured for this stage.</p></div>';
return;
}
var html = '';
for (var i = 0; i < tpl.fields.length; i++) {
html += renderField(tpl.fields[i]);
}
html += '<div class="wf-form-submit"><button id="formSubmitBtn">Submit</button></div>';
container.innerHTML = html;
container.querySelector('#formSubmitBtn').addEventListener('click', submitForm);
},
unmount() {
container.innerHTML = '';
},
};
});
// ── Built-in: Chat Surface ──────────────────────
WorkflowSurfaces.register('chat', function ChatSurface(container, ctx) {
var chatEl, inputEl, sendBtn, sending = false;
function addMessage(role, content, name) {
if (!chatEl) return;
var div = document.createElement('div');
div.className = 'message ' + role;
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;
}
async function sendMessage() {
if (!inputEl || !sendBtn) return;
var text = inputEl.value.trim();
if (!text || sending) return;
sending = true;
sendBtn.disabled = true;
inputEl.value = '';
addMessage('user', text, ctx.sessionName || 'You');
try {
var basePath = ctx.basePath || '';
var resp = await fetch(basePath + '/api/v1/w/' + ctx.channelId + '/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel_id: ctx.channelId, content: text, stream: false }),
});
if (resp.ok) {
var data = await resp.json();
if (data.content) {
addMessage('assistant', data.content, data.persona_name || 'Assistant');
}
} else {
var err = await resp.json().catch(() => ({}));
addMessage('assistant', 'Error: ' + (err.error || resp.statusText));
}
} catch(e) {
addMessage('assistant', 'Network error: ' + e.message);
}
sending = false;
sendBtn.disabled = false;
inputEl.focus();
}
return {
mount() {
chatEl = document.createElement('div');
chatEl.className = 'wf-chat';
chatEl.style.cssText = 'flex:1;overflow-y:auto;padding:16px 24px';
var inputWrap = document.createElement('div');
inputWrap.className = 'wf-input';
inputEl = document.createElement('textarea');
inputEl.placeholder = 'Type a message\u2026';
inputEl.rows = 1;
inputEl.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
inputEl.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 160) + 'px';
});
sendBtn = document.createElement('button');
sendBtn.textContent = 'Send';
sendBtn.addEventListener('click', sendMessage);
inputWrap.appendChild(inputEl);
inputWrap.appendChild(sendBtn);
container.appendChild(chatEl);
container.appendChild(inputWrap);
},
unmount() {
container.innerHTML = '';
chatEl = inputEl = sendBtn = null;
},
};
});
// ── Built-in: Review Surface ────────────────────
WorkflowSurfaces.register('review', function ReviewSurface(container, ctx) {
var mounted = false;
async function loadReviewData() {
var basePath = ctx.basePath || '';
var html = '<div style="padding:24px;max-width:640px;margin:0 auto">';
html += '<h3 style="margin-bottom:16px">Review Stage</h3>';
try {
// Load workflow status to get stage data
var statusResp = await fetch(basePath + '/api/v1/channels/' + ctx.channelId + '/workflow/status', {
headers: ctx.authHeaders ? ctx.authHeaders() : {},
});
if (statusResp.ok) {
var status = await statusResp.json();
html += '<div style="margin-bottom:16px">';
html += '<h4 style="margin-bottom:8px;font-size:14px;color:var(--text-2)">Collected Data</h4>';
if (status.stage_data && typeof status.stage_data === 'object') {
html += '<table style="width:100%;border-collapse:collapse">';
for (var key in status.stage_data) {
if (!status.stage_data.hasOwnProperty(key)) continue;
html += '<tr style="border-bottom:1px solid var(--border)">';
html += '<td style="padding:8px;font-weight:600;font-size:13px;width:30%">' + _escHtml(key) + '</td>';
html += '<td style="padding:8px;font-size:13px">' + _escHtml(String(status.stage_data[key])) + '</td>';
html += '</tr>';
}
html += '</table>';
} else {
html += '<p style="color:var(--text-3)">No data collected yet.</p>';
}
html += '</div>';
html += '<div style="margin-bottom:16px">';
html += '<span style="font-size:12px;color:var(--text-3)">Status: ' + _escHtml(status.status) + '</span>';
html += '</div>';
}
} catch(e) {
html += '<p style="color:var(--danger)">Failed to load review data.</p>';
}
// Action buttons
html += '<div style="display:flex;gap:8px;margin-top:24px">';
html += '<button id="reviewAdvanceBtn" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer">Approve &amp; Advance</button>';
html += '<button id="reviewRejectBtn" style="background:var(--danger,#e74c3c);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer">Reject</button>';
html += '</div>';
html += '</div>';
container.innerHTML = html;
var advBtn = container.querySelector('#reviewAdvanceBtn');
var rejBtn = container.querySelector('#reviewRejectBtn');
if (advBtn) {
advBtn.addEventListener('click', async function() {
advBtn.disabled = true;
try {
var resp = await fetch(basePath + '/api/v1/channels/' + ctx.channelId + '/workflow/advance', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, ctx.authHeaders ? ctx.authHeaders() : {}),
body: JSON.stringify({ data: {} }),
});
if (resp.ok) {
container.innerHTML = '<div style="padding:40px;text-align:center"><h3>Approved</h3><p style="color:var(--text-2)">Stage advanced successfully.</p></div>';
setTimeout(function() { window.location.reload(); }, 1500);
} else {
var err = await resp.json().catch(() => ({}));
alert('Failed: ' + (err.error || 'unknown error'));
advBtn.disabled = false;
}
} catch(e) {
alert('Network error: ' + e.message);
advBtn.disabled = false;
}
});
}
if (rejBtn) {
rejBtn.addEventListener('click', async function() {
var reason = prompt('Rejection reason:');
if (!reason) return;
rejBtn.disabled = true;
try {
var resp = await fetch(basePath + '/api/v1/channels/' + ctx.channelId + '/workflow/reject', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, ctx.authHeaders ? ctx.authHeaders() : {}),
body: JSON.stringify({ reason: reason }),
});
if (resp.ok) {
container.innerHTML = '<div style="padding:40px;text-align:center"><h3>Rejected</h3><p style="color:var(--text-2)">Stage sent back for revision.</p></div>';
setTimeout(function() { window.location.reload(); }, 1500);
} else {
var err = await resp.json().catch(() => ({}));
alert('Failed: ' + (err.error || 'unknown error'));
rejBtn.disabled = false;
}
} catch(e) {
alert('Network error: ' + e.message);
rejBtn.disabled = false;
}
});
}
}
return {
mount() {
if (mounted) return;
mounted = true;
container.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text-3)">Loading review data\u2026</div>';
loadReviewData();
},
unmount() {
container.innerHTML = '';
mounted = false;
},
};
});
// ── Helpers ─────────────────────────────────────
function _escHtml(s) {
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;');
}