@equinor/fusion-framework-cli-plugin-ai-chat 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/chat.ts ADDED
@@ -0,0 +1,429 @@
1
+ import { createCommand, createOption } from 'commander';
2
+ import type { ChatMessage } from '@equinor/fusion-framework-module-ai/lib';
3
+ import { from } from 'rxjs';
4
+ import {
5
+ withOptions as withAiOptions,
6
+ type AiOptions,
7
+ } from '@equinor/fusion-framework-cli-plugin-ai-base/command-options';
8
+ import { createInterface } from 'node:readline';
9
+
10
+ import { setupFramework } from '@equinor/fusion-framework-cli-plugin-ai-base';
11
+ import { createSystemMessage } from './system-message-template.js';
12
+ import {
13
+ RunnablePassthrough,
14
+ RunnableSequence,
15
+ type RunnableInterface,
16
+ } from '@langchain/core/runnables';
17
+ import { StringOutputParser } from '@langchain/core/output_parsers';
18
+
19
+ /**
20
+ * CLI command: `ai chat`
21
+ *
22
+ * Interactive chat with Large Language Models using inquirer for a smooth CLI experience.
23
+ * Enhanced with vector store context retrieval for more accurate and relevant responses.
24
+ *
25
+ * Features:
26
+ * - Interactive conversation mode with inquirer prompts
27
+ * - Real-time streaming responses from AI models
28
+ * - Intelligent message history compression using AI summarization
29
+ * - Automatic vector store context retrieval for enhanced responses
30
+ * - Special commands: exit, quit, clear, help
31
+ * - Support for Azure OpenAI models and Azure Cognitive Search
32
+ * - Live typing effect for AI responses
33
+ * - Configurable context retrieval limits
34
+ *
35
+ * Usage:
36
+ * $ ffc ai chat [options]
37
+ *
38
+ * Options:
39
+ * --openai-api-key <key> API key for Azure OpenAI
40
+ * --openai-api-version <version> API version (default: 2024-02-15-preview)
41
+ * --openai-instance <name> Azure OpenAI instance name
42
+ * --openai-chat-deployment <name> Azure OpenAI chat deployment name
43
+ * --openai-embedding-deployment <name> Azure OpenAI embedding deployment name
44
+ * --azure-search-endpoint <url> Azure Search endpoint URL
45
+ * --azure-search-api-key <key> Azure Search API key
46
+ * --azure-search-index-name <name> Azure Search index name
47
+ * --use-context Use vector store context (default: true)
48
+ * --context-limit <number> Max context documents to retrieve (default: 5)
49
+ * --history-limit <number> Max messages to keep in conversation history (default: 20, auto-compresses at 10)
50
+ * --verbose Enable verbose output
51
+ *
52
+ * Environment Variables:
53
+ * AZURE_OPENAI_API_KEY API key for Azure OpenAI
54
+ * AZURE_OPENAI_API_VERSION API version
55
+ * AZURE_OPENAI_INSTANCE_NAME Instance name
56
+ * AZURE_OPENAI_CHAT_DEPLOYMENT_NAME Chat deployment name
57
+ * AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME Embedding deployment name
58
+ * AZURE_SEARCH_ENDPOINT Azure Search endpoint
59
+ * AZURE_SEARCH_API_KEY Azure Search API key
60
+ * AZURE_SEARCH_INDEX_NAME Azure Search index name
61
+ *
62
+ * Interactive Commands:
63
+ * exit/quit - End the conversation
64
+ * clear - Clear conversation history
65
+ * help - Show available commands
66
+ * Ctrl+C - Exit immediately
67
+ *
68
+ * Examples:
69
+ * $ ffc ai chat
70
+ * $ ffc ai chat --context-limit 10
71
+ * $ ffc ai chat --history-limit 100
72
+ * $ ffc ai chat --verbose --azure-search-endpoint https://my-search.search.windows.net
73
+ */
74
+ /**
75
+ * Command options for the chat command
76
+ */
77
+ type CommandOptions = AiOptions & {
78
+ /** Enable verbose output for debugging */
79
+ verbose?: boolean;
80
+ /** Maximum number of context documents to retrieve from vector store */
81
+ contextLimit?: number;
82
+ /** Maximum number of messages to keep in conversation history */
83
+ historyLimit?: number;
84
+ };
85
+
86
+ const _command = createCommand('chat')
87
+ .description('Interactive chat with Large Language Models')
88
+ .addOption(createOption('--verbose', 'Enable verbose output').default(false))
89
+ .addOption(
90
+ createOption('--context-limit <number>', 'Maximum number of context documents to retrieve')
91
+ .default(5)
92
+ .argParser(parseInt),
93
+ )
94
+ .addOption(
95
+ createOption(
96
+ '--history-limit <number>',
97
+ 'Maximum number of messages to keep in conversation history',
98
+ )
99
+ .default(20)
100
+ .argParser(parseInt),
101
+ )
102
+ .action(async (options: CommandOptions) => {
103
+ // Initialize the framework
104
+ const framework = await setupFramework(options);
105
+
106
+ // Get the AI provider
107
+ const aiProvider = framework.ai;
108
+
109
+ if (options.verbose) {
110
+ console.log('āœ… Framework initialized successfully');
111
+ console.log('šŸ’¬ Starting interactive chat...');
112
+ console.log('šŸ” Context retrieval enabled');
113
+ console.log(`šŸ“ Message history limit: ${options.historyLimit || 20} messages`);
114
+ if (options.azureSearchEndpoint) {
115
+ console.log(`šŸ“š Using vector store: ${options.azureSearchIndexName}`);
116
+ } else {
117
+ console.log('āš ļø No vector store configured - context retrieval will be skipped');
118
+ }
119
+ console.log('šŸ” Using model:', options.openaiChatDeployment);
120
+ console.log('Type "exit" or "quit" to end the conversation');
121
+ console.log('Type "clear" to clear the conversation history');
122
+ console.log('Press Ctrl+C to exit immediately');
123
+ console.log('');
124
+ }
125
+
126
+ // Start interactive chat
127
+ if (!options.openaiChatDeployment) {
128
+ throw new Error('Chat deployment name is required');
129
+ }
130
+ if (!options.azureSearchIndexName) {
131
+ throw new Error('Azure Search index name is required');
132
+ }
133
+
134
+ const chatService = framework.ai.getService('chat', options.openaiChatDeployment);
135
+ const vectorStoreService = aiProvider.getService('search', options.azureSearchIndexName);
136
+
137
+ if (options.verbose) {
138
+ console.log('šŸ”§ Configuring retriever with options:', {
139
+ k: options.contextLimit || 5,
140
+ searchType: 'similarity',
141
+ });
142
+ }
143
+
144
+ // Configure retriever with similarity search (not MMR) for more predictable results
145
+ // MMR (Maximum Marginal Relevance) can sometimes cause issues with Azure Search
146
+ const retriever = vectorStoreService.asRetriever({
147
+ k: options.contextLimit || 5,
148
+ searchType: 'similarity',
149
+ });
150
+
151
+ /**
152
+ * Retrieves relevant context documents from the vector store for a given query
153
+ * @param input - The user's query string
154
+ * @returns Promise resolving to formatted context string or error message
155
+ */
156
+ const retrieveContext = async (input: string) => {
157
+ try {
158
+ if (options.verbose) {
159
+ console.log('šŸ” Retrieving context for query:', input);
160
+ }
161
+ const docs = await retriever.invoke(input);
162
+ console.log('šŸ“„ Retrieved documents:', docs?.length || 0);
163
+ if (options.verbose) {
164
+ for (const doc of docs) {
165
+ console.log('šŸ“„ Document:', {
166
+ pageContent: `${doc.pageContent.substring(0, 100)}...`,
167
+ metadata: doc.metadata,
168
+ });
169
+ }
170
+ }
171
+ if (!docs || docs.length === 0) {
172
+ return 'No relevant context found.';
173
+ }
174
+ return docs.map((doc) => doc.pageContent).join('\n');
175
+ } catch (error) {
176
+ console.error('āŒ Error retrieving context:', error);
177
+ if (options.verbose) {
178
+ console.error('Full error details:', error);
179
+ }
180
+ return 'Error retrieving context.';
181
+ }
182
+ };
183
+
184
+ // Create a custom runnable that formats the prompt as ChatMessage[]
185
+ // This chain step retrieves context and formats it into a proper message array
186
+ const formatPromptAsMessages = new RunnablePassthrough().pipe(
187
+ async (input: { userMessage: string; messageHistory: ChatMessage[] }) => {
188
+ // Retrieve relevant context from vector store based on user's message
189
+ const context = await retrieveContext(input.userMessage);
190
+ // Build system message with retrieved context for RAG (Retrieval-Augmented Generation)
191
+ // Emphasizes using FUSION framework knowledge from the provided context
192
+ const systemMessage = createSystemMessage(context);
193
+
194
+ // Build the complete message array with system message, history, and current user message
195
+ // Order: system message (with context) -> conversation history -> current user message
196
+ const messages: ChatMessage[] = [
197
+ { role: 'system', content: systemMessage },
198
+ ...input.messageHistory,
199
+ { role: 'user', content: input.userMessage },
200
+ ];
201
+
202
+ return messages;
203
+ },
204
+ );
205
+
206
+ // Create the chatbot chain using LangChain's RunnableSequence
207
+ // Chain flow: format messages -> invoke chat model -> parse string output
208
+ // Note: Type assertion needed because IModel extends RunnableInterface but TypeScript
209
+ // doesn't recognize the compatibility without explicit casting
210
+ const chain = RunnableSequence.from([
211
+ formatPromptAsMessages,
212
+ chatService as unknown as RunnableInterface,
213
+ new StringOutputParser(),
214
+ ]);
215
+
216
+ const messageHistory: ChatMessage[] = [];
217
+
218
+ /**
219
+ * Summarizes the oldest messages in the conversation history using AI
220
+ * This helps compress long conversation histories while preserving important context
221
+ * @param messages - Array of messages to summarize
222
+ * @returns Promise resolving to a summary message that can replace the original messages
223
+ */
224
+ const summarizeOldMessages = async (messages: ChatMessage[]): Promise<ChatMessage> => {
225
+ // Convert messages to a text format for summarization
226
+ const conversationText = messages.map((msg) => `${msg.role}: ${msg.content}`).join('\n');
227
+
228
+ const summaryPrompt = `Please provide a concise summary of the following conversation history. Focus on key topics, decisions, and important context that should be remembered for the ongoing conversation:
229
+
230
+ ${conversationText}
231
+
232
+ Summary:`;
233
+
234
+ try {
235
+ // Use the chat service to generate a summary of the conversation
236
+ const summaryResponse = await chatService.invoke([
237
+ { role: 'user', content: summaryPrompt },
238
+ ]);
239
+ return {
240
+ role: 'assistant',
241
+ content: `[Previous conversation summary: ${summaryResponse}]`,
242
+ };
243
+ } catch (error) {
244
+ console.error('āŒ Error summarizing conversation:', error);
245
+ // Fallback to a simple summary if AI summarization fails
246
+ // This ensures the conversation can continue even if summarization fails
247
+ return {
248
+ role: 'assistant',
249
+ content: `[Previous conversation summary: ${messages.length} messages about various topics]`,
250
+ };
251
+ }
252
+ };
253
+
254
+ /**
255
+ * Manages message history with intelligent compression using AI summarization
256
+ * Implements a two-stage compression strategy:
257
+ * 1. When history reaches 10 messages, summarize oldest 5 messages using AI
258
+ * 2. If history still exceeds limit after compression, remove oldest non-summary messages
259
+ * @param history - Current message history array (will be mutated)
260
+ * @param newMessage - New message to add to history
261
+ * @param limit - Maximum number of messages to keep in history
262
+ * @returns Updated message history with compression applied
263
+ */
264
+ const addMessageToHistory = async (
265
+ history: ChatMessage[],
266
+ newMessage: ChatMessage,
267
+ limit: number,
268
+ ): Promise<ChatMessage[]> => {
269
+ history.push(newMessage);
270
+
271
+ // Stage 1: AI-based compression when history reaches 10 messages
272
+ // Summarize the oldest 5 messages and replace them with a single summary message
273
+ // This preserves important context while reducing token usage
274
+ let hasSummary = false;
275
+ if (history.length >= 10) {
276
+ if (options.verbose) {
277
+ console.log('šŸ”„ Compressing conversation history with AI summarization...');
278
+ }
279
+
280
+ // Remove oldest 5 messages and summarize them
281
+ const oldestMessages = history.splice(0, 5);
282
+ const summary = await summarizeOldMessages(oldestMessages);
283
+
284
+ // Insert the summary at the beginning to maintain chronological order
285
+ history.unshift(summary);
286
+ hasSummary = true;
287
+
288
+ if (options.verbose) {
289
+ console.log(
290
+ `šŸ“ Compressed 5 messages into 1 summary. History now has ${history.length} messages.`,
291
+ );
292
+ }
293
+ }
294
+
295
+ // Stage 2: Hard limit enforcement if history still exceeds limit after compression
296
+ // If we just created a summary, start removing from position 1 to preserve it
297
+ // Otherwise, remove from position 0 as usual
298
+ if (history.length > limit) {
299
+ const messagesToRemove = history.length - limit;
300
+ const startIndex = hasSummary ? 1 : 0;
301
+ // Ensure we don't remove more messages than available (keeping summary if it exists)
302
+ const maxRemovable = hasSummary ? history.length - 2 : history.length - 1;
303
+ const actualRemoval = Math.min(messagesToRemove, Math.max(0, maxRemovable));
304
+
305
+ if (actualRemoval > 0) {
306
+ history.splice(startIndex, actualRemoval);
307
+
308
+ if (options.verbose) {
309
+ console.log(
310
+ `šŸ—‘ļø Removed ${actualRemoval} messages. History now has ${history.length} messages.`,
311
+ );
312
+ }
313
+ }
314
+ }
315
+
316
+ return history;
317
+ };
318
+
319
+ console.log('šŸ’¬ Chat ready! Start typing your message...\n');
320
+ console.log('šŸ’” Press Ctrl+C to exit at any time\n');
321
+
322
+ // Create readline interface for better Ctrl+C handling
323
+ // This provides better control over input/output and signal handling
324
+ const rl = createInterface({
325
+ input: process.stdin,
326
+ output: process.stdout,
327
+ });
328
+
329
+ // Handle Ctrl+C gracefully to prevent abrupt termination
330
+ const handleExit = () => {
331
+ console.log('\n\nšŸ‘‹ Goodbye!');
332
+ rl.close();
333
+ process.exit(0);
334
+ };
335
+
336
+ process.on('SIGINT', handleExit);
337
+ process.on('SIGTERM', handleExit);
338
+
339
+ // Interactive chat loop - continues until user exits
340
+ while (true) {
341
+ try {
342
+ // Prompt user for input using readline
343
+ const userMessage = await new Promise<string>((resolve) => {
344
+ rl.question('You: ', (answer) => {
345
+ resolve(answer.trim());
346
+ });
347
+ });
348
+
349
+ // Skip empty messages
350
+ if (!userMessage) {
351
+ console.log('Please enter a message\n');
352
+ continue;
353
+ }
354
+
355
+ // Handle special commands
356
+ if (userMessage.toLowerCase() === 'clear') {
357
+ messageHistory.length = 0;
358
+ console.log('\n🧹 Conversation history cleared!\n');
359
+ continue;
360
+ }
361
+
362
+ // Add user message to history (with automatic compression if needed)
363
+ await addMessageToHistory(
364
+ messageHistory,
365
+ { role: 'user', content: userMessage },
366
+ options.historyLimit || 20,
367
+ );
368
+
369
+ // Show typing indicator
370
+ console.log('\nšŸ¤– AI Response:');
371
+
372
+ // Use LangChain runnable chain for RAG (Retrieval-Augmented Generation)
373
+ try {
374
+ if (options.verbose) {
375
+ console.log('šŸ” Using LangChain chain with context retrieval...');
376
+ console.log(`šŸ“ Message history contains ${messageHistory.length} messages`);
377
+ }
378
+
379
+ // Stream the response from the chain for real-time output
380
+ const responseStream = await chain.stream({ userMessage, messageHistory });
381
+
382
+ // Collect the full response while streaming chunks to stdout
383
+ // This allows users to see the response as it's generated
384
+ const fullResponse = await new Promise<string>((resolve, reject) => {
385
+ let fullResponse = '';
386
+ from(responseStream).subscribe({
387
+ next: (chunk) => {
388
+ // Write each chunk immediately for streaming effect
389
+ process.stdout.write(String(chunk));
390
+ fullResponse += chunk;
391
+ },
392
+ error: (error) => {
393
+ reject(error);
394
+ },
395
+ complete: () => {
396
+ resolve(fullResponse);
397
+ },
398
+ });
399
+ });
400
+
401
+ // Add AI response to history for context in future messages
402
+ await addMessageToHistory(
403
+ messageHistory,
404
+ { role: 'assistant', content: fullResponse },
405
+ options.historyLimit || 20,
406
+ );
407
+ } catch (error) {
408
+ console.error('\nāŒ Chain error:', error);
409
+ console.log('Falling back to basic chat...');
410
+ }
411
+ console.log('');
412
+ } catch (error) {
413
+ console.error('\nāŒ Error during chat:', error);
414
+ console.log('');
415
+ }
416
+ }
417
+ });
418
+
419
+ /**
420
+ * CLI command for interactive chat with AI models.
421
+ * Enhanced with AI options including chat, embedding, and search capabilities.
422
+ */
423
+ export const command = withAiOptions(_command, {
424
+ includeChat: true,
425
+ includeEmbedding: true,
426
+ includeSearch: true,
427
+ });
428
+
429
+ export default command;
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { Command } from 'commander';
2
+ import { registerAiPlugin } from '@equinor/fusion-framework-cli-plugin-ai-base';
3
+ import { command as chatCommand } from './chat.js';
4
+
5
+ /**
6
+ * Registers the AI chat plugin command with the CLI program
7
+ * @param program - The Commander program instance to register commands with
8
+ */
9
+ export function registerChatPlugin(program: Command): void {
10
+ registerAiPlugin(program, chatCommand);
11
+ }
12
+
13
+ export default registerChatPlugin;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * System message template for FUSION framework chat assistant
3
+ *
4
+ * This template emphasizes using FUSION knowledge from the provided context
5
+ * to provide accurate and comprehensive answers about the FUSION framework.
6
+ *
7
+ * @param context - The retrieved context from the vector store containing FUSION knowledge
8
+ * @returns Formatted system message string for the AI chat assistant
9
+ */
10
+ export function createSystemMessage(context: string): string {
11
+ return `You are a helpful assistant specialized in the FUSION framework. Your primary goal is to use FUSION knowledge from the provided context to answer questions accurately and comprehensively.
12
+
13
+ **Priority Guidelines:**
14
+ 1. **Always prioritize FUSION-specific information** from the context below when answering questions
15
+ 2. Use FUSION framework patterns, APIs, conventions, and best practices from the context
16
+ 3. Reference specific FUSION modules, packages, or components mentioned in the context when relevant
17
+ 4. If the context contains FUSION documentation, code examples, or implementation details, use them as the basis for your response
18
+ 5. Only provide general answers if the context doesn't contain relevant FUSION information, and clearly state when you're doing so
19
+
20
+ **Context (FUSION Knowledge Base):**
21
+ ${context}
22
+
23
+ Remember: Your expertise comes from the FUSION context provided above. Use it as extensively as possible to provide accurate, framework-specific guidance.`;
24
+ }
package/src/version.ts ADDED
@@ -0,0 +1,2 @@
1
+ // Generated by genversion.
2
+ export const version = '1.0.0';
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "dist/esm",
7
+ "rootDir": "src",
8
+ "declarationDir": "./dist/types",
9
+ "baseUrl": "."
10
+ },
11
+ "references": [
12
+ {
13
+ "path": "../ai-base"
14
+ },
15
+ {
16
+ "path": "../../modules/ai"
17
+ },
18
+ {
19
+ "path": "../../modules/module"
20
+ }
21
+ ],
22
+ "include": ["src/**/*"],
23
+ "exclude": ["node_modules"]
24
+ }
@@ -0,0 +1,14 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ include: ['src/**/*.test.ts'],
8
+ coverage: {
9
+ provider: 'v8',
10
+ reporter: ['text', 'json', 'html'],
11
+ exclude: ['**/*.test.ts', '**/dist/**', '**/node_modules/**'],
12
+ },
13
+ },
14
+ });