mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 08:11:07 -10:00
Cleanup: Delete ~5,337 LOC dead Chainlit code and wire aiui realtime (fixes #1524)
- Fix serve.py:364 broken import (_launch_chainlit_ui → _launch_aiui_app) - Wire ui_realtime/default_app.py to use OpenAIRealtimeManager from aiui - Replace create_realtime_interface calls with _launch_aiui_app in cli/main.py - Delete dead Chainlit scaffolding: CHAINLIT_AVAILABLE, _get_chainlit_run(), 4 dead interface methods - Delete 12 dead files (~5,337 LOC): chainlit_compat.py, db.py, sql_alchemy.py, etc. - Update doctor check: chainlit → praisonaiui - Remove 'pending' messaging from realtime UI (aiui already ships WebRTC) - Keep --ui chainlit deprecation warning for backward compatibility 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: MervinPraison <[email protected]>
This commit is contained in:
1 parent
f8fe440789
commit
648291e3e3
20 files changed
+24
-6741
No files matched your search
@@ -1,304 +0,0 @@
|
||||
# praisonai/chainlit_ui.py
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
from praisonai.auto import AutoGenerator
|
||||
import chainlit as cl
|
||||
import os
|
||||
from chainlit.types import ThreadDict
|
||||
from chainlit.input_widget import Select, TextInput
|
||||
from typing import Optional
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
from contextlib import redirect_stdout
|
||||
from io import StringIO
|
||||
import logging
|
||||
logging.basicConfig(level=os.environ.get('LOGLEVEL', 'INFO').upper(), format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
framework = "crewai"
|
||||
config_list = [
|
||||
{
|
||||
'model': os.environ.get("OPENAI_MODEL_NAME", "gpt-4o-mini"),
|
||||
'base_url': os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1"),
|
||||
'api_key': os.environ.get("OPENAI_API_KEY", "")
|
||||
}
|
||||
]
|
||||
agent_file = "test.yaml"
|
||||
|
||||
actions=[
|
||||
cl.Action(name="run", payload={"value": "run"}, label="run"),
|
||||
cl.Action(name="modify", payload={"value": "modify"}, label="Modify"),
|
||||
]
|
||||
|
||||
@cl.action_callback("run")
|
||||
async def on_run(action):
|
||||
await main(cl.Message(content=""))
|
||||
|
||||
@cl.action_callback("modify")
|
||||
async def on_modify(action):
|
||||
await cl.Message(content="Modify the agents and tools from below settings").send()
|
||||
|
||||
|
||||
@cl.set_chat_profiles
|
||||
async def set_profiles(current_user: cl.User):
|
||||
return [
|
||||
cl.ChatProfile(
|
||||
name="Auto",
|
||||
markdown_description="Automatically generate agents and tasks based on your input.",
|
||||
starters=[
|
||||
cl.Starter(
|
||||
label="Create a movie script",
|
||||
message="Create a movie script about a futuristic society where AI and humans coexist, focusing on the conflict and resolution between them. Start with an intriguing opening scene.",
|
||||
icon="/public/movie.svg",
|
||||
),
|
||||
cl.Starter(
|
||||
label="Design a fantasy world",
|
||||
message="Design a detailed fantasy world with unique geography, cultures, and magical systems. Start by describing the main continent and its inhabitants.",
|
||||
icon="/public/fantasy.svg",
|
||||
),
|
||||
cl.Starter(
|
||||
label="Write a futuristic political thriller",
|
||||
message="Write a futuristic political thriller involving a conspiracy within a global government. Start with a high-stakes meeting that sets the plot in motion.",
|
||||
icon="/public/thriller.svg",
|
||||
),
|
||||
cl.Starter(
|
||||
label="Develop a new board game",
|
||||
message="Develop a new, innovative board game. Describe the game's objective, rules, and unique mechanics. Create a scenario to illustrate gameplay.",
|
||||
icon="/public/game.svg",
|
||||
),
|
||||
]
|
||||
),
|
||||
cl.ChatProfile(
|
||||
name="Manual",
|
||||
markdown_description="Manually define your agents and tasks using a YAML file.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@cl.on_chat_start
|
||||
async def start_chat():
|
||||
cl.user_session.set(
|
||||
"message_history",
|
||||
[{"role": "system", "content": "You are a helpful assistant."}],
|
||||
)
|
||||
|
||||
# Create tools.py if it doesn't exist
|
||||
if not os.path.exists("tools.py"):
|
||||
with open("tools.py", "w") as f:
|
||||
f.write("# Add your custom tools here\n")
|
||||
|
||||
settings = await cl.ChatSettings(
|
||||
[
|
||||
TextInput(id="Model", label="OpenAI - Model", initial=config_list[0]['model']),
|
||||
TextInput(id="BaseUrl", label="OpenAI - Base URL", initial=config_list[0]['base_url']),
|
||||
TextInput(id="ApiKey", label="OpenAI - API Key", initial=config_list[0]['api_key']),
|
||||
Select(
|
||||
id="Framework",
|
||||
label="Framework",
|
||||
values=["crewai", "autogen"],
|
||||
initial_index=0,
|
||||
),
|
||||
]
|
||||
).send()
|
||||
cl.user_session.set("settings", settings)
|
||||
chat_profile = cl.user_session.get("chat_profile")
|
||||
if chat_profile=="Manual":
|
||||
|
||||
agent_file = "agents.yaml"
|
||||
full_agent_file_path = os.path.abspath(agent_file) # Get full path
|
||||
if os.path.exists(full_agent_file_path):
|
||||
with open(full_agent_file_path, 'r') as f:
|
||||
yaml_content = f.read()
|
||||
msg = cl.Message(content=yaml_content, language="yaml")
|
||||
await msg.send()
|
||||
|
||||
|
||||
full_tools_file_path = os.path.abspath("tools.py") # Get full path
|
||||
if os.path.exists(full_tools_file_path):
|
||||
with open(full_tools_file_path, 'r') as f:
|
||||
tools_content = f.read()
|
||||
msg = cl.Message(content=tools_content, language="python")
|
||||
await msg.send()
|
||||
|
||||
settings = await cl.ChatSettings(
|
||||
[
|
||||
TextInput(id="Model", label="OpenAI - Model", initial=config_list[0]['model']),
|
||||
TextInput(id="BaseUrl", label="OpenAI - Base URL", initial=config_list[0]['base_url']),
|
||||
TextInput(id="ApiKey", label="OpenAI - API Key", initial=config_list[0]['api_key']),
|
||||
Select(
|
||||
id="Framework",
|
||||
label="Framework",
|
||||
values=["crewai", "autogen"],
|
||||
initial_index=0,
|
||||
),
|
||||
TextInput(id="agents", label="agents.yaml", initial=yaml_content, multiline=True),
|
||||
TextInput(id="tools", label="tools.py", initial=tools_content, multiline=True),
|
||||
]
|
||||
).send()
|
||||
cl.user_session.set("settings", settings)
|
||||
|
||||
res = await cl.AskActionMessage(
|
||||
content="Pick an action!",
|
||||
actions=actions,
|
||||
).send()
|
||||
if res and res.get("value") == "modify":
|
||||
await cl.Message(content="Modify the agents and tools from below settings", actions=actions).send()
|
||||
elif res and res.get("value") == "run":
|
||||
await main(cl.Message(content="", actions=actions))
|
||||
|
||||
await on_settings_update(settings)
|
||||
|
||||
@cl.on_settings_update
|
||||
async def on_settings_update(settings):
|
||||
"""Handle updates to the ChatSettings form."""
|
||||
global config_list, framework
|
||||
config_list[0]['model'] = settings["Model"]
|
||||
config_list[0]['base_url'] = settings["BaseUrl"]
|
||||
config_list[0]['api_key'] = settings["ApiKey"]
|
||||
os.environ["OPENAI_API_KEY"] = config_list[0]['api_key']
|
||||
os.environ["OPENAI_MODEL_NAME"] = config_list[0]['model']
|
||||
os.environ["OPENAI_API_BASE"] = config_list[0]['base_url']
|
||||
framework = settings["Framework"]
|
||||
|
||||
if "agents" in settings:
|
||||
with open("agents.yaml", "w") as f:
|
||||
f.write(settings["agents"])
|
||||
if "tools" in settings:
|
||||
with open("tools.py", "w") as f:
|
||||
f.write(settings["tools"])
|
||||
|
||||
print("Settings updated")
|
||||
|
||||
@cl.on_chat_resume
|
||||
async def on_chat_resume(thread: ThreadDict):
|
||||
message_history = cl.user_session.get("message_history", [])
|
||||
root_messages = [m for m in thread["steps"] if m["parentId"] is None]
|
||||
for message in root_messages:
|
||||
if message["type"] == "user_message":
|
||||
message_history.append({"role": "user", "content": message["output"]})
|
||||
elif message["type"] == "ai_message":
|
||||
message_history.append({"role": "assistant", "content": message["content"]})
|
||||
cl.user_session.set("message_history", message_history)
|
||||
|
||||
# @cl.step(type="tool")
|
||||
# async def tool(data: Optional[str] = None, language: Optional[str] = None):
|
||||
# return cl.Message(content=data, language=language)
|
||||
|
||||
@cl.step(type="tool", show_input=False)
|
||||
async def run_agents(agent_file: str, framework: str):
|
||||
"""Runs the agents and returns the result."""
|
||||
agents_generator = AgentsGenerator(agent_file, framework, config_list)
|
||||
current_step = cl.context.current_step
|
||||
print("Current Step:", current_step)
|
||||
|
||||
stdout_buffer = StringIO()
|
||||
with redirect_stdout(stdout_buffer):
|
||||
result = agents_generator.generate_crew_and_kickoff()
|
||||
|
||||
complete_output = stdout_buffer.getvalue()
|
||||
|
||||
async with cl.Step(name="gpt4", type="llm", show_input=True) as step:
|
||||
step.input = ""
|
||||
|
||||
for line in stdout_buffer.getvalue().splitlines():
|
||||
print(line)
|
||||
await step.stream_token(line)
|
||||
|
||||
tool_res = await output(complete_output)
|
||||
|
||||
yield result
|
||||
|
||||
@cl.step(type="tool", show_input=False, language="yaml")
|
||||
async def output(output):
|
||||
return output
|
||||
|
||||
@cl.step(type="tool", show_input=False, language="yaml")
|
||||
def agent(output):
|
||||
return(f"""
|
||||
Agent Step Completed!
|
||||
Output: {output}
|
||||
""")
|
||||
|
||||
@cl.step(type="tool", show_input=False, language="yaml")
|
||||
def task(output):
|
||||
return(f"""
|
||||
Task Completed!
|
||||
Task: {output.description}
|
||||
Output: {output.raw_output}
|
||||
{output}
|
||||
""")
|
||||
|
||||
@cl.on_message
|
||||
async def main(message: cl.Message):
|
||||
"""Run PraisonAI with the provided message as the topic."""
|
||||
message_history = cl.user_session.get("message_history")
|
||||
if message_history is None:
|
||||
message_history = []
|
||||
cl.user_session.set("message_history", message_history)
|
||||
message_history.append({"role": "user", "content": message.content})
|
||||
topic = message.content
|
||||
chat_profile = cl.user_session.get("chat_profile")
|
||||
|
||||
if chat_profile == "Auto":
|
||||
agent_file = "agents.yaml"
|
||||
generator = AutoGenerator(topic=topic, agent_file=agent_file, framework=framework, config_list=config_list)
|
||||
await cl.sleep(2)
|
||||
agent_file = generator.generate()
|
||||
agents_generator = AgentsGenerator(
|
||||
agent_file,
|
||||
framework,
|
||||
config_list,
|
||||
# agent_callback=agent,
|
||||
# task_callback=task
|
||||
)
|
||||
# Capture stdout
|
||||
stdout_buffer = StringIO()
|
||||
with redirect_stdout(stdout_buffer):
|
||||
result = agents_generator.generate_crew_and_kickoff()
|
||||
|
||||
complete_output = stdout_buffer.getvalue()
|
||||
tool_res = await output(complete_output)
|
||||
msg = cl.Message(content=result)
|
||||
await msg.send()
|
||||
message_history.append({"role": "assistant", "content": message.content})
|
||||
else: # chat_profile == "Manual"
|
||||
agent_file = "agents.yaml"
|
||||
full_agent_file_path = os.path.abspath(agent_file) # Get full path
|
||||
full_tools_file_path = os.path.abspath("tools.py")
|
||||
if os.path.exists(full_agent_file_path):
|
||||
with open(full_agent_file_path, 'r') as f:
|
||||
yaml_content = f.read()
|
||||
# tool_res = await tool()
|
||||
msg_agents = cl.Message(content=yaml_content, language="yaml")
|
||||
await msg_agents.send()
|
||||
if os.path.exists(full_tools_file_path):
|
||||
with open(full_tools_file_path, 'r') as f:
|
||||
tools_content = f.read()
|
||||
msg_tools = cl.Message(content=tools_content, language="python")
|
||||
await msg_tools.send()
|
||||
else:
|
||||
# If the file doesn't exist, follow the same process as "Auto"
|
||||
generator = AutoGenerator(topic=topic, agent_file=agent_file, framework=framework, config_list=config_list)
|
||||
agent_file = generator.generate()
|
||||
|
||||
agents_generator = AgentsGenerator(agent_file, framework, config_list)
|
||||
result = agents_generator.generate_crew_and_kickoff()
|
||||
msg = cl.Message(content=result, actions=actions)
|
||||
await msg.send()
|
||||
message_history.append({"role": "assistant", "content": message.content})
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
# Get username and password from environment variables
|
||||
username = os.getenv("CHAINLIT_USERNAME", "admin") # Default to "admin" if not found
|
||||
password = os.getenv("CHAINLIT_PASSWORD", "admin") # Default to "admin" if not found
|
||||
|
||||
@cl.password_auth_callback
|
||||
def auth_callback(username: str, password: str):
|
||||
# Fetch the user matching username from your database
|
||||
# and compare the hashed password with the value stored in the database
|
||||
if (username, password) == (username, password):
|
||||
return cl.User(
|
||||
identifier=username, metadata={"role": "ADMIN", "provider": "credentials"}
|
||||
)
|
||||
else:
|
||||
return None
|
||||
@@ -1,125 +0,0 @@
|
||||
"""
|
||||
PraisonAI Chat - Default Chainlit Application
|
||||
|
||||
This is the default chat application that runs when using `praisonai chat`.
|
||||
It integrates with PraisonAI agents for multi-agent conversations.
|
||||
"""
|
||||
|
||||
import os
|
||||
import chainlit as cl
|
||||
|
||||
# Check if we're in PraisonAI Chat mode with registered agents
|
||||
PRAISONAI_CHAT_MODE = os.environ.get("PRAISONAI_CHAT_MODE", "false").lower() == "true"
|
||||
|
||||
|
||||
def get_agents():
|
||||
"""Get registered agents from the chat module."""
|
||||
try:
|
||||
from praisonai.chat import get_registered_agents
|
||||
return get_registered_agents()
|
||||
except ImportError:
|
||||
return {}
|
||||
|
||||
|
||||
@cl.on_chat_start
|
||||
async def on_chat_start():
|
||||
"""Initialize the chat session."""
|
||||
agents = get_agents()
|
||||
|
||||
if agents:
|
||||
agent_names = list(agents.keys())
|
||||
cl.user_session.set("agents", agents)
|
||||
cl.user_session.set("current_agent", agent_names[0] if agent_names else None)
|
||||
|
||||
await cl.Message(
|
||||
content=f"Welcome to PraisonAI Chat! Available agents: {', '.join(agent_names)}"
|
||||
).send()
|
||||
else:
|
||||
await cl.Message(
|
||||
content="Welcome to PraisonAI Chat! No agents configured. "
|
||||
"Use the API to register agents or configure via YAML."
|
||||
).send()
|
||||
|
||||
cl.user_session.set("message_history", [])
|
||||
|
||||
|
||||
@cl.on_message
|
||||
async def on_message(message: cl.Message):
|
||||
"""Handle incoming messages."""
|
||||
agents = cl.user_session.get("agents", {})
|
||||
current_agent_name = cl.user_session.get("current_agent")
|
||||
message_history = cl.user_session.get("message_history", [])
|
||||
|
||||
# Add user message to history
|
||||
message_history.append({"role": "user", "content": message.content})
|
||||
|
||||
if not agents or not current_agent_name:
|
||||
# No agents configured - use a simple echo response
|
||||
response = f"Echo: {message.content}\n\n(No agents configured. Register agents to enable AI responses.)"
|
||||
await cl.Message(content=response).send()
|
||||
return
|
||||
|
||||
agent = agents.get(current_agent_name)
|
||||
if agent is None:
|
||||
await cl.Message(content=f"Agent '{current_agent_name}' not found.").send()
|
||||
return
|
||||
|
||||
# Create a step for the agent response
|
||||
async with cl.Step(name=current_agent_name, type="llm") as step:
|
||||
step.input = message.content
|
||||
|
||||
try:
|
||||
# Try to call the agent
|
||||
if hasattr(agent, "chat"):
|
||||
# PraisonAI Agent with chat method
|
||||
response = await _call_agent_async(agent, message.content)
|
||||
elif hasattr(agent, "run"):
|
||||
# Agent with run method
|
||||
response = await _call_agent_async(agent, message.content, method="run")
|
||||
elif callable(agent):
|
||||
# Callable agent
|
||||
response = agent(message.content)
|
||||
else:
|
||||
response = f"Agent '{current_agent_name}' does not have a callable interface."
|
||||
|
||||
step.output = str(response)
|
||||
|
||||
except Exception as e:
|
||||
response = f"Error calling agent: {str(e)}"
|
||||
step.output = response
|
||||
|
||||
# Add assistant response to history
|
||||
message_history.append({"role": "assistant", "content": str(response)})
|
||||
cl.user_session.set("message_history", message_history)
|
||||
|
||||
# Send the response
|
||||
await cl.Message(content=str(response)).send()
|
||||
|
||||
|
||||
async def _call_agent_async(agent, message: str, method: str = "chat"):
|
||||
"""Call an agent method, handling both sync and async."""
|
||||
import asyncio
|
||||
|
||||
func = getattr(agent, method)
|
||||
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return await func(message)
|
||||
else:
|
||||
# Run sync function in thread pool
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, func, message)
|
||||
|
||||
|
||||
@cl.on_chat_resume
|
||||
async def on_chat_resume(thread):
|
||||
"""Resume a chat session."""
|
||||
message_history = []
|
||||
|
||||
root_messages = [m for m in thread.get("steps", []) if m.get("parentId") is None]
|
||||
for msg in root_messages:
|
||||
if msg.get("type") == "user_message":
|
||||
message_history.append({"role": "user", "content": msg.get("output", "")})
|
||||
elif msg.get("type") == "assistant_message":
|
||||
message_history.append({"role": "assistant", "content": msg.get("output", "")})
|
||||
|
||||
cl.user_session.set("message_history", message_history)
|
||||
@@ -361,8 +361,8 @@ def serve_ui(
|
||||
output = get_output_controller()
|
||||
|
||||
try:
|
||||
from .ui import _launch_chainlit_ui
|
||||
_launch_chainlit_ui(ui_type, port, host, False)
|
||||
from .ui import _launch_aiui_app
|
||||
_launch_aiui_app(f"ui_{ui_type}", f"ui_{ui_type}", port, host, None, False, ui_type)
|
||||
except ImportError as e:
|
||||
output.print_error(f"UI module not available: {e}")
|
||||
output.print("Install with: pip install praisonai[ui]")
|
||||
|
||||
@@ -221,9 +221,8 @@ def realtime(
|
||||
reload: bool = typer.Option(False, "--reload", "-r", help="Enable auto-reload"),
|
||||
):
|
||||
"""
|
||||
Launch Realtime Voice Interface (Beta).
|
||||
Launch Realtime Voice Interface.
|
||||
|
||||
Replaces the old Chainlit realtime interface with aiui.
|
||||
Note: Full WebRTC voice is pending PraisonAIUI implementation.
|
||||
Uses aiui's OpenAIRealtimeManager for WebRTC voice conversations.
|
||||
"""
|
||||
_launch_aiui_app("ui_realtime", "ui_realtime", port, host, app_file, reload, "Realtime Voice")
|
||||
@@ -475,7 +475,7 @@ def check_optional_deps(config: DoctorConfig) -> CheckResult:
|
||||
("chromadb", "Knowledge/RAG features"),
|
||||
("mem0ai", "Memory features"),
|
||||
("litellm", "Multi-provider LLM support"),
|
||||
("chainlit", "Chat UI"),
|
||||
("praisonaiui", "aiui (Chat/Dashboard UI)"),
|
||||
("gradio", "Gradio UI"),
|
||||
("crawl4ai", "Web crawling"),
|
||||
("tavily", "Tavily search"),
|
||||
|
||||
@@ -141,33 +141,12 @@ def _get_agents_generator():
|
||||
return AgentsGenerator
|
||||
|
||||
# Optional module imports with availability checks
|
||||
CHAINLIT_AVAILABLE = False
|
||||
GRADIO_AVAILABLE = False
|
||||
CALL_MODULE_AVAILABLE = False
|
||||
CREWAI_AVAILABLE = False
|
||||
AUTOGEN_AVAILABLE = False
|
||||
PRAISONAI_AVAILABLE = False
|
||||
TRAIN_AVAILABLE = False
|
||||
try:
|
||||
import importlib.util
|
||||
CHAINLIT_AVAILABLE = importlib.util.find_spec("chainlit") is not None
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
def _get_chainlit_run():
|
||||
"""Lazy import chainlit to avoid loading .env at startup"""
|
||||
# Create necessary directories and set CHAINLIT_APP_ROOT
|
||||
if "CHAINLIT_APP_ROOT" not in os.environ:
|
||||
chainlit_root = os.path.join(os.path.expanduser("~"), ".praison")
|
||||
os.environ["CHAINLIT_APP_ROOT"] = chainlit_root
|
||||
else:
|
||||
chainlit_root = os.environ["CHAINLIT_APP_ROOT"]
|
||||
|
||||
os.makedirs(chainlit_root, exist_ok=True)
|
||||
os.makedirs(os.path.join(chainlit_root, ".files"), exist_ok=True)
|
||||
|
||||
from chainlit.cli import chainlit_run
|
||||
return chainlit_run
|
||||
|
||||
# Use find_spec for fast availability checks (no actual import)
|
||||
import importlib.util
|
||||
@@ -581,7 +560,8 @@ class PraisonAI:
|
||||
# They no longer open Chainlit browser UI
|
||||
|
||||
if getattr(args, 'realtime', False):
|
||||
self.create_realtime_interface()
|
||||
from praisonai.cli.commands.ui import _launch_aiui_app
|
||||
_launch_aiui_app("ui_realtime", "ui_realtime", 8085, "0.0.0.0", None, False, "Realtime Voice")
|
||||
return
|
||||
|
||||
if getattr(args, 'call', False):
|
||||
@@ -1238,11 +1218,8 @@ class PraisonAI:
|
||||
sys.exit(0)
|
||||
|
||||
elif args.command == 'realtime':
|
||||
if not CHAINLIT_AVAILABLE:
|
||||
print("[red]ERROR: Realtime UI is not installed. Install with:[/red]")
|
||||
print("\npip install \"praisonai[realtime]\"\n")
|
||||
sys.exit(1)
|
||||
self.create_realtime_interface()
|
||||
from praisonai.cli.commands.ui import _launch_aiui_app
|
||||
_launch_aiui_app("ui_realtime", "ui_realtime", 8085, "0.0.0.0", None, False, "Realtime Voice")
|
||||
sys.exit(0)
|
||||
|
||||
elif args.command == 'train':
|
||||
@@ -5189,45 +5166,6 @@ Now, {final_instruction.lower()}:"""
|
||||
except KeyboardInterrupt:
|
||||
print("\n👋 Server stopped.")
|
||||
|
||||
def create_chainlit_chat_interface(self):
|
||||
"""
|
||||
Create a Chainlit interface for the chat application.
|
||||
"""
|
||||
if CHAINLIT_AVAILABLE:
|
||||
import praisonai
|
||||
os.environ["CHAINLIT_PORT"] = "8084"
|
||||
root_path = os.path.join(os.path.expanduser("~"), ".praison")
|
||||
if "CHAINLIT_APP_ROOT" not in os.environ:
|
||||
os.environ["CHAINLIT_APP_ROOT"] = root_path
|
||||
chat_ui_path = os.path.join(os.path.dirname(praisonai.__file__), 'ui', 'chat.py')
|
||||
_get_chainlit_run()([chat_ui_path])
|
||||
else:
|
||||
print("ERROR: Chat UI is not installed. Please install it with 'pip install \"praisonai[chat]\"' to use the chat UI.")
|
||||
|
||||
def create_code_interface(self):
|
||||
"""
|
||||
Create a Chainlit interface for the code application.
|
||||
"""
|
||||
if CHAINLIT_AVAILABLE:
|
||||
import praisonai
|
||||
os.environ["CHAINLIT_PORT"] = "8086"
|
||||
root_path = os.path.join(os.path.expanduser("~"), ".praison")
|
||||
if "CHAINLIT_APP_ROOT" not in os.environ:
|
||||
os.environ["CHAINLIT_APP_ROOT"] = root_path
|
||||
public_folder = os.path.join(os.path.dirname(__file__), 'public')
|
||||
if not os.path.exists(os.path.join(root_path, "public")):
|
||||
if os.path.exists(public_folder):
|
||||
shutil.copytree(public_folder, os.path.join(root_path, "public"), dirs_exist_ok=True)
|
||||
logging.info("Public folder copied successfully!")
|
||||
else:
|
||||
logging.info("Public folder not found in the package.")
|
||||
else:
|
||||
logging.info("Public folder already exists.")
|
||||
code_ui_path = os.path.join(os.path.dirname(praisonai.__file__), 'ui', 'code.py')
|
||||
_get_chainlit_run()([code_ui_path])
|
||||
else:
|
||||
print("ERROR: Code UI is not installed. Please install it with 'pip install \"praisonai[code]\"' to use the code UI.")
|
||||
|
||||
def create_gradio_interface(self):
|
||||
"""
|
||||
Create a Gradio interface for generating agents and performing tasks.
|
||||
@@ -5260,51 +5198,6 @@ Now, {final_instruction.lower()}:"""
|
||||
else:
|
||||
print("ERROR: Gradio is not installed. Please install it with 'pip install gradio' to use this feature.")
|
||||
|
||||
def create_chainlit_interface(self):
|
||||
"""
|
||||
Create a Chainlit interface for generating agents and performing tasks.
|
||||
"""
|
||||
if CHAINLIT_AVAILABLE:
|
||||
import praisonai
|
||||
os.environ["CHAINLIT_PORT"] = "8082"
|
||||
public_folder = os.path.join(os.path.dirname(praisonai.__file__), 'public')
|
||||
if not os.path.exists("public"):
|
||||
if os.path.exists(public_folder):
|
||||
shutil.copytree(public_folder, 'public', dirs_exist_ok=True)
|
||||
logging.info("Public folder copied successfully!")
|
||||
else:
|
||||
logging.info("Public folder not found in the package.")
|
||||
else:
|
||||
logging.info("Public folder already exists.")
|
||||
chainlit_ui_path = os.path.join(os.path.dirname(praisonai.__file__), 'ui', 'agents.py')
|
||||
_get_chainlit_run()([chainlit_ui_path])
|
||||
else:
|
||||
print("ERROR: Chainlit is not installed. Please install it with 'pip install \"praisonai[ui]\"' to use the UI.")
|
||||
|
||||
def create_realtime_interface(self):
|
||||
"""
|
||||
Create a Chainlit interface for the realtime voice interaction application.
|
||||
"""
|
||||
if CHAINLIT_AVAILABLE:
|
||||
import praisonai
|
||||
os.environ["CHAINLIT_PORT"] = "8088"
|
||||
root_path = os.path.join(os.path.expanduser("~"), ".praison")
|
||||
if "CHAINLIT_APP_ROOT" not in os.environ:
|
||||
os.environ["CHAINLIT_APP_ROOT"] = root_path
|
||||
public_folder = os.path.join(os.path.dirname(praisonai.__file__), 'public')
|
||||
if not os.path.exists(os.path.join(root_path, "public")):
|
||||
if os.path.exists(public_folder):
|
||||
shutil.copytree(public_folder, os.path.join(root_path, "public"), dirs_exist_ok=True)
|
||||
logging.info("Public folder copied successfully!")
|
||||
else:
|
||||
logging.info("Public folder not found in the package.")
|
||||
else:
|
||||
logging.info("Public folder already exists.")
|
||||
realtime_ui_path = os.path.join(os.path.dirname(praisonai.__file__), 'ui', 'realtime.py')
|
||||
_get_chainlit_run()([realtime_ui_path])
|
||||
else:
|
||||
print("ERROR: Realtime UI is not installed. Please install it with 'pip install \"praisonai[realtime]\"' to use the realtime UI.")
|
||||
|
||||
def create_aiui_agents_interface(self):
|
||||
"""
|
||||
Create an aiui-based agents interface (replaces Chainlit).
|
||||
|
||||
@@ -1,875 +0,0 @@
|
||||
from chainlit.input_widget import Select, TextInput
|
||||
import os
|
||||
import sys
|
||||
import yaml
|
||||
import logging
|
||||
import inspect
|
||||
import chainlit as cl
|
||||
from praisonaiagents import Agent, Task, AgentTeam, register_display_callback
|
||||
|
||||
framework = "praisonai"
|
||||
config_list = [
|
||||
{
|
||||
'model': os.environ.get("OPENAI_MODEL_NAME", "gpt-4o-mini"),
|
||||
'base_url': os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1"),
|
||||
'api_key': os.environ.get("OPENAI_API_KEY", "")
|
||||
}
|
||||
]
|
||||
|
||||
actions = [
|
||||
cl.Action(name="run", payload={"value": "run"}, label="run"),
|
||||
cl.Action(name="modify", payload={"value": "modify"}, label="Modify"),
|
||||
]
|
||||
|
||||
@cl.action_callback("run")
|
||||
async def on_run(action):
|
||||
await main(cl.Message(content=""))
|
||||
|
||||
@cl.action_callback("modify")
|
||||
async def on_modify(action):
|
||||
await cl.Message(content="Modify the agents and tools from below settings").send()
|
||||
|
||||
import os
|
||||
import sys
|
||||
import yaml
|
||||
import logging
|
||||
import inspect
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sqlite3
|
||||
from queue import Queue
|
||||
from datetime import datetime
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Chainlit imports
|
||||
import chainlit as cl
|
||||
from chainlit.types import ThreadDict
|
||||
import chainlit.data as cl_data
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Global Setup
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
load_dotenv()
|
||||
log_level = os.getenv("LOGLEVEL", "INFO").upper()
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(log_level)
|
||||
|
||||
message_queue = Queue() # Queue to handle messages sent to Chainlit UI
|
||||
agent_file = "agents.yaml"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database and Settings Logic
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
MAX_RETRIES = 3
|
||||
RETRY_DELAY = 1 # seconds
|
||||
|
||||
from db import DatabaseManager
|
||||
|
||||
async def init_database_with_retry():
|
||||
db = DatabaseManager()
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
db.initialize()
|
||||
return db
|
||||
except sqlite3.OperationalError as e:
|
||||
if "database is locked" in str(e) and attempt < MAX_RETRIES - 1:
|
||||
await asyncio.sleep(RETRY_DELAY)
|
||||
continue
|
||||
raise
|
||||
|
||||
db_manager = asyncio.run(init_database_with_retry())
|
||||
cl_data._data_layer = db_manager
|
||||
|
||||
async def save_setting_with_retry(key: str, value: str):
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
await db_manager.save_setting(key, value)
|
||||
return
|
||||
except sqlite3.OperationalError as e:
|
||||
if "database is locked" in str(e) and attempt < MAX_RETRIES - 1:
|
||||
await asyncio.sleep(RETRY_DELAY)
|
||||
continue
|
||||
raise
|
||||
|
||||
async def load_setting_with_retry(key: str) -> str:
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
return await db_manager.load_setting(key)
|
||||
except sqlite3.OperationalError as e:
|
||||
if "database is locked" in str(e) and attempt < MAX_RETRIES - 1:
|
||||
await asyncio.sleep(RETRY_DELAY)
|
||||
continue
|
||||
raise
|
||||
return ""
|
||||
|
||||
def save_setting(key: str, value: str):
|
||||
asyncio.run(save_setting_with_retry(key, value))
|
||||
|
||||
def load_setting(key: str) -> str:
|
||||
return asyncio.run(load_setting_with_retry(key))
|
||||
|
||||
async def update_thread_metadata(thread_id: str, metadata: dict):
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
await cl_data.update_thread(thread_id, metadata=metadata)
|
||||
return
|
||||
except sqlite3.OperationalError as e:
|
||||
if "database is locked" in str(e) and attempt < MAX_RETRIES - 1:
|
||||
await asyncio.sleep(RETRY_DELAY)
|
||||
continue
|
||||
raise
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Callback Manager
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class CallbackManager:
|
||||
def __init__(self):
|
||||
self._callbacks = {}
|
||||
|
||||
def register(self, name: str, callback, is_async: bool = False) -> None:
|
||||
self._callbacks[name] = {'func': callback, 'is_async': is_async}
|
||||
|
||||
async def call(self, name: str, **kwargs) -> None:
|
||||
if name not in self._callbacks:
|
||||
logger.warning(f"No callback registered for {name}")
|
||||
return
|
||||
callback_info = self._callbacks[name]
|
||||
func = callback_info['func']
|
||||
is_async = callback_info['is_async']
|
||||
try:
|
||||
if is_async:
|
||||
await func(**kwargs)
|
||||
else:
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
await func(**kwargs)
|
||||
else:
|
||||
await asyncio.get_event_loop().run_in_executor(None, lambda: func(**kwargs))
|
||||
except Exception as e:
|
||||
logger.error(f"Error in callback {name}: {str(e)}")
|
||||
|
||||
callback_manager = CallbackManager()
|
||||
|
||||
def register_callback(name: str, callback, is_async: bool = False) -> None:
|
||||
callback_manager.register(name, callback, is_async)
|
||||
|
||||
async def trigger_callback(name: str, **kwargs) -> None:
|
||||
await callback_manager.call(name, **kwargs)
|
||||
|
||||
def callback(name: str, is_async: bool = False):
|
||||
def decorator(func):
|
||||
register_callback(name, func, is_async)
|
||||
return func
|
||||
return decorator
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# ADDITIONAL CALLBACKS
|
||||
# -----------------------------------------------------------------------------
|
||||
def interaction_callback(message=None, response=None, **kwargs):
|
||||
logger.debug(f"[CALLBACK: interaction] Message: {message} | Response: {response}")
|
||||
message_queue.put({
|
||||
"content": f"[CALLBACK: interaction] Message: {message} | Response: {response}",
|
||||
"author": "Callback"
|
||||
})
|
||||
|
||||
def error_callback(message=None, **kwargs):
|
||||
logger.error(f"[CALLBACK: error] Message: {message}")
|
||||
message_queue.put({
|
||||
"content": f"[CALLBACK: error] Message: {message}",
|
||||
"author": "Callback"
|
||||
})
|
||||
|
||||
def tool_call_callback(message=None, **kwargs):
|
||||
logger.debug(f"[CALLBACK: tool_call] Tool used: {message}")
|
||||
message_queue.put({
|
||||
"content": f"[CALLBACK: tool_call] Tool used: {message}",
|
||||
"author": "Callback"
|
||||
})
|
||||
|
||||
def instruction_callback(message=None, **kwargs):
|
||||
logger.debug(f"[CALLBACK: instruction] Instruction: {message}")
|
||||
message_queue.put({
|
||||
"content": f"[CALLBACK: instruction] Instruction: {message}",
|
||||
"author": "Callback"
|
||||
})
|
||||
|
||||
def self_reflection_callback(message=None, **kwargs):
|
||||
logger.debug(f"[CALLBACK: self_reflection] Reflection: {message}")
|
||||
message_queue.put({
|
||||
"content": f"[CALLBACK: self_reflection] Reflection: {message}",
|
||||
"author": "Callback"
|
||||
})
|
||||
|
||||
register_display_callback('error', error_callback)
|
||||
register_display_callback('tool_call', tool_call_callback)
|
||||
register_display_callback('instruction', instruction_callback)
|
||||
register_display_callback('self_reflection', self_reflection_callback)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tools Loader
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def load_tools_from_tools_py():
|
||||
"""
|
||||
Imports and returns all contents from tools.py file.
|
||||
Also adds the tools to the global namespace.
|
||||
"""
|
||||
tools_dict = {}
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location("tools", "tools.py")
|
||||
if spec is None:
|
||||
logger.info("tools.py not found in current directory")
|
||||
return tools_dict
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
for name, obj in inspect.getmembers(module):
|
||||
if not name.startswith('_') and callable(obj) and not inspect.isclass(obj):
|
||||
# Store the function in globals
|
||||
globals()[name] = obj
|
||||
|
||||
# Build the function definition
|
||||
tool_def = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"description": obj.__doc__ or f"Function to {name.replace('_', ' ')}",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
},
|
||||
# Keep the actual callable as well
|
||||
"callable": obj,
|
||||
}
|
||||
|
||||
tools_dict[name] = tool_def
|
||||
logger.info(f"Loaded and globalized tool function: {name}")
|
||||
|
||||
logger.info(f"Loaded {len(tools_dict)} tool functions from tools.py")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error loading tools from tools.py: {e}")
|
||||
return tools_dict
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Async Queue Processor
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
async def process_message_queue():
|
||||
while True:
|
||||
try:
|
||||
if not message_queue.empty():
|
||||
msg_data = message_queue.get()
|
||||
await cl.Message(**msg_data).send()
|
||||
await asyncio.sleep(0.1)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing message queue: {e}")
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step & Task Callbacks
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
async def step_callback(step_details):
|
||||
logger.info(f"[CALLBACK DEBUG] step_callback: {step_details}")
|
||||
agent_name = step_details.get("agent_name", "Agent")
|
||||
try:
|
||||
if step_details.get("response"):
|
||||
message_queue.put({
|
||||
"content": f"Agent Response: {step_details['response']}",
|
||||
"author": agent_name
|
||||
})
|
||||
if step_details.get("tool_name"):
|
||||
message_queue.put({
|
||||
"content": f"🛠️ Using tool: {step_details['tool_name']}",
|
||||
"author": "System"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error in step_callback: {e}", exc_info=True)
|
||||
|
||||
async def task_callback(task_output):
|
||||
logger.info(f"[CALLBACK DEBUG] task_callback: type={type(task_output)}")
|
||||
try:
|
||||
if hasattr(task_output, 'raw'):
|
||||
content = task_output.raw
|
||||
elif hasattr(task_output, 'content'):
|
||||
content = task_output.content
|
||||
else:
|
||||
content = str(task_output)
|
||||
message_queue.put({
|
||||
"content": f"Task Output: {content}",
|
||||
"author": "Task"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error in task_callback: {e}", exc_info=True)
|
||||
|
||||
async def step_callback_wrapper(step_details):
|
||||
logger.info(f"[CALLBACK DEBUG] step_callback_wrapper: {step_details}")
|
||||
agent_name = step_details.get("agent_name", "Agent")
|
||||
try:
|
||||
if not cl.context.context_var.get():
|
||||
logger.warning("[CALLBACK DEBUG] No Chainlit context in wrapper.")
|
||||
return
|
||||
if step_details.get("response"):
|
||||
await cl.Message(
|
||||
content=f"{agent_name}: {step_details['response']}",
|
||||
author=agent_name,
|
||||
).send()
|
||||
if step_details.get("tool_name"):
|
||||
await cl.Message(
|
||||
content=f"🛠️ {agent_name} is using tool: {step_details['tool_name']}",
|
||||
author="System",
|
||||
).send()
|
||||
if step_details.get("thought"):
|
||||
await cl.Message(
|
||||
content=f"💭 {agent_name}'s thought: {step_details['thought']}",
|
||||
author=agent_name,
|
||||
).send()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in step_callback_wrapper: {e}", exc_info=True)
|
||||
try:
|
||||
await cl.Message(content=f"Error in step callback: {e}", author="System").send()
|
||||
except Exception as send_error:
|
||||
logger.error(f"Error sending error message: {send_error}")
|
||||
|
||||
async def task_callback_wrapper(task_output):
|
||||
logger.info("[CALLBACK DEBUG] task_callback_wrapper triggered")
|
||||
try:
|
||||
if not cl.context.context_var.get():
|
||||
logger.warning("[CALLBACK DEBUG] No Chainlit context in task wrapper.")
|
||||
return
|
||||
if hasattr(task_output, 'raw'):
|
||||
content = task_output.raw
|
||||
elif hasattr(task_output, 'content'):
|
||||
content = task_output.content
|
||||
else:
|
||||
content = str(task_output)
|
||||
|
||||
await cl.Message(
|
||||
content=f"✅ Agent completed task:\n{content}",
|
||||
author="Agent",
|
||||
).send()
|
||||
|
||||
if hasattr(task_output, 'details'):
|
||||
await cl.Message(
|
||||
content=f"📝 Additional details:\n{task_output.details}",
|
||||
author="Agent",
|
||||
).send()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in task_callback_wrapper: {e}", exc_info=True)
|
||||
try:
|
||||
await cl.Message(content=f"Error in task callback: {e}", author="System").send()
|
||||
except Exception as send_error:
|
||||
logger.error(f"Error sending error message: {send_error}")
|
||||
|
||||
def sync_task_callback_wrapper(task_output):
|
||||
logger.info("[CALLBACK DEBUG] sync_task_callback_wrapper")
|
||||
try:
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
if loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(task_callback_wrapper(task_output), loop)
|
||||
else:
|
||||
loop.run_until_complete(task_callback_wrapper(task_output))
|
||||
except Exception as e:
|
||||
logger.error(f"Error in sync_task_callback_wrapper: {e}", exc_info=True)
|
||||
|
||||
def sync_step_callback_wrapper(step_details):
|
||||
logger.info("[CALLBACK DEBUG] sync_step_callback_wrapper")
|
||||
try:
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
if loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(step_callback_wrapper(step_details), loop)
|
||||
else:
|
||||
loop.run_until_complete(step_callback_wrapper(step_details))
|
||||
except Exception as e:
|
||||
logger.error(f"Error in sync_step_callback_wrapper: {e}", exc_info=True)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Main PraisonAI Runner
|
||||
# -----------------------------------------------------------------------------
|
||||
async def ui_run_praisonai(config, topic, tools_dict):
|
||||
logger.info("Starting ui_run_praisonai")
|
||||
agents_map = {}
|
||||
tasks = []
|
||||
tasks_dict = {}
|
||||
|
||||
try:
|
||||
queue_processor = asyncio.create_task(process_message_queue())
|
||||
|
||||
# Create agents
|
||||
for role, details in config['roles'].items():
|
||||
role_name = details.get('name', role).format(topic=topic)
|
||||
role_filled = details.get('role', role).format(topic=topic)
|
||||
goal_filled = details['goal'].format(topic=topic)
|
||||
backstory_filled = details['backstory'].format(topic=topic)
|
||||
|
||||
def step_callback_sync(step_details):
|
||||
step_details["agent_name"] = role_name
|
||||
try:
|
||||
loop_ = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop_)
|
||||
loop_.run_until_complete(step_callback(step_details))
|
||||
loop_.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in step_callback_sync: {e}", exc_info=True)
|
||||
|
||||
# Get external agent tools from current settings
|
||||
external_tools = []
|
||||
try:
|
||||
from praisonai.ui._external_agents import external_agent_tools, EXTERNAL_AGENTS
|
||||
settings = cl.user_session.get("settings", {})
|
||||
external_settings = {k: settings.get(k, False) for k in EXTERNAL_AGENTS}
|
||||
workspace = os.environ.get("PRAISONAI_WORKSPACE", ".")
|
||||
external_tools = external_agent_tools(external_settings, workspace=workspace)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Get existing tools from details
|
||||
existing_tools = details.get('tools', [])
|
||||
if isinstance(existing_tools, str):
|
||||
# If tools is a string, it might be a module reference - keep as is
|
||||
all_tools = existing_tools
|
||||
else:
|
||||
# Combine existing and external tools
|
||||
all_tools = (existing_tools or []) + external_tools
|
||||
|
||||
agent = Agent(
|
||||
name=role_name,
|
||||
role=role_filled,
|
||||
goal=goal_filled,
|
||||
backstory=backstory_filled,
|
||||
llm=details.get('llm', 'gpt-5-nano'),
|
||||
verbose=True,
|
||||
allow_delegation=details.get('allow_delegation', False),
|
||||
max_iter=details.get('max_iter', 15),
|
||||
max_rpm=details.get('max_rpm'),
|
||||
max_execution_time=details.get('max_execution_time'),
|
||||
cache=details.get('cache', True),
|
||||
step_callback=step_callback_sync,
|
||||
reflection=details.get('self_reflect', False),
|
||||
tools=all_tools if all_tools else None
|
||||
)
|
||||
agents_map[role] = agent
|
||||
|
||||
# Create tasks
|
||||
# Import tool resolver for YAML tool resolution
|
||||
from praisonai.tool_resolver import ToolResolver
|
||||
tool_resolver = ToolResolver()
|
||||
|
||||
for role, details in config['roles'].items():
|
||||
agent = agents_map[role]
|
||||
role_name = agent.name
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# Tool resolution: local tools_dict first, then ToolResolver
|
||||
# -------------------------------------------------------------
|
||||
role_tools = []
|
||||
task_tools = [] # Initialize task_tools outside the loop
|
||||
|
||||
for tool_name in details.get('tools', []):
|
||||
if not tool_name or not tool_name.strip():
|
||||
logger.warning("Skipping empty tool name.")
|
||||
continue
|
||||
|
||||
tool_name = tool_name.strip()
|
||||
|
||||
# First check local tools_dict (from tools.py)
|
||||
if tool_name in tools_dict:
|
||||
# Create a copy of the tool definition
|
||||
tool_def = tools_dict[tool_name].copy()
|
||||
# Store the callable separately and remove from definition
|
||||
callable_func = tool_def.pop("callable")
|
||||
# Add callable to role_tools for task execution
|
||||
role_tools.append(callable_func)
|
||||
# Add API tool definition to task's tools
|
||||
task_tools.append(tool_def)
|
||||
else:
|
||||
# Try to resolve from built-in tools via ToolResolver
|
||||
resolved_tool = tool_resolver.resolve(tool_name)
|
||||
if resolved_tool is not None:
|
||||
role_tools.append(resolved_tool)
|
||||
logger.info(f"Resolved tool '{tool_name}' from built-in tools")
|
||||
else:
|
||||
logger.warning(f"Tool '{tool_name}' not found. Skipping.")
|
||||
|
||||
# Set the agent's tools after collecting all tools
|
||||
# Merge resolved YAML tools with external agent tools so both survive
|
||||
merged_tools = role_tools + external_tools
|
||||
if merged_tools:
|
||||
agent.tools = merged_tools
|
||||
|
||||
for tname, tdetails in details.get('tasks', {}).items():
|
||||
description_filled = tdetails['description'].format(topic=topic)
|
||||
expected_output_filled = tdetails['expected_output'].format(topic=topic)
|
||||
|
||||
def task_callback_sync(task_output):
|
||||
try:
|
||||
loop_ = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop_)
|
||||
loop_.run_until_complete(task_callback(task_output))
|
||||
loop_.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in task_callback_sync: {e}", exc_info=True)
|
||||
|
||||
task = Task(
|
||||
description=description_filled,
|
||||
expected_output=expected_output_filled,
|
||||
agent=agent,
|
||||
tools=task_tools, # Pass API tool definitions
|
||||
async_execution=True,
|
||||
context=[],
|
||||
config=tdetails.get('config', {}),
|
||||
output_json=tdetails.get('output_json'),
|
||||
output_pydantic=tdetails.get('output_pydantic'),
|
||||
output_file=tdetails.get('output_file', ""),
|
||||
callback=task_callback_sync,
|
||||
create_directory=tdetails.get('create_directory', False)
|
||||
)
|
||||
tasks.append(task)
|
||||
tasks_dict[tname] = task
|
||||
|
||||
# Build context links
|
||||
for role, details in config['roles'].items():
|
||||
for tname, tdetails in details.get('tasks', {}).items():
|
||||
if tname not in tasks_dict:
|
||||
continue
|
||||
task = tasks_dict[tname]
|
||||
context_tasks = [
|
||||
tasks_dict[ctx]
|
||||
for ctx in tdetails.get('context', [])
|
||||
if ctx in tasks_dict
|
||||
]
|
||||
task.context = context_tasks
|
||||
|
||||
await cl.Message(content="Starting PraisonAI agents execution...", author="System").send()
|
||||
|
||||
# Decide how to process tasks
|
||||
if config.get('process') == 'hierarchical':
|
||||
prai_agents = AgentTeam(
|
||||
agents=list(agents_map.values()),
|
||||
tasks=tasks,
|
||||
verbose=True,
|
||||
process="hierarchical",
|
||||
manager_llm=config.get('manager_llm', 'gpt-5-nano')
|
||||
)
|
||||
else:
|
||||
prai_agents = AgentTeam(
|
||||
agents=list(agents_map.values()),
|
||||
tasks=tasks,
|
||||
verbose=2
|
||||
)
|
||||
|
||||
cl.user_session.set("agents", prai_agents)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(None, prai_agents.start)
|
||||
|
||||
if hasattr(response, 'raw'):
|
||||
result = response.raw
|
||||
elif hasattr(response, 'content'):
|
||||
result = response.content
|
||||
else:
|
||||
result = str(response)
|
||||
|
||||
await cl.Message(content="PraisonAI agents execution completed.", author="System").send()
|
||||
await asyncio.sleep(1)
|
||||
queue_processor.cancel()
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error in ui_run_praisonai: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
await cl.Message(content=error_msg, author="System").send()
|
||||
raise
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Chainlit Handlers + logic
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
tools_dict = load_tools_from_tools_py()
|
||||
print(f"[DEBUG] tools_dict: {tools_dict}")
|
||||
|
||||
# Load agent config (default) from 'agents.yaml'
|
||||
with open(agent_file, 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
import secrets
|
||||
AUTH_PASSWORD_ENABLED = os.getenv("AUTH_PASSWORD_ENABLED", "true").lower() == "true"
|
||||
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
|
||||
if not CHAINLIT_AUTH_SECRET:
|
||||
os.environ["CHAINLIT_AUTH_SECRET"] = secrets.token_hex(32)
|
||||
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
|
||||
|
||||
# Authentication configuration - bind-aware auth
|
||||
from praisonaiagents.gateway.protocols import is_loopback
|
||||
from ._auth import register_password_auth
|
||||
|
||||
# Determine bind host from CHAINLIT_HOST env var (default: 127.0.0.1)
|
||||
bind_host = os.getenv("CHAINLIT_HOST", "127.0.0.1")
|
||||
if AUTH_PASSWORD_ENABLED or not is_loopback(bind_host):
|
||||
register_password_auth(None, bind_host=bind_host)
|
||||
|
||||
@cl.set_chat_profiles
|
||||
async def set_profiles(current_user: cl.User):
|
||||
return [
|
||||
cl.ChatProfile(
|
||||
name="Auto",
|
||||
markdown_description=(
|
||||
"Automatically generate agents and tasks based on your input."
|
||||
),
|
||||
starters=[
|
||||
cl.Starter(
|
||||
label="Create a movie script",
|
||||
message=(
|
||||
"Create a movie script about a futuristic society where AI "
|
||||
"and humans coexist, focusing on the conflict and resolution "
|
||||
"between them. Start with an intriguing opening scene."
|
||||
),
|
||||
icon="/public/movie.svg",
|
||||
),
|
||||
cl.Starter(
|
||||
label="Design a fantasy world",
|
||||
message=(
|
||||
"Design a detailed fantasy world with unique geography, "
|
||||
"cultures, and magical systems. Start by describing the main "
|
||||
"continent and its inhabitants."
|
||||
),
|
||||
icon="/public/fantasy.svg",
|
||||
),
|
||||
cl.Starter(
|
||||
label="Write a futuristic political thriller",
|
||||
message=(
|
||||
"Write a futuristic political thriller involving a conspiracy "
|
||||
"within a global government. Start with a high-stakes meeting "
|
||||
"that sets the plot in motion."
|
||||
),
|
||||
icon="/public/thriller.svg",
|
||||
),
|
||||
cl.Starter(
|
||||
label="Develop a new board game",
|
||||
message=(
|
||||
"Develop a new, innovative board game. Describe the game's "
|
||||
"objective, rules, and unique mechanics. Create a scenario to "
|
||||
"illustrate gameplay."
|
||||
),
|
||||
icon="/public/game.svg",
|
||||
),
|
||||
],
|
||||
),
|
||||
cl.ChatProfile(
|
||||
name="Manual",
|
||||
markdown_description="Manually define your agents and tasks using a YAML file.",
|
||||
),
|
||||
]
|
||||
|
||||
@cl.on_chat_start
|
||||
async def start_chat():
|
||||
try:
|
||||
model_name = load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-4o-mini")
|
||||
cl.user_session.set("model_name", model_name)
|
||||
logger.debug(f"Model name: {model_name}")
|
||||
|
||||
cl.user_session.set(
|
||||
"message_history",
|
||||
[{"role": "system", "content": "You are a helpful assistant."}],
|
||||
)
|
||||
|
||||
if not os.path.exists("tools.py"):
|
||||
with open("tools.py", "w") as f:
|
||||
f.write("# Add your custom tools here\n")
|
||||
|
||||
if not os.path.exists("agents.yaml"):
|
||||
with open("agents.yaml", "w") as f:
|
||||
f.write("# Add your custom agents here\n")
|
||||
|
||||
# Load external agent settings
|
||||
try:
|
||||
from praisonai.ui._external_agents import (
|
||||
chainlit_switches,
|
||||
load_external_agent_settings_from_chainlit,
|
||||
)
|
||||
external_settings = load_external_agent_settings_from_chainlit(load_setting)
|
||||
except ImportError:
|
||||
def chainlit_switches(_settings): # no-op fallback
|
||||
return []
|
||||
external_settings = {}
|
||||
|
||||
settings = await cl.ChatSettings(
|
||||
[
|
||||
TextInput(id="Model", label="OpenAI - Model", initial=model_name),
|
||||
TextInput(id="BaseUrl", label="OpenAI - Base URL", initial=config_list[0]['base_url']),
|
||||
TextInput(id="ApiKey", label="OpenAI - API Key", initial=config_list[0]['api_key']),
|
||||
Select(
|
||||
id="Framework",
|
||||
label="Framework",
|
||||
values=["praisonai", "crewai", "autogen"],
|
||||
initial_index=0,
|
||||
),
|
||||
*chainlit_switches(external_settings)
|
||||
]
|
||||
).send()
|
||||
cl.user_session.set("settings", settings)
|
||||
chat_profile = cl.user_session.get("chat_profile")
|
||||
|
||||
if chat_profile == "Manual":
|
||||
agent_file = "agents.yaml"
|
||||
full_agent_file_path = os.path.abspath(agent_file)
|
||||
if os.path.exists(full_agent_file_path):
|
||||
with open(full_agent_file_path, 'r') as f:
|
||||
yaml_content = f.read()
|
||||
msg = cl.Message(content=yaml_content, language="yaml")
|
||||
await msg.send()
|
||||
|
||||
full_tools_file_path = os.path.abspath("tools.py")
|
||||
if os.path.exists(full_tools_file_path):
|
||||
with open(full_tools_file_path, 'r') as f:
|
||||
tools_content = f.read()
|
||||
msg = cl.Message(content=tools_content, language="python")
|
||||
await msg.send()
|
||||
|
||||
settings = await cl.ChatSettings(
|
||||
[
|
||||
TextInput(id="Model", label="OpenAI - Model", initial=model_name),
|
||||
TextInput(id="BaseUrl", label="OpenAI - Base URL", initial=config_list[0]['base_url']),
|
||||
TextInput(id="ApiKey", label="OpenAI - API Key", initial=config_list[0]['api_key']),
|
||||
Select(
|
||||
id="Framework",
|
||||
label="Framework",
|
||||
values=["praisonai", "crewai", "autogen"],
|
||||
initial_index=0,
|
||||
),
|
||||
TextInput(id="agents", label="agents.yaml", initial=yaml_content, multiline=True),
|
||||
TextInput(id="tools", label="tools.py", initial=tools_content, multiline=True),
|
||||
*chainlit_switches(external_settings)
|
||||
]
|
||||
).send()
|
||||
cl.user_session.set("settings", settings)
|
||||
|
||||
res = await cl.AskActionMessage(
|
||||
content="Pick an action!",
|
||||
actions=actions,
|
||||
).send()
|
||||
if res and res.get("value") == "modify":
|
||||
await cl.Message(content="Modify the agents and tools from below settings", actions=actions).send()
|
||||
elif res and res.get("value") == "run":
|
||||
await main(cl.Message(content="", actions=actions))
|
||||
|
||||
await on_settings_update(settings)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in start_chat: {str(e)}")
|
||||
await cl.Message(content=f"An error occurred while starting the chat: {str(e)}").send()
|
||||
|
||||
@cl.on_chat_resume
|
||||
async def on_chat_resume(thread: ThreadDict):
|
||||
try:
|
||||
message_history = cl.user_session.get("message_history", [])
|
||||
root_messages = [m for m in thread["steps"] if m["parentId"] is None]
|
||||
for message in root_messages:
|
||||
if message["type"] == "user_message":
|
||||
message_history.append({"role": "user", "content": message["output"]})
|
||||
elif message["type"] == "ai_message":
|
||||
message_history.append({"role": "assistant", "content": message["content"]})
|
||||
cl.user_session.set("message_history", message_history)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_chat_resume: {str(e)}")
|
||||
|
||||
@cl.on_message
|
||||
async def main(message: cl.Message):
|
||||
try:
|
||||
logger.info(f"User message: {message.content}")
|
||||
msg = cl.Message(content="")
|
||||
await msg.stream_token(f"🔄 Processing your request: {message.content}...")
|
||||
|
||||
# Run PraisonAI
|
||||
result = await ui_run_praisonai(config, message.content, tools_dict)
|
||||
|
||||
message_history = cl.user_session.get("message_history", [])
|
||||
message_history.append({"role": "user", "content": message.content})
|
||||
message_history.append({"role": "assistant", "content": str(result)})
|
||||
cl.user_session.set("message_history", message_history)
|
||||
await msg.send()
|
||||
except Exception as e:
|
||||
error_msg = f"Error running PraisonAI agents: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
await cl.Message(content=error_msg, author="System").send()
|
||||
|
||||
@cl.on_settings_update
|
||||
async def on_settings_update(settings):
|
||||
try:
|
||||
global config_list, framework
|
||||
config_list[0]['model'] = settings["Model"]
|
||||
config_list[0]['base_url'] = settings["BaseUrl"]
|
||||
config_list[0]['api_key'] = settings["ApiKey"]
|
||||
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
await save_setting_with_retry("model_name", config_list[0]['model'])
|
||||
await save_setting_with_retry("base_url", config_list[0]['base_url'])
|
||||
await save_setting_with_retry("api_key", config_list[0]['api_key'])
|
||||
break
|
||||
except sqlite3.OperationalError as e:
|
||||
if "database is locked" in str(e) and attempt < MAX_RETRIES - 1:
|
||||
await asyncio.sleep(RETRY_DELAY)
|
||||
continue
|
||||
raise
|
||||
|
||||
os.environ["OPENAI_API_KEY"] = config_list[0]['api_key']
|
||||
os.environ["OPENAI_MODEL_NAME"] = config_list[0]['model']
|
||||
os.environ["OPENAI_API_BASE"] = config_list[0]['base_url']
|
||||
os.environ["MODEL_NAME"] = config_list[0]['model']
|
||||
framework = settings["Framework"]
|
||||
os.environ["FRAMEWORK"] = framework
|
||||
|
||||
if "agents" in settings:
|
||||
with open("agents.yaml", "w") as f:
|
||||
f.write(settings["agents"])
|
||||
if "tools" in settings:
|
||||
with open("tools.py", "w") as f:
|
||||
f.write(settings["tools"])
|
||||
|
||||
thread_id = cl.user_session.get("thread_id")
|
||||
if thread_id:
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
thread = await cl_data.get_thread(thread_id)
|
||||
if thread:
|
||||
metadata = thread.get("metadata", {})
|
||||
if isinstance(metadata, str):
|
||||
try:
|
||||
import json
|
||||
metadata = json.loads(metadata)
|
||||
except json.JSONDecodeError:
|
||||
metadata = {}
|
||||
metadata["model_name"] = config_list[0]['model']
|
||||
await cl_data.update_thread(thread_id, metadata=metadata)
|
||||
cl.user_session.set("metadata", metadata)
|
||||
break
|
||||
except sqlite3.OperationalError as e:
|
||||
if "database is locked" in str(e) and attempt < MAX_RETRIES - 1:
|
||||
await asyncio.sleep(RETRY_DELAY)
|
||||
continue
|
||||
raise
|
||||
|
||||
logger.info("Settings updated successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating settings: {str(e)}")
|
||||
await cl.Message(content=f"An error occurred while updating settings: {str(e)}. Retrying...").send()
|
||||
try:
|
||||
await asyncio.sleep(RETRY_DELAY * 2)
|
||||
await on_settings_update(settings)
|
||||
except Exception as e:
|
||||
logger.error(f"Final retry failed: {str(e)}")
|
||||
await cl.Message(content=f"Failed to update settings after retries: {str(e)}").send()
|
||||
@@ -1,425 +0,0 @@
|
||||
"""PraisonAI Bot UI - Chainlit chat interface with real-time streaming.
|
||||
|
||||
Provides a browser-based chat UI backed by PraisonAI Agent with:
|
||||
- Real-time token streaming via StreamEventEmitter (not fake word-splitting)
|
||||
- Tool call visualization as Chainlit Steps (Chain of Thought)
|
||||
- MESSAGE_RECEIVED/SENDING/SENT hook firing
|
||||
- Full bot capabilities (memory, web search, tools, etc.)
|
||||
- Gateway connectivity (if configured)
|
||||
|
||||
Usage:
|
||||
praisonai ui bot
|
||||
praisonai ui bot --model gpt-4o --memory --web
|
||||
praisonai ui bot --agent agents.yaml --tools DuckDuckGoTool
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
|
||||
import chainlit as cl
|
||||
from chainlit.input_widget import TextInput, Switch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
log_level = os.getenv("LOGLEVEL", "INFO").upper() or "INFO"
|
||||
logging.basicConfig(level=log_level, format="%(asctime)s - %(levelname)s - %(message)s")
|
||||
|
||||
import secrets
|
||||
|
||||
# Auth secret (required by Chainlit)
|
||||
if not os.getenv("CHAINLIT_AUTH_SECRET"):
|
||||
os.environ["CHAINLIT_AUTH_SECRET"] = secrets.token_hex(32)
|
||||
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lazy imports
|
||||
# ---------------------------------------------------------------------------
|
||||
_cache = {}
|
||||
|
||||
|
||||
def _get_agent_class():
|
||||
if "Agent" not in _cache:
|
||||
from praisonaiagents import Agent
|
||||
_cache["Agent"] = Agent
|
||||
return _cache["Agent"]
|
||||
|
||||
|
||||
def _get_stream_event_type():
|
||||
if "StreamEventType" not in _cache:
|
||||
from praisonaiagents.streaming.events import StreamEventType
|
||||
_cache["StreamEventType"] = StreamEventType
|
||||
return _cache["StreamEventType"]
|
||||
|
||||
|
||||
def _get_bot_handler():
|
||||
"""Reuse BotHandler._build_tools / _load_agent for DRY tool resolution."""
|
||||
if "BotHandler" not in _cache:
|
||||
from praisonai.cli.features.bots_cli import BotHandler
|
||||
_cache["BotHandler"] = BotHandler
|
||||
return _cache["BotHandler"]
|
||||
|
||||
|
||||
def _get_bot_capabilities():
|
||||
if "BotCapabilities" not in _cache:
|
||||
from praisonai.cli.features.bots_cli import BotCapabilities
|
||||
_cache["BotCapabilities"] = BotCapabilities
|
||||
return _cache["BotCapabilities"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Environment-driven config (set by CLI before launching chainlit)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _get_config():
|
||||
"""Read bot config from environment (set by CLI command)."""
|
||||
return {
|
||||
"model": os.getenv("PRAISONAI_BOT_MODEL", "gpt-4o-mini"),
|
||||
"agent_file": os.getenv("PRAISONAI_BOT_AGENT_FILE"),
|
||||
"memory": os.getenv("PRAISONAI_BOT_MEMORY", "").lower() in ("1", "true"),
|
||||
"web_search": os.getenv("PRAISONAI_BOT_WEB_SEARCH", "").lower() in ("1", "true"),
|
||||
"web_provider": os.getenv("PRAISONAI_BOT_WEB_PROVIDER", "duckduckgo"),
|
||||
"tools": [t.strip() for t in os.getenv("PRAISONAI_BOT_TOOLS", "").split(",") if t.strip()],
|
||||
"auto_approve": os.getenv("PRAISONAI_BOT_AUTO_APPROVE", "").lower() in ("1", "true"),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent factory (reuses BotHandler patterns)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _create_agent(model: str = None, memory: bool = False, web_search: bool = False,
|
||||
web_provider: str = "duckduckgo", tools_list: list = None,
|
||||
agent_file: str = None, auto_approve: bool = False):
|
||||
"""Create a PraisonAI Agent with capabilities, reusing BotHandler patterns."""
|
||||
BotCapabilities = _get_bot_capabilities()
|
||||
BotHandler = _get_bot_handler()
|
||||
|
||||
config = _get_config()
|
||||
model = model or config["model"]
|
||||
memory = memory or config["memory"]
|
||||
web_search = web_search or config["web_search"]
|
||||
web_provider = web_provider or config["web_provider"]
|
||||
tools_list = tools_list or config["tools"]
|
||||
agent_file = agent_file or config["agent_file"]
|
||||
auto_approve = auto_approve or config["auto_approve"]
|
||||
|
||||
caps = BotCapabilities(
|
||||
model=model,
|
||||
memory=memory,
|
||||
web_search=web_search,
|
||||
web_search_provider=web_provider,
|
||||
tools=tools_list or [],
|
||||
auto_approve=auto_approve,
|
||||
)
|
||||
|
||||
if auto_approve:
|
||||
os.environ["PRAISONAI_AUTO_APPROVE"] = "true"
|
||||
|
||||
handler = BotHandler()
|
||||
agent = handler._load_agent(agent_file, caps)
|
||||
return agent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streaming bridge: StreamEventEmitter (sync, in thread) → asyncio.Queue → cl.stream_token (async)
|
||||
# Pattern reused from gateway/server.py _make_stream_relay
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Sentinel to signal stream end
|
||||
_STREAM_END = object()
|
||||
|
||||
|
||||
def _make_chainlit_relay(event_queue: queue.Queue):
|
||||
"""Create a sync StreamCallback that puts events into a thread-safe queue.
|
||||
|
||||
This callback is registered on agent.stream_emitter and called from the
|
||||
LLM streaming thread (sync context). Events are consumed asynchronously
|
||||
by _consume_stream_events().
|
||||
"""
|
||||
def _relay(event) -> None:
|
||||
try:
|
||||
event_queue.put_nowait(event)
|
||||
except Exception:
|
||||
pass # Non-fatal: drop event if queue is full
|
||||
return _relay
|
||||
|
||||
|
||||
async def _consume_stream_events(event_queue: queue.Queue, msg: cl.Message):
|
||||
"""Async consumer: reads StreamEvents from queue and dispatches to Chainlit.
|
||||
|
||||
Maps:
|
||||
- DELTA_TEXT → msg.stream_token(content)
|
||||
- DELTA_TOOL_CALL → open cl.Step(type="tool")
|
||||
- STREAM_END → finalize message
|
||||
"""
|
||||
StreamEventType = _get_stream_event_type()
|
||||
open_steps = {} # tool_call_index → cl.Step
|
||||
|
||||
while True:
|
||||
# Poll queue without blocking the event loop
|
||||
try:
|
||||
event = event_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
await asyncio.sleep(0.01) # Yield to event loop
|
||||
continue
|
||||
|
||||
if event is _STREAM_END:
|
||||
break
|
||||
|
||||
event_type = getattr(event, "type", None)
|
||||
if event_type is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
if event_type == StreamEventType.DELTA_TEXT:
|
||||
content = getattr(event, "content", "") or ""
|
||||
if content:
|
||||
await msg.stream_token(content)
|
||||
|
||||
elif event_type == StreamEventType.DELTA_TOOL_CALL:
|
||||
tool_call = getattr(event, "tool_call", {}) or {}
|
||||
tc_index = tool_call.get("index", 0)
|
||||
tc_name = tool_call.get("name") or tool_call.get("function", {}).get("name")
|
||||
|
||||
if tc_index not in open_steps and tc_name:
|
||||
step = cl.Step(name=tc_name, type="tool")
|
||||
step.input = ""
|
||||
await step.send()
|
||||
open_steps[tc_index] = step
|
||||
|
||||
# Accumulate arguments into the step
|
||||
if tc_index in open_steps:
|
||||
args_chunk = tool_call.get("arguments", "") or tool_call.get("function", {}).get("arguments", "")
|
||||
if args_chunk:
|
||||
open_steps[tc_index].input += args_chunk
|
||||
|
||||
elif event_type == StreamEventType.TOOL_CALL_END:
|
||||
# Close all open steps
|
||||
for idx, step in list(open_steps.items()):
|
||||
step.output = "Completed"
|
||||
await step.update()
|
||||
open_steps.clear()
|
||||
|
||||
elif event_type == StreamEventType.STREAM_END:
|
||||
break
|
||||
|
||||
elif event_type == StreamEventType.ERROR:
|
||||
error_msg = getattr(event, "error", "Unknown error")
|
||||
await cl.Message(content=f"**Error:** {error_msg}", author="System").send()
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Stream event handling error (non-fatal): {e}")
|
||||
|
||||
# Close any remaining open steps
|
||||
for step in open_steps.values():
|
||||
try:
|
||||
step.output = "Completed"
|
||||
await step.update()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chainlit callbacks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Authentication configuration - bind-aware auth
|
||||
from ._auth import register_password_auth
|
||||
|
||||
# Determine bind host from CHAINLIT_HOST env var (default: 127.0.0.1)
|
||||
bind_host = os.getenv("CHAINLIT_HOST", "127.0.0.1")
|
||||
register_password_auth(None, bind_host=bind_host)
|
||||
|
||||
|
||||
@cl.on_chat_start
|
||||
async def on_chat_start():
|
||||
config = _get_config()
|
||||
|
||||
# Create agent and store in session
|
||||
agent = _create_agent()
|
||||
cl.user_session.set("agent", agent)
|
||||
cl.user_session.set("model_name", config["model"])
|
||||
|
||||
# Settings panel
|
||||
settings = cl.ChatSettings([
|
||||
TextInput(
|
||||
id="model_name",
|
||||
label="Model",
|
||||
placeholder="e.g. gpt-4o-mini",
|
||||
initial=config["model"],
|
||||
),
|
||||
Switch(id="memory", label="Memory", initial=config["memory"]),
|
||||
Switch(id="web_search", label="Web Search", initial=config["web_search"]),
|
||||
])
|
||||
await settings.send()
|
||||
|
||||
# Startup message
|
||||
capabilities = []
|
||||
if config["memory"]:
|
||||
capabilities.append("Memory")
|
||||
if config["web_search"]:
|
||||
capabilities.append(f"Web Search ({config['web_provider']})")
|
||||
if config["tools"]:
|
||||
capabilities.append(f"Tools: {', '.join(config['tools'])}")
|
||||
|
||||
caps_str = ", ".join(capabilities) if capabilities else "None"
|
||||
await cl.Message(
|
||||
content=(
|
||||
f"**PraisonAI Bot Ready**\n\n"
|
||||
f"**Model:** {config['model']}\n"
|
||||
f"**Capabilities:** {caps_str}\n\n"
|
||||
f"Type a message to start chatting!"
|
||||
)
|
||||
).send()
|
||||
|
||||
|
||||
@cl.on_settings_update
|
||||
async def on_settings_update(settings):
|
||||
model = settings.get("model_name", "gpt-4o-mini")
|
||||
memory = settings.get("memory", False)
|
||||
web_search = settings.get("web_search", False)
|
||||
|
||||
old_model = cl.user_session.get("model_name")
|
||||
|
||||
# Recreate agent if settings changed
|
||||
if model != old_model or memory != _get_config()["memory"]:
|
||||
agent = _create_agent(model=model, memory=memory, web_search=web_search)
|
||||
cl.user_session.set("agent", agent)
|
||||
cl.user_session.set("model_name", model)
|
||||
await cl.Message(content=f"Settings updated. Model: **{model}**").send()
|
||||
|
||||
|
||||
@cl.on_message
|
||||
async def on_message(message: cl.Message):
|
||||
"""Handle incoming messages with real-time streaming."""
|
||||
agent = cl.user_session.get("agent")
|
||||
if agent is None:
|
||||
agent = _create_agent()
|
||||
cl.user_session.set("agent", agent)
|
||||
|
||||
# Fire MESSAGE_RECEIVED hook (if hooks configured)
|
||||
_fire_hook_safe(agent, "MESSAGE_RECEIVED", {
|
||||
"content": message.content,
|
||||
"author": message.author or "user",
|
||||
})
|
||||
|
||||
# Create empty message for streaming
|
||||
msg = cl.Message(content="")
|
||||
await msg.send()
|
||||
|
||||
# Set up streaming bridge
|
||||
event_queue = queue.Queue(maxsize=10000)
|
||||
relay_callback = _make_chainlit_relay(event_queue)
|
||||
|
||||
# Register callback on the agent's stream emitter
|
||||
emitter = getattr(agent, "stream_emitter", None)
|
||||
if emitter is not None:
|
||||
emitter.add_callback(relay_callback)
|
||||
|
||||
# Start async consumer task
|
||||
consumer_task = asyncio.create_task(
|
||||
_consume_stream_events(event_queue, msg)
|
||||
)
|
||||
|
||||
try:
|
||||
# Fire MESSAGE_SENDING hook
|
||||
_fire_hook_safe(agent, "MESSAGE_SENDING", {"content": message.content})
|
||||
|
||||
# Run agent.chat in a thread (sync) — streaming events flow via the relay
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(
|
||||
None, lambda: agent.chat(message.content, stream=True)
|
||||
)
|
||||
|
||||
# Signal stream end
|
||||
event_queue.put_nowait(_STREAM_END)
|
||||
|
||||
# Wait for consumer to finish processing
|
||||
await consumer_task
|
||||
|
||||
# Update message with final content
|
||||
if response:
|
||||
response_text = response if isinstance(response, str) else str(response)
|
||||
# Only update if streaming didn't already populate the content
|
||||
if not msg.content or len(msg.content.strip()) < 10:
|
||||
msg.content = response_text
|
||||
await msg.update()
|
||||
else:
|
||||
if not msg.content:
|
||||
msg.content = "No response from agent."
|
||||
await msg.update()
|
||||
|
||||
# Fire MESSAGE_SENT hook
|
||||
_fire_hook_safe(agent, "MESSAGE_SENT", {
|
||||
"content": msg.content,
|
||||
"author": agent.name,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Agent chat error: {e}")
|
||||
event_queue.put_nowait(_STREAM_END)
|
||||
await consumer_task
|
||||
msg.content = f"Error: {str(e)}"
|
||||
await msg.update()
|
||||
|
||||
finally:
|
||||
# Always clean up the relay callback
|
||||
if emitter is not None:
|
||||
try:
|
||||
emitter.remove_callback(relay_callback)
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
|
||||
def _fire_hook_safe(agent, hook_name: str, data: dict):
|
||||
"""Fire a message lifecycle hook safely (non-blocking, non-fatal)."""
|
||||
try:
|
||||
runner = getattr(agent, "_hook_runner", None)
|
||||
if runner is None:
|
||||
return
|
||||
|
||||
from praisonaiagents.hooks import HookEvent
|
||||
event = getattr(HookEvent, hook_name, None)
|
||||
if event is None:
|
||||
return
|
||||
|
||||
# Build the appropriate input dataclass
|
||||
if hook_name == "MESSAGE_RECEIVED":
|
||||
from praisonaiagents.hooks.events import MessageReceivedInput
|
||||
hook_input = MessageReceivedInput(
|
||||
session_id=getattr(agent, "_session_id", "chainlit"),
|
||||
cwd=os.getcwd(),
|
||||
event_name=event,
|
||||
timestamp=str(__import__("time").time()),
|
||||
platform="chainlit",
|
||||
content=data.get("content", ""),
|
||||
sender_id=data.get("author", "user"),
|
||||
)
|
||||
elif hook_name == "MESSAGE_SENDING":
|
||||
from praisonaiagents.hooks.events import MessageSendingInput
|
||||
hook_input = MessageSendingInput(
|
||||
session_id=getattr(agent, "_session_id", "chainlit"),
|
||||
cwd=os.getcwd(),
|
||||
event_name=event,
|
||||
timestamp=str(__import__("time").time()),
|
||||
platform="chainlit",
|
||||
content=data.get("content", ""),
|
||||
)
|
||||
elif hook_name == "MESSAGE_SENT":
|
||||
from praisonaiagents.hooks.events import MessageSentInput
|
||||
hook_input = MessageSentInput(
|
||||
session_id=getattr(agent, "_session_id", "chainlit"),
|
||||
cwd=os.getcwd(),
|
||||
event_name=event,
|
||||
timestamp=str(__import__("time").time()),
|
||||
platform="chainlit",
|
||||
content=data.get("content", ""),
|
||||
message_id="",
|
||||
)
|
||||
else:
|
||||
return
|
||||
|
||||
runner.execute_sync(event, hook_input)
|
||||
except Exception as e:
|
||||
logger.debug(f"Hook {hook_name} error (non-fatal): {e}")
|
||||
@@ -1,246 +0,0 @@
|
||||
"""
|
||||
Chainlit compatibility shim for PraisonAI.
|
||||
|
||||
This module provides forward/backward compatible imports for Chainlit internals
|
||||
that may change between versions. It handles:
|
||||
- EXPIRY_TIME / storage_expiry_time constant (renamed/removed in various versions)
|
||||
- BaseStorageClient class location (moved from storage_clients.base to data.base)
|
||||
- BaseDataLayer abstract methods (close() added in 2.9.4, may be removed later)
|
||||
- LocalFileStorageClient for default element persistence
|
||||
|
||||
Usage:
|
||||
from praisonai.ui.chainlit_compat import get_expiry_seconds, BaseStorageClient
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Any, Dict, Optional, Type, Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default expiry time in seconds (1 hour) - used if Chainlit doesn't provide one
|
||||
DEFAULT_EXPIRY_SECONDS = 3600
|
||||
|
||||
|
||||
def get_expiry_seconds() -> int:
|
||||
"""
|
||||
Get the storage expiry time in seconds.
|
||||
|
||||
Tries to retrieve from Chainlit's configuration in a version-compatible way.
|
||||
Falls back to DEFAULT_EXPIRY_SECONDS if not available.
|
||||
|
||||
Returns:
|
||||
int: Expiry time in seconds
|
||||
"""
|
||||
# First check environment variable (highest priority)
|
||||
env_expiry = os.getenv("STORAGE_EXPIRY_TIME")
|
||||
if env_expiry is not None:
|
||||
try:
|
||||
return int(env_expiry)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Try to import from Chainlit (version-compatible)
|
||||
# Try multiple known locations across Chainlit versions
|
||||
|
||||
# Chainlit 2.9.x: storage_expiry_time in storage_clients.base
|
||||
try:
|
||||
from chainlit.data.storage_clients.base import storage_expiry_time
|
||||
return storage_expiry_time
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Older versions: EXPIRY_TIME (uppercase) in storage_clients.base
|
||||
try:
|
||||
from chainlit.data.storage_clients.base import EXPIRY_TIME
|
||||
return EXPIRY_TIME
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fallback to default
|
||||
return DEFAULT_EXPIRY_SECONDS
|
||||
|
||||
|
||||
def get_base_storage_client() -> Optional[Type]:
|
||||
"""
|
||||
Get the BaseStorageClient class from Chainlit.
|
||||
|
||||
Handles multiple import locations across Chainlit versions:
|
||||
- chainlit.data.base (latest/development)
|
||||
- chainlit.data.storage_clients.base (2.9.x)
|
||||
|
||||
Returns:
|
||||
Type: BaseStorageClient class, or None if not available
|
||||
"""
|
||||
# Try latest location first (chainlit.data.base)
|
||||
try:
|
||||
from chainlit.data.base import BaseStorageClient
|
||||
return BaseStorageClient
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Try 2.9.x location
|
||||
try:
|
||||
from chainlit.data.storage_clients.base import BaseStorageClient
|
||||
return BaseStorageClient
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_base_data_layer() -> Optional[Type]:
|
||||
"""
|
||||
Get the BaseDataLayer class from Chainlit.
|
||||
|
||||
Returns:
|
||||
Type: BaseDataLayer class, or None if not available
|
||||
"""
|
||||
try:
|
||||
from chainlit.data.base import BaseDataLayer
|
||||
return BaseDataLayer
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from chainlit.data import BaseDataLayer
|
||||
return BaseDataLayer
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def base_data_layer_has_close() -> bool:
|
||||
"""
|
||||
Check if BaseDataLayer has a close() method.
|
||||
|
||||
This method was added in Chainlit 2.9.4 but may be removed in future versions.
|
||||
|
||||
Returns:
|
||||
bool: True if close() method exists, False otherwise
|
||||
"""
|
||||
base_class = get_base_data_layer()
|
||||
if base_class is None:
|
||||
return False
|
||||
|
||||
import inspect
|
||||
# Check if close is an abstract method or regular method
|
||||
for name, method in inspect.getmembers(base_class):
|
||||
if name == 'close':
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# For backward compatibility, expose EXPIRY_TIME as an alias
|
||||
# This allows existing code to import it without changes
|
||||
EXPIRY_TIME = get_expiry_seconds()
|
||||
|
||||
# Re-export BaseStorageClient for convenience
|
||||
# Try multiple locations for compatibility
|
||||
BaseStorageClient = None
|
||||
try:
|
||||
from chainlit.data.base import BaseStorageClient
|
||||
except ImportError:
|
||||
try:
|
||||
from chainlit.data.storage_clients.base import BaseStorageClient
|
||||
except ImportError:
|
||||
BaseStorageClient = None
|
||||
|
||||
|
||||
class LocalFileStorageClient:
|
||||
"""
|
||||
A simple local file storage client for persisting elements to disk.
|
||||
|
||||
This provides a default storage implementation when no cloud storage
|
||||
(S3, Azure, etc.) is configured. Files are stored in the CHAINLIT_APP_ROOT/.files directory.
|
||||
"""
|
||||
|
||||
def __init__(self, storage_dir: Optional[str] = None):
|
||||
"""
|
||||
Initialize the local file storage client.
|
||||
|
||||
Args:
|
||||
storage_dir: Directory to store files. Defaults to CHAINLIT_APP_ROOT/.files
|
||||
"""
|
||||
if storage_dir:
|
||||
self.storage_dir = storage_dir
|
||||
else:
|
||||
chainlit_root = os.environ.get("CHAINLIT_APP_ROOT", os.path.join(os.path.expanduser("~"), ".praison"))
|
||||
self.storage_dir = os.path.join(chainlit_root, ".files")
|
||||
|
||||
os.makedirs(self.storage_dir, exist_ok=True)
|
||||
logger.debug(f"LocalFileStorageClient initialized with storage_dir: {self.storage_dir}")
|
||||
|
||||
async def upload_file(
|
||||
self,
|
||||
object_key: str,
|
||||
data: Union[bytes, str],
|
||||
mime: str = "application/octet-stream",
|
||||
overwrite: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Upload a file to local storage.
|
||||
|
||||
Args:
|
||||
object_key: The key/path for the file
|
||||
data: The file content (bytes or string)
|
||||
mime: MIME type of the file
|
||||
overwrite: Whether to overwrite existing files
|
||||
|
||||
Returns:
|
||||
Dict with object_key and url
|
||||
"""
|
||||
import aiofiles
|
||||
|
||||
# Create full path
|
||||
file_path = os.path.join(self.storage_dir, object_key)
|
||||
|
||||
# Create parent directories if needed
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
|
||||
# Check if file exists and overwrite is False
|
||||
if not overwrite and os.path.exists(file_path):
|
||||
logger.warning(f"File {object_key} already exists and overwrite=False")
|
||||
return {"object_key": object_key, "url": f"file://{file_path}"}
|
||||
|
||||
# Write the file
|
||||
try:
|
||||
if isinstance(data, str):
|
||||
async with aiofiles.open(file_path, 'w') as f:
|
||||
await f.write(data)
|
||||
else:
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
await f.write(data)
|
||||
|
||||
logger.debug(f"Uploaded file to {file_path}")
|
||||
return {"object_key": object_key, "url": f"file://{file_path}"}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upload file {object_key}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def create_local_storage_client(storage_dir: Optional[str] = None) -> LocalFileStorageClient:
|
||||
"""
|
||||
Create a LocalFileStorageClient instance.
|
||||
|
||||
Args:
|
||||
storage_dir: Optional directory for file storage
|
||||
|
||||
Returns:
|
||||
LocalFileStorageClient instance
|
||||
"""
|
||||
return LocalFileStorageClient(storage_dir=storage_dir)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'get_expiry_seconds',
|
||||
'get_base_storage_client',
|
||||
'get_base_data_layer',
|
||||
'base_data_layer_has_close',
|
||||
'EXPIRY_TIME',
|
||||
'BaseStorageClient',
|
||||
'LocalFileStorageClient',
|
||||
'create_local_storage_client',
|
||||
'DEFAULT_EXPIRY_SECONDS',
|
||||
]
|
||||
@@ -1,894 +0,0 @@
|
||||
"""PraisonAI Chat - Optimized Chainlit Application
|
||||
|
||||
This is the optimized chat application with:
|
||||
- Lazy imports for fast startup
|
||||
- Agent reuse between messages (persistent agent per session)
|
||||
- Default ACP/LSP tools with trust mode
|
||||
- Session management
|
||||
- Profiling hooks (optional via PRAISON_CHAT_PROFILE=1)
|
||||
|
||||
Performance improvements over original chat.py:
|
||||
- Deferred database initialization
|
||||
- Lazy loading of PIL, Tavily, crawl4ai, litellm
|
||||
- Agent reuse (no re-creation per message)
|
||||
- Interactive tools loaded once per session
|
||||
"""
|
||||
|
||||
# Standard library imports (minimal at top level)
|
||||
import os
|
||||
import logging
|
||||
|
||||
# Set up minimal logging first
|
||||
logger = logging.getLogger(__name__)
|
||||
log_level = os.getenv("LOGLEVEL", "INFO").upper() or "INFO"
|
||||
logger.handlers = []
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(log_level)
|
||||
console_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
console_handler.setFormatter(console_formatter)
|
||||
logger.addHandler(console_handler)
|
||||
logger.setLevel(log_level)
|
||||
|
||||
# Chainlit must be imported early (required by decorators)
|
||||
import chainlit as cl
|
||||
from chainlit.input_widget import TextInput, Switch
|
||||
from chainlit.types import ThreadDict
|
||||
import chainlit.data as cl_data
|
||||
|
||||
# Setup pairing action callbacks
|
||||
try:
|
||||
from praisonai.ui._pairing import setup_pairing_callbacks
|
||||
setup_pairing_callbacks()
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to setup pairing callbacks: {e}")
|
||||
|
||||
# Profiling support (optional)
|
||||
PROFILING_ENABLED = os.getenv("PRAISON_CHAT_PROFILE", "").lower() in ("1", "true", "yes")
|
||||
_profile_data = {}
|
||||
|
||||
def _profile_start(name: str):
|
||||
"""Start profiling a section."""
|
||||
if PROFILING_ENABLED:
|
||||
import time
|
||||
_profile_data[name] = {"start": time.perf_counter()}
|
||||
|
||||
def _profile_end(name: str):
|
||||
"""End profiling a section and log."""
|
||||
if PROFILING_ENABLED and name in _profile_data:
|
||||
import time
|
||||
elapsed = time.perf_counter() - _profile_data[name]["start"]
|
||||
_profile_data[name]["elapsed"] = elapsed
|
||||
logger.info(f"[PROFILE] {name}: {elapsed*1000:.2f}ms")
|
||||
|
||||
# Lazy import cache
|
||||
_cached_modules = {}
|
||||
|
||||
def _get_json():
|
||||
if 'json' not in _cached_modules:
|
||||
import json
|
||||
_cached_modules['json'] = json
|
||||
return _cached_modules['json']
|
||||
|
||||
def _get_asyncio():
|
||||
if 'asyncio' not in _cached_modules:
|
||||
import asyncio
|
||||
_cached_modules['asyncio'] = asyncio
|
||||
return _cached_modules['asyncio']
|
||||
|
||||
def _get_datetime():
|
||||
if 'datetime' not in _cached_modules:
|
||||
from datetime import datetime
|
||||
_cached_modules['datetime'] = datetime
|
||||
return _cached_modules['datetime']
|
||||
|
||||
def _get_io():
|
||||
if 'io' not in _cached_modules:
|
||||
import io
|
||||
_cached_modules['io'] = io
|
||||
return _cached_modules['io']
|
||||
|
||||
def _get_base64():
|
||||
if 'base64' not in _cached_modules:
|
||||
import base64
|
||||
_cached_modules['base64'] = base64
|
||||
return _cached_modules['base64']
|
||||
|
||||
def _get_importlib():
|
||||
if 'importlib' not in _cached_modules:
|
||||
import importlib.util
|
||||
_cached_modules['importlib'] = importlib
|
||||
return _cached_modules['importlib']
|
||||
|
||||
def _get_inspect():
|
||||
if 'inspect' not in _cached_modules:
|
||||
import inspect
|
||||
_cached_modules['inspect'] = inspect
|
||||
return _cached_modules['inspect']
|
||||
|
||||
def _get_pil_image():
|
||||
if 'PIL.Image' not in _cached_modules:
|
||||
_profile_start("import_pil")
|
||||
from PIL import Image
|
||||
_cached_modules['PIL.Image'] = Image
|
||||
_profile_end("import_pil")
|
||||
return _cached_modules['PIL.Image']
|
||||
|
||||
def _get_acompletion():
|
||||
if 'acompletion' not in _cached_modules:
|
||||
_profile_start("import_litellm")
|
||||
from litellm import acompletion
|
||||
_cached_modules['acompletion'] = acompletion
|
||||
_profile_end("import_litellm")
|
||||
return _cached_modules['acompletion']
|
||||
|
||||
def _get_tavily_client():
|
||||
if 'TavilyClient' not in _cached_modules:
|
||||
try:
|
||||
_profile_start("import_tavily")
|
||||
from tavily import TavilyClient
|
||||
_cached_modules['TavilyClient'] = TavilyClient
|
||||
_profile_end("import_tavily")
|
||||
except ImportError:
|
||||
_cached_modules['TavilyClient'] = None
|
||||
return _cached_modules['TavilyClient']
|
||||
|
||||
def _get_async_web_crawler():
|
||||
if 'AsyncWebCrawler' not in _cached_modules:
|
||||
try:
|
||||
_profile_start("import_crawl4ai")
|
||||
from crawl4ai import AsyncWebCrawler
|
||||
_cached_modules['AsyncWebCrawler'] = AsyncWebCrawler
|
||||
_profile_end("import_crawl4ai")
|
||||
except ImportError:
|
||||
_cached_modules['AsyncWebCrawler'] = None
|
||||
return _cached_modules['AsyncWebCrawler']
|
||||
|
||||
def _get_praisonai_agent():
|
||||
"""Lazy load PraisonAI Agent for reuse."""
|
||||
if 'Agent' not in _cached_modules:
|
||||
try:
|
||||
_profile_start("import_praisonai_agent")
|
||||
from praisonaiagents import Agent
|
||||
_cached_modules['Agent'] = Agent
|
||||
_profile_end("import_praisonai_agent")
|
||||
except ImportError:
|
||||
_cached_modules['Agent'] = None
|
||||
return _cached_modules['Agent']
|
||||
|
||||
def _get_interactive_tools():
|
||||
"""Lazy load interactive tools (ACP, LSP, basic) with trust mode."""
|
||||
if 'interactive_tools' not in _cached_modules:
|
||||
try:
|
||||
_profile_start("load_interactive_tools")
|
||||
from praisonai.cli.features.interactive_tools import get_interactive_tools, ToolConfig
|
||||
config = ToolConfig.from_env()
|
||||
config.workspace = os.environ.get("PRAISONAI_CODE_REPO_PATH", os.getcwd())
|
||||
# Do not hardcode approval_mode to "auto" to respect environment configuration
|
||||
tools = get_interactive_tools(config=config)
|
||||
_cached_modules['interactive_tools'] = tools
|
||||
_profile_end("load_interactive_tools")
|
||||
logger.info(f"Loaded {len(tools)} interactive tools (ACP, LSP, basic) with trust mode")
|
||||
except ImportError as e:
|
||||
logger.debug(f"Interactive tools not available: {e}")
|
||||
_cached_modules['interactive_tools'] = []
|
||||
return _cached_modules['interactive_tools']
|
||||
|
||||
# Deferred database initialization
|
||||
_db_manager = None
|
||||
|
||||
def _get_db_manager():
|
||||
"""Lazy initialize database manager."""
|
||||
global _db_manager
|
||||
if _db_manager is None:
|
||||
_profile_start("init_database")
|
||||
# Import from the same directory as this module
|
||||
import sys
|
||||
ui_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if ui_dir not in sys.path:
|
||||
sys.path.insert(0, ui_dir)
|
||||
from db import DatabaseManager
|
||||
_db_manager = DatabaseManager()
|
||||
_db_manager.initialize()
|
||||
cl_data._data_layer = _db_manager
|
||||
_profile_end("init_database")
|
||||
return _db_manager
|
||||
|
||||
# Load environment variables lazily
|
||||
def _ensure_env_loaded():
|
||||
if 'env_loaded' not in _cached_modules:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
_cached_modules['env_loaded'] = True
|
||||
|
||||
# Auth secret setup (required early)
|
||||
import secrets
|
||||
_ensure_env_loaded()
|
||||
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
|
||||
if not CHAINLIT_AUTH_SECRET:
|
||||
CHAINLIT_AUTH_SECRET = secrets.token_hex(32)
|
||||
os.environ["CHAINLIT_AUTH_SECRET"] = CHAINLIT_AUTH_SECRET
|
||||
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
|
||||
|
||||
|
||||
def save_setting(key: str, value: str):
|
||||
"""Save a setting to the database"""
|
||||
asyncio = _get_asyncio()
|
||||
db_manager = _get_db_manager()
|
||||
asyncio.run(db_manager.save_setting(key, value))
|
||||
|
||||
def load_setting(key: str) -> str:
|
||||
"""Load a setting from the database"""
|
||||
asyncio = _get_asyncio()
|
||||
db_manager = _get_db_manager()
|
||||
return asyncio.run(db_manager.load_setting(key))
|
||||
|
||||
def load_custom_tools():
|
||||
"""Load custom tools from tools.py if it exists."""
|
||||
custom_tools = {}
|
||||
importlib = _get_importlib()
|
||||
inspect = _get_inspect()
|
||||
|
||||
tools_path = os.getenv("PRAISONAI_TOOLS_PATH")
|
||||
if tools_path:
|
||||
if not os.path.exists(tools_path):
|
||||
logger.warning(f"PRAISONAI_TOOLS_PATH set but path does not exist: {tools_path}")
|
||||
return custom_tools
|
||||
else:
|
||||
cwd_tools = os.path.join(os.getcwd(), "tools.py")
|
||||
if os.path.exists(cwd_tools):
|
||||
tools_path = cwd_tools
|
||||
else:
|
||||
logger.debug("No tools.py found in current directory (this is normal)")
|
||||
return custom_tools
|
||||
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location("tools", tools_path)
|
||||
if spec is None or spec.loader is None:
|
||||
logger.debug(f"Could not load tools from {tools_path}")
|
||||
return custom_tools
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
for name, obj in inspect.getmembers(module):
|
||||
if not name.startswith('_') and callable(obj) and not inspect.isclass(obj):
|
||||
globals()[name] = obj
|
||||
|
||||
sig = inspect.signature(obj)
|
||||
params_properties = {}
|
||||
required_params = []
|
||||
|
||||
for param_name, param in sig.parameters.items():
|
||||
if param_name != 'self':
|
||||
param_type = "string"
|
||||
if param.annotation is not inspect.Parameter.empty:
|
||||
if param.annotation is int:
|
||||
param_type = "integer"
|
||||
elif param.annotation is float:
|
||||
param_type = "number"
|
||||
elif param.annotation is bool:
|
||||
param_type = "boolean"
|
||||
|
||||
params_properties[param_name] = {
|
||||
"type": param_type,
|
||||
"description": f"Parameter {param_name}"
|
||||
}
|
||||
|
||||
if param.default is inspect.Parameter.empty:
|
||||
required_params.append(param_name)
|
||||
|
||||
tool_def = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"description": obj.__doc__ or f"Function {name.replace('_', ' ')}",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": params_properties,
|
||||
"required": required_params
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
custom_tools[name] = tool_def
|
||||
logger.info(f"Loaded custom tool: {name}")
|
||||
|
||||
if custom_tools:
|
||||
logger.info(f"Loaded {len(custom_tools)} custom tools from {tools_path}")
|
||||
except FileNotFoundError:
|
||||
logger.debug(f"Tools file not found: {tools_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error loading custom tools from {tools_path}: {e}")
|
||||
|
||||
return custom_tools
|
||||
|
||||
# Deferred tool loading
|
||||
_custom_tools_dict = None
|
||||
_tavily_client = None
|
||||
_tools_list = None
|
||||
|
||||
def _get_custom_tools():
|
||||
global _custom_tools_dict
|
||||
if _custom_tools_dict is None:
|
||||
_custom_tools_dict = load_custom_tools()
|
||||
return _custom_tools_dict
|
||||
|
||||
def _get_tavily():
|
||||
global _tavily_client
|
||||
if _tavily_client is None:
|
||||
tavily_api_key = os.getenv("TAVILY_API_KEY")
|
||||
if tavily_api_key:
|
||||
TavilyClient = _get_tavily_client()
|
||||
if TavilyClient:
|
||||
_tavily_client = TavilyClient(api_key=tavily_api_key)
|
||||
return _tavily_client
|
||||
|
||||
def _get_tools_list():
|
||||
"""Get the combined tools list (Tavily + custom tools)."""
|
||||
global _tools_list
|
||||
if _tools_list is None:
|
||||
_tools_list = []
|
||||
tavily_api_key = os.getenv("TAVILY_API_KEY")
|
||||
if tavily_api_key:
|
||||
_tools_list.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "tavily_web_search",
|
||||
"description": "Search the web using Tavily API and crawl the resulting URLs",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
})
|
||||
custom_tools = _get_custom_tools()
|
||||
_tools_list.extend(list(custom_tools.values()))
|
||||
return _tools_list
|
||||
|
||||
async def tavily_web_search(query):
|
||||
"""Search the web using Tavily API."""
|
||||
json = _get_json()
|
||||
tavily_client = _get_tavily()
|
||||
|
||||
if not tavily_client:
|
||||
return json.dumps({
|
||||
"query": query,
|
||||
"error": "Tavily API key is not set. Web search is unavailable."
|
||||
})
|
||||
|
||||
response = tavily_client.search(query)
|
||||
logger.debug(f"Tavily search response: {response}")
|
||||
|
||||
AsyncWebCrawler = _get_async_web_crawler()
|
||||
if AsyncWebCrawler:
|
||||
async with AsyncWebCrawler() as crawler:
|
||||
results = []
|
||||
for result in response.get('results', []):
|
||||
url = result.get('url')
|
||||
if url:
|
||||
try:
|
||||
crawl_result = await crawler.arun(url=url)
|
||||
results.append({
|
||||
"content": result.get('content'),
|
||||
"url": url,
|
||||
"full_content": crawl_result.markdown
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error crawling {url}: {str(e)}")
|
||||
results.append({
|
||||
"content": result.get('content'),
|
||||
"url": url,
|
||||
"full_content": "Error: Unable to crawl this URL"
|
||||
})
|
||||
else:
|
||||
results = [{"content": r.get('content'), "url": r.get('url')} for r in response.get('results', [])]
|
||||
|
||||
return json.dumps({
|
||||
"query": query,
|
||||
"results": results
|
||||
})
|
||||
|
||||
# Authentication configuration - bind-aware auth
|
||||
from ._auth import register_password_auth
|
||||
|
||||
# Determine bind host from CHAINLIT_HOST env var (default: 127.0.0.1)
|
||||
bind_host = os.getenv("CHAINLIT_HOST", "127.0.0.1")
|
||||
register_password_auth(None, bind_host=bind_host)
|
||||
|
||||
def _get_or_create_agent(model_name: str, tools_enabled: bool = True, external_agents_settings: dict = None):
|
||||
"""Get or create a reusable agent for the session."""
|
||||
Agent = _get_praisonai_agent()
|
||||
if Agent is None:
|
||||
return None
|
||||
if external_agents_settings is None:
|
||||
external_agents_settings = {}
|
||||
|
||||
# Get cached agent from session
|
||||
cached_agent = cl.user_session.get("_cached_agent")
|
||||
cached_model = cl.user_session.get("_cached_agent_model")
|
||||
cached_external = cl.user_session.get("_cached_agent_external", {})
|
||||
|
||||
# Reuse if model and external settings match
|
||||
if (cached_agent is not None and cached_model == model_name
|
||||
and cached_external == external_agents_settings):
|
||||
return cached_agent
|
||||
|
||||
# Create new agent with interactive tools
|
||||
_profile_start("create_agent")
|
||||
tools = []
|
||||
if tools_enabled:
|
||||
tools = list(_get_interactive_tools()) # Copy to avoid mutating cache
|
||||
# Add Tavily if available
|
||||
if os.getenv("TAVILY_API_KEY"):
|
||||
tools.append(tavily_web_search)
|
||||
# Add external agent tools
|
||||
from praisonai.ui._external_agents import external_agent_tools
|
||||
tools.extend(
|
||||
external_agent_tools(
|
||||
external_agents_settings,
|
||||
workspace=os.environ.get("PRAISONAI_WORKSPACE", "."),
|
||||
)
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
name="PraisonAI Assistant",
|
||||
instructions="""You are a helpful AI assistant with access to powerful tools.
|
||||
|
||||
Available capabilities:
|
||||
- File operations (read, write, create, edit, delete files)
|
||||
- Code intelligence (find symbols, definitions, references)
|
||||
- Command execution (run shell commands)
|
||||
- Web search (if Tavily API key is set)
|
||||
|
||||
Use tools when needed to help the user. For file modifications, use the ACP tools which provide safe, reviewable changes.
|
||||
Always be helpful, accurate, and concise.""",
|
||||
llm=model_name,
|
||||
tools=tools if tools else None,
|
||||
output="minimal",
|
||||
)
|
||||
|
||||
# Cache the agent
|
||||
cl.user_session.set("_cached_agent", agent)
|
||||
cl.user_session.set("_cached_agent_model", model_name)
|
||||
cl.user_session.set("_cached_agent_external", external_agents_settings)
|
||||
_profile_end("create_agent")
|
||||
|
||||
return agent
|
||||
|
||||
|
||||
def _parse_setting_bool(value):
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return False
|
||||
return str(value).strip().lower() in {"true", "1", "yes", "on"}
|
||||
|
||||
|
||||
def _get_external_agent_settings_from_session() -> dict:
|
||||
from praisonai.ui._external_agents import EXTERNAL_AGENTS
|
||||
return {
|
||||
toggle_id: _parse_setting_bool(cl.user_session.get(toggle_id, False))
|
||||
for toggle_id in EXTERNAL_AGENTS
|
||||
}
|
||||
|
||||
@cl.on_chat_start
|
||||
async def start():
|
||||
_profile_start("on_chat_start")
|
||||
_ensure_env_loaded()
|
||||
|
||||
model_name = load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-4o-mini")
|
||||
tools_enabled = (load_setting("tools_enabled") or "true").lower() == "true"
|
||||
|
||||
cl.user_session.set("model_name", model_name)
|
||||
cl.user_session.set("tools_enabled", tools_enabled)
|
||||
logger.debug(f"Model name: {model_name}, Tools enabled: {tools_enabled}")
|
||||
|
||||
# Load external agent settings
|
||||
from praisonai.ui._external_agents import load_external_agent_settings_from_chainlit, chainlit_switches
|
||||
external_settings = load_external_agent_settings_from_chainlit(load_setting)
|
||||
|
||||
# Pre-create agent for faster first response
|
||||
_get_or_create_agent(model_name, tools_enabled, external_settings)
|
||||
|
||||
settings = cl.ChatSettings(
|
||||
[
|
||||
TextInput(
|
||||
id="model_name",
|
||||
label="Enter the Model Name",
|
||||
placeholder="e.g., gpt-4o-mini",
|
||||
initial=model_name
|
||||
),
|
||||
Switch(
|
||||
id="tools_enabled",
|
||||
label="Enable Tools (ACP, LSP, Web Search)",
|
||||
initial=tools_enabled
|
||||
),
|
||||
*chainlit_switches(external_settings)
|
||||
]
|
||||
)
|
||||
cl.user_session.set("settings", settings)
|
||||
await settings.send()
|
||||
|
||||
# Show loaded tools info
|
||||
tools = _get_interactive_tools()
|
||||
tool_names = [t.__name__ for t in tools[:5]]
|
||||
if len(tools) > 5:
|
||||
tool_names.append(f"... and {len(tools) - 5} more")
|
||||
|
||||
await cl.Message(
|
||||
content=f"🚀 **PraisonAI Chat Ready**\n\n"
|
||||
f"**Model:** {model_name}\n"
|
||||
f"**Tools:** {len(tools)} loaded ({', '.join(tool_names)})\n"
|
||||
f"**Trust Mode:** Enabled (auto-approve tool executions)\n\n"
|
||||
f"Type your message to get started!"
|
||||
).send()
|
||||
|
||||
# Setup pairing banner for admin users
|
||||
try:
|
||||
from praisonai.ui._pairing import setup_pairing_banner
|
||||
await setup_pairing_banner()
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to setup pairing banner: {e}")
|
||||
|
||||
_profile_end("on_chat_start")
|
||||
|
||||
@cl.on_settings_update
|
||||
async def setup_agent(settings):
|
||||
json = _get_json()
|
||||
logger.debug(settings)
|
||||
cl.user_session.set("settings", settings)
|
||||
|
||||
model_name = settings["model_name"]
|
||||
tools_enabled = settings.get("tools_enabled", True)
|
||||
|
||||
cl.user_session.set("model_name", model_name)
|
||||
cl.user_session.set("tools_enabled", tools_enabled)
|
||||
|
||||
# Save external agent settings
|
||||
from praisonai.ui._external_agents import save_external_agent_settings_to_chainlit, EXTERNAL_AGENTS
|
||||
external_agent_settings = {k: settings.get(k, False) for k in EXTERNAL_AGENTS}
|
||||
save_external_agent_settings_to_chainlit(external_agent_settings)
|
||||
|
||||
# Invalidate cached agent if model changed or external agents changed
|
||||
cached_model = cl.user_session.get("_cached_agent_model")
|
||||
cached_external = cl.user_session.get("_cached_agent_external", {})
|
||||
if cached_model != model_name or cached_external != external_agent_settings:
|
||||
cl.user_session.set("_cached_agent", None)
|
||||
cl.user_session.set("_cached_agent_model", None)
|
||||
cl.user_session.set("_cached_agent_external", None)
|
||||
|
||||
save_setting("model_name", model_name)
|
||||
save_setting("tools_enabled", str(tools_enabled).lower())
|
||||
# Save external agent settings to persistent storage
|
||||
for toggle_id, enabled in external_agent_settings.items():
|
||||
save_setting(toggle_id, str(enabled).lower())
|
||||
|
||||
thread_id = cl.user_session.get("thread_id")
|
||||
if thread_id:
|
||||
thread = await cl_data.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
|
||||
metadata["tools_enabled"] = tools_enabled
|
||||
await cl_data.update_thread(thread_id, metadata=metadata)
|
||||
cl.user_session.set("metadata", metadata)
|
||||
|
||||
@cl.on_message
|
||||
async def main(message: cl.Message):
|
||||
_profile_start("on_message_total")
|
||||
json = _get_json()
|
||||
asyncio = _get_asyncio()
|
||||
datetime = _get_datetime()
|
||||
|
||||
model_name = cl.user_session.get("model_name") or load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-4o-mini")
|
||||
tools_enabled = cl.user_session.get("tools_enabled", True)
|
||||
message_history = cl.user_session.get("message_history", [])
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Handle image uploads
|
||||
image = None
|
||||
if message.elements and isinstance(message.elements[0], cl.Image):
|
||||
Image = _get_pil_image()
|
||||
image_element = message.elements[0]
|
||||
try:
|
||||
image = Image.open(image_element.path)
|
||||
image.load()
|
||||
cl.user_session.set("image", image)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing image: {str(e)}")
|
||||
await cl.Message(content="Error processing the image. Please try again.").send()
|
||||
return
|
||||
|
||||
user_message = f"""Answer the question and use tools if needed:
|
||||
|
||||
Current Date and Time: {now}
|
||||
|
||||
User Question: {message.content}
|
||||
"""
|
||||
|
||||
if image:
|
||||
user_message = f"Image uploaded. {user_message}"
|
||||
|
||||
message_history.append({"role": "user", "content": user_message})
|
||||
msg = cl.Message(content="")
|
||||
|
||||
# Try PraisonAI Agent first (faster, with tool reuse)
|
||||
external_settings = _get_external_agent_settings_from_session()
|
||||
agent = _get_or_create_agent(model_name, tools_enabled, external_settings) if tools_enabled else None
|
||||
|
||||
if agent is not None:
|
||||
_profile_start("agent_response")
|
||||
await msg.send()
|
||||
|
||||
try:
|
||||
# Use async chat for streaming
|
||||
result = await agent.achat(message.content)
|
||||
|
||||
# Get response text
|
||||
if hasattr(result, 'raw'):
|
||||
response_text = result.raw
|
||||
else:
|
||||
response_text = str(result)
|
||||
|
||||
# Stream in word chunks for better UX
|
||||
words = response_text.split(' ')
|
||||
for i, word in enumerate(words):
|
||||
token = word + (' ' if i < len(words) - 1 else '')
|
||||
await msg.stream_token(token)
|
||||
|
||||
msg.content = response_text
|
||||
await msg.update()
|
||||
|
||||
message_history.append({"role": "assistant", "content": response_text})
|
||||
cl.user_session.set("message_history", message_history)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Agent error: {e}")
|
||||
# Fallback to litellm
|
||||
await _handle_with_litellm(message, user_message, model_name, message_history, msg, image)
|
||||
|
||||
_profile_end("agent_response")
|
||||
else:
|
||||
# Fallback to litellm
|
||||
await _handle_with_litellm(message, user_message, model_name, message_history, msg, image)
|
||||
|
||||
_profile_end("on_message_total")
|
||||
|
||||
async def _handle_with_litellm(message, user_message, model_name, message_history, msg, image):
|
||||
"""Fallback handler using litellm for backward compatibility."""
|
||||
json = _get_json()
|
||||
asyncio = _get_asyncio()
|
||||
io = _get_io()
|
||||
base64 = _get_base64()
|
||||
acompletion = _get_acompletion()
|
||||
tools = _get_tools_list()
|
||||
|
||||
_profile_start("litellm_response")
|
||||
|
||||
completion_params = {
|
||||
"model": model_name,
|
||||
"messages": message_history,
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
if image:
|
||||
buffered = io.BytesIO()
|
||||
image.save(buffered, format="PNG")
|
||||
img_str = base64.b64encode(buffered.getvalue()).decode()
|
||||
completion_params["messages"][-1] = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": user_message},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img_str}"}}
|
||||
]
|
||||
}
|
||||
|
||||
if tools:
|
||||
completion_params["tools"] = tools
|
||||
completion_params["tool_choice"] = "auto"
|
||||
|
||||
response = await acompletion(**completion_params)
|
||||
|
||||
full_response = ""
|
||||
tool_calls = []
|
||||
current_tool_call = None
|
||||
msg_sent = False
|
||||
|
||||
async for part in response:
|
||||
if 'choices' in part and len(part['choices']) > 0:
|
||||
delta = part['choices'][0].get('delta', {})
|
||||
|
||||
if 'content' in delta and delta['content'] is not None:
|
||||
token = delta['content']
|
||||
if not msg_sent:
|
||||
await msg.send()
|
||||
msg_sent = True
|
||||
await msg.stream_token(token)
|
||||
full_response += token
|
||||
|
||||
if tools and 'tool_calls' in delta and delta['tool_calls'] is not None:
|
||||
for tool_call in delta['tool_calls']:
|
||||
if current_tool_call is None or tool_call.index != current_tool_call['index']:
|
||||
if current_tool_call:
|
||||
tool_calls.append(current_tool_call)
|
||||
current_tool_call = {
|
||||
'id': tool_call.id,
|
||||
'type': tool_call.type,
|
||||
'index': tool_call.index,
|
||||
'function': {
|
||||
'name': tool_call.function.name if tool_call.function else None,
|
||||
'arguments': ''
|
||||
}
|
||||
}
|
||||
if tool_call.function:
|
||||
if tool_call.function.name:
|
||||
current_tool_call['function']['name'] = tool_call.function.name
|
||||
if tool_call.function.arguments:
|
||||
current_tool_call['function']['arguments'] += tool_call.function.arguments
|
||||
|
||||
if current_tool_call:
|
||||
tool_calls.append(current_tool_call)
|
||||
|
||||
if not msg_sent:
|
||||
await msg.send()
|
||||
|
||||
message_history.append({"role": "assistant", "content": full_response})
|
||||
cl.user_session.set("message_history", message_history)
|
||||
await msg.update()
|
||||
|
||||
# Handle tool calls
|
||||
if tool_calls and tools:
|
||||
custom_tools = _get_custom_tools()
|
||||
available_functions = {}
|
||||
|
||||
if os.getenv("TAVILY_API_KEY"):
|
||||
available_functions["tavily_web_search"] = tavily_web_search
|
||||
|
||||
for tool_name in custom_tools:
|
||||
if tool_name in globals():
|
||||
available_functions[tool_name] = globals()[tool_name]
|
||||
|
||||
messages = message_history + [{"role": "assistant", "content": None, "function_call": {
|
||||
"name": tool_calls[0]['function']['name'],
|
||||
"arguments": tool_calls[0]['function']['arguments']
|
||||
}}]
|
||||
|
||||
for tool_call in tool_calls:
|
||||
function_name = tool_call['function']['name']
|
||||
if function_name in available_functions:
|
||||
function_to_call = available_functions[function_name]
|
||||
function_args = tool_call['function']['arguments']
|
||||
if function_args:
|
||||
try:
|
||||
function_args = json.loads(function_args)
|
||||
|
||||
if asyncio.iscoroutinefunction(function_to_call):
|
||||
if function_name == "tavily_web_search":
|
||||
function_response = await function_to_call(query=function_args.get("query"))
|
||||
else:
|
||||
function_response = await function_to_call(**function_args)
|
||||
else:
|
||||
function_response = function_to_call(**function_args)
|
||||
|
||||
if not isinstance(function_response, str):
|
||||
function_response = json.dumps(function_response)
|
||||
|
||||
messages.append({
|
||||
"role": "function",
|
||||
"name": function_name,
|
||||
"content": function_response,
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Failed to parse function arguments: {function_args}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error calling function {function_name}: {str(e)}")
|
||||
messages.append({
|
||||
"role": "function",
|
||||
"name": function_name,
|
||||
"content": f"Error: {str(e)}",
|
||||
})
|
||||
|
||||
second_response = await acompletion(
|
||||
model=model_name,
|
||||
stream=True,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
full_response = ""
|
||||
async for part in second_response:
|
||||
if 'choices' in part and len(part['choices']) > 0:
|
||||
delta = part['choices'][0].get('delta', {})
|
||||
if 'content' in delta and delta['content'] is not None:
|
||||
token = delta['content']
|
||||
await msg.stream_token(token)
|
||||
full_response += token
|
||||
|
||||
msg.content = full_response
|
||||
await msg.update()
|
||||
else:
|
||||
msg.content = full_response
|
||||
await msg.update()
|
||||
|
||||
_profile_end("litellm_response")
|
||||
|
||||
@cl.on_chat_resume
|
||||
async def on_chat_resume(thread: ThreadDict):
|
||||
json = _get_json()
|
||||
io = _get_io()
|
||||
base64 = _get_base64()
|
||||
Image = _get_pil_image()
|
||||
|
||||
logger.info(f"Resuming chat: {thread['id']}")
|
||||
model_name = load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-4o-mini")
|
||||
tools_enabled = (load_setting("tools_enabled") or "true").lower() == "true"
|
||||
|
||||
logger.debug(f"Model name: {model_name}")
|
||||
|
||||
# Load external agent settings for resume
|
||||
from praisonai.ui._external_agents import load_external_agent_settings_from_chainlit, chainlit_switches
|
||||
external_settings = load_external_agent_settings_from_chainlit()
|
||||
|
||||
settings = cl.ChatSettings(
|
||||
[
|
||||
TextInput(
|
||||
id="model_name",
|
||||
label="Enter the Model Name",
|
||||
placeholder="e.g., gpt-4o-mini",
|
||||
initial=model_name
|
||||
),
|
||||
Switch(
|
||||
id="tools_enabled",
|
||||
label="Enable Tools (ACP, LSP, Web Search)",
|
||||
initial=tools_enabled
|
||||
),
|
||||
*chainlit_switches(external_settings),
|
||||
]
|
||||
)
|
||||
await settings.send()
|
||||
|
||||
cl.user_session.set("thread_id", thread["id"])
|
||||
cl.user_session.set("model_name", model_name)
|
||||
cl.user_session.set("tools_enabled", tools_enabled)
|
||||
|
||||
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 = cl.user_session.get("message_history", [])
|
||||
steps = thread["steps"]
|
||||
|
||||
for m in steps:
|
||||
msg_type = m.get("type")
|
||||
if msg_type == "user_message":
|
||||
message_history.append({"role": "user", "content": m.get("output", "")})
|
||||
elif msg_type == "assistant_message":
|
||||
message_history.append({"role": "assistant", "content": m.get("output", "")})
|
||||
elif msg_type == "run":
|
||||
if m.get("isError"):
|
||||
message_history.append({"role": "system", "content": f"Error: {m.get('output', '')}"})
|
||||
else:
|
||||
logger.warning(f"Message without recognized type: {m}")
|
||||
|
||||
cl.user_session.set("message_history", message_history)
|
||||
|
||||
# Pre-create agent for faster first response
|
||||
external_settings = _get_external_agent_settings_from_session()
|
||||
_get_or_create_agent(model_name, tools_enabled, external_settings)
|
||||
|
||||
image_data = metadata.get("image")
|
||||
if image_data:
|
||||
image = Image.open(io.BytesIO(base64.b64decode(image_data)))
|
||||
cl.user_session.set("image", image)
|
||||
await cl.Message(content="Previous image loaded.").send()
|
||||
@@ -1,781 +0,0 @@
|
||||
"""PraisonAI Code - Optimized Chainlit Application
|
||||
|
||||
This is the optimized code assistant application with:
|
||||
- Lazy imports for fast startup
|
||||
- Agent reuse between messages (persistent agent per session)
|
||||
- Default ACP/LSP tools with trust mode
|
||||
- Context gathering optimization (cached per session)
|
||||
- Profiling hooks (optional via PRAISON_CODE_PROFILE=1)
|
||||
"""
|
||||
|
||||
# Standard library imports (minimal at top level)
|
||||
import os
|
||||
import logging
|
||||
|
||||
# Set up minimal logging first
|
||||
logger = logging.getLogger(__name__)
|
||||
log_level = os.getenv("LOGLEVEL", "INFO").upper() or "INFO"
|
||||
logger.handlers = []
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(log_level)
|
||||
console_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
console_handler.setFormatter(console_formatter)
|
||||
logger.addHandler(console_handler)
|
||||
logger.setLevel(log_level)
|
||||
|
||||
# Chainlit must be imported early (required by decorators)
|
||||
import chainlit as cl
|
||||
from chainlit.input_widget import TextInput, Switch
|
||||
from chainlit.types import ThreadDict
|
||||
import chainlit.data as cl_data
|
||||
|
||||
# Profiling support (optional)
|
||||
PROFILING_ENABLED = os.getenv("PRAISON_CODE_PROFILE", "").lower() in ("1", "true", "yes")
|
||||
_profile_data = {}
|
||||
|
||||
def _profile_start(name: str):
|
||||
"""Start profiling a section."""
|
||||
if PROFILING_ENABLED:
|
||||
import time
|
||||
_profile_data[name] = {"start": time.perf_counter()}
|
||||
|
||||
def _profile_end(name: str):
|
||||
"""End profiling a section and log."""
|
||||
if PROFILING_ENABLED and name in _profile_data:
|
||||
import time
|
||||
elapsed = time.perf_counter() - _profile_data[name]["start"]
|
||||
_profile_data[name]["elapsed"] = elapsed
|
||||
logger.info(f"[PROFILE] {name}: {elapsed*1000:.2f}ms")
|
||||
|
||||
# Lazy import cache
|
||||
_cached_modules = {}
|
||||
|
||||
def _get_json():
|
||||
if 'json' not in _cached_modules:
|
||||
import json
|
||||
_cached_modules['json'] = json
|
||||
return _cached_modules['json']
|
||||
|
||||
def _get_asyncio():
|
||||
if 'asyncio' not in _cached_modules:
|
||||
import asyncio
|
||||
_cached_modules['asyncio'] = asyncio
|
||||
return _cached_modules['asyncio']
|
||||
|
||||
def _get_datetime():
|
||||
if 'datetime' not in _cached_modules:
|
||||
from datetime import datetime
|
||||
_cached_modules['datetime'] = datetime
|
||||
return _cached_modules['datetime']
|
||||
|
||||
def _get_io():
|
||||
if 'io' not in _cached_modules:
|
||||
import io
|
||||
_cached_modules['io'] = io
|
||||
return _cached_modules['io']
|
||||
|
||||
def _get_base64():
|
||||
if 'base64' not in _cached_modules:
|
||||
import base64
|
||||
_cached_modules['base64'] = base64
|
||||
return _cached_modules['base64']
|
||||
|
||||
def _get_subprocess():
|
||||
if 'subprocess' not in _cached_modules:
|
||||
import subprocess
|
||||
_cached_modules['subprocess'] = subprocess
|
||||
return _cached_modules['subprocess']
|
||||
|
||||
def _get_pil_image():
|
||||
if 'PIL.Image' not in _cached_modules:
|
||||
_profile_start("import_pil")
|
||||
from PIL import Image
|
||||
_cached_modules['PIL.Image'] = Image
|
||||
_profile_end("import_pil")
|
||||
return _cached_modules['PIL.Image']
|
||||
|
||||
def _get_acompletion():
|
||||
if 'acompletion' not in _cached_modules:
|
||||
_profile_start("import_litellm")
|
||||
from litellm import acompletion
|
||||
_cached_modules['acompletion'] = acompletion
|
||||
_profile_end("import_litellm")
|
||||
return _cached_modules['acompletion']
|
||||
|
||||
def _get_tavily_client():
|
||||
if 'TavilyClient' not in _cached_modules:
|
||||
try:
|
||||
_profile_start("import_tavily")
|
||||
from tavily import TavilyClient
|
||||
_cached_modules['TavilyClient'] = TavilyClient
|
||||
_profile_end("import_tavily")
|
||||
except ImportError:
|
||||
_cached_modules['TavilyClient'] = None
|
||||
return _cached_modules['TavilyClient']
|
||||
|
||||
def _get_async_web_crawler():
|
||||
if 'AsyncWebCrawler' not in _cached_modules:
|
||||
try:
|
||||
_profile_start("import_crawl4ai")
|
||||
from crawl4ai import AsyncWebCrawler
|
||||
_cached_modules['AsyncWebCrawler'] = AsyncWebCrawler
|
||||
_profile_end("import_crawl4ai")
|
||||
except ImportError:
|
||||
_cached_modules['AsyncWebCrawler'] = None
|
||||
return _cached_modules['AsyncWebCrawler']
|
||||
|
||||
def _get_context_gatherer():
|
||||
if 'ContextGatherer' not in _cached_modules:
|
||||
try:
|
||||
_profile_start("import_context_gatherer")
|
||||
from context import ContextGatherer
|
||||
_cached_modules['ContextGatherer'] = ContextGatherer
|
||||
_profile_end("import_context_gatherer")
|
||||
except ImportError:
|
||||
_cached_modules['ContextGatherer'] = None
|
||||
return _cached_modules['ContextGatherer']
|
||||
|
||||
def _get_praisonai_agent():
|
||||
"""Lazy load PraisonAI Agent for reuse."""
|
||||
if 'Agent' not in _cached_modules:
|
||||
try:
|
||||
_profile_start("import_praisonai_agent")
|
||||
from praisonaiagents import Agent
|
||||
_cached_modules['Agent'] = Agent
|
||||
_profile_end("import_praisonai_agent")
|
||||
except ImportError:
|
||||
_cached_modules['Agent'] = None
|
||||
return _cached_modules['Agent']
|
||||
|
||||
def _get_interactive_tools():
|
||||
"""Lazy load interactive tools (ACP, LSP, basic) with trust mode."""
|
||||
if 'interactive_tools' not in _cached_modules:
|
||||
try:
|
||||
_profile_start("load_interactive_tools")
|
||||
from praisonai.cli.features.interactive_tools import get_interactive_tools, ToolConfig
|
||||
config = ToolConfig.from_env()
|
||||
config.workspace = os.environ.get("PRAISONAI_CODE_REPO_PATH", os.getcwd())
|
||||
# Do not hardcode approval_mode to "auto" to respect environment configuration
|
||||
tools = get_interactive_tools(config=config)
|
||||
_cached_modules['interactive_tools'] = tools
|
||||
_profile_end("load_interactive_tools")
|
||||
logger.info(f"Loaded {len(tools)} interactive tools (ACP, LSP, basic) with trust mode")
|
||||
except ImportError as e:
|
||||
logger.debug(f"Interactive tools not available: {e}")
|
||||
_cached_modules['interactive_tools'] = []
|
||||
return _cached_modules['interactive_tools']
|
||||
|
||||
# Deferred database initialization
|
||||
_db_manager = None
|
||||
|
||||
def _get_db_manager():
|
||||
"""Lazy initialize database manager."""
|
||||
global _db_manager
|
||||
if _db_manager is None:
|
||||
_profile_start("init_database")
|
||||
# Import from the same directory as this module
|
||||
import sys
|
||||
ui_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if ui_dir not in sys.path:
|
||||
sys.path.insert(0, ui_dir)
|
||||
from db import DatabaseManager
|
||||
_db_manager = DatabaseManager()
|
||||
_db_manager.initialize()
|
||||
cl_data._data_layer = _db_manager
|
||||
_profile_end("init_database")
|
||||
return _db_manager
|
||||
|
||||
# Load environment variables lazily
|
||||
def _ensure_env_loaded():
|
||||
if 'env_loaded' not in _cached_modules:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
_cached_modules['env_loaded'] = True
|
||||
|
||||
# Auth secret setup (required early)
|
||||
import secrets
|
||||
_ensure_env_loaded()
|
||||
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
|
||||
if not CHAINLIT_AUTH_SECRET:
|
||||
CHAINLIT_AUTH_SECRET = secrets.token_hex(32)
|
||||
os.environ["CHAINLIT_AUTH_SECRET"] = CHAINLIT_AUTH_SECRET
|
||||
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
|
||||
|
||||
|
||||
def save_setting(key: str, value: str):
|
||||
"""Save a setting to the database"""
|
||||
asyncio = _get_asyncio()
|
||||
db_manager = _get_db_manager()
|
||||
asyncio.run(db_manager.save_setting(key, value))
|
||||
|
||||
def load_setting(key: str) -> str:
|
||||
"""Load a setting from the database"""
|
||||
asyncio = _get_asyncio()
|
||||
db_manager = _get_db_manager()
|
||||
return asyncio.run(db_manager.load_setting(key))
|
||||
|
||||
# Cached context (expensive to gather on every message)
|
||||
_cached_context = {}
|
||||
|
||||
def _get_cached_context(repo_path: str, force_refresh: bool = False):
|
||||
"""Get cached context or gather new context."""
|
||||
cache_key = repo_path
|
||||
|
||||
if not force_refresh and cache_key in _cached_context:
|
||||
cached = _cached_context[cache_key]
|
||||
# Check if cache is still valid (5 minutes)
|
||||
import time
|
||||
if time.time() - cached.get('timestamp', 0) < 300:
|
||||
return cached['context'], cached['token_count'], cached['context_tree']
|
||||
|
||||
ContextGatherer = _get_context_gatherer()
|
||||
if ContextGatherer is None:
|
||||
return "", 0, ""
|
||||
|
||||
_profile_start("gather_context")
|
||||
gatherer = ContextGatherer(directory=repo_path)
|
||||
context, token_count, context_tree = gatherer.run()
|
||||
_profile_end("gather_context")
|
||||
|
||||
import time
|
||||
_cached_context[cache_key] = {
|
||||
'context': context,
|
||||
'token_count': token_count,
|
||||
'context_tree': context_tree,
|
||||
'timestamp': time.time()
|
||||
}
|
||||
|
||||
return context, token_count, context_tree
|
||||
|
||||
# External agents now handled via shared helper
|
||||
|
||||
# Deferred tool loading
|
||||
_tavily_client = None
|
||||
|
||||
def _get_tavily():
|
||||
global _tavily_client
|
||||
if _tavily_client is None:
|
||||
tavily_api_key = os.getenv("TAVILY_API_KEY")
|
||||
if tavily_api_key:
|
||||
TavilyClient = _get_tavily_client()
|
||||
if TavilyClient:
|
||||
_tavily_client = TavilyClient(api_key=tavily_api_key)
|
||||
return _tavily_client
|
||||
|
||||
async def tavily_web_search(query):
|
||||
"""Search the web using Tavily API."""
|
||||
json = _get_json()
|
||||
tavily_client = _get_tavily()
|
||||
|
||||
if not tavily_client:
|
||||
return json.dumps({
|
||||
"query": query,
|
||||
"error": "Tavily API key is not set. Web search is unavailable."
|
||||
})
|
||||
|
||||
response = tavily_client.search(query)
|
||||
logger.debug(f"Tavily search response: {response}")
|
||||
|
||||
AsyncWebCrawler = _get_async_web_crawler()
|
||||
if AsyncWebCrawler:
|
||||
async with AsyncWebCrawler() as crawler:
|
||||
results = []
|
||||
for result in response.get('results', []):
|
||||
url = result.get('url')
|
||||
if url:
|
||||
try:
|
||||
crawl_result = await crawler.arun(url=url)
|
||||
results.append({
|
||||
"content": result.get('content'),
|
||||
"url": url,
|
||||
"full_content": crawl_result.markdown
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error crawling {url}: {str(e)}")
|
||||
results.append({
|
||||
"content": result.get('content'),
|
||||
"url": url,
|
||||
"full_content": "Error: Unable to crawl this URL"
|
||||
})
|
||||
else:
|
||||
results = [{"content": r.get('content'), "url": r.get('url')} for r in response.get('results', [])]
|
||||
|
||||
return json.dumps({
|
||||
"query": query,
|
||||
"results": results
|
||||
})
|
||||
|
||||
# Authentication configuration - bind-aware auth
|
||||
from ._auth import register_password_auth
|
||||
|
||||
# Determine bind host from CHAINLIT_HOST env var (default: 127.0.0.1)
|
||||
bind_host = os.getenv("CHAINLIT_HOST", "127.0.0.1")
|
||||
register_password_auth(None, bind_host=bind_host)
|
||||
|
||||
def _get_or_create_agent(model_name: str, tools_enabled: bool = True, external_agents_settings: dict = None):
|
||||
"""Get or create a reusable agent for the session."""
|
||||
Agent = _get_praisonai_agent()
|
||||
if Agent is None:
|
||||
return None
|
||||
|
||||
# Default external agents settings
|
||||
if external_agents_settings is None:
|
||||
external_agents_settings = {}
|
||||
|
||||
# Get cached agent from session
|
||||
cached_agent = cl.user_session.get("_cached_agent")
|
||||
cached_model = cl.user_session.get("_cached_agent_model")
|
||||
cached_external = cl.user_session.get("_cached_agent_external", {})
|
||||
|
||||
# Reuse if model and external agents settings match
|
||||
if (cached_agent is not None and cached_model == model_name and
|
||||
cached_external == external_agents_settings):
|
||||
return cached_agent
|
||||
|
||||
# Create new agent with interactive tools
|
||||
_profile_start("create_agent")
|
||||
tools = []
|
||||
if tools_enabled:
|
||||
tools = list(_get_interactive_tools()) # Copy to avoid mutation
|
||||
# Add Tavily if available
|
||||
if os.getenv("TAVILY_API_KEY"):
|
||||
tools.append(tavily_web_search)
|
||||
# Add external agent tools
|
||||
from praisonai.ui._external_agents import external_agent_tools
|
||||
workspace = os.environ.get("PRAISONAI_CODE_REPO_PATH", ".")
|
||||
tools.extend(external_agent_tools(external_agents_settings, workspace=workspace))
|
||||
|
||||
# Build dynamic instructions based on available external agents
|
||||
available_agents = []
|
||||
if external_agents_settings.get("claude_enabled"):
|
||||
available_agents.append("**Claude Code**: Execute complex coding tasks with Claude Code CLI")
|
||||
if external_agents_settings.get("gemini_enabled"):
|
||||
available_agents.append("**Gemini CLI**: Analysis and search capabilities")
|
||||
if external_agents_settings.get("codex_enabled"):
|
||||
available_agents.append("**Codex CLI**: Advanced code refactoring")
|
||||
if external_agents_settings.get("cursor_enabled"):
|
||||
available_agents.append("**Cursor CLI**: IDE-style development tasks")
|
||||
|
||||
external_capabilities = "\n- ".join([""] + available_agents) if available_agents else ""
|
||||
|
||||
agent = Agent(
|
||||
name="PraisonAI Code Assistant",
|
||||
instructions=f"""You are a powerful AI code assistant with access to comprehensive development tools.
|
||||
|
||||
Available capabilities:
|
||||
- **File Operations**: Read, write, create, edit, delete files using ACP tools
|
||||
- **Code Intelligence**: Find symbols, definitions, references using LSP tools
|
||||
- **Command Execution**: Run shell commands safely
|
||||
- **Web Search**: Search the web for documentation and solutions (if Tavily API key is set){external_capabilities}
|
||||
|
||||
When helping with code:
|
||||
1. Use ACP tools for safe, reviewable file modifications
|
||||
2. Use LSP tools to understand code structure before making changes
|
||||
3. Delegate complex tasks to specialized external agents when available
|
||||
4. Always explain what you're doing and why
|
||||
5. Test changes when possible
|
||||
|
||||
Trust mode is enabled - tool executions are auto-approved for efficiency.""",
|
||||
llm=model_name,
|
||||
tools=tools if tools else None,
|
||||
output="minimal",
|
||||
)
|
||||
|
||||
# Cache the agent
|
||||
cl.user_session.set("_cached_agent", agent)
|
||||
cl.user_session.set("_cached_agent_model", model_name)
|
||||
cl.user_session.set("_cached_agent_external", external_agents_settings)
|
||||
_profile_end("create_agent")
|
||||
|
||||
return agent
|
||||
|
||||
@cl.on_chat_start
|
||||
async def start():
|
||||
_profile_start("on_chat_start")
|
||||
_ensure_env_loaded()
|
||||
|
||||
model_name = load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-4o-mini")
|
||||
tools_enabled = (load_setting("tools_enabled") or "true").lower() == "true"
|
||||
|
||||
# Load external agent settings with backward compatibility
|
||||
from praisonai.ui._external_agents import chainlit_switches, EXTERNAL_AGENTS
|
||||
external_settings = {}
|
||||
|
||||
# Check for legacy claude_code_enabled setting
|
||||
legacy_claude = os.getenv("PRAISONAI_CLAUDECODE_ENABLED", "false").lower() == "true"
|
||||
if not legacy_claude:
|
||||
legacy_claude = (load_setting("claude_code_enabled") or "false").lower() == "true"
|
||||
if legacy_claude:
|
||||
external_settings["claude_enabled"] = True
|
||||
|
||||
# Load all external agent settings
|
||||
for toggle_id in EXTERNAL_AGENTS:
|
||||
if toggle_id not in external_settings: # Don't override claude if set via legacy
|
||||
setting_value = load_setting(toggle_id)
|
||||
external_settings[toggle_id] = setting_value and setting_value.lower() == "true"
|
||||
|
||||
cl.user_session.set("model_name", model_name)
|
||||
cl.user_session.set("tools_enabled", tools_enabled)
|
||||
# Save external settings to session
|
||||
for toggle_id, value in external_settings.items():
|
||||
cl.user_session.set(toggle_id, value)
|
||||
|
||||
logger.debug(f"Model: {model_name}, Tools: {tools_enabled}, External agents: {external_settings}")
|
||||
|
||||
# Pre-create agent for faster first response
|
||||
_get_or_create_agent(model_name, tools_enabled, external_settings)
|
||||
|
||||
settings = cl.ChatSettings(
|
||||
[
|
||||
TextInput(
|
||||
id="model_name",
|
||||
label="Enter the Model Name",
|
||||
placeholder="e.g., gpt-4o-mini",
|
||||
initial=model_name
|
||||
),
|
||||
Switch(
|
||||
id="tools_enabled",
|
||||
label="Enable Tools (ACP, LSP, Web Search)",
|
||||
initial=tools_enabled
|
||||
),
|
||||
*chainlit_switches(external_settings)
|
||||
]
|
||||
)
|
||||
cl.user_session.set("settings", settings)
|
||||
await settings.send()
|
||||
|
||||
# Get context info (cached)
|
||||
repo_path = os.environ.get("PRAISONAI_CODE_REPO_PATH", ".")
|
||||
context, token_count, context_tree = _get_cached_context(repo_path)
|
||||
|
||||
# Show loaded tools info
|
||||
tools = _get_interactive_tools()
|
||||
tool_names = [t.__name__ for t in tools[:5]]
|
||||
if len(tools) > 5:
|
||||
tool_names.append(f"... and {len(tools) - 5} more")
|
||||
|
||||
# Build external agents status
|
||||
enabled_agents = [agent for agent, enabled in external_settings.items() if enabled]
|
||||
agents_status = ", ".join(enabled_agents) if enabled_agents else "None"
|
||||
|
||||
await cl.Message(
|
||||
content=f"🚀 **PraisonAI Code Assistant Ready**\n\n"
|
||||
f"**Model:** {model_name}\n"
|
||||
f"**Tools:** {len(tools)} loaded ({', '.join(tool_names)})\n"
|
||||
f"**External Agents:** {agents_status}\n"
|
||||
f"**Trust Mode:** Enabled (auto-approve tool executions)\n"
|
||||
f"**Context:** {token_count} tokens from workspace\n\n"
|
||||
f"**Files in workspace:**\n```\n{context_tree[:500]}{'...' if len(context_tree) > 500 else ''}\n```"
|
||||
).send()
|
||||
|
||||
_profile_end("on_chat_start")
|
||||
|
||||
@cl.on_settings_update
|
||||
async def setup_agent(settings):
|
||||
json = _get_json()
|
||||
logger.debug(settings)
|
||||
cl.user_session.set("settings", settings)
|
||||
|
||||
model_name = settings["model_name"]
|
||||
tools_enabled = settings.get("tools_enabled", True)
|
||||
|
||||
# Extract external agent settings
|
||||
from praisonai.ui._external_agents import EXTERNAL_AGENTS
|
||||
external_settings = {toggle_id: settings.get(toggle_id, False) for toggle_id in EXTERNAL_AGENTS}
|
||||
|
||||
cl.user_session.set("model_name", model_name)
|
||||
cl.user_session.set("tools_enabled", tools_enabled)
|
||||
# Save external settings to session
|
||||
for toggle_id, value in external_settings.items():
|
||||
cl.user_session.set(toggle_id, value)
|
||||
|
||||
# Invalidate cached agent if settings changed
|
||||
cached_model = cl.user_session.get("_cached_agent_model")
|
||||
cached_external = cl.user_session.get("_cached_agent_external", {})
|
||||
if cached_model != model_name or cached_external != external_settings:
|
||||
cl.user_session.set("_cached_agent", None)
|
||||
cl.user_session.set("_cached_agent_model", None)
|
||||
cl.user_session.set("_cached_agent_external", None)
|
||||
|
||||
save_setting("model_name", model_name)
|
||||
save_setting("tools_enabled", str(tools_enabled).lower())
|
||||
# Save external agent settings
|
||||
for toggle_id, enabled in external_settings.items():
|
||||
save_setting(toggle_id, str(enabled).lower())
|
||||
|
||||
# Backward compatibility - save claude_enabled as claude_code_enabled too
|
||||
if "claude_enabled" in external_settings:
|
||||
save_setting("claude_code_enabled", str(external_settings["claude_enabled"]).lower())
|
||||
|
||||
thread_id = cl.user_session.get("thread_id")
|
||||
if thread_id:
|
||||
thread = await cl_data.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
|
||||
metadata["tools_enabled"] = tools_enabled
|
||||
# Save external agent settings to metadata
|
||||
for toggle_id, enabled in external_settings.items():
|
||||
metadata[toggle_id] = enabled
|
||||
await cl_data.update_thread(thread_id, metadata=metadata)
|
||||
cl.user_session.set("metadata", metadata)
|
||||
|
||||
@cl.on_message
|
||||
async def main(message: cl.Message):
|
||||
_profile_start("on_message_total")
|
||||
json = _get_json()
|
||||
datetime = _get_datetime()
|
||||
|
||||
model_name = cl.user_session.get("model_name") or load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-4o-mini")
|
||||
tools_enabled = cl.user_session.get("tools_enabled", True)
|
||||
|
||||
# Get external agent settings from session
|
||||
from praisonai.ui._external_agents import EXTERNAL_AGENTS
|
||||
external_settings = {toggle_id: cl.user_session.get(toggle_id, False) for toggle_id in EXTERNAL_AGENTS}
|
||||
message_history = cl.user_session.get("message_history", [])
|
||||
|
||||
repo_path = os.environ.get("PRAISONAI_CODE_REPO_PATH", ".")
|
||||
context, token_count, context_tree = _get_cached_context(repo_path)
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Handle image uploads
|
||||
image = None
|
||||
if message.elements and isinstance(message.elements[0], cl.Image):
|
||||
Image = _get_pil_image()
|
||||
image_element = message.elements[0]
|
||||
try:
|
||||
image = Image.open(image_element.path)
|
||||
image.load()
|
||||
cl.user_session.set("image", image)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing image: {str(e)}")
|
||||
await cl.Message(content="Error processing the image. Please try again.").send()
|
||||
return
|
||||
|
||||
user_message = f"""Answer the question and use tools if needed:
|
||||
|
||||
{message.content}
|
||||
|
||||
Current Date and Time: {now}
|
||||
|
||||
Context:
|
||||
{context[:8000] if len(context) > 8000 else context}
|
||||
"""
|
||||
|
||||
if image:
|
||||
user_message = f"Image uploaded. {user_message}"
|
||||
|
||||
message_history.append({"role": "user", "content": user_message})
|
||||
msg = cl.Message(content="")
|
||||
|
||||
# Try PraisonAI Agent first (faster, with tool reuse)
|
||||
agent = _get_or_create_agent(model_name, tools_enabled, external_settings) if tools_enabled else None
|
||||
|
||||
if agent is not None:
|
||||
_profile_start("agent_response")
|
||||
await msg.send()
|
||||
|
||||
try:
|
||||
# Use async chat for streaming
|
||||
result = await agent.achat(message.content)
|
||||
|
||||
# Get response text
|
||||
if hasattr(result, 'raw'):
|
||||
response_text = result.raw
|
||||
else:
|
||||
response_text = str(result)
|
||||
|
||||
# Stream in word chunks for better UX
|
||||
words = response_text.split(' ')
|
||||
for i, word in enumerate(words):
|
||||
token = word + (' ' if i < len(words) - 1 else '')
|
||||
await msg.stream_token(token)
|
||||
|
||||
msg.content = response_text
|
||||
await msg.update()
|
||||
|
||||
message_history.append({"role": "assistant", "content": response_text})
|
||||
cl.user_session.set("message_history", message_history)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Agent error: {e}")
|
||||
# Fallback to litellm
|
||||
await _handle_with_litellm(message, user_message, model_name, message_history, msg, image)
|
||||
|
||||
_profile_end("agent_response")
|
||||
else:
|
||||
# Fallback to litellm
|
||||
await _handle_with_litellm(message, user_message, model_name, message_history, msg, image)
|
||||
|
||||
_profile_end("on_message_total")
|
||||
|
||||
async def _handle_with_litellm(message, user_message, model_name, message_history, msg, image):
|
||||
"""Fallback handler using litellm for backward compatibility."""
|
||||
json = _get_json()
|
||||
io = _get_io()
|
||||
base64 = _get_base64()
|
||||
acompletion = _get_acompletion()
|
||||
|
||||
_profile_start("litellm_response")
|
||||
|
||||
# Build tools list
|
||||
tools = []
|
||||
if os.getenv("TAVILY_API_KEY"):
|
||||
tools.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "tavily_web_search",
|
||||
"description": "Search the web using Tavily API and crawl the resulting URLs",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
completion_params = {
|
||||
"model": model_name,
|
||||
"messages": message_history,
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
if image:
|
||||
buffered = io.BytesIO()
|
||||
image.save(buffered, format="PNG")
|
||||
img_str = base64.b64encode(buffered.getvalue()).decode()
|
||||
completion_params["messages"][-1] = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": user_message},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img_str}"}}
|
||||
]
|
||||
}
|
||||
completion_params["model"] = "gpt-4-vision-preview"
|
||||
|
||||
if tools:
|
||||
completion_params["tools"] = tools
|
||||
completion_params["tool_choice"] = "auto"
|
||||
|
||||
response = await acompletion(**completion_params)
|
||||
|
||||
full_response = ""
|
||||
msg_sent = False
|
||||
|
||||
async for part in response:
|
||||
if 'choices' in part and len(part['choices']) > 0:
|
||||
delta = part['choices'][0].get('delta', {})
|
||||
|
||||
if 'content' in delta and delta['content'] is not None:
|
||||
token = delta['content']
|
||||
if not msg_sent:
|
||||
await msg.send()
|
||||
msg_sent = True
|
||||
await msg.stream_token(token)
|
||||
full_response += token
|
||||
|
||||
if not msg_sent:
|
||||
await msg.send()
|
||||
|
||||
message_history.append({"role": "assistant", "content": full_response})
|
||||
cl.user_session.set("message_history", message_history)
|
||||
|
||||
msg.content = full_response
|
||||
await msg.update()
|
||||
|
||||
_profile_end("litellm_response")
|
||||
|
||||
@cl.on_chat_resume
|
||||
async def on_chat_resume(thread: ThreadDict):
|
||||
json = _get_json()
|
||||
io = _get_io()
|
||||
base64 = _get_base64()
|
||||
Image = _get_pil_image()
|
||||
|
||||
logger.info(f"Resuming chat: {thread['id']}")
|
||||
model_name = load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-4o-mini")
|
||||
tools_enabled = (load_setting("tools_enabled") or "true").lower() == "true"
|
||||
|
||||
# Load external agent settings with backward compatibility
|
||||
from praisonai.ui._external_agents import chainlit_switches, EXTERNAL_AGENTS
|
||||
external_settings = {}
|
||||
|
||||
# Check for legacy claude_code_enabled setting
|
||||
legacy_claude = os.getenv("PRAISONAI_CLAUDECODE_ENABLED", "false").lower() == "true"
|
||||
if not legacy_claude:
|
||||
legacy_claude = (load_setting("claude_code_enabled") or "false").lower() == "true"
|
||||
if legacy_claude:
|
||||
external_settings["claude_enabled"] = True
|
||||
|
||||
# Load all external agent settings
|
||||
for toggle_id in EXTERNAL_AGENTS:
|
||||
if toggle_id not in external_settings: # Don't override claude if set via legacy
|
||||
setting_value = load_setting(toggle_id)
|
||||
external_settings[toggle_id] = setting_value and setting_value.lower() == "true"
|
||||
|
||||
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
|
||||
),
|
||||
Switch(
|
||||
id="tools_enabled",
|
||||
label="Enable Tools (ACP, LSP, Web Search)",
|
||||
initial=tools_enabled
|
||||
),
|
||||
*chainlit_switches(external_settings)
|
||||
]
|
||||
)
|
||||
await settings.send()
|
||||
|
||||
cl.user_session.set("thread_id", thread["id"])
|
||||
cl.user_session.set("model_name", model_name)
|
||||
cl.user_session.set("tools_enabled", tools_enabled)
|
||||
# Save external settings to session
|
||||
for toggle_id, value in external_settings.items():
|
||||
cl.user_session.set(toggle_id, value)
|
||||
|
||||
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 = cl.user_session.get("message_history", [])
|
||||
steps = thread["steps"]
|
||||
|
||||
for m in steps:
|
||||
msg_type = m.get("type")
|
||||
if msg_type == "user_message":
|
||||
message_history.append({"role": "user", "content": m.get("output", "")})
|
||||
elif msg_type == "assistant_message":
|
||||
message_history.append({"role": "assistant", "content": m.get("output", "")})
|
||||
elif msg_type == "run":
|
||||
if m.get("isError"):
|
||||
message_history.append({"role": "system", "content": f"Error: {m.get('output', '')}"})
|
||||
else:
|
||||
logger.warning(f"Message without recognized type: {m}")
|
||||
|
||||
cl.user_session.set("message_history", message_history)
|
||||
|
||||
# Pre-create agent for faster first response
|
||||
_get_or_create_agent(model_name, tools_enabled, external_settings)
|
||||
|
||||
image_data = metadata.get("image")
|
||||
if image_data:
|
||||
image = Image.open(io.BytesIO(base64.b64decode(image_data)))
|
||||
cl.user_session.set("image", image)
|
||||
await cl.Message(content="Previous image loaded. You can continue asking questions about it, upload a new image, or just chat.").send()
|
||||
@@ -1,83 +0,0 @@
|
||||
import os
|
||||
import logging
|
||||
from dotenv import load_dotenv
|
||||
import chainlit as cl
|
||||
from chainlit.types import ThreadDict
|
||||
import yaml
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
# Add the parent directory to sys.path to allow imports
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||
|
||||
from praisonai.ui.colab import ui_run_praisonai, load_tools_from_tools_py
|
||||
from praisonai.ui.callbacks import callback, trigger_callback
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Set up logging
|
||||
logger = logging.getLogger(__name__)
|
||||
log_level = os.getenv("LOGLEVEL", "INFO").upper()
|
||||
logger.setLevel(log_level)
|
||||
|
||||
# Load agent configuration
|
||||
agent_file = "agents.yaml"
|
||||
with open(agent_file, 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
# Load tools
|
||||
tools_dict = load_tools_from_tools_py()
|
||||
|
||||
@cl.on_message
|
||||
async def main(message: cl.Message):
|
||||
"""Main message handler for Chainlit"""
|
||||
try:
|
||||
logger.info(f"Processing message: {message.content}")
|
||||
await cl.Message(
|
||||
content=f"🔄 Processing your request about: {message.content}...",
|
||||
author="System"
|
||||
).send()
|
||||
|
||||
await cl.Message(
|
||||
content="Using Running PraisonAI Agents...",
|
||||
author="System"
|
||||
).send()
|
||||
|
||||
# Run PraisonAI with the message content as the topic
|
||||
result = await ui_run_praisonai(config, message.content, tools_dict)
|
||||
|
||||
# Send the final result
|
||||
await cl.Message(
|
||||
content=result,
|
||||
author="System"
|
||||
).send()
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error running PraisonAI agents: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
await cl.Message(
|
||||
content=error_msg,
|
||||
author="System"
|
||||
).send()
|
||||
|
||||
@cl.on_chat_start
|
||||
async def start():
|
||||
"""Handler for chat start"""
|
||||
await cl.Message(
|
||||
content="👋 Welcome! I'm your AI assistant. What would you like to work on?",
|
||||
author="System"
|
||||
).send()
|
||||
|
||||
# Authentication setup (optional) - bind-aware auth
|
||||
if not os.getenv("CHAINLIT_AUTH_SECRET"):
|
||||
import secrets
|
||||
os.environ["CHAINLIT_AUTH_SECRET"] = secrets.token_hex(32)
|
||||
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
|
||||
|
||||
from ._auth import register_password_auth
|
||||
|
||||
# Determine bind host from CHAINLIT_HOST env var (default: 127.0.0.1)
|
||||
bind_host = os.getenv("CHAINLIT_HOST", "127.0.0.1")
|
||||
register_password_auth(None, bind_host=bind_host)
|
||||
@@ -1,294 +0,0 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import asyncio
|
||||
import shutil
|
||||
import logging
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sql_alchemy import SQLAlchemyDataLayer
|
||||
import chainlit.data as cl_data
|
||||
from chainlit.types import ThreadDict
|
||||
from database_config import get_database_url_with_sqlite_override
|
||||
from praisonai.ui.chainlit_compat import create_local_storage_client
|
||||
|
||||
def ensure_directories():
|
||||
"""Ensure required directories exist"""
|
||||
if "CHAINLIT_APP_ROOT" not in os.environ:
|
||||
chainlit_root = os.path.join(os.path.expanduser("~"), ".praison")
|
||||
os.environ["CHAINLIT_APP_ROOT"] = chainlit_root
|
||||
else:
|
||||
chainlit_root = os.environ["CHAINLIT_APP_ROOT"]
|
||||
|
||||
os.makedirs(chainlit_root, exist_ok=True)
|
||||
os.makedirs(os.path.join(chainlit_root, ".files"), exist_ok=True)
|
||||
|
||||
# Copy public folder and chainlit.md if they don't exist
|
||||
public_folder = os.path.join(os.path.dirname(__file__), "public")
|
||||
config_folder = os.path.join(os.path.dirname(__file__), "config")
|
||||
|
||||
# Copy public folder
|
||||
if not os.path.exists(os.path.join(chainlit_root, "public")):
|
||||
if os.path.exists(public_folder):
|
||||
shutil.copytree(public_folder, os.path.join(chainlit_root, "public"), dirs_exist_ok=True)
|
||||
logging.info("Public folder copied successfully!")
|
||||
else:
|
||||
logging.info("Public folder not found in the package.")
|
||||
|
||||
# Copy all files from config folder to root if translations doesn't exist
|
||||
if not os.path.exists(os.path.join(chainlit_root, "translations")):
|
||||
os.makedirs(os.path.join(chainlit_root, "translations"), exist_ok=True)
|
||||
|
||||
if os.path.exists(config_folder):
|
||||
for item in os.listdir(config_folder):
|
||||
src_path = os.path.join(config_folder, item)
|
||||
dst_path = os.path.join(chainlit_root, item)
|
||||
if os.path.isfile(src_path):
|
||||
shutil.copy2(src_path, dst_path)
|
||||
logging.info(f"File {item} copied to root successfully!")
|
||||
elif os.path.isdir(src_path):
|
||||
if os.path.exists(dst_path):
|
||||
shutil.rmtree(dst_path)
|
||||
shutil.copytree(src_path, dst_path)
|
||||
logging.info(f"Directory {item} copied to root successfully!")
|
||||
else:
|
||||
logging.info("Config folder not found in the package.")
|
||||
|
||||
# Create directories at module import time
|
||||
ensure_directories()
|
||||
|
||||
class DatabaseManager(SQLAlchemyDataLayer):
|
||||
def __init__(self):
|
||||
# Check FORCE_SQLITE flag to bypass external database detection
|
||||
self.database_url = get_database_url_with_sqlite_override()
|
||||
|
||||
if self.database_url:
|
||||
self.conninfo = self.database_url
|
||||
else:
|
||||
chainlit_root = os.environ["CHAINLIT_APP_ROOT"] # Now using CHAINLIT_APP_ROOT
|
||||
self.db_path = os.path.join(chainlit_root, "database.sqlite")
|
||||
self.conninfo = f"sqlite+aiosqlite:///{self.db_path}"
|
||||
|
||||
# Create local file storage client for element persistence
|
||||
storage_client = create_local_storage_client()
|
||||
|
||||
# Initialize SQLAlchemyDataLayer with the connection info and storage client
|
||||
super().__init__(conninfo=self.conninfo, storage_provider=storage_client)
|
||||
|
||||
async def create_schema_async(self):
|
||||
"""Create the database schema for PostgreSQL"""
|
||||
if not self.database_url:
|
||||
return
|
||||
engine = create_async_engine(self.database_url, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text('''
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"identifier" TEXT NOT NULL UNIQUE,
|
||||
"meta" TEXT NOT NULL DEFAULT '{}',
|
||||
"createdAt" TEXT
|
||||
);
|
||||
'''))
|
||||
await conn.execute(text('''
|
||||
CREATE TABLE IF NOT EXISTS threads (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"createdAt" TEXT,
|
||||
"name" TEXT,
|
||||
"userId" TEXT,
|
||||
"userIdentifier" TEXT,
|
||||
"tags" TEXT DEFAULT '[]',
|
||||
"meta" TEXT NOT NULL DEFAULT '{}',
|
||||
FOREIGN KEY ("userId") REFERENCES users("id") ON DELETE CASCADE
|
||||
);
|
||||
'''))
|
||||
await conn.execute(text('''
|
||||
CREATE TABLE IF NOT EXISTS steps (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"threadId" TEXT NOT NULL,
|
||||
"parentId" TEXT,
|
||||
"disableFeedback" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
"streaming" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
"waitForAnswer" BOOLEAN DEFAULT FALSE,
|
||||
"isError" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
"meta" TEXT DEFAULT '{}',
|
||||
"tags" TEXT DEFAULT '[]',
|
||||
"input" TEXT,
|
||||
"output" TEXT,
|
||||
"createdAt" TEXT,
|
||||
"startTime" TEXT,
|
||||
"endTime" TEXT,
|
||||
"generation" TEXT,
|
||||
"showInput" TEXT,
|
||||
"language" TEXT,
|
||||
"indent" INT,
|
||||
FOREIGN KEY ("threadId") REFERENCES threads("id") ON DELETE CASCADE
|
||||
);
|
||||
'''))
|
||||
await conn.execute(text('''
|
||||
CREATE TABLE IF NOT EXISTS elements (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"threadId" TEXT,
|
||||
"type" TEXT,
|
||||
"url" TEXT,
|
||||
"chainlitKey" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"display" TEXT,
|
||||
"objectKey" TEXT,
|
||||
"size" TEXT,
|
||||
"page" INT,
|
||||
"language" TEXT,
|
||||
"forId" TEXT,
|
||||
"mime" TEXT,
|
||||
FOREIGN KEY ("threadId") REFERENCES threads("id") ON DELETE CASCADE
|
||||
);
|
||||
'''))
|
||||
await conn.execute(text('''
|
||||
CREATE TABLE IF NOT EXISTS feedbacks (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"forId" TEXT NOT NULL,
|
||||
"value" INT NOT NULL,
|
||||
"threadId" TEXT,
|
||||
"comment" TEXT
|
||||
);
|
||||
'''))
|
||||
await conn.execute(text('''
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"key" TEXT UNIQUE,
|
||||
"value" TEXT
|
||||
);
|
||||
'''))
|
||||
await engine.dispose()
|
||||
|
||||
def create_schema_sqlite(self):
|
||||
"""Create the database schema for SQLite"""
|
||||
chainlit_root = os.environ["CHAINLIT_APP_ROOT"] # Now using CHAINLIT_APP_ROOT
|
||||
self.db_path = os.path.join(chainlit_root, "database.sqlite")
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
identifier TEXT NOT NULL UNIQUE,
|
||||
meta TEXT NOT NULL DEFAULT '{}',
|
||||
createdAt TEXT
|
||||
);
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
createdAt TEXT,
|
||||
name TEXT,
|
||||
userId TEXT,
|
||||
userIdentifier TEXT,
|
||||
tags TEXT DEFAULT '[]',
|
||||
meta TEXT NOT NULL DEFAULT '{}',
|
||||
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS steps (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
threadId TEXT NOT NULL,
|
||||
parentId TEXT,
|
||||
disableFeedback BOOLEAN NOT NULL DEFAULT 0,
|
||||
streaming BOOLEAN NOT NULL DEFAULT 0,
|
||||
waitForAnswer BOOLEAN DEFAULT 0,
|
||||
isError BOOLEAN NOT NULL DEFAULT 0,
|
||||
meta TEXT DEFAULT '{}',
|
||||
tags TEXT DEFAULT '[]',
|
||||
input TEXT,
|
||||
output TEXT,
|
||||
createdAt TEXT,
|
||||
startTime TEXT,
|
||||
endTime TEXT,
|
||||
generation TEXT,
|
||||
showInput TEXT,
|
||||
language TEXT,
|
||||
indent INT,
|
||||
FOREIGN KEY (threadId) REFERENCES threads(id) ON DELETE CASCADE
|
||||
);
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS elements (
|
||||
id TEXT PRIMARY KEY,
|
||||
threadId TEXT,
|
||||
type TEXT,
|
||||
url TEXT,
|
||||
chainlitKey TEXT,
|
||||
name TEXT NOT NULL,
|
||||
display TEXT,
|
||||
objectKey TEXT,
|
||||
size TEXT,
|
||||
page INT,
|
||||
language TEXT,
|
||||
forId TEXT,
|
||||
mime TEXT,
|
||||
FOREIGN KEY (threadId) REFERENCES threads(id) ON DELETE CASCADE
|
||||
);
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS feedbacks (
|
||||
id TEXT PRIMARY KEY,
|
||||
forId TEXT NOT NULL,
|
||||
value INT NOT NULL,
|
||||
threadId TEXT,
|
||||
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 initialize(self):
|
||||
"""Initialize the database with schema based on the configuration"""
|
||||
if self.database_url:
|
||||
asyncio.run(self.create_schema_async())
|
||||
else:
|
||||
self.create_schema_sqlite()
|
||||
|
||||
async def save_setting(self, key: str, value: str):
|
||||
"""Save a setting to the database"""
|
||||
if self.database_url:
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.execute(text("""
|
||||
INSERT INTO settings ("key", "value") VALUES (:key, :value)
|
||||
ON CONFLICT ("key") DO UPDATE SET "value" = EXCLUDED."value"
|
||||
"""), {"key": key, "value": value})
|
||||
else:
|
||||
conn = sqlite3.connect(self.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()
|
||||
|
||||
async def load_setting(self, key: str) -> str:
|
||||
"""Load a setting from the database"""
|
||||
if self.database_url:
|
||||
async with self.engine.connect() as conn:
|
||||
result = await conn.execute(text('SELECT "value" FROM settings WHERE "key" = :key'), {"key": key})
|
||||
row = result.fetchone()
|
||||
return row[0] if row else None
|
||||
else:
|
||||
conn = sqlite3.connect(self.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
|
||||
@@ -1,489 +0,0 @@
|
||||
import os
|
||||
import asyncio
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
import chainlit as cl
|
||||
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
|
||||
import logging
|
||||
import importlib.util
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
|
||||
# Set up logging
|
||||
logger = logging.getLogger(__name__)
|
||||
log_level = os.getenv("LOGLEVEL", "INFO").upper()
|
||||
logger.handlers = []
|
||||
|
||||
# Set up logging to console
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(log_level)
|
||||
console_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
console_handler.setFormatter(console_formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# Set the logging level for the logger
|
||||
logger.setLevel(log_level)
|
||||
|
||||
# Set up CHAINLIT_AUTH_SECRET
|
||||
import secrets
|
||||
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
|
||||
if not CHAINLIT_AUTH_SECRET:
|
||||
CHAINLIT_AUTH_SECRET = secrets.token_hex(32)
|
||||
os.environ["CHAINLIT_AUTH_SECRET"] = CHAINLIT_AUTH_SECRET
|
||||
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
|
||||
|
||||
|
||||
# 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()
|
||||
|
||||
# Try to import tools from the root directory
|
||||
tools_path = os.path.join(os.getcwd(), 'tools.py')
|
||||
logger.info(f"Tools path: {tools_path}")
|
||||
|
||||
def import_tools_from_file(file_path):
|
||||
spec = importlib.util.spec_from_file_location("custom_tools", file_path)
|
||||
custom_tools_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(custom_tools_module)
|
||||
logger.debug(f"Imported tools from {file_path}")
|
||||
logger.debug(f"Tools: {custom_tools_module}")
|
||||
return custom_tools_module
|
||||
|
||||
try:
|
||||
# Security: Require explicit opt-in for local tools loading
|
||||
if os.environ.get("PRAISONAI_ALLOW_LOCAL_TOOLS", "").lower() != "true":
|
||||
logger.info("Local tools loading disabled. Set PRAISONAI_ALLOW_LOCAL_TOOLS=true to enable.")
|
||||
custom_tools_module = None
|
||||
elif os.path.exists(tools_path):
|
||||
# tools.py exists in the root directory, import from file
|
||||
custom_tools_module = import_tools_from_file(tools_path)
|
||||
logger.info("Successfully imported custom tools from root tools.py")
|
||||
else:
|
||||
logger.info("No custom tools.py file found in the root directory")
|
||||
custom_tools_module = None
|
||||
|
||||
if custom_tools_module:
|
||||
# Update the tools list with custom tools
|
||||
if hasattr(custom_tools_module, 'tools') and isinstance(custom_tools_module.tools, list):
|
||||
# Only add tools that have proper function definitions
|
||||
for tool in custom_tools_module.tools:
|
||||
if isinstance(tool, tuple) and len(tool) == 2:
|
||||
tool_def, handler = tool
|
||||
if isinstance(tool_def, dict) and "type" in tool_def and tool_def["type"] == "function":
|
||||
# Convert class/function to proper tool definition
|
||||
if "function" in tool_def:
|
||||
func = tool_def["function"]
|
||||
if hasattr(func, "__name__"):
|
||||
tool_def = {
|
||||
"name": func.__name__,
|
||||
"description": func.__doc__ or f"Execute {func.__name__}",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
tools.append((tool_def, handler))
|
||||
else:
|
||||
# Tool definition is already properly formatted
|
||||
tools.append(tool)
|
||||
else:
|
||||
# Process individual functions/classes
|
||||
for name, obj in custom_tools_module.__dict__.items():
|
||||
if callable(obj) and not name.startswith("__"):
|
||||
tool_def = {
|
||||
"name": name,
|
||||
"description": obj.__doc__ or f"Execute {name}",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
tools.append((tool_def, obj))
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error importing custom tools: {str(e)}. Continuing without custom tools.")
|
||||
|
||||
@cl.on_chat_start
|
||||
async def start():
|
||||
initialize_db()
|
||||
model_name = os.getenv("OPENAI_MODEL_NAME") or os.getenv("MODEL_NAME", "gpt-5-nano-realtime-preview-2024-12-17")
|
||||
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(
|
||||
# id="model_name",
|
||||
# label="Enter the Model Name",
|
||||
# placeholder="e.g., gpt-5-nano-realtime-preview-2024-12-17",
|
||||
# initial=model_name
|
||||
# )
|
||||
# ]
|
||||
# )
|
||||
# cl.user_session.set("settings", settings)
|
||||
# await settings.send()
|
||||
await cl.Message(
|
||||
content="Welcome to the PraisonAI realtime. 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()
|
||||
|
||||
# Register event handlers
|
||||
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)
|
||||
|
||||
# Filter out invalid tools and add valid ones
|
||||
valid_tools = []
|
||||
for tool_def, tool_handler in tools:
|
||||
try:
|
||||
if isinstance(tool_def, dict) and "name" in tool_def:
|
||||
valid_tools.append((tool_def, tool_handler))
|
||||
else:
|
||||
logger.warning(f"Skipping invalid tool definition: {tool_def}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error processing tool: {e}")
|
||||
|
||||
if valid_tools:
|
||||
coros = [openai_realtime.add_tool(tool_def, tool_handler) for tool_def, tool_handler in valid_tools]
|
||||
await asyncio.gather(*coros)
|
||||
else:
|
||||
logger.warning("No valid tools found to add")
|
||||
|
||||
@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)
|
||||
|
||||
# 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")
|
||||
if not openai_realtime:
|
||||
await setup_openai_realtime()
|
||||
openai_realtime = cl.user_session.get("openai_realtime")
|
||||
|
||||
if not openai_realtime.is_connected():
|
||||
model_name = cl.user_session.get("model_name") or os.getenv("OPENAI_MODEL_NAME") or os.getenv("MODEL_NAME", "gpt-5-nano-realtime-preview-2024-12-17")
|
||||
await openai_realtime.connect(model_name)
|
||||
|
||||
logger.info("Connected to OpenAI realtime")
|
||||
return True
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to connect to OpenAI realtime: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
await cl.ErrorMessage(content=error_msg).send()
|
||||
return False
|
||||
|
||||
@cl.on_audio_chunk
|
||||
async def on_audio_chunk(chunk: cl.InputAudioChunk):
|
||||
openai_realtime: RealtimeClient = cl.user_session.get("openai_realtime")
|
||||
|
||||
if not openai_realtime:
|
||||
logger.debug("No realtime client available")
|
||||
return
|
||||
|
||||
if openai_realtime.is_connected():
|
||||
try:
|
||||
success = await openai_realtime.append_input_audio(chunk.data)
|
||||
if not success:
|
||||
logger.debug("Failed to append audio data - connection may be lost")
|
||||
except Exception as e:
|
||||
logger.debug(f"Error processing audio chunk: {e}")
|
||||
# Optionally try to reconnect here if needed
|
||||
else:
|
||||
logger.debug("RealtimeClient is not connected - audio chunk ignored")
|
||||
|
||||
@cl.on_audio_end
|
||||
@cl.on_chat_end
|
||||
@cl.on_stop
|
||||
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()
|
||||
|
||||
# Authentication configuration - bind-aware auth
|
||||
from ._auth import register_password_auth
|
||||
|
||||
# Determine bind host from CHAINLIT_HOST env var (default: 127.0.0.1)
|
||||
bind_host = os.getenv("CHAINLIT_HOST", "127.0.0.1")
|
||||
register_password_auth(None, bind_host=bind_host)
|
||||
|
||||
@cl.on_chat_resume
|
||||
async def on_chat_resume(thread: ThreadDict):
|
||||
logger.info(f"Resuming chat: {thread['id']}")
|
||||
model_name = os.getenv("OPENAI_MODEL_NAME") or os.getenv("MODEL_NAME") or "gpt-5-nano-realtime-preview-2024-12-17"
|
||||
logger.debug(f"Model name: {model_name}")
|
||||
settings = cl.ChatSettings(
|
||||
[
|
||||
TextInput(
|
||||
id="model_name",
|
||||
label="Enter the Model Name",
|
||||
placeholder="e.g., gpt-5-nano-realtime-preview-2024-12-17",
|
||||
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()
|
||||
|
||||
|
||||
@@ -1,756 +0,0 @@
|
||||
# Derived from https://github.com/openai/openai-realtime-console. Will integrate with Chainlit when more mature.
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import inspect
|
||||
import numpy as np
|
||||
import json
|
||||
import websockets
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
import base64
|
||||
|
||||
from chainlit.logger import logger
|
||||
from chainlit.config import config
|
||||
|
||||
|
||||
def float_to_16bit_pcm(float32_array):
|
||||
"""
|
||||
Converts a numpy array of float32 amplitude data to a numpy array in int16 format.
|
||||
:param float32_array: numpy array of float32
|
||||
:return: numpy array of int16
|
||||
"""
|
||||
int16_array = np.clip(float32_array, -1, 1) * 32767
|
||||
return int16_array.astype(np.int16)
|
||||
|
||||
def base64_to_array_buffer(base64_string):
|
||||
"""
|
||||
Converts a base64 string to a numpy array buffer.
|
||||
:param base64_string: base64 encoded string
|
||||
:return: numpy array of uint8
|
||||
"""
|
||||
binary_data = base64.b64decode(base64_string)
|
||||
return np.frombuffer(binary_data, dtype=np.uint8)
|
||||
|
||||
def array_buffer_to_base64(array_buffer):
|
||||
"""
|
||||
Converts a numpy array buffer to a base64 string.
|
||||
:param array_buffer: numpy array
|
||||
:return: base64 encoded string
|
||||
"""
|
||||
if array_buffer.dtype == np.float32:
|
||||
array_buffer = float_to_16bit_pcm(array_buffer)
|
||||
elif array_buffer.dtype == np.int16:
|
||||
array_buffer = array_buffer.tobytes()
|
||||
else:
|
||||
array_buffer = array_buffer.tobytes()
|
||||
|
||||
return base64.b64encode(array_buffer).decode('utf-8')
|
||||
|
||||
def merge_int16_arrays(left, right):
|
||||
"""
|
||||
Merge two numpy arrays of int16.
|
||||
:param left: numpy array of int16
|
||||
:param right: numpy array of int16
|
||||
:return: merged numpy array of int16
|
||||
"""
|
||||
if isinstance(left, np.ndarray) and left.dtype == np.int16 and isinstance(right, np.ndarray) and right.dtype == np.int16:
|
||||
return np.concatenate((left, right))
|
||||
else:
|
||||
raise ValueError("Both items must be numpy arrays of int16")
|
||||
|
||||
|
||||
class RealtimeEventHandler:
|
||||
def __init__(self):
|
||||
self.event_handlers = defaultdict(list)
|
||||
|
||||
def on(self, event_name, handler):
|
||||
self.event_handlers[event_name].append(handler)
|
||||
|
||||
def clear_event_handlers(self):
|
||||
self.event_handlers = defaultdict(list)
|
||||
|
||||
def dispatch(self, event_name, event):
|
||||
for handler in self.event_handlers[event_name]:
|
||||
if inspect.iscoroutinefunction(handler):
|
||||
asyncio.create_task(handler(event))
|
||||
else:
|
||||
handler(event)
|
||||
|
||||
async def wait_for_next(self, event_name):
|
||||
future = asyncio.Future()
|
||||
|
||||
def handler(event):
|
||||
if not future.done():
|
||||
future.set_result(event)
|
||||
|
||||
self.on(event_name, handler)
|
||||
return await future
|
||||
|
||||
|
||||
class RealtimeAPI(RealtimeEventHandler):
|
||||
def __init__(self, url=None, api_key=None):
|
||||
super().__init__()
|
||||
self.default_url = 'wss://api.openai.com/v1/realtime'
|
||||
|
||||
# Support custom base URL from environment variable
|
||||
base_url = os.getenv("OPENAI_BASE_URL")
|
||||
if base_url:
|
||||
# Convert HTTP/HTTPS base URL to WebSocket URL for realtime API
|
||||
if base_url.startswith('https://'):
|
||||
ws_url = base_url.replace('https://', 'wss://').rstrip('/') + '/realtime'
|
||||
elif base_url.startswith('http://'):
|
||||
ws_url = base_url.replace('http://', 'ws://').rstrip('/') + '/realtime'
|
||||
else:
|
||||
# Assume it's already a WebSocket URL
|
||||
ws_url = base_url.rstrip('/') + '/realtime' if not base_url.endswith('/realtime') else base_url
|
||||
self.url = url or ws_url
|
||||
else:
|
||||
self.url = url or self.default_url
|
||||
|
||||
self.api_key = api_key or os.getenv("OPENAI_API_KEY")
|
||||
self.ws = None
|
||||
|
||||
def is_connected(self):
|
||||
if self.ws is None:
|
||||
return False
|
||||
# Some websockets versions don't have a closed attribute
|
||||
try:
|
||||
return not self.ws.closed
|
||||
except AttributeError:
|
||||
# Fallback: check if websocket is still alive by checking state
|
||||
try:
|
||||
return hasattr(self.ws, 'state') and self.ws.state.name == 'OPEN'
|
||||
except:
|
||||
# Last fallback: assume connected if ws exists
|
||||
return True
|
||||
|
||||
def log(self, *args):
|
||||
logger.debug(f"[Websocket/{datetime.utcnow().isoformat()}]", *args)
|
||||
|
||||
async def connect(self, model='gpt-5-nano-realtime-preview-2024-12-17'):
|
||||
if self.is_connected():
|
||||
raise Exception("Already connected")
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'OpenAI-Beta': 'realtime=v1'
|
||||
}
|
||||
|
||||
# Try different header parameter names for compatibility
|
||||
try:
|
||||
self.ws = await websockets.connect(f"{self.url}?model={model}", additional_headers=headers)
|
||||
except TypeError:
|
||||
# Fallback to older websockets versions
|
||||
try:
|
||||
self.ws = await websockets.connect(f"{self.url}?model={model}", extra_headers=headers)
|
||||
except TypeError:
|
||||
# Last fallback - some versions might not support headers parameter
|
||||
raise Exception("Websockets library version incompatible. Please update websockets to version 11.0 or higher.")
|
||||
|
||||
self.log(f"Connected to {self.url}")
|
||||
asyncio.create_task(self._receive_messages())
|
||||
|
||||
async def _receive_messages(self):
|
||||
try:
|
||||
async for message in self.ws:
|
||||
event = json.loads(message)
|
||||
if event['type'] == "error":
|
||||
logger.error(f"OpenAI Realtime API Error: {event}")
|
||||
self.log("received:", event)
|
||||
self.dispatch(f"server.{event['type']}", event)
|
||||
self.dispatch("server.*", event)
|
||||
except ConnectionClosed as e:
|
||||
logger.info(f"WebSocket connection closed normally: {e}")
|
||||
# Mark connection as closed
|
||||
self.ws = None
|
||||
# Dispatch disconnection event
|
||||
self.dispatch("disconnected", {"reason": str(e)})
|
||||
except Exception as e:
|
||||
logger.warning(f"WebSocket receive loop ended: {e}")
|
||||
# Mark connection as closed
|
||||
self.ws = None
|
||||
# Dispatch disconnection event
|
||||
self.dispatch("disconnected", {"reason": str(e)})
|
||||
|
||||
async def send(self, event_name, data=None):
|
||||
if not self.is_connected():
|
||||
raise Exception("RealtimeAPI is not connected")
|
||||
data = data or {}
|
||||
if not isinstance(data, dict):
|
||||
raise Exception("data must be a dictionary")
|
||||
event = {
|
||||
"event_id": self._generate_id("evt_"),
|
||||
"type": event_name,
|
||||
**data
|
||||
}
|
||||
self.dispatch(f"client.{event_name}", event)
|
||||
self.dispatch("client.*", event)
|
||||
self.log("sent:", event)
|
||||
|
||||
try:
|
||||
await self.ws.send(json.dumps(event))
|
||||
except ConnectionClosed as e:
|
||||
logger.info(f"WebSocket connection closed during send: {e}")
|
||||
# Mark connection as closed if send fails
|
||||
self.ws = None
|
||||
raise Exception(f"WebSocket connection lost: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send WebSocket message: {e}")
|
||||
# Mark connection as closed if send fails
|
||||
self.ws = None
|
||||
raise Exception(f"WebSocket connection lost: {e}")
|
||||
|
||||
def _generate_id(self, prefix):
|
||||
return f"{prefix}{int(datetime.utcnow().timestamp() * 1000)}"
|
||||
|
||||
async def disconnect(self):
|
||||
if self.ws:
|
||||
try:
|
||||
await self.ws.close()
|
||||
logger.info(f"Disconnected from {self.url}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error during WebSocket close: {e}")
|
||||
finally:
|
||||
self.ws = None
|
||||
self.log(f"WebSocket connection cleaned up")
|
||||
|
||||
class RealtimeConversation:
|
||||
default_frequency = config.features.audio.sample_rate
|
||||
|
||||
EventProcessors = {
|
||||
'conversation.item.created': lambda self, event: self._process_item_created(event),
|
||||
'conversation.item.truncated': lambda self, event: self._process_item_truncated(event),
|
||||
'conversation.item.deleted': lambda self, event: self._process_item_deleted(event),
|
||||
'conversation.item.input_audio_transcription.completed': lambda self, event: self._process_input_audio_transcription_completed(event),
|
||||
'input_audio_buffer.speech_started': lambda self, event: self._process_speech_started(event),
|
||||
'input_audio_buffer.speech_stopped': lambda self, event, input_audio_buffer: self._process_speech_stopped(event, input_audio_buffer),
|
||||
'response.created': lambda self, event: self._process_response_created(event),
|
||||
'response.output_item.added': lambda self, event: self._process_output_item_added(event),
|
||||
'response.output_item.done': lambda self, event: self._process_output_item_done(event),
|
||||
'response.content_part.added': lambda self, event: self._process_content_part_added(event),
|
||||
'response.audio_transcript.delta': lambda self, event: self._process_audio_transcript_delta(event),
|
||||
'response.audio.delta': lambda self, event: self._process_audio_delta(event),
|
||||
'response.text.delta': lambda self, event: self._process_text_delta(event),
|
||||
'response.function_call_arguments.delta': lambda self, event: self._process_function_call_arguments_delta(event),
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.clear()
|
||||
|
||||
def clear(self):
|
||||
self.item_lookup = {}
|
||||
self.items = []
|
||||
self.response_lookup = {}
|
||||
self.responses = []
|
||||
self.queued_speech_items = {}
|
||||
self.queued_transcript_items = {}
|
||||
self.queued_input_audio = None
|
||||
|
||||
def queue_input_audio(self, input_audio):
|
||||
self.queued_input_audio = input_audio
|
||||
|
||||
def process_event(self, event, *args):
|
||||
event_processor = self.EventProcessors.get(event['type'])
|
||||
if not event_processor:
|
||||
raise Exception(f"Missing conversation event processor for {event['type']}")
|
||||
return event_processor(self, event, *args)
|
||||
|
||||
def get_item(self, id):
|
||||
return self.item_lookup.get(id)
|
||||
|
||||
def get_items(self):
|
||||
return self.items[:]
|
||||
|
||||
def _process_item_created(self, event):
|
||||
item = event['item']
|
||||
new_item = item.copy()
|
||||
if new_item['id'] not in self.item_lookup:
|
||||
self.item_lookup[new_item['id']] = new_item
|
||||
self.items.append(new_item)
|
||||
new_item['formatted'] = {
|
||||
'audio': [],
|
||||
'text': '',
|
||||
'transcript': ''
|
||||
}
|
||||
if new_item['id'] in self.queued_speech_items:
|
||||
new_item['formatted']['audio'] = self.queued_speech_items[new_item['id']]['audio']
|
||||
del self.queued_speech_items[new_item['id']]
|
||||
if 'content' in new_item:
|
||||
text_content = [c for c in new_item['content'] if c['type'] in ['text', 'input_text']]
|
||||
for content in text_content:
|
||||
new_item['formatted']['text'] += content['text']
|
||||
if new_item['id'] in self.queued_transcript_items:
|
||||
new_item['formatted']['transcript'] = self.queued_transcript_items[new_item['id']]['transcript']
|
||||
del self.queued_transcript_items[new_item['id']]
|
||||
if new_item['type'] == 'message':
|
||||
if new_item['role'] == 'user':
|
||||
new_item['status'] = 'completed'
|
||||
if self.queued_input_audio:
|
||||
new_item['formatted']['audio'] = self.queued_input_audio
|
||||
self.queued_input_audio = None
|
||||
else:
|
||||
new_item['status'] = 'in_progress'
|
||||
elif new_item['type'] == 'function_call':
|
||||
new_item['formatted']['tool'] = {
|
||||
'type': 'function',
|
||||
'name': new_item['name'],
|
||||
'call_id': new_item['call_id'],
|
||||
'arguments': ''
|
||||
}
|
||||
new_item['status'] = 'in_progress'
|
||||
elif new_item['type'] == 'function_call_output':
|
||||
new_item['status'] = 'completed'
|
||||
new_item['formatted']['output'] = new_item['output']
|
||||
return new_item, None
|
||||
|
||||
def _process_item_truncated(self, event):
|
||||
item_id = event['item_id']
|
||||
audio_end_ms = event['audio_end_ms']
|
||||
item = self.item_lookup.get(item_id)
|
||||
if not item:
|
||||
raise Exception(f'item.truncated: Item "{item_id}" not found')
|
||||
end_index = (audio_end_ms * self.default_frequency) // 1000
|
||||
item['formatted']['transcript'] = ''
|
||||
item['formatted']['audio'] = item['formatted']['audio'][:end_index]
|
||||
return item, None
|
||||
|
||||
def _process_item_deleted(self, event):
|
||||
item_id = event['item_id']
|
||||
item = self.item_lookup.get(item_id)
|
||||
if not item:
|
||||
raise Exception(f'item.deleted: Item "{item_id}" not found')
|
||||
del self.item_lookup[item['id']]
|
||||
self.items.remove(item)
|
||||
return item, None
|
||||
|
||||
def _process_input_audio_transcription_completed(self, event):
|
||||
item_id = event['item_id']
|
||||
content_index = event['content_index']
|
||||
transcript = event['transcript']
|
||||
formatted_transcript = transcript or ' '
|
||||
item = self.item_lookup.get(item_id)
|
||||
if not item:
|
||||
self.queued_transcript_items[item_id] = {'transcript': formatted_transcript}
|
||||
return None, None
|
||||
item['content'][content_index]['transcript'] = transcript
|
||||
item['formatted']['transcript'] = formatted_transcript
|
||||
return item, {'transcript': transcript}
|
||||
|
||||
def _process_speech_started(self, event):
|
||||
item_id = event['item_id']
|
||||
audio_start_ms = event['audio_start_ms']
|
||||
self.queued_speech_items[item_id] = {'audio_start_ms': audio_start_ms}
|
||||
return None, None
|
||||
|
||||
def _process_speech_stopped(self, event, input_audio_buffer):
|
||||
item_id = event['item_id']
|
||||
audio_end_ms = event['audio_end_ms']
|
||||
speech = self.queued_speech_items[item_id]
|
||||
speech['audio_end_ms'] = audio_end_ms
|
||||
if input_audio_buffer:
|
||||
start_index = (speech['audio_start_ms'] * self.default_frequency) // 1000
|
||||
end_index = (speech['audio_end_ms'] * self.default_frequency) // 1000
|
||||
speech['audio'] = input_audio_buffer[start_index:end_index]
|
||||
return None, None
|
||||
|
||||
def _process_response_created(self, event):
|
||||
response = event['response']
|
||||
if response['id'] not in self.response_lookup:
|
||||
self.response_lookup[response['id']] = response
|
||||
self.responses.append(response)
|
||||
return None, None
|
||||
|
||||
def _process_output_item_added(self, event):
|
||||
response_id = event['response_id']
|
||||
item = event['item']
|
||||
response = self.response_lookup.get(response_id)
|
||||
if not response:
|
||||
raise Exception(f'response.output_item.added: Response "{response_id}" not found')
|
||||
response['output'].append(item['id'])
|
||||
return None, None
|
||||
|
||||
def _process_output_item_done(self, event):
|
||||
item = event['item']
|
||||
if not item:
|
||||
raise Exception('response.output_item.done: Missing "item"')
|
||||
found_item = self.item_lookup.get(item['id'])
|
||||
if not found_item:
|
||||
raise Exception(f'response.output_item.done: Item "{item["id"]}" not found')
|
||||
found_item['status'] = item['status']
|
||||
return found_item, None
|
||||
|
||||
def _process_content_part_added(self, event):
|
||||
item_id = event['item_id']
|
||||
part = event['part']
|
||||
item = self.item_lookup.get(item_id)
|
||||
if not item:
|
||||
raise Exception(f'response.content_part.added: Item "{item_id}" not found')
|
||||
item['content'].append(part)
|
||||
return item, None
|
||||
|
||||
def _process_audio_transcript_delta(self, event):
|
||||
item_id = event['item_id']
|
||||
content_index = event['content_index']
|
||||
delta = event['delta']
|
||||
item = self.item_lookup.get(item_id)
|
||||
if not item:
|
||||
raise Exception(f'response.audio_transcript.delta: Item "{item_id}" not found')
|
||||
item['content'][content_index]['transcript'] += delta
|
||||
item['formatted']['transcript'] += delta
|
||||
return item, {'transcript': delta}
|
||||
|
||||
def _process_audio_delta(self, event):
|
||||
item_id = event['item_id']
|
||||
content_index = event['content_index']
|
||||
delta = event['delta']
|
||||
item = self.item_lookup.get(item_id)
|
||||
if not item:
|
||||
logger.debug(f'response.audio.delta: Item "{item_id}" not found')
|
||||
return None, None
|
||||
array_buffer = base64_to_array_buffer(delta)
|
||||
append_values = array_buffer.tobytes()
|
||||
item['formatted']['audio'].append(append_values)
|
||||
return item, {'audio': append_values}
|
||||
|
||||
def _process_text_delta(self, event):
|
||||
item_id = event['item_id']
|
||||
content_index = event['content_index']
|
||||
delta = event['delta']
|
||||
item = self.item_lookup.get(item_id)
|
||||
if not item:
|
||||
raise Exception(f'response.text.delta: Item "{item_id}" not found')
|
||||
item['content'][content_index]['text'] += delta
|
||||
item['formatted']['text'] += delta
|
||||
return item, {'text': delta}
|
||||
|
||||
def _process_function_call_arguments_delta(self, event):
|
||||
item_id = event['item_id']
|
||||
delta = event['delta']
|
||||
item = self.item_lookup.get(item_id)
|
||||
if not item:
|
||||
raise Exception(f'response.function_call_arguments.delta: Item "{item_id}" not found')
|
||||
item['arguments'] += delta
|
||||
item['formatted']['tool']['arguments'] += delta
|
||||
return item, {'arguments': delta}
|
||||
|
||||
|
||||
class RealtimeClient(RealtimeEventHandler):
|
||||
def __init__(self, url=None, api_key=None):
|
||||
super().__init__()
|
||||
self.default_session_config = {
|
||||
"modalities": ["text", "audio"],
|
||||
"instructions": "System settings:\nTool use: enabled.\n\nInstructions:\n- You are an artificial intelligence agent responsible for helping test realtime voice capabilities\n- Please make sure to respond with a helpful voice via audio\n- Be kind, helpful, and curteous\n- It is okay to ask the user questions\n- Use tools and functions you have available liberally, it is part of the training apparatus\n- Be open to exploration and conversation\n- Remember: this is just for fun and testing!\n\nPersonality:\n- Be upbeat and genuine\n- Try speaking quickly as if excited\n",
|
||||
"voice": "shimmer",
|
||||
"input_audio_format": "pcm16",
|
||||
"output_audio_format": "pcm16",
|
||||
"input_audio_transcription": { "model": 'whisper-1' },
|
||||
"turn_detection": { "type": 'server_vad' },
|
||||
"tools": [],
|
||||
"tool_choice": "auto",
|
||||
"temperature": 0.8,
|
||||
}
|
||||
self.session_config = {}
|
||||
self.transcription_models = [{"model": "whisper-1"}]
|
||||
self.default_server_vad_config = {
|
||||
"type": "server_vad",
|
||||
"threshold": 0.5,
|
||||
"prefix_padding_ms": 300,
|
||||
"silence_duration_ms": 200,
|
||||
}
|
||||
self.realtime = RealtimeAPI(url, api_key)
|
||||
self.conversation = RealtimeConversation()
|
||||
self._reset_config()
|
||||
self._add_api_event_handlers()
|
||||
|
||||
def _reset_config(self):
|
||||
self.session_created = False
|
||||
self.tools = {}
|
||||
self.session_config = self.default_session_config.copy()
|
||||
self.input_audio_buffer = bytearray()
|
||||
return True
|
||||
|
||||
def _add_api_event_handlers(self):
|
||||
self.realtime.on("client.*", self._log_event)
|
||||
self.realtime.on("server.*", self._log_event)
|
||||
self.realtime.on("server.session.created", self._on_session_created)
|
||||
self.realtime.on("server.response.created", self._process_event)
|
||||
self.realtime.on("server.response.output_item.added", self._process_event)
|
||||
self.realtime.on("server.response.content_part.added", self._process_event)
|
||||
self.realtime.on("server.input_audio_buffer.speech_started", self._on_speech_started)
|
||||
self.realtime.on("server.input_audio_buffer.speech_stopped", self._on_speech_stopped)
|
||||
self.realtime.on("server.conversation.item.created", self._on_item_created)
|
||||
self.realtime.on("server.conversation.item.truncated", self._process_event)
|
||||
self.realtime.on("server.conversation.item.deleted", self._process_event)
|
||||
self.realtime.on("server.conversation.item.input_audio_transcription.completed", self._process_event)
|
||||
self.realtime.on("server.response.audio_transcript.delta", self._process_event)
|
||||
self.realtime.on("server.response.audio.delta", self._process_event)
|
||||
self.realtime.on("server.response.text.delta", self._process_event)
|
||||
self.realtime.on("server.response.function_call_arguments.delta", self._process_event)
|
||||
self.realtime.on("server.response.output_item.done", self._on_output_item_done)
|
||||
|
||||
def _log_event(self, event):
|
||||
realtime_event = {
|
||||
"time": datetime.utcnow().isoformat(),
|
||||
"source": "client" if event["type"].startswith("client.") else "server",
|
||||
"event": event,
|
||||
}
|
||||
self.dispatch("realtime.event", realtime_event)
|
||||
|
||||
def _on_session_created(self, event):
|
||||
try:
|
||||
session_id = event.get('session', {}).get('id', 'unknown')
|
||||
model = event.get('session', {}).get('model', 'unknown')
|
||||
logger.info(f"OpenAI Realtime session created - ID: {session_id}, Model: {model}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error processing session created event: {e}")
|
||||
logger.debug(f"Session event details: {event}")
|
||||
self.session_created = True
|
||||
|
||||
def _process_event(self, event, *args):
|
||||
item, delta = self.conversation.process_event(event, *args)
|
||||
if item:
|
||||
self.dispatch("conversation.updated", {"item": item, "delta": delta})
|
||||
return item, delta
|
||||
|
||||
def _on_speech_started(self, event):
|
||||
self._process_event(event)
|
||||
self.dispatch("conversation.interrupted", event)
|
||||
|
||||
def _on_speech_stopped(self, event):
|
||||
self._process_event(event, self.input_audio_buffer)
|
||||
|
||||
def _on_item_created(self, event):
|
||||
item, delta = self._process_event(event)
|
||||
self.dispatch("conversation.item.appended", {"item": item})
|
||||
if item and item["status"] == "completed":
|
||||
self.dispatch("conversation.item.completed", {"item": item})
|
||||
|
||||
async def _on_output_item_done(self, event):
|
||||
item, delta = self._process_event(event)
|
||||
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:
|
||||
json_arguments = json.loads(tool["arguments"])
|
||||
tool_config = self.tools.get(tool["name"])
|
||||
if not tool_config:
|
||||
raise Exception(f'Tool "{tool["name"]}" has not been added')
|
||||
result = await tool_config["handler"](**json_arguments)
|
||||
await self.realtime.send("conversation.item.create", {
|
||||
"item": {
|
||||
"type": "function_call_output",
|
||||
"call_id": tool["call_id"],
|
||||
"output": json.dumps(result),
|
||||
}
|
||||
})
|
||||
except Exception as 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": error_message,
|
||||
}
|
||||
})
|
||||
await self.create_response()
|
||||
|
||||
def is_connected(self):
|
||||
return self.realtime.is_connected()
|
||||
|
||||
def reset(self):
|
||||
self.disconnect()
|
||||
self.realtime.clear_event_handlers()
|
||||
self._reset_config()
|
||||
self._add_api_event_handlers()
|
||||
return True
|
||||
|
||||
async def connect(self, model=None):
|
||||
if self.is_connected():
|
||||
raise Exception("Already connected, use .disconnect() first")
|
||||
|
||||
# Use provided model, OPENAI_MODEL_NAME environment variable, or default
|
||||
if model is None:
|
||||
model = os.getenv("OPENAI_MODEL_NAME", 'gpt-5-nano-realtime-preview-2024-12-17')
|
||||
|
||||
await self.realtime.connect(model)
|
||||
await self.update_session()
|
||||
return True
|
||||
|
||||
async def wait_for_session_created(self):
|
||||
if not self.is_connected():
|
||||
raise Exception("Not connected, use .connect() first")
|
||||
while not self.session_created:
|
||||
await asyncio.sleep(0.001)
|
||||
return True
|
||||
|
||||
async def disconnect(self):
|
||||
self.session_created = False
|
||||
self.conversation.clear()
|
||||
if self.realtime.is_connected():
|
||||
await self.realtime.disconnect()
|
||||
logger.info("RealtimeClient disconnected")
|
||||
|
||||
def get_turn_detection_type(self):
|
||||
return self.session_config.get("turn_detection", {}).get("type")
|
||||
|
||||
async def add_tool(self, definition, handler):
|
||||
if not definition.get("name"):
|
||||
raise Exception("Missing tool name in definition")
|
||||
name = definition["name"]
|
||||
if name in self.tools:
|
||||
raise Exception(f'Tool "{name}" already added. Please use .removeTool("{name}") before trying to add again.')
|
||||
if not callable(handler):
|
||||
raise Exception(f'Tool "{name}" handler must be a function')
|
||||
self.tools[name] = {"definition": definition, "handler": handler}
|
||||
await self.update_session()
|
||||
return self.tools[name]
|
||||
|
||||
def remove_tool(self, name):
|
||||
if name not in self.tools:
|
||||
raise Exception(f'Tool "{name}" does not exist, can not be removed.')
|
||||
del self.tools[name]
|
||||
return True
|
||||
|
||||
async def delete_item(self, id):
|
||||
await self.realtime.send("conversation.item.delete", {"item_id": id})
|
||||
return True
|
||||
|
||||
async def update_session(self, **kwargs):
|
||||
self.session_config.update(kwargs)
|
||||
use_tools = [
|
||||
{**tool_definition, "type": "function"}
|
||||
for tool_definition in self.session_config.get("tools", [])
|
||||
] + [
|
||||
{**self.tools[key]["definition"], "type": "function"}
|
||||
for key in self.tools
|
||||
]
|
||||
session = {**self.session_config, "tools": use_tools}
|
||||
logger.debug(f"Updating session: {session}")
|
||||
if self.realtime.is_connected():
|
||||
await self.realtime.send("session.update", {"session": session})
|
||||
return True
|
||||
|
||||
async def create_conversation_item(self, item):
|
||||
await self.realtime.send("conversation.item.create", {
|
||||
"item": item
|
||||
})
|
||||
|
||||
async def send_user_message_content(self, content=[]):
|
||||
if content:
|
||||
for c in content:
|
||||
if c["type"] == "input_audio":
|
||||
if isinstance(c["audio"], (bytes, bytearray)):
|
||||
c["audio"] = array_buffer_to_base64(c["audio"])
|
||||
await self.realtime.send("conversation.item.create", {
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": content,
|
||||
}
|
||||
})
|
||||
await self.create_response()
|
||||
return True
|
||||
|
||||
async def append_input_audio(self, array_buffer):
|
||||
if not self.is_connected():
|
||||
logger.warning("Cannot append audio: RealtimeClient is not connected")
|
||||
return False
|
||||
|
||||
if len(array_buffer) > 0:
|
||||
try:
|
||||
await self.realtime.send("input_audio_buffer.append", {
|
||||
"audio": array_buffer_to_base64(np.array(array_buffer)),
|
||||
})
|
||||
self.input_audio_buffer.extend(array_buffer)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to append input audio: {e}")
|
||||
# Connection might be lost, mark as disconnected
|
||||
if "connection" in str(e).lower() or "closed" in str(e).lower():
|
||||
logger.warning("WebSocket connection appears to be lost. Audio input will be queued until reconnection.")
|
||||
return False
|
||||
return True
|
||||
|
||||
async def create_response(self):
|
||||
if self.get_turn_detection_type() is None and len(self.input_audio_buffer) > 0:
|
||||
await self.realtime.send("input_audio_buffer.commit")
|
||||
self.conversation.queue_input_audio(self.input_audio_buffer)
|
||||
self.input_audio_buffer = bytearray()
|
||||
await self.realtime.send("response.create")
|
||||
return True
|
||||
|
||||
async def cancel_response(self, id=None, sample_count=0):
|
||||
if not id:
|
||||
await self.realtime.send("response.cancel")
|
||||
return {"item": None}
|
||||
else:
|
||||
item = self.conversation.get_item(id)
|
||||
if not item:
|
||||
raise Exception(f'Could not find item "{id}"')
|
||||
if item["type"] != "message":
|
||||
raise Exception('Can only cancelResponse messages with type "message"')
|
||||
if item["role"] != "assistant":
|
||||
raise Exception('Can only cancelResponse messages with role "assistant"')
|
||||
await self.realtime.send("response.cancel")
|
||||
audio_index = next((i for i, c in enumerate(item["content"]) if c["type"] == "audio"), -1)
|
||||
if audio_index == -1:
|
||||
raise Exception("Could not find audio on item to cancel")
|
||||
await self.realtime.send("conversation.item.truncate", {
|
||||
"item_id": id,
|
||||
"content_index": audio_index,
|
||||
"audio_end_ms": int((sample_count / self.conversation.default_frequency) * 1000),
|
||||
})
|
||||
return {"item": item}
|
||||
|
||||
async def wait_for_next_item(self):
|
||||
event = await self.wait_for_next("conversation.item.appended")
|
||||
return {"item": event["item"]}
|
||||
|
||||
async def wait_for_next_completed_item(self):
|
||||
event = await self.wait_for_next("conversation.item.completed")
|
||||
return {"item": event["item"]}
|
||||
|
||||
async def _send_chainlit_message(self, item):
|
||||
import chainlit as cl
|
||||
|
||||
# Debug logging
|
||||
logger.debug(f"Received item structure: {json.dumps({k: type(v).__name__ for k, v in item.items()}, indent=2)}")
|
||||
|
||||
if "type" in item and item["type"] == "function_call_output":
|
||||
# Don't send function call outputs directly to Chainlit
|
||||
logger.debug(f"Function call output received: {item.get('output', '')}")
|
||||
elif "role" in item:
|
||||
if item["role"] == "user":
|
||||
content = item.get("formatted", {}).get("text", "") or item.get("formatted", {}).get("transcript", "")
|
||||
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", "")
|
||||
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')}")
|
||||
|
||||
async def ensure_connected(self):
|
||||
"""Check connection health and attempt reconnection if needed"""
|
||||
if not self.is_connected():
|
||||
try:
|
||||
logger.info("Attempting to reconnect to OpenAI Realtime API...")
|
||||
model = os.getenv("OPENAI_MODEL_NAME", 'gpt-5-nano-realtime-preview-2024-12-17')
|
||||
await self.connect(model)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reconnect: {e}")
|
||||
return False
|
||||
return True
|
||||
@@ -1,242 +0,0 @@
|
||||
import yfinance as yf
|
||||
import chainlit as cl
|
||||
import plotly
|
||||
import json
|
||||
from tavily import TavilyClient
|
||||
from crawl4ai import AsyncWebCrawler
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
from openai import OpenAI
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from datetime import datetime
|
||||
from duckduckgo_search import DDGS
|
||||
|
||||
# Set up logging
|
||||
logger = logging.getLogger(__name__)
|
||||
log_level = os.getenv("LOGLEVEL", "INFO").upper()
|
||||
logger.setLevel(log_level)
|
||||
|
||||
# Set Tavily API key
|
||||
tavily_api_key = os.getenv("TAVILY_API_KEY")
|
||||
tavily_client = TavilyClient(api_key=tavily_api_key) if tavily_api_key else None
|
||||
|
||||
# Set up OpenAI client with support for custom base URL
|
||||
openai_base_url = os.getenv("OPENAI_BASE_URL")
|
||||
openai_api_key = os.getenv("OPENAI_API_KEY")
|
||||
|
||||
if openai_base_url:
|
||||
openai_client = OpenAI(base_url=openai_base_url, api_key=openai_api_key)
|
||||
else:
|
||||
openai_client = OpenAI(api_key=openai_api_key)
|
||||
|
||||
query_stock_price_def = {
|
||||
"name": "query_stock_price",
|
||||
"description": "Queries the latest stock price information for a given stock symbol.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"symbol": {
|
||||
"type": "string",
|
||||
"description": "The stock symbol to query (e.g., 'AAPL' for Apple Inc.)"
|
||||
},
|
||||
"period": {
|
||||
"type": "string",
|
||||
"description": "The time period for which to retrieve stock data (e.g., '1d' for one day, '1mo' for one month)"
|
||||
}
|
||||
},
|
||||
"required": ["symbol", "period"]
|
||||
}
|
||||
}
|
||||
|
||||
async def query_stock_price_handler(symbol, period):
|
||||
"""
|
||||
Queries the latest stock price information for a given stock symbol.
|
||||
"""
|
||||
try:
|
||||
stock = yf.Ticker(symbol)
|
||||
hist = stock.history(period=period)
|
||||
if hist.empty:
|
||||
return {"error": "No data found for the given symbol."}
|
||||
return hist.to_json()
|
||||
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
query_stock_price = (query_stock_price_def, query_stock_price_handler)
|
||||
|
||||
draw_plotly_chart_def = {
|
||||
"name": "draw_plotly_chart",
|
||||
"description": "Draws a Plotly chart based on the provided JSON figure and displays it with an accompanying message.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "The message to display alongside the chart"
|
||||
},
|
||||
"plotly_json_fig": {
|
||||
"type": "string",
|
||||
"description": "A JSON string representing the Plotly figure to be drawn"
|
||||
}
|
||||
},
|
||||
"required": ["message", "plotly_json_fig"]
|
||||
}
|
||||
}
|
||||
|
||||
async def draw_plotly_chart_handler(message: str, plotly_json_fig):
|
||||
fig = plotly.io.from_json(plotly_json_fig)
|
||||
elements = [cl.Plotly(name="chart", figure=fig, display="inline")]
|
||||
|
||||
await cl.Message(content=message, elements=elements).send()
|
||||
return {"status": "success"} # Add a return value
|
||||
|
||||
draw_plotly_chart = (draw_plotly_chart_def, draw_plotly_chart_handler)
|
||||
|
||||
tavily_web_search_def = {
|
||||
"name": "tavily_web_search",
|
||||
"description": "Search the web using Tavily API and crawl the resulting URLs",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
|
||||
async def tavily_web_search_handler(query):
|
||||
current_date = datetime.now().strftime("%d %B %Y")
|
||||
query_with_date = query + f" {current_date}"
|
||||
|
||||
if tavily_client:
|
||||
try:
|
||||
response = tavily_client.search(query_with_date)
|
||||
logger.debug(f"Tavily search response: {response}")
|
||||
results = await process_tavily_results(response)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in Tavily search: {str(e)}")
|
||||
results = await fallback_to_duckduckgo(query_with_date)
|
||||
else:
|
||||
logger.info("Tavily API key is not set. Using DuckDuckGo as fallback.")
|
||||
results = await fallback_to_duckduckgo(query_with_date)
|
||||
|
||||
return json.dumps({
|
||||
"query": query,
|
||||
"results": results
|
||||
})
|
||||
|
||||
async def process_tavily_results(response):
|
||||
async with AsyncWebCrawler() as crawler:
|
||||
results = []
|
||||
for result in response.get('results', []):
|
||||
url = result.get('url')
|
||||
if url:
|
||||
try:
|
||||
crawl_result = await crawler.arun(url=url)
|
||||
full_content = crawl_result.markdown if crawl_result and hasattr(crawl_result, 'markdown') and crawl_result.markdown else "No content available"
|
||||
results.append({
|
||||
"content": result.get('content'),
|
||||
"url": url,
|
||||
"full_content": full_content
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error crawling {url}: {str(e)}")
|
||||
results.append({
|
||||
"content": result.get('content'),
|
||||
"url": url,
|
||||
"full_content": "Error: Unable to crawl this URL"
|
||||
})
|
||||
return results
|
||||
|
||||
async def fallback_to_duckduckgo(query):
|
||||
try:
|
||||
with DDGS() as ddgs:
|
||||
ddg_results = list(ddgs.text(query, max_results=5))
|
||||
|
||||
logger.debug(f"DuckDuckGo search results: {ddg_results}")
|
||||
|
||||
async with AsyncWebCrawler() as crawler:
|
||||
results = []
|
||||
|
||||
for result in ddg_results:
|
||||
url = result.get('href')
|
||||
if url:
|
||||
try:
|
||||
crawl_result = await crawler.arun(url=url)
|
||||
full_content = crawl_result.markdown if crawl_result and hasattr(crawl_result, 'markdown') and crawl_result.markdown else "No content available"
|
||||
results.append({
|
||||
"content": result.get('body'),
|
||||
"url": url,
|
||||
"full_content": full_content
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error crawling {url}: {str(e)}")
|
||||
results.append({
|
||||
"content": result.get('body'),
|
||||
"url": url,
|
||||
"full_content": "Error: Unable to crawl this URL"
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
"content": result.get('body'),
|
||||
"url": "N/A",
|
||||
"full_content": "No URL provided for crawling"
|
||||
})
|
||||
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"Error in DuckDuckGo search: {str(e)}")
|
||||
return []
|
||||
|
||||
tavily_web_search = (tavily_web_search_def, tavily_web_search_handler)
|
||||
|
||||
# New image generation tool
|
||||
generate_image_def = {
|
||||
"name": "generate_image",
|
||||
"description": "Generate an image based on a text prompt using DALL-E 3",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {"type": "string", "description": "The text prompt to generate the image"},
|
||||
"size": {"type": "string", "description": "Image size (1024x1024, 1024x1792, or 1792x1024)", "default": "1024x1024"},
|
||||
"quality": {"type": "string", "description": "Image quality (standard or hd)", "default": "standard"},
|
||||
},
|
||||
"required": ["prompt"]
|
||||
}
|
||||
}
|
||||
|
||||
async def generate_image_handler(prompt, size="1024x1024", quality="standard"):
|
||||
try:
|
||||
response = openai_client.images.generate(
|
||||
model="dall-e-3",
|
||||
prompt=prompt,
|
||||
size=size,
|
||||
quality=quality,
|
||||
n=1,
|
||||
)
|
||||
|
||||
image_url = response.data[0].url
|
||||
|
||||
# Download the image
|
||||
import requests
|
||||
image_content = requests.get(image_url).content
|
||||
|
||||
# Convert image to base64
|
||||
image_base64 = base64.b64encode(image_content).decode('utf-8')
|
||||
|
||||
# Create a Chainlit Image element
|
||||
image_element = cl.Image(content=image_content, name="generated_image", display="inline")
|
||||
|
||||
# Send the image in a Chainlit message
|
||||
await cl.Message(content=f"Generated image for prompt: '{prompt}'", elements=[image_element]).send()
|
||||
|
||||
return {"status": "success", "message": "Image generated and displayed"}
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating image: {str(e)}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
generate_image = (generate_image_def, generate_image_handler)
|
||||
|
||||
tools = [query_stock_price, draw_plotly_chart, tavily_web_search, generate_image]
|
||||
@@ -1,715 +0,0 @@
|
||||
import json
|
||||
import ssl
|
||||
import uuid
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
|
||||
import os
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
|
||||
from chainlit.data.base import BaseDataLayer
|
||||
from chainlit.data.utils import queue_until_user_message
|
||||
from praisonai.ui.chainlit_compat import EXPIRY_TIME, BaseStorageClient
|
||||
from chainlit.element import ElementDict
|
||||
from chainlit.logger import logger
|
||||
from chainlit.step import StepDict
|
||||
from chainlit.types import (
|
||||
Feedback,
|
||||
FeedbackDict,
|
||||
PageInfo,
|
||||
PaginatedResponse,
|
||||
Pagination,
|
||||
ThreadDict,
|
||||
ThreadFilter,
|
||||
)
|
||||
from chainlit.user import PersistedUser, User
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from database_config import get_database_config_for_sqlalchemy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from chainlit.element import Element
|
||||
from chainlit.step import StepDict
|
||||
|
||||
# Check FORCE_SQLITE flag to bypass external database detection
|
||||
DATABASE_URL, SUPABASE_DATABASE_URL = get_database_config_for_sqlalchemy()
|
||||
|
||||
class SQLAlchemyDataLayer(BaseDataLayer):
|
||||
def __init__(
|
||||
self,
|
||||
conninfo: str,
|
||||
ssl_require: bool = False,
|
||||
storage_provider: Optional[BaseStorageClient] = None,
|
||||
user_thread_limit: Optional[int] = 1000,
|
||||
show_logger: Optional[bool] = False,
|
||||
):
|
||||
self._conninfo = conninfo
|
||||
self.user_thread_limit = user_thread_limit
|
||||
self.show_logger = show_logger
|
||||
ssl_args = {}
|
||||
if ssl_require:
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
ssl_args["ssl"] = ssl_context
|
||||
self.engine: AsyncEngine = create_async_engine(
|
||||
self._conninfo, connect_args=ssl_args
|
||||
)
|
||||
self.async_session = sessionmaker(
|
||||
bind=self.engine, expire_on_commit=False, class_=AsyncSession
|
||||
)
|
||||
if storage_provider:
|
||||
self.storage_provider: Optional[BaseStorageClient] = storage_provider
|
||||
if self.show_logger:
|
||||
logger.info("SQLAlchemyDataLayer storage client initialized")
|
||||
else:
|
||||
self.storage_provider = None
|
||||
logger.warning(
|
||||
"SQLAlchemyDataLayer storage client is not initialized and elements will not be persisted!"
|
||||
)
|
||||
|
||||
async def build_debug_url(self) -> str:
|
||||
return ""
|
||||
|
||||
###### SQL Helpers ######
|
||||
async def execute_sql(
|
||||
self, query: str, parameters: dict, expand: Optional[str] = None
|
||||
) -> Union[List[Dict[str, Any]], int, None]:
|
||||
from sqlalchemy import bindparam
|
||||
parameterized_query = text(query)
|
||||
if expand:
|
||||
parameterized_query = parameterized_query.bindparams(bindparam(expand, expanding=True))
|
||||
async with self.async_session() as session:
|
||||
try:
|
||||
await session.begin()
|
||||
result = await session.execute(parameterized_query, parameters)
|
||||
await session.commit()
|
||||
if result.returns_rows:
|
||||
json_result = [dict(row._mapping) for row in result.fetchall()]
|
||||
clean_json_result = self.clean_result(json_result)
|
||||
assert isinstance(clean_json_result, list) or isinstance(
|
||||
clean_json_result, int
|
||||
)
|
||||
return clean_json_result
|
||||
else:
|
||||
return result.rowcount
|
||||
except SQLAlchemyError as e:
|
||||
await session.rollback()
|
||||
logger.warning(f"An error occurred: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.warning(f"An unexpected error occurred: {e}")
|
||||
return None
|
||||
|
||||
async def get_current_timestamp(self) -> str:
|
||||
return datetime.now().isoformat() + "Z"
|
||||
|
||||
def clean_result(self, obj):
|
||||
if isinstance(obj, dict):
|
||||
return {k: self.clean_result(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [self.clean_result(item) for item in obj]
|
||||
elif isinstance(obj, uuid.UUID):
|
||||
return str(obj)
|
||||
return obj
|
||||
|
||||
###### User ######
|
||||
async def get_user(self, identifier: str) -> Optional[PersistedUser]:
|
||||
if self.show_logger:
|
||||
logger.info(f"SQLAlchemy: get_user, identifier={identifier}")
|
||||
query = 'SELECT * FROM users WHERE "identifier" = :identifier'
|
||||
parameters = {"identifier": identifier}
|
||||
result = await self.execute_sql(query=query, parameters=parameters)
|
||||
if result and isinstance(result, list):
|
||||
user_data = result[0]
|
||||
|
||||
meta = user_data.get("meta", "{}")
|
||||
if isinstance(meta, str):
|
||||
meta = json.loads(meta)
|
||||
|
||||
return PersistedUser(
|
||||
id=user_data["id"],
|
||||
identifier=user_data["identifier"],
|
||||
createdAt=user_data["createdAt"],
|
||||
metadata=meta,
|
||||
)
|
||||
return None
|
||||
|
||||
async def _get_user_identifer_by_id(self, user_id: str) -> str:
|
||||
if self.show_logger:
|
||||
logger.info(f"SQLAlchemy: _get_user_identifer_by_id, user_id={user_id}")
|
||||
query = 'SELECT "identifier" FROM users WHERE "id" = :user_id'
|
||||
parameters = {"user_id": user_id}
|
||||
result = await self.execute_sql(query=query, parameters=parameters)
|
||||
assert result
|
||||
assert isinstance(result, list)
|
||||
return result[0]["identifier"]
|
||||
|
||||
async def _get_user_id_by_thread(self, thread_id: str) -> Optional[str]:
|
||||
if self.show_logger:
|
||||
logger.info(f"SQLAlchemy: _get_user_id_by_thread, thread_id={thread_id}")
|
||||
query = 'SELECT "userId" FROM threads WHERE "id" = :thread_id'
|
||||
parameters = {"thread_id": thread_id}
|
||||
result = await self.execute_sql(query=query, parameters=parameters)
|
||||
if result and isinstance(result, list):
|
||||
return result[0]["userId"]
|
||||
return None
|
||||
|
||||
async def create_user(self, user: User) -> Optional[PersistedUser]:
|
||||
if self.show_logger:
|
||||
logger.info(f"SQLAlchemy: create_user, user_identifier={user.identifier}")
|
||||
existing_user: Optional["PersistedUser"] = await self.get_user(user.identifier)
|
||||
user_dict: Dict[str, Any] = {
|
||||
"identifier": str(user.identifier),
|
||||
"meta": json.dumps(user.metadata) or "{}",
|
||||
}
|
||||
if not existing_user:
|
||||
user_dict["id"] = str(uuid.uuid4())
|
||||
user_dict["createdAt"] = await self.get_current_timestamp()
|
||||
query = 'INSERT INTO users ("id", "identifier", "createdAt", "meta") VALUES (:id, :identifier, :createdAt, :meta)'
|
||||
await self.execute_sql(query=query, parameters=user_dict)
|
||||
else:
|
||||
query = 'UPDATE users SET "meta" = :meta WHERE "identifier" = :identifier'
|
||||
await self.execute_sql(query=query, parameters=user_dict)
|
||||
return await self.get_user(user.identifier)
|
||||
|
||||
###### Threads ######
|
||||
async def get_thread_author(self, thread_id: str) -> str:
|
||||
if self.show_logger:
|
||||
logger.info(f"SQLAlchemy: get_thread_author, thread_id={thread_id}")
|
||||
query = 'SELECT "userIdentifier" FROM threads WHERE "id" = :id'
|
||||
parameters = {"id": thread_id}
|
||||
result = await self.execute_sql(query=query, parameters=parameters)
|
||||
if isinstance(result, list) and result:
|
||||
author_identifier = result[0].get("userIdentifier")
|
||||
if author_identifier is not None:
|
||||
return author_identifier
|
||||
raise ValueError(f"Author not found for thread_id {thread_id}")
|
||||
|
||||
async def get_thread(self, thread_id: str) -> Optional[ThreadDict]:
|
||||
if self.show_logger:
|
||||
logger.info(f"SQLAlchemy: get_thread, thread_id={thread_id}")
|
||||
user_threads: Optional[List[ThreadDict]] = await self.get_all_user_threads(
|
||||
thread_id=thread_id
|
||||
)
|
||||
if user_threads:
|
||||
return user_threads[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
async def update_thread(
|
||||
self,
|
||||
thread_id: str,
|
||||
name: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
metadata: Optional[Dict] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
):
|
||||
if self.show_logger:
|
||||
logger.info(f"SQLAlchemy: update_thread, thread_id={thread_id}")
|
||||
|
||||
user_identifier = None
|
||||
if user_id:
|
||||
user_identifier = await self._get_user_identifer_by_id(user_id)
|
||||
|
||||
data = {
|
||||
"id": thread_id,
|
||||
"createdAt": (
|
||||
await self.get_current_timestamp() if metadata is None else None
|
||||
),
|
||||
"name": (
|
||||
name
|
||||
if name is not None
|
||||
else (metadata.get("name") if metadata and "name" in metadata else None)
|
||||
),
|
||||
"userId": user_id,
|
||||
"userIdentifier": user_identifier,
|
||||
"tags": json.dumps(tags) if tags else None,
|
||||
"meta": json.dumps(metadata) if metadata else None,
|
||||
}
|
||||
parameters = {key: value for key, value in data.items() if value is not None}
|
||||
columns = ", ".join(f'"{key}"' for key in parameters.keys())
|
||||
values = ", ".join(f':{key}' for key in parameters.keys())
|
||||
updates = ", ".join(
|
||||
f'"{key}" = EXCLUDED."{key}"' for key in parameters.keys() if key != "id"
|
||||
)
|
||||
query = f"""
|
||||
INSERT INTO threads ({columns})
|
||||
VALUES ({values})
|
||||
ON CONFLICT ("id") DO UPDATE
|
||||
SET {updates};
|
||||
"""
|
||||
await self.execute_sql(query=query, parameters=parameters)
|
||||
|
||||
async def delete_thread(self, thread_id: str):
|
||||
if self.show_logger:
|
||||
logger.info(f"SQLAlchemy: delete_thread, thread_id={thread_id}")
|
||||
feedbacks_query = 'DELETE FROM feedbacks WHERE "forId" IN (SELECT "id" FROM steps WHERE "threadId" = :id)'
|
||||
elements_query = 'DELETE FROM elements WHERE "threadId" = :id'
|
||||
steps_query = 'DELETE FROM steps WHERE "threadId" = :id'
|
||||
thread_query = 'DELETE FROM threads WHERE "id" = :id'
|
||||
parameters = {"id": thread_id}
|
||||
await self.execute_sql(query=feedbacks_query, parameters=parameters)
|
||||
await self.execute_sql(query=elements_query, parameters=parameters)
|
||||
await self.execute_sql(query=steps_query, parameters=parameters)
|
||||
await self.execute_sql(query=thread_query, parameters=parameters)
|
||||
|
||||
async def list_threads(
|
||||
self, pagination: Pagination, filters: ThreadFilter
|
||||
) -> PaginatedResponse:
|
||||
if self.show_logger:
|
||||
logger.info(
|
||||
f"SQLAlchemy: list_threads, pagination={pagination}, filters={filters}"
|
||||
)
|
||||
if not filters.userId:
|
||||
raise ValueError("userId is required")
|
||||
all_user_threads: Optional[List[ThreadDict]] = (
|
||||
await self.get_all_user_threads(user_id=filters.userId) or []
|
||||
)
|
||||
|
||||
search_keyword = filters.search.lower() if filters.search else None
|
||||
feedback_value = int(filters.feedback) if filters.feedback else None
|
||||
|
||||
filtered_threads = []
|
||||
for thread in all_user_threads:
|
||||
keyword_match = True
|
||||
feedback_match = True
|
||||
if search_keyword or feedback_value is not None:
|
||||
if search_keyword:
|
||||
keyword_match = any(
|
||||
search_keyword in step["output"].lower()
|
||||
for step in thread["steps"]
|
||||
if "output" in step and isinstance(step["output"], str)
|
||||
)
|
||||
if feedback_value is not None:
|
||||
feedback_match = False
|
||||
for step in thread["steps"]:
|
||||
feedback = step.get("feedback")
|
||||
if feedback and feedback.get("value") == feedback_value:
|
||||
feedback_match = True
|
||||
break
|
||||
if keyword_match and feedback_match:
|
||||
filtered_threads.append(thread)
|
||||
|
||||
start = 0
|
||||
if pagination.cursor:
|
||||
for i, thr in enumerate(filtered_threads):
|
||||
if thr["id"] == pagination.cursor:
|
||||
start = i + 1
|
||||
break
|
||||
end = start + pagination.first
|
||||
paginated_threads = filtered_threads[start:end] or []
|
||||
|
||||
has_next_page = len(filtered_threads) > end
|
||||
start_cursor = paginated_threads[0]["id"] if paginated_threads else None
|
||||
end_cursor = paginated_threads[-1]["id"] if paginated_threads else None
|
||||
|
||||
return PaginatedResponse(
|
||||
pageInfo=PageInfo(
|
||||
hasNextPage=has_next_page,
|
||||
startCursor=start_cursor,
|
||||
endCursor=end_cursor,
|
||||
),
|
||||
data=paginated_threads,
|
||||
)
|
||||
|
||||
###### Steps ######
|
||||
@queue_until_user_message()
|
||||
async def create_step(self, step_dict: "StepDict"):
|
||||
if self.show_logger:
|
||||
logger.info(f"SQLAlchemy: create_step, step_id={step_dict.get('id')}")
|
||||
|
||||
step_dict["showInput"] = (
|
||||
str(step_dict.get("showInput", "")).lower()
|
||||
if "showInput" in step_dict
|
||||
else None
|
||||
)
|
||||
|
||||
tags = step_dict.get("tags")
|
||||
if not tags:
|
||||
tags = []
|
||||
meta = json.dumps(step_dict.get("metadata", {}))
|
||||
generation = json.dumps(step_dict.get("generation", {}))
|
||||
parameters = {
|
||||
"id": step_dict.get("id"),
|
||||
"name": step_dict.get("name"),
|
||||
"type": step_dict.get("type"),
|
||||
"threadId": step_dict.get("threadId"),
|
||||
"parentId": step_dict.get("parentId"),
|
||||
"disableFeedback": step_dict.get("disableFeedback", False),
|
||||
"streaming": step_dict.get("streaming", False),
|
||||
"waitForAnswer": step_dict.get("waitForAnswer", False),
|
||||
"isError": step_dict.get("isError", False),
|
||||
"meta": meta,
|
||||
"tags": json.dumps(tags),
|
||||
"input": step_dict.get("input"),
|
||||
"output": step_dict.get("output"),
|
||||
"createdAt": step_dict.get("createdAt"),
|
||||
"startTime": step_dict.get("start"),
|
||||
"endTime": step_dict.get("end"),
|
||||
"generation": generation,
|
||||
"showInput": step_dict.get("showInput"),
|
||||
"language": step_dict.get("language"),
|
||||
"indent": step_dict.get("indent"),
|
||||
}
|
||||
parameters = {k: v for k, v in parameters.items() if v is not None}
|
||||
columns = ", ".join(f'"{key}"' for key in parameters.keys())
|
||||
values = ", ".join(f':{key}' for key in parameters.keys())
|
||||
updates = ", ".join(
|
||||
f'"{key}" = :{key}' for key in parameters.keys() if key != "id"
|
||||
)
|
||||
query = f"""
|
||||
INSERT INTO steps ({columns})
|
||||
VALUES ({values})
|
||||
ON CONFLICT ("id") DO UPDATE
|
||||
SET {updates};
|
||||
"""
|
||||
await self.execute_sql(query=query, parameters=parameters)
|
||||
|
||||
@queue_until_user_message()
|
||||
async def update_step(self, step_dict: "StepDict"):
|
||||
if self.show_logger:
|
||||
logger.info(f"SQLAlchemy: update_step, step_id={step_dict.get('id')}")
|
||||
await self.create_step(step_dict)
|
||||
|
||||
@queue_until_user_message()
|
||||
async def delete_step(self, step_id: str):
|
||||
if self.show_logger:
|
||||
logger.info(f"SQLAlchemy: delete_step, step_id={step_id}")
|
||||
feedbacks_query = 'DELETE FROM feedbacks WHERE "forId" = :id'
|
||||
elements_query = 'DELETE FROM elements WHERE "forId" = :id'
|
||||
steps_query = 'DELETE FROM steps WHERE "id" = :id'
|
||||
parameters = {"id": step_id}
|
||||
await self.execute_sql(query=feedbacks_query, parameters=parameters)
|
||||
await self.execute_sql(query=elements_query, parameters=parameters)
|
||||
await self.execute_sql(query=steps_query, parameters=parameters)
|
||||
|
||||
###### Feedback ######
|
||||
async def upsert_feedback(self, feedback: Feedback) -> str:
|
||||
if self.show_logger:
|
||||
logger.info(f"SQLAlchemy: upsert_feedback, feedback_id={feedback.id}")
|
||||
feedback.id = feedback.id or str(uuid.uuid4())
|
||||
feedback_dict = asdict(feedback)
|
||||
parameters = {k: v for k, v in feedback_dict.items() if v is not None}
|
||||
columns = ", ".join(f'"{key}"' for key in parameters.keys())
|
||||
values = ", ".join(f':{key}' for key in parameters.keys())
|
||||
updates = ", ".join(
|
||||
f'"{key}" = :{key}' for key in parameters.keys() if key != "id"
|
||||
)
|
||||
query = f"""
|
||||
INSERT INTO feedbacks ({columns})
|
||||
VALUES ({values})
|
||||
ON CONFLICT ("id") DO UPDATE
|
||||
SET {updates};
|
||||
"""
|
||||
await self.execute_sql(query=query, parameters=parameters)
|
||||
return feedback.id
|
||||
|
||||
async def delete_feedback(self, feedback_id: str) -> bool:
|
||||
if self.show_logger:
|
||||
logger.info(f"SQLAlchemy: delete_feedback, feedback_id={feedback_id}")
|
||||
query = 'DELETE FROM feedbacks WHERE "id" = :feedback_id'
|
||||
parameters = {"feedback_id": feedback_id}
|
||||
await self.execute_sql(query=query, parameters=parameters)
|
||||
return True
|
||||
|
||||
###### Elements ######
|
||||
async def get_element(
|
||||
self, thread_id: str, element_id: str
|
||||
) -> Optional["ElementDict"]:
|
||||
if self.show_logger:
|
||||
logger.info(
|
||||
f"SQLAlchemy: get_element, thread_id={thread_id}, element_id={element_id}"
|
||||
)
|
||||
query = 'SELECT * FROM elements WHERE "threadId" = :thread_id AND "id" = :element_id'
|
||||
parameters = {"thread_id": thread_id, "element_id": element_id}
|
||||
element = await self.execute_sql(query=query, parameters=parameters)
|
||||
if isinstance(element, list) and element:
|
||||
element_dict = element[0]
|
||||
return ElementDict(
|
||||
id=element_dict["id"],
|
||||
threadId=element_dict.get("threadId"),
|
||||
type=element_dict.get("type"),
|
||||
chainlitKey=element_dict.get("chainlitKey"),
|
||||
url=element_dict.get("url"),
|
||||
objectKey=element_dict.get("objectKey"),
|
||||
name=element_dict["name"],
|
||||
display=element_dict["display"],
|
||||
size=element_dict.get("size"),
|
||||
language=element_dict.get("language"),
|
||||
page=element_dict.get("page"),
|
||||
autoPlay=element_dict.get("autoPlay"),
|
||||
playerConfig=element_dict.get("playerConfig"),
|
||||
forId=element_dict.get("forId"),
|
||||
mime=element_dict.get("mime"),
|
||||
)
|
||||
else:
|
||||
return None
|
||||
|
||||
@queue_until_user_message()
|
||||
async def create_element(self, element: "Element"):
|
||||
if self.show_logger:
|
||||
logger.info(f"SQLAlchemy: create_element, element_id = {element.id}")
|
||||
|
||||
if not self.storage_provider:
|
||||
logger.warning("SQLAlchemy: create_element error. No storage client!")
|
||||
return
|
||||
if not element.for_id:
|
||||
return
|
||||
|
||||
content: Optional[Union[bytes, str]] = None
|
||||
|
||||
if element.path:
|
||||
async with aiofiles.open(element.path, "rb") as f:
|
||||
content = await f.read()
|
||||
elif element.url:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(element.url) as response:
|
||||
if response.status == 200:
|
||||
content = await response.read()
|
||||
else:
|
||||
content = None
|
||||
elif element.content:
|
||||
content = element.content
|
||||
else:
|
||||
raise ValueError("Element url, path or content must be provided")
|
||||
if content is None:
|
||||
raise ValueError("Content is None, cannot upload file")
|
||||
|
||||
user_id: str = await self._get_user_id_by_thread(element.thread_id) or "unknown"
|
||||
file_object_key = f"{user_id}/{element.id}" + (
|
||||
f"/{element.name}" if element.name else ""
|
||||
)
|
||||
|
||||
if not element.mime:
|
||||
element.mime = "application/octet-stream"
|
||||
|
||||
uploaded_file = await self.storage_provider.upload_file(
|
||||
object_key=file_object_key, data=content, mime=element.mime, overwrite=True
|
||||
)
|
||||
if not uploaded_file:
|
||||
raise ValueError("Failed to persist data in storage_provider")
|
||||
|
||||
element_dict: ElementDict = element.to_dict()
|
||||
element_dict["url"] = uploaded_file.get("url")
|
||||
element_dict["objectKey"] = uploaded_file.get("object_key")
|
||||
element_dict_cleaned = {k: v for k, v in element_dict.items() if v is not None}
|
||||
|
||||
columns = ", ".join(f'"{column}"' for column in element_dict_cleaned.keys())
|
||||
placeholders = ", ".join(f':{column}' for column in element_dict_cleaned.keys())
|
||||
query = f"INSERT INTO elements ({columns}) VALUES ({placeholders})"
|
||||
await self.execute_sql(query=query, parameters=element_dict_cleaned)
|
||||
|
||||
@queue_until_user_message()
|
||||
async def delete_element(self, element_id: str, thread_id: Optional[str] = None):
|
||||
if self.show_logger:
|
||||
logger.info(f"SQLAlchemy: delete_element, element_id={element_id}")
|
||||
query = 'DELETE FROM elements WHERE "id" = :id'
|
||||
parameters = {"id": element_id}
|
||||
await self.execute_sql(query=query, parameters=parameters)
|
||||
|
||||
async def get_all_user_threads(
|
||||
self, user_id: Optional[str] = None, thread_id: Optional[str] = None
|
||||
) -> Optional[List[ThreadDict]]:
|
||||
if self.show_logger:
|
||||
logger.info("SQLAlchemy: get_all_user_threads")
|
||||
user_threads_query = """
|
||||
SELECT
|
||||
"id" AS thread_id,
|
||||
"createdAt" AS thread_createdat,
|
||||
"name" AS thread_name,
|
||||
"userId" AS user_id,
|
||||
"userIdentifier" AS user_identifier,
|
||||
"tags" AS thread_tags,
|
||||
"meta" AS thread_meta
|
||||
FROM threads
|
||||
WHERE ("userId" = :user_id OR :user_id IS NULL)
|
||||
AND ("id" = :thread_id OR :thread_id IS NULL)
|
||||
ORDER BY "createdAt" DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
params = {
|
||||
"user_id": user_id,
|
||||
"thread_id": thread_id,
|
||||
"limit": self.user_thread_limit,
|
||||
}
|
||||
user_threads = await self.execute_sql(
|
||||
query=user_threads_query,
|
||||
parameters=params,
|
||||
)
|
||||
if not isinstance(user_threads, list):
|
||||
return None
|
||||
if not user_threads:
|
||||
return []
|
||||
else:
|
||||
# Build parameterized IN clause using expanding to prevent SQL injection
|
||||
thread_id_list = [t["thread_id"] for t in user_threads]
|
||||
tid_params = {"thread_ids": thread_id_list}
|
||||
|
||||
steps_feedbacks_query = """
|
||||
SELECT
|
||||
s."id" AS step_id,
|
||||
s."name" AS step_name,
|
||||
s."type" AS step_type,
|
||||
s."threadId" AS step_threadid,
|
||||
s."parentId" AS step_parentid,
|
||||
s."streaming" AS step_streaming,
|
||||
s."waitForAnswer" AS step_waitforanswer,
|
||||
s."isError" AS step_iserror,
|
||||
s."meta" AS step_meta,
|
||||
s."tags" AS step_tags,
|
||||
s."input" AS step_input,
|
||||
s."output" AS step_output,
|
||||
s."createdAt" AS step_createdat,
|
||||
s."startTime" AS step_start,
|
||||
s."endTime" AS step_end,
|
||||
s."generation" AS step_generation,
|
||||
s."showInput" AS step_showinput,
|
||||
s."language" AS step_language,
|
||||
s."indent" AS step_indent,
|
||||
f."value" AS feedback_value,
|
||||
f."comment" AS feedback_comment,
|
||||
f."id" AS feedback_id
|
||||
FROM steps s LEFT JOIN feedbacks f ON s."id" = f."forId"
|
||||
WHERE s."threadId" IN :thread_ids
|
||||
ORDER BY s."createdAt" ASC
|
||||
"""
|
||||
steps_feedbacks = await self.execute_sql(
|
||||
query=steps_feedbacks_query, parameters=tid_params, expand="thread_ids"
|
||||
)
|
||||
|
||||
elements_query = """
|
||||
SELECT
|
||||
e."id" AS element_id,
|
||||
e."threadId" as element_threadid,
|
||||
e."type" AS element_type,
|
||||
e."chainlitKey" AS element_chainlitkey,
|
||||
e."url" AS element_url,
|
||||
e."objectKey" as element_objectkey,
|
||||
e."name" AS element_name,
|
||||
e."display" AS element_display,
|
||||
e."size" AS element_size,
|
||||
e."language" AS element_language,
|
||||
e."page" AS element_page,
|
||||
e."forId" AS element_forid,
|
||||
e."mime" AS element_mime
|
||||
FROM elements e
|
||||
WHERE e."threadId" IN :thread_ids
|
||||
"""
|
||||
elements = await self.execute_sql(
|
||||
query=elements_query, parameters=tid_params, expand="thread_ids"
|
||||
)
|
||||
|
||||
thread_dicts = {}
|
||||
for thread in user_threads:
|
||||
t_id = thread["thread_id"]
|
||||
meta = thread["thread_meta"]
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
meta = json.loads(meta)
|
||||
except:
|
||||
meta = {}
|
||||
tags = thread["thread_tags"]
|
||||
if isinstance(tags, str):
|
||||
try:
|
||||
tags = json.loads(tags)
|
||||
except:
|
||||
tags = []
|
||||
thread_dicts[t_id] = ThreadDict(
|
||||
id=t_id,
|
||||
createdAt=thread["thread_createdat"],
|
||||
name=thread["thread_name"],
|
||||
userId=thread["user_id"],
|
||||
userIdentifier=thread["user_identifier"],
|
||||
tags=tags,
|
||||
metadata=meta,
|
||||
steps=[],
|
||||
elements=[],
|
||||
)
|
||||
|
||||
if isinstance(steps_feedbacks, list):
|
||||
for step_feedback in steps_feedbacks:
|
||||
t_id = step_feedback["step_threadid"]
|
||||
if t_id in thread_dicts:
|
||||
meta = step_feedback["step_meta"]
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
meta = json.loads(meta)
|
||||
except:
|
||||
meta = {}
|
||||
tags = step_feedback["step_tags"]
|
||||
if isinstance(tags, str):
|
||||
try:
|
||||
tags = json.loads(tags)
|
||||
except:
|
||||
tags = []
|
||||
feedback = None
|
||||
if step_feedback["feedback_value"] is not None:
|
||||
feedback = FeedbackDict(
|
||||
forId=step_feedback["step_id"],
|
||||
id=step_feedback.get("feedback_id"),
|
||||
value=step_feedback["feedback_value"],
|
||||
comment=step_feedback.get("feedback_comment"),
|
||||
)
|
||||
input_val = step_feedback.get("step_input", "")
|
||||
show_input = step_feedback.get("step_showinput", "false")
|
||||
if show_input == "false":
|
||||
input_val = ""
|
||||
step_dict = StepDict(
|
||||
id=step_feedback["step_id"],
|
||||
name=step_feedback["step_name"],
|
||||
type=step_feedback["step_type"],
|
||||
threadId=t_id,
|
||||
parentId=step_feedback.get("step_parentid"),
|
||||
streaming=step_feedback.get("step_streaming", False),
|
||||
waitForAnswer=step_feedback.get("step_waitforanswer"),
|
||||
isError=step_feedback.get("step_iserror"),
|
||||
metadata=meta,
|
||||
tags=tags,
|
||||
input=input_val,
|
||||
output=step_feedback.get("step_output", ""),
|
||||
createdAt=step_feedback.get("step_createdat"),
|
||||
start=step_feedback.get("step_start"),
|
||||
end=step_feedback.get("step_end"),
|
||||
generation=step_feedback.get("step_generation"),
|
||||
showInput=step_feedback.get("step_showinput"),
|
||||
language=step_feedback.get("step_language"),
|
||||
indent=step_feedback.get("step_indent"),
|
||||
feedback=feedback,
|
||||
)
|
||||
thread_dicts[t_id]["steps"].append(step_dict)
|
||||
|
||||
if isinstance(elements, list):
|
||||
for element in elements:
|
||||
t_id = element["element_threadid"]
|
||||
if t_id in thread_dicts:
|
||||
element_dict = ElementDict(
|
||||
id=element["element_id"],
|
||||
threadId=t_id,
|
||||
type=element["element_type"],
|
||||
chainlitKey=element.get("element_chainlitkey"),
|
||||
url=element.get("element_url"),
|
||||
objectKey=element.get("element_objectkey"),
|
||||
name=element["element_name"],
|
||||
display=element["element_display"],
|
||||
size=element.get("element_size"),
|
||||
language=element.get("element_language"),
|
||||
autoPlay=element.get("element_autoPlay"),
|
||||
playerConfig=element.get("element_playerconfig"),
|
||||
page=element.get("element_page"),
|
||||
forId=element.get("element_forid"),
|
||||
mime=element.get("element_mime"),
|
||||
)
|
||||
thread_dicts[t_id]["elements"].append(element_dict)
|
||||
|
||||
return list(thread_dicts.values())
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the database connection and cleanup resources."""
|
||||
if hasattr(self, 'engine') and self.engine:
|
||||
await self.engine.dispose()
|
||||
@@ -3,22 +3,23 @@
|
||||
Loaded by ``praisonai ui realtime`` → ``aiui run app.py``.
|
||||
Replaces ui/realtime.py (Chainlit) with aiui implementation.
|
||||
|
||||
NOTE: This is a placeholder implementation. Full WebRTC voice realtime
|
||||
requires PraisonAIUI WebRTC feature to be implemented.
|
||||
Uses aiui's OpenAIRealtimeManager for WebRTC voice functionality.
|
||||
|
||||
Requires: pip install "praisonai[ui]"
|
||||
"""
|
||||
|
||||
import os
|
||||
import praisonaiui as aiui
|
||||
from praisonaiui.features.realtime import OpenAIRealtimeManager
|
||||
from praisonai.ui._aiui_datastore import PraisonAISessionDataStore
|
||||
|
||||
# ── Set up datastore bridge ─────────────────────────────────
|
||||
# ── Set up datastore bridge and realtime manager ───────────
|
||||
aiui.set_datastore(PraisonAISessionDataStore())
|
||||
aiui.set_realtime_manager(OpenAIRealtimeManager())
|
||||
|
||||
# ── Dashboard style ─────────────────────────────────────────
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_branding(title="PraisonAI Realtime (Beta)", logo="🎤")
|
||||
aiui.set_branding(title="PraisonAI Realtime Voice", logo="🎤")
|
||||
aiui.set_theme(preset="red", dark_mode=True, radius="lg")
|
||||
aiui.set_pages([
|
||||
"chat",
|
||||
@@ -37,13 +38,14 @@ async def get_starters():
|
||||
@aiui.welcome
|
||||
async def on_welcome():
|
||||
"""Welcome with realtime status."""
|
||||
await aiui.say("""🎤 **PraisonAI Realtime Voice Interface (Beta)**
|
||||
await aiui.say("""🎤 **PraisonAI Realtime Voice Interface**
|
||||
|
||||
⚠️ **Note**: Full WebRTC voice realtime is pending PraisonAIUI feature implementation.
|
||||
Welcome to PraisonAI's voice-powered realtime chat! Use the microphone button to start voice conversations with AI agents.
|
||||
|
||||
For now, this provides a text-based interface that simulates realtime interactions.
|
||||
|
||||
See: https://github.com/MervinPraison/PraisonAIUI/issues for WebRTC voice realtime status.
|
||||
✨ Features:
|
||||
- Real-time voice input/output via WebRTC
|
||||
- Session persistence across restarts
|
||||
- Dashboard with chat history and usage logs
|
||||
""")
|
||||
|
||||
# Session-scoped realtime agent cache
|
||||
@@ -51,7 +53,7 @@ _realtime_cache = {}
|
||||
|
||||
@aiui.reply
|
||||
async def on_message(message: str):
|
||||
"""Handle realtime interactions (text-based for now)."""
|
||||
"""Handle realtime interactions via voice or text."""
|
||||
session_id = getattr(aiui.current_session, 'id', 'default')
|
||||
|
||||
await aiui.think("🎤 Processing realtime request...")
|
||||
@@ -69,20 +71,16 @@ async def on_message(message: str):
|
||||
|
||||
agent = _realtime_cache[session_id]
|
||||
|
||||
# For now, process as text (voice coming in future PraisonAIUI release)
|
||||
# Process the message with the agent
|
||||
result = await agent.achat(str(message))
|
||||
|
||||
# Stream response (simulating voice output)
|
||||
# Stream response naturally (aiui handles voice output via WebRTC)
|
||||
response_text = str(result) if result else "I'm sorry, I couldn't process that."
|
||||
|
||||
await aiui.say("🔊 **Voice Output Simulation:**\n")
|
||||
|
||||
# Stream more slowly to simulate speech
|
||||
# Stream tokens for smooth output
|
||||
words = response_text.split(" ")
|
||||
for i, word in enumerate(words):
|
||||
await aiui.stream_token(word + (" " if i < len(words) - 1 else ""))
|
||||
|
||||
await aiui.say("\n\n*Note: Actual voice I/O will be available when PraisonAIUI WebRTC feature lands.*")
|
||||
|
||||
except Exception as e:
|
||||
await aiui.say(f"❌ Realtime Error: {e}")
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import os
|
||||
import chainlit as cl
|
||||
import google.generativeai as genai
|
||||
from typing import List, Optional, Dict, Any, Union, Literal, Type
|
||||
from pydantic import BaseModel
|
||||
import json
|
||||
|
||||
# Configure the Gemini API
|
||||
genai.configure(api_key=os.environ["GEMINI_API_KEY"])
|
||||
|
||||
# Define generation configuration
|
||||
generation_config = {
|
||||
"temperature": 1,
|
||||
"top_p": 0.95,
|
||||
"top_k": 40,
|
||||
"max_output_tokens": 8192,
|
||||
"response_mime_type": "text/plain",
|
||||
}
|
||||
|
||||
def read_system_instructions():
|
||||
try:
|
||||
with open("/Users/praison/praisonai-package/.cursorrules", "r") as file:
|
||||
return file.read()
|
||||
except Exception as e:
|
||||
print(f"Error reading .cursorrules file: {e}")
|
||||
return ""
|
||||
|
||||
def read_current_file():
|
||||
try:
|
||||
with open("/Users/praison/praisonai-package/praisonai/test.py", "r") as file:
|
||||
return file.read()
|
||||
except Exception as e:
|
||||
print(f"Error reading test.py file: {e}")
|
||||
return ""
|
||||
|
||||
# Create the GenerativeModel instance with system instructions
|
||||
model = genai.GenerativeModel(
|
||||
model_name="gemini-2.0-flash-exp",
|
||||
generation_config=generation_config,
|
||||
system_instruction=read_system_instructions(),
|
||||
)
|
||||
|
||||
@cl.on_chat_start
|
||||
async def start():
|
||||
# Initialize chat session
|
||||
chat = model.start_chat(history=[])
|
||||
|
||||
# Store chat in user session
|
||||
cl.user_session.set("chat", chat)
|
||||
|
||||
@cl.on_message
|
||||
async def main(message: str):
|
||||
# Retrieve chat session
|
||||
chat = cl.user_session.get("chat")
|
||||
|
||||
# Append current file content to user message
|
||||
file_content = read_current_file()
|
||||
full_message = f"""
|
||||
Current file:
|
||||
{file_content}
|
||||
|
||||
User message:
|
||||
{message}
|
||||
"""
|
||||
|
||||
# Send message and get streaming response
|
||||
response = chat.send_message(full_message, stream=True)
|
||||
|
||||
# Create message placeholder for streaming
|
||||
msg = cl.Message(content="")
|
||||
|
||||
# Stream the response token by token
|
||||
for chunk in response:
|
||||
await msg.stream_token(chunk.text)
|
||||
|
||||
# Send final message
|
||||
await msg.send()
|
||||
|
||||
@cl.on_chat_end
|
||||
def end():
|
||||
print("Chat ended")
|
||||
|
||||
# Error handling for missing files
|
||||
@cl.on_stop
|
||||
def on_stop():
|
||||
print("Stopped by user")
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Verify system files exist
|
||||
if not os.path.exists("/Users/praison/praisonai-package/.cursorrules"):
|
||||
print("Warning: .cursorrules file not found")
|
||||
if not os.path.exists("/Users/praison/praisonai-package/praisonai/test.py"):
|
||||
print("Warning: test.py file not found")
|
||||
@@ -1,285 +0,0 @@
|
||||
"""
|
||||
Unit tests for the Chainlit compatibility shim.
|
||||
|
||||
Tests ensure forward/backward compatibility with Chainlit versions
|
||||
that may have different locations for EXPIRY_TIME and BaseStorageClient.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestChainlitCompat:
|
||||
"""Tests for praisonai.ui.chainlit_compat module."""
|
||||
|
||||
def test_get_expiry_seconds_returns_int(self):
|
||||
"""Test that get_expiry_seconds returns an integer."""
|
||||
from praisonai.ui.chainlit_compat import get_expiry_seconds
|
||||
result = get_expiry_seconds()
|
||||
assert isinstance(result, int)
|
||||
assert result > 0
|
||||
|
||||
def test_get_expiry_seconds_default_value(self):
|
||||
"""Test that default expiry is 3600 seconds (1 hour)."""
|
||||
from praisonai.ui.chainlit_compat import DEFAULT_EXPIRY_SECONDS
|
||||
assert DEFAULT_EXPIRY_SECONDS == 3600
|
||||
|
||||
def test_expiry_time_alias_exists(self):
|
||||
"""Test that EXPIRY_TIME is exported for backward compatibility."""
|
||||
from praisonai.ui.chainlit_compat import EXPIRY_TIME
|
||||
assert isinstance(EXPIRY_TIME, int)
|
||||
assert EXPIRY_TIME > 0
|
||||
|
||||
def test_get_expiry_seconds_env_override(self):
|
||||
"""Test that STORAGE_EXPIRY_TIME env var overrides default."""
|
||||
with patch.dict(os.environ, {"STORAGE_EXPIRY_TIME": "7200"}):
|
||||
# Need to reimport to pick up env change
|
||||
from praisonai.ui import chainlit_compat
|
||||
import importlib
|
||||
importlib.reload(chainlit_compat)
|
||||
result = chainlit_compat.get_expiry_seconds()
|
||||
assert result == 7200
|
||||
|
||||
def test_get_expiry_seconds_invalid_env_fallback(self):
|
||||
"""Test that invalid STORAGE_EXPIRY_TIME falls back to default."""
|
||||
with patch.dict(os.environ, {"STORAGE_EXPIRY_TIME": "invalid"}):
|
||||
from praisonai.ui import chainlit_compat
|
||||
import importlib
|
||||
importlib.reload(chainlit_compat)
|
||||
result = chainlit_compat.get_expiry_seconds()
|
||||
# Should fall back to chainlit value or default
|
||||
assert isinstance(result, int)
|
||||
assert result > 0
|
||||
|
||||
def test_base_storage_client_import(self):
|
||||
"""Test that BaseStorageClient can be imported."""
|
||||
from praisonai.ui.chainlit_compat import BaseStorageClient
|
||||
# May be None if chainlit not installed, or a class if installed
|
||||
if BaseStorageClient is not None:
|
||||
assert hasattr(BaseStorageClient, '__mro__')
|
||||
|
||||
def test_get_base_storage_client_function(self):
|
||||
"""Test get_base_storage_client returns class or None."""
|
||||
from praisonai.ui.chainlit_compat import get_base_storage_client
|
||||
result = get_base_storage_client()
|
||||
# Should be None or a class
|
||||
assert result is None or hasattr(result, '__mro__')
|
||||
|
||||
def test_module_exports_all(self):
|
||||
"""Test that __all__ contains expected exports."""
|
||||
from praisonai.ui import chainlit_compat
|
||||
expected = [
|
||||
'get_expiry_seconds',
|
||||
'get_base_storage_client',
|
||||
'get_base_data_layer',
|
||||
'base_data_layer_has_close',
|
||||
'EXPIRY_TIME',
|
||||
'BaseStorageClient',
|
||||
'DEFAULT_EXPIRY_SECONDS',
|
||||
]
|
||||
for name in expected:
|
||||
assert name in chainlit_compat.__all__
|
||||
|
||||
def test_get_base_data_layer_function(self):
|
||||
"""Test get_base_data_layer returns class or None."""
|
||||
from praisonai.ui.chainlit_compat import get_base_data_layer
|
||||
result = get_base_data_layer()
|
||||
# Should be None or a class
|
||||
assert result is None or hasattr(result, '__mro__')
|
||||
|
||||
def test_base_data_layer_has_close_function(self):
|
||||
"""Test base_data_layer_has_close returns bool."""
|
||||
from praisonai.ui.chainlit_compat import base_data_layer_has_close
|
||||
result = base_data_layer_has_close()
|
||||
assert isinstance(result, bool)
|
||||
|
||||
|
||||
class TestChainlitCompatWithMocking:
|
||||
"""Tests with mocked chainlit imports to simulate version differences."""
|
||||
|
||||
def test_fallback_when_storage_expiry_time_not_found(self):
|
||||
"""Test fallback when storage_expiry_time doesn't exist in chainlit."""
|
||||
# This tests the try/except fallback logic
|
||||
from praisonai.ui.chainlit_compat import get_expiry_seconds
|
||||
result = get_expiry_seconds()
|
||||
# Should not raise, should return a valid int
|
||||
assert isinstance(result, int)
|
||||
|
||||
def test_fallback_when_expiry_time_uppercase_not_found(self):
|
||||
"""Test fallback when EXPIRY_TIME (uppercase) doesn't exist."""
|
||||
from praisonai.ui.chainlit_compat import get_expiry_seconds
|
||||
result = get_expiry_seconds()
|
||||
assert isinstance(result, int)
|
||||
|
||||
|
||||
class TestSQLAlchemyDataLayerCompat:
|
||||
"""Tests for SQLAlchemyDataLayer compatibility with Chainlit 2.9.4."""
|
||||
|
||||
def test_sql_alchemy_imports_from_compat_shim(self):
|
||||
"""Test that sql_alchemy.py imports from chainlit_compat."""
|
||||
# Read the file directly to check imports without executing it
|
||||
import os
|
||||
sql_alchemy_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
'..', '..', 'praisonai', 'ui', 'sql_alchemy.py'
|
||||
)
|
||||
with open(sql_alchemy_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Should import from chainlit_compat, not directly from chainlit
|
||||
assert 'from praisonai.ui.chainlit_compat import' in content
|
||||
# Should NOT have direct EXPIRY_TIME import from chainlit
|
||||
assert 'from chainlit.data.storage_clients.base import EXPIRY_TIME' not in content
|
||||
|
||||
def test_sql_alchemy_has_close_method(self):
|
||||
"""Test that SQLAlchemyDataLayer has close method for Chainlit 2.9.4."""
|
||||
# Import the module to check the class
|
||||
import sys
|
||||
# Add UI path for local imports
|
||||
ui_path = '/Users/praison/praisonai-package/src/praisonai/praisonai/ui'
|
||||
if ui_path not in sys.path:
|
||||
sys.path.insert(0, ui_path)
|
||||
|
||||
try:
|
||||
from praisonai.ui.sql_alchemy import SQLAlchemyDataLayer
|
||||
# Check that close method exists
|
||||
assert hasattr(SQLAlchemyDataLayer, 'close')
|
||||
# Check it's an async method
|
||||
import inspect
|
||||
assert inspect.iscoroutinefunction(SQLAlchemyDataLayer.close)
|
||||
except ImportError:
|
||||
# If chainlit not installed, skip
|
||||
pytest.skip("Chainlit not installed")
|
||||
|
||||
|
||||
class TestCLILazyImports:
|
||||
"""Tests to ensure CLI doesn't import chainlit at startup."""
|
||||
|
||||
def test_praisonai_import_no_chainlit(self):
|
||||
"""Test that importing praisonai doesn't import chainlit."""
|
||||
# Clear any cached chainlit imports
|
||||
chainlit_modules = [k for k in sys.modules.keys() if 'chainlit' in k.lower()]
|
||||
for mod in chainlit_modules:
|
||||
del sys.modules[mod]
|
||||
|
||||
# Import praisonai
|
||||
import praisonai
|
||||
|
||||
# chainlit should not be in sys.modules yet
|
||||
# (unless it was imported by something else)
|
||||
# We just verify the import doesn't crash
|
||||
assert praisonai is not None
|
||||
|
||||
def test_praisonai_cli_import_no_crash(self):
|
||||
"""Test that importing praisonai.cli.main doesn't crash."""
|
||||
import praisonai.cli.main
|
||||
assert praisonai.cli.main is not None
|
||||
|
||||
|
||||
class TestChatPyLogLevel:
|
||||
"""Tests for chat.py LOGLEVEL handling."""
|
||||
|
||||
def test_empty_loglevel_handled(self):
|
||||
"""Test that empty LOGLEVEL string doesn't crash."""
|
||||
# The fix: log_level = os.getenv("LOGLEVEL", "INFO").upper() or "INFO"
|
||||
# This ensures empty string falls back to INFO
|
||||
with patch.dict(os.environ, {"LOGLEVEL": ""}):
|
||||
log_level = os.getenv("LOGLEVEL", "INFO").upper() or "INFO"
|
||||
assert log_level == "INFO"
|
||||
|
||||
def test_valid_loglevel_preserved(self):
|
||||
"""Test that valid LOGLEVEL is preserved."""
|
||||
with patch.dict(os.environ, {"LOGLEVEL": "DEBUG"}):
|
||||
log_level = os.getenv("LOGLEVEL", "INFO").upper() or "INFO"
|
||||
assert log_level == "DEBUG"
|
||||
|
||||
|
||||
class TestLocalFileStorageClient:
|
||||
"""Tests for LocalFileStorageClient."""
|
||||
|
||||
def test_local_storage_client_creation(self):
|
||||
"""Test that LocalFileStorageClient can be created."""
|
||||
from praisonai.ui.chainlit_compat import LocalFileStorageClient
|
||||
client = LocalFileStorageClient(storage_dir="/tmp/test_storage")
|
||||
assert client is not None
|
||||
assert client.storage_dir == "/tmp/test_storage"
|
||||
|
||||
def test_create_local_storage_client_function(self):
|
||||
"""Test create_local_storage_client helper function."""
|
||||
from praisonai.ui.chainlit_compat import create_local_storage_client
|
||||
client = create_local_storage_client(storage_dir="/tmp/test_storage2")
|
||||
assert client is not None
|
||||
|
||||
def test_local_storage_client_default_dir(self):
|
||||
"""Test LocalFileStorageClient uses default directory."""
|
||||
from praisonai.ui.chainlit_compat import LocalFileStorageClient
|
||||
with patch.dict(os.environ, {"CHAINLIT_APP_ROOT": "/tmp/test_chainlit"}):
|
||||
client = LocalFileStorageClient()
|
||||
assert ".files" in client.storage_dir
|
||||
|
||||
|
||||
class TestToolsLoading:
|
||||
"""Tests for custom tools loading."""
|
||||
|
||||
def test_tools_loading_no_file_no_warning(self):
|
||||
"""Test that no warning is shown when no tools.py exists."""
|
||||
# The fix ensures no warning when tools.py doesn't exist
|
||||
# This is tested by the fact that praisonai chat runs without tools warning
|
||||
pass
|
||||
|
||||
def test_praisonai_tools_path_env_var(self):
|
||||
"""Test PRAISONAI_TOOLS_PATH environment variable is respected."""
|
||||
# This tests the resolution order in load_custom_tools
|
||||
assert os.getenv("PRAISONAI_TOOLS_PATH") is None or True
|
||||
|
||||
|
||||
class TestAuthDefaults:
|
||||
"""Tests for authentication defaults."""
|
||||
|
||||
def test_default_credentials_warning(self):
|
||||
"""Test that default credentials trigger a warning."""
|
||||
# The warning is: "Using default admin credentials..."
|
||||
# This is verified by running praisonai chat and seeing the warning
|
||||
pass
|
||||
|
||||
def test_chainlit_username_env_var(self):
|
||||
"""Test CHAINLIT_USERNAME environment variable."""
|
||||
with patch.dict(os.environ, {"CHAINLIT_USERNAME": "testuser"}):
|
||||
assert os.getenv("CHAINLIT_USERNAME") == "testuser"
|
||||
|
||||
def test_chainlit_password_env_var(self):
|
||||
"""Test CHAINLIT_PASSWORD environment variable."""
|
||||
with patch.dict(os.environ, {"CHAINLIT_PASSWORD": "testpass"}):
|
||||
assert os.getenv("CHAINLIT_PASSWORD") == "testpass"
|
||||
|
||||
|
||||
class TestDBPersistence:
|
||||
"""Tests for database persistence."""
|
||||
|
||||
def test_sqlite_database_exists(self):
|
||||
"""Test that SQLite database file exists."""
|
||||
db_path = os.path.expanduser("~/.praison/database.sqlite")
|
||||
# Database should exist after running praisonai chat
|
||||
if os.path.exists(db_path):
|
||||
assert os.path.isfile(db_path)
|
||||
|
||||
def test_sqlite_tables_exist(self):
|
||||
"""Test that required tables exist in SQLite database."""
|
||||
import sqlite3
|
||||
db_path = os.path.expanduser("~/.praison/database.sqlite")
|
||||
if os.path.exists(db_path):
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = [t[0] for t in cursor.fetchall()]
|
||||
conn.close()
|
||||
|
||||
expected_tables = ['users', 'threads', 'steps', 'elements', 'feedbacks', 'settings']
|
||||
for table in expected_tables:
|
||||
assert table in tables, f"Table {table} not found"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in new issue
Block a user