Skip to content
Draft
2 changes: 1 addition & 1 deletion src/Client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** THIS FILE IS MANUALLY MAINAINED: see .fernignore */
/** THIS FILE IS MANUALLY MAINTAINED: see .fernignore */

import * as environments from "./environments";
import * as core from "./core";
Expand Down
2 changes: 1 addition & 1 deletion src/api/resources/empathicVoice/client/Client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** THIS FILE IS MANUALLY MAINAINED: see .fernignore */
/** THIS FILE IS MANUALLY MAINTAINED: see .fernignore */

import * as environments from "../../../../environments";
import * as core from "../../../../core";
Expand Down
110 changes: 68 additions & 42 deletions src/api/resources/empathicVoice/resources/chat/client/Client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** THIS FILE IS MANUALLY MAINAINED: see .fernignore */
/** THIS FILE IS MANUALLY MAINTAINED: see .fernignore */
import * as environments from "../../../../../../environments";
import * as core from "../../../../../../core";
import qs from "qs";
Expand Down Expand Up @@ -28,69 +28,95 @@ export declare namespace Chat {
/** The ID of a chat group, used to resume a previous chat. */
resumedChatGroupId?: string;

/** A flag to enable verbose transcription. Set this query parameter to `true` to have unfinalized user transcripts be sent to the client as interim UserMessage messages. The [interim](/reference/empathic-voice-interface-evi/chat/chat#receive.User%20Message.interim) field on a [UserMessage](/reference/empathic-voice-interface-evi/chat/chat#receive.User%20Message.type) denotes whether the message is "interim" or "final." */
/**
* A flag to enable verbose transcription. Set this query parameter to `true` to have unfinalized user
* transcripts be sent to the client as interim `UserMessage` messages.
*
* The [interim](/reference/empathic-voice-interface-evi/chat/chat#receive.User%20Message.interim) field
* on a [UserMessage](/reference/empathic-voice-interface-evi/chat/chat#receive.User%20Message.type)
* denotes whether the message is "interim" or "final."
*/
verboseTranscription?: boolean;

/** Extra query parameters sent at WebSocket connection */
queryParams?: Record<string, string | string[] | object | object[]>;

/**
* Determines whether to resume the previous Chat context when reconnecting after specific types of
* disconnections. When `true`, upon reconnection after the WebSocket disconnects with one of the
* following [close codes](https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code#value):
* - `1006` (Abnormal Closure)
* - `1011` (Internal Error)
* - `1012` (Service Restart)
* - `1013` (Try Again Later)
* - `1014` (Bad Gateway)
*
* The SDK will use the `chat_group_id` from the disconnected session to restore the conversation history
* and context. This preserves the continuity of the conversation across connections. Defaults to `false`.
*/
shouldResumeChatOnReconnect?: boolean;
}
}

export class Chat {
constructor(protected readonly _options: Chat.Options) {}

public connect(args: Chat.ConnectArgs = {}): ChatSocket {
const queryParams: Record<string, string | string[] | object | object[]> = {};
const shouldResumeChatOnReconnect = args.shouldResumeChatOnReconnect ?? false;
const url = this._buildSocketUrl(args);
const options: core.Options = {
debug: args.debug ?? false,
maxRetries: args.reconnectAttempts ?? 30,
shouldAttemptReconnectHook: (event) => Chat._staticShouldAttemptReconnectEvi(event),
};
const socket = new core.ReconnectingWebSocket(url, [], options);
return new ChatSocket({ socket, shouldResumeChatOnReconnect });
}

queryParams["fernSdkLanguage"] = "JavaScript";
queryParams["fernSdkVersion"] = SDK_VERSION;
private static _staticShouldAttemptReconnectEvi({ code }: core.CloseEvent): boolean {
const abnormalCloseCodes: Set<number> = new Set([1006, 1011, 1012, 1013, 1014]);
return abnormalCloseCodes.has(code);
}

private _buildSocketUrl(args: Chat.ConnectArgs): string {
const baseParams = {
fernSdkLanguage: "JavaScript",
fernSdkVersion: SDK_VERSION,
};

let authParam = {};
if (this._options.accessToken != null) {
queryParams["accessToken"] = this._options.accessToken;
authParam = { accessToken: this._options.accessToken };
} else if (this._options.apiKey != null) {
queryParams["apiKey"] = this._options.apiKey;
authParam = { apiKey: this._options.apiKey };
}

if (args.configId !== null && args.configId !== undefined && args.configId !== "") {
queryParams["config_id"] = args.configId;
const optionalParams: Record<string, string> = {};
if (args.configId != null && args.configId !== "") {
optionalParams.config_id = args.configId;
}

if (args.configVersion !== null && args.configVersion !== undefined && args.configVersion !== "") {
queryParams["config_version"] = args.configVersion;
if (args.configVersion != null && args.configVersion !== "") {
optionalParams.config_version = args.configVersion;
}

if (
args.resumedChatGroupId !== null &&
args.resumedChatGroupId !== undefined &&
args.resumedChatGroupId !== ""
) {
queryParams["resumed_chat_group_id"] = args.resumedChatGroupId;
if (args.resumedChatGroupId != null && args.resumedChatGroupId !== "") {
optionalParams.resumed_chat_group_id = args.resumedChatGroupId;
}

if (args.verboseTranscription !== null) {
queryParams["verbose_transcription"] = args.verboseTranscription ? "true" : "false";
if (args.verboseTranscription != null) {
optionalParams.verbose_transcription = String(args.verboseTranscription);
}

if (args.queryParams !== null && args.queryParams !== undefined) {
for (const [name, value] of Object.entries(args.queryParams)) {
queryParams[name] = value;
}
}
const additionalParams = args.queryParams ?? {};

const queryParams = {
...baseParams,
...authParam,
...optionalParams,
...additionalParams,
};

const socket = new core.ReconnectingWebSocket(
`wss://${(core.Supplier.get(this._options.environment) ?? environments.HumeEnvironment.Production).replace(
"https://",
"",
)}/v0/evi/chat?${qs.stringify(queryParams)}`,
[],
{
debug: args.debug ?? false,
maxRetries: args.reconnectAttempts ?? 30,
},
);

return new ChatSocket({
socket,
});
const baseUrl = core.Supplier.get(this._options.environment) ?? environments.HumeEnvironment.Production;
const host = baseUrl.replace("https://", "");
const queryString = qs.stringify(queryParams, { addQueryPrefix: true });
return `wss://${host}/v0/evi/chat${queryString}`;
}
}
23 changes: 17 additions & 6 deletions src/api/resources/empathicVoice/resources/chat/client/Socket.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** THIS FILE IS MANUALLY MAINAINED: see .fernignore */
/** THIS FILE IS MANUALLY MAINTAINED: see .fernignore */
import * as core from "../../../../../../core";
import * as errors from "../../../../../../errors";
import * as Hume from "../../../../../index";
Expand All @@ -7,6 +7,7 @@ import * as serializers from "../../../../../../serialization/index";
export declare namespace ChatSocket {
interface Args {
socket: core.ReconnectingWebSocket;
shouldResumeChatOnReconnect?: boolean;
}

type Response = Hume.empathicVoice.SubscribeEvent & { receivedAt: Date };
Expand All @@ -24,8 +25,11 @@ export class ChatSocket {

protected readonly eventHandlers: ChatSocket.EventHandlers = {};

constructor({ socket }: ChatSocket.Args) {
private readonly _shouldResumeChatOnReconnect: boolean;

constructor({ socket, shouldResumeChatOnReconnect }: ChatSocket.Args) {
this.socket = socket;
this._shouldResumeChatOnReconnect = shouldResumeChatOnReconnect ?? false;

this.socket.addEventListener("open", this.handleOpen);
this.socket.addEventListener("message", this.handleMessage);
Expand Down Expand Up @@ -220,10 +224,17 @@ export class ChatSocket {
breadcrumbsPrefix: ["response"],
});
if (parsedResponse.ok) {
this.eventHandlers.message?.({
...parsedResponse.value,
receivedAt: new Date(),
});
const message = parsedResponse.value;
/**
* When shouldResumeChat is true, extract the chatGroupId from the chat_metadata message received at
* the start of the Chat session and add the "resumed_chat_group_id" query param to the url query param
* overrides to support resuming the Chat (preserving context from the disconnected chat) when
* reconnecting after an unexpected disconnect.
*/
if (message.type === "chat_metadata" && this._shouldResumeChatOnReconnect) {
this.socket.setQueryParamOverride("resumed_chat_group_id", message.chatGroupId);
}
this.eventHandlers.message?.({ ...message, receivedAt: new Date() });
} else {
this.eventHandlers.error?.(new Error(`Received unknown message type`));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
/** THIS FILE IS MANUALLY MAINAINED: see .fernignore */
/** THIS FILE IS MANUALLY MAINTAINED: see .fernignore */
export { ChatSocket } from "./Socket";
export { Chat } from "./Client";
2 changes: 1 addition & 1 deletion src/api/resources/empathicVoice/resources/chat/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
/** THIS FILE IS MANUALLY MAINAINED: see .fernignore */
/** THIS FILE IS MANUALLY MAINTAINED: see .fernignore */
export * from "./types";
export * from "./client";
2 changes: 1 addition & 1 deletion src/core/fetcher/Supplier.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** THIS FILE IS MANUALLY MAINAINED: see .fernignore */
/** THIS FILE IS MANUALLY MAINTAINED: see .fernignore */
export type Supplier<T> = T | (() => T);

export const Supplier = {
Expand Down
2 changes: 1 addition & 1 deletion src/core/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** THIS FILE IS MANUALLY MAINAINED: see .fernignore */
/** THIS FILE IS MANUALLY MAINTAINED: see .fernignore */
export * from "./streaming-fetcher";
export * from "./fetcher";
export * from "./runtime";
Expand Down
2 changes: 1 addition & 1 deletion src/core/websocket/events.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** THIS FILE IS MANUALLY MAINAINED: see .fernignore */
/** THIS FILE IS MANUALLY MAINTAINED: see .fernignore */
export class Event {
public target: any;
public type: string;
Expand Down
2 changes: 1 addition & 1 deletion src/core/websocket/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
/** THIS FILE IS MANUALLY MAINAINED: see .fernignore */
/** THIS FILE IS MANUALLY MAINTAINED: see .fernignore */
export * from "./ws";
52 changes: 50 additions & 2 deletions src/core/websocket/ws.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** THIS FILE IS MANUALLY MAINAINED: see .fernignore */
/** THIS FILE IS MANUALLY MAINTAINED: see .fernignore */
import { RUNTIME } from "../runtime";
import * as Events from "./events";
import { WebSocket as NodeWebSocket } from "ws";
Expand Down Expand Up @@ -33,6 +33,7 @@ export type Options = {
maxEnqueuedMessages?: number;
startClosed?: boolean;
debug?: boolean;
shouldAttemptReconnectHook?: (event: CloseEvent) => boolean;
};

const DEFAULT = {
Expand Down Expand Up @@ -74,6 +75,7 @@ export class ReconnectingWebSocket {
private _binaryType: BinaryType = "blob";
private _closeCalled = false;
private _messageQueue: Message[] = [];
private _queryParamOverrides = new Map<string, string>();

private readonly _url: UrlProvider;
private readonly _protocols?: string | string[];
Expand Down Expand Up @@ -298,6 +300,36 @@ export class ReconnectingWebSocket {
}
}

/**
* Sets or removes a query parameter to be automatically applied to the URL just before connection attempts.
* Setting a parameter here overrides any parameter with the same key that might be present in the original URL provider.
*
* @param key The query parameter key.
* @param value The value to set. If null, the override for this key is removed.
*/
public setQueryParamOverride(key: string, value: string | null): void {
if (value === null) {
if (this._queryParamOverrides.delete(key)) {
this._debug(`Removed query parameter override for "${key}"`);
}
} else {
this._debug(`Setting query parameter override: ${key}=${value}`);
this._queryParamOverrides.set(key, value);
}
}

private _applyQueryParamOverrides(url: string) {
let finalUrlString = url;
if (this._queryParamOverrides.size > 0) {
const finalUrl = new URL(url);
for (const [key, value] of this._queryParamOverrides.entries()) {
finalUrl.searchParams.set(key, value);
}
finalUrlString = finalUrl.toString();
}
return finalUrlString;
}

private _debug(...args: any[]) {
if (this._options.debug) {
// not using spread because compiled version uses Symbols
Expand Down Expand Up @@ -372,6 +404,7 @@ export class ReconnectingWebSocket {
}
this._wait()
.then(() => this._getNextUrl(this._url))
.then((url) => this._applyQueryParamOverrides(url))
.then((url) => {
// close could be called before creating the ws
if (this._closeCalled) {
Expand Down Expand Up @@ -469,10 +502,25 @@ export class ReconnectingWebSocket {
this._debug("close event");
this._clearTimeouts();

let finalShouldReconnect = this._shouldReconnect;

if (event.code === 1000) {
this._shouldReconnect = false;
finalShouldReconnect = false;
}

if (finalShouldReconnect && this._options.shouldAttemptReconnectHook) {
try {
if (!this._options.shouldAttemptReconnectHook(event)) {
finalShouldReconnect = false;
}
} catch (e) {
console.error("Error executing shouldAttemptReconnectHook:", e);
finalShouldReconnect = false;
}
}

this._shouldReconnect = finalShouldReconnect;

if (this._shouldReconnect) {
this._connect();
}
Expand Down
2 changes: 1 addition & 1 deletion src/errors/HumeError.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** THIS FILE IS MANUALLY MAINAINED: see .fernignore */
/** THIS FILE IS MANUALLY MAINTAINED: see .fernignore */
export class HumeError extends Error {
readonly statusCode?: number;
readonly body?: unknown;
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** THIS FILE IS MANUALLY MAINAINED: see .fernignore */
/** THIS FILE IS MANUALLY MAINTAINED: see .fernignore */
export * as Hume from "./api";
export * from "./wrapper";
export { HumeEnvironment } from "./environments";
Expand Down
2 changes: 1 addition & 1 deletion tests/empathicVoice/chat.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** THIS FILE IS MANUALLY MAINAINED: see .fernignore */
/** THIS FILE IS MANUALLY MAINTAINED: see .fernignore */
import { HumeClient } from "../../src/";

describe("Empathic Voice Interface", () => {
Expand Down
2 changes: 1 addition & 1 deletion tests/expressionMeasurement/batch.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** THIS FILE IS MANUALLY MAINAINED: see .fernignore */
/** THIS FILE IS MANUALLY MAINTAINED: see .fernignore */
import { HumeClient } from "../../src/";

describe("Streaming Expression Measurement", () => {
Expand Down