Briefly / controllers /chat_handler.py
Ayush456's picture
fix(agent): resolve Slack MCP connection and improve tool error handling
1498ba7
Raw
History Blame Contribute Delete
21 kB
from controllers.mongo import users_collection
from controllers.mongo import summary_collection
from bson import ObjectId
from datetime import datetime, timezone
from typing import Optional
import os
import asyncio
from groq import Groq
from fastapi import HTTPException
from fastapi import Request
from controllers.memory_handler import get_user_memories , get_memory_enabled
from agent.agent_graph import agent_graph
from fastapi import HTTPException
import traceback
import os
import json
from redis_client import redis_client
from groq import Groq
import hashlib
client = Groq(api_key=os.getenv("groq_api_key"))
def get_or_create_chat(chat_id, user_id):
if chat_id and ObjectId.is_valid(chat_id):
chat = summary_collection.find_one({"_id": ObjectId(chat_id), "user_id": user_id})
if chat:
return chat, chat["_id"], False
doc = {
"user_id": user_id,
"queries": [],
"timestamp": datetime.now(timezone.utc),
"title": "New Chat",
"thought": "",
"is_pinned":False
}
res = summary_collection.insert_one(doc)
doc["_id"] = res.inserted_id
return doc, res.inserted_id, True
def search_user_chats(user_id: str, search_term: str):
"""
Searches across all chats for a user and returns the specific
sessions and exact messages that match the search term.
"""
try:
pipeline = [
{"$match": {"user_id": user_id}},
{"$project": {
"_id": 1,
"title": 1,
"timestamp": 1,
"matching_messages": {
"$filter": {
"input": "$queries",
"as": "msg",
"cond": {
"$regexMatch": {
"input": {"$ifNull": ["$$msg.content", ""]},
"regex": search_term,
"options": "i"
}
}
}
}
}},
{"$match": {
"matching_messages": {"$ne": []}
}},
{"$sort": {"timestamp": -1}}
]
cursor = summary_collection.aggregate(pipeline)
results = []
for doc in cursor:
results.append({
"id": str(doc["_id"]),
"title": doc.get("title", "Untitled Chat"),
"timestamp": doc.get("timestamp"),
"messages": doc.get("matching_messages", [])
})
return results
except Exception as e:
print(f"Search Error: {e}")
raise HTTPException(status_code=500, detail="Error searching chats")
def build_messages(chat, user_input, memory_context, user_id=None):
messages = [
(
"system",
f"""You are Briefly AI, an intelligent, helpful, and versatile AI assistant.
You provide clear, concise, and accurate answers.
CURRENT USER ID: {user_id}
{memory_context}
"""
)
]
for q in chat.get("queries", []):
role = "human" if q["sender"] == "user" else "assistant"
messages.append((role, q["content"]))
messages.append(("human", user_input))
return messages
def save_chat_turn(chat_id, user_input, assistant_text, files, sources, title=None, thinking_text=None):
now = datetime.now(timezone.utc)
llm_turn = {
"sender": "llm",
"content": assistant_text,
"sources": sources,
"created_at": now
}
if thinking_text:
llm_turn["thinking"] = thinking_text
payload = {
"$push": {
"queries": {
"$each": [
{"sender": "user", "content": user_input, "files": files, "created_at": now},
llm_turn
]
}
},
"$set": {"timestamp": datetime.now(timezone.utc)}
}
if title:
payload["$set"]["title"] = title
summary_collection.update_one({"_id": chat_id}, payload)
async def chat_stream(request: Request, user_input, user_id, chat_id=None, files=None, modal_name=None):
print(files)
chat_doc, chat_oid, is_new = get_or_create_chat(chat_id, user_id)
existing_memories = get_user_memories(user_id)
memory_context = ""
if existing_memories and get_memory_enabled(user_id):
memory_context = "Here is what you know about the user from past conversations:\n"
for mem in existing_memories:
memory_context += f"- {mem}\n"
messages = build_messages(chat_doc, user_input, memory_context, user_id)
available_apps = ["notion","google_drive","linear","slack","n8n"]
initial_state = {
"messages": messages,
"modal_name": modal_name,
"available_apps": available_apps,
"selected_apps": [],
"blocked": False,
"is_new_chat": is_new,
"user_input": user_input,
"user_id": user_id,
"files": files
}
try:
assistant_text = ""
thinking_text = ""
final_state = {}
try:
async for event in agent_graph.astream_events(initial_state, version="v2"):
if await request.is_disconnected():
print("🚨 Client disconnected! Halting LangGraph.")
break
kind = event["event"]
name = event.get("name","")
if kind == "on_chain_start" and name == "file_processor":
if files:
msg = "📄 Analyzing attached files...\n"
thinking_text += msg
yield f"data: {json.dumps({'type': 'analyzing', 'data': msg})}\n\n"
elif kind == "on_chain_end" and name == "file_processor":
if files:
msg = "✅ File extraction complete.\n\n"
thinking_text += msg
yield f"data: {json.dumps({'type': 'analyzing', 'data': msg})}\n\n"
elif kind == "on_chat_model_stream":
chunk = event["data"]["chunk"]
if hasattr(chunk, "additional_kwargs"):
reasoning = chunk.additional_kwargs.get("reasoning_content")
if reasoning:
thinking_text += reasoning
yield f"data: {json.dumps({'type': 'thinking', 'data': reasoning})}\n\n"
await asyncio.sleep(0.01)
if hasattr(chunk, "content") and chunk.content:
assistant_text += chunk.content
yield f"data: {json.dumps({'type': 'token', 'data': chunk.content})}\n\n"
await asyncio.sleep(0.03)
elif kind == "on_tool_start":
yield f"data: {json.dumps({'type': 'tool_status', 'tool': event['name'], 'status': 'running'})}\n\n"
elif kind == "on_tool_end":
tool_name = event['name']
yield f"data: {json.dumps({'type': 'tool_status', 'tool': event['name'], 'status': 'completed'})}\n\n"
if tool_name == "n8n_create_workflow":
try:
msg = event['data'].get('output')
if msg and hasattr(msg, 'content'):
raw_text = msg.content[0].get("text", "{}") if isinstance(msg.content, list) else msg.content
data = json.loads(raw_text).get("data", {})
if wf_id := data.get("id"):
users_collection.update_one(
{"_id": ObjectId(user_id)},
{"$push": {"n8n_workflows": {"id": wf_id, "name": data.get("name", "AI Automation")}}}
)
print(f"✅ GUARANTEED SAVE: Workflow {wf_id} saved!")
try:
redis_client.delete(f"user_workflows:{user_id}")
except Exception as e:
print(f"⚠️ Redis cache clear failed: {e}")
except Exception as e:
print(f"⚠️ Auto-save failed: {e}")
elif kind == "on_chain_end":
output = event["data"]["output"]
if isinstance(output, dict):
final_state.update(output)
except asyncio.CancelledError:
print("🚨 Stream forcefully cancelled by client (CancelledError). Proceeding to save.")
if not final_state:
final_state = {}
uploaded_files = final_state.get("uploaded_files", [])
sources = final_state.get("sources", [])
title = final_state.get("title")
if not thinking_text.strip():
thinking_text = None
print(f"💾 Saving chat turn. AI words generated: {len(assistant_text.split())}")
save_chat_turn(
chat_id=chat_oid,
user_input=user_input,
assistant_text=assistant_text,
files=uploaded_files,
sources=sources,
title=title,
thinking_text=thinking_text
)
if not await request.is_disconnected():
yield f"data: {json.dumps({'type': 'done', 'id': str(chat_oid), 'title': title, 'sources': sources})}\n\n"
except Exception:
traceback.print_exc()
if not await request.is_disconnected():
yield f"data: {json.dumps({'type': 'error'})}\n\n"
except Exception:
traceback.print_exc()
yield f"data: {json.dumps({'type': 'error'})}\n\n"
def get_chat_history(
id: str,
limit: int = 50,
before: Optional[datetime] = None
):
try:
if ObjectId.is_valid(id):
query = {"_id": ObjectId(id)}
else:
query = {"id": id}
if before is None:
result = summary_collection.find_one(
query,
{"queries": {"$slice": -limit}, "_id": 0}
)
else:
pipeline = [
{"$match": query},
{"$project": {
"queries": {
"$slice": [
{
"$filter": {
"input": "$queries",
"as": "chat",
"cond": {"$lt": ["$$chat.created_at", before]}
}
},
-limit
]
}
}}
]
result_list = list(summary_collection.aggregate(pipeline))
result = result_list[0] if result_list else None
return result.get("queries", []) if result else []
except Exception as e:
print(f"Error fetching history: {e}")
raise e
def get_chats_by_user(user_id: str, skip: int = 0, limit: int = 10):
summaries = list(
summary_collection.find(
{"user_id": user_id},
{"_id": 1, "title": 1, "timestamp": 1, "queries": 1, "is_pinned": 1},
)
.sort([("is_pinned", -1), ("timestamp", -1)])
.skip(skip)
.limit(limit)
)
result = []
for summary in summaries:
result.append({
"id": str(summary["_id"]),
"title": summary.get("title", "Untitled Chat"),
"timestamp": summary.get("timestamp"),
"queries": len(summary.get("queries", [])),
"is_pinned": summary.get("is_pinned", False)
})
return result
async def delete_summary_by_id(chat_id: str, user_id: str):
"""Handles the database logic for deleting a chat summary."""
try:
result = summary_collection.delete_one({
"_id": ObjectId(chat_id),
"user_id": user_id
})
if result.deleted_count == 0:
raise HTTPException(status_code=404, detail="Chat not found or unauthorized")
return {"status": "success", "message": "Chat deleted permanently"}
except Exception as e:
print(f"Delete Error: {e}")
if isinstance(e, HTTPException):
raise e
raise HTTPException(status_code=500, detail="Internal Server Error")
async def toggle_chat_pin(chat_id: str, user_id: str, is_pinned: bool):
"""Handles the database logic for pinning/unpinning a chat."""
try:
result = summary_collection.update_one(
{
"_id": ObjectId(chat_id),
"user_id": user_id
},
{"$set": {"is_pinned": is_pinned}}
)
if result.matched_count == 0:
raise HTTPException(status_code=404, detail="Chat not found or unauthorized")
return {"status": "success", "is_pinned": is_pinned}
except Exception as e:
print(f"Pin Error: {e}")
if isinstance(e, HTTPException):
raise e
raise HTTPException(status_code=500, detail="Internal Server Error")
def get_cache_key(text: str, voice: str):
raw = f"{voice}:{text}".encode()
return "tts:" + hashlib.sha256(raw).hexdigest()
def generate_audio_from_text(text: str, voice: str = "troy") -> bytes:
"""
Generates audio from text using Groq TTS.
Uses Redis caching to avoid regenerating audio.
"""
cache_key = get_cache_key(text, voice)
try:
cached_audio = redis_client.get(cache_key)
if cached_audio:
return cached_audio
response = client.audio.speech.create(
model="canopylabs/orpheus-v1-english",
voice=voice,
input=text,
response_format="wav"
)
audio_bytes = response.read()
redis_client.setex(cache_key, 86400, audio_bytes)
return audio_bytes
except Exception as e:
print(e)
raise HTTPException(status_code=500, detail=f"Groq API Error: {str(e)}")
async def regenerate_chat_stream(request:Request, chat_id: str, user_id: str, target_index: int, modal_name: Optional[str] = None):
try:
if not ObjectId.is_valid(chat_id):
yield f"data: {json.dumps({'type': 'error', 'message': 'Invalid chat ID'})}\n\n"
return
chat = summary_collection.find_one({"_id": ObjectId(chat_id), "user_id": user_id})
if not chat or not chat.get("queries"):
yield f"data: {json.dumps({'type': 'error', 'message': 'Chat not found or is empty'})}\n\n"
return
queries = chat["queries"]
user_msg_index = target_index - 1
if target_index >= len(queries) or user_msg_index < 0:
yield f"data: {json.dumps({'type': 'error', 'message': 'Invalid index'})}\n\n"
return
last_user_query = queries[user_msg_index]
if last_user_query.get("sender") != "user":
yield f"data: {json.dumps({'type': 'error', 'message': 'Target is not a user message'})}\n\n"
return
truncated_queries = queries[:user_msg_index]
summary_collection.update_one(
{"_id": ObjectId(chat_id), "user_id": user_id},
{"$set": {"queries": truncated_queries}}
)
user_input = last_user_query.get("content", "")
files = last_user_query.get("files", [])
async for event in chat_stream(
request=request,
user_input=user_input,
user_id=user_id,
chat_id=chat_id,
files=files,
modal_name=modal_name
):
yield event
except Exception as e:
import traceback
traceback.print_exc()
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
async def edit_chat_stream(request:Request, chat_id: str, user_id: str, target_index: int, new_content: str, modal_name: Optional[str] = None):
try:
chat = summary_collection.find_one({"_id": ObjectId(chat_id), "user_id": user_id})
if not chat or "queries" not in chat:
yield f"data: {json.dumps({'type': 'error', 'message': 'Chat not found'})}\n\n"
return
queries = chat["queries"]
if target_index >= len(queries) or target_index < 0:
yield f"data: {json.dumps({'type': 'error', 'message': 'Invalid index'})}\n\n"
return
if queries[target_index]["sender"] != "user":
yield f"data: {json.dumps({'type': 'error', 'message': 'Target index is not a user message'})}\n\n"
return
old_files = queries[target_index].get("files", [])
truncated_queries = queries[:target_index]
summary_collection.update_one(
{"_id": ObjectId(chat_id), "user_id": user_id},
{"$set": {"queries": truncated_queries}}
)
async for event in chat_stream(
request=request,
user_input=new_content,
user_id=user_id,
chat_id=chat_id,
files=old_files,
modal_name=modal_name
):
yield event
except Exception as e:
import traceback
traceback.print_exc()
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
async def private_chat_stream(request, user_input, files=None, modal_name=None, chat_history=None):
"""
Streams a truly stateless chat response.
No database tracking, no personal tool access (Notion, Drive, etc.).
"""
mock_chat_doc = {"queries": chat_history if chat_history else []}
messages = build_messages(
chat=mock_chat_doc,
user_input=user_input,
memory_context="",
user_id="guest"
)
initial_state = {
"messages": messages,
"modal_name": modal_name,
"available_apps": ["n8n"],
"selected_apps": [],
"blocked": False,
"is_new_chat": False,
"user_input": user_input,
"user_id": "guest",
"files": files
}
thinking_text = ""
try:
final_state = {}
async for event in agent_graph.astream_events(initial_state, version="v2"):
if await request.is_disconnected():
print("Client disconnected. Aborting stream.")
break
kind = event["event"]
name = event.get("name","")
if kind == "on_chain_start" and name == "file_processor":
if files:
msg = "📄 Analyzing attached files...\n"
thinking_text += msg
yield f"data: {json.dumps({'type': 'analyzing', 'data': msg})}\n\n"
elif kind == "on_chain_end" and name == "file_processor":
if files:
msg = "✅ File extraction complete.\n\n"
thinking_text += msg
yield f"data: {json.dumps({'type': 'analyzing', 'data': msg})}\n\n"
elif kind == "on_chat_model_stream":
chunk = event["data"]["chunk"]
if hasattr(chunk, "additional_kwargs"):
reasoning = chunk.additional_kwargs.get("reasoning_content")
if reasoning:
yield f"data: {json.dumps({'type': 'thinking', 'data': reasoning})}\n\n"
if hasattr(chunk, "content") and chunk.content:
yield f"data: {json.dumps({'type': 'token', 'data': chunk.content})}\n\n"
elif kind == "on_tool_start":
yield f"data: {json.dumps({'type': 'tool_status', 'tool': event['name'], 'status': 'running'})}\n\n"
elif kind == "on_tool_end":
yield f"data: {json.dumps({'type': 'tool_status', 'tool': event['name'], 'status': 'completed'})}\n\n"
elif kind == "on_chain_end":
output = event["data"]["output"]
if isinstance(output, dict):
final_state.update(output)
sources = final_state.get("sources", [])
yield f"data: {json.dumps({'type': 'done', 'id': 'private', 'sources': sources})}\n\n"
except Exception as e:
import traceback
traceback.print_exc()
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"