diff --git "a/app.py" "b/app.py"
--- "a/app.py"
+++ "b/app.py"
@@ -255,6 +255,25 @@ class NovelDatabase:
)
''')
+ # NEW: Random themes library table
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS random_themes_library (
+ theme_id TEXT PRIMARY KEY,
+ theme_text TEXT NOT NULL,
+ language TEXT NOT NULL,
+ title TEXT,
+ opening_sentence TEXT,
+ protagonist TEXT,
+ conflict TEXT,
+ philosophical_question TEXT,
+ generated_at TEXT DEFAULT (datetime('now')),
+ view_count INTEGER DEFAULT 0,
+ used_count INTEGER DEFAULT 0,
+ tags TEXT,
+ metadata TEXT
+ )
+ ''')
+
conn.commit()
@staticmethod
@@ -401,6 +420,81 @@ class NovelDatabase:
return tracker
return None
+ # NEW: Random theme library methods
+ @staticmethod
+ def save_random_theme(theme_text: str, language: str, metadata: Dict[str, Any]) -> str:
+ """Save randomly generated theme to library"""
+ theme_id = hashlib.md5(f"{theme_text}{datetime.now()}".encode()).hexdigest()[:12]
+
+ # Extract components from theme text
+ title = metadata.get('title', '')
+ opening_sentence = metadata.get('opening_sentence', '')
+ protagonist = metadata.get('protagonist', '')
+ conflict = metadata.get('conflict', '')
+ philosophical_question = metadata.get('philosophical_question', '')
+ tags = json.dumps(metadata.get('tags', []))
+
+ with NovelDatabase.get_db() as conn:
+ conn.cursor().execute('''
+ INSERT INTO random_themes_library
+ (theme_id, theme_text, language, title, opening_sentence,
+ protagonist, conflict, philosophical_question, tags, metadata)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ''', (theme_id, theme_text, language, title, opening_sentence,
+ protagonist, conflict, philosophical_question, tags,
+ json.dumps(metadata)))
+ conn.commit()
+
+ return theme_id
+
+ @staticmethod
+ def get_random_themes_library(language: str = None, limit: int = 50) -> List[Dict]:
+ """Get random themes from library"""
+ with NovelDatabase.get_db() as conn:
+ query = '''
+ SELECT * FROM random_themes_library
+ {}
+ ORDER BY generated_at DESC
+ LIMIT ?
+ '''.format('WHERE language = ?' if language else '')
+
+ if language:
+ rows = conn.cursor().execute(query, (language, limit)).fetchall()
+ else:
+ rows = conn.cursor().execute(query, (limit,)).fetchall()
+
+ return [dict(row) for row in rows]
+
+ @staticmethod
+ def update_theme_view_count(theme_id: str):
+ """Update view count for a theme"""
+ with NovelDatabase.get_db() as conn:
+ conn.cursor().execute(
+ 'UPDATE random_themes_library SET view_count = view_count + 1 WHERE theme_id = ?',
+ (theme_id,)
+ )
+ conn.commit()
+
+ @staticmethod
+ def update_theme_used_count(theme_id: str):
+ """Update used count when theme is used for novel"""
+ with NovelDatabase.get_db() as conn:
+ conn.cursor().execute(
+ 'UPDATE random_themes_library SET used_count = used_count + 1 WHERE theme_id = ?',
+ (theme_id,)
+ )
+ conn.commit()
+
+ @staticmethod
+ def get_theme_by_id(theme_id: str) -> Optional[Dict]:
+ """Get specific theme by ID"""
+ with NovelDatabase.get_db() as conn:
+ row = conn.cursor().execute(
+ 'SELECT * FROM random_themes_library WHERE theme_id = ?',
+ (theme_id,)
+ ).fetchone()
+ return dict(row) if row else None
+
# Maintain existing methods
@staticmethod
def get_session(session_id: str) -> Optional[Dict]:
@@ -1408,7 +1502,7 @@ Provide specific and actionable revision instructions."""
logger.error(f"Novel generation process error: {e}", exc_info=True)
yield f"❌ Error occurred: {e}", stages if 'stages' in locals() else [], self.current_session_id
- def get_stage_prompt(self, stage_idx: int, role: str, query: str,
+def get_stage_prompt(self, stage_idx: int, role: str, query: str,
language: str, stages: List[Dict]) -> str:
"""Generate stage-specific prompt"""
if stage_idx == 0: # Director initial planning
@@ -1739,8 +1833,8 @@ def export_to_docx(content: str, filename: str, language: str, session_id: str)
patterns_to_remove = [
r'^#{1,6}\s+.*', # Markdown headers
r'^\*\*.*\*\*', # 굵은 글씨 **text**
- r'^Part\s*\d+.*', # “Part 1 …” 형식
- r'^\d+\.\s+.*:.*', # “1. 제목: …” 형식
+ r'^Part\s*\d+.*', # "Part 1 …" 형식
+ r'^\d+\.\s+.*:.*', # "1. 제목: …" 형식
r'^---+', # 구분선
r'^\s*\[.*\]\s*', # 대괄호 라벨
]
@@ -1829,131 +1923,561 @@ def export_to_txt(content: str, filename: str) -> str:
return filepath
-# CSS styles
-# CSS styles - Writer's Study Theme (Light & Warm)
-custom_css = """
-/* Global container - Light paper background */
-.gradio-container {
- background: linear-gradient(135deg, #faf8f3 0%, #f5f2e8 50%, #f0ebe0 100%);
- min-height: 100vh;
- font-family: 'Georgia', 'Times New Roman', serif;
-}
+def generate_random_theme(language="English"):
+ """Generate a coherent and natural novel theme using LLM"""
+ try:
+ # JSON 파일 로드
+ json_path = Path("novel_themes.json")
+ if not json_path.exists():
+ print("[WARNING] novel_themes.json not found, using built-in data")
+ # 기본 데이터 정의 - 더 현실적인 테마로 수정
+ themes_data = {
+ "themes": ["family secrets", "career transition", "lost love", "friendship test", "generational conflict"],
+ "characters": ["middle-aged teacher", "retiring doctor", "single parent", "immigrant artist", "war veteran"],
+ "hooks": ["unexpected inheritance", "old diary discovery", "chance reunion", "life-changing diagnosis", "sudden job loss"],
+ "questions": ["What defines family?", "Can people truly change?", "What is worth sacrificing?", "How do we forgive?"]
+ }
+ else:
+ with open(json_path, 'r', encoding='utf-8') as f:
+ data = json.load(f)
+ # 가중치 기반 필터링 - 현실적인 테마 우선
+ realistic_themes = []
+ for theme_key, theme_data in data.get('core_themes', {}).items():
+ weight = theme_data.get('weight', 0.1)
+ # 현실적인 테마에 더 높은 가중치
+ if any(word in theme_key for word in ['family', 'love', 'work', 'memory', 'identity', 'aging']):
+ weight *= 1.5
+ elif any(word in theme_key for word in ['digital', 'extinction', 'apocalypse', 'quantum']):
+ weight *= 0.5
+ realistic_themes.append((theme_key, weight))
+
+ # 가중치 기반 선택
+ themes = [t[0] for t in sorted(realistic_themes, key=lambda x: x[1], reverse=True)[:10]]
+
+ themes_data = {
+ "themes": themes if themes else ["family secrets", "career crisis", "lost love"],
+ "characters": [],
+ "hooks": [],
+ "questions": []
+ }
+
+ # Extract realistic data
+ for char_data in data.get('characters', {}).values():
+ for variation in char_data.get('variations', []):
+ # 현실적인 캐릭터 필터링
+ if not any(word in variation.lower() for word in ['cyborg', 'quantum', 'binary', 'extinct']):
+ themes_data["characters"].append(variation)
+
+ for hook_list in data.get('narrative_hooks', {}).values():
+ for hook in hook_list:
+ # 현실적인 사건 필터링
+ if not any(word in hook.lower() for word in ['download', 'digital', 'algorithm', 'corporate subscription']):
+ themes_data["hooks"].append(hook)
+
+ for phil_data in data.get('philosophies', {}).values():
+ themes_data["questions"].extend(phil_data.get('core_questions', []))
+
+ # 기본값 설정
+ if not themes_data["characters"]:
+ themes_data["characters"] = ["struggling artist", "retired teacher", "young mother", "elderly caregiver", "small business owner"]
+ if not themes_data["hooks"]:
+ themes_data["hooks"] = ["discovering family secret", "unexpected reunion", "facing illness", "losing home", "finding old letters"]
+ if not themes_data["questions"]:
+ themes_data["questions"] = ["What makes a family?", "How do we find meaning?", "Can we escape our past?", "What legacy do we leave?"]
+
+ # Random selection
+ import secrets
+ theme = secrets.choice(themes_data["themes"])
+ character = secrets.choice(themes_data["characters"])
+ hook = secrets.choice(themes_data["hooks"])
+ question = secrets.choice(themes_data["questions"])
+
+ # 언어별 프롬프트 - 톤과 스타일 섹션 제거
+ if language == "Korean":
+ # 한국어 번역 및 자연스러�� 표현
+ theme_kr = translate_theme_naturally(theme, "theme")
+ character_kr = translate_theme_naturally(character, "character")
+ hook_kr = translate_theme_naturally(hook, "hook")
+ question_kr = translate_theme_naturally(question, "question")
+
+ prompt = f"""다음 요소들을 사용하여 현실적이고 공감가능한 소설 주제를 생성하세요:
-/* Main header - Classic book cover feel */
-.main-header {
- background: linear-gradient(145deg, #ffffff 0%, #fdfcf8 100%);
- backdrop-filter: blur(10px);
- padding: 45px;
- border-radius: 20px;
- margin-bottom: 35px;
- text-align: center;
- color: #3d2914;
- border: 1px solid #e8dcc6;
- box-shadow: 0 10px 30px rgba(139, 69, 19, 0.08),
- 0 5px 15px rgba(139, 69, 19, 0.05),
- inset 0 1px 2px rgba(255, 255, 255, 0.9);
- position: relative;
- overflow: hidden;
-}
+주제: {theme_kr}
+인물: {character_kr}
+사건: {hook_kr}
+핵심 질문: {question_kr}
-/* Book spine decoration */
-.main-header::before {
- content: '';
- position: absolute;
- left: 50px;
- top: 0;
- bottom: 0;
- width: 3px;
- background: linear-gradient(180deg, #d4a574 0%, #c19656 50%, #d4a574 100%);
- box-shadow: 1px 0 2px rgba(0, 0, 0, 0.1);
-}
+요구사항:
+1. 현대 한국 사회에서 일어날 수 있는 현실적인 이야기
+2. 보편적으로 공감할 수 있는 인물과 상황
+3. 구체적이고 생생한 배경 설정
+4. 깊이 있는 심리 묘사가 가능한 갈등
-.header-title {
- font-size: 3.2em;
- margin-bottom: 20px;
- font-weight: 700;
- color: #2c1810;
- text-shadow: 2px 2px 4px rgba(139, 69, 19, 0.1);
- font-family: 'Playfair Display', 'Georgia', serif;
- letter-spacing: -0.5px;
-}
+다음 형식으로 간결하게 작성하세요:
-.header-description {
- font-size: 0.95em;
- color: #5a453a;
- line-height: 1.7;
- margin-top: 25px;
- text-align: justify;
- max-width: 920px;
- margin-left: auto;
- margin-right: auto;
- font-family: 'Georgia', serif;
-}
+[한 문장으로 된 매력적인 첫 문장]
-.badges-container {
- display: flex;
- justify-content: center;
- gap: 12px;
- margin-top: 25px;
- margin-bottom: 25px;
-}
+주인공은 [구체적인 상황의 인물]입니다.
+[핵심 사건]을 계기로 [내적 갈등]에 직면하게 되고,
+결국 [철학적 질문]에 대한 답을 찾아가는 여정을 그립니다."""
+
+ else:
+ prompt = f"""Generate a realistic and relatable novel theme using these elements:
-/* Progress notes - Manuscript notes style */
-.progress-note {
- background: linear-gradient(135deg, #fff9e6 0%, #fff5d6 100%);
- border-left: 4px solid #d4a574;
- padding: 22px 30px;
- margin: 25px auto;
- border-radius: 12px;
- color: #5a453a;
- max-width: 820px;
- font-weight: 500;
- box-shadow: 0 4px 12px rgba(212, 165, 116, 0.15);
- position: relative;
-}
+Theme: {theme}
+Character: {character}
+Event: {hook}
+Core Question: {question}
-/* Handwritten note effect */
-.progress-note::after {
- content: '📌';
- position: absolute;
- top: -10px;
- right: 20px;
- font-size: 24px;
- transform: rotate(15deg);
-}
+Requirements:
+1. A story that could happen in contemporary society
+2. Universally relatable characters and situations
+3. Specific and vivid settings
+4. Conflicts allowing deep psychological exploration
-.warning-note {
- background: #fef3e2;
- border-left: 4px solid #f6b73c;
- padding: 18px 25px;
- margin: 20px auto;
- border-radius: 10px;
- color: #7a5c00;
- max-width: 820px;
- font-size: 0.92em;
- box-shadow: 0 3px 10px rgba(246, 183, 60, 0.15);
-}
+Write concisely in this format:
-/* Input section - Writing desk feel */
-.input-section {
- background: linear-gradient(145deg, #ffffff 0%, #fcfaf7 100%);
- backdrop-filter: blur(10px);
- padding: 30px;
- border-radius: 16px;
- margin-bottom: 28px;
- border: 1px solid #e8dcc6;
- box-shadow: 0 6px 20px rgba(139, 69, 19, 0.06),
- inset 0 1px 3px rgba(255, 255, 255, 0.8);
-}
+[One compelling opening sentence]
-/* Session section - File cabinet style */
-.session-section {
- background: linear-gradient(145deg, #f8f4ed 0%, #f3ede2 100%);
- backdrop-filter: blur(8px);
- padding: 22px;
- border-radius: 14px;
- margin-top: 28px;
- color: #3d2914;
+The protagonist is [character in specific situation].
+Through [key event], they face [internal conflict],
+ultimately embarking on a journey to answer [philosophical question]."""
+
+ # Use the UnifiedLiterarySystem's LLM to generate coherent theme
+ system = UnifiedLiterarySystem()
+
+ # Call LLM synchronously for theme generation
+ messages = [{"role": "user", "content": prompt}]
+ generated_theme = system.call_llm_sync(messages, "director", language)
+
+ # Extract metadata for database storage
+ metadata = extract_theme_metadata(generated_theme, language)
+ metadata.update({
+ 'original_theme': theme,
+ 'original_character': character,
+ 'original_hook': hook,
+ 'original_question': question
+ })
+
+ # Save to database
+ theme_id = NovelDatabase.save_random_theme(generated_theme, language, metadata)
+ logger.info(f"Saved random theme with ID: {theme_id}")
+
+ # 톤과 스타일 섹션 제거 - 불필요한 반복 내용 삭제
+ if "**톤과 스타일:**" in generated_theme or "**Tone and Style:**" in generated_theme:
+ lines = generated_theme.split('\n')
+ filtered_lines = []
+ skip = False
+ for line in lines:
+ if "톤과 스타일" in line or "Tone and Style" in line:
+ skip = True
+ elif skip and (line.strip() == "" or line.startswith("**")):
+ skip = False
+ if not skip:
+ filtered_lines.append(line)
+ generated_theme = '\n'.join(filtered_lines).strip()
+
+ return generated_theme
+
+ except Exception as e:
+ logger.error(f"Theme generation error: {str(e)}")
+ # Fallback to simple realistic themes
+ fallback_themes = {
+ "Korean": [
+ """"아버지가 돌아가신 날, 나는 그가 평생 숨겨온 또 다른 가족의 존재를 알게 되었다."
+
+주인공은 평범한 회사원으로 살아온 40대 여성입니다.
+아버지의 장례식에서 낯선 여인과 그녀의 딸을 만나게 되면서 가족의 의미에 대해 다시 생각하게 되고,
+결국 진정한 가족이란 무엇인지에 대한 답을 찾아가는 여정을 그립니다.""",
+
+ """"서른 년간 가르친 학교에서 나온 날, 처음으로 내가 누구인지 몰랐다."
+
+주인공은 정년퇴직을 맞은 고등학교 국어 교사입니다.
+갑작스러운 일상의 공백 속에서 잊고 지냈던 젊은 날의 꿈을 마주하게 되고,
+결국 남은 인생에서 무엇을 할 것인가에 대한 답을 찾아가는 여정을 그립니다."""
+ ],
+ "English": [
+ """"The day my father died, I discovered he had another family he'd hidden all his life."
+
+The protagonist is a woman in her 40s who has lived as an ordinary office worker.
+Through meeting a strange woman and her daughter at her father's funeral, she confronts what family truly means,
+ultimately embarking on a journey to answer what constitutes a real family.""",
+
+ """"The day I left the school where I'd taught for thirty years, I didn't know who I was anymore."
+
+The protagonist is a high school literature teacher facing retirement.
+Through the sudden emptiness of daily life, they confront long-forgotten dreams of youth,
+ultimately embarking on a journey to answer what to do with the remaining years."""
+ ]
+ }
+
+ import secrets
+ return secrets.choice(fallback_themes.get(language, fallback_themes["English"]))
+
+def translate_theme_naturally(text, category):
+ """자연스러운 한국어 번역"""
+ translations = {
+ # 테마
+ "family secrets": "가족의 비밀",
+ "career transition": "인생의 전환점",
+ "lost love": "잃어버린 사랑",
+ "friendship test": "우정의 시험",
+ "generational conflict": "세대 간 갈등",
+ "digital extinction": "디지털 시대의 소외",
+ "sensory revolution": "감각의 혁명",
+ "temporal paradox": "시간의 역설",
+
+ # 캐릭터
+ "struggling artist": "생활고에 시달리는 예술가",
+ "retired teacher": "은퇴한 교사",
+ "young mother": "젊은 엄마",
+ "elderly caregiver": "노인을 돌보는 간병인",
+ "small business owner": "작은 가게 주인",
+ "middle-aged teacher": "중년의 교사",
+ "retiring doctor": "은퇴를 앞둔 의사",
+ "single parent": "혼자 아이를 키우는 부모",
+ "immigrant artist": "이민자 예술가",
+ "war veteran": "전쟁 참전용사",
+ "last person who dreams without ads": "광고 없이 꿈꾸는 마지막 사람",
+ "memory trader": "기억 거래상",
+
+ # 사건
+ "discovering family secret": "가족의 비밀을 발견하다",
+ "unexpected reunion": "예상치 못한 재회",
+ "facing illness": "질병과 마주하다",
+ "losing home": "집을 잃다",
+ "finding old letters": "오래된 편지를 발견하다",
+ "unexpected inheritance": "뜻밖의 유산",
+ "old diary discovery": "오래된 일기장 발견",
+ "chance reunion": "우연한 재회",
+ "life-changing diagnosis": "인생을 바꾸는 진단",
+ "sudden job loss": "갑작스러운 실직",
+ "discovers their memories belong to a corporate subscription": "기억이 기업 서비스의 일부임을 발견하다",
+
+ # 질문
+ "What makes a family?": "가족이란 무엇인가?",
+ "How do we find meaning?": "우리는 어떻게 의미를 찾는가?",
+ "Can we escape our past?": "과거로부터 벗어날 수 있는가?",
+ "What legacy do we leave?": "우리는 어떤 유산을 남기는가?",
+ "What defines family?": "무엇이 가족을 정의하는가?",
+ "Can people truly change?": "사람은 정말 변할 수 있는가?",
+ "What is worth sacrificing?": "무엇을 위해 희생할 가치가 있는가?",
+ "How do we forgive?": "우리는 어떻게 용서하는가?",
+ "What remains human when humanity is optional?": "인간성이 선택사항일 때 무엇이 인간으로 남는가?"
+ }
+
+ # 먼저 정확한 매칭 시도
+ if text in translations:
+ return translations[text]
+
+ # 부분 매칭 시도
+ text_lower = text.lower()
+ for key, value in translations.items():
+ if key.lower() in text_lower or text_lower in key.lower():
+ return value
+
+ # 번역이 없으면 원문 반환
+ return text
+
+def extract_theme_metadata(theme_text: str, language: str) -> Dict[str, Any]:
+ """Extract metadata from generated theme text"""
+ metadata = {
+ 'title': '',
+ 'opening_sentence': '',
+ 'protagonist': '',
+ 'conflict': '',
+ 'philosophical_question': '',
+ 'tags': []
+ }
+
+ lines = theme_text.split('\n')
+
+ # Extract opening sentence (usually in quotes)
+ for line in lines:
+ if '"' in line or '"' in line or '「' in line:
+ # Extract text between quotes
+ import re
+ quotes = re.findall(r'["""「](.*?)["""」]', line)
+ if quotes:
+ metadata['opening_sentence'] = quotes[0]
+ break
+
+ # Extract other elements based on patterns
+ for i, line in enumerate(lines):
+ line = line.strip()
+
+ # Title extraction (if exists)
+ if i == 0 and not any(quote in line for quote in ['"', '"', '「']):
+ metadata['title'] = line.replace('**', '').strip()
+
+ # Protagonist
+ if any(marker in line for marker in ['protagonist is', '주인공은', 'The protagonist']):
+ metadata['protagonist'] = line.split('is' if 'is' in line else '은')[-1].strip().rstrip('.')
+
+ # Conflict/Event
+ if any(marker in line for marker in ['Through', '통해', '계기로', 'face']):
+ metadata['conflict'] = line
+
+ # Question
+ if any(marker in line for marker in ['answer', '답을', 'question', '질문']):
+ metadata['philosophical_question'] = line
+
+ # Generate tags based on content
+ tag_keywords = {
+ 'family': ['family', '가족', 'father', '아버지', 'mother', '어머니'],
+ 'love': ['love', '사랑', 'relationship', '관계'],
+ 'death': ['death', '죽음', 'died', '돌아가신'],
+ 'memory': ['memory', '기억', 'remember', '추억'],
+ 'identity': ['identity', '정체성', 'who am I', '누구인지'],
+ 'work': ['work', '일', 'career', '직업', 'retirement', '은퇴'],
+ 'aging': ['aging', '노화', 'old', '늙은', 'elderly', '노인']
+ }
+
+ theme_lower = theme_text.lower()
+ for tag, keywords in tag_keywords.items():
+ if any(keyword in theme_lower for keyword in keywords):
+ metadata['tags'].append(tag)
+
+ return metadata
+
+def format_theme_card(theme_data: Dict, language: str) -> str:
+ """Format theme data as a card for display"""
+ theme_id = theme_data.get('theme_id', '')
+ theme_text = theme_data.get('theme_text', '')
+ generated_at = theme_data.get('generated_at', '')
+ view_count = theme_data.get('view_count', 0)
+ used_count = theme_data.get('used_count', 0)
+ tags = json.loads(theme_data.get('tags', '[]'))
+
+ # Format timestamp
+ if generated_at:
+ try:
+ dt = datetime.fromisoformat(generated_at.replace(' ', 'T'))
+ time_str = dt.strftime('%Y-%m-%d %H:%M')
+ except:
+ time_str = generated_at
+ else:
+ time_str = ""
+
+ # Create tag badges
+ tag_badges = ' '.join([f'{tag}' for tag in tags])
+
+ # Truncate theme text for card display
+ if len(theme_text) > 300:
+ preview_text = theme_text[:300] + "..."
+ else:
+ preview_text = theme_text
+
+ # Create card HTML
+ card_html = f"""
+
+
+
+
{preview_text}
+
{tag_badges}
+
+
+
"""
+
+ return card_html
+
+def get_theme_library_display(language: str = None, search_query: str = "") -> str:
+ """Get formatted display of theme library"""
+ themes = NovelDatabase.get_random_themes_library(language, limit=50)
+
+ if not themes:
+ empty_msg = {
+ "Korean": "아직 생성된 테마가 없습니다. 랜덤 버튼을 눌러 첫 테마를 만들어보세요!",
+ "English": "No themes generated yet. Click the Random button to create your first theme!"
+ }
+ return f'{empty_msg.get(language, empty_msg["English"])}
'
+
+ # Filter by search query if provided
+ if search_query:
+ search_lower = search_query.lower()
+ themes = [t for t in themes if search_lower in t.get('theme_text', '').lower()]
+
+ # Statistics
+ total_themes = len(themes)
+ total_views = sum(t.get('view_count', 0) for t in themes)
+ total_uses = sum(t.get('used_count', 0) for t in themes)
+
+ stats_html = f"""
+
+
+ {'총 테마' if language == 'Korean' else 'Total Themes'}
+ {total_themes}
+
+
+ {'총 조회수' if language == 'Korean' else 'Total Views'}
+ {total_views}
+
+
+ {'총 사용수' if language == 'Korean' else 'Total Uses'}
+ {total_uses}
+
+
"""
+
+ # Theme cards
+ cards_html = ''
+ for theme in themes:
+ cards_html += format_theme_card(theme, language)
+ cards_html += '
'
+
+ # JavaScript for interactions
+ js_script = """
+"""
+
+ return stats_html + cards_html + js_script
+
+# CSS styles
+custom_css = """
+/* Global container - Light paper background */
+.gradio-container {
+ background: linear-gradient(135deg, #faf8f3 0%, #f5f2e8 50%, #f0ebe0 100%);
+ min-height: 100vh;
+ font-family: 'Georgia', 'Times New Roman', serif;
+}
+
+/* Main header - Classic book cover feel */
+.main-header {
+ background: linear-gradient(145deg, #ffffff 0%, #fdfcf8 100%);
+ backdrop-filter: blur(10px);
+ padding: 45px;
+ border-radius: 20px;
+ margin-bottom: 35px;
+ text-align: center;
+ color: #3d2914;
+ border: 1px solid #e8dcc6;
+ box-shadow: 0 10px 30px rgba(139, 69, 19, 0.08),
+ 0 5px 15px rgba(139, 69, 19, 0.05),
+ inset 0 1px 2px rgba(255, 255, 255, 0.9);
+ position: relative;
+ overflow: hidden;
+}
+
+/* Book spine decoration */
+.main-header::before {
+ content: '';
+ position: absolute;
+ left: 50px;
+ top: 0;
+ bottom: 0;
+ width: 3px;
+ background: linear-gradient(180deg, #d4a574 0%, #c19656 50%, #d4a574 100%);
+ box-shadow: 1px 0 2px rgba(0, 0, 0, 0.1);
+}
+
+.header-title {
+ font-size: 3.2em;
+ margin-bottom: 20px;
+ font-weight: 700;
+ color: #2c1810;
+ text-shadow: 2px 2px 4px rgba(139, 69, 19, 0.1);
+ font-family: 'Playfair Display', 'Georgia', serif;
+ letter-spacing: -0.5px;
+}
+
+.header-description {
+ font-size: 0.95em;
+ color: #5a453a;
+ line-height: 1.7;
+ margin-top: 25px;
+ text-align: justify;
+ max-width: 920px;
+ margin-left: auto;
+ margin-right: auto;
+ font-family: 'Georgia', serif;
+}
+
+.badges-container {
+ display: flex;
+ justify-content: center;
+ gap: 12px;
+ margin-top: 25px;
+ margin-bottom: 25px;
+}
+
+/* Progress notes - Manuscript notes style */
+.progress-note {
+ background: linear-gradient(135deg, #fff9e6 0%, #fff5d6 100%);
+ border-left: 4px solid #d4a574;
+ padding: 22px 30px;
+ margin: 25px auto;
+ border-radius: 12px;
+ color: #5a453a;
+ max-width: 820px;
+ font-weight: 500;
+ box-shadow: 0 4px 12px rgba(212, 165, 116, 0.15);
+ position: relative;
+}
+
+/* Handwritten note effect */
+.progress-note::after {
+ content: '📌';
+ position: absolute;
+ top: -10px;
+ right: 20px;
+ font-size: 24px;
+ transform: rotate(15deg);
+}
+
+.warning-note {
+ background: #fef3e2;
+ border-left: 4px solid #f6b73c;
+ padding: 18px 25px;
+ margin: 20px auto;
+ border-radius: 10px;
+ color: #7a5c00;
+ max-width: 820px;
+ font-size: 0.92em;
+ box-shadow: 0 3px 10px rgba(246, 183, 60, 0.15);
+}
+
+/* Input section - Writing desk feel */
+.input-section {
+ background: linear-gradient(145deg, #ffffff 0%, #fcfaf7 100%);
+ backdrop-filter: blur(10px);
+ padding: 30px;
+ border-radius: 16px;
+ margin-bottom: 28px;
+ border: 1px solid #e8dcc6;
+ box-shadow: 0 6px 20px rgba(139, 69, 19, 0.06),
+ inset 0 1px 3px rgba(255, 255, 255, 0.8);
+}
+
+/* Session section - File cabinet style */
+.session-section {
+ background: linear-gradient(145deg, #f8f4ed 0%, #f3ede2 100%);
+ backdrop-filter: blur(8px);
+ padding: 22px;
+ border-radius: 14px;
+ margin-top: 28px;
+ color: #3d2914;
border: 1px solid #ddd0b8;
box-shadow: inset 0 2px 4px rgba(139, 69, 19, 0.08);
}
@@ -2205,405 +2729,207 @@ select, .gr-dropdown {
}
"""
-def load_theme_data():
- """Load theme data from JSON file"""
- json_path = Path("novel_themes.json")
- if json_path.exists():
- with open(json_path, 'r', encoding='utf-8') as f:
- return json.load(f)
- else:
- # Fallback data if JSON file not found
- return {
- "core_themes": {
- "digital_extinction": {
- "weight": 0.5,
- "compatible_elements": {
- "characters": ["last_human"],
- "philosophies": ["posthuman"]
- }
- }
- },
- "characters": {
- "last_human": {
- "variations": ["last person who dreams without ads"],
- "traits": ["stubborn", "melancholic"],
- "arc_potential": "preservation_vs_evolution"
- }
- },
- "philosophies": {
- "posthuman": {
- "core_questions": ["What remains human when humanity is optional?"],
- "manifestations": ["voluntary human extinction movements"]
- }
- },
- "narrative_hooks": {
- "identity_crisis": ["discovers their memories belong to a corporate subscription"]
- },
- "opening_sentences": {
- "shocking": ["The notification read: 'Your humanity subscription expires in 24 hours.'"]
- }
- }
+# Additional CSS for theme library
+theme_library_css = """
+/* Theme Library Styles */
+.library-stats {
+ display: flex;
+ justify-content: space-around;
+ margin-bottom: 30px;
+ padding: 20px;
+ background: linear-gradient(145deg, #f8f4ed 0%, #f3ede2 100%);
+ border-radius: 12px;
+ box-shadow: 0 4px 12px rgba(139, 69, 19, 0.08);
+}
-def weighted_random_choice(items_dict):
- """Select item based on weights"""
- items = list(items_dict.keys())
- weights = [items_dict[item].get('weight', 0.1) for item in items]
- # random.choices 대신 numpy 사용하거나 수동으로 구현
- total_weight = sum(weights)
- r = random.uniform(0, total_weight)
- upto = 0
- for i, item in enumerate(items):
- if upto + weights[i] >= r:
- return item
- upto += weights[i]
- return items[-1]
+.stat-item {
+ text-align: center;
+}
-
-def generate_random_theme(language="English"):
- """Generate a coherent and natural novel theme using LLM"""
- try:
- # JSON 파일 로드
- json_path = Path("novel_themes.json")
- if not json_path.exists():
- print("[WARNING] novel_themes.json not found, using built-in data")
- # 기본 데이터 정의 - 더 현실적인 테마로 수정
- themes_data = {
- "themes": ["family secrets", "career transition", "lost love", "friendship test", "generational conflict"],
- "characters": ["middle-aged teacher", "retiring doctor", "single parent", "immigrant artist", "war veteran"],
- "hooks": ["unexpected inheritance", "old diary discovery", "chance reunion", "life-changing diagnosis", "sudden job loss"],
- "questions": ["What defines family?", "Can people truly change?", "What is worth sacrificing?", "How do we forgive?"]
- }
- else:
- with open(json_path, 'r', encoding='utf-8') as f:
- data = json.load(f)
- # 가중치 기반 필터링 - 현실적인 테마 우선
- realistic_themes = []
- for theme_key, theme_data in data.get('core_themes', {}).items():
- weight = theme_data.get('weight', 0.1)
- # 현실적인 테마에 더 높은 가중치
- if any(word in theme_key for word in ['family', 'love', 'work', 'memory', 'identity', 'aging']):
- weight *= 1.5
- elif any(word in theme_key for word in ['digital', 'extinction', 'apocalypse', 'quantum']):
- weight *= 0.5
- realistic_themes.append((theme_key, weight))
-
- # 가중치 기반 선택
- themes = [t[0] for t in sorted(realistic_themes, key=lambda x: x[1], reverse=True)[:10]]
-
- themes_data = {
- "themes": themes if themes else ["family secrets", "career crisis", "lost love"],
- "characters": [],
- "hooks": [],
- "questions": []
- }
-
- # Extract realistic data
- for char_data in data.get('characters', {}).values():
- for variation in char_data.get('variations', []):
- # 현실적인 캐릭터 필터링
- if not any(word in variation.lower() for word in ['cyborg', 'quantum', 'binary', 'extinct']):
- themes_data["characters"].append(variation)
-
- for hook_list in data.get('narrative_hooks', {}).values():
- for hook in hook_list:
- # 현실적인 사건 필터링
- if not any(word in hook.lower() for word in ['download', 'digital', 'algorithm', 'corporate subscription']):
- themes_data["hooks"].append(hook)
-
- for phil_data in data.get('philosophies', {}).values():
- themes_data["questions"].extend(phil_data.get('core_questions', []))
-
- # 기본값 설정
- if not themes_data["characters"]:
- themes_data["characters"] = ["struggling artist", "retired teacher", "young mother", "elderly caregiver", "small business owner"]
- if not themes_data["hooks"]:
- themes_data["hooks"] = ["discovering family secret", "unexpected reunion", "facing illness", "losing home", "finding old letters"]
- if not themes_data["questions"]:
- themes_data["questions"] = ["What makes a family?", "How do we find meaning?", "Can we escape our past?", "What legacy do we leave?"]
-
- # Random selection
- import secrets
- theme = secrets.choice(themes_data["themes"])
- character = secrets.choice(themes_data["characters"])
- hook = secrets.choice(themes_data["hooks"])
- question = secrets.choice(themes_data["questions"])
-
- # 언어별 프롬프트 - 톤과 스타일 섹션 제거
- if language == "Korean":
- # 한국어 번역 및 자연스러운 표현
- theme_kr = translate_theme_naturally(theme, "theme")
- character_kr = translate_theme_naturally(character, "character")
- hook_kr = translate_theme_naturally(hook, "hook")
- question_kr = translate_theme_naturally(question, "question")
-
- prompt = f"""다음 요소들을 사용하여 현실적이고 공감가능한 소설 주제를 생성하세요:
+.stat-label {
+ display: block;
+ font-size: 0.9em;
+ color: #5a453a;
+ margin-bottom: 5px;
+}
-주제: {theme_kr}
-인물: {character_kr}
-사건: {hook_kr}
-핵심 질문: {question_kr}
+.stat-value {
+ display: block;
+ font-size: 2em;
+ font-weight: bold;
+ color: #2c1810;
+ font-family: 'Playfair Display', 'Georgia', serif;
+}
-요구사항:
-1. 현대 한국 사회에서 일어날 수 있는 현실적인 이야기
-2. 보편적으로 공감할 수 있는 인물과 상황
-3. 구체적이고 생생한 배경 설정
-4. 깊이 있는 심리 묘사가 가능한 갈등
+.theme-cards-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
+ gap: 20px;
+ padding: 20px;
+}
-다음 형식으로 간결하게 작성하세요:
+.theme-card {
+ background: linear-gradient(145deg, #ffffff 0%, #fdfcf8 100%);
+ border: 1px solid #e8dcc6;
+ border-radius: 12px;
+ padding: 20px;
+ box-shadow: 0 4px 12px rgba(139, 69, 19, 0.06);
+ transition: all 0.3s ease;
+ position: relative;
+ overflow: hidden;
+}
-[한 문장으로 된 매력적인 첫 문장]
+.theme-card:hover {
+ transform: translateY(-3px);
+ box-shadow: 0 6px 20px rgba(139, 69, 19, 0.12);
+}
-주인공은 [구체적인 상황의 인물]입니다.
-[핵심 사건]을 계기로 [내적 갈등]에 직면하게 되고,
-결국 [철학적 질문]에 대한 답을 찾아가는 여정을 그립니다."""
-
- else:
- prompt = f"""Generate a realistic and relatable novel theme using these elements:
+.theme-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 15px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid #e8dcc6;
+}
-Theme: {theme}
-Character: {character}
-Event: {hook}
-Core Question: {question}
+.theme-id {
+ font-family: 'Courier New', monospace;
+ color: #8b6239;
+ font-size: 0.85em;
+ font-weight: bold;
+}
-Requirements:
-1. A story that could happen in contemporary society
-2. Universally relatable characters and situations
-3. Specific and vivid settings
-4. Conflicts allowing deep psychological exploration
+.theme-timestamp {
+ font-size: 0.8em;
+ color: #8a7968;
+}
-Write concisely in this format:
+.theme-card-content {
+ margin-bottom: 15px;
+}
-[One compelling opening sentence]
+.theme-preview {
+ font-family: 'Georgia', serif;
+ line-height: 1.6;
+ color: #3d2914;
+ margin-bottom: 12px;
+ min-height: 80px;
+}
-The protagonist is [character in specific situation].
-Through [key event], they face [internal conflict],
-ultimately embarking on a journey to answer [philosophical question]."""
+.theme-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
- # Use the UnifiedLiterarySystem's LLM to generate coherent theme
- system = UnifiedLiterarySystem()
-
- # Call LLM synchronously for theme generation
- messages = [{"role": "user", "content": prompt}]
- generated_theme = system.call_llm_sync(messages, "director", language)
-
- # 톤과 스타일 섹션 제거 - 불필요한 반복 내용 삭제
- if "**톤과 스타일:**" in generated_theme or "**Tone and Style:**" in generated_theme:
- lines = generated_theme.split('\n')
- filtered_lines = []
- skip = False
- for line in lines:
- if "톤과 스타일" in line or "Tone and Style" in line:
- skip = True
- elif skip and (line.strip() == "" or line.startswith("**")):
- skip = False
- if not skip:
- filtered_lines.append(line)
- generated_theme = '\n'.join(filtered_lines).strip()
-
- return generated_theme
-
- except Exception as e:
- logger.error(f"Theme generation error: {str(e)}")
- # Fallback to simple realistic themes
- fallback_themes = {
- "Korean": [
- """"아버지가 돌아가신 날, 나는 그가 평생 숨겨온 또 다른 가족의 존재를 알게 되었다."
+.theme-tag {
+ display: inline-block;
+ padding: 3px 10px;
+ background: #f0e6d6;
+ border-radius: 15px;
+ font-size: 0.75em;
+ color: #6d4e31;
+ border: 1px solid #d4c4b0;
+}
-주인공은 평범한 회사원으로 살아온 40대 여성입니다.
-아버지의 장례식에서 낯선 여인과 그녀의 딸을 만나게 되면서 가족의 의미에 대해 다시 생각하게 되고,
-결국 진정한 가족이란 무엇인지에 대한 답을 찾아가는 여정을 그립니다.""",
-
- """"서른 년간 가르친 학교에서 나온 날, 처음으로 내가 누구인지 몰랐다."
+.theme-card-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding-top: 15px;
+ border-top: 1px solid #e8dcc6;
+}
-주인공은 정년퇴직을 맞은 고등학교 국어 교사입니다.
-갑작스러운 일상의 공백 속에서 잊고 지냈던 젊은 날의 꿈을 마주하게 되고,
-결국 남은 인생에서 무엇을 할 것인가에 대한 답을 찾아가는 여정을 그립니다."""
- ],
- "English": [
- """"The day my father died, I discovered he had another family he'd hidden all his life."
+.theme-stat {
+ font-size: 0.85em;
+ color: #8a7968;
+ margin-right: 10px;
+}
+
+.theme-action-btn {
+ padding: 6px 15px;
+ font-size: 0.85em;
+ border-radius: 6px;
+ border: 1px solid #d4a574;
+ background: linear-gradient(145deg, #faf8f5 0%, #f0e8dc 100%);
+ color: #3d2914;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-family: 'Georgia', serif;
+}
+
+.theme-action-btn:hover {
+ background: linear-gradient(145deg, #e4c896 0%, #d4a574 100%);
+ transform: translateY(-1px);
+}
+
+.use-btn {
+ background: linear-gradient(145deg, #e4c896 0%, #d4a574 100%);
+ font-weight: bold;
+}
+
+.empty-library {
+ text-align: center;
+ padding: 60px 20px;
+ color: #5a453a;
+ font-size: 1.1em;
+ font-style: italic;
+}
+
+/* Search box styling */
+.library-search {
+ margin-bottom: 20px;
+}
+
+.library-search input {
+ width: 100%;
+ padding: 12px 20px;
+ font-size: 1em;
+ border: 1px solid #d4c4b0;
+ border-radius: 8px;
+ background: #fffefa;
+ font-family: 'Georgia', serif;
+}
-The protagonist is a woman in her 40s who has lived as an ordinary office worker.
-Through meeting a strange woman and her daughter at her father's funeral, she confronts what family truly means,
-ultimately embarking on a journey to answer what constitutes a real family.""",
-
- """"The day I left the school where I'd taught for thirty years, I didn't know who I was anymore."
+/* Theme viewer modal */
+.theme-viewer {
+ background: linear-gradient(to bottom, #ffffff 0%, #fdfcfa 100%);
+ padding: 30px;
+ border-radius: 12px;
+ box-shadow: 0 10px 30px rgba(139, 69, 19, 0.15);
+ max-height: 600px;
+ overflow-y: auto;
+}
-The protagonist is a high school literature teacher facing retirement.
-Through the sudden emptiness of daily life, they confront long-forgotten dreams of youth,
-ultimately embarking on a journey to answer what to do with the remaining years."""
- ]
- }
-
- import secrets
- return secrets.choice(fallback_themes.get(language, fallback_themes["English"]))
+.theme-viewer-header {
+ border-bottom: 2px solid #d4a574;
+ padding-bottom: 15px;
+ margin-bottom: 20px;
+}
-def translate_theme_naturally(text, category):
- """자연스러운 한국어 번역"""
- translations = {
- # 테마
- "family secrets": "가족의 비밀",
- "career transition": "인생의 전환점",
- "lost love": "잃어버린 사랑",
- "friendship test": "우정의 시험",
- "generational conflict": "세대 간 갈등",
- "digital extinction": "디지털 시대의 소외",
- "sensory revolution": "감각의 혁명",
- "temporal paradox": "시간의 역설",
-
- # 캐릭터
- "struggling artist": "생활고에 시달리는 예술가",
- "retired teacher": "은퇴한 교사",
- "young mother": "젊은 엄마",
- "elderly caregiver": "노인을 돌보는 간병인",
- "small business owner": "작은 가게 주인",
- "middle-aged teacher": "중년의 교사",
- "retiring doctor": "은퇴를 앞둔 의사",
- "single parent": "혼자 아이를 키우는 부모",
- "immigrant artist": "이민자 예술가",
- "war veteran": "전쟁 참전용사",
- "last person who dreams without ads": "광고 없이 꿈꾸는 마지막 사람",
- "memory trader": "기억 거래상",
-
- # 사건
- "discovering family secret": "가족의 비밀을 발견하다",
- "unexpected reunion": "예상치 못한 재회",
- "facing illness": "질병과 마주하다",
- "losing home": "집을 잃다",
- "finding old letters": "오래된 편지를 발견하다",
- "unexpected inheritance": "뜻밖의 유산",
- "old diary discovery": "오래된 일기장 발견",
- "chance reunion": "우연한 재회",
- "life-changing diagnosis": "인생을 바꾸는 진단",
- "sudden job loss": "갑작스러운 실직",
- "discovers their memories belong to a corporate subscription": "기억이 기업 서비스의 일부임을 발견하다",
-
- # 질문
- "What makes a family?": "가족이란 무엇인가?",
- "How do we find meaning?": "우리는 어떻게 의미를 찾는가?",
- "Can we escape our past?": "과거로부터 벗어날 수 있는가?",
- "What legacy do we leave?": "우리는 어떤 유산을 남기는가?",
- "What defines family?": "무엇이 가족을 정의하는가?",
- "Can people truly change?": "사람은 정말 변할 수 있는가?",
- "What is worth sacrificing?": "무엇을 위해 희생할 가치가 있는가?",
- "How do we forgive?": "우리는 어떻게 용서하는가?",
- "What remains human when humanity is optional?": "인간성이 선택사항일 때 무엇이 인간으로 남는가?"
- }
-
- # 먼저 정확한 매칭 시도
- if text in translations:
- return translations[text]
-
- # 부분 매칭 시도
- text_lower = text.lower()
- for key, value in translations.items():
- if key.lower() in text_lower or text_lower in key.lower():
- return value
-
- # 번역이 없으면 원문 반환
- return text
+.theme-viewer-content {
+ font-family: 'Georgia', serif;
+ line-height: 1.8;
+ color: #2c1810;
+}
-# Update the handle_random_theme function in create_interface
-def handle_random_theme(language):
- """Handle random theme generation with improved feedback"""
- try:
- # Generate theme using LLM for natural output
- theme = generate_random_theme(language)
- logger.info(f"Generated theme successfully")
- return theme
- except Exception as e:
- logger.error(f"Random theme generation failed: {str(e)}")
- # Return a simple fallback theme
- if language == "Korean":
- return "기억을 잃어가는 노인과 AI 간병인의 특별한 우정"
- else:
- return "An unlikely friendship between an elderly person losing memories and their AI caregiver"
-
-# Update the augment_query method to better handle generated themes
-def augment_query(self, user_query: str, language: str) -> str:
- """Augment and clean user query"""
- # Remove any formatting artifacts from random generation
- if "**" in user_query or "##" in user_query:
- # This is likely a generated theme with formatting
- # Extract the essence without formatting
- lines = user_query.split('\n')
- cleaned_parts = []
-
- for line in lines:
- # Remove markdown formatting
- line = line.replace('**', '').replace('##', '').strip()
- if line and not line.startswith(('-', '•', '*')) and ':' not in line[:20]:
- cleaned_parts.append(line)
-
- if cleaned_parts:
- user_query = ' '.join(cleaned_parts[:3]) # Use first few meaningful lines
-
- # If query is too short, enhance it
- if len(user_query.split()) < 15:
- if language == "Korean":
- return f"{user_query}\n\n이 주제를 현대적 관점에서 재해석하여 인간 존재의 본질과 기술 시대의 딜레마를 탐구하는 8,000단어 분량의 철학적 중편소설을 작성하세요."
- else:
- return f"{user_query}\n\nReinterpret this theme from a contemporary perspective to explore the essence of human existence and dilemmas of the technological age in an 8,000-word philosophical novella."
-
- return user_query
-
-# Add method to UnifiedLiterarySystem class for better theme processing
-def process_generated_theme(self, theme_text: str, language: str) -> str:
- """Process generated theme for novel writing"""
- # Extract key elements from generated theme
- theme_elements = {
- "title": "",
- "opening": "",
- "protagonist": "",
- "conflict": "",
- "exploration": ""
- }
-
- lines = theme_text.split('\n')
- current_key = None
-
- for line in lines:
- line = line.strip()
- if not line:
- continue
-
- # Detect sections
- if any(marker in line.lower() for marker in ['title:', 'opening:', 'protagonist:', 'conflict:', 'exploration:', '제목:', '첫 문장:', '주인공:', '갈등:', '탐구']):
- for key in theme_elements:
- if key in line.lower() or (language == "Korean" and key in translate_to_korean(line.lower())):
- current_key = key
- # Extract content after colon
- if ':' in line:
- content = line.split(':', 1)[1].strip()
- if content:
- theme_elements[current_key] = content
- break
- elif current_key and line:
- # Continue adding to current element
- theme_elements[current_key] = (theme_elements[current_key] + " " + line).strip()
-
- # Construct a coherent theme summary
- if language == "Korean":
- summary = f"{theme_elements.get('title', '무제')}. "
- if theme_elements.get('opening'):
- summary += f"'{theme_elements['opening']}' "
- summary += f"{theme_elements.get('protagonist', '주인공')}의 이야기. "
- summary += f"{theme_elements.get('conflict', '')} "
- summary += f"{theme_elements.get('exploration', '')}"
- else:
- summary = f"{theme_elements.get('title', 'Untitled')}. "
- if theme_elements.get('opening'):
- summary += f"'{theme_elements['opening']}' "
- summary += f"The story of {theme_elements.get('protagonist', 'a protagonist')}. "
- summary += f"{theme_elements.get('conflict', '')} "
- summary += f"{theme_elements.get('exploration', '')}"
-
- return summary.strip()
-
-# Create Gradio interface
-# Create Gradio interface - Writer's Study Theme
+.theme-viewer-metadata {
+ margin-top: 20px;
+ padding-top: 20px;
+ border-top: 1px solid #e8dcc6;
+ font-size: 0.9em;
+ color: #5a453a;
+}
+"""
+
+# Create Gradio interface - Writer's Study Theme with Random Library
def create_interface():
+ # Combine CSS
+ combined_css = custom_css + theme_library_css
+
# Using Soft theme with safe color options
- with gr.Blocks(theme=gr.themes.Soft(), css=custom_css, title="AGI NOVEL Generator") as interface:
+ with gr.Blocks(theme=gr.themes.Soft(), css=combined_css, title="AGI NOVEL Generator") as interface:
gr.HTML("""
@@ -2613,263 +2939,812 @@ def create_interface():
-
-
-
-
-
-
-
-
-
-
- 🎲 Novel Theme Random Generator: This system can generate up to approximately 170 quadrillion (1.7 × 10¹⁷) unique novel themes.
- Even writing 100 novels per day, it would take 4.6 million years to exhaust all combinations.
- Click the "Random" button to explore infinite creative possibilities!
-
-
-
- ⏱️ Note: Creating a complete novel takes approximately 20 minutes. If your web session disconnects, you can restore your work using the "Session Recovery" feature.
-
-
-
- 🎯 Core Innovation: Not fragmented texts from multiple writers,
- but a genuine full-length novel written consistently by a single author from beginning to end.
-
-
- """)
-
- # State management
- current_session_id = gr.State(None)
-
- with gr.Row():
- with gr.Column(scale=1):
- with gr.Group(elem_classes=["input-section"]):
- gr.Markdown("### ✍️ Writing Desk")
- query_input = gr.Textbox(
- label="Novel Theme",
- placeholder="""Enter your novella theme. Like a seed that grows into a tree, your theme will blossom into a full narrative...""",
- lines=5,
- elem_id="theme_input"
- )
-
- language_select = gr.Radio(
- choices=["English", "Korean"],
- value="English",
- label="Language",
- elem_id="language_select"
- )
-
- with gr.Row():
- random_btn = gr.Button("🎲 Random Theme", variant="primary", scale=1)
- submit_btn = gr.Button("🖋️ Begin Writing", variant="secondary", scale=2)
- clear_btn = gr.Button("🗑️ Clear", scale=1)
-
- status_text = gr.Textbox(
- label="Writing Progress",
- interactive=False,
- value="✨ Ready to begin your literary journey",
- elem_id="status_text"
- )
-
- # Session management
- with gr.Group(elem_classes=["session-section"]):
- gr.Markdown("### 📚 Your Library")
- session_dropdown = gr.Dropdown(
- label="Saved Manuscripts",
- choices=[],
- interactive=True,
- elem_id="session_dropdown"
- )
- with gr.Row():
- refresh_btn = gr.Button("🔄 Refresh Library", scale=1)
- resume_btn = gr.Button("📖 Continue Writing", variant="secondary", scale=1)
- auto_recover_btn = gr.Button("🔮 Recover Last Work", scale=1)
-
- with gr.Column(scale=2):
- with gr.Tab("🖋️ Writing Process", elem_id="writing_tab"):
- stages_display = gr.Markdown(
- value="*Your writing journey will unfold here, like pages turning in a book...*",
- elem_id="stages-display"
- )
-
- with gr.Tab("📖 Completed Manuscript", elem_id="manuscript_tab"):
- novel_output = gr.Markdown(
- value="*Your completed novel will appear here, ready to be read and cherished...*",
- elem_id="novel-output"
- )
-
- with gr.Group(elem_classes=["download-section"]):
- gr.Markdown("### 📦 Bind Your Book")
- with gr.Row():
- format_select = gr.Radio(
- choices=["DOCX", "TXT"],
- value="DOCX" if DOCX_AVAILABLE else "TXT",
- label="Format",
- elem_id="format_select"
- )
- download_btn = gr.Button("📥 Download Manuscript", variant="secondary")
-
- download_file = gr.File(
- label="Your Manuscript",
- visible=False,
- elem_id="download_file"
- )
-
- # Hidden state
- novel_text_state = gr.State("")
-
- # Examples with literary flair
- with gr.Row():
- gr.Examples(
- examples=[
- ["A daughter discovering her mother's hidden past through old letters found in an attic trunk"],
- ["An architect losing sight who learns to design through touch, sound, and the memories of light"],
- ["A translator replaced by AI rediscovering the essence of language through handwritten poetry"],
- ["A middle-aged man who lost his job finding new meaning in the rhythms of rural life"],
- ["A doctor with war trauma finding healing through Doctors Without Borders missions"],
- ["A neighborhood coming together to save their beloved bookstore from corporate development"],
- ["A year in the life of a professor losing memory and his devoted last student"]
- ],
- inputs=query_input,
- label="💡 Inspiring Themes",
- examples_per_page=7,
- elem_id="example_themes"
- )
-
- # Event handlers
- def refresh_sessions():
- try:
- sessions = get_active_sessions("English")
- return gr.update(choices=sessions)
- except Exception as e:
- logger.error(f"Session refresh error: {str(e)}")
- return gr.update(choices=[])
-
- def handle_auto_recover(language):
- session_id, message = auto_recover_session(language)
- return session_id, message
-
- def handle_random_theme(language):
- """Handle random theme generation with language support"""
- import time
- import datetime
- time.sleep(0.05)
- logger.info(f"Random theme requested at {datetime.datetime.now()}")
- theme = generate_random_theme(language)
- logger.info(f"Generated theme: {theme[:100]}...")
- return theme
-
- # Event connections
- submit_btn.click(
- fn=process_query,
- inputs=[query_input, language_select, current_session_id],
- outputs=[stages_display, novel_output, status_text, current_session_id]
- )
-
- novel_output.change(
- fn=lambda x: x,
- inputs=[novel_output],
- outputs=[novel_text_state]
- )
-
- resume_btn.click(
- fn=lambda x: x.split("...")[0] if x and "..." in x else x,
- inputs=[session_dropdown],
- outputs=[current_session_id]
- ).then(
- fn=resume_session,
- inputs=[current_session_id, language_select],
- outputs=[stages_display, novel_output, status_text, current_session_id]
- )
-
- auto_recover_btn.click(
- fn=handle_auto_recover,
- inputs=[language_select],
- outputs=[current_session_id, status_text]
- ).then(
- fn=resume_session,
- inputs=[current_session_id, language_select],
- outputs=[stages_display, novel_output, status_text, current_session_id]
- )
-
- refresh_btn.click(
- fn=refresh_sessions,
- outputs=[session_dropdown]
- )
-
- clear_btn.click(
- fn=lambda: ("", "", "✨ Ready to begin your literary journey", "", None),
- outputs=[stages_display, novel_output, status_text, novel_text_state, current_session_id]
- )
-
- random_btn.click(
- fn=lambda lang: generate_random_theme(lang),
- inputs=[language_select],
- outputs=[query_input],
- queue=False
- )
-
- def handle_download(format_type, language, session_id, novel_text):
- if not session_id or not novel_text:
- return gr.update(visible=False)
-
- file_path = download_novel(novel_text, format_type, language, session_id)
- if file_path:
- return gr.update(value=file_path, visible=True)
- else:
- return gr.update(visible=False)
-
- download_btn.click(
- fn=handle_download,
- inputs=[format_select, language_select, current_session_id, novel_text_state],
- outputs=[download_file]
- )
-
- # Load sessions on start
- interface.load(
- fn=refresh_sessions,
- outputs=[session_dropdown]
- )
-
- return interface
+
+
+
+
+
+
+
+
+
+
+
+
+ 🎲 Novel Theme Random Generator: This system can generate up to approximately 170 quadrillion (1.7 × 10¹⁷) unique novel themes.
+ Even writing 100 novels per day, it would take 4.6 million years to exhaust all combinations.
+ Click the "Random" button to explore infinite creative possibilities!
+
+
+
+ ⏱️ Note: Creating a complete novel takes approximately 20 minutes. If your web session disconnects, you can restore your work using the "Session Recovery" feature.
+
+
+
+ 🎯 Core Innovation: Not fragmented texts from multiple writers,
+ but a genuine full-length novel written consistently by a single author from beginning to end.
+
+
+ """)
+
+ # State management
+ current_session_id = gr.State(None)
+ selected_theme_id = gr.State(None)
+
+ # Create tabs
+ with gr.Tabs():
+ # Main Novel Writing Tab
+ with gr.Tab("📝 Novel Writing", elem_id="writing_main_tab"):
+ with gr.Row():
+ with gr.Column(scale=1):
+ with gr.Group(elem_classes=["input-section"]):
+ gr.Markdown("### ✍️ Writing Desk")
+ query_input = gr.Textbox(
+ label="Novel Theme",
+ placeholder="""Enter your novella theme. Like a seed that grows into a tree, your theme will blossom into a full narrative...""",
+ lines=5,
+ elem_id="theme_input"
+ )
+
+ language_select = gr.Radio(
+ choices=["English", "Korean"],
+ value="English",
+ label="Language",
+ elem_id="language_select"
+ )
+
+ with gr.Row():
+ random_btn = gr.Button("🎲 Random Theme", variant="primary", scale=1)
+ submit_btn = gr.Button("🖋️ Begin Writing", variant="secondary", scale=2)
+ clear_btn = gr.Button("🗑️ Clear", scale=1)
+
+ status_text = gr.Textbox(
+ label="Writing Progress",
+ interactive=False,
+ value="✨ Ready to begin your literary journey",
+ elem_id="status_text"
+ )
+
+ # Session management
+ with gr.Group(elem_classes=["session-section"]):
+ gr.Markdown("### 📚 Your Library")
+ session_dropdown = gr.Dropdown(
+ label="Saved Manuscripts",
+ choices=[],
+ interactive=True,
+ elem_id="session_dropdown"
+ )
+ with gr.Row():
+ refresh_btn = gr.Button("🔄 Refresh Library", scale=1)
+ resume_btn = gr.Button("📖 Continue Writing", variant="secondary", scale=1)
+ auto_recover_btn = gr.Button("🔮 Recover Last Work", scale=1)
+
+ with gr.Column(scale=2):
+ with gr.Tab("🖋️ Writing Process", elem_id="writing_tab"):
+ stages_display = gr.Markdown(
+ value="*Your writing journey will unfold here, like pages turning in a book...*",
+ elem_id="stages-display"
+ )
+
+ with gr.Tab("📖 Completed Manuscript", elem_id="manuscript_tab"):
+ novel_output = gr.Markdown(
+ value="*Your completed novel will appear here, ready to be read and cherished...*",
+ elem_id="novel-output"
+ )
+
+ with gr.Group(elem_classes=["download-section"]):
+ gr.Markdown("### 📦 Bind Your Book")
+ with gr.Row():
+ format_select = gr.Radio(
+ choices=["DOCX", "TXT"],
+ value="DOCX" if DOCX_AVAILABLE else "TXT",
+ label="Format",
+ elem_id="format_select"
+ )
+ download_btn = gr.Button("📥 Download Manuscript", variant="secondary")
+
+ download_file = gr.File(
+ label="Your Manuscript",
+ visible=False,
+ elem_id="download_file"
+ )
+
+ # Hidden state
+ novel_text_state = gr.State("")
+
+ # Examples with literary flair
+ with gr.Row():
+ gr.Examples(
+ examples=[
+ ["A daughter discovering her mother's hidden past through old letters found in an attic trunk"],
+ ["An architect losing sight who learns to design through touch, sound, and the memories of light"],
+ ["A translator replaced by AI rediscovering the essence of language through handwritten poetry"],
+ ["A middle-aged man who lost his job finding new meaning in the rhythms of rural life"],
+ ["A doctor with war trauma finding healing through Doctors Without Borders missions"],
+ ["A neighborhood coming together to save their beloved bookstore from corporate development"],
+ ["A year in the life of a professor losing memory and his devoted last student"]
+ ],
+ inputs=query_input,
+ label="💡 Inspiring Themes",
+ examples_per_page=7,
+ elem_id="example_themes"
+ )
+
+ # Random Theme Library Tab
+ with gr.Tab("🎲 Random Theme Library", elem_id="library_tab"):
+ with gr.Column():
+ gr.Markdown("""
+ ### 📚 Random Theme Library
+
+ Browse through all randomly generated themes. Each theme is unique and can be used to create a novel.
+ Click "View Full" to see the complete theme or "Use This" to start writing with it.
+ """)
+
+ with gr.Row():
+ library_search = gr.Textbox(
+ label="Search Themes",
+ placeholder="Search by keywords...",
+ elem_classes=["library-search"]
+ )
+ library_refresh_btn = gr.Button("🔄 Refresh", scale=1)
+ library_language_filter = gr.Radio(
+ choices=["All", "English", "Korean"],
+ value="All",
+ label="Filter by Language",
+ scale=2
+ )
+
+ library_display = gr.HTML(
+ value=get_theme_library_display(),
+ elem_id="library-display"
+ )
+
+ # Hidden components for JavaScript interaction
+ with gr.Row(visible=False):
+ view_theme_btn = gr.Button("View Theme", elem_id="view_theme_btn")
+ use_theme_btn = gr.Button("Use Theme", elem_id="use_theme_btn")
+
+ # Theme viewer
+ with gr.Group(visible=False) as theme_viewer_group:
+ theme_viewer = gr.Markdown(
+ elem_classes=["theme-viewer"]
+ )
+ with gr.Row():
+ close_viewer_btn = gr.Button("Close", scale=1)
+ use_viewed_theme_btn = gr.Button("Use This Theme", variant="primary", scale=2)
+
+ # Event handlers
+ def refresh_sessions():
+ try:
+ sessions = get_active_sessions("English")
+ return gr.update(choices=sessions)
+ except Exception as e:
+ logger.error(f"Session refresh error: {str(e)}")
+ return gr.update(choices=[])
+
+ def handle_auto_recover(language):
+ session_id, message = auto_recover_session(language)
+ return session_id, message
+
+ def handle_random_theme(language):
+ """Handle random theme generation with library storage"""
+ import time
+ import datetime
+ time.sleep(0.05)
+ logger.info(f"Random theme requested at {datetime.datetime.now()}")
+ theme = generate_random_theme(language)
+ logger.info(f"Generated theme: {theme[:100]}...")
+ return theme
+
+ def refresh_library(language_filter, search_query):
+ """Refresh theme library display"""
+ lang = None if language_filter == "All" else language_filter
+ return get_theme_library_display(lang, search_query)
+
+ def handle_view_theme(evt: gr.EventData):
+ """Handle theme viewing"""
+ # Extract theme ID from JavaScript
+ theme_id = getattr(evt, '_js', {}).get('viewThemeId', '')
+ if not theme_id:
+ return gr.update(), gr.update(), None
+
+ theme_data = NovelDatabase.get_theme_by_id(theme_id)
+ if theme_data:
+ NovelDatabase.update_theme_view_count(theme_id)
+
+ viewer_content = f"""
+### Theme #{theme_id[:8]}
+
+**Generated:** {theme_data.get('generated_at', 'Unknown')}
+**Language:** {theme_data.get('language', 'Unknown')}
+**Views:** {theme_data.get('view_count', 0)} | **Uses:** {theme_data.get('used_count', 0)}
+
+---
+
+{theme_data.get('theme_text', '')}
+
+---
+
+**Tags:** {', '.join(json.loads(theme_data.get('tags', '[]')))}
+"""
+ return gr.update(value=viewer_content), gr.update(visible=True), theme_id
+
+ return gr.update(), gr.update(), None
+
+ def handle_use_theme(theme_id):
+ """Handle using a theme from library"""
+ if not theme_id:
+ return gr.update(), "No theme selected"
+
+ theme_data = NovelDatabase.get_theme_by_id(theme_id)
+ if theme_data:
+ NovelDatabase.update_theme_used_count(theme_id)
+ return gr.update(value=theme_data.get('theme_text', '')), f"Theme #{theme_id[:8]} loaded"
+
+ return gr.update(), "Theme not found"
+
+ # Event connections
+ submit_btn.click(
+ fn=process_query,
+ inputs=[query_input, language_select, current_session_id],
+ outputs=[stages_display, novel_output, status_text, current_session_id]
+ )
+
+ novel_output.change(
+ fn=lambda x: x,
+ inputs=[novel_output],
+ outputs=[novel_text_state]
+ )
+
+ resume_btn.click(
+ fn=lambda x: x.split("...")[0] if x and "..." in x else x,
+ inputs=[session_dropdown],
+ outputs=[current_session_id]
+ ).then(
+ fn=resume_session,
+ inputs=[current_session_id, language_select],
+ outputs=[stages_display, novel_output, status_text, current_session_id]
+ )
+
+ auto_recover_btn.click(
+ fn=handle_auto_recover,
+ inputs=[language_select],
+ outputs=[current_session_id, status_text]
+ ).then(
+ fn=resume_session,
+ inputs=[current_session_id, language_select],
+ outputs=[stages_display, novel_output, status_text, current_session_id]
+ )
+
+ refresh_btn.click(
+ fn=refresh_sessions,
+ outputs=[session_dropdown]
+ )
+
+ clear_btn.click(
+ fn=lambda: ("", "", "✨ Ready to begin your literary journey", "", None),
+ outputs=[stages_display, novel_output, status_text, novel_text_state, current_session_id]
+ )
+
+ random_btn.click(
+ fn=handle_random_theme,
+ inputs=[language_select],
+ outputs=[query_input],
+ queue=False
+ )
+
+ # Library event handlers
+ library_refresh_btn.click(
+ fn=refresh_library,
+ inputs=[library_language_filter, library_search],
+ outputs=[library_display]
+ )
+
+ library_search.change(
+ fn=refresh_library,
+ inputs=[library_language_filter, library_search],
+ outputs=[library_display]
+ )
+
+ library_language_filter.change(
+ fn=refresh_library,
+ inputs=[library_language_filter, library_search],
+ outputs=[library_display]
+ )
+
+ view_theme_btn.click(
+ fn=handle_view_theme,
+ outputs=[theme_viewer, theme_viewer_group, selected_theme_id],
+ _js="() => ({ viewThemeId: window.viewThemeId })"
+ )
+
+ use_theme_btn.click(
+ fn=lambda: gr.update(selected=0), # Switch to main tab
+ outputs=[gr.Tabs]
+ ).then(
+ fn=handle_use_theme,
+ inputs=[selected_theme_id],
+ outputs=[query_input, status_text],
+ _js="() => window.useThemeId"
+ )
+
+ use_viewed_theme_btn.click(
+ fn=lambda: gr.update(selected=0), # Switch to main tab
+ outputs=[gr.Tabs]
+ ).then(
+ fn=handle_use_theme,
+ inputs=[selected_theme_id],
+ outputs=[query_input, status_text]
+ )
+
+ close_viewer_btn.click(
+ fn=lambda: gr.update(visible=False),
+ outputs=[theme_viewer_group]
+ )
+
+ def handle_download(format_type, language, session_id, novel_text):
+ if not session_id or not novel_text:
+ return gr.update(visible=False)
+
+ file_path = download_novel(novel_text, format_type, language, session_id)
+ if file_path:
+ return gr.update(value=file_path, visible=True)
+ else:
+ return gr.update(visible=False)
+
+ download_btn.click(
+ fn=handle_download,
+ inputs=[format_select, language_select, current_session_id, novel_text_state],
+ outputs=[download_file]
+ )
+
+ # Load sessions and library on start
+ interface.load(
+ fn=lambda: (refresh_sessions(), refresh_library("All", "")),
+ outputs=[session_dropdown, library_display]
+ )
+
+ return interface
+
+# Main execution
+if __name__ == "__main__":
+ logger.info("AGI NOVEL Generator v2.0 Starting...")
+ logger.info("=" * 60)
+
+ # Environment check
+ logger.info(f"API Endpoint: {API_URL}")
+ logger.info(f"Target Length: {TARGET_WORDS:,} words")
+ logger.info(f"Minimum Words per Part: {MIN_WORDS_PER_PART:,} words")
+ logger.info("System Features: Single writer + Immediate part-by-part critique")
+
+ if BRAVE_SEARCH_API_KEY:
+ logger.info("Web search enabled.")
+ else:
+ logger.warning("Web search disabled.")
+
+ if DOCX_AVAILABLE:
+ logger.info("DOCX export enabled.")
+ else:
+ logger.warning("DOCX export disabled.")
+
+ logger.info("=" * 60)
+
+ # Initialize database
+ logger.info("Initializing database...")
+ NovelDatabase.init_db()
+ logger.info("Database initialization complete.")
+
+ # Create and launch interface
+ interface = create_interface()
+
+ interface.launch(
+ server_name="0.0.0.0",
+ server_port=7860,
+ share=False,
+ debug=True
+ )
+
+
+
+
+
+
+
+
+
+ 🎲 Novel Theme Random Generator: This system can generate up to approximately 170 quadrillion (1.7 × 10¹⁷) unique novel themes.
+ Even writing 100 novels per day, it would take 4.6 million years to exhaust all combinations.
+ Click the "Random" button to explore infinite creative possibilities!
+
+
+
+ ⏱️ Note: Creating a complete novel takes approximately 20 minutes. If your web session disconnects, you can restore your work using the "Session Recovery" feature.
+
+
+
+ 🎯 Core Innovation: Not fragmented texts from multiple writers,
+ but a genuine full-length novel written consistently by a single author from beginning to end.
+
+
+ """)
+
+ # State management
+ current_session_id = gr.State(None)
+ selected_theme_id = gr.State(None)
+
+ # Create tabs
+ with gr.Tabs():
+ # Main Novel Writing Tab
+ with gr.Tab("📝 Novel Writing", elem_id="writing_main_tab"):
+ with gr.Row():
+ with gr.Column(scale=1):
+ with gr.Group(elem_classes=["input-section"]):
+ gr.Markdown("### ✍️ Writing Desk")
+ query_input = gr.Textbox(
+ label="Novel Theme",
+ placeholder="""Enter your novella theme. Like a seed that grows into a tree, your theme will blossom into a full narrative...""",
+ lines=5,
+ elem_id="theme_input"
+ )
+
+ language_select = gr.Radio(
+ choices=["English", "Korean"],
+ value="English",
+ label="Language",
+ elem_id="language_select"
+ )
+
+ with gr.Row():
+ random_btn = gr.Button("🎲 Random Theme", variant="primary", scale=1)
+ submit_btn = gr.Button("🖋️ Begin Writing", variant="secondary", scale=2)
+ clear_btn = gr.Button("🗑️ Clear", scale=1)
+
+ status_text = gr.Textbox(
+ label="Writing Progress",
+ interactive=False,
+ value="✨ Ready to begin your literary journey",
+ elem_id="status_text"
+ )
+
+ # Session management
+ with gr.Group(elem_classes=["session-section"]):
+ gr.Markdown("### 📚 Your Library")
+ session_dropdown = gr.Dropdown(
+ label="Saved Manuscripts",
+ choices=[],
+ interactive=True,
+ elem_id="session_dropdown"
+ )
+ with gr.Row():
+ refresh_btn = gr.Button("🔄 Refresh Library", scale=1)
+ resume_btn = gr.Button("📖 Continue Writing", variant="secondary", scale=1)
+ auto_recover_btn = gr.Button("🔮 Recover Last Work", scale=1)
+
+ with gr.Column(scale=2):
+ with gr.Tab("🖋️ Writing Process", elem_id="writing_tab"):
+ stages_display = gr.Markdown(
+ value="*Your writing journey will unfold here, like pages turning in a book...*",
+ elem_id="stages-display"
+ )
+
+ with gr.Tab("📖 Completed Manuscript", elem_id="manuscript_tab"):
+ novel_output = gr.Markdown(
+ value="*Your completed novel will appear here, ready to be read and cherished...*",
+ elem_id="novel-output"
+ )
+
+ with gr.Group(elem_classes=["download-section"]):
+ gr.Markdown("### 📦 Bind Your Book")
+ with gr.Row():
+ format_select = gr.Radio(
+ choices=["DOCX", "TXT"],
+ value="DOCX" if DOCX_AVAILABLE else "TXT",
+ label="Format",
+ elem_id="format_select"
+ )
+ download_btn = gr.Button("📥 Download Manuscript", variant="secondary")
+
+ download_file = gr.File(
+ label="Your Manuscript",
+ visible=False,
+ elem_id="download_file"
+ )
+
+ # Hidden state
+ novel_text_state = gr.State("")
+
+ # Examples with literary flair
+ with gr.Row():
+ gr.Examples(
+ examples=[
+ ["A daughter discovering her mother's hidden past through old letters found in an attic trunk"],
+ ["An architect losing sight who learns to design through touch, sound, and the memories of light"],
+ ["A translator replaced by AI rediscovering the essence of language through handwritten poetry"],
+ ["A middle-aged man who lost his job finding new meaning in the rhythms of rural life"],
+ ["A doctor with war trauma finding healing through Doctors Without Borders missions"],
+ ["A neighborhood coming together to save their beloved bookstore from corporate development"],
+ ["A year in the life of a professor losing memory and his devoted last student"]
+ ],
+ inputs=query_input,
+ label="💡 Inspiring Themes",
+ examples_per_page=7,
+ elem_id="example_themes"
+ )
+
+ # Random Theme Library Tab
+ with gr.Tab("🎲 Random Theme Library", elem_id="library_tab"):
+ with gr.Column():
+ gr.Markdown("""
+ ### 📚 Random Theme Library
+
+ Browse through all randomly generated themes. Each theme is unique and can be used to create a novel.
+ Click "View Full" to see the complete theme or "Use This" to start writing with it.
+ """)
+
+ with gr.Row():
+ library_search = gr.Textbox(
+ label="Search Themes",
+ placeholder="Search by keywords...",
+ elem_classes=["library-search"]
+ )
+ library_refresh_btn = gr.Button("🔄 Refresh", scale=1)
+ library_language_filter = gr.Radio(
+ choices=["All", "English", "Korean"],
+ value="All",
+ label="Filter by Language",
+ scale=2
+ )
+
+ library_display = gr.HTML(
+ value=get_theme_library_display(),
+ elem_id="library-display"
+ )
+
+ # Hidden components for JavaScript interaction
+ with gr.Row(visible=False):
+ view_theme_btn = gr.Button("View Theme", elem_id="view_theme_btn")
+ use_theme_btn = gr.Button("Use Theme", elem_id="use_theme_btn")
+
+ # Theme viewer
+ with gr.Group(visible=False) as theme_viewer_group:
+ theme_viewer = gr.Markdown(
+ elem_classes=["theme-viewer"]
+ )
+ with gr.Row():
+ close_viewer_btn = gr.Button("Close", scale=1)
+ use_viewed_theme_btn = gr.Button("Use This Theme", variant="primary", scale=2)
+
+ # Event handlers
+ def refresh_sessions():
+ try:
+ sessions = get_active_sessions("English")
+ return gr.update(choices=sessions)
+ except Exception as e:
+ logger.error(f"Session refresh error: {str(e)}")
+ return gr.update(choices=[])
+
+ def handle_auto_recover(language):
+ session_id, message = auto_recover_session(language)
+ return session_id, message
+
+ def handle_random_theme(language):
+ """Handle random theme generation with library storage"""
+ import time
+ import datetime
+ time.sleep(0.05)
+ logger.info(f"Random theme requested at {datetime.datetime.now()}")
+ theme = generate_random_theme(language)
+ logger.info(f"Generated theme: {theme[:100]}...")
+ return theme
+
+ def refresh_library(language_filter, search_query):
+ """Refresh theme library display"""
+ lang = None if language_filter == "All" else language_filter
+ return get_theme_library_display(lang, search_query)
+
+ def handle_view_theme(evt: gr.EventData):
+ """Handle theme viewing"""
+ # Extract theme ID from JavaScript
+ theme_id = getattr(evt, '_js', {}).get('viewThemeId', '')
+ if not theme_id:
+ return gr.update(), gr.update(), None
+
+ theme_data = NovelDatabase.get_theme_by_id(theme_id)
+ if theme_data:
+ NovelDatabase.update_theme_view_count(theme_id)
+
+ viewer_content = f"""
+### Theme #{theme_id[:8]}
+
+**Generated:** {theme_data.get('generated_at', 'Unknown')}
+**Language:** {theme_data.get('language', 'Unknown')}
+**Views:** {theme_data.get('view_count', 0)} | **Uses:** {theme_data.get('used_count', 0)}
+
+---
+
+{theme_data.get('theme_text', '')}
+
+---
+
+**Tags:** {', '.join(json.loads(theme_data.get('tags', '[]')))}
+"""
+ return gr.update(value=viewer_content), gr.update(visible=True), theme_id
+
+ return gr.update(), gr.update(), None
+
+ def handle_use_theme(theme_id):
+ """Handle using a theme from library"""
+ if not theme_id:
+ return gr.update(), "No theme selected"
+
+ theme_data = NovelDatabase.get_theme_by_id(theme_id)
+ if theme_data:
+ NovelDatabase.update_theme_used_count(theme_id)
+ return gr.update(value=theme_data.get('theme_text', '')), f"Theme #{theme_id[:8]} loaded"
+
+ return gr.update(), "Theme not found"
+
+ # Event connections
+ submit_btn.click(
+ fn=process_query,
+ inputs=[query_input, language_select, current_session_id],
+ outputs=[stages_display, novel_output, status_text, current_session_id]
+ )
+
+ novel_output.change(
+ fn=lambda x: x,
+ inputs=[novel_output],
+ outputs=[novel_text_state]
+ )
+
+ resume_btn.click(
+ fn=lambda x: x.split("...")[0] if x and "..." in x else x,
+ inputs=[session_dropdown],
+ outputs=[current_session_id]
+ ).then(
+ fn=resume_session,
+ inputs=[current_session_id, language_select],
+ outputs=[stages_display, novel_output, status_text, current_session_id]
+ )
+
+ auto_recover_btn.click(
+ fn=handle_auto_recover,
+ inputs=[language_select],
+ outputs=[current_session_id, status_text]
+ ).then(
+ fn=resume_session,
+ inputs=[current_session_id, language_select],
+ outputs=[stages_display, novel_output, status_text, current_session_id]
+ )
+
+ refresh_btn.click(
+ fn=refresh_sessions,
+ outputs=[session_dropdown]
+ )
+
+ clear_btn.click(
+ fn=lambda: ("", "", "✨ Ready to begin your literary journey", "", None),
+ outputs=[stages_display, novel_output, status_text, novel_text_state, current_session_id]
+ )
+
+ random_btn.click(
+ fn=handle_random_theme,
+ inputs=[language_select],
+ outputs=[query_input],
+ queue=False
+ )
+
+ # Library event handlers
+ library_refresh_btn.click(
+ fn=refresh_library,
+ inputs=[library_language_filter, library_search],
+ outputs=[library_display]
+ )
+
+ library_search.change(
+ fn=refresh_library,
+ inputs=[library_language_filter, library_search],
+ outputs=[library_display]
+ )
+
+ library_language_filter.change(
+ fn=refresh_library,
+ inputs=[library_language_filter, library_search],
+ outputs=[library_display]
+ )
+
+ view_theme_btn.click(
+ fn=handle_view_theme,
+ outputs=[theme_viewer, theme_viewer_group, selected_theme_id],
+ _js="() => ({ viewThemeId: window.viewThemeId })"
+ )
+
+ use_theme_btn.click(
+ fn=lambda: gr.update(selected=0), # Switch to main tab
+ outputs=[gr.Tabs]
+ ).then(
+ fn=handle_use_theme,
+ inputs=[selected_theme_id],
+ outputs=[query_input, status_text],
+ _js="() => window.useThemeId"
+ )
+
+ use_viewed_theme_btn.click(
+ fn=lambda: gr.update(selected=0), # Switch to main tab
+ outputs=[gr.Tabs]
+ ).then(
+ fn=handle_use_theme,
+ inputs=[selected_theme_id],
+ outputs=[query_input, status_text]
+ )
+
+ close_viewer_btn.click(
+ fn=lambda: gr.update(visible=False),
+ outputs=[theme_viewer_group]
+ )
+
+ def handle_download(format_type, language, session_id, novel_text):
+ if not session_id or not novel_text:
+ return gr.update(visible=False)
+
+ file_path = download_novel(novel_text, format_type, language, session_id)
+ if file_path:
+ return gr.update(value=file_path, visible=True)
+ else:
+ return gr.update(visible=False)
+
+ download_btn.click(
+ fn=handle_download,
+ inputs=[format_select, language_select, current_session_id, novel_text_state],
+ outputs=[download_file]
+ )
+
+ # Load sessions and library on start
+ interface.load(
+ fn=lambda: (refresh_sessions(), refresh_library("All", "")),
+ outputs=[session_dropdown, library_display]
+ )
+
+ return interface
# Main execution
if __name__ == "__main__":
- logger.info("AGI NOVEL Generator v2.0 Starting...")
- logger.info("=" * 60)
-
- # Environment check
- logger.info(f"API Endpoint: {API_URL}")
- logger.info(f"Target Length: {TARGET_WORDS:,} words")
- logger.info(f"Minimum Words per Part: {MIN_WORDS_PER_PART:,} words")
- logger.info("System Features: Single writer + Immediate part-by-part critique")
-
- if BRAVE_SEARCH_API_KEY:
- logger.info("Web search enabled.")
- else:
- logger.warning("Web search disabled.")
-
- if DOCX_AVAILABLE:
- logger.info("DOCX export enabled.")
- else:
- logger.warning("DOCX export disabled.")
-
- logger.info("=" * 60)
-
- # Initialize database
- logger.info("Initializing database...")
- NovelDatabase.init_db()
- logger.info("Database initialization complete.")
-
- # Create and launch interface
- interface = create_interface()
-
- interface.launch(
- server_name="0.0.0.0",
- server_port=7860,
- share=False,
- debug=True
- )
\ No newline at end of file
+ logger.info("AGI NOVEL Generator v2.0 Starting...")
+ logger.info("=" * 60)
+
+ # Environment check
+ logger.info(f"API Endpoint: {API_URL}")
+ logger.info(f"Target Length: {TARGET_WORDS:,} words")
+ logger.info(f"Minimum Words per Part: {MIN_WORDS_PER_PART:,} words")
+ logger.info("System Features: Single writer + Immediate part-by-part critique")
+
+ if BRAVE_SEARCH_API_KEY:
+ logger.info("Web search enabled.")
+ else:
+ logger.warning("Web search disabled.")
+
+ if DOCX_AVAILABLE:
+ logger.info("DOCX export enabled.")
+ else:
+ logger.warning("DOCX export disabled.")
+
+ logger.info("=" * 60)
+
+ # Initialize database
+ logger.info("Initializing database...")
+ NovelDatabase.init_db()
+ logger.info("Database initialization complete.")
+
+ # Create and launch interface
+ interface = create_interface()
+
+ interface.launch(
+ server_name="0.0.0.0",
+ server_port=7860,
+ share=False,
+ debug=True
+ )
\ No newline at end of file