| 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" |