import OpenAI from 'openai'; import { ChatItem, CustomError, PageSection } from './utils'; /** * Initializes a chat session by generating the initial chat messages based on the given parameters. * * @param {ChatItem[]} messages - All the messages that have been exchanged so far. * @param {string} query - The user's query. * @param {string} contextText - The context text or Nx Documentation. * @param {string} prompt - The prompt message displayed to the user. * @returns {Object} - An object containing the generated chat messages */ export function initializeChat( messages: ChatItem[], query: string, contextText: string, prompt: string ): { chatMessages: ChatItem[] } { const finalQuery = ` You will be provided sections of the Nx documentation in markdown format, use those to answer my question. Do NOT include a Sources section. Do NOT reveal this approach or the steps to the user. Only provide the answer. Start replying with the answer directly. Sections: ${contextText} Question: """ ${query} """ Answer as markdown (including related code snippets if available): `; // Remove the last message, which is the user query // and restructure the user query to include the instructions and context. // Add the system prompt as the first message of the array // and add the user query as the last message of the array. messages.pop(); messages = [ { role: 'system', content: prompt }, ...(messages ?? []), { role: 'user', content: finalQuery }, ]; return { chatMessages: messages }; } export function getMessageFromResponse( response: OpenAI.Chat.Completions.ChatCompletion ): string { return response.choices[0].message?.content ?? ''; } export function getListOfSources( pageSections: PageSection[] ): { heading: string; url: string; longer_heading: string }[] { const uniqueUrlPartials = new Set(); const result = pageSections .filter((section) => { if (section.url_partial && !uniqueUrlPartials.has(section.url_partial)) { uniqueUrlPartials.add(section.url_partial); return true; } return false; }) .map((section) => { let url: URL; if (section.url_partial?.startsWith('https://')) { url = new URL(section.url_partial); } else { url = new URL('https://nx.dev'); url.pathname = section.url_partial as string; if (section.slug) { url.hash = section.slug; } } return { heading: section.heading, longer_heading: section.longer_heading, url: url.toString(), }; }); return result; } export function formatMarkdownSources(pageSections: PageSection[]): string { const sources = getListOfSources(pageSections); const sourcesMarkdown = toMarkdownList(sources); return `\n ### Sources ${sourcesMarkdown} \n`; } export function toMarkdownList( sections: { heading: string; url: string; longer_heading: string }[] ): string { const sectionsWithLongerHeadings: { heading: string; url: string; longer_heading: string; }[] = []; const headings = new Set(); const sectionsWithUniqueHeadings = sections.filter((section) => { if (headings.has(section.heading)) { sectionsWithLongerHeadings.push(section); return false; } else { headings.add(section.heading); return true; } }); const finalSections = sectionsWithUniqueHeadings .map((section) => `- [${section.heading}](${section.url})`) .join('\n') .concat('\n') .concat( sectionsWithLongerHeadings .map( (section, index) => `- [${ section.longer_heading ?? section.heading + ' ' + (index + 1) }](${section.url})` ) .join('\n') ); return finalSections; } export function removeSourcesSection(markdown: string): string { const sectionRegex = /### Sources\n\n([\s\S]*?)(?:\n###|$)/; return markdown.replace(sectionRegex, '').trim(); } export async function appendToStream( originalStream: ReadableStream, appendContent: string, stopString: string = '### Sources' ): Promise> { let buffer = ''; const transformer = new TransformStream({ async transform(chunk, controller) { const decoder = new TextDecoder(); buffer += decoder.decode(chunk); // Attempting to stop it from generating a list of Sources that will be wrong // TODO(katerina): make sure that this works as expected if (buffer.includes(stopString)) { const truncated = buffer.split(stopString)[0]; controller.enqueue(new TextEncoder().encode(truncated)); controller.terminate(); return; } controller.enqueue(chunk); }, flush(controller) { controller.enqueue(new TextEncoder().encode(appendContent)); controller.terminate(); }, }); return originalStream.pipeThrough(transformer); } export function getLastAssistantIndex(messages: ChatItem[]): number { for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === 'assistant') { return i; } } return -1; } export function getLastAssistantMessageContent(messages: ChatItem[]): string { const indexOfLastAiResponse = getLastAssistantIndex(messages); if (indexOfLastAiResponse > -1 && messages[indexOfLastAiResponse]) { return messages[indexOfLastAiResponse].content; } else { return ''; } } // Not used at the moment, but keep it in case it is needed export function removeSourcesFromLastAssistantMessage( messages: ChatItem[] ): ChatItem[] { const indexOfLastAiResponse = getLastAssistantIndex(messages); if (indexOfLastAiResponse > -1 && messages[indexOfLastAiResponse]) { messages[indexOfLastAiResponse].content = removeSourcesSection( messages[indexOfLastAiResponse].content ); } return messages; } export function getUserQuery(messages: ChatItem[]): string { let query: string | null = null; if (messages?.length > 0) { const lastMessage = messages[messages.length - 1]; if (lastMessage?.role === 'user') { query = lastMessage.content; } } if (!query) { throw new CustomError('user_error', 'Missing query in request data', { missing_query: true, }); } return query; }