All files / src/app/extensions/copilot-embedded/services/copilot-embedded-api copilot-embedded-api.service.ts

91.46% Statements 75/82
68.57% Branches 24/35
96% Functions 24/25
92.1% Lines 70/76

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 2017x 7x 7x 7x   7x   7x               10x 10x 10x                     8x   8x 8x   8x 8x       8x 8x     8x 1x     7x 7x 7x 7x                               7x   9x 9x                 2x                   1x                       5x             8x       8x 1x   7x                 5x 5x   5x               5x   8x 1x 4x     5x               5x 5x 1x 1x   4x         4x 4x 4x     4x 14x 14x 8x 8x 8x 8x 8x   8x       4x 14x 14x   4x 4x 4x 4x     10x 10x   10x     4x   4x 4x          
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, from } from 'rxjs';
import { concatMap, map, switchMap, take } from 'rxjs/operators';
 
import { whenTruthy } from 'ish-core/utils/operators';
 
import { CopilotEmbeddedFacade } from '../../facades/copilot-embedded.facade';
import { CopilotEmbeddedStreamEvent } from '../../models/copilot-embedded-answer/copilot-embedded-answer.interface';
 
/**
 * Converts the SSE line endings CRLF and CR to LF. A trailing CR is kept, as it may be the first half of a CRLF
 * that is split across two chunks; it is converted together with the next chunk.
 */
function normalizeLineEndings(text: string): string {
  const pendingCr = text.endsWith('\r');
  const normalized = (pendingCr ? text.slice(0, -1) : text).replace(/\r\n?/g, '\n');
  return pendingCr ? `${normalized}\r` : normalized;
}
 
/**
 * Parses a single raw SSE event block into a {@link CopilotEmbeddedStreamEvent}.
 *
 * Flowise wraps the payload as JSON in the `data:` field, e.g. `data:{"event":"token","data":"Hi"}`.
 * Falls back to the SSE `event:` field and raw string data if the payload is not JSON.
 */
function parseSseEvent(rawEvent: string): CopilotEmbeddedStreamEvent {
  let eventName: string;
  const dataLines: string[] = [];
 
  for (const line of rawEvent.split('\n')) {
    Iif (line.startsWith('event:')) {
      eventName = line.slice(6).trim();
    } else Eif (line.startsWith('data:')) {
      dataLines.push(line.slice(5).trim());
    }
  }
 
  const dataString = dataLines.join('\n');
  Iif (!dataString && !eventName) {
    return;
  }
  if (dataString === '[DONE]') {
    return { event: 'end', data: undefined };
  }
 
  try {
    const parsed = JSON.parse(dataString);
    Eif (parsed && typeof parsed === 'object' && 'event' in parsed) {
      return parsed as CopilotEmbeddedStreamEvent;
    }
    return { event: eventName ?? 'message', data: parsed };
  } catch {
    return { event: eventName ?? 'message', data: dataString };
  }
}
 
/**
 * The Copilot Embedded API Service handles the HTTP interaction with the Flowise API of the configured chatflow.
 *
 * Every request targets a chatflow-scoped endpoint `{apiHost}/api/v1/{endpoint}/{chatflowid}`; `apiHost` and
 * `chatflowid` are read from the Copilot Embedded configuration. Besides plain `get` and `post` requests it
 * supports streamed responses via Server-Sent Events ({@link stream}).
 */
@Injectable({ providedIn: 'root' })
export class CopilotEmbeddedApiService {
  constructor(
    private httpClient: HttpClient,
    private copilotEmbeddedFacade: CopilotEmbeddedFacade
  ) {}
 
  /**
   * Sends a GET request to the given chatflow endpoint.
   *
   * @param endpoint  The Flowise endpoint without the chatflow ID, e.g. `chatflows-streaming`.
   */
  get<T>(endpoint: string): Observable<T> {
    return this.url$(endpoint).pipe(concatMap(url => this.httpClient.get<T>(url)));
  }
 
  /**
   * Sends a POST request to the given chatflow endpoint.
   *
   * @param endpoint  The Flowise endpoint without the chatflow ID, e.g. `prediction`.
   * @param body      The request body.
   */
  post<T>(endpoint: string, body: unknown): Observable<T> {
    return this.url$(endpoint).pipe(concatMap(url => this.httpClient.post<T>(url, body)));
  }
 
  /**
   * Sends a POST request to the given chatflow endpoint and emits the Server-Sent Events of the response.
   *
   * Completes when the stream ends and errors on network/HTTP failures. Streaming only works in the browser.
   *
   * @param endpoint  The Flowise endpoint without the chatflow ID, e.g. `prediction`.
   * @param body      The request body.
   */
  stream(endpoint: string, body: unknown): Observable<CopilotEmbeddedStreamEvent> {
    return this.url$(endpoint).pipe(concatMap(url => this.fetchEventStream(url, body)));
  }
 
  /**
   * Builds the URL of a chatflow-scoped endpoint from the Copilot Embedded configuration.
   */
  private url$(endpoint: string): Observable<string> {
    return this.copilotEmbeddedFacade.configuration$.pipe(
      whenTruthy(),
      take(1),
      map(config => {
        if (!config.apiHost || !config.chatflowid) {
          throw new Error('Copilot Embedded is not configured (apiHost and chatflowid are required)');
        }
        return `${config.apiHost}/api/v1/${endpoint}/${config.chatflowid}`;
      })
    );
  }
 
  /**
   * Performs the streaming request via the fetch API and parses the Server-Sent Events.
   */
  private fetchEventStream(url: string, body: unknown): Observable<CopilotEmbeddedStreamEvent> {
    return new Observable<CopilotEmbeddedStreamEvent>(subscriber => {
      const abortController = new AbortController();
 
      from(
        fetch(url, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
          body: JSON.stringify(body),
          signal: abortController.signal,
        })
      )
        .pipe(switchMap(response => this.readEventStream(response)))
        .subscribe({
          next: event => subscriber.next(event),
          error: error => subscriber.error(error),
          complete: () => subscriber.complete(),
        });
 
      return () => abortController.abort();
    });
  }
 
  /**
   * Reads a `text/event-stream` response body and emits the parsed Copilot Embedded events.
   */
  private readEventStream(response: Response): Observable<CopilotEmbeddedStreamEvent> {
    return new Observable<CopilotEmbeddedStreamEvent>(subscriber => {
      if (!response.ok) {
        subscriber.error(new Error(`Copilot Embedded request failed with status ${response.status}`));
        return;
      }
      Iif (!response.body) {
        subscriber.error(new Error('Copilot Embedded response does not provide a readable stream'));
        return;
      }
 
      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let buffer = '';
 
      // SSE events are separated by a blank line.
      const emitCompleteEvents = () => {
        let boundary = buffer.indexOf('\n\n');
        while (boundary !== -1) {
          const rawEvent = buffer.slice(0, boundary);
          buffer = buffer.slice(boundary + 2);
          const event = parseSseEvent(rawEvent);
          Eif (event) {
            subscriber.next(event);
          }
          boundary = buffer.indexOf('\n\n');
        }
      };
 
      const pump = (): Promise<void> =>
        reader.read().then(({ done, value }) => {
          if (done) {
            // a CR kept back for a possible CRLF is a line ending once the stream has ended
            buffer = buffer.replace(/\r$/, '\n');
            emitCompleteEvents();
            subscriber.complete();
            return;
          }
 
          buffer = normalizeLineEndings(buffer + decoder.decode(value, { stream: true }));
          emitCompleteEvents();
 
          return pump();
        });
 
      pump().catch(error => subscriber.error(error));
 
      return () => {
        reader.cancel();
      };
    });
  }
}