);
}
function UenNote({ status }) {
if (!status) return null;
if (status === "looking") return
🔍 Looking up UEN in the ACRA registry…
;
if (status === "indexing") return
⏳ Preparing the registry index — this resolves shortly.
;
if (status === "notfound") return
No ACRA match for that UEN — please enter the company details manually.
;
return null; // no green "found" confirmation — the filled fields speak for themselves
}
function NameNote({ status }) {
if (!status) return null;
if (status === "looking") return
🔍 Searching the ACRA registry by name…
;
if (status === "indexing") return
⏳ Preparing the registry index — this resolves shortly.
;
return null; // no green "matched" confirmation — the filled UEN speaks for itself
}
function StepForm({ step, values, setValues, onSubmit, busy, extra, onPick }) {
const set = (name, v) => setValues({ ...values, [name]: v });
return (
{ const n = e.target.nextElementSibling; if (n) n.style.display = "inline"; e.target.style.display = "none"; }} />
OmniSkillsAgentic AI Digital Advisor · SBF Skills Development Partner
{today}
{onRegenerate && (
)}
{sessionId && (
)}
{pdfErr &&
{pdfErr}
}
Workforce Transformation Report
{report.title}
{report.executive_summary}
{report.readiness_note &&
{report.readiness_note}
}
{kpis.map((k, i) =>
{k.v}{k.l}
)}
{isOrg && report.job_redesign?.length > 0 && (
Job-Redesign Recommendations
How each role shifts after transformation, and the new skills it needs.
Job Redesign
{report.job_redesign.map((r, i) => (
{r.role}
{r.from_tasks}→{r.to_tasks}
{(r.new_skills || []).map((s, j) => {s})}
))}
)}
{isOrg && report.role_impact?.length > 0 && (
Recommended Job-Role Redesign
Roles to transform, add, reduce, eliminate or retain based on the evidence.
);
}
/* ───────────────────────────────────────────────────────────── Advisor */
function Advisor({ journeyId, provider, onExit, voiceEnabled }) {
const [journey, setJourney] = useState(null);
const [stepIndex, setStepIndex] = useState(0);
const [values, setValues] = useState({});
const [transcript, setTranscript] = useState([]);
const [busy, setBusy] = useState(false);
const [report, setReport] = useState(null);
const [genReport, setGenReport] = useState(false);
const [stepError, setStepError] = useState("");
const [savedId, setSavedId] = useState(null);
const [voiceOn, setVoiceOn] = useState(false);
const [voicePaused, setVoicePaused] = useState(false);
const [voiceReview, setVoiceReview] = useState(false);
const [voiceStatus, setVoiceStatus] = useState("");
const [uenStatus, setUenStatus] = useState("");
const [nameStatus, setNameStatus] = useState("");
const uenLast = useRef("");
const nameLast = useRef("");
const collected = useRef({});
const analyses = useRef({});
const bodyRef = useRef(null);
const anchorRef = useRef(null);
const voice = useRef({});
const live = useRef({}); // fresh {step, values, setValues, submitStep, stepIndex} for voice callbacks
// ── Realtime voice (talk to the advisor) ──
// Update the in-flight assistant bubble by a stable id (not array index) so a
// user transcript arriving mid-stream can't shift/overwrite the wrong row.
const upsertAssistant = (text, done) => {
setTranscript((t) => {
const v = voice.current, copy = [...t];
if (!v.asstId) { v.asstId = "a" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); copy.push({ role: "assistant", content: text, _id: v.asstId }); }
else { const i = copy.findIndex((m) => m._id === v.asstId); if (i >= 0) copy[i] = { ...copy[i], content: text }; else copy.push({ role: "assistant", content: text, _id: v.asstId }); }
return copy;
});
if (done) voice.current.asstId = null;
};
const playPCM = (b64) => {
const v = voice.current; if (!v.playCtx) return;
const f32 = base64ToF32(b64);
const buf = v.playCtx.createBuffer(1, f32.length, 24000);
buf.getChannelData(0).set(f32);
const node = v.playCtx.createBufferSource();
node.buffer = buf; node.connect(v.playGain || v.playCtx.destination);
v.playTime = Math.max(v.playTime || 0, v.playCtx.currentTime);
node.start(v.playTime); v.playTime += buf.duration;
};
const sendVoice = (obj) => {
const v = voice.current;
if (v.ws && v.ws.readyState === 1) { try { v.ws.send(JSON.stringify(obj)); } catch (e) {} }
};
// ── numeric-range helpers: map a spoken number onto a range option ──
const parseRange = (opt) => {
const s = String(opt);
let m = s.match(/(\d[\d,]*)\s*(?:-|–|to)\s*(\d[\d,]*)/i);
if (m) return { lo: +m[1].replace(/,/g, ""), hi: +m[2].replace(/,/g, "") };
m = s.match(/(\d[\d,]*)\s*\+/); if (m) return { lo: +m[1].replace(/,/g, ""), hi: Infinity };
m = s.match(/(?:under|below|less than|up to)\s*(\d[\d,]*)/i); if (m) return { lo: 0, hi: +m[1].replace(/,/g, "") };
m = s.match(/(?:over|above|more than)\s*(\d[\d,]*)/i); if (m) return { lo: +m[1].replace(/,/g, ""), hi: Infinity };
return null;
};
const isRangeField = (f) => f.type === "select" && (f.options || []).length > 0 && (f.options || []).every((o) => parseRange(o));
const numberIn = (raw) => { const m = String(raw).replace(/,/g, "").match(/\d+(?:\.\d+)?/); return m ? +m[0] : null; };
// Human-readable current value of a field (null = treat as not yet answered).
const fieldValueLabel = (f, val) => {
if (val == null || val === "") return null;
if (f.type === "consent") return val === true ? "consent given" : null; // default-false = unanswered
if (Array.isArray(val)) return val.length ? val.join(", ") : null;
if (f.type === "file") return val && val.filename ? val.filename : null;
return String(val);
};
const describeField = (f, i) => {
const opts = (f.options || []).join(", ");
if (isRangeField(f)) return `${i + 1}. ${f.label} (id: ${f.name}) — ask for the ACTUAL number; do NOT read the ranges aloud, I map the number to a band.`;
if (f.type === "select") return `${i + 1}. ${f.label} (id: ${f.name}) — one choice from: ${opts}. Offer a couple as examples; if the user says something off-list, briefly clarify or pick the nearest option and confirm it.`;
if (f.type === "multiselect") return `${i + 1}. ${f.label} (id: ${f.name}) — one or more of: ${opts}.`;
if (f.type === "consent") return `${i + 1}. ${f.label} (id: ${f.name}) — a yes/no consent.`;
return `${i + 1}. ${f.label} (id: ${f.name}).`;
};
// Build a per-step realtime session: instructions + tools so the advisor holds a
// natural conversation and writes answers into the form (shown at review time).
// Reads the CURRENT form values so it never re-asks fields the user already filled.
const voiceSession = (step) => {
const vals = (live.current && live.current.values) || {};
const fillable = (step.fields || []).filter((f) => f.type !== "file");
const files = (step.fields || []).filter((f) => f.type === "file");
const already = [], remaining = [];
fillable.forEach((f) => {
const disp = fieldValueLabel(f, vals[f.name]);
if (disp != null) already.push(`- ${f.label}: ${disp}`);
else remaining.push(f);
});
const remLines = remaining.map((f, i) => describeField(f, i)).join("\n");
const fileMissing = files.filter((f) => !fieldValueLabel(f, vals[f.name]));
const fileLines = fileMissing.map((f) => `- ${f.label}: the user must upload it with the on-screen button; you cannot capture a file by voice.`).join("\n");
const instructions =
`You are the OmniSkills Agentic AI Digital Advisor for the Singapore Business Federation's Skills Development Partner programme (Trade, Investment & Internationalisation and Green Skills). Speak in warm, concise Singapore/British English.\n\n` +
`You are helping the user complete the step "${step.label.replace(/^\d+\.\s*/, "")}". ${step.brief || ""}\n\n` +
`The user may have ALREADY FILLED PART OF THE FORM by hand before starting the voice chat. Continue from where they left off — never ask again for anything already captured.\n\n` +
`HOW TO CONVERSE — optimise for the user's time and be intelligent:\n` +
`- Open by briefly acknowledging what's already captured (one short sentence), then ask only for the first STILL-NEEDED field.\n` +
`- Keep spoken turns short (one or two sentences).\n` +
`- Prefer one field per question, but you MAY combine two closely-related short still-needed fields into a single natural question when it saves time.\n` +
`- If the user answers SEVERAL fields in one sentence, capture ALL of them — emit a separate set_field call for each — and do NOT ask those again.\n` +
`- For a size/number field, just ask for the actual number (e.g. "How many staff?"). Never read out numeric ranges.\n` +
`- For a choice field, you may suggest a couple of options; if the user says something not on the list, ask a short clarifying question or pick the nearest match and confirm.\n` +
`- Never re-ask a field that is already filled; move to the next still-needed field. If the user asks to change something already captured, call set_field to update it.\n` +
`- If the user says "pause", stop talking and wait quietly.\n\n` +
`WHEN ALL STILL-NEEDED FIELDS ARE CAPTURED:\n` +
`- Call the review tool (do NOT submit yet). The on-screen form then shows everything captured.\n` +
`- Then ask the user, in one sentence, to confirm the details are correct.\n` +
`- Only when the user confirms (e.g. "yes", "looks good", "submit"), call the submit tool. If they want a change, call set_field to correct it, then review again.\n\n` +
(already.length ? `ALREADY CAPTURED (do NOT ask these again):\n${already.join("\n")}\n\n` : ``) +
(remLines ? `STILL NEEDED — ask only these, in order:\n${remLines}\n` : (fillable.length ? `All spoken fields are already filled — briefly confirm and call review.\n` : `This step has no spoken fields.\n`)) +
(fileLines ? `\nFiles the user must still upload:\n${fileLines}\n` : ``);
const tools = [];
if (fillable.length) {
tools.push({
type: "function", name: "set_field",
description: "Record one answer into the on-screen form. Call once per field; call it several times in a turn if the user gave several answers at once.",
parameters: {
type: "object",
properties: {
field: { type: "string", enum: fillable.map((f) => f.name), description: "The field id to fill." },
value: { type: "string", description: "The user's answer as plain text (for a size field, just the number)." },
},
required: ["field", "value"],
},
});
tools.push({ type: "function", name: "review", description: "Show the filled form to the user for confirmation once all fields are captured.", parameters: { type: "object", properties: {} } });
}
tools.push({ type: "function", name: "submit", description: "Submit this step after the user confirms the reviewed details are correct.", parameters: { type: "object", properties: {} } });
return { type: "session.update", session: { instructions, tools, tool_choice: "auto" } };
};
const matchOption = (opts, val) => {
const s = String(val).toLowerCase().trim();
return (opts || []).find((o) => o.toLowerCase() === s)
|| (opts || []).find((o) => o.toLowerCase().includes(s) || s.includes(o.toLowerCase()));
};
// Apply a spoken answer to the visible form. Returns a short result message for the model.
const applyVoiceField = (name, raw) => {
const L = live.current, step = L.step;
if (!step) return "No active step.";
const f = (step.fields || []).find((x) => x.name === name || x.label.toLowerCase() === String(name).toLowerCase());
if (!f) return `There is no field called ${name}.`;
if (f.type === "file") return `${f.label} needs a file upload; ask the user to use the upload button.`;
let v;
if (f.type === "consent") v = /^(yes|y|agree|consent|ok|sure|granted|i agree|of course|absolutely)/i.test(String(raw).trim());
else if (f.type === "select") {
if (isRangeField(f)) {
const n = numberIn(raw);
if (n != null) { const opt = (f.options || []).find((o) => { const r = parseRange(o); return r && n >= r.lo && n <= r.hi; }); v = opt || matchOption(f.options, raw); }
else v = matchOption(f.options, raw);
} else v = matchOption(f.options, raw);
if (!v) return `"${raw}" doesn't match an option (${(f.options || []).join(", ")}); ask the user to clarify or pick the nearest.`;
} else if (f.type === "multiselect") {
const parts = String(raw).split(/,| and | or |;|\/|&/i).map((s) => s.trim()).filter(Boolean);
v = Array.from(new Set(parts.map((p) => matchOption(f.options, p)).filter(Boolean)));
if (!v.length) return `Please choose from: ${(f.options || []).join(", ")}.`;
} else v = String(raw).trim();
if (L.setValues) L.setValues((prev) => ({ ...prev, [f.name]: v }));
const vErr = f.validate ? fieldError(f, v) : "";
if (vErr) return `I noted ${f.label} as "${v}", but that doesn't look valid — ${vErr} Please ask the user to confirm or correct it.`;
if (f.validate === "uen") return `UEN ${v} recorded and valid. I'm auto-filling the company name (and subsector where known) from the registry — do NOT ask for the company name; move to the next still-needed field.`;
return `Recorded ${f.label}: ${Array.isArray(v) ? v.join(", ") : (v === true ? "consent given" : v === false ? "declined" : v)}.`;
};
const handleVoiceTool = (name, args, callId) => {
const v = voice.current;
let result = "Done.";
if (name === "set_field") { result = applyVoiceField(args.field, args.value); v.needFollowup = true; }
else if (name === "review") { setVoiceReview(true); v.needFollowup = true; result = "The filled form is now shown for the user to confirm."; }
else if (name === "submit") {
const r = live.current.advanceVoiceStep ? live.current.advanceVoiceStep() : { ok: false, msg: "Cannot submit right now." };
if (r.ok) { setVoiceReview(false); v.submitting = true; v.needFollowup = false; }
else { v.needFollowup = true; } // stay on this step and ask the user for what's missing
result = r.msg;
} else { result = "Unknown action."; }
// Return the tool result; the single follow-up question is issued on response.done
// so multiple answers in one turn don't trigger multiple replies.
if (callId) sendVoice({ type: "conversation.item.create", item: { type: "function_call_output", call_id: callId, output: JSON.stringify({ result }) } });
};
const pauseVoice = () => {
const v = voice.current; v.paused = true;
if (v.playGain) v.playGain.gain.value = 0;
v.playTime = 0;
if (v.responseActive) sendVoice({ type: "response.cancel" }); // only cancel a live response
setVoicePaused(true); setVoiceStatus("Paused");
};
const resumeVoice = () => {
const v = voice.current; v.paused = false;
if (v.playGain) v.playGain.gain.value = 1;
setVoicePaused(false); setVoiceStatus("Listening…");
};
const handleVoiceEvent = (msg) => {
const v = voice.current;
switch (msg.type) {
case "response.created": v.responseActive = true; break;
case "response.audio.delta": if (msg.delta && !v.paused) playPCM(msg.delta); break;
case "response.audio_transcript.delta": v.asstBuf = (v.asstBuf || "") + (msg.delta || ""); upsertAssistant(v.asstBuf); break;
case "response.audio_transcript.done": upsertAssistant(msg.transcript || v.asstBuf || "", true); v.asstBuf = ""; break;
case "response.output_item.added":
if (msg.item && msg.item.type === "function_call") { v.fcalls = v.fcalls || {}; v.fcalls[msg.item.call_id] = msg.item.name; }
break;
case "response.function_call_arguments.done": {
let args = {}; try { args = JSON.parse(msg.arguments || "{}"); } catch (e) {}
const nm = msg.name || (v.fcalls || {})[msg.call_id];
handleVoiceTool(nm, args, msg.call_id);
break;
}
case "conversation.item.input_audio_transcription.completed": {
const txt = (msg.transcript || "").trim();
if (txt) {
setTranscript((t) => [...t, { role: "user", content: txt }]);
if (/\bend (the |this )?(conversation|call|voice|chat|session)\b|\bstop (the |this )?conversation\b|\bhang up\b|\bwe'?re (all )?done\b|\bthat'?s all( for now)?\b|\bthat is all\b|\bfinish (the )?(conversation|call)\b/i.test(txt)) { stopVoice(); break; }
else if (/\bpause\b|\bhold on\b|\bwait a\b/i.test(txt)) pauseVoice();
else if (/\bresume\b|\bunpause\b|\bcarry on\b|\bgo ahead\b/i.test(txt)) resumeVoice();
}
break;
}
case "input_audio_buffer.speech_started": if (!v.paused) setVoiceStatus("Listening…"); break;
case "input_audio_buffer.speech_stopped": if (!v.paused) setVoiceStatus("Thinking…"); break;
case "response.done": {
v.responseActive = false;
// One consolidated follow-up after tool calls (set_field/review) — even if
// several answers arrived in one turn — unless we just submitted.
if (v.needFollowup && !v.submitting && !v.paused) { v.needFollowup = false; sendVoice({ type: "response.create" }); }
v.submitting = false;
if (!v.paused) setVoiceStatus("Listening…");
break;
}
case "error": {
const m = (msg.error && (msg.error.message || msg.error.code)) || "";
if (/cancel|no active response|active response/i.test(m)) break; // benign: pause with nothing to cancel
console.warn("realtime error:", m);
break;
}
default: break;
}
};
const stopVoice = () => {
const v = voice.current;
try { v.ws && v.ws.close(); } catch (e) {}
try { v.proc && v.proc.disconnect(); } catch (e) {}
try { v.src && v.src.disconnect(); } catch (e) {}
try { v.sink && v.sink.disconnect(); } catch (e) {}
try { v.stream && v.stream.getTracks().forEach((t) => t.stop()); } catch (e) {}
try { v.ctx && v.ctx.close(); } catch (e) {}
try { v.playCtx && v.playCtx.close(); } catch (e) {}
voice.current = {};
setVoiceOn(false); setVoicePaused(false); setVoiceReview(false); setVoiceStatus("");
};
const startVoice = async () => {
// Mic needs a secure context: https OR localhost/127.0.0.1 (NOT 0.0.0.0).
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
const onZero = location.hostname === "0.0.0.0";
setVoiceStatus(onZero
? "Voice needs localhost — open http://localhost:8000 (not 0.0.0.0)"
: "Voice needs a secure origin (https or localhost).");
return;
}
try {
setVoiceStatus("Connecting…");
const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
const AC = window.AudioContext || window.webkitAudioContext;
const ctx = new AC();
const playCtx = new AC({ sampleRate: 24000 });
const playGain = playCtx.createGain(); playGain.gain.value = 1; playGain.connect(playCtx.destination);
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${proto}//${location.host}/api/realtime`);
voice.current = { ws, ctx, playCtx, playGain, stream, playTime: 0, asstBuf: "", asstId: null, paused: false, fcalls: {}, lastStep: null };
ws.onopen = () => {
setVoiceOn(true); setVoicePaused(false); setVoiceStatus("Listening…");
const st = live.current.step;
if (st) { sendVoice(voiceSession(st)); sendVoice({ type: "response.create" }); voice.current.lastStep = live.current.stepIndex; }
};
ws.onclose = () => stopVoice();
ws.onerror = () => setVoiceStatus("Voice error");
ws.onmessage = (e) => { try { handleVoiceEvent(JSON.parse(e.data)); } catch (x) {} };
const src = ctx.createMediaStreamSource(stream);
const proc = ctx.createScriptProcessor(4096, 1, 1);
proc.onaudioprocess = (ev) => {
if (!ws || ws.readyState !== 1 || voice.current.paused) return;
const ds = downsampleTo24k(ev.inputBuffer.getChannelData(0), ctx.sampleRate);
ws.send(JSON.stringify({ type: "input_audio_buffer.append", audio: pcm16ToBase64(ds) }));
};
const sink = ctx.createGain(); sink.gain.value = 0;
src.connect(proc); proc.connect(sink); sink.connect(ctx.destination);
Object.assign(voice.current, { src, proc, sink });
} catch (err) {
setVoiceStatus(err && err.name === "NotAllowedError" ? "Mic permission denied" : "Voice unavailable");
setVoiceOn(false);
}
};
useEffect(() => () => stopVoice(), []); // cleanup on unmount
// When the step changes while voice is on, re-point the advisor at the new
// step's fields and have it ask the first question.
useEffect(() => {
const v = voice.current;
if (voiceOn && v.ws && v.ws.readyState === 1 && v.lastStep !== stepIndex && live.current.step) {
setVoiceReview(false);
sendVoice(voiceSession(live.current.step));
sendVoice({ type: "response.create" });
v.lastStep = stepIndex;
}
}, [stepIndex, voiceOn]);
// Stop voice once the report is shown.
useEffect(() => { if (report) stopVoice(); }, [report]);
// Auto-fill company details from the UEN. When the UEN field becomes valid, look it
// up (debounced) and fill any EMPTY mapped fields (company name, subsector). Never
// overwrites what the user already typed. Refreshes the voice session so the advisor
// treats the filled fields as captured.
useEffect(() => {
if (!journey) return;
const st = journey.steps[stepIndex];
const uenField = (st && st.fields || []).find((f) => f.validate === "uen");
if (!uenField) return;
const val = String(values[uenField.name] || "").trim().toUpperCase();
if (!val || fieldError(uenField, val)) { // wait for a valid UEN
if (uenStatus) setUenStatus(""); uenLast.current = ""; return;
}
if (val === uenLast.current) return; // already looked this one up
const t = setTimeout(() => {
uenLast.current = val;
setUenStatus("looking");
api(`/api/uen-lookup?uen=${encodeURIComponent(val)}`).then((res) => {
if (res && res.found && res.fields) {
setValues((prev) => {
const nx = { ...prev };
Object.entries(res.fields).forEach(([k, v]) => {
const f = (st.fields || []).find((x) => x.name === k);
if (!f) return;
const cur = nx[k];
const empty = f.type === "multiselect" ? !(cur && cur.length) : !(cur && String(cur).trim());
if (!empty) return;
if (f.type === "select") { if ((f.options || []).includes(v)) nx[k] = v; }
else nx[k] = v;
});
return nx;
});
setUenStatus("found:" + (res.fields.company_name || ""));
setTimeout(() => { if (voice.current.ws && voice.current.ws.readyState === 1 && live.current.step) sendVoice(voiceSession(live.current.step)); }, 80);
} else if (res && res.building) { setUenStatus("indexing"); uenLast.current = ""; }
else setUenStatus("notfound");
}).catch(() => setUenStatus("notfound"));
}, 500);
return () => clearTimeout(t);
}, [journey, stepIndex, values]);
// Reverse lookup: when a company NAME is entered but the UEN is still empty, find
// the UEN from the ACRA registry (exact name match) and fill it in.
useEffect(() => {
if (!journey) return;
const st = journey.steps[stepIndex];
const nameField = (st && st.fields || []).find((f) => f.name === "company_name");
const uenField = (st && st.fields || []).find((f) => f.validate === "uen");
if (!nameField || !uenField) return;
const nm = String(values[nameField.name] || "").trim();
const uenVal = String(values[uenField.name] || "").trim().toUpperCase();
// Only search by name while the UEN is still empty/invalid.
if (!nm || nm.length < 4 || (uenVal && !fieldError(uenField, uenVal))) { if (nameStatus) setNameStatus(""); nameLast.current = ""; return; }
if (nm.toUpperCase() === nameLast.current) return;
const t = setTimeout(() => {
nameLast.current = nm.toUpperCase();
setNameStatus("looking");
api(`/api/uen-lookup?name=${encodeURIComponent(nm)}`).then((res) => {
if (res && res.found && res.fields && res.fields.uen) {
setValues((prev) => (prev[uenField.name] && String(prev[uenField.name]).trim()) ? prev : { ...prev, [uenField.name]: res.fields.uen });
setNameStatus("found:" + res.fields.uen);
} else if (res && res.building) { setNameStatus("indexing"); nameLast.current = ""; }
else setNameStatus(""); // stay quiet if no exact match (don't nag mid-typing)
}).catch(() => setNameStatus(""));
}, 700);
return () => clearTimeout(t);
}, [journey, stepIndex, values]);
useEffect(() => {
api(`/api/journey/${journeyId}`).then((j) => {
setJourney(j);
setTranscript([{ role: "assistant", content: j.greeting }]);
setValues(defaults(j.steps[0]));
});
}, [journeyId]);
// While the advisor is thinking/generating, keep the latest chat in view (bottom).
useEffect(() => {
if ((busy || genReport) && bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
}, [busy, genReport]);
// When a new step form / analysis / report appears, scroll it to the TOP so the
// user starts at the first field instead of the middle of the form.
useEffect(() => {
const b = bodyRef.current, an = anchorRef.current;
if (!b || !an) return;
requestAnimationFrame(() => { b.scrollTop = Math.max(0, an.offsetTop - 6); });
}, [stepIndex, report, genReport, journeyId]);
if (!journey) return
Loading advisor…
;
const steps = journey.steps;
const step = steps[stepIndex];
const kind = step ? (step.kind || "form") : "form";
const totalStops = steps.length + 1;
const progress = Math.round(((report ? totalStops : stepIndex + 1) / totalStops) * 100);
const stageLabel = report ? (journey.report_label || "Report") : step.label;
const goNext = (afterTranscript) => {
setStepError("");
if (stepIndex + 1 < steps.length) {
const ni = stepIndex + 1; setStepIndex(ni); setValues(defaults(steps[ni]));
} else {
runReport(afterTranscript || transcript);
}
};
// Format checks (e.g. UEN) across the step's fields. Returns "" or an error string.
const validationError = () => {
for (const f of (step.fields || [])) {
const e = fieldError(f, values[f.name]);
if (e) return e;
}
return "";
};
const submitStep = async () => {
// Gate: required file uploads must be present (with parsed text) before continuing.
const missing = (step.fields || []).filter(
(f) => f.type === "file" && f.required && !(values[f.name] && values[f.name].text && values[f.name].text.trim())
);
if (missing.length) {
setStepError(`Please upload ${missing.map((f) => f.label).join(" and ")} (a readable CV) to continue.`);
return;
}
const verr = validationError();
if (verr) { setStepError(verr); return; }
setStepError("");
setBusy(true);
collected.current = { ...collected.current, ...flatten(values) };
const summary = summarize(step, values);
const next = [...transcript, { role: "user", content: summary }];
setTranscript(next);
let res;
try {
res = await api("/api/chat", { method: "POST", body: JSON.stringify({ journey: journeyId, step: step.id, collected: collected.current, transcript: next, message: summary, provider }) });
} catch (e) { res = { reply: "Recorded. Let's continue." }; }
const after = [...next, { role: "assistant", content: res.reply }];
setTranscript(after);
setBusy(false);
goNext(after);
};
// Voice advance: move to the next step WITHOUT injecting the typed-flow summary
// message or a canned chat reply (the advisor already speaks). Keeps the
// transcript clean and correctly ordered. Returns {ok, msg} for the model.
const advanceVoiceStep = () => {
const missing = (step.fields || []).filter(
(f) => f.type === "file" && f.required && !(values[f.name] && values[f.name].text && values[f.name].text.trim())
);
if (missing.length) {
setVoiceReview(false);
return { ok: false, msg: `Ask the user to upload ${missing.map((f) => f.label).join(" and ")} before continuing.` };
}
const verr = validationError();
if (verr) { setStepError(verr); return { ok: false, msg: `${verr} Ask the user to provide a valid value.` }; }
setStepError("");
collected.current = { ...collected.current, ...flatten(values) };
goNext(transcript);
return { ok: true, msg: "Advanced to the next step." };
};
// Keep a fresh snapshot for voice callbacks (avoids stale closures in ws handlers).
live.current = { step, values, setValues, submitStep, advanceVoiceStep, stepIndex };
const continueAnalysis = (summary) => {
let after = transcript;
const s = typeof summary === "string" ? summary.trim() : "";
const label = step.label.replace(/^\d+\.\s*/, "");
after = [...transcript, { role: "assistant", content: s ? `${label} generated. ${s}` : `${label} generated.` }];
setTranscript(after);
goNext(after);
};
const runReport = async (full) => {
setGenReport(true);
let rep;
try {
rep = await api("/api/report", { method: "POST", body: JSON.stringify({ journey: journeyId, collected: collected.current, transcript: full, provider, analyses: analyses.current }) });
} catch (e) { rep = { title: "Report", executive_summary: "Could not generate the report.", surveys: [] }; }
setReport(rep);
setGenReport(false);
// Persist the full consultation + report, keyed by the email from the first form.
try {
const email = collected.current.email || "";
const s = await api("/api/session", { method: "POST", body: JSON.stringify({
journey: journeyId, title: rep.title, email, collected: collected.current, transcript: full, report: rep }) });
if (s && s.id) setSavedId(s.id);
} catch (e) { /* non-fatal */ }
};
return (