mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
feat: widget chat with generative UI, branch selection, captcha gate, lead dedup
- Streaming AI chat via Vercel AI SDK v6 UI message stream — tool-based
generative UI (pick_branch, list_departments, show_clinic_timings,
show_doctors, show_doctor_slots, suggest_booking). Typing indicator,
markdown suppressed, text parts hidden when widgets are rendered.
- Centralized Preact store (store.tsx) for visitor, leadId, captchaToken,
bookingPrefill, doctors roster, branches, selectedBranch — replaces prop
drilling across chat/book/contact tabs.
- Cloudflare Turnstile captcha gate rendered via light-DOM portal so it
renders correctly inside the shadow DOM (Turnstile CSS doesn't cross
shadow boundaries).
- Lead dedup helper (findOrCreateLeadByPhone, 24h phone window) shared
across chat-start / book / contact so one visitor == one lead. Booking
upgrades existing lead status NEW → APPOINTMENT_SET via updateLeadStatus.
- Pre-chat name+phone form captures the visitor; chat transcript logged
to leadActivity records after each stream.
- Booking wizard gains a branch step 0 (skipped for single-branch
hospitals); departments + doctors filtered by selectedBranch. Chat slot
picks prefill the booking details step and lock the branch.
- Window-level captcha gate, modal maximize mode, header badge showing
selected branch, widget font inherits from host page (fix :host { all:
initial } override).
- 23 FA Pro 7.1 duotone icons bundled — medical departments, nav, actions,
hospital/location-dot for branch context.
- main.ts: resolve public/ from process.cwd() so widget.js serves in both
dev and prod. tsconfig: exclude widget-src/public/data from server tsc.
- captcha.guard: switch from reCAPTCHA v3 to Cloudflare Turnstile verify.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,23 @@
|
||||
import { Controller, Get, Post, Delete, Body, Query, Param, UseGuards, Logger, HttpException } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Delete, Body, Query, Param, Req, Res, UseGuards, Logger, HttpException } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { ModelMessage } from 'ai';
|
||||
import { WidgetService } from './widget.service';
|
||||
import { WidgetChatService } from './widget-chat.service';
|
||||
import { WidgetKeysService } from './widget-keys.service';
|
||||
import { WidgetKeyGuard } from './widget-key.guard';
|
||||
import { CaptchaGuard } from './captcha.guard';
|
||||
import type { WidgetBookRequest, WidgetLeadRequest } from './widget.types';
|
||||
|
||||
type ChatStartBody = { name?: string; phone?: string };
|
||||
type ChatStreamBody = { leadId?: string; messages?: ModelMessage[]; branch?: string | null };
|
||||
|
||||
@Controller('api/widget')
|
||||
export class WidgetController {
|
||||
private readonly logger = new Logger(WidgetController.name);
|
||||
|
||||
constructor(
|
||||
private readonly widget: WidgetService,
|
||||
private readonly chat: WidgetChatService,
|
||||
private readonly keys: WidgetKeysService,
|
||||
) {}
|
||||
|
||||
@@ -51,6 +58,97 @@ export class WidgetController {
|
||||
return this.widget.createLead(body);
|
||||
}
|
||||
|
||||
// Start (or resume) a chat session. Dedups by phone in the last 24h so a
|
||||
// single visitor who books + contacts + chats doesn't create three leads.
|
||||
// No CaptchaGuard: the window-level gate already verified humanity, and
|
||||
// Turnstile tokens are single-use so reusing them on every endpoint breaks
|
||||
// the multi-action flow.
|
||||
@Post('chat-start')
|
||||
@UseGuards(WidgetKeyGuard)
|
||||
async chatStart(@Body() body: ChatStartBody) {
|
||||
if (!body.name?.trim() || !body.phone?.trim()) {
|
||||
throw new HttpException('name and phone required', 400);
|
||||
}
|
||||
try {
|
||||
const leadId = await this.chat.findOrCreateLead(body.name.trim(), body.phone.trim());
|
||||
return { leadId };
|
||||
} catch (err: any) {
|
||||
this.logger.error(`chatStart failed: ${err?.message ?? err}`);
|
||||
throw new HttpException('Failed to start chat session', 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Stream the AI reply. Requires an active leadId from chat-start. The
|
||||
// conversation is logged to leadActivity after the stream completes so the
|
||||
// CC agent can review the transcript when they call the visitor back.
|
||||
@Post('chat')
|
||||
@UseGuards(WidgetKeyGuard)
|
||||
async chat_(@Req() req: Request, @Res() res: Response) {
|
||||
const body = req.body as ChatStreamBody;
|
||||
const leadId = body?.leadId?.trim();
|
||||
const messages = body?.messages ?? [];
|
||||
const selectedBranch = body?.branch?.trim() || null;
|
||||
if (!leadId) {
|
||||
res.status(400).json({ error: 'leadId required' });
|
||||
return;
|
||||
}
|
||||
if (!messages.length) {
|
||||
res.status(400).json({ error: 'messages required' });
|
||||
return;
|
||||
}
|
||||
if (!this.chat.hasAiModel()) {
|
||||
res.status(503).json({ error: 'AI not configured' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the last user message up-front so we can log it after the
|
||||
// stream finishes (reverse-walking messages is cheap).
|
||||
const lastUser = [...messages].reverse().find(m => m.role === 'user');
|
||||
const userText = typeof lastUser?.content === 'string'
|
||||
? lastUser.content
|
||||
: '';
|
||||
|
||||
// Fetch the visitor's first name from the lead so the AI can personalize.
|
||||
const userName = await this.chat.getLeadFirstName(leadId);
|
||||
|
||||
// SSE framing — each UIMessageChunk is serialized as a `data:` event.
|
||||
// See AI SDK v6 UI_MESSAGE_STREAM_HEADERS for the canonical values.
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.setHeader('X-Vercel-Ai-Ui-Message-Stream', 'v1');
|
||||
|
||||
let aiText = '';
|
||||
try {
|
||||
const systemPrompt = await this.chat.buildSystemPrompt(userName, selectedBranch);
|
||||
for await (const chunk of this.chat.streamReply(systemPrompt, messages)) {
|
||||
// Track accumulated text for transcript logging.
|
||||
if (chunk?.type === 'text-delta' && typeof chunk.delta === 'string') {
|
||||
aiText += chunk.delta;
|
||||
}
|
||||
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||
}
|
||||
res.write('data: [DONE]\n\n');
|
||||
res.end();
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Chat stream failed for lead ${leadId}: ${err?.message ?? err}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Chat failed' });
|
||||
} else {
|
||||
res.write(`data: ${JSON.stringify({ type: 'error', errorText: 'Chat failed' })}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fire-and-forget transcript logging. We intentionally do not await
|
||||
// this so the stream response is not delayed.
|
||||
if (userText && aiText) {
|
||||
void this.chat.logExchange(leadId, userText, aiText);
|
||||
}
|
||||
}
|
||||
|
||||
// Key management (admin endpoints)
|
||||
@Post('keys/generate')
|
||||
async generateKey(@Body() body: { hospitalName: string; allowedOrigins: string[] }) {
|
||||
|
||||
Reference in New Issue
Block a user