Prompt Anything

A modular and customizable framework to build prompts of any kind (such as ones within the console)! Originally inspired by the need to create console-like prompts in other applications such as chatting with bots.
Table of Contents
Implementation
The following interfaces should be implemented:
interface VisualInterface = {
text: string;
}
interface MessageInterface {
content: string;
}
interface ChannelInterface<MessageType extends MessageInterface> {
send: (visual: VisualInterface) => Promise<MessageType>;
}
The Prompt
method must be extended to implement the abstract methods createCollector
, onReject
, onInactivity
and onExit
. createCollector
should returns an event emitter that should also emit message
whenever your collector gets a message. Your collector should stop when the emitter emits stop
.
class MyPrompt<DataType, MessageType> extends Prompt<DataType, MessageType> {
createCollector(channel: ChannelInterface<MessageType>, data: DataType): PromptCollector<DataType, MessageType> {
const emitter: PromptCollector<DataType, MessageType> = new EventEmitter()
myCollector.on('myMessage', (message: MessageType) => {
emitter.emit('message', message)
})
emitter.once('stop', () => {
myCollector.stop()
})
return emitter
}
abstract async onReject(message: MessageType, error: Rejection, channel: ChannelInterface<MessageType>): Promise<void>;
abstract async onInactivity(channel: ChannelInterface<MessageType>): Promise<void>;
abstract async onExit(message: MessageType, channel: ChannelInterface<MessageType>): Promise<void>;
}
Usage
See the examples/console.ts
for a functioning implementation that accepts input from the console.
Creating a Prompt
A prompt is composed of three parts:
VisualInterface|VisualGenerator
- A object or function that determines how the prompt looks like to the userPromptFunction
- An (ideally pure) function that runs on every input from your collectorPromptCondition
- A function to determine if it should run
type MyData = {
human?: boolean;
name?: string;
age?: number;
}
const askNameVisual: VisualInterface = {
text: 'What is your name?'
}
const askNameFn: PromptFunction<MyData, MessageType> = async (m: MessageType, data: MyData) => {
return {
...data,
name: m.content
}
}
const askNamePrompt = new MyPrompt<MyData, MessageType>(askNameVisual, askNameFn)
The PromptFunction
should be pure function to
- Minimize side effects that can affect every other function that depends on the data.
- Simplify unit-testing
As a result, the function should always be referencing the original data variable passed from the previous prompt, regardless of how many times the function is run.
Conditional Prompts
If you only want a prompt to run if it matches a condition, you can specify a condition function as the third argument of a Prompt
.
const askNameCondition = (data: MyData) => {
if (data.human) {
return false
} else {
return true
}
}
const askNamePrompt = new MyPrompt<MyData, MessageType>({
text: 'What is your name?'
}, askNameFn, askNameCondition)
Conditional Visuals
If you want a prompt's visual to be dependent on the given data, you can pass a function as the argument of a Prompt
instead of an object.
const askNamePrompt = new MyPrompt<MyData, MessageType>((data: MyData): VisualInterface => ({
text: `Hello ${data.human ? 'non-human' : 'human'}! What is your name?`
}), askNameFn)
Rejecting Input
To reject input, you can check the the content of the message in PromptFunction
, and throw a Rejection
. Upon throwing it:
- The rejection's message will be sent via your channel implementation's
send
method - The prompt will again wait for input
- Run the prompt function again
const askAgeFn: PromptFunction<MyData, MessageType> = async (m: MessageType, data: MyData) => {
const age = Number(m.content)
if (isNaN(age)) {
throw new Rejection(`That's not a valid number! Try again.`)
}
return {
...data,
age
}
}
Skipping Message Collection
To skip message collecting and only send a prompt's visual (usually done at the end of prompts), simply leave the second argument of Prompt
as undefined
.
const askNamePrompt = new MyPrompt<MyData, MessageType>({
text: 'The end is nigh'
})
Time Limits
To automatically end message collection after a set duration, pass your duration in milliseconds as the 4th argument to Prompt
. Your implemented onInactivity
method will then be called.
const duration = 90000
const askNamePrompt = new MyPrompt<MyData, MessageType>(askNameVisual, askNameFn, askNameCondition, duration)
Connecting Prompts
To connect prompts, you must put them into nodes and connect nodes together by setting their children. This allows prompts to be reused by attaching children to nodes instead of prompts.
const askNameNode = new PromptNode<MyData, MessageType>(askNamePrompt)
const askAgeNode = new PromptNode<MyData, MessageType>(askAgePrompt)
const askLocationNode = new PromptNode<MyData, MessageType>(askLocationPrompt)
const englishAskNode = new PromptNode<MyData, MessageType>(englishAskPrompt)
const spanishAskNode = new PromptNode<MyData, MessageType>(spanishAskPrompt)
askNameNode.addChild(askAgeNode)
askAgeNode.addChild(askLocationNode)
askLocationNode
.addChild(englishAskNode)
.addChild(spanishAskNode)
askLocationNode.setChildren([englishAskNode, spanishAskNode])
The order of the children matters. The first child that matches its condition based on the given data will run. In this example, if englishAskPrompt
's condition function returns true
, then spanishAskNode
will never run.
Running Prompts
After your prompt nodes are created, create a PromptRunner
that is initialized with the data you'll be passing to the first prompt, then call its run method with the first prompt node.
const runner = new PromptRunner<MyData, MessageType>({})
const channel: ChannelInterface = myImplementedChannel()
const lastPromptData: MyData = await runner.run(askNameNode, channel)
You can also run an array of prompt nodes. The first node that either has no condition, or has a matching condition will be passd to the run
method.
const runner = new PromptRunner<MyData>({})
const channel: ChannelInterface<MessageType> = myImplementedChannel()
const lastPromptData: MyData = await runner.runArray([
askSurnameNode,
askNameNode
], channel)
Testing
Unit testing is straightforward since the tree of responses is built up from individual prompts that can be exported for testing. The prompts can be further decomposed into their visual, functional and conditional parts for even more granular tests.
Integration testing can be asserted on the execution order of the phases. Unfortunately, a "flush promises" method must be used since we cannot normally await
the promises while we are waiting for messages from EventEmitter
, otherwise the promise would never resolve until the series of prompts has ended.
async function flushPromises(): Promise<void> {
return new Promise(setImmediate);
}
type MockMessage = {
content: string;
}
const createMockMessage = (content = ''): MockMessage => ({
content
})
it('runs correctly for age <= 20', () => {
type AgeData = {
name?: string;
age?: number;
}
const emitter: PromptCollector<AgeData, MessageType> = new EventEmitter()
const spy = jest.spyOn(MyPrompt.prototype, 'createCollector')
.mockReturnValue(emitter)
const askNameFn: PromptFunction<AgeData, MessageType> = async function (m, data) {
return {
...data,
name: m.content
}
}
const askName = new MyPrompt<AgeData>(() => ({
text: `What's your name?`
}), askNameFn)
const askAgeFn: PromptFunction<AgeData, MessageType> = async function (m, data) {
if (isNaN(Number(m.content))) {
throw new Errors.Rejection()
}
return {
...data,
age: Number(m.content)
}
}
const askAge = new MyPrompt<AgeData>((data) => ({
text: `How old are you, ${data.name}?`
}), askAgeFn)
const tooOld = new MyPrompt<AgeData>((data) => ({
text: `Wow ${data.name}, you are pretty old at ${data.age} years old!`
}), undefined, async (data) => !!data.age && data.age > 20)
const tooYoung = new MyPrompt<AgeData>((data) => ({
text: `Wow ${data.name}, you are pretty young at ${data.age} years old!`
}), undefined, async (data) => !!data.age && data.age <= 20)
const askNameNode = new PromptNode(askName)
const askAgeNode = new PromptNode(askAge)
const tooYoungNode = new PromptNode(tooYoung)
const tooOldNode = new PromptNode(tooOld)
askNameNode.setChildren([askAgeNode])
askAgeNode.setChildren([tooOldNode, tooYoungNode])
const message = createMockMessage()
const name = 'George'
const age = '30'
const runner = new PromptRunner<AgeData>()
const promise = runner.run(askNameNode, message)
await flushPromises()
emitter.emit('message', createMockMessage(name))
await flushPromises()
expect(runner.indexOf(askName)).toEqual(0)
emitter.emit('message', createMockMessage(age))
await flushPromises()
expect(runner.indexOf(askAge)).toEqual(1)
await promise
expect(runner.indexesOf([tooOld, tooYoung]))
.toEqual([2, -1])
spy.mockRestore()
})