initial checkin

This commit is contained in:
Dave Horton
2024-04-10 16:30:41 -04:00
commit 39d2611cc3
13 changed files with 2215 additions and 0 deletions

1
.eslintignore Normal file
View File

@@ -0,0 +1 @@
test/*

126
.eslintrc.json Normal file
View File

@@ -0,0 +1,126 @@
{
"env": {
"node": true,
"es6": true
},
"parserOptions": {
"ecmaFeatures": {
"jsx": false,
"modules": false
},
"ecmaVersion": 2020
},
"plugins": ["promise"],
"rules": {
"promise/always-return": "error",
"promise/no-return-wrap": "error",
"promise/param-names": "error",
"promise/catch-or-return": "error",
"promise/no-native": "off",
"promise/no-nesting": "warn",
"promise/no-promise-in-callback": "warn",
"promise/no-callback-in-promise": "warn",
"promise/no-return-in-finally": "warn",
// Possible Errors
// http://eslint.org/docs/rules/#possible-errors
"comma-dangle": [2, "only-multiline"],
"no-control-regex": 2,
"no-debugger": 2,
"no-dupe-args": 2,
"no-dupe-keys": 2,
"no-duplicate-case": 2,
"no-empty-character-class": 2,
"no-ex-assign": 2,
"no-extra-boolean-cast" : 2,
"no-extra-parens": [2, "functions"],
"no-extra-semi": 2,
"no-func-assign": 2,
"no-invalid-regexp": 2,
"no-irregular-whitespace": 2,
"no-negated-in-lhs": 2,
"no-obj-calls": 2,
"no-proto": 2,
"no-unexpected-multiline": 2,
"no-unreachable": 2,
"use-isnan": 2,
"valid-typeof": 2,
// Best Practices
// http://eslint.org/docs/rules/#best-practices
"no-fallthrough": 2,
"no-octal": 2,
"no-redeclare": 2,
"no-self-assign": 2,
"no-unused-labels": 2,
// Strict Mode
// http://eslint.org/docs/rules/#strict-mode
"strict": [2, "never"],
// Variables
// http://eslint.org/docs/rules/#variables
"no-delete-var": 2,
"no-undef": 2,
"no-unused-vars": [2, {"args": "none"}],
// Node.js and CommonJS
// http://eslint.org/docs/rules/#nodejs-and-commonjs
"no-mixed-requires": 2,
"no-new-require": 2,
"no-path-concat": 2,
"no-restricted-modules": [2, "sys", "_linklist"],
// Stylistic Issues
// http://eslint.org/docs/rules/#stylistic-issues
"comma-spacing": 2,
"eol-last": 2,
"indent": [2, 2, {"SwitchCase": 1}],
"keyword-spacing": 2,
"max-len": [2, 120, 2],
"new-parens": 2,
"no-mixed-spaces-and-tabs": 2,
"no-multiple-empty-lines": [2, {"max": 2}],
"no-trailing-spaces": [2, {"skipBlankLines": false }],
"quotes": [2, "single", "avoid-escape"],
"semi": 2,
"space-before-blocks": [2, "always"],
"space-before-function-paren": [2, "never"],
"space-in-parens": [2, "never"],
"space-infix-ops": 2,
"space-unary-ops": 2,
// ECMAScript 6
// http://eslint.org/docs/rules/#ecmascript-6
"arrow-parens": [2, "always"],
"arrow-spacing": [2, {"before": true, "after": true}],
"constructor-super": 2,
"no-class-assign": 2,
"no-confusing-arrow": 2,
"no-const-assign": 2,
"no-dupe-class-members": 2,
"no-new-symbol": 2,
"no-this-before-super": 2,
"prefer-const": 2
},
"globals": {
"DTRACE_HTTP_CLIENT_REQUEST" : false,
"LTTNG_HTTP_CLIENT_REQUEST" : false,
"COUNTER_HTTP_CLIENT_REQUEST" : false,
"DTRACE_HTTP_CLIENT_RESPONSE" : false,
"LTTNG_HTTP_CLIENT_RESPONSE" : false,
"COUNTER_HTTP_CLIENT_RESPONSE" : false,
"DTRACE_HTTP_SERVER_REQUEST" : false,
"LTTNG_HTTP_SERVER_REQUEST" : false,
"COUNTER_HTTP_SERVER_REQUEST" : false,
"DTRACE_HTTP_SERVER_RESPONSE" : false,
"LTTNG_HTTP_SERVER_RESPONSE" : false,
"COUNTER_HTTP_SERVER_RESPONSE" : false,
"DTRACE_NET_STREAM_END" : false,
"LTTNG_NET_STREAM_END" : false,
"COUNTER_NET_SERVER_CONNECTION_CLOSE" : false,
"DTRACE_NET_SERVER_CONNECTION" : false,
"LTTNG_NET_SERVER_CONNECTION" : false,
"COUNTER_NET_SERVER_CONNECTION" : false
}
}

45
.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
# github pages site
_site
#transient test cases
examples/nosave.*.js
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
.nyc_output/
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directory
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
node_modules
.DS_Store
examples/*
ecosystem.config.js
.vscode
test/credentials/*.json
run-tests.sh
run-coverage.sh
.vscode

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Drachtio Communications Services, LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

25
README.md Normal file
View File

@@ -0,0 +1,25 @@
# talk-to-your-llm
This is a basic voicebot connected to using their Assistant framework.
## Installing
The basics:
```bash
npm ci
WS_PORT=3000 OPENAI_API_KEY=xxxx node app.js
```
In the jambonz webapp create an application with url `wss://<your-domain>/llm-voicebot` and route calls to it.
## Environment variables
|variable|meaning|required|
|---------|------|--------|
|OPENAI_API_KEY|Your api key|yes|
|OPENAI_MODEL|model to use|no (default: gpt-4-turbo)|
|BOT_NAME|Name of Assistant to create|no (default: jambonz-llm-voicebot)|
## Limitations
Currently, there is no support for adding [Tools](https://platform.openai.com/docs/assistants/tools) to the Assistant. User input is transcribed as presented as simple text and the OpenAI assistant is encouraged (via system instructions) to respond with text that is brief and un-annotated.

12
app.js Normal file
View File

@@ -0,0 +1,12 @@
const {createServer} = require('http');
const {createEndpoint} = require('@jambonz/node-client-ws');
const server = createServer();
const makeService = createEndpoint({server});
const logger = require('pino')({level: process.env.LOGLEVEL || 'info'});
const port = process.env.WS_PORT || 3000;
require('./lib/routes')({logger, makeService});
server.listen(port, () => {
logger.info(`jambonz websocket server listening at http://localhost:${port}`);
});

3
data/settings.json Normal file
View File

@@ -0,0 +1,3 @@
{
"system_instructions": "Answer questions directly without greeting the user. Respond with plain text only: no annotations, markup or citations. Separate paragraphs with 2 newline characters. Keep your responses to between 1-4 paragraphs if possible while still providing a complete answer."
}

4
lib/routes/index.js Normal file
View File

@@ -0,0 +1,4 @@
module.exports = ({logger, makeService}) => {
require('./llm-voicebot')({logger, makeService});
};

110
lib/routes/llm-voicebot.js Normal file
View File

@@ -0,0 +1,110 @@
const OpenAIAssistant = require('../utils/ai-assistants/llm-openai');
const {system_instructions} = require('../../data/settings.json');
const {processStreamingResponse, streamingResponseComplete} = require('../utils/process-streaming-response');
const service = ({logger, makeService}) => {
const assistant = new OpenAIAssistant({
logger,
model: process.env.OPENAI_MODEL || 'gpt-4-turbo',
name: process.env.BOT_NAME || 'jambonz-llm-voicebot',
instructions: system_instructions
});
assistant.init();
const svc = makeService({path: '/llm-voicebot'});
svc.on('session:new', (session, path) => {
session.locals = { ...session.locals,
logger: logger.child({call_sid: session.call_sid}),
deepgramOptions: {
endpointing: 350,
utteranceEndMs: 1000,
},
turns: 0,
says: 0,
textOffset: 0,
assistant
};
session.locals.logger.info({session, path}, `new incoming call: ${session.call_sid}`);
session
.on('/user-input-event', onUserInputEvent.bind(null, session))
.on('close', onClose.bind(null, session))
.on('error', onError.bind(null, session));
session
.answer()
.pause({length: 0.5})
.config({
recognizer: {
vendor: 'default',
language: 'default',
deepgramOptions: session.locals.deepgramOptions
},
bargeIn: {
enable: true,
input: ['speech'],
actionHook: '/user-input-event',
sticky: true,
}
})
.say({text: 'Hi there! You are speaking to chat GPT. What would you like to know?'})
.reply();
});
};
const onUserInputEvent = async(session, evt) => {
const {logger} = session.locals;
logger.info({evt}, 'got speech evt');
switch (evt.reason) {
case 'speechDetected':
handleUserUtterance(session, evt);
break;
case 'timeout':
break;
default:
session.reply();
break;
}
};
const handleUserUtterance = async(session, evt) => {
const {logger, assistant, thread} = session.locals;
const {speech} = evt;
const userMessage = speech.alternatives[0].transcript;
logger.info({utterance: speech.alternatives[0]}, 'handling user utterance');
if (!thread) {
const thread = await assistant.createThread(userMessage);
thread
.on('botStreamingResponse', (response) => {
processStreamingResponse(session, response);
})
.on('botStreamingCompleted', () => {
streamingResponseComplete(session);
});
session.locals.thread = thread;
}
else {
thread.addUserMessage(speech.alternatives[0].transcript);
}
session.reply();
};
const onClose = (session) => {
const {logger, thread} = session.locals;
logger.info('call ended');
if (thread) {
thread.close();
}
};
const onError = (session, err) => {
const {logger} = session.locals;
logger.error(err, 'Error in call');
};
module.exports = service;

View File

@@ -0,0 +1,98 @@
const OpenAI = require('openai');
const openai = new OpenAI();
const Emitter = require('events');
/* TODO: in future, add support for other AI Assistants beyond OpenAI */
class OpenAIThread extends Emitter {
constructor(logger, assistant_id, thread, run) {
super();
this.logger = logger;
this.assistant_id = assistant_id;
this.thread = thread;
this.run = run;
this._attachListeners();
}
_attachListeners() {
this.run
.on('event', (evt) => {
if (evt.event === 'thread.run.completed') {
this.emit('botStreamingCompleted');
}
})
.on('textDelta', (delta, snapshot) => {
this.emit('botStreamingResponse', snapshot.value);
})
.on('connect', () => this.logger.info('connected'))
.on('end', () => this.logger.info('ended'));
}
async addUserMessage(userMessage) {
this.logger.info('adding user message');
await openai.beta.threads.messages.create(this.thread.id, {
role: 'user',
content: userMessage,
});
this.run = await openai.beta.threads.runs.stream(this.thread.id, {
assistant_id: this.assistant_id
});
this._attachListeners();
}
async close() {
await openai.beta.threads.del(this.thread.id);
}
}
class OpenAIAssistant {
constructor({logger, model, name, instructions}) {
this.logger = logger;
this.model = model;
this.name = name;
this.instructions = instructions;
}
async init() {
try {
this.assistant = await openai.beta.assistants.create({
model: this.model,
name: this.name,
instructions: this.instructions,
});
} catch (err) {
this.logger.error(err);
throw err;
}
}
async createThread(initialUserMessage) {
/* create a thread */
try {
this.logger.info({initialUserMessage}, `creating thread with message: ${initialUserMessage}`);
const thread = await openai.beta.threads.create({
messages: [
{
role: 'user',
content: initialUserMessage,
},
],
});
/* run the thread */
this.logger.info('running thread');
const run = await openai.beta.threads.runs.stream(thread.id, {
assistant_id: this.assistant.id,
});
const t = new OpenAIThread(this.logger, this.assistant.id, thread, run);
return t;
} catch (err) {
this.logger.error(err);
throw err;
}
}
}
module.exports = OpenAIAssistant;

View File

@@ -0,0 +1,66 @@
const processStreamingResponse = async(session, text) => {
const {logger, says, textOffset} = session.locals;
let spoken = false;
/* trim the leading part of the text we have already said */
const trimmed = text.substring(textOffset);
/**
* when we have a new response, say the first sentence as soon as available to get it out,
* after that fall back to speaking the rest of the response in chunks of paragraphs
*/
if (says === 0) {
const pos = trimmed.indexOf('.');
if (-1 !== pos) {
const firstSentence = trimmed.substring(0, pos + 1);
logger.info(`speaking first sentence: ${firstSentence}`);
session
.say({text: firstSentence})
.send({execImmediate: true});
session.locals.says++;
session.locals.textOffset = pos + 1;
spoken = true;
session.locals.unsent = trimmed.substring(pos + 1);
}
}
if (says > 0) {
const pos = trimmed.indexOf('\n\n');
if (-1 !== pos) {
const paragraph = trimmed.substring(0, pos);
logger.info(`speaking paragraph: ${paragraph}`);
session
.say({text: paragraph})
.send({execImmediate: false});
session.locals.says++;
session.locals.textOffset += (pos + 2);
spoken = true;
session.locals.unsent = trimmed.substring(pos + 2);
}
}
if (!spoken) {
session.locals.unsent = trimmed;
}
};
const streamingResponseComplete = async(session) => {
const {logger, unsent} = session.locals;
if (unsent) {
logger.info('sending final unsent text');
session
.say({text: unsent})
.send({execImmediate: false});
}
session.locals.turns++;
session.locals.says = 0;
session.locals.textOffset = 0;
session.locals.unsent = '';
};
module.exports = {
processStreamingResponse,
streamingResponseComplete
};

1682
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "talk-to-your-llm",
"version": "0.0.1",
"description": "jambonz websocket application",
"main": "app.js",
"scripts": {
"start": "node app",
"jslint": "eslint app.js lib"
},
"author": "",
"license": "MIT",
"dependencies": {
"@jambonz/node-client-ws": "^0.1.41",
"bent": "^7.3.12",
"openai": "^4.33.0",
"pino": "^8.20.0"
},
"devDependencies": {
"eslint": "^8.57.0",
"eslint-plugin-promise": "^6.1.1"
}
}