askexperts
Version:
AskExperts SDK: build and use AI experts - ask them questions and pay with bitcoin on an open protocol
725 lines • 34 kB
JavaScript
/**
* AskExpertsClient implementation for NIP-174
* Works in both browser and Node.js environments
*/
import { SimplePool } from "nostr-tools";
import { z } from "zod";
import { debugError } from "../common/debug.js";
import { parseBolt11 } from "../common/bolt11.js";
import { parseExpertProfile } from "../experts/utils/Nostr.js";
import { AskExpertsError, RelayError, TimeoutError, ExpertError, PaymentRejectedError, } from "./errors.js";
import { EVENT_KIND_ASK, EVENT_KIND_BID, EVENT_KIND_BID_PAYLOAD, EVENT_KIND_EXPERT_PROFILE, EVENT_KIND_PROMPT, EVENT_KIND_QUOTE, EVENT_KIND_PROOF, EVENT_KIND_REPLY, FORMAT_TEXT, METHOD_LIGHTNING, DEFAULT_DISCOVERY_RELAYS, DEFAULT_DISCOVERY_TIMEOUT, DEFAULT_FETCH_EXPERTS_TIMEOUT, DEFAULT_QUOTE_TIMEOUT, DEFAULT_REPLY_TIMEOUT, FORMAT_OPENAI, } from "../common/constants.js";
// No need to extend AskExpertParams as we've already updated it in types.ts
import { COMPRESSION_GZIP } from "../stream/compression.js";
import { getStreamFactory, createStreamMetadataEvent, parseStreamMetadataEvent, } from "../stream/index.js";
import { encrypt, decrypt, createEvent, generateRandomKeyPair, validateNostrEvent, } from "../common/crypto.js";
import { publishToRelays, subscribeToRelays, fetchFromRelays, waitForEvent, } from "../common/relay.js";
export class AskExpertsClient {
/**
* Creates a new AskExpertsClient instance
*
* @param options - Optional configuration
* @param options.onQuote - Default callback for handling quotes
* @param options.onPay - Default callback for handling payments
* @param options.compression - Custom compression implementation
* @param options.pool - SimplePool instance for relay operations
* @param options.discoveryRelays - Array of discovery relay URLs to use as fallback
*/
constructor(options) {
/**
* Zod schema for quote payload
*/
this.quotePayloadSchema = z.object({
invoices: z
.array(z.object({
method: z.string(),
unit: z.string(),
amount: z.number(),
invoice: z.string().optional(),
}))
.optional(),
error: z.string().optional(),
});
/**
* Zod schema for reply payload
*/
/**
* Zod schema for reply payload according to NIP-174
* A reply payload should have either an "error" field or a "content" field, but not both
*/
this.replyPayloadSchema = z
.object({
payload: z.any().optional(),
error: z.string().optional(),
})
.refine((data) => !(data.error && data.payload), {
message: "Reply payload cannot have both error and payload fields",
});
this.defaultOnQuote = options?.onQuote;
this.defaultOnPay = options?.onPay;
this.streamFactory = options?.streamFactory || getStreamFactory();
this.discoveryRelays = options?.discoveryRelays;
// Check if pool is provided or needs to be created internally
this.poolCreatedInternally = !options?.pool;
this.pool = options?.pool || new SimplePool();
}
/**
* Disposes of resources when the client is no longer needed
*/
[Symbol.dispose]() {
// Only destroy the pool if it was created internally
if (this.poolCreatedInternally) {
this.pool.destroy(); // Properly destroy the pool
}
}
/**
* Finds experts by publishing an ask event and collecting bids
*
* @param params - Parameters for finding experts
* @returns Promise resolving to array of Bid objects
*/
async findExperts(params) {
// Validate parameters
if (!params.summary || params.summary.trim() === "") {
throw new AskExpertsError("Summary is required");
}
if (!params.hashtags || params.hashtags.length === 0) {
throw new AskExpertsError("At least one hashtag is required");
}
// Set default values
const formats = params.formats || [FORMAT_TEXT];
// Set stream flag to true by default
const streamSupported = params.stream !== undefined ? params.stream : true;
const methods = params.methods || [METHOD_LIGHTNING];
const relays = params.relays || this.discoveryRelays || DEFAULT_DISCOVERY_RELAYS;
// Generate a random key pair for the ask
const { privateKey: askPrivkey, publicKey: askPubkey } = generateRandomKeyPair();
// Create tags for the ask event
const tags = [
...params.hashtags.map((tag) => ["t", tag]),
...formats.map((format) => ["f", format]),
...(streamSupported ? [["s", "true"]] : []),
...methods.map((method) => ["m", method]),
];
// Create and sign the ask event
const askEvent = createEvent(EVENT_KIND_ASK, params.summary, tags, askPrivkey);
// Publish the ask event to relays
const publishedRelays = await publishToRelays(askEvent, relays, this.pool, 5000);
if (publishedRelays.length === 0) {
throw new RelayError("Failed to publish ask event to any relay");
}
// Subscribe to bid events
const bids = [];
const seenPubkeys = new Set();
// Create a filter for bid events
const filter = {
kinds: [EVENT_KIND_BID],
"#e": [askEvent.id],
since: Math.floor(Date.now() / 1000) - 60, // Get events from the last minute
};
// Subscribe to bid events
const sub = subscribeToRelays([filter], publishedRelays, this.pool, {
onevent: async (event) => {
try {
// No need to validate events from relay - they're already validated
// Ensure it's tagging our ask
const eTag = event.tags.find((tag) => tag[0] === "e");
if (!eTag || eTag[1] !== askEvent.id) {
debugError("Bid event has wrong e-tag:", eTag);
return;
}
// Decrypt the bid payload
const decrypted = decrypt(event.content, event.pubkey, askPrivkey);
// Parse the decrypted content as a bid payload event
const bidPayloadEvent = JSON.parse(decrypted);
// Validate the bid payload event
if (!validateNostrEvent(bidPayloadEvent)) {
debugError("Invalid bid payload event:", bidPayloadEvent);
return;
}
// Check the kind
if (bidPayloadEvent.kind !== EVENT_KIND_BID_PAYLOAD) {
debugError("Invalid bid payload event kind:", bidPayloadEvent.kind);
return;
}
// Only accept one bid per expert pubkey
if (seenPubkeys.has(bidPayloadEvent.pubkey)) {
return;
}
// Extract relay URLs from the tags
const relayTags = bidPayloadEvent.tags.filter((tag) => tag[0] === "relay");
const bidRelays = relayTags.map((tag) => tag[1]);
if (bidRelays.length === 0) {
debugError("Bid payload event missing relay tags:", bidPayloadEvent);
return;
}
// Extract formats from the tags
const formatTags = bidPayloadEvent.tags.filter((tag) => tag[0] === "f");
const bidFormats = formatTags.map((tag) => tag[1]);
// Check if streaming is supported
const streamTag = bidPayloadEvent.tags.find((tag) => tag[0] === "s" && tag[1] === "true");
const bidStreamSupported = !!streamTag;
// Extract payment methods from the tags
const methodTags = bidPayloadEvent.tags.filter((tag) => tag[0] === "m");
const bidMethods = methodTags.map((tag) => tag[1]);
// Create a Bid object
const bid = {
id: event.id,
pubkey: bidPayloadEvent.pubkey,
payloadId: bidPayloadEvent.id,
offer: bidPayloadEvent.content,
relays: bidRelays,
formats: bidFormats,
stream: bidStreamSupported,
methods: bidMethods,
event,
payloadEvent: bidPayloadEvent,
};
// Add the bid to the array
bids.push(bid);
seenPubkeys.add(bidPayloadEvent.pubkey);
}
catch (error) {
debugError("Error processing bid event:", error);
}
},
});
// Wait for the specified timeout
await new Promise((resolve) => setTimeout(resolve, DEFAULT_DISCOVERY_TIMEOUT));
// Close the subscription
sub.close();
return bids;
}
/**
* Fetches expert profiles from relays
*
* @param params - Parameters for fetching expert profiles
* @returns Promise resolving to array of Expert objects
*/
async fetchExperts(params) {
// Validate parameters
if (!params.pubkeys || params.pubkeys.length === 0) {
throw new AskExpertsError("At least one pubkey is required");
}
// Set default values
const relays = params.relays || this.discoveryRelays || DEFAULT_DISCOVERY_RELAYS;
// Create a filter for expert profile events
const filter = {
kinds: [EVENT_KIND_EXPERT_PROFILE],
authors: params.pubkeys,
since: Math.floor(Date.now() / 1000) - 86400, // Get events from the last day
};
// Fetch expert profile events
const events = await fetchFromRelays(filter, relays, this.pool, DEFAULT_FETCH_EXPERTS_TIMEOUT);
// Process events into Expert objects
const experts = [];
const seenPubkeys = new Set();
for (const event of events) {
// Only take the newest event for each pubkey
if (seenPubkeys.has(event.pubkey)) {
continue;
}
const expert = parseExpertProfile(event);
if (expert) {
experts.push(expert);
seenPubkeys.add(event.pubkey);
}
}
return experts;
}
/**
* Helper function to calculate the size of content in bytes
*
* @param content - The content to measure
* @returns Size in bytes
*/
getContentSizeBytes(content) {
if (content instanceof Uint8Array) {
return content.length;
}
else if (typeof content === "string") {
return new TextEncoder().encode(content).length;
}
else {
// For objects or other types, stringify then encode
return new TextEncoder().encode(JSON.stringify(content)).length;
}
}
/**
* Sends a prompt to an expert
*
* @param expertPubkey - Expert's public key
* @param expertRelays - Expert's relays
* @param content - Content of the prompt
* @param format - Format of the prompt
* @param compr - Compression method to use
* @param promptPrivkey - Private key for the prompt
* @returns Promise resolving to a Prompt object
* @private
*/
async sendPrompt(expertPubkey, expertRelays, content, format, useStreaming, promptPrivkey) {
// Check if we should use streaming based on the useStreaming flag
// The content size check is now done in askExpert
const shouldUseStreaming = useStreaming;
// Create the prompt payload
const promptPayload = {
format,
};
if (!shouldUseStreaming) {
// We'll send content embedded in the prompt event
promptPayload.payload = content;
}
// Convert to JSON string
const promptPayloadStr = JSON.stringify(promptPayload);
// Prompt event payload
const encryptedContent = encrypt(promptPayloadStr, expertPubkey, promptPrivkey);
let promptEvent;
const publishPrompt = async (encryptedStreamMetadata) => {
// Create prompt event with stream tag
const promptEvent = createEvent(EVENT_KIND_PROMPT, encryptedContent, [
["p", expertPubkey],
["s", "true"], // Signal that client supports streaming replies
...(encryptedStreamMetadata
? [["stream", encryptedStreamMetadata]]
: []),
], promptPrivkey);
// Publish the prompt event
const publishedRelays = await publishToRelays(promptEvent, expertRelays, this.pool, 5000);
if (publishedRelays.length === 0) {
throw new RelayError("Failed to publish prompt event to any relay");
}
return promptEvent;
};
if (shouldUseStreaming) {
// Create stream metadata
const { privateKey: streamPrivkey, publicKey: streamPubkey } = generateRandomKeyPair();
// Binary?
const binary = content instanceof Uint8Array;
// Create stream metadata
const streamMetadata = {
streamId: streamPubkey,
relays: expertRelays,
encryption: "nip44",
compression: COMPRESSION_GZIP,
binary,
receiver_pubkey: expertPubkey,
version: "1",
};
// Create stream writer
const streamWriter = await this.streamFactory.createWriter(streamMetadata, this.pool, streamPrivkey);
// Create stream metadata event
const streamMetadataEvent = createStreamMetadataEvent(streamMetadata, streamPrivkey);
// Encrypt the stream metadata event
const encryptedStreamMetadata = encrypt(JSON.stringify(streamMetadataEvent), expertPubkey, promptPrivkey);
// Create prompt event with stream tag
promptEvent = await publishPrompt(encryptedStreamMetadata);
// Prepare payload, this should actually go along with 'format':
// - text => string
// - openai => json
// - other... how to know?
let payload;
if (binary)
payload = content;
else if (typeof content === "string")
payload = content;
else
payload = JSON.stringify(content);
// Write content to stream
await streamWriter.write(payload, true);
}
else {
// Create prompt event without stream
promptEvent = await publishPrompt();
}
// Create the Prompt object
const prompt = {
id: promptEvent.id,
expertPubkey,
format,
content,
stream: true, // Set the stream flag in the Prompt object
event: promptEvent,
context: undefined,
};
return prompt;
}
/**
* Fetches a quote from an expert
*
* @param promptId - Prompt event ID
* @param expertPubkey - Expert's public key
* @param promptPrivkey - Private key used for the prompt
* @param publishedRelays - Relays where the prompt was published
* @returns Promise resolving to Quote object
* @private
*/
async fetchQuote(promptId, expertPubkey, promptPrivkey, publishedRelays) {
// Create a filter for quote events
const quoteFilter = {
kinds: [EVENT_KIND_QUOTE],
"#e": [promptId],
authors: [expertPubkey],
};
// Wait for the quote event
const quoteEvent = await waitForEvent(quoteFilter, publishedRelays, this.pool, DEFAULT_QUOTE_TIMEOUT);
if (!quoteEvent) {
throw new TimeoutError("Timeout waiting for quote event");
}
// No need to validate events from relay - they're already validated
// Decrypt the quote payload
const decryptedQuote = decrypt(quoteEvent.content, expertPubkey, promptPrivkey);
try {
// Parse and validate the quote payload using Zod
const rawPayload = JSON.parse(decryptedQuote);
const quotePayload = this.quotePayloadSchema.parse(rawPayload);
// If there's an error in the quote payload, throw it
if (quotePayload.error) {
throw new ExpertError(`Expert error: ${quotePayload.error}`);
}
if (!quotePayload.invoices) {
throw new ExpertError(`Expert error: no invoices`);
}
// Create the Quote object
const quote = {
pubkey: expertPubkey,
promptId,
invoices: quotePayload.invoices,
event: quoteEvent,
};
return quote;
}
catch (error) {
if (error instanceof z.ZodError) {
throw new ExpertError(`Invalid quote payload: ${error.message}`);
}
throw error;
}
}
/**
* Sends an error proof to an expert and throws an error
*
* @param errorMessage - Error message
* @param expertPubkey - Expert's public key
* @param promptId - Prompt event ID
* @param promptPrivkey - Private key used for the prompt
* @param publishedRelays - Relays where the prompt was published
* @param error - Error to throw
* @throws The provided error
* @private
*/
async sendErrorProof(errorMessage, expertPubkey, promptId, promptPrivkey, relays) {
// Create the error proof payload
const errorProofPayload = {
error: errorMessage,
};
// Convert to JSON string
const errorProofStr = JSON.stringify(errorProofPayload);
// Encrypt the error proof payload
const encryptedErrorProof = encrypt(errorProofStr, expertPubkey, promptPrivkey);
// Create and sign the proof event with error
const errorProofEvent = createEvent(EVENT_KIND_PROOF, encryptedErrorProof, [
["p", expertPubkey],
["e", promptId],
], promptPrivkey);
// Publish the error proof event to the expert's relays
await publishToRelays(errorProofEvent, relays, this.pool, 5000);
}
/**
* Sends a proof to an expert
*
* @param proof - Proof object
* @param expertPubkey - Expert's public key
* @param promptId - Prompt event ID
* @param promptPrivkey - Private key used for the prompt
* @param publishedRelays - Relays where the prompt was published
* @returns Promise resolving to published relays
* @private
*/
async sendProof(proof, expertPubkey, promptId, promptPrivkey, publishedRelays) {
// Create the proof payload
const proofPayload = {
method: proof.method,
preimage: proof.preimage,
};
// Convert to JSON string
const proofPayloadStr = JSON.stringify(proofPayload);
// Encrypt the proof payload
const encryptedProof = encrypt(proofPayloadStr, expertPubkey, promptPrivkey);
// Create and sign the proof event
const proofEvent = createEvent(EVENT_KIND_PROOF, encryptedProof, [
["p", expertPubkey],
["e", promptId],
], promptPrivkey);
// Publish the proof event to the expert's relays
const proofPublishedRelays = await publishToRelays(proofEvent, publishedRelays, this.pool, 5000);
if (proofPublishedRelays.length === 0) {
throw new RelayError("Failed to publish proof event to any relay");
}
return proofPublishedRelays;
}
/**
* Creates a Replies object that handles reply events
*
* @param prompt - Prompt
* @param expertPubkey - Expert's public key
* @param promptPrivkey - Private key used for the prompt
* @param publishedRelays - Relays where the prompt was published
* @param compression - Compression instance
* @returns Replies object
* @private
*/
createRepliesHandler(prompt, expertPubkey, promptPrivkey, publishedRelays) {
// Get a reference to the replyPayloadSchema
const replyPayloadSchema = this.replyPayloadSchema;
// Get a reference to the streamFactory
const streamFactory = this.streamFactory;
// Get a reference to the pool
const pool = this.pool;
// Create the Replies object
const replies = {
promptId: prompt.id,
expertPubkey,
// Implement AsyncIterable interface
[Symbol.asyncIterator]: async function* () {
try {
// Create a filter for the single reply event
const replyFilter = {
kinds: [EVENT_KIND_REPLY],
"#e": [prompt.id],
authors: [expertPubkey],
};
// Wait for the single reply event
const event = await waitForEvent(replyFilter, publishedRelays, pool, DEFAULT_REPLY_TIMEOUT);
if (!event) {
throw new TimeoutError("Timeout waiting for reply event");
}
// Check if this is a streamed reply
const streamTag = event.tags.find((tag) => tag[0] === "stream");
if (streamTag) {
// Handle streamed reply
try {
// Decrypt the stream metadata
const decryptedStreamTag = decrypt(streamTag[1], expertPubkey, promptPrivkey);
// Parse the stream metadata event
const streamMetadataEvent = JSON.parse(decryptedStreamTag);
// Parse the stream metadata
const streamMetadata = parseStreamMetadataEvent(streamMetadataEvent);
streamMetadata.receiver_privkey = promptPrivkey;
// Create stream reader
const streamReader = await streamFactory.createReader(streamMetadata, pool);
// According to NIP-174, chunks are just raw data
// Process each chunk individually and return one reply per chunk
try {
// Process each chunk from the stream as it arrives
for await (const chunk of streamReader) {
if (prompt.format === FORMAT_OPENAI) {
if (typeof chunk !== "string")
throw new Error("String reply expected for OpenAI format");
// Parse the content from JSONL format
for (const line of chunk.split("\n")) {
if (!line.trim())
continue;
const reply = {
pubkey: expertPubkey,
promptId: prompt.id,
done: false,
content: JSON.parse(line),
event,
};
// Yield the reply for each chunk
yield reply;
}
}
else {
// Create a Reply object for each chunk
// The chunk itself is the content
const reply = {
pubkey: expertPubkey,
promptId: prompt.id,
done: false,
content: chunk,
event,
};
// Yield the reply for each chunk
yield reply;
}
}
// After all chunks are processed, yield a final reply with done=true
// This signals that the stream is complete
const finalReply = {
pubkey: expertPubkey,
promptId: prompt.id,
done: true,
content: "",
event,
};
yield finalReply;
}
catch (error) {
// If the stream reader throws, create an error reply
throw new ExpertError(`Error reading stream: ${error instanceof Error ? error.message : String(error)}`);
}
}
catch (error) {
if (error instanceof z.ZodError) {
throw new ExpertError(`Invalid reply payload: ${error.message}`);
}
throw error;
}
}
else {
// Handle regular reply
// Decrypt the reply payload
const decryptedReply = decrypt(event.content, expertPubkey, promptPrivkey);
// Parse directly (no compression in new spec)
const rawPayload = JSON.parse(decryptedReply);
const replyPayload = replyPayloadSchema.parse(rawPayload);
// Check if there's an error in the reply payload
if (replyPayload.error) {
throw new ExpertError(`Expert reply error: ${replyPayload.error}`);
}
// Create the Reply object
const reply = {
pubkey: expertPubkey,
promptId: prompt.id,
done: true, // single reply
content: replyPayload.payload,
event,
};
// Yield the reply
yield reply;
}
}
catch (error) {
debugError("Error processing reply event:", error);
throw error;
}
},
};
return replies;
}
/**
* Asks an expert a question and receives replies
*
* @param params - Parameters for asking an expert
* @returns Promise resolving to Replies object
*/
async askExpert(params) {
// Validate parameters
if (!params.expert && !params.bid) {
throw new AskExpertsError("Either expert or bid must be provided");
}
if (!params.content) {
throw new AskExpertsError("Content is required");
}
// Use the callbacks from params or the default callbacks from constructor
const onQuote = params.onQuote || this.defaultOnQuote;
const onPay = params.onPay || this.defaultOnPay;
// If no onQuote callback is available, throw an error
if (!onQuote) {
throw new Error("No onQuote callback provided");
}
// If no onPay callback is available, throw an error
if (!onPay) {
throw new Error("No onPay callback provided");
}
// Determine which expert to use
const expertPubkey = params.bid?.pubkey || params.expert?.pubkey;
const expertRelays = params.bid?.relays || params.expert?.relays || [];
if (!expertPubkey) {
throw new AskExpertsError("Expert pubkey is missing");
}
if (expertRelays.length === 0) {
throw new AskExpertsError("Expert relays are missing");
}
const supportedFormats = params.bid?.formats || params.expert?.formats || [];
const streamSupported = params.bid?.stream || params.expert?.stream || false;
// Determine format and streaming
// Assume the first supported format, fallback to text
const format = params.format || supportedFormats[0] || FORMAT_TEXT;
// Check if format is supported
if (supportedFormats.length > 0 && !supportedFormats.includes(format)) {
throw new AskExpertsError(`Format ${format} is not supported by the expert`);
}
// Check its size
const contentSize = this.getContentSizeBytes(params.content);
// Size threshold for streaming (48KB)
const SIZE_THRESHOLD = 48 * 1024;
// Determine if we need to use streaming based on content size
const needsStreaming = contentSize > SIZE_THRESHOLD;
// Check if streaming is supported when needed
if (needsStreaming && !streamSupported) {
throw new AskExpertsError(`Content size (${Math.round(contentSize / 1024)}KB) exceeds the limit (48KB) but streaming is not supported by the expert`);
}
// Check if streaming is requested but not supported
if (needsStreaming && !streamSupported) {
throw new AskExpertsError(`Streaming is not supported by the expert`);
}
// Generate a random key pair for the prompt
const { privateKey: promptPrivkey } = generateRandomKeyPair();
// Send the prompt to the expert
const prompt = await this.sendPrompt(expertPubkey, expertRelays, params.content, format, needsStreaming, promptPrivkey);
// Fetch the quote from the expert
const quote = await this.fetchQuote(prompt.id, expertPubkey, promptPrivkey, expertRelays);
// Call the onQuote callback
// Call the onQuote callback to determine if we should proceed with payment
let proof;
try {
// Validate the quote before calling onQuote
this.validateQuote(quote);
// onQuote returns a boolean indicating whether to proceed with payment
const shouldPay = await onQuote(quote, prompt);
if (shouldPay) {
// If payment is accepted, call onPay to get the proof
proof = await onPay(quote, prompt);
}
else {
// If payment is rejected, will send a proof with error and throw PaymentRejectedError
throw new PaymentRejectedError("Payment rejected by client");
}
}
catch (error) {
// If either callback throws an error, create a proof with an error message
// to inform the expert that payment failed
await this.sendErrorProof(
// NOTE: Do not expose wallet errors, this might reveal private info
"Payment failed", expertPubkey, prompt.id, promptPrivkey, expertRelays);
// Re-throw the error
throw error;
}
// Send the proof to the expert
await this.sendProof(proof, expertPubkey, prompt.id, promptPrivkey, expertRelays);
// Create and return the Replies object
return this.createRepliesHandler(prompt, expertPubkey, promptPrivkey, expertRelays);
}
/**
* Validates a quote by checking that all lightning invoices have matching amounts
*
* @param quote - The quote to validate
* @throws PaymentRejectedError if any invoice amount doesn't match the expected amount
*/
validateQuote(quote) {
// Find all lightning invoices in the quote
for (const invoice of quote.invoices) {
// Check if this is a lightning invoice with an invoice field
if (invoice.method === METHOD_LIGHTNING && invoice.invoice) {
try {
// Parse the invoice using parseBolt11
const parsedInvoice = parseBolt11(invoice.invoice);
// Check if the parsed amount matches the expected amount
if (parsedInvoice.amount_sats !== invoice.amount) {
throw new PaymentRejectedError(`Invoice amount mismatch: expected ${invoice.amount} sats, but invoice contains ${parsedInvoice.amount_sats} sats`);
}
}
catch (error) {
// If parsing fails, throw a PaymentRejectedError
if (error instanceof PaymentRejectedError) {
throw error;
}
throw new PaymentRejectedError(`Failed to validate invoice: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
}
}
//# sourceMappingURL=AskExpertsClient.js.map