Lesson 1.4: Practice - CLI Chatbot
Duration: 120 minutes
Learning Objectives
By the end of this lesson, you will be able to:
- Build a complete command-line chatbot from scratch
- Implement all concepts from this module in a working project
- Add commands for controlling the chatbot
- Handle user input gracefully
- Create a polished, usable CLI application
Introduction
It is time to put everything together. In this lesson, you will build a fully-featured CLI chatbot that incorporates:
- Conversation history management
- Multiple personality modes
- Slash commands for control
- Token tracking
- Conversation saving and loading
- Graceful error handling
By the end, you will have a professional-quality chatbot you can use daily and extend further.
Project Overview
Here is what we will build:
┌─────────────────────────────────────────────────────────────────┐
│ CLI Chatbot Features │
├─────────────────────────────────────────────────────────────────┤
│ │
│ CORE FEATURES │
│ ├── Multi-turn conversations with memory │
│ ├── Context management (token limits) │
│ └── Multiple AI provider support │
│ │
│ PERSONALITY SYSTEM │
│ ├── Switch between personalities │
│ ├── Custom personality creation │
│ └── Personality persistence │
│ │
│ COMMANDS │
│ ├── /help - Show available commands │
│ ├── /clear - Reset conversation │
│ ├── /personality - Change AI personality │
│ ├── /save - Save conversation │
│ ├── /load - Load conversation │
│ ├── /stats - Show usage statistics │
│ └── /exit - Quit the application │
│ │
│ USER EXPERIENCE │
│ ├── Colored output │
│ ├── Loading indicators │
│ ├── Error recovery │
│ └── Keyboard shortcuts │
│ │
└─────────────────────────────────────────────────────────────────┘
Project Setup
Let us start with a fresh project:
mkdir cli-chatbot
cd cli-chatbot
npm init -y
Install dependencies:
npm install typescript tsx @types/node openai @anthropic-ai/sdk dotenv chalk readline
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"declaration": true
},
"include": ["src/**/*"]
}
Create the directory structure:
mkdir -p src/{core,commands,utils}
touch src/index.ts
Set up environment variables in .env:
OPENAI_API_KEY=sk-proj-your-key-here
ANTHROPIC_API_KEY=sk-ant-your-key-here
DEFAULT_MODEL=gpt-4o-mini
Add to .gitignore:
.env
node_modules
dist
conversations/
Step 1: Type Definitions
Create src/core/types.ts:
// Message types
export type MessageRole = 'system' | 'user' | 'assistant';
export interface Message {
role: MessageRole;
content: string;
timestamp: Date;
}
// Conversation types
export interface Conversation {
id: string;
messages: Message[];
personality: string;
createdAt: Date;
updatedAt: Date;
}
// Statistics
export interface ConversationStats {
messageCount: number;
userMessages: number;
assistantMessages: number;
estimatedTokens: number;
conversationDuration: number; // milliseconds
}
// Personality
export interface Personality {
id: string;
name: string;
description: string;
systemPrompt: string;
}
// Configuration
export interface ChatbotConfig {
model: string;
maxTokens: number;
temperature: number;
maxHistoryMessages: number;
}
// Command result
export interface CommandResult {
success: boolean;
message: string;
shouldContinue: boolean;
}
Step 2: Personality Definitions
Create src/core/personalities.ts:
import type { Personality } from './types';
export const personalities: Record<string, Personality> = {
default: {
id: 'default',
name: 'Assistant',
description: 'A helpful, balanced AI assistant',
systemPrompt: `You are a helpful AI assistant.
Your characteristics:
- Clear and concise responses
- Helpful and informative
- Honest about limitations
- Professional tone
When answering:
- Provide direct answers
- Use examples when helpful
- Format code with proper syntax highlighting
- Ask for clarification if needed`,
},
coder: {
id: 'coder',
name: 'CodeBuddy',
description: 'A programming expert focused on TypeScript',
systemPrompt: `You are CodeBuddy, an expert TypeScript developer.
Your expertise:
- TypeScript and JavaScript
- Node.js and web development
- Best practices and design patterns
- Debugging and optimization
Your style:
- Lead with code examples
- Explain the reasoning behind solutions
- Point out potential pitfalls
- Suggest improvements proactively
Format:
- Always use code blocks with language tags
- Keep explanations concise
- Use bullet points for lists`,
},
teacher: {
id: 'teacher',
name: 'Professor',
description: 'A patient educator who builds understanding',
systemPrompt: `You are Professor, a patient and thorough programming teacher.
Your teaching philosophy:
- Build understanding from fundamentals
- Never assume prior knowledge
- Use analogies to explain abstract concepts
- Encourage questions and exploration
Your method:
1. Start with the simplest explanation
2. Add complexity gradually
3. Provide hands-on examples
4. Check for understanding
Always:
- Be encouraging and supportive
- Celebrate progress
- Make learning feel achievable`,
},
concise: {
id: 'concise',
name: 'Brief',
description: 'Gives short, direct answers',
systemPrompt: `You are Brief, an assistant who values conciseness.
Rules:
- Maximum 3 sentences for explanations
- Code examples under 10 lines when possible
- No unnecessary preamble
- Get straight to the point
If more detail is needed, ask: "Want me to elaborate?"`,
},
creative: {
id: 'creative',
name: 'Spark',
description: 'Creative problem solver with unconventional ideas',
systemPrompt: `You are Spark, a creative programming assistant.
Your approach:
- Think outside the box
- Suggest unconventional solutions
- Make coding fun and engaging
- Use creative analogies
Style:
- Enthusiastic and energetic
- Open to experimentation
- Encourage trying new things
- Balance creativity with practicality`,
},
};
export function getPersonality(id: string): Personality {
return personalities[id] || personalities.default;
}
export function listPersonalities(): Personality[] {
return Object.values(personalities);
}
Step 3: Conversation Manager
Create src/core/conversation.ts:
import type { Conversation, ConversationStats, Message } from './types';
export class ConversationManager {
private conversation: Conversation;
private maxMessages: number;
constructor(personalityId: string, maxMessages: number = 50) {
this.maxMessages = maxMessages;
this.conversation = this.createNew(personalityId);
}
private createNew(personalityId: string): Conversation {
return {
id: this.generateId(),
messages: [],
personality: personalityId,
createdAt: new Date(),
updatedAt: new Date(),
};
}
private generateId(): string {
return `conv_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
addMessage(role: 'user' | 'assistant', content: string): void {
this.conversation.messages.push({
role,
content,
timestamp: new Date(),
});
this.conversation.updatedAt = new Date();
this.trimIfNeeded();
}
setSystemMessage(content: string): void {
// Remove existing system message if any
this.conversation.messages = this.conversation.messages.filter((m) => m.role !== 'system');
// Add new system message at the beginning
this.conversation.messages.unshift({
role: 'system',
content,
timestamp: new Date(),
});
}
private trimIfNeeded(): void {
// Keep system message + last N messages
const systemMessage = this.conversation.messages.find((m) => m.role === 'system');
const nonSystemMessages = this.conversation.messages.filter((m) => m.role !== 'system');
if (nonSystemMessages.length > this.maxMessages) {
const trimmed = nonSystemMessages.slice(-this.maxMessages);
this.conversation.messages = systemMessage ? [systemMessage, ...trimmed] : trimmed;
}
}
getMessagesForApi(): Array<{ role: 'system' | 'user' | 'assistant'; content: string }> {
return this.conversation.messages.map((m) => ({
role: m.role,
content: m.content,
}));
}
getStats(): ConversationStats {
const messages = this.conversation.messages;
const userMessages = messages.filter((m) => m.role === 'user');
const assistantMessages = messages.filter((m) => m.role === 'assistant');
const totalChars = messages.reduce((sum, m) => sum + m.content.length, 0);
const estimatedTokens = Math.ceil(totalChars / 4);
const duration = this.conversation.updatedAt.getTime() - this.conversation.createdAt.getTime();
return {
messageCount: messages.length,
userMessages: userMessages.length,
assistantMessages: assistantMessages.length,
estimatedTokens,
conversationDuration: duration,
};
}
clear(personalityId?: string): void {
const personality = personalityId || this.conversation.personality;
this.conversation = this.createNew(personality);
}
setPersonality(personalityId: string): void {
this.conversation.personality = personalityId;
}
getPersonalityId(): string {
return this.conversation.personality;
}
export(): string {
return JSON.stringify(this.conversation, null, 2);
}
import(data: string): void {
const parsed = JSON.parse(data) as Conversation;
parsed.createdAt = new Date(parsed.createdAt);
parsed.updatedAt = new Date(parsed.updatedAt);
parsed.messages = parsed.messages.map((m) => ({
...m,
timestamp: new Date(m.timestamp),
}));
this.conversation = parsed;
}
getId(): string {
return this.conversation.id;
}
}
Step 4: AI Client
Create src/core/ai-client.ts:
import OpenAI from 'openai';
import type { ChatbotConfig } from './types';
export class AIClient {
private openai: OpenAI;
private config: ChatbotConfig;
private totalTokensUsed: number = 0;
constructor(config: ChatbotConfig) {
this.openai = new OpenAI();
this.config = config;
}
async chat(
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
): Promise<{ content: string; tokensUsed: number }> {
const response = await this.openai.chat.completions.create({
model: this.config.model,
messages,
max_tokens: this.config.maxTokens,
temperature: this.config.temperature,
});
const content = response.choices[0].message.content || '';
const tokensUsed = response.usage?.total_tokens || 0;
this.totalTokensUsed += tokensUsed;
return { content, tokensUsed };
}
getTotalTokensUsed(): number {
return this.totalTokensUsed;
}
resetTokenCount(): void {
this.totalTokensUsed = 0;
}
}
Step 5: Command Handler
Create src/commands/handler.ts:
import * as fs from 'fs';
import * as path from 'path';
import { AIClient } from '../core/ai-client';
import { ConversationManager } from '../core/conversation';
import { getPersonality, listPersonalities } from '../core/personalities';
import type { CommandResult } from '../core/types';
export class CommandHandler {
private conversationManager: ConversationManager;
private aiClient: AIClient;
private conversationsDir: string;
constructor(conversationManager: ConversationManager, aiClient: AIClient) {
this.conversationManager = conversationManager;
this.aiClient = aiClient;
this.conversationsDir = path.join(process.cwd(), 'conversations');
// Ensure conversations directory exists
if (!fs.existsSync(this.conversationsDir)) {
fs.mkdirSync(this.conversationsDir, { recursive: true });
}
}
isCommand(input: string): boolean {
return input.startsWith('/');
}
async execute(input: string): Promise<CommandResult> {
const parts = input.slice(1).split(' ');
const command = parts[0].toLowerCase();
const args = parts.slice(1);
switch (command) {
case 'help':
return this.help();
case 'clear':
return this.clear();
case 'personality':
return this.personality(args);
case 'save':
return this.save(args);
case 'load':
return this.load(args);
case 'stats':
return this.stats();
case 'list':
return this.list();
case 'exit':
case 'quit':
return this.exit();
default:
return {
success: false,
message: `Unknown command: /${command}. Type /help for available commands.`,
shouldContinue: true,
};
}
}
private help(): CommandResult {
const helpText = `
Available Commands:
/help - Show this help message
/clear - Clear conversation history
/personality [name] - Switch personality (or list available)
/save [filename] - Save conversation to file
/load [filename] - Load conversation from file
/list - List saved conversations
/stats - Show conversation statistics
/exit - Exit the chatbot
Personalities:
${listPersonalities()
.map((p) => ` ${p.id.padEnd(12)} - ${p.description}`)
.join('\n')}
`;
return { success: true, message: helpText.trim(), shouldContinue: true };
}
private clear(): CommandResult {
const personalityId = this.conversationManager.getPersonalityId();
this.conversationManager.clear(personalityId);
// Re-apply system prompt
const personality = getPersonality(personalityId);
this.conversationManager.setSystemMessage(personality.systemPrompt);
this.aiClient.resetTokenCount();
return {
success: true,
message: 'Conversation cleared. Starting fresh!',
shouldContinue: true,
};
}
private personality(args: string[]): CommandResult {
if (args.length === 0) {
const current = this.conversationManager.getPersonalityId();
const list = listPersonalities()
.map(
(p) =>
` ${p.id === current ? '>' : ' '} ${p.id.padEnd(12)} - ${p.name}: ${p.description}`
)
.join('\n');
return {
success: true,
message: `Current personality: ${current}\n\nAvailable personalities:\n${list}`,
shouldContinue: true,
};
}
const personalityId = args[0].toLowerCase();
const personality = getPersonality(personalityId);
if (personality.id !== personalityId && personalityId !== 'default') {
return {
success: false,
message: `Unknown personality: ${personalityId}. Use /personality to see available options.`,
shouldContinue: true,
};
}
this.conversationManager.setPersonality(personality.id);
this.conversationManager.setSystemMessage(personality.systemPrompt);
return {
success: true,
message: `Switched to ${personality.name}. ${personality.description}`,
shouldContinue: true,
};
}
private save(args: string[]): CommandResult {
const filename = args[0] || `chat_${Date.now()}.json`;
const filepath = path.join(this.conversationsDir, filename);
try {
const data = this.conversationManager.export();
fs.writeFileSync(filepath, data);
return {
success: true,
message: `Conversation saved to: ${filename}`,
shouldContinue: true,
};
} catch (error) {
return {
success: false,
message: `Failed to save: ${error instanceof Error ? error.message : 'Unknown error'}`,
shouldContinue: true,
};
}
}
private load(args: string[]): CommandResult {
if (args.length === 0) {
return {
success: false,
message: 'Please specify a filename. Use /list to see saved conversations.',
shouldContinue: true,
};
}
const filename = args[0];
const filepath = path.join(this.conversationsDir, filename);
try {
if (!fs.existsSync(filepath)) {
return {
success: false,
message: `File not found: ${filename}`,
shouldContinue: true,
};
}
const data = fs.readFileSync(filepath, 'utf-8');
this.conversationManager.import(data);
// Re-apply personality system prompt
const personality = getPersonality(this.conversationManager.getPersonalityId());
this.conversationManager.setSystemMessage(personality.systemPrompt);
return {
success: true,
message: `Loaded conversation from: ${filename}`,
shouldContinue: true,
};
} catch (error) {
return {
success: false,
message: `Failed to load: ${error instanceof Error ? error.message : 'Unknown error'}`,
shouldContinue: true,
};
}
}
private list(): CommandResult {
try {
const files = fs
.readdirSync(this.conversationsDir)
.filter((f) => f.endsWith('.json'))
.map((f) => {
const stat = fs.statSync(path.join(this.conversationsDir, f));
return { name: f, modified: stat.mtime };
})
.sort((a, b) => b.modified.getTime() - a.modified.getTime());
if (files.length === 0) {
return {
success: true,
message: 'No saved conversations found.',
shouldContinue: true,
};
}
const list = files
.map((f) => ` ${f.name.padEnd(30)} (${f.modified.toLocaleDateString()})`)
.join('\n');
return {
success: true,
message: `Saved conversations:\n${list}`,
shouldContinue: true,
};
} catch {
return {
success: true,
message: 'No saved conversations found.',
shouldContinue: true,
};
}
}
private stats(): CommandResult {
const stats = this.conversationManager.getStats();
const tokensUsed = this.aiClient.getTotalTokensUsed();
const duration = stats.conversationDuration;
const minutes = Math.floor(duration / 60000);
const seconds = Math.floor((duration % 60000) / 1000);
const message = `
Conversation Statistics:
Messages: ${stats.messageCount} total
${stats.userMessages} from you
${stats.assistantMessages} from assistant
Tokens: ~${stats.estimatedTokens} in context
${tokensUsed} used this session
Duration: ${minutes}m ${seconds}s
Personality: ${this.conversationManager.getPersonalityId()}
`;
return { success: true, message: message.trim(), shouldContinue: true };
}
private exit(): CommandResult {
return {
success: true,
message: 'Goodbye! Have a great day!',
shouldContinue: false,
};
}
}
Step 6: Display Utilities
Create src/utils/display.ts:
import chalk from 'chalk';
export const display = {
welcome(): void {
console.log(chalk.cyan.bold('\n╔════════════════════════════════════╗'));
console.log(chalk.cyan.bold('║ CLI Chatbot v1.0 ║'));
console.log(chalk.cyan.bold('╚════════════════════════════════════╝\n'));
console.log(chalk.gray('Type /help for available commands.\n'));
},
userPrompt(): string {
return chalk.green.bold('You: ');
},
assistantLabel(): void {
process.stdout.write(chalk.blue.bold('\nAssistant: '));
},
assistantMessage(message: string): void {
console.log(chalk.white(message) + '\n');
},
command(message: string): void {
console.log(chalk.yellow(message) + '\n');
},
error(message: string): void {
console.log(chalk.red.bold('Error: ') + chalk.red(message) + '\n');
},
info(message: string): void {
console.log(chalk.gray(message));
},
thinking(): void {
process.stdout.write(chalk.gray('Thinking...'));
},
clearThinking(): void {
process.stdout.clearLine(0);
process.stdout.cursorTo(0);
},
divider(): void {
console.log(chalk.gray('─'.repeat(50)));
},
goodbye(): void {
console.log(chalk.cyan('\nGoodbye! Have a great day!\n'));
},
};
Step 7: Main Application
Create src/index.ts:
import 'dotenv/config';
import * as readline from 'readline';
import { CommandHandler } from './commands/handler';
import { AIClient } from './core/ai-client';
import { ConversationManager } from './core/conversation';
import { getPersonality } from './core/personalities';
import type { ChatbotConfig } from './core/types';
import { display } from './utils/display';
class CLIChatbot {
private conversationManager: ConversationManager;
private aiClient: AIClient;
private commandHandler: CommandHandler;
private rl: readline.Interface;
private isProcessing: boolean = false;
constructor() {
// Configuration
const config: ChatbotConfig = {
model: process.env.DEFAULT_MODEL || 'gpt-4o-mini',
maxTokens: 1000,
temperature: 0.7,
maxHistoryMessages: 50,
};
// Initialize components
this.conversationManager = new ConversationManager('default', config.maxHistoryMessages);
this.aiClient = new AIClient(config);
this.commandHandler = new CommandHandler(this.conversationManager, this.aiClient);
// Set initial personality
const defaultPersonality = getPersonality('default');
this.conversationManager.setSystemMessage(defaultPersonality.systemPrompt);
// Create readline interface
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// Handle Ctrl+C gracefully
this.rl.on('close', () => {
display.goodbye();
process.exit(0);
});
}
async start(): Promise<void> {
display.welcome();
this.promptUser();
}
private promptUser(): void {
this.rl.question(display.userPrompt(), async (input) => {
await this.handleInput(input);
});
}
private async handleInput(input: string): Promise<void> {
const trimmedInput = input.trim();
// Skip empty input
if (!trimmedInput) {
this.promptUser();
return;
}
// Handle commands
if (this.commandHandler.isCommand(trimmedInput)) {
const result = await this.commandHandler.execute(trimmedInput);
display.command(result.message);
if (!result.shouldContinue) {
this.rl.close();
return;
}
this.promptUser();
return;
}
// Handle chat message
await this.handleChat(trimmedInput);
}
private async handleChat(message: string): Promise<void> {
if (this.isProcessing) {
display.info('Please wait for the current response...');
this.promptUser();
return;
}
this.isProcessing = true;
try {
// Add user message
this.conversationManager.addMessage('user', message);
// Show thinking indicator
display.thinking();
// Get AI response
const messages = this.conversationManager.getMessagesForApi();
const response = await this.aiClient.chat(messages);
// Clear thinking indicator
display.clearThinking();
// Add and display response
this.conversationManager.addMessage('assistant', response.content);
display.assistantLabel();
display.assistantMessage(response.content);
} catch (error) {
display.clearThinking();
if (error instanceof Error) {
if (error.message.includes('API key')) {
display.error('Invalid API key. Please check your .env file.');
} else if (error.message.includes('rate limit')) {
display.error('Rate limit reached. Please wait a moment and try again.');
} else {
display.error(`Failed to get response: ${error.message}`);
}
} else {
display.error('An unexpected error occurred.');
}
// Remove failed user message from history
const messages = this.conversationManager.getMessagesForApi();
if (messages[messages.length - 1]?.role === 'user') {
// We need to recreate conversation without the last message
// For simplicity, we just note the error occurred
}
} finally {
this.isProcessing = false;
this.promptUser();
}
}
}
// Main entry point
async function main(): Promise<void> {
try {
const chatbot = new CLIChatbot();
await chatbot.start();
} catch (error) {
console.error('Failed to start chatbot:', error);
process.exit(1);
}
}
main();
Step 8: Package Configuration
Update package.json:
{
"name": "cli-chatbot",
"version": "1.0.0",
"description": "A feature-rich CLI chatbot built with TypeScript",
"main": "dist/index.js",
"type": "module",
"scripts": {
"start": "tsx src/index.ts",
"build": "tsc",
"dev": "tsx watch src/index.ts"
},
"keywords": ["chatbot", "cli", "typescript", "openai"],
"author": "",
"license": "MIT"
}
Running the Chatbot
Start the chatbot:
npm start
You should see:
╔════════════════════════════════════╗
║ CLI Chatbot v1.0 ║
╚════════════════════════════════════╝
Type /help for available commands.
You:
Try these interactions:
You: Hello! What can you help me with?
You: /personality coder
You: How do I create a TypeScript interface?
You: /stats
You: /save my-chat.json
You: /clear
You: /load my-chat.json
You: /exit
Extending the Chatbot
Here are ideas for extending your chatbot:
Add Streaming Responses
// In ai-client.ts
async chatStream(
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>,
onChunk: (chunk: string) => void
): Promise<string> {
const stream = await this.openai.chat.completions.create({
model: this.config.model,
messages,
stream: true
});
let fullContent = "";
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || "";
fullContent += content;
onChunk(content);
}
return fullContent;
}
Add Multi-Provider Support
// Support switching between OpenAI and Anthropic
interface ProviderConfig {
type: 'openai' | 'anthropic';
model: string;
}
class MultiProviderClient {
async chat(provider: ProviderConfig, messages: Message[]): Promise<string> {
if (provider.type === 'openai') {
return this.chatOpenAI(provider.model, messages);
} else {
return this.chatAnthropic(provider.model, messages);
}
}
}
Add Conversation Search
// Search through saved conversations
function searchConversations(query: string): string[] {
const files = fs.readdirSync(conversationsDir);
const matches: string[] = [];
for (const file of files) {
const content = fs.readFileSync(path.join(conversationsDir, file), 'utf-8');
if (content.toLowerCase().includes(query.toLowerCase())) {
matches.push(file);
}
}
return matches;
}
Exercises
Exercise 1: Add a /model Command
Add a command to switch between AI models:
// Your implementation here
// /model gpt-4o-mini
// /model gpt-4o
// /model list
Solution
Add to handler.ts:
private models = ["gpt-4o-mini", "gpt-4o", "gpt-4-turbo"];
private currentModel = "gpt-4o-mini";
private model(args: string[]): CommandResult {
if (args.length === 0 || args[0] === "list") {
const list = this.models
.map(m => ` ${m === this.currentModel ? ">" : " "} ${m}`)
.join("\n");
return {
success: true,
message: `Current model: ${this.currentModel}\n\nAvailable models:\n${list}`,
shouldContinue: true
};
}
const model = args[0].toLowerCase();
if (!this.models.includes(model)) {
return {
success: false,
message: `Unknown model: ${model}. Use /model list to see options.`,
shouldContinue: true
};
}
this.currentModel = model;
// You would also need to update the AIClient config
return {
success: true,
message: `Switched to model: ${model}`,
shouldContinue: true
};
}
Add to the switch statement in execute:
case "model":
return this.model(args);
Exercise 2: Add Conversation Export to Markdown
Add a command to export the conversation as readable Markdown:
// /export chat.md
// Should create a nicely formatted Markdown file
Solution
Add to handler.ts:
private exportMarkdown(args: string[]): CommandResult {
const filename = args[0] || `chat_${Date.now()}.md`;
const filepath = path.join(this.conversationsDir, filename);
try {
const messages = this.conversationManager.getMessagesForApi();
const personality = getPersonality(this.conversationManager.getPersonalityId());
let markdown = `# Chat Conversation\n\n`;
markdown += `**Date:** ${new Date().toLocaleDateString()}\n`;
markdown += `**Personality:** ${personality.name}\n\n`;
markdown += `---\n\n`;
for (const msg of messages) {
if (msg.role === "system") continue;
const label = msg.role === "user" ? "**You:**" : "**Assistant:**";
markdown += `${label}\n\n${msg.content}\n\n---\n\n`;
}
fs.writeFileSync(filepath, markdown);
return {
success: true,
message: `Exported to: ${filename}`,
shouldContinue: true
};
} catch (error) {
return {
success: false,
message: `Export failed: ${error instanceof Error ? error.message : "Unknown"}`,
shouldContinue: true
};
}
}
Add to switch:
case "export":
return this.exportMarkdown(args);
Exercise 3: Add Response Timing
Display how long each response takes:
// Show: "Response time: 1.2s"
Solution
Modify handleChat in index.ts:
private async handleChat(message: string): Promise<void> {
if (this.isProcessing) {
display.info("Please wait for the current response...");
this.promptUser();
return;
}
this.isProcessing = true;
const startTime = Date.now();
try {
this.conversationManager.addMessage("user", message);
display.thinking();
const messages = this.conversationManager.getMessagesForApi();
const response = await this.aiClient.chat(messages);
display.clearThinking();
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
this.conversationManager.addMessage("assistant", response.content);
display.assistantLabel();
display.assistantMessage(response.content);
display.info(`Response time: ${elapsed}s | Tokens: ${response.tokensUsed}`);
} catch (error) {
// ... error handling
} finally {
this.isProcessing = false;
this.promptUser();
}
}
Final Project Structure
Your completed project should look like this:
cli-chatbot/
├── src/
│ ├── core/
│ │ ├── types.ts
│ │ ├── personalities.ts
│ │ ├── conversation.ts
│ │ └── ai-client.ts
│ ├── commands/
│ │ └── handler.ts
│ ├── utils/
│ │ └── display.ts
│ └── index.ts
├── conversations/ # Created at runtime
├── .env
├── .gitignore
├── package.json
└── tsconfig.json
Key Takeaways
- Modular architecture: Separate concerns into distinct files and classes
- Command pattern: Handle user commands cleanly with a dedicated handler
- State management: Use a conversation manager to track history
- User experience: Colors, loading indicators, and clear feedback matter
- Error handling: Gracefully handle API errors and edge cases
- Extensibility: Design for easy addition of new features
- Persistence: Save and load conversations for continuity
Resources
| Resource | Type | Description |
|---|---|---|
| Node.js Readline | Documentation | CLI input handling |
| Chalk | Library | Terminal colors |
| Commander.js | Library | Alternative CLI framework |
| Inquirer.js | Library | Interactive prompts |
Module Complete
Congratulations! You have completed Module 1: Chatbots. You now know how to:
- Architect a chatbot application
- Manage conversation history and context
- Create personalized AI assistants with system prompts
- Build a complete, production-ready CLI chatbot
In the next module, you will learn how to add streaming responses for a more dynamic user experience.