Blog writer enhancements & fixes
This commit is contained in:
635
lib/ai_writers/ai_blog_writer/ai_blog_generator.py
Normal file
635
lib/ai_writers/ai_blog_writer/ai_blog_generator.py
Normal file
@@ -0,0 +1,635 @@
|
||||
import os
|
||||
import streamlit as st
|
||||
from loguru import logger
|
||||
|
||||
from lib.utils.voice_processing import record_voice
|
||||
from lib.ai_writers.ai_blog_writer.blog_writer_styles import apply_blog_writer_styles
|
||||
from lib.ai_writers.ai_blog_writer.ai_blog_generator_utils import (
|
||||
CONFIG_PATH,
|
||||
load_config,
|
||||
get_search_params_from_config,
|
||||
get_blog_characteristics_from_config,
|
||||
get_blog_images_from_config,
|
||||
get_llm_options_from_config,
|
||||
process_input,
|
||||
handle_content_generation
|
||||
)
|
||||
|
||||
apply_blog_writer_styles()
|
||||
|
||||
def display_input_section():
|
||||
"""Display the input section with text area, file upload, and voice recording options."""
|
||||
# Main container with columns for better organization
|
||||
col1, col2, col3 = st.columns([2, 1.5, 0.5])
|
||||
|
||||
# First column: Keywords input
|
||||
with col1:
|
||||
st.markdown("### 📌 Content Source")
|
||||
st.markdown("#### Enter Keywords, Title or URL")
|
||||
user_input = st.text_area(
|
||||
'Power your content with keywords or a website URL',
|
||||
help='Provide keywords, a blog title, YouTube link, or web URL to generate targeted content.',
|
||||
placeholder="Examples:\n- Keywords: AI tools, digital marketing\n- Blog Title: The Future of AI in Marketing\n- YouTube Link: https://youtube.com/...\n- Web URL: https://example.com/...",
|
||||
height=150
|
||||
)
|
||||
|
||||
# Second column: File uploader
|
||||
with col2:
|
||||
st.markdown("### 📁 File Upload")
|
||||
st.markdown("#### Upload Reference Content")
|
||||
uploaded_file = st.file_uploader(
|
||||
"Add files to enhance your content",
|
||||
type=["txt", "pdf", "docx", "jpg", "jpeg", "png", "mp3", "wav", "mp4", "mkv", "avi"],
|
||||
help='Upload documents, images, or media files to incorporate additional information in your blog.'
|
||||
)
|
||||
|
||||
# Third column: Voice input
|
||||
with col3:
|
||||
st.markdown("### 🎤 Voice")
|
||||
st.markdown("#### Record Ideas")
|
||||
audio_input = record_voice()
|
||||
if audio_input:
|
||||
st.success("Voice recorded!")
|
||||
|
||||
return user_input, uploaded_file, audio_input
|
||||
|
||||
|
||||
def display_content_type_selection():
|
||||
"""Display the content type selection section and return the selected type."""
|
||||
# Content options in a cleaner layout
|
||||
st.markdown("### 🔧 Content Configuration")
|
||||
|
||||
# Content type selection with better UI
|
||||
st.markdown("#### Select Content Type")
|
||||
content_type = st.radio(
|
||||
"Choose the format and length of your blog content",
|
||||
["Standard Blog Post", "Comprehensive Long-form", "AI Agent Team (Beta)"],
|
||||
horizontal=True,
|
||||
help="Standard: 800-1200 words | Long-form: 1500+ words | AI Agent: Experimental multi-perspective content"
|
||||
)
|
||||
|
||||
# Map the friendly content type names to the original options
|
||||
content_type_map = {
|
||||
"Standard Blog Post": "Normal-length content",
|
||||
"Comprehensive Long-form": "Long-form content",
|
||||
"AI Agent Team (Beta)": "Experimental - AI Agents team"
|
||||
}
|
||||
|
||||
return content_type, content_type_map[content_type]
|
||||
|
||||
|
||||
def display_content_characteristics_tab():
|
||||
"""Display the Content Characteristics tab and return the selected options."""
|
||||
st.markdown("#### Blog Content Characteristics")
|
||||
|
||||
# Load default values from configuration
|
||||
config_blog_chars = get_blog_characteristics_from_config()
|
||||
|
||||
# Blog length
|
||||
blog_length = st.number_input(
|
||||
"Blog Length (words)",
|
||||
min_value=500,
|
||||
max_value=5000,
|
||||
value=int(config_blog_chars.get("blog_length", 2000)),
|
||||
step=100,
|
||||
help="Target word count for your blog post"
|
||||
)
|
||||
|
||||
# Blog tone
|
||||
tone_options = ["Professional", "Casual", "Formal", "Conversational", "Authoritative", "Friendly"]
|
||||
default_tone = config_blog_chars.get("blog_tone", "Professional")
|
||||
default_tone_index = tone_options.index(default_tone) if default_tone in tone_options else 0
|
||||
|
||||
blog_tone = st.selectbox(
|
||||
"Blog Tone",
|
||||
options=tone_options,
|
||||
index=default_tone_index,
|
||||
help="The overall tone and style of your blog content"
|
||||
)
|
||||
|
||||
# Blog demographic
|
||||
demographic_options = ["Professional", "General", "Technical", "Beginner", "Expert", "Student"]
|
||||
default_demo = config_blog_chars.get("blog_demographic", "Professional")
|
||||
default_demo_index = demographic_options.index(default_demo) if default_demo in demographic_options else 0
|
||||
|
||||
blog_demographic = st.selectbox(
|
||||
"Target Audience",
|
||||
options=demographic_options,
|
||||
index=default_demo_index,
|
||||
help="Who your blog content is primarily written for"
|
||||
)
|
||||
|
||||
# Blog type
|
||||
type_options = ["Informational", "How-to", "List", "Review", "Tutorial", "Opinion"]
|
||||
default_type = config_blog_chars.get("blog_type", "Informational")
|
||||
default_type_index = type_options.index(default_type) if default_type in type_options else 0
|
||||
|
||||
blog_type = st.selectbox(
|
||||
"Blog Type",
|
||||
options=type_options,
|
||||
index=default_type_index,
|
||||
help="The format and purpose of your blog content"
|
||||
)
|
||||
|
||||
# Blog language
|
||||
language_options = ["English", "Spanish", "French", "German", "Italian", "Portuguese"]
|
||||
default_lang = config_blog_chars.get("blog_language", "English")
|
||||
default_lang_index = language_options.index(default_lang) if default_lang in language_options else 0
|
||||
|
||||
blog_language = st.selectbox(
|
||||
"Blog Language",
|
||||
options=language_options,
|
||||
index=default_lang_index,
|
||||
help="The language your blog will be written in"
|
||||
)
|
||||
|
||||
# Blog output format
|
||||
format_options = ["markdown", "html", "plain text"]
|
||||
default_format = config_blog_chars.get("blog_output_format", "markdown").lower()
|
||||
default_format_index = format_options.index(default_format) if default_format in format_options else 0
|
||||
|
||||
blog_output_format = st.selectbox(
|
||||
"Output Format",
|
||||
options=format_options,
|
||||
index=default_format_index,
|
||||
help="The format in which the blog content will be generated"
|
||||
)
|
||||
|
||||
# Show current configuration source
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
st.success(f"✅ Using blog characteristics from configuration file")
|
||||
else:
|
||||
st.info("ℹ️ Using default blog characteristics (no configuration file found)")
|
||||
|
||||
return {
|
||||
"blog_length": blog_length,
|
||||
"blog_tone": blog_tone,
|
||||
"blog_demographic": blog_demographic,
|
||||
"blog_type": blog_type,
|
||||
"blog_language": blog_language,
|
||||
"blog_output_format": blog_output_format
|
||||
}
|
||||
|
||||
|
||||
def display_content_analysis_tab():
|
||||
"""Display the Content & Analysis Options tab and return the selected options."""
|
||||
st.markdown("#### Content & Analysis Options")
|
||||
|
||||
# Create two columns for better organization
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
st.markdown("**Content Enhancements**")
|
||||
create_seo_tags = st.checkbox(
|
||||
'✅ Generate SEO metadata',
|
||||
value=True,
|
||||
help='Create schema markup, meta tags, and social media metadata'
|
||||
)
|
||||
generate_social_media = st.checkbox(
|
||||
'✅ Create social media posts',
|
||||
value=False,
|
||||
help="Generate matching social content for Facebook, Twitter, and LinkedIn"
|
||||
)
|
||||
add_table_of_contents = st.checkbox(
|
||||
'✅ Add table of contents',
|
||||
value=True,
|
||||
help="Include an auto-generated table of contents at the beginning of the blog"
|
||||
)
|
||||
|
||||
with col2:
|
||||
st.markdown("**Analysis & Improvement**")
|
||||
content_analysis = st.checkbox(
|
||||
'✅ Perform content analysis',
|
||||
value=False,
|
||||
help="Include proofreading, readability score, and improvement suggestions"
|
||||
)
|
||||
enhance_readability = st.checkbox(
|
||||
'✅ Enhance readability',
|
||||
value=True,
|
||||
help="Optimize sentence structure and vocabulary for better readability"
|
||||
)
|
||||
fact_checking = st.checkbox(
|
||||
'✅ Basic fact verification',
|
||||
value=False,
|
||||
help="Verify key facts from multiple sources when possible"
|
||||
)
|
||||
|
||||
st.markdown("---")
|
||||
st.markdown("**Formatting Options**")
|
||||
|
||||
# Create two columns for formatting options
|
||||
fmt_col1, fmt_col2 = st.columns(2)
|
||||
|
||||
with fmt_col1:
|
||||
section_headings = st.checkbox(
|
||||
'✅ Use section headings',
|
||||
value=True,
|
||||
help="Include clear section headings throughout the blog"
|
||||
)
|
||||
include_lists = st.checkbox(
|
||||
'✅ Use bullet points and lists',
|
||||
value=True,
|
||||
help="Format appropriate content as bullet points or numbered lists"
|
||||
)
|
||||
|
||||
with fmt_col2:
|
||||
include_quotes = st.checkbox(
|
||||
'✅ Include relevant quotes',
|
||||
value=False,
|
||||
help="Add expert quotes or important statements as blockquotes"
|
||||
)
|
||||
use_subheadings = st.checkbox(
|
||||
'✅ Use subheadings',
|
||||
value=True,
|
||||
help="Break down sections with descriptive subheadings"
|
||||
)
|
||||
|
||||
return {
|
||||
"create_seo_tags": create_seo_tags,
|
||||
"generate_social_media": generate_social_media,
|
||||
"add_table_of_contents": add_table_of_contents,
|
||||
"content_analysis": content_analysis,
|
||||
"enhance_readability": enhance_readability,
|
||||
"fact_checking": fact_checking,
|
||||
"section_headings": section_headings,
|
||||
"include_lists": include_lists,
|
||||
"include_quotes": include_quotes,
|
||||
"use_subheadings": use_subheadings
|
||||
}
|
||||
|
||||
|
||||
def display_blog_images_tab():
|
||||
"""Display the Blog Images Details tab and return the selected options."""
|
||||
st.markdown("#### Blog Images Settings")
|
||||
|
||||
# Load default values from configuration
|
||||
config_images = get_blog_images_from_config()
|
||||
|
||||
# Image generation model selection
|
||||
model_options = ["stable-diffusion", "dall-e", "midjourney", "imagen"]
|
||||
default_model = config_images.get("image_model", "stable-diffusion")
|
||||
default_model_index = model_options.index(default_model) if default_model in model_options else 0
|
||||
|
||||
image_model = st.selectbox(
|
||||
"Image Generation Model",
|
||||
options=model_options,
|
||||
index=default_model_index,
|
||||
help="AI model used to generate blog images"
|
||||
)
|
||||
|
||||
# Number of blog images
|
||||
num_images = st.number_input(
|
||||
"Number of Blog Images",
|
||||
min_value=0,
|
||||
max_value=10,
|
||||
value=config_images.get("num_images", 1),
|
||||
step=1,
|
||||
help="Number of images to generate for the blog"
|
||||
)
|
||||
|
||||
# Image style
|
||||
style_options = ["Realistic", "Artistic", "Cartoon", "Minimalist", "Corporate", "Vibrant"]
|
||||
default_style = config_images.get("image_style", "Realistic")
|
||||
default_style_index = style_options.index(default_style) if default_style in style_options else 0
|
||||
|
||||
image_style = st.selectbox(
|
||||
"Image Style",
|
||||
options=style_options,
|
||||
index=default_style_index,
|
||||
help="Visual style of the generated images"
|
||||
)
|
||||
|
||||
# Additional image options
|
||||
st.markdown("**Additional Image Options**")
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
generate_featured = st.checkbox(
|
||||
'✅ Generate featured image',
|
||||
value=True,
|
||||
help="Create a featured header image for the blog"
|
||||
)
|
||||
add_captions = st.checkbox(
|
||||
'✅ Add image captions',
|
||||
value=True,
|
||||
help="Generate descriptive captions for each image"
|
||||
)
|
||||
|
||||
with col2:
|
||||
use_alt_text = st.checkbox(
|
||||
'✅ Generate alt text',
|
||||
value=True,
|
||||
help="Create accessibility alt text for all images"
|
||||
)
|
||||
optimize_images = st.checkbox(
|
||||
'✅ Optimize image placement',
|
||||
value=True,
|
||||
help="Intelligently place images throughout the content"
|
||||
)
|
||||
|
||||
# Show current configuration source
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
st.success(f"✅ Using image settings from configuration file")
|
||||
else:
|
||||
st.info("ℹ️ Using default image settings (no configuration file found)")
|
||||
|
||||
return {
|
||||
"image_model": image_model,
|
||||
"num_images": num_images,
|
||||
"image_style": image_style,
|
||||
"generate_featured": generate_featured,
|
||||
"add_captions": add_captions,
|
||||
"use_alt_text": use_alt_text,
|
||||
"optimize_placement": optimize_images
|
||||
}
|
||||
|
||||
|
||||
def display_llm_options_tab():
|
||||
"""Display the LLM Options tab and return the selected options."""
|
||||
st.markdown("#### Language Model Settings")
|
||||
|
||||
# Load default values from configuration
|
||||
config_llm = get_llm_options_from_config()
|
||||
|
||||
# LLM provider selection
|
||||
provider_options = ["google", "openai", "anthropic", "local"]
|
||||
default_provider = config_llm.get("provider", "google")
|
||||
default_provider_index = provider_options.index(default_provider) if default_provider in provider_options else 0
|
||||
|
||||
llm_provider = st.selectbox(
|
||||
"AI Provider",
|
||||
options=provider_options,
|
||||
index=default_provider_index,
|
||||
help="The AI provider to use for content generation"
|
||||
)
|
||||
|
||||
# Model selection (dynamic based on provider)
|
||||
if llm_provider == "google":
|
||||
model_options = ["gemini-1.5-flash-latest", "gemini-1.5-pro-latest", "gemini-pro"]
|
||||
elif llm_provider == "openai":
|
||||
model_options = ["gpt-4o", "gpt-4-turbo", "gpt-3.5-turbo"]
|
||||
elif llm_provider == "anthropic":
|
||||
model_options = ["claude-3-opus", "claude-3-sonnet", "claude-3-haiku"]
|
||||
else:
|
||||
model_options = ["llama-3-70b", "mistral-large", "local-model"]
|
||||
|
||||
default_model = config_llm.get("model", "gemini-1.5-flash-latest")
|
||||
default_model_index = 0
|
||||
if default_model in model_options:
|
||||
default_model_index = model_options.index(default_model)
|
||||
|
||||
llm_model = st.selectbox(
|
||||
"AI Model",
|
||||
options=model_options,
|
||||
index=default_model_index,
|
||||
help="The specific AI model to use for content generation"
|
||||
)
|
||||
|
||||
# Create two columns for temperature and max tokens
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
# Temperature setting
|
||||
temperature = st.slider(
|
||||
"Temperature",
|
||||
min_value=0.0,
|
||||
max_value=1.0,
|
||||
value=config_llm.get("temperature", 0.7),
|
||||
step=0.1,
|
||||
help="Controls randomness: lower values are more deterministic, higher values more creative"
|
||||
)
|
||||
|
||||
with col2:
|
||||
# Max tokens
|
||||
max_tokens = st.number_input(
|
||||
"Max Tokens",
|
||||
min_value=1000,
|
||||
max_value=32000,
|
||||
value=config_llm.get("max_tokens", 4000),
|
||||
step=1000,
|
||||
help="Maximum length of generated content (in tokens)"
|
||||
)
|
||||
|
||||
# Advanced LLM options
|
||||
st.markdown("---")
|
||||
st.markdown("**Advanced LLM Options**")
|
||||
show_advanced_llm = st.checkbox("Show advanced LLM parameters", value=False)
|
||||
|
||||
advanced_params = {}
|
||||
if show_advanced_llm:
|
||||
# Top-p (nucleus sampling)
|
||||
top_p = st.slider(
|
||||
"Top-p (Nucleus Sampling)",
|
||||
min_value=0.1,
|
||||
max_value=1.0,
|
||||
value=0.9,
|
||||
step=0.1,
|
||||
help="Controls diversity via nucleus sampling: 1.0 considers all tokens, lower values restrict to more likely tokens"
|
||||
)
|
||||
|
||||
# Top-k
|
||||
top_k = st.slider(
|
||||
"Top-k",
|
||||
min_value=1,
|
||||
max_value=100,
|
||||
value=40,
|
||||
step=1,
|
||||
help="Controls diversity by limiting to top k tokens: higher values allow more diversity"
|
||||
)
|
||||
|
||||
# Presence penalty
|
||||
presence_penalty = st.slider(
|
||||
"Presence Penalty",
|
||||
min_value=-2.0,
|
||||
max_value=2.0,
|
||||
value=0.0,
|
||||
step=0.1,
|
||||
help="Penalizes repeated tokens: positive values discourage repetition"
|
||||
)
|
||||
|
||||
advanced_params = {
|
||||
"top_p": top_p,
|
||||
"top_k": top_k,
|
||||
"presence_penalty": presence_penalty
|
||||
}
|
||||
|
||||
# Show current configuration source
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
st.success(f"✅ Using LLM settings from configuration file")
|
||||
else:
|
||||
st.info("ℹ️ Using default LLM settings (no configuration file found)")
|
||||
|
||||
return {
|
||||
"provider": llm_provider,
|
||||
"model": llm_model,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
**advanced_params
|
||||
}
|
||||
|
||||
|
||||
def display_search_settings_tab():
|
||||
"""Display the Search Settings tab and return the selected options."""
|
||||
st.markdown("#### AI Search Configuration")
|
||||
st.markdown("Control how the AI researches your topic")
|
||||
|
||||
# Load default values from configuration
|
||||
config_search_params = get_search_params_from_config()
|
||||
|
||||
# Number of search results
|
||||
max_results = st.slider(
|
||||
"Maximum Results",
|
||||
min_value=5,
|
||||
max_value=30,
|
||||
value=config_search_params.get("max_results", 10),
|
||||
step=5,
|
||||
help="Maximum number of search results to use for research"
|
||||
)
|
||||
|
||||
# Search depth
|
||||
search_depth = st.radio(
|
||||
"Search Depth",
|
||||
options=["basic", "advanced"],
|
||||
index=0,
|
||||
horizontal=True,
|
||||
help="Basic: Faster but less comprehensive. Advanced: More thorough but slower."
|
||||
)
|
||||
|
||||
# Include domains
|
||||
include_domains = st.text_input(
|
||||
"Include Domains (Optional)",
|
||||
value="",
|
||||
help="Comma-separated list of domains to prioritize in search (e.g., wikipedia.org,nih.gov)"
|
||||
)
|
||||
|
||||
# Time range - use value from config
|
||||
time_options = ["day", "week", "month", "year", "all"]
|
||||
default_time_index = time_options.index(config_search_params.get("time_range", "year")) if config_search_params.get("time_range", "year") in time_options else 3 # Default to "year" (index 3)
|
||||
|
||||
time_range = st.select_slider(
|
||||
"Time Range",
|
||||
options=time_options,
|
||||
value=time_options[default_time_index],
|
||||
help="Limit search results to a specific time period"
|
||||
)
|
||||
|
||||
# Show current configuration source
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
st.success(f"✅ Using search defaults from configuration file")
|
||||
else:
|
||||
st.info("ℹ️ Using default search settings (no configuration file found)")
|
||||
|
||||
# Replace expander with checkbox for configuration display
|
||||
show_config = st.checkbox("Show configuration details", value=False)
|
||||
if show_config:
|
||||
st.markdown("""
|
||||
**Configuration File Location**
|
||||
Search parameters are loaded from the main configuration file at:
|
||||
`lib/workspace/alwrity_config/main_config.json`
|
||||
|
||||
You can modify this file to change the default search settings.
|
||||
""")
|
||||
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
try:
|
||||
with open(CONFIG_PATH, 'r') as f:
|
||||
config_content = f.read()
|
||||
st.code(config_content, language="json")
|
||||
except:
|
||||
st.warning("Could not read configuration file")
|
||||
|
||||
st.info("These settings control how the AI performs web research for your content. More thorough searches may take longer but produce better results.")
|
||||
|
||||
# Process include_domains from string to list if provided
|
||||
domains_list = []
|
||||
if include_domains:
|
||||
domains_list = [domain.strip() for domain in include_domains.split(",") if domain.strip()]
|
||||
|
||||
return {
|
||||
"max_results": max_results,
|
||||
"search_depth": search_depth,
|
||||
"time_range": time_range,
|
||||
"include_domains": domains_list
|
||||
}
|
||||
|
||||
|
||||
def display_advanced_options():
|
||||
"""Display all advanced options tabs and return the selected configurations."""
|
||||
with st.expander("⚙️ Advanced Options", expanded=False):
|
||||
tabs = st.tabs(["Content Characteristics", "Content & Analysis Options", "Blog Images Details", "LLM Options", "Search Settings"])
|
||||
|
||||
with tabs[0]: # Content Characteristics
|
||||
blog_params = display_content_characteristics_tab()
|
||||
|
||||
with tabs[1]: # Combined Content & Analysis Options
|
||||
content_analysis_params = display_content_analysis_tab()
|
||||
|
||||
with tabs[2]: # Blog Images Details
|
||||
image_params = display_blog_images_tab()
|
||||
|
||||
with tabs[3]: # LLM Options
|
||||
llm_params = display_llm_options_tab()
|
||||
|
||||
with tabs[4]: # Search Settings
|
||||
search_params = display_search_settings_tab()
|
||||
|
||||
return blog_params, content_analysis_params, image_params, llm_params, search_params
|
||||
|
||||
|
||||
def blog_from_keyword():
|
||||
"""Input blog keywords, research and write a factual blog with enhanced UI."""
|
||||
|
||||
# Get user inputs
|
||||
user_input, uploaded_file, audio_input = display_input_section()
|
||||
|
||||
# Get content type selection
|
||||
content_type, selected_content_type = display_content_type_selection()
|
||||
|
||||
# Display advanced options and get configurations
|
||||
blog_params, content_analysis_params, image_params, llm_params, search_params = display_advanced_options()
|
||||
|
||||
# Generate button with icon and clearer purpose
|
||||
st.markdown("") # Add spacing
|
||||
generate_pressed = st.button("✨ Generate Professional Blog Content", use_container_width=True)
|
||||
|
||||
# Processing logic
|
||||
if generate_pressed:
|
||||
st.empty()
|
||||
|
||||
if not uploaded_file and not user_input and not audio_input:
|
||||
st.error("Please provide at least one input source (keywords, file, or voice recording)")
|
||||
st.stop()
|
||||
|
||||
input_type = process_input(user_input, uploaded_file)
|
||||
|
||||
# Use the utility function to handle content generation
|
||||
handle_content_generation(input_type, user_input, uploaded_file, search_params, blog_params, selected_content_type)
|
||||
|
||||
|
||||
def ai_blog_writer_page():
|
||||
"""Render the AI Blog Writer page with enhanced styling."""
|
||||
logger.info("Rendering AI Blog Writer page")
|
||||
|
||||
# Apply shared blog writer styles
|
||||
apply_blog_writer_styles()
|
||||
|
||||
# Back button with icon
|
||||
if st.button("← Back to Dashboard", key="back_to_dashboard"):
|
||||
logger.info("User clicked back button, returning to ai writer dashboard")
|
||||
st.query_params.clear()
|
||||
st.rerun()
|
||||
|
||||
# Enhanced header with icon
|
||||
st.markdown("""
|
||||
<div class="page-header">
|
||||
<h1>✍️ AI Blog Writer</h1>
|
||||
<p>Create engaging, SEO-optimized blog content with AI assistance. Our advanced algorithms help you generate high-quality, relevant articles for any topic or niche.</p>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
# Call the blog generator function with enhanced UI
|
||||
logger.info("Calling blog_from_keyword function")
|
||||
blog_from_keyword()
|
||||
|
||||
logger.info("Finished rendering AI Blog Writer page")
|
||||
382
lib/ai_writers/ai_blog_writer/ai_blog_generator_utils.py
Normal file
382
lib/ai_writers/ai_blog_writer/ai_blog_generator_utils.py
Normal file
@@ -0,0 +1,382 @@
|
||||
import re
|
||||
import os
|
||||
import json
|
||||
from loguru import logger
|
||||
import PyPDF2
|
||||
import streamlit as st
|
||||
import tiktoken
|
||||
import openai
|
||||
|
||||
from lib.gpt_providers.text_generation.main_text_generation import llm_text_gen
|
||||
from lib.ai_writers.keywords_to_blog_streamlit import write_blog_from_keywords
|
||||
from lib.ai_writers.speech_to_blog.main_audio_to_blog import generate_audio_blog
|
||||
from lib.ai_writers.long_form_ai_writer import long_form_generator
|
||||
from lib.ai_writers.web_url_ai_writer import blog_from_url
|
||||
from lib.ai_writers.image_ai_writer import blog_from_image
|
||||
|
||||
# Constants
|
||||
CONFIG_PATH = os.path.join("lib", "workspace", "alwrity_config", "main_config.json")
|
||||
DEFAULT_CONFIG = {
|
||||
"Search Engine Parameters": {
|
||||
"Geographic Location": "us",
|
||||
"Search Language": "en",
|
||||
"Number of Results": 10,
|
||||
"Time Range": "year"
|
||||
}
|
||||
}
|
||||
|
||||
# Function to load configuration from JSON file
|
||||
def load_config():
|
||||
"""Load configuration from the main config JSON file."""
|
||||
try:
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
with open(CONFIG_PATH, 'r') as f:
|
||||
config = json.load(f)
|
||||
logger.info(f"Loaded configuration from {CONFIG_PATH}")
|
||||
return config
|
||||
else:
|
||||
logger.warning(f"Configuration file not found at {CONFIG_PATH}, using defaults")
|
||||
return DEFAULT_CONFIG
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading configuration: {str(e)}")
|
||||
return DEFAULT_CONFIG
|
||||
|
||||
# Function to get search parameters from config
|
||||
def get_search_params_from_config():
|
||||
"""Extract search parameters from the main configuration."""
|
||||
config = load_config()
|
||||
search_params = config.get("Search Engine Parameters", {})
|
||||
|
||||
# Map config values to expected parameter names
|
||||
result = {
|
||||
"max_results": search_params.get("Number of Results", 10),
|
||||
"time_range": search_params.get("Time Range", "year").lower(),
|
||||
"geo": search_params.get("Geographic Location", "us"),
|
||||
"language": search_params.get("Search Language", "en")
|
||||
}
|
||||
|
||||
# Normalize time_range to match our options
|
||||
time_map = {
|
||||
"day": "day",
|
||||
"week": "week",
|
||||
"month": "month",
|
||||
"year": "year",
|
||||
"anytime": "all",
|
||||
"all": "all"
|
||||
}
|
||||
result["time_range"] = time_map.get(result["time_range"].lower(), "year")
|
||||
|
||||
logger.info(f"Using search parameters from config: {result}")
|
||||
return result
|
||||
|
||||
# Function to get blog content characteristics from config
|
||||
def get_blog_characteristics_from_config():
|
||||
"""Extract blog content characteristics from the main configuration."""
|
||||
config = load_config()
|
||||
blog_characteristics = config.get("Blog Content Characteristics", {})
|
||||
|
||||
# Map config values to expected parameter names
|
||||
result = {
|
||||
"blog_length": blog_characteristics.get("Blog Length", "2000"),
|
||||
"blog_tone": blog_characteristics.get("Blog Tone", "Professional"),
|
||||
"blog_demographic": blog_characteristics.get("Blog Demographic", "Professional"),
|
||||
"blog_type": blog_characteristics.get("Blog Type", "Informational"),
|
||||
"blog_language": blog_characteristics.get("Blog Language", "English"),
|
||||
"blog_output_format": blog_characteristics.get("Blog Output Format", "markdown")
|
||||
}
|
||||
|
||||
logger.info(f"Using blog characteristics from config: {result}")
|
||||
return result
|
||||
|
||||
# Function to get blog image details from config
|
||||
def get_blog_images_from_config():
|
||||
"""Extract blog image details from the main configuration."""
|
||||
config = load_config()
|
||||
blog_images = config.get("Blog Images Details", {})
|
||||
|
||||
# Map config values to expected parameter names
|
||||
result = {
|
||||
"image_model": blog_images.get("Image Generation Model", "stable-diffusion"),
|
||||
"num_images": int(blog_images.get("Number of Blog Images", 1)),
|
||||
"image_style": blog_images.get("Image Style", "Realistic")
|
||||
}
|
||||
|
||||
logger.info(f"Using blog image details from config: {result}")
|
||||
return result
|
||||
|
||||
# Function to get LLM options from config
|
||||
def get_llm_options_from_config():
|
||||
"""Extract LLM options from the main configuration."""
|
||||
config = load_config()
|
||||
llm_options = config.get("LLM Options", {})
|
||||
|
||||
# Map config values to expected parameter names
|
||||
result = {
|
||||
"provider": llm_options.get("GPT Provider", "google"),
|
||||
"model": llm_options.get("Model", "gemini-1.5-flash-latest"),
|
||||
"temperature": float(llm_options.get("Temperature", 0.7)),
|
||||
"max_tokens": int(llm_options.get("Max Tokens", 4000))
|
||||
}
|
||||
|
||||
logger.info(f"Using LLM options from config: {result}")
|
||||
return result
|
||||
|
||||
# Split a text into smaller chunks of size n, preferably ending at the end of a sentence
|
||||
def create_chunks(text, n, tokenizer):
|
||||
tokens = tokenizer.encode(text)
|
||||
"""Yield successive n-sized chunks from text."""
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
# Find the nearest end of sentence within a range of 0.5 * n and 1.5 * n tokens
|
||||
j = min(i + int(1.5 * n), len(tokens))
|
||||
while j > i + int(0.5 * n):
|
||||
# Decode the tokens and check for full stop or newline
|
||||
chunk = tokenizer.decode(tokens[i:j])
|
||||
if chunk.endswith(".") or chunk.endswith("\n"):
|
||||
break
|
||||
j -= 1
|
||||
# If no end of sentence found, use n tokens as the chunk size
|
||||
if j == i + int(0.5 * n):
|
||||
j = min(i + n, len(tokens))
|
||||
yield tokens[i:j]
|
||||
i = j
|
||||
|
||||
|
||||
def extract_chunk(document, template_prompt):
|
||||
""" Chunking for large documents, exceed context window"""
|
||||
prompt = template_prompt.replace('<document>', document)
|
||||
|
||||
try:
|
||||
response = llm_text_gen(prompt)
|
||||
return response
|
||||
except Exception as err:
|
||||
logger.error(f"Failed to get response from LLM: {err}")
|
||||
raise
|
||||
|
||||
|
||||
def blog_from_pdf(pdf_text):
|
||||
"""
|
||||
Load in a long PDF and extract key information.
|
||||
Chunk up document and process each chunk, then combine them.
|
||||
"""
|
||||
template_prompt=f'''Extract key pieces of information from the given document.
|
||||
|
||||
When you extract a key piece of information, include the closest page number.
|
||||
Ex: Extracted Information (Page number)
|
||||
\n\nDocument: \"\"\"<document>\"\"\"\n\n'''
|
||||
|
||||
# Initialize tokenizer
|
||||
tokenizer = tiktoken.get_encoding("cl100k_base")
|
||||
results = []
|
||||
|
||||
chunks = create_chunks(pdf_text, 1000, tokenizer)
|
||||
text_chunks = [tokenizer.decode(chunk) for chunk in chunks]
|
||||
|
||||
for chunk in text_chunks:
|
||||
try:
|
||||
results.append(extract_chunk(chunk, template_prompt))
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing chunk: {e}")
|
||||
# Continue with other chunks even if one fails
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# Input validation functions
|
||||
def is_youtube_link(text):
|
||||
"""Check if text is a valid YouTube link."""
|
||||
if text is not None:
|
||||
youtube_regex = re.compile(r'(https?://)?(www\.)?(youtube|youtu|youtube-nocookie)\.(com|be)/(watch\?v=|embed/|v/|.+\?v=)?([^&=%\?]{11})')
|
||||
return youtube_regex.match(text)
|
||||
return False
|
||||
|
||||
|
||||
def is_web_link(text):
|
||||
"""Check if text is a valid web link."""
|
||||
if text is not None:
|
||||
web_regex = re.compile(r'(https?://)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)')
|
||||
return web_regex.match(text)
|
||||
return False
|
||||
|
||||
|
||||
def process_input(input_text, uploaded_file):
|
||||
"""
|
||||
Determine the type of input provided by the user.
|
||||
|
||||
Args:
|
||||
input_text (str): The text input from the user
|
||||
uploaded_file: The file uploaded by the user
|
||||
|
||||
Returns:
|
||||
str: The determined input type ("youtube_url", "web_url", "keywords", "PDF_file", "image_file", "audio_file", "video_file", or None)
|
||||
"""
|
||||
# Process text input
|
||||
if input_text:
|
||||
if is_youtube_link(input_text):
|
||||
if input_text.startswith("https://www.youtube.com/") or input_text.startswith("http://www.youtube.com/"):
|
||||
return "youtube_url"
|
||||
else:
|
||||
st.error("Invalid YouTube URL. Please enter a valid URL.")
|
||||
return None
|
||||
elif is_web_link(input_text):
|
||||
return "web_url"
|
||||
else:
|
||||
return "keywords"
|
||||
|
||||
# Process file input
|
||||
if uploaded_file is not None:
|
||||
file_details = {"filename": uploaded_file.name, "filetype": uploaded_file.type}
|
||||
st.write(file_details)
|
||||
|
||||
# Handle different file types
|
||||
if uploaded_file.type.startswith("text/"):
|
||||
content = uploaded_file.read().decode("utf-8")
|
||||
st.text(content)
|
||||
return "text_file"
|
||||
elif uploaded_file.type == "application/pdf":
|
||||
return "PDF_file"
|
||||
elif uploaded_file.type in ["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/msword"]:
|
||||
st.write("Word document uploaded. Add your DOCX processing logic here.")
|
||||
return "word_file"
|
||||
elif uploaded_file.type.startswith("image/"):
|
||||
st.image(uploaded_file)
|
||||
return "image_file"
|
||||
elif uploaded_file.type.startswith("audio/"):
|
||||
st.audio(uploaded_file)
|
||||
return "audio_file"
|
||||
elif uploaded_file.type.startswith("video/"):
|
||||
st.video(uploaded_file)
|
||||
return "video_file"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Content processing functions
|
||||
def process_keywords_input(user_input, search_params, blog_params, selected_content_type):
|
||||
"""Process keywords input and generate content based on the selected options."""
|
||||
if not user_input or len(user_input.split()) < 2:
|
||||
st.error('Please provide at least two keywords for best results')
|
||||
return False
|
||||
|
||||
try:
|
||||
if selected_content_type == "Normal-length content":
|
||||
st.subheader("Your Generated Blog Post")
|
||||
logger.info(f"Generating standard blog post with parameters: {blog_params}")
|
||||
|
||||
# Ensure all blog parameters are properly passed
|
||||
# This is important as the UI may have settings that aren't in the default blog_params
|
||||
short_blog = write_blog_from_keywords(
|
||||
user_input,
|
||||
search_params=search_params,
|
||||
blog_params=blog_params
|
||||
)
|
||||
st.markdown(short_blog)
|
||||
return True
|
||||
|
||||
elif selected_content_type == "Long-form content":
|
||||
logger.info(f"Generating long-form content with parameters: {blog_params}")
|
||||
|
||||
# Ensure all blog parameters are properly passed to long-form generator
|
||||
long_form_generator(
|
||||
user_input,
|
||||
search_params=search_params,
|
||||
blog_params=blog_params
|
||||
)
|
||||
st.success(f"Successfully generated long-form content for: {user_input}")
|
||||
return True
|
||||
|
||||
else:
|
||||
st.info("AI Agent Team feature is coming soon! This will provide multi-perspective content with different AI experts collaborating on your blog.")
|
||||
return False
|
||||
|
||||
except Exception as err:
|
||||
logger.error(f"An error occurred while generating content: {err}")
|
||||
st.error(f"An error occurred while generating content: {err}")
|
||||
return False
|
||||
|
||||
|
||||
def process_pdf_input(uploaded_file):
|
||||
"""Process a PDF file and generate content."""
|
||||
with st.expander("Processing PDF Document", expanded=True):
|
||||
pdf_reader = PyPDF2.PdfReader(uploaded_file)
|
||||
text = ""
|
||||
combined_result = ""
|
||||
|
||||
# Show progress with better UI
|
||||
progress_text = st.empty()
|
||||
progress_bar = st.progress(0)
|
||||
|
||||
total_pages = len(pdf_reader.pages)
|
||||
for page_num, page in enumerate(pdf_reader.pages):
|
||||
progress_text.text(f"Processing page {page_num+1}/{total_pages}")
|
||||
text += page.extract_text()
|
||||
text = text.replace("\n", " ")
|
||||
text = re.sub(r"(\w)([A-Z])", r"\1 \2", text)
|
||||
|
||||
results = blog_from_pdf(text)
|
||||
progress_percent = (page_num + 1) / total_pages
|
||||
progress_bar.progress(progress_percent)
|
||||
combined_result += str(results[-1])
|
||||
|
||||
progress_text.empty()
|
||||
progress_bar.empty()
|
||||
|
||||
st.subheader("Generated Content from PDF")
|
||||
st.markdown(combined_result)
|
||||
return True
|
||||
|
||||
|
||||
def process_youtube_or_audio(user_input):
|
||||
"""Process a YouTube URL or audio file and generate content."""
|
||||
if not generate_audio_blog(user_input):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def process_web_url(user_input):
|
||||
"""Process a web URL and generate content."""
|
||||
blog_from_url(user_input)
|
||||
return True
|
||||
|
||||
|
||||
def process_image_input(user_input, uploaded_file):
|
||||
"""Process an image file and generate content."""
|
||||
blog_from_image(user_input, uploaded_file)
|
||||
return True
|
||||
|
||||
|
||||
def handle_content_generation(input_type, user_input, uploaded_file, search_params, blog_params, selected_content_type):
|
||||
"""
|
||||
Handle content generation based on the input type.
|
||||
|
||||
Args:
|
||||
input_type: The type of input ("youtube_url", "web_url", etc.)
|
||||
user_input: The text input from the user
|
||||
uploaded_file: The uploaded file (if any)
|
||||
search_params: Search parameters
|
||||
blog_params: Blog content parameters
|
||||
selected_content_type: The selected content type
|
||||
|
||||
Returns:
|
||||
bool: True if content generation was successful, False otherwise
|
||||
"""
|
||||
with st.spinner("Crafting your blog content... Please wait."):
|
||||
if input_type == "keywords":
|
||||
return process_keywords_input(user_input, search_params, blog_params, selected_content_type)
|
||||
|
||||
elif input_type == "youtube_url" or input_type == "audio_file":
|
||||
return process_youtube_or_audio(user_input)
|
||||
|
||||
elif input_type == "web_url":
|
||||
return process_web_url(user_input)
|
||||
|
||||
elif input_type == "image_file":
|
||||
return process_image_input(user_input, uploaded_file)
|
||||
|
||||
elif input_type == "PDF_file":
|
||||
return process_pdf_input(uploaded_file)
|
||||
|
||||
else:
|
||||
st.error(f"Unsupported input type: {input_type}")
|
||||
return False
|
||||
252
lib/ai_writers/ai_blog_writer/blog_writer_styles.py
Normal file
252
lib/ai_writers/ai_blog_writer/blog_writer_styles.py
Normal file
@@ -0,0 +1,252 @@
|
||||
import streamlit as st
|
||||
|
||||
def apply_blog_writer_styles():
|
||||
st.markdown("""
|
||||
<style>
|
||||
/* Base UI improvements */
|
||||
body, .main .block-container {
|
||||
background: linear-gradient(135deg, #f0f4f8 0%, #d7e1ec 100%) !important;
|
||||
min-height: 100vh;
|
||||
color: #2c3e50;
|
||||
font-family: 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
/* Main layout improvements */
|
||||
.main .block-container {
|
||||
padding: 1rem 2rem 2rem 2rem !important;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Back button styling */
|
||||
[data-testid="stButton"] > button:first-of-type {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
padding: 0.5rem 1.5rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 10px rgba(25, 118, 210, 0.2);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
[data-testid="stButton"] > button:first-of-type:hover {
|
||||
background: #1565c0;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 15px rgba(25, 118, 210, 0.3);
|
||||
}
|
||||
|
||||
/* Header styling */
|
||||
.blog-header, .page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
padding: 2rem 1.5rem;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f5f7fa 100%);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.blog-header h1, .page-header h1 {
|
||||
font-size: 2.5em;
|
||||
font-family: 'Helvetica Neue', sans-serif;
|
||||
font-weight: 700;
|
||||
color: #1976d2;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.blog-header p, .page-header p {
|
||||
font-size: 1.1em;
|
||||
color: #546e7a;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Input section styling */
|
||||
.stTextArea textarea, .stTextInput input {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
color: #2c3e50;
|
||||
font-size: 1rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.stTextArea textarea:focus, .stTextInput input:focus {
|
||||
border: 1.5px solid #1976d2;
|
||||
box-shadow: 0 2px 10px rgba(25, 118, 210, 0.12);
|
||||
}
|
||||
|
||||
/* File uploader styling */
|
||||
.stFileUploader > div {
|
||||
background: #ffffff;
|
||||
border: 2px dashed #cfd8dc;
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem 1rem;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.stFileUploader > div:hover {
|
||||
border-color: #1976d2;
|
||||
background: rgba(25, 118, 210, 0.03);
|
||||
}
|
||||
|
||||
/* Options expander styling */
|
||||
.stExpander {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin: 1.5rem 0;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.stExpander > details {
|
||||
background: #ffffff;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.stExpander > details > summary {
|
||||
padding: 0.75rem 1rem;
|
||||
font-weight: 600;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.stExpander > details > summary:hover {
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
/* Checkbox styling */
|
||||
.stCheckbox > div {
|
||||
background: #ffffff;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.stCheckbox > div:hover {
|
||||
background: rgba(25, 118, 210, 0.03);
|
||||
}
|
||||
|
||||
.stCheckbox label {
|
||||
font-weight: 500;
|
||||
color: #455a64;
|
||||
}
|
||||
|
||||
/* Radio button styling */
|
||||
.stRadio > div {
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.stRadio > div > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stRadio > div > div > label {
|
||||
background: #f5f7fa;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 30px;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
color: #546e7a;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.stRadio > div > div > label:hover {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
border-color: #bbdefb;
|
||||
}
|
||||
|
||||
.stRadio > div > div > label[data-baseweb="radio"] input:checked + div {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
border-color: #1976d2;
|
||||
}
|
||||
|
||||
/* Generate button styling */
|
||||
button[data-testid="baseButton-secondary"],
|
||||
button[data-testid="baseButton-primary"] {
|
||||
background: linear-gradient(45deg, #1976d2, #2196f3);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
padding: 0.85rem 1.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 0.5px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(25, 118, 210, 0.25);
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
button[data-testid="baseButton-secondary"]:hover,
|
||||
button[data-testid="baseButton-primary"]:hover {
|
||||
background: linear-gradient(45deg, #1565c0, #1976d2);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(25, 118, 210, 0.35);
|
||||
}
|
||||
|
||||
/* Input labels */
|
||||
.stTextArea label, .stTextInput label, .stFileUploader label {
|
||||
font-weight: 600;
|
||||
color: #455a64;
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Section headers */
|
||||
.stMarkdown h3 {
|
||||
color: #1976d2;
|
||||
font-weight: 600;
|
||||
font-size: 1.3rem;
|
||||
margin: 1.5rem 0 0.75rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid rgba(25, 118, 210, 0.1);
|
||||
}
|
||||
|
||||
.stMarkdown h4 {
|
||||
color: #455a64;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
margin: 1rem 0 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Column layout improvements */
|
||||
[data-testid="column"] {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 1.2rem;
|
||||
box-shadow: 0 2px 15px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
/* Success and error messages */
|
||||
.stSuccess, .stInfo, .stError {
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
""", unsafe_allow_html=True)
|
||||
Reference in New Issue
Block a user