diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index d50385ce6..d7630ba04 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -49,6 +49,15 @@ jobs: python scripts/typesense_indexer.py --docs-path ./docs --blog-path ./blog --force python scripts/synonym_indexer.py + - name: Create Conversational RAG + env: + TYPESENSE_ADMIN_API_KEY: ${{ secrets.DEV_TYPESENSE_ADMIN_API_KEY }} + TYPESENSE_HOST: ${{ secrets.DEV_TYPESENSE_HOST }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + run: | + uv pip install typesense + python scripts/conversation_indexer.py --force + - name: Deploy to Reflex id: deploy run: | diff --git a/.github/workflows/deploy-prd.yml b/.github/workflows/deploy-prd.yml index 22da844e0..6e36df700 100644 --- a/.github/workflows/deploy-prd.yml +++ b/.github/workflows/deploy-prd.yml @@ -47,6 +47,15 @@ jobs: python scripts/typesense_indexer.py --docs-path ./docs --blog-path ./blog --force python scripts/synonym_indexer.py + - name: Create Conversational RAG + env: + TYPESENSE_ADMIN_API_KEY: ${{ secrets.PRD_TYPESENSE_ADMIN_API_KEY }} + TYPESENSE_HOST: ${{ secrets.PRD_TYPESENSE_HOST }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + run: | + uv pip install typesense + python scripts/conversation_indexer.py --force + - name: Deploy to Reflex id: deploy run: | diff --git a/.github/workflows/deploy-stg.yml b/.github/workflows/deploy-stg.yml index 06af9cb2e..677133b39 100644 --- a/.github/workflows/deploy-stg.yml +++ b/.github/workflows/deploy-stg.yml @@ -49,6 +49,15 @@ jobs: python scripts/typesense_indexer.py --docs-path ./docs --blog-path ./blog --force python scripts/synonym_indexer.py + - name: Create Conversational RAG + env: + TYPESENSE_ADMIN_API_KEY: ${{ secrets.STG_TYPESENSE_ADMIN_API_KEY }} + TYPESENSE_HOST: ${{ secrets.STG_TYPESENSE_HOST }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + run: | + uv pip install typesense + python scripts/conversation_indexer.py --force + - name: Deploy to Reflex id: deploy run: | diff --git a/pcweb/components/docpage/navbar/typesense.py b/pcweb/components/docpage/navbar/typesense.py index 6b72cfc39..c7b14de2a 100644 --- a/pcweb/components/docpage/navbar/typesense.py +++ b/pcweb/components/docpage/navbar/typesense.py @@ -4,6 +4,10 @@ import typesense from reflex.experimental import ClientStateVar +from .web_ai import Message, ConversationalSearch + +web_interface = ClientStateVar.create("web_interface", "search") +is_processing_prompt = ClientStateVar.create("is_processing_prompt", False) last_copied = ClientStateVar.create("is_copied", "") suggestion_items = [ @@ -387,9 +391,10 @@ def search_input(): size=14, class_name="absolute left-2 top-1/2 transform -translate-y-1/2 !text-gray-500/40", ), - rx.box( - filter_component(), - rx.link( + rx.cond( + web_interface.value == "search", + rx.box( + filter_component(), ui.button( ui.icon(icon="SparklesIcon", class_name="shrink-0 size-2"), "Ask AI", @@ -397,29 +402,79 @@ def search_input(): variant="secondary", size="xs", class_name="text-sm flex flex-row gap-x-2 items-center", - + on_click=web_interface.set_value("ai_chat"), ), - href="https://reflex.dev/docs/ai-builder/integrations/mcp-overview/" + ui.button( + "Esc", + size="xs", + type="button", + variant="outline", + on_click=rx.run_script( + "document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))" + ), + ), + class_name="hidden md:flex absolute right-2 top-1/2 transform -translate-y-1/2 text-sm flex-row items-center gap-x-2", ), - ui.button( - "Esc", - size="xs", - type="button", - variant="outline", - on_click=rx.run_script( - "document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))" + rx.box( + rx.el.div( + rx.el.p("← Back to search", class_name="text-xs text-slate-9 cursor-pointer", on_click=web_interface.set_value("search")), ), + rx.el.button( + rx.cond( + ConversationalSearch.is_loading, + rx.box( + class_name="flex size-3 bg-white rounded-[15px]", + ), + rx.icon( + tag="arrow-up", + size=13, + class_name=rx.cond( + ConversationalSearch.current_message.length() > 1, + "!text-white", + "", + ), + ), + ), + class_name=( + "p-2 rounded-md cursor-pointer disabled:cursor-not-allowed overflow-hidden " + + + rx.cond( + ConversationalSearch.is_loading, + "bg-violet-9", + rx.cond( + ConversationalSearch.current_message.length() > 1, + "bg-violet-9", + "bg-secondary-3", + ) + ) + ), + on_click=ConversationalSearch.send_message, + ), + class_name="hidden md:flex absolute right-2 top-1/2 transform -translate-y-1/2 text-sm flex-row items-center gap-x-2", ), - class_name="hidden md:flex absolute right-2 top-1/2 transform -translate-y-1/2 text-sm flex-row items-center gap-x-2", ), - rx.el.input( - on_change=[ - lambda value: SimpleSearch.user_query(value).debounce(500), - SimpleSearch.perform_search(), - ], - auto_focus=True, - placeholder="Search documentation ...", - class_name="py-2 pl-7 md:pr-[310px] w-full placeholder:text-sm text-sm rounded-lg outline-none focus:outline-none border border-secondary-a4 bg-secondary-1 text-secondary-12" + rx.cond( + web_interface.value == "search", + rx.el.input( + on_change=[ + lambda value: SimpleSearch.user_query(value).debounce(500), + SimpleSearch.perform_search(), + ], + auto_focus=True, + placeholder="Search documentation ...", + class_name="py-2 pl-7 md:pr-[310px] w-full placeholder:text-sm text-sm rounded-lg outline-none focus:outline-none border border-secondary-a4 bg-secondary-1 text-secondary-12" + ), + rx.form( + rx.el.input( + on_change=lambda value: ConversationalSearch.set_current_message(value), + auto_focus=True, + placeholder="Ask the AI about Reflex ...", + class_name="py-2 pl-7 md:pr-[100px] w-full placeholder:text-sm text-sm rounded-lg outline-none focus:outline-none border border-secondary-a4 bg-secondary-1 text-secondary-12 resize-none" + ), + enter_key_submit=True, + on_submit=ConversationalSearch.send_message, + reset_on_submit=True, + ), ), class_name="w-full relative focus:outline-none", ), @@ -545,7 +600,6 @@ def searching_in_progress(): class_name="w-full flex items-center justify-center text-sm py-4", ) - def search_content(): return rx.scroll_area( rx.cond( @@ -604,6 +658,104 @@ def search_content(): ) +@rx.memo +def markdown_copy_button( + content: str, +): + markdown_copy_state = ClientStateVar.create( + "content_id", default=False, global_ref=False + ) + + return rx.el.button( + rx.cond( + markdown_copy_state.value, + rx.icon(tag="check", size=13, class_name="!text-slate-9"), + rx.icon(tag="copy", size=13, class_name="!text-slate-9"), + ), + cursor="pointer", + position="absolute", + right="15px", + top="35px", + on_click=[ + rx.call_function(markdown_copy_state.set_value(True)), + rx.set_clipboard(content), + ], + on_mouse_down=rx.call_function(markdown_copy_state.set_value(False)).debounce( + 1500 + ), + ) + +def chat_message(message: Message): + return rx.cond( + message.role == "user", + rx.el.div( + rx.el.div( + rx.el.p(message.content, class_name="text-sm"), + class_name="bg-secondary-3 rounded-md p-2 max-w-xs break-words" + ), + class_name="flex justify-end" + ), + rx.el.div( + rx.el.div( + rx.markdown( + message.content, + component_map={ + "h1": lambda value: rx.el.h1(value), + "h2": lambda value: rx.el.h2(value), + "h3": lambda value: rx.el.h3(value), + "h4": lambda value: rx.el.h4(value), + "h5": lambda value: rx.el.h5(value), + "h6": lambda value: rx.el.h6(value), + "p": lambda value: rx.el.p(value, class_name="leading-7"), + "strong": lambda value: rx.el.strong( + value, class_name="text-secondary-12" + ), + "ul": lambda value: rx.el.ul(value), + "ol": lambda value: rx.el.ol(value), + "li": lambda value: rx.el.li(value), + "a": lambda value: rx.el.a( + value, + class_name="underline", + target="_blank", + ), + "pre": lambda value: rx.el.pre(value, class_name="not-prose"), + "codeblock": lambda value, **props: rx.el.div( + rx.code_block( + value, + **props, + wrap_long_lines=False, + class_name="code-block !text-xs max-h-[300px] overflow-auto", + ), + markdown_copy_button(content=value), + class_name="flex flex-row relative py-2", + ), + "code": lambda value, **props: rx.el.code( + value, + **props, + class_name="font-mono border border-slate-4 bg-slate-3 px-1 rounded-[0.35rem] font-normal not-prose text-sm text-[12.5px] text-slate-12", + ), + }, + ), + class_name="p-2 text-sm" + ), + class_name="flex justify-start" + ) + ) + + + +def chat_content(): + return rx.box( + rx.auto_scroll( + rx.foreach( + ConversationalSearch.messages, chat_message, + ), + class_name="h-[57vh] px-1 flex flex-col gap-y-2 [&_.rt-ScrollAreaScrollbar]:right-[0.2875rem] [&_.rt-ScrollAreaScrollbar]:mt-[3rem]" + ), + class_name="w-full h-full pt-12" + ), + + def typesense_search() -> rx.Component: """Create the main search component for Reflex Web""" return rx.fragment( @@ -611,7 +763,11 @@ def typesense_search() -> rx.Component: rx.dialog.trigger(search_trigger(), id="search-trigger"), rx.dialog.content( search_input(), - search_content(), + rx.cond( + web_interface.value == "search", + search_content(), + chat_content(), + ), on_interact_outside=SimpleSearch.reset_search, on_escape_key_down=SimpleSearch.reset_search, class_name="w-full max-w-[650px] mx-auto bg-secondary-1 border-none outline-none p-3 lg:!fixed lg:!top-24 lg:!left-1/2 lg:!transform lg:!-translate-x-1/2 lg:!translate-y-0 lg:!m-0 " diff --git a/pcweb/components/docpage/navbar/web_ai.py b/pcweb/components/docpage/navbar/web_ai.py new file mode 100644 index 000000000..e0db0703c --- /dev/null +++ b/pcweb/components/docpage/navbar/web_ai.py @@ -0,0 +1,222 @@ +import os +import time +import uuid +from typing import List, Dict, Any +import typesense +import reflex as rx + + +TYPESENSE_CONFIG = { + "nodes": [{"host": os.getenv("TYPESENSE_HOST"), "port": "443", "protocol": "https"}], + "api_key": os.getenv("TYPESENSE_SEARCH_API_KEY"), + "connection_timeout_seconds": 10, +} + + +class Message(rx.Base): + role: str # 'user' or 'assistant' + content: str + timestamp: float + message_id: str = "" + sources: List[Dict[str, Any]] = [] + +class ConversationalSearch(rx.State): + current_message: str = "" + messages: List[Message] = [] + conversation_id: str = "" + is_loading: bool = False + error_message: str = "" + + @rx.event(temporal=True) + def update_current_message(self, message: str): + self.current_message = message + + @rx.event(temporal=True) + def clear_error(self): + self.error_message = "" + + @rx.event(temporal=True) + def start_new_conversation(self): + """Start a new conversation""" + self.conversation_id = "" + self.messages = [] + self.current_message = "" + self.error_message = "" + + + def _get_snippet(self, document: Dict, highlights: List[Dict]) -> str: + """Extract snippet from highlights or content""" + if highlights: + for highlight in highlights: + if highlight.get("field") in ["content", "title"]: + snippets = highlight.get("snippets", []) + if snippets: + return snippets[0] + + content = document.get("content", "") + return content[:200] + "..." if len(content) > 200 else content + + def _extract_key_terms(self, query: str) -> str: + """Extract key technical terms from conversational queries for better search""" + import re + + stop_words = { + # Existing core + 'how', 'do', 'i', 'can', 'you', 'what', 'is', 'are', 'the', 'in', 'to', + 'use', 'with', 'for', 'a', 'an', 'and', 'or', 'but', 'of', 'on', 'at', + 'by', 'this', 'that', 'it', 'from', 'they', 'be', 'been', 'have', 'has', + 'had', 'will', 'would', 'could', 'should', 'may', 'might', 'must', + 'does', 'did', 'was', 'were', 'am', 'about', 'help', 'me', 'please', + 'get', 'make', 'create', 'build', 'add', 'set', 'up', 'work', 'works', 'reflex', + 'tell', + + # Additional common stopwords + 'as', 'if', 'then', 'than', 'so', 'because', 'also', 'just', 'now', 'only', + 'really', 'very', 'even', 'still', 'some', 'any', 'more', 'most', 'much', + 'into', 'out', 'over', 'under', 'again', 'there', 'here', 'when', 'where', + 'why', 'who', 'whom', 'whose', 'which', 'while', 'too', + + # Pronouns and variations + 'my', 'mine', 'your', 'yours', 'he', 'him', 'his', 'she', 'her', 'hers', + 'we', 'us', 'our', 'ours', 'their', 'them', 'theirs', 'itself', 'yourself', + 'ourselves', 'themselves', + + # Contractions and negations + "i'm", "i've", "i'd", "i'll", + "you're", "you've", "you'd", "you'll", + "he's", "he'd", "he'll", + "she's", "she'd", "she'll", + "it's", "it'd", "it'll", + "we're", "we've", "we'd", "we'll", + "they're", "they've", "they'd", "they'll", + "isn't", "aren't", "wasn't", "weren't", + "hasn't", "haven't", "hadn't", + "doesn't", "don't", "didn't", + "won't", "wouldn't", "can't", "couldn't", "shouldn't", "mustn't", "shan't", + "ok", "okay", "yeah", "yes", "no", "uh", "um", "like", + "image", "photo", "picture", "draw", "drawing", "show", "generate", "render", "display" + } + + cleaned = re.sub(r'[^\w\s-]', ' ', query.lower()) + words = re.findall(r'\b\w+\b', cleaned) + + key_terms = [] + for word in words: + if word not in stop_words and len(word) > 2: + key_terms.append(word) + + seen = set() + unique_terms = [] + for term in key_terms: + if term not in seen: + seen.add(term) + unique_terms.append(term) + + if unique_terms: + result = ' '.join(unique_terms) + return result + + fallback = re.sub(r'^(how do i|how to|what is|can i|how can i)\s+', '', query.lower()) + return fallback.strip() or query + + @rx.event(background=True, temporal=True) + async def send_message(self): + """Send a message and get AI response using Typesense conversational search""" + if not self.current_message.strip(): + return + + user_message = Message( + role="user", + content=self.current_message.strip(), + timestamp=time.time(), + message_id=str(uuid.uuid4()) + ) + + async with self: + self.messages.append(user_message) + self.is_loading = True + self.error_message = "" + original_query = self.current_message + self.current_message = "" + yield + + try: + client = typesense.Client(TYPESENSE_CONFIG) + + search_query = self._extract_key_terms(original_query) + + # search_params = { + # "q": original_query, # Use processed query for search + # "query_by": "title,content,headings,components", + # "query_by_weights": "6,8,3,12", + # "per_page": 15, + # "num_typos": 2, + # "sort_by": "_text_match:desc", + # "text_match_threshold": "0.6", + # "exhaustive_search": True, + # "highlight_fields": "content", + # "highlight_full_fields": "content,components", + # "highlight_start_tag": "", + # "highlight_end_tag": "", + # "snippet_threshold": 30, + # "filter_by": "url:!~blog", + # "conversation": "true", + # "conversation_model_id": "reflex-docs-convo", + # "exclude_fields": "hits.document.content" + # } + + # result = client.collections["docs"].documents.search(search_params) + + result = client.collections["docs"].documents.search({ + "q": search_query, + "query_by": "title,content", + "num_typos": 2, + "conversation": True, + "conversation_model_id": "reflex-docs-convo", + "exclude_fields": "hits.document.content" + }) + + conversation = result.get("conversation", {}) + ai_response = conversation.get("answer", "") + + + if not ai_response: + ai_response = "I couldn't generate a response. Please try rephrasing your question." + + + assistant_message = Message( + role="assistant", + content=ai_response, + timestamp=time.time(), + message_id=str(uuid.uuid4()), + sources=[] + ) + + async with self: + self.messages.append(assistant_message) + self.is_loading = False + yield + + except Exception as e: + print(f"Error in send_message: {str(e)}") + import traceback + traceback.print_exc() + + async with self: + self.messages.append(Message( + role="assistant", + content=f"I encountered an error: {str(e)}. Please try again.", + timestamp=time.time(), + message_id=str(uuid.uuid4()) + )) + self.is_loading = False + self.error_message = f"Error: {str(e)}" + yield + + @rx.event(temporal=True) + def retry_last_message(self): + """Retry the last user message if there was an error""" + if self.messages and self.messages[-1].role == "user": + self.current_message = self.messages[-1].content + if len(self.messages) > 1 and "error" in self.messages[-1].content.lower(): + self.messages = self.messages[:-1] diff --git a/scripts/conversation_indexer.py b/scripts/conversation_indexer.py new file mode 100644 index 000000000..203a1ad75 --- /dev/null +++ b/scripts/conversation_indexer.py @@ -0,0 +1,97 @@ +import os +import typesense +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +TYPESENSE_CONFIG = { + 'nodes': [{ + 'host': os.getenv('TYPESENSE_HOST'), + 'port': '443', + 'protocol': 'https' + }], + 'api_key': os.getenv('TYPESENSE_ADMIN_API_KEY'), + 'connection_timeout_seconds': 60 +} + +# Conversation history collection schema +CONVERSATION_SCHEMA = { + 'name': 'conversations', + 'fields': [ + {'name': 'id', 'type': 'string'}, + {'name': 'conversation_id', 'type': 'string', 'facet': True}, + {'name': 'model_id', 'type': 'string', 'facet': True}, + {'name': 'role', 'type': 'string', 'facet': True}, # 'user' or 'assistant' + {'name': 'message', 'type': 'string'}, + {'name': 'timestamp', 'type': 'int32'}, + ] +} + +class ConversationSetup: + def __init__(self): + self.client = typesense.Client(TYPESENSE_CONFIG) + + def create_conversation_collection(self, force_recreate: bool = False): + """Create the conversation history collection""" + try: + # Check if collection exists + try: + self.client.collections['conversations'].retrieve() + if force_recreate: + logger.info("Deleting existing conversation collection...") + self.client.collections['conversations'].delete() + else: + logger.info("Conversation collection already exists. Use force_recreate=True to recreate.") + return True + except typesense.exceptions.ObjectNotFound: + pass + + logger.info("Creating conversation collection...") + self.client.collections.create(CONVERSATION_SCHEMA) + logger.info("Conversation collection created successfully.") + return True + + except Exception as e: + logger.error(f"Error creating conversation collection: {e}") + return False + + + def setup_all(self, force_recreate: bool = False): + """Setup conversation collection and test RAG configuration""" + success = True + + if not self.create_conversation_collection(force_recreate): + success = False + + return success + + +def main(): + import argparse + + parser = argparse.ArgumentParser(description='Setup Typesense Conversational RAG') + parser.add_argument('--force', action='store_true', help='Force recreate collections and models') + args = parser.parse_args() + + # Validate environment variables + required_vars = ['TYPESENSE_HOST', 'TYPESENSE_ADMIN_API_KEY', 'GEMINI_API_KEY'] + missing_vars = [var for var in required_vars if not os.getenv(var)] + + if missing_vars: + logger.error(f"Missing required environment variables: {missing_vars}") + return False + + setup = ConversationSetup() + success = setup.setup_all(force_recreate=args.force) + + if success: + logger.info("Conversational RAG setup completed successfully!") + else: + logger.error("Setup failed. Check the logs above for details.") + + return success + +if __name__ == '__main__': + success = main() + exit(0 if success else 1)