mirror of
https://github.com/jambonz/talk-to-your-llm.git
synced 2025-12-18 19:47:43 +00:00
initial checkin
This commit is contained in:
1
.eslintignore
Normal file
1
.eslintignore
Normal file
@@ -0,0 +1 @@
|
||||
test/*
|
||||
126
.eslintrc.json
Normal file
126
.eslintrc.json
Normal 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
45
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
25
README.md
Normal 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
12
app.js
Normal 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
3
data/settings.json
Normal 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
4
lib/routes/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = ({logger, makeService}) => {
|
||||
require('./llm-voicebot')({logger, makeService});
|
||||
};
|
||||
|
||||
110
lib/routes/llm-voicebot.js
Normal file
110
lib/routes/llm-voicebot.js
Normal 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;
|
||||
98
lib/utils/ai-assistants/llm-openai.js
Normal file
98
lib/utils/ai-assistants/llm-openai.js
Normal 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;
|
||||
66
lib/utils/process-streaming-response.js
Normal file
66
lib/utils/process-streaming-response.js
Normal 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
1682
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user