import gradio as gr import os from datetime import datetime from openai import OpenAI from typing import List, Tuple, Optional import json from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() # Initialize OpenAI client client = None api_key = os.getenv("OPENAI_API_KEY") if api_key: client = OpenAI(api_key=api_key) def set_api_key(key: str) -> str: """Set the OpenAI API key.""" global client, api_key if key.strip(): api_key = key.strip() client = OpenAI(api_key=api_key) return "API key set successfully!" return "Please enter a valid API key." # Global state to store conversation threads conversation_state = { "thread_history": [], "current_subject": None, "user_turn": 0, "ai_turn": 0 } def generate_subject_line(user_letter: str) -> str: """Generate a subject line for the first letter using OpenAI.""" if not client: return "General Correspondence" try: response = client.chat.completions.create( model="gpt-4o-mini", messages=[ { "role": "system", "content": "You are a helpful assistant that generates concise, descriptive subject lines for letters. Generate a subject line (max 6 words) that captures the main topic. Return ONLY the subject line, nothing else." }, { "role": "user", "content": f"Generate a subject line for this letter:\n\n{user_letter}" } ], max_tokens=20, temperature=0.7 ) subject = response.choices[0].message.content.strip() # Remove any quotes if the model added them subject = subject.strip('"').strip("'") return subject except Exception as e: return f"General Correspondence" def generate_ai_response(user_letter: str, subject: str) -> str: """Generate AI response in letter format.""" if not client: return "⚠️ OpenAI API key not found. Please set OPENAI_API_KEY environment variable." try: response = client.chat.completions.create( model="gpt-4o", messages=[ { "role": "system", "content": """You are a thoughtful pen pal who writes detailed, meaningful letters. Your writing style should be: - Warm and personal, like writing to a friend - Structured like a proper letter (greeting, body, closing) - Thoughtful and substantive - take time to explore ideas thoroughly - Formatted in markdown for readability Start each letter with a greeting (e.g., "Dear Friend," or "Hello,") and end with a closing (e.g., "Warm regards," or "Best wishes,") followed by "Your AI Pen Pal". Respond to the user's letter comprehensively. This is asynchronous correspondence - take your time to provide a complete, thoughtful response in a single letter.""" }, { "role": "user", "content": user_letter } ], temperature=0.8, max_tokens=2000 ) return response.choices[0].message.content except Exception as e: return f"⚠️ Error generating response: {str(e)}" def format_letter_header(subject: str, turn_type: str, turn_number: int) -> str: """Format the letter header with subject line.""" date_str = datetime.now().strftime("%B %d, %Y") return f"**Re: {subject} ({turn_type} {turn_number})**\n\n*{date_str}*\n\n---\n\n" def send_letter(user_letter: str, thread_history: List): """Process user letter and generate AI response.""" if not user_letter.strip(): # Determine labels based on conversation state is_first = conversation_state["current_subject"] is None btn_text = "Send Letter" if is_first else "Send Reply" input_label = "Your Letter" if is_first else "Your Reply" input_placeholder = "Dear AI Pen Pal,\n\nI've been thinking about..." if is_first else "Dear AI Pen Pal,\n\nThank you for your letter. In response..." section_title = "## Compose Your Letter" if is_first else "## Compose Your Reply" return ( "", "Please write a letter before sending.", thread_history, "", "", gr.update(value=btn_text), gr.update(label=input_label, placeholder=input_placeholder), gr.update(value=section_title) ) # Generate subject line if this is the first letter if conversation_state["current_subject"] is None: conversation_state["current_subject"] = generate_subject_line(user_letter) conversation_state["user_turn"] = 0 conversation_state["ai_turn"] = 0 # Increment turn counters conversation_state["user_turn"] += 1 subject = conversation_state["current_subject"] # Format user letter with header user_header = format_letter_header(subject, "User Prompt", conversation_state["user_turn"]) formatted_user_letter = user_header + user_letter # Add user letter to thread thread_history.append({ "type": "user", "content": formatted_user_letter, "timestamp": datetime.now().isoformat() }) # Generate AI response ai_response_content = generate_ai_response(user_letter, subject) # Increment AI turn conversation_state["ai_turn"] += 1 # Format AI response with header ai_header = format_letter_header(subject, "AI Reply", conversation_state["ai_turn"]) formatted_ai_response = ai_header + ai_response_content # Add AI response to thread thread_history.append({ "type": "ai", "content": formatted_ai_response, "timestamp": datetime.now().isoformat() }) # Build thread display thread_display = build_thread_display(thread_history) # Update UI labels for reply mode btn_text = "Send Reply" input_label = "Your Reply" input_placeholder = "Dear AI Pen Pal,\n\nThank you for your letter. In response..." section_title = "## Compose Your Reply" # Clear user input and show AI response return ( "", formatted_ai_response, thread_history, thread_display, formatted_user_letter, gr.update(value=btn_text), gr.update(label=input_label, placeholder=input_placeholder), gr.update(value=section_title) ) def build_thread_display(thread_history: List) -> str: """Build formatted thread history display.""" if not thread_history: return "*No letters yet. Start writing!*" thread_md = "# Letter Thread\n\n" for i, letter in enumerate(thread_history): if letter["type"] == "user": avatar_img = '' sender = f'{avatar_img} **You**' else: avatar_img = '' sender = f'{avatar_img} **AI Pen Pal**' thread_md += f"### {sender}\n\n{letter['content']}\n\n---\n\n" return thread_md def download_letter(letter_content: str, letter_type: str) -> str: """Prepare letter content for download.""" if not letter_content: return "# No letter to download\n\nPlease send a letter first." return letter_content def new_conversation(): """Start a new conversation thread.""" conversation_state["thread_history"] = [] conversation_state["current_subject"] = None conversation_state["user_turn"] = 0 conversation_state["ai_turn"] = 0 return ( "", "", [], "*No letters yet. Start writing!*", "", gr.update(value="Send Letter"), gr.update(label="Your Letter", placeholder="Dear AI Pen Pal,\n\nI've been thinking about..."), gr.update(value="## Compose Your Letter") ) # Custom CSS for letter-writing aesthetic custom_css = """ .letter-box textarea { font-family: 'Georgia', 'Times New Roman', serif !important; font-size: 16px !important; line-height: 1.8 !important; padding: 20px !important; } .gradio-container { font-family: 'Georgia', 'Times New Roman', serif !important; } #thread-history { background-color: #f9f7f4; padding: 20px; border-radius: 8px; border: 1px solid #d4c5b9; } .letter-display { background-color: #fffef8; padding: 25px; border-left: 4px solid #8b7355; } .mic-button { min-width: 50px !important; } """ # JavaScript for speech-to-text speech_to_text_js = """ function setupSpeechRecognition() { const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) { alert('Speech recognition is not supported in your browser. Please use Chrome, Edge, or Safari.'); return null; } const recognition = new SpeechRecognition(); recognition.continuous = true; recognition.interimResults = true; recognition.lang = 'en-US'; return recognition; } let recognition = null; let isRecording = false; let fullTranscript = ''; function toggleSpeechRecognition(currentText) { if (!recognition) { recognition = setupSpeechRecognition(); if (!recognition) return [currentText, 'Start Dictation']; } if (isRecording) { recognition.stop(); isRecording = false; return [currentText, 'Start Dictation']; } else { fullTranscript = currentText || ''; recognition.onresult = (event) => { let interimTranscript = ''; for (let i = event.resultIndex; i < event.results.length; i++) { const transcript = event.results[i][0].transcript; if (event.results[i].isFinal) { fullTranscript += (fullTranscript ? ' ' : '') + transcript; } else { interimTranscript += transcript; } } const textarea = document.querySelector('.letter-box textarea'); if (textarea) { textarea.value = fullTranscript + (interimTranscript ? ' ' + interimTranscript : ''); textarea.dispatchEvent(new Event('input', { bubbles: true })); } }; recognition.onerror = (event) => { console.error('Speech recognition error:', event.error); isRecording = false; const button = document.querySelector('.mic-button'); if (button) button.value = 'Start Dictation'; }; recognition.onend = () => { if (isRecording) { recognition.start(); } }; recognition.start(); isRecording = true; return [fullTranscript, 'Stop Dictation']; } } """ # Build Gradio interface with gr.Blocks(css=custom_css, title="Pen Pal AI - Letter Exchange", theme=gr.themes.Soft()) as demo: # Header image gr.Image("demo-header.png", show_label=False, show_download_button=False, container=False) gr.Markdown(""" Welcome to a different kind of AI conversation. Write thoughtful, long-form letters and receive detailed responses. **How it works:** 1. Set your OpenAI API key below (if not already configured) 2. Write your letter in the text area 3. Click "Send Letter" or use dictation 4. Receive a reply from your AI pen pal 5. Download any letter as markdown This is asynchronous correspondence - take your time, be thoughtful, and enjoy the exchange! """) # API Key Configuration with gr.Accordion("API Key Configuration (BYOK)", open=False): gr.Markdown(""" This app requires an OpenAI API key. You can get one at [platform.openai.com](https://platform.openai.com/api-keys). **For Hugging Face Spaces:** Set your key as a secret named `OPENAI_API_KEY` in your Space settings. **For local use:** You can also set it here temporarily (not saved between sessions). """) with gr.Row(): api_key_input = gr.Textbox( label="OpenAI API Key", placeholder="sk-...", type="password", scale=3 ) set_key_btn = gr.Button("Set API Key", size="sm", scale=1) api_key_status = gr.Markdown("") # Hidden state for thread history thread_state = gr.State([]) with gr.Row(): with gr.Column(scale=1): compose_section_title = gr.Markdown("## Compose Your Letter") user_input = gr.Textbox( label="Your Letter", placeholder="Dear AI Pen Pal,\n\nI've been thinking about...", lines=15, elem_classes=["letter-box"] ) with gr.Row(): mic_btn = gr.Button("Start Dictation", size="sm", elem_classes=["mic-button"]) with gr.Row(): send_btn = gr.Button("Send Letter", variant="primary", size="lg") new_conv_btn = gr.Button("New Conversation", size="lg") gr.Markdown("---") gr.Markdown("### Your Last Letter") user_letter_display = gr.Markdown( value="*Your letter will appear here after sending*", elem_classes=["letter-display"] ) download_user = gr.DownloadButton( label="Download Your Letter", size="sm" ) with gr.Column(scale=1): gr.Markdown("## AI Reply") ai_response = gr.Markdown( value="*Waiting for your letter...*", elem_classes=["letter-display"] ) download_ai = gr.DownloadButton( label="Download AI Reply", size="sm" ) gr.Markdown("---") gr.Markdown("## Letter Thread") thread_display = gr.Markdown( value="*No letters yet. Start writing!*", elem_id="thread-history" ) # Event handlers set_key_btn.click( fn=set_api_key, inputs=[api_key_input], outputs=[api_key_status] ) send_btn.click( fn=send_letter, inputs=[user_input, thread_state], outputs=[user_input, ai_response, thread_state, thread_display, user_letter_display, send_btn, user_input, compose_section_title] ) new_conv_btn.click( fn=new_conversation, inputs=[], outputs=[user_input, ai_response, thread_state, thread_display, user_letter_display, send_btn, user_input, compose_section_title] ) # Speech-to-text handler mic_btn.click( fn=None, inputs=[user_input], outputs=[user_input, mic_btn], js=speech_to_text_js.replace("function toggleSpeechRecognition", "function(currentText) { return toggleSpeechRecognition") ) # Download handlers user_letter_display.change( fn=lambda x: gr.DownloadButton( label="Download Your Letter", value=x if x and x != "*Your letter will appear here after sending*" else None, visible=bool(x and x != "*Your letter will appear here after sending*") ), inputs=[user_letter_display], outputs=[download_user] ) ai_response.change( fn=lambda x: gr.DownloadButton( label="Download AI Reply", value=x if x and x != "*Waiting for your letter...*" else None, visible=bool(x and x != "*Waiting for your letter...*") ), inputs=[ai_response], outputs=[download_ai] ) if __name__ == "__main__": demo.launch(share=False)