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)