Adding OpenAI Realtime API and multiple tools

This commit is contained in:
MervinPraison committed 2024-10-08 01:10:08 +01:00
1 parent 1efea38d88
commit 9b4b987dfa
3 files changed
+318 -136

No files matched your search

+1 -7
View File
@@ -426,16 +426,10 @@ class PraisonAI:
def create_realtime_interface(self):
"""
Create a Chainlit interface for the realtime voice interaction application.
This function sets up a Chainlit application for real-time voice interaction with AI.
It uses the OpenAI Realtime Client for processing audio input and generating responses.
Returns:
None: This function does not return any value. It starts the Chainlit application.
"""
if CHAINLIT_AVAILABLE:
import praisonai
os.environ["CHAINLIT_PORT"] = "8088"
os.environ["CHAINLIT_PORT"] = "8088" # Ensure this port is not in use by another service
root_path = os.path.join(os.path.expanduser("~"), ".praison")
os.environ["CHAINLIT_APP_ROOT"] = root_path
public_folder = os.path.join(os.path.dirname(praisonai.__file__), 'public')
+303 -109
View File
@@ -1,92 +1,156 @@
import os
import asyncio
import sqlite3
from datetime import datetime
from uuid import uuid4
from openai import AsyncOpenAI
import chainlit as cl
from uuid import uuid4
from chainlit.logger import logger
from chainlit.input_widget import TextInput
from chainlit.types import ThreadDict
from realtimeclient import RealtimeClient
from realtimeclient.tools import tools
from sql_alchemy import SQLAlchemyDataLayer
import chainlit.data as cl_data
from literalai.helper import utc_now
import json
from dotenv import load_dotenv
from chainlit.input_widget import TextInput
import logging
from PIL import Image
import io
import base64
load_dotenv()
# Set up CHAINLIT_AUTH_SECRET
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
# Set up logging
logger = logging.getLogger(__name__)
log_level = os.getenv("LOGLEVEL", "DEBUG").upper()
logger.setLevel(log_level)
if not CHAINLIT_AUTH_SECRET:
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
# Database path
DB_PATH = os.path.expanduser("~/.praison/database.sqlite")
def initialize_db():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY,
identifier TEXT NOT NULL UNIQUE,
metadata JSONB NOT NULL,
createdAt TEXT
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS threads (
id UUID PRIMARY KEY,
createdAt TEXT,
name TEXT,
userId UUID,
userIdentifier TEXT,
tags TEXT[],
metadata JSONB NOT NULL DEFAULT '{}',
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS steps (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
threadId UUID NOT NULL,
parentId UUID,
disableFeedback BOOLEAN NOT NULL DEFAULT 0,
streaming BOOLEAN NOT NULL DEFAULT 0,
waitForAnswer BOOLEAN DEFAULT 0,
isError BOOLEAN NOT NULL DEFAULT 0,
metadata JSONB DEFAULT '{}',
tags TEXT[],
input TEXT,
output TEXT,
createdAt TEXT,
start TEXT,
end TEXT,
generation JSONB,
showInput TEXT,
language TEXT,
indent INT,
FOREIGN KEY (threadId) REFERENCES threads (id) ON DELETE CASCADE
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS elements (
id UUID PRIMARY KEY,
threadId UUID,
type TEXT,
url TEXT,
chainlitKey TEXT,
name TEXT NOT NULL,
display TEXT,
objectKey TEXT,
size TEXT,
page INT,
language TEXT,
forId UUID,
mime TEXT,
FOREIGN KEY (threadId) REFERENCES threads (id) ON DELETE CASCADE
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS feedbacks (
id UUID PRIMARY KEY,
forId UUID NOT NULL,
value INT NOT NULL,
threadId UUID,
comment TEXT
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE,
value TEXT
)
''')
conn.commit()
conn.close()
def save_setting(key: str, value: str):
"""Saves a setting to the database."""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"""
INSERT OR REPLACE INTO settings (id, key, value)
VALUES ((SELECT id FROM settings WHERE key = ?), ?, ?)
""",
(key, key, value),
)
conn.commit()
conn.close()
def load_setting(key: str) -> str:
"""Loads a setting from the database."""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute('SELECT value FROM settings WHERE key = ?', (key,))
result = cursor.fetchone()
conn.close()
return result[0] if result else None
# Initialize the database
initialize_db()
# Set up SQLAlchemy data layer
cl_data._data_layer = SQLAlchemyDataLayer(conninfo=f"sqlite+aiosqlite:///{DB_PATH}")
client = AsyncOpenAI()
async def setup_openai_realtime():
"""Instantiate and configure the OpenAI Realtime Client"""
openai_realtime = RealtimeClient(api_key=os.getenv("OPENAI_API_KEY"))
cl.user_session.set("track_id", str(uuid4()))
async def handle_conversation_updated(event):
item = event.get("item")
delta = event.get("delta")
"""Currently used to stream audio back to the client."""
if delta:
# Debug logging
# debug_delta = {k: f"<{type(v).__name__} of length {len(v)}>" if isinstance(v, (bytes, bytearray)) else v for k, v in delta.items()}
# logger.debug(f"Delta received: {json.dumps(debug_delta, indent=2)}")
# Only one of the following will be populated for any given event
if 'audio' in delta:
audio = delta['audio'] # Int16Array, audio added
await cl.context.emitter.send_audio_chunk(cl.OutputAudioChunk(mimeType="pcm16", data=audio, track=cl.user_session.get("track_id")))
if 'transcript' in delta:
transcript = delta['transcript'] # string, transcript added
# logger.debug(f"Transcript delta: {transcript}")
if 'text' in delta:
text = delta['text'] # string, text added
# logger.debug(f"Text delta: {text}")
if 'arguments' in delta:
arguments = delta['arguments'] # string, function arguments added
# logger.debug(f"Function arguments delta: {arguments}")
async def handle_item_completed(event):
"""Used to populate the chat context with transcription once an item is completed."""
try:
item = event.get("item")
# logger.debug(f"Item completed: {json.dumps(item, indent=2, default=str)}")
openai_realtime = cl.user_session.get("openai_realtime")
await openai_realtime._send_chainlit_message(item)
except Exception as e:
error_message = f"Error in handle_item_completed: {str(e)}"
logger.error(error_message)
debug_item = json.dumps(item, indent=2, default=str)
logger.error(f"Item causing error: {debug_item}")
async def handle_conversation_interrupt(event):
"""Used to cancel the client previous audio playback."""
cl.user_session.set("track_id", str(uuid4()))
await cl.context.emitter.send_audio_interrupt()
async def handle_error(event):
logger.error(event)
await cl.Message(content=f"Error: {event}", author="System").send()
openai_realtime.on('conversation.updated', handle_conversation_updated)
openai_realtime.on('conversation.item.completed', handle_item_completed)
openai_realtime.on('conversation.interrupted', handle_conversation_interrupt)
openai_realtime.on('error', handle_error)
cl.user_session.set("openai_realtime", openai_realtime)
coros = [openai_realtime.add_tool(tool_def, tool_handler) for tool_def, tool_handler in tools]
await asyncio.gather(*coros)
@cl.on_chat_start
async def start():
model_name = os.getenv("MODEL_NAME", "gpt-4o-mini")
initialize_db()
model_name = load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-4o-mini")
cl.user_session.set("model_name", model_name)
cl.user_session.set("message_history", []) # Initialize message history
logger.debug(f"Model name: {model_name}")
settings = cl.ChatSettings(
[
TextInput(
@@ -99,57 +163,122 @@ async def start():
)
cl.user_session.set("settings", settings)
await settings.send()
await cl.Message(
content="Welcome to the Chainlit x OpenAI realtime example. Press `P` to talk!"
).send()
await setup_openai_realtime()
@cl.on_message
async def on_message(message: cl.Message):
openai_realtime: RealtimeClient = cl.user_session.get("openai_realtime")
message_history = cl.user_session.get("message_history", [])
if openai_realtime and openai_realtime.is_connected():
current_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
prompt = f"Current time Just for reference: {current_date}\n\n{message.content}"
# Add user message to history
message_history.append({"role": "user", "content": prompt})
cl.user_session.set("message_history", message_history)
await openai_realtime.send_user_message_content([{ "type": 'input_text', "text": message.content }])
else:
await cl.Message(content="Please activate voice mode before sending messages!").send()
async def setup_openai_realtime():
"""Instantiate and configure the OpenAI Realtime Client"""
openai_realtime = RealtimeClient(api_key=os.getenv("OPENAI_API_KEY"))
cl.user_session.set("track_id", str(uuid4()))
async def handle_conversation_updated(event):
item = event.get("item")
delta = event.get("delta")
"""Currently used to stream audio back to the client."""
if delta:
if 'audio' in delta:
audio = delta['audio'] # Int16Array, audio added
await cl.context.emitter.send_audio_chunk(cl.OutputAudioChunk(mimeType="pcm16", data=audio, track=cl.user_session.get("track_id")))
if 'transcript' in delta:
transcript = delta['transcript'] # string, transcript added
logger.debug(f"Transcript delta: {transcript}")
if 'text' in delta:
text = delta['text'] # string, text added
logger.debug(f"Text delta: {text}")
if 'arguments' in delta:
arguments = delta['arguments'] # string, function arguments added
logger.debug(f"Function arguments delta: {arguments}")
async def handle_item_completed(event):
"""Used to populate the chat context with transcription once an item is completed."""
try:
item = event.get("item")
logger.debug(f"Item completed: {json.dumps(item, indent=2, default=str)}")
await openai_realtime._send_chainlit_message(item)
# Add assistant message to history
message_history = cl.user_session.get("message_history", [])
content = item.get("formatted", {}).get("text", "") or item.get("formatted", {}).get("transcript", "")
if content:
message_history.append({"role": "assistant", "content": content})
cl.user_session.set("message_history", message_history)
except Exception as e:
error_message = f"Error in handle_item_completed: {str(e)}"
logger.error(error_message)
debug_item = json.dumps(item, indent=2, default=str)
logger.error(f"Item causing error: {debug_item}")
async def handle_conversation_interrupt(event):
"""Used to cancel the client previous audio playback."""
cl.user_session.set("track_id", str(uuid4()))
await cl.context.emitter.send_audio_interrupt()
async def handle_error(event):
logger.error(event)
await cl.Message(content=f"Error: {event}", author="System").send()
openai_realtime.on('conversation.updated', handle_conversation_updated)
openai_realtime.on('conversation.item.completed', handle_item_completed)
openai_realtime.on('conversation.interrupted', handle_conversation_interrupt)
openai_realtime.on('error', handle_error)
cl.user_session.set("openai_realtime", openai_realtime)
coros = [openai_realtime.add_tool(tool_def, tool_handler) for tool_def, tool_handler in tools]
await asyncio.gather(*coros)
@cl.on_settings_update
async def setup_agent(settings):
logger.debug(settings)
cl.user_session.set("settings", settings)
model_name = settings["model_name"]
cl.user_session.set("model_name", model_name)
@cl.on_message
async def on_message(message: cl.Message):
openai_realtime: RealtimeClient = cl.user_session.get("openai_realtime")
if openai_realtime and openai_realtime.is_connected():
# Check if an image was uploaded with this message
image = None
if message.elements and isinstance(message.elements[0], cl.Image):
image_element = message.elements[0]
try:
# Open the image and keep it in memory
image = Image.open(image_element.path)
image.load() # This ensures the file is fully loaded into memory
cl.user_session.set("image", image)
except Exception as e:
logger.error(f"Error processing image: {str(e)}")
await cl.Message(content="There was an error processing the uploaded image. Please try again.").send()
return
# Prepare user message
user_message = [{ "type": 'input_text', "text": message.content }]
# If an image is uploaded, include it in the message
if image:
buffered = io.BytesIO()
image.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode()
user_message.append({ "type": 'image', "image_url": f"data:image/png;base64,{img_str}" })
await openai_realtime.send_user_message_content(user_message)
else:
await cl.Message(content="Please activate voice mode before sending messages!").send()
# Save in settings table
save_setting("model_name", model_name)
# Save in thread metadata
thread_id = cl.user_session.get("thread_id")
if thread_id:
thread = await cl_data._data_layer.get_thread(thread_id)
if thread:
metadata = thread.get("metadata", {})
if isinstance(metadata, str):
try:
metadata = json.loads(metadata)
except json.JSONDecodeError:
metadata = {}
metadata["model_name"] = model_name
# Always store metadata as a dictionary
await cl_data._data_layer.update_thread(thread_id, metadata=metadata)
# Update the user session with the new metadata
cl.user_session.set("metadata", metadata)
@cl.on_audio_start
async def on_audio_start():
try:
openai_realtime: RealtimeClient = cl.user_session.get("openai_realtime")
# TODO: might want to recreate items to restore context
# openai_realtime.create_conversation_item(item)
await openai_realtime.connect()
logger.info("Connected to OpenAI realtime")
return True
@@ -171,4 +300,69 @@ async def on_audio_chunk(chunk: cl.InputAudioChunk):
async def on_end():
openai_realtime: RealtimeClient = cl.user_session.get("openai_realtime")
if openai_realtime and openai_realtime.is_connected():
await openai_realtime.disconnect()
await openai_realtime.disconnect()
@cl.password_auth_callback
def auth_callback(username: str, password: str):
# You can customize this function to use your own authentication logic
expected_username = os.getenv("CHAINLIT_USERNAME", "admin")
expected_password = os.getenv("CHAINLIT_PASSWORD", "admin")
if (username, password) == (expected_username, expected_password):
return cl.User(
identifier=username, metadata={"role": "ADMIN", "provider": "credentials"}
)
else:
return None
@cl.on_chat_resume
async def on_chat_resume(thread: ThreadDict):
logger.info(f"Resuming chat: {thread['id']}")
model_name = load_setting("model_name") or os.getenv("MODEL_NAME") or "gpt-4o-mini"
logger.debug(f"Model name: {model_name}")
settings = cl.ChatSettings(
[
TextInput(
id="model_name",
label="Enter the Model Name",
placeholder="e.g., gpt-4o-mini",
initial=model_name
)
]
)
await settings.send()
thread_id = thread["id"]
cl.user_session.set("thread_id", thread["id"])
# Ensure metadata is a dictionary
metadata = thread.get("metadata", {})
if isinstance(metadata, str):
try:
metadata = json.loads(metadata)
except json.JSONDecodeError:
metadata = {}
cl.user_session.set("metadata", metadata)
message_history = []
steps = thread["steps"]
for message in steps:
msg_type = message.get("type")
if msg_type == "user_message":
message_history.append({"role": "user", "content": message.get("output", "")})
elif msg_type == "assistant_message":
message_history.append({"role": "assistant", "content": message.get("output", "")})
elif msg_type == "run":
# Handle 'run' type messages
if message.get("isError"):
message_history.append({"role": "system", "content": f"Error: {message.get('output', '')}"})
else:
# You might want to handle non-error 'run' messages differently
pass
else:
logger.warning(f"Message without recognized type: {message}")
cl.user_session.set("message_history", message_history)
# Reconnect to OpenAI realtime
await setup_openai_realtime()
+14 -20
View File
@@ -451,24 +451,13 @@ class RealtimeClient(RealtimeEventHandler):
self.dispatch("conversation.item.appended", {"item": item})
if item and item["status"] == "completed":
self.dispatch("conversation.item.completed", {"item": item})
# Send Chainlit message
asyncio.create_task(self._send_chainlit_message(item))
async def _on_output_item_done(self, event):
item, delta = self._process_event(event)
if item:
if item.get("status") == "completed":
self.dispatch("conversation.item.completed", {"item": item})
try:
await self._send_chainlit_message(item)
except Exception as e:
logger.error(f"Error in _on_output_item_done when sending message: {str(e)}")
logger.error(f"Item causing error: {json.dumps(item, indent=2)}")
if item.get("formatted", {}).get("tool"):
await self._call_tool(item["formatted"]["tool"])
else:
logger.warning("_on_output_item_done received an empty item")
if item and item["status"] == "completed":
self.dispatch("conversation.item.completed", {"item": item})
if item and item.get("formatted", {}).get("tool"):
await self._call_tool(item["formatted"]["tool"])
async def _call_tool(self, tool):
try:
@@ -485,12 +474,13 @@ class RealtimeClient(RealtimeEventHandler):
}
})
except Exception as e:
logger.error(f"Tool call error: {json.dumps({'error': str(e)})}")
error_message = json.dumps({"error": str(e)})
logger.error(f"Tool call error: {error_message}")
await self.realtime.send("conversation.item.create", {
"item": {
"type": "function_call_output",
"call_id": tool["call_id"],
"output": json.dumps({"error": str(e)}),
"output": error_message,
}
})
await self.create_response()
@@ -644,13 +634,17 @@ class RealtimeClient(RealtimeEventHandler):
elif "role" in item:
if item["role"] == "user":
content = item.get("formatted", {}).get("text", "") or item.get("formatted", {}).get("transcript", "")
await cl.Message(content=content, author="User").send()
if content:
await cl.Message(content=content, author="User").send()
elif item["role"] == "assistant":
content = item.get("formatted", {}).get("text", "") or item.get("formatted", {}).get("transcript", "")
await cl.Message(content=content, author="AI").send()
if content:
await cl.Message(content=content, author="AI").send()
else:
logger.warning(f"Unhandled role: {item['role']}")
else:
# Handle items without a 'role' or 'type'
logger.debug(f"Unhandled item type:\n{json.dumps(item, indent=2)}")
# Additional debug logging
logger.debug(f"Processed Chainlit message for item: {item.get('id', 'unknown')}")
logger.debug(f"Processed Chainlit message for item: {item.get('id', 'unknown')}")