flowiseClient.ts 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
  1. import { config } from '../config.js';
  2. import { logger } from '../logger.js';
  3. import { upstream } from '../errors.js';
  4. import { request, safeLabel } from './httpClient.js';
  5. export interface FlowisePayload {
  6. question: string;
  7. overrideConfig: Record<string, unknown>;
  8. metadata: Record<string, unknown>;
  9. }
  10. export interface FlowiseResult {
  11. type: 'reply' | 'handoff' | 'unknown';
  12. text: string | null;
  13. actions: unknown[] | null;
  14. }
  15. export class FlowiseClient {
  16. private readonly predictUrl: string;
  17. private readonly apiKey: string;
  18. private readonly timeoutMs: number;
  19. constructor() {
  20. const cfg = config();
  21. this.predictUrl = cfg.FLOWISE_PREDICT_URL;
  22. this.apiKey = cfg.FLOWISE_API_KEY;
  23. this.timeoutMs = cfg.FLOWISE_TIMEOUT_MS;
  24. }
  25. async predict(payload: FlowisePayload): Promise<FlowiseResult> {
  26. const headers: Record<string, string> = {};
  27. // The PHP relay shipped a literal `Bearer ***` placeholder here; send the
  28. // real key (and only when one is configured).
  29. if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
  30. logger.info('Calling Flowise', { target: safeLabel(this.predictUrl) });
  31. const res = await request<Record<string, unknown>>(this.predictUrl, {
  32. method: 'POST',
  33. headers,
  34. json: payload,
  35. timeoutMs: this.timeoutMs,
  36. label: 'flowise predict',
  37. });
  38. if (res.status >= 400) {
  39. logger.error('Flowise error response', { status: res.status });
  40. throw upstream('FLOWISE_ERROR', `Flowise returned HTTP ${res.status}.`);
  41. }
  42. return normaliseFlowiseResponse(res.json, res.body);
  43. }
  44. /** Health probe against the Flowise API root; used by /ready only. */
  45. async ping(): Promise<{ ok: boolean; status: number; target: string }> {
  46. const cfg = config();
  47. const base = cfg.FLOWISE_BASE_URL || deriveBaseUrl(this.predictUrl);
  48. if (!base) return { ok: false, status: 0, target: 'not-configured' };
  49. const url = `${base.replace(/\/+$/, '')}/api/v1/ping`;
  50. try {
  51. const headers: Record<string, string> = {};
  52. if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
  53. const res = await request(url, { headers, label: 'flowise ping', timeoutMs: 5_000 });
  54. return { ok: res.status < 400, status: res.status, target: safeLabel(url) };
  55. } catch {
  56. return { ok: false, status: 0, target: safeLabel(url) };
  57. }
  58. }
  59. }
  60. function deriveBaseUrl(predictUrl: string): string {
  61. try {
  62. const u = new URL(predictUrl);
  63. return `${u.protocol}//${u.host}`;
  64. } catch {
  65. return '';
  66. }
  67. }
  68. /**
  69. * Flowise answers in several shapes depending on chatflow/version:
  70. * - a bare text body,
  71. * - `{ text | response | answer }`,
  72. * - the same plus `actions: [{ type: 'handoff' }]`.
  73. */
  74. export function normaliseFlowiseResponse(json: unknown, rawBody: string): FlowiseResult {
  75. if (json && typeof json === 'object' && !Array.isArray(json)) {
  76. const obj = json as Record<string, unknown>;
  77. const candidate = obj.text ?? obj.response ?? obj.answer;
  78. const text = typeof candidate === 'string' ? candidate : null;
  79. const actions = Array.isArray(obj.actions) ? (obj.actions as unknown[]) : null;
  80. if (actions) {
  81. const handoff = actions.some(
  82. (a) =>
  83. a !== null &&
  84. typeof a === 'object' &&
  85. (a as Record<string, unknown>).type === 'handoff',
  86. );
  87. if (handoff) return { type: 'handoff', text, actions };
  88. }
  89. if (text !== null && text.trim() !== '') return { type: 'reply', text, actions };
  90. }
  91. const body = rawBody.trim();
  92. // A plain-text body is the answer itself, but only when it is not JSON we
  93. // already failed to make sense of.
  94. if (body !== '' && json === null) return { type: 'reply', text: body, actions: null };
  95. return { type: 'unknown', text: null, actions: null };
  96. }