84 lines
3.4 KiB
JavaScript
84 lines
3.4 KiB
JavaScript
// ==============================|| Doormile AI — conversational flow engine ||============================== //
|
|
//
|
|
// One step-walker, shared by every conversational create (orderFlow.js,
|
|
// bulkFlow.js). It was written inside orderFlow and extracted when the bulk
|
|
// create became a conversation too — a second copy would have been a third
|
|
// definition of the same branching rules to keep in sync.
|
|
//
|
|
// A step is a plain object:
|
|
//
|
|
// id required. Also the draft key the answer lands on.
|
|
// type 'select' → the panel renders a dropdown (AIFlowStep)
|
|
// 'rows' → the panel renders the file/paste input
|
|
// 'text' → answered through the composer
|
|
// ask the question
|
|
// when (draft) => boolean. Omitted means always asked. THIS is branching.
|
|
// options async (draft) => [{ value, label, record? }] — for 'select'
|
|
// validate (raw, option) => error | null. Re-asks; stores nothing.
|
|
// resolve async (raw) => { value } | { error }. May fail and re-ask —
|
|
// geocoding. A value the rest of the flow depends on is never
|
|
// stored half-resolved.
|
|
// auto async (draft) => { value?, ask?, patch? }. The step answers itself
|
|
// from real data and is only ASKED when that fails, with the reason.
|
|
// apply (draft, value, option) => draft
|
|
//
|
|
// A step is skipped when `when` is false OR when `draft[step.id]` is already
|
|
// set — which is what lets a caller seed the draft (a client login's tenant) or
|
|
// one step fill several fields (picking an existing customer).
|
|
|
|
const applicable = (step, draft) => (typeof step.when === 'function' ? step.when(draft) : true);
|
|
|
|
// Finds the next step that applies and hasn't been answered. Async because a
|
|
// step may answer itself from the network before we know whether to ask it.
|
|
export const advanceFlow = async (steps, flow) => {
|
|
let { step, draft } = flow;
|
|
|
|
while (step < steps.length) {
|
|
const s = steps[step];
|
|
if (!applicable(s, draft) || draft[s.id] !== undefined) {
|
|
step += 1;
|
|
// eslint-disable-next-line no-continue
|
|
continue;
|
|
}
|
|
|
|
if (s.auto) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const auto = await s.auto(draft);
|
|
if (auto?.patch) draft = { ...draft, ...auto.patch };
|
|
if (auto?.value !== undefined) {
|
|
draft = s.apply(draft, auto.value);
|
|
step += 1;
|
|
// eslint-disable-next-line no-continue
|
|
continue;
|
|
}
|
|
return { flow: { ...flow, step, draft }, step: s, ask: auto?.ask || s.ask, done: false };
|
|
}
|
|
|
|
return { flow: { ...flow, step, draft }, step: s, done: false };
|
|
}
|
|
|
|
return { flow: { ...flow, step, draft, complete: true }, done: true, draft };
|
|
};
|
|
|
|
export const startFlow = (steps, kind, draft = {}) => advanceFlow(steps, { kind, step: 0, draft });
|
|
|
|
// Applies one answer — typed text, a chosen dropdown option, or a parsed file.
|
|
export const answerFlowStep = async (steps, flow, raw, option) => {
|
|
const s = steps[flow.step];
|
|
if (!s) return advanceFlow(steps, flow);
|
|
|
|
if (s.validate) {
|
|
const error = s.validate(raw, option);
|
|
if (error) return { flow, step: s, ask: error, done: false, retry: true };
|
|
}
|
|
|
|
let value = raw;
|
|
if (s.resolve) {
|
|
const resolved = await s.resolve(raw);
|
|
if (resolved.error) return { flow, step: s, ask: resolved.error, done: false, retry: true };
|
|
value = resolved.value;
|
|
}
|
|
|
|
return advanceFlow(steps, { ...flow, step: flow.step + 1, draft: s.apply(flow.draft, value, option) });
|
|
};
|