Features: - Add LinkedIn OAuth integration and auto-posting functionality - Add scheduler service for automated post publishing - Add metadata field to generated_posts for LinkedIn URLs - Add privacy policy page for LinkedIn API compliance - Add company management features and employee accounts - Add license key system for company registrations Fixes: - Fix timezone issues (use UTC consistently across app) - Fix datetime serialization errors in database operations - Fix scheduling timezone conversion (local time to UTC) - Fix import errors (get_database -> db) Infrastructure: - Update Docker setup to use port 8001 (avoid conflicts) - Add SSL support with nginx-proxy and Let's Encrypt - Add LinkedIn setup documentation - Add migration scripts for schema updates Services: - Add linkedin_service.py for LinkedIn API integration - Add scheduler_service.py for background job processing - Add storage_service.py for Supabase Storage - Add email_service.py improvements - Add encryption utilities for token storage Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
913 lines
33 KiB
Python
913 lines
33 KiB
Python
"""Main TUI application using Textual."""
|
|
import threading
|
|
from textual.app import App, ComposeResult
|
|
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
|
|
from textual.widgets import Header, Footer, Button, Static, Input, Label, TextArea, OptionList, LoadingIndicator, ProgressBar
|
|
from textual.widgets.option_list import Option
|
|
from textual.binding import Binding
|
|
from textual.screen import Screen
|
|
from textual.worker import Worker, WorkerState
|
|
from loguru import logger
|
|
|
|
from src.orchestrator import orchestrator
|
|
from src.database import db
|
|
|
|
|
|
class WelcomeScreen(Screen):
|
|
"""Welcome screen with main menu."""
|
|
|
|
BINDINGS = [
|
|
Binding("q", "quit", "Quit"),
|
|
]
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Create child widgets."""
|
|
yield Header()
|
|
yield Container(
|
|
Static(
|
|
"""
|
|
[bold cyan]Multi-Agent AI Workflow[/]
|
|
|
|
|
|
[yellow]Choose an option:[/]
|
|
""",
|
|
id="welcome_text",
|
|
),
|
|
Button("🚀 New Customer Setup", id="btn_new_customer", variant="primary"),
|
|
Button("🔍 Research Topics", id="btn_research", variant="success"),
|
|
Button("✍️ Create Post", id="btn_create_post", variant="success"),
|
|
Button("📊 View Status", id="btn_status", variant="default"),
|
|
Button("❌ Exit", id="btn_exit", variant="error"),
|
|
id="menu_container",
|
|
)
|
|
yield Footer()
|
|
|
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
"""Handle button presses."""
|
|
button_id = event.button.id
|
|
|
|
if button_id == "btn_new_customer":
|
|
self.app.push_screen(NewCustomerScreen())
|
|
elif button_id == "btn_research":
|
|
self.app.push_screen(ResearchScreen())
|
|
elif button_id == "btn_create_post":
|
|
self.app.push_screen(CreatePostScreen())
|
|
elif button_id == "btn_status":
|
|
self.app.push_screen(StatusScreen())
|
|
elif button_id == "btn_exit":
|
|
self.app.exit()
|
|
|
|
|
|
class NewCustomerScreen(Screen):
|
|
"""Screen for setting up a new customer."""
|
|
|
|
BINDINGS = [
|
|
Binding("escape", "app.pop_screen", "Back"),
|
|
]
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Create child widgets."""
|
|
yield Header()
|
|
yield ScrollableContainer(
|
|
Static("[bold cyan]═══ New Customer Setup ═══[/]\n", id="title"),
|
|
|
|
# Basic Info Section
|
|
Static("[bold yellow]Basic Information[/]"),
|
|
Label("Customer Name *:"),
|
|
Input(placeholder="Enter customer name", id="input_name"),
|
|
|
|
Label("LinkedIn URL *:"),
|
|
Input(placeholder="https://www.linkedin.com/in/username", id="input_linkedin"),
|
|
|
|
Label("Company Name:"),
|
|
Input(placeholder="Enter company name", id="input_company"),
|
|
|
|
Label("Email:"),
|
|
Input(placeholder="customer@example.com", id="input_email"),
|
|
|
|
# Persona Section
|
|
Static("\n[bold yellow]Persona[/]"),
|
|
Label("Describe the customer's persona, expertise, and positioning:"),
|
|
TextArea(id="input_persona"),
|
|
|
|
# Form of Address
|
|
Static("\n[bold yellow]Communication Style[/]"),
|
|
Label("Form of Address:"),
|
|
Input(placeholder="e.g., Duzen (Du/Euch) or Siezen (Sie)", id="input_address"),
|
|
|
|
# Style Guide
|
|
Label("Style Guide:"),
|
|
Label("Describe writing style, tone, and guidelines:"),
|
|
TextArea(id="input_style_guide"),
|
|
|
|
# Topic History
|
|
Static("\n[bold yellow]Content History[/]"),
|
|
Label("Topic History (comma separated):"),
|
|
Label("Enter previous topics covered:"),
|
|
TextArea(id="input_topic_history"),
|
|
|
|
# Example Posts
|
|
Label("Example Posts (separate with --- on new line):"),
|
|
Label("Paste example posts to analyze writing style:"),
|
|
TextArea(id="input_example_posts"),
|
|
|
|
# Actions
|
|
Static("\n"),
|
|
Horizontal(
|
|
Button("Cancel", id="btn_cancel", variant="error"),
|
|
Button("Start Setup", id="btn_start", variant="primary"),
|
|
id="button_row"
|
|
),
|
|
|
|
# Status/Progress area
|
|
Container(
|
|
Static("", id="status_message"),
|
|
id="status_container"
|
|
),
|
|
|
|
id="form_container",
|
|
)
|
|
yield Footer()
|
|
|
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
"""Handle button presses."""
|
|
if event.button.id == "btn_cancel":
|
|
self.app.pop_screen()
|
|
elif event.button.id == "btn_start":
|
|
self.start_setup()
|
|
|
|
def start_setup(self) -> None:
|
|
"""Start the customer setup process."""
|
|
# Get inputs
|
|
name = self.query_one("#input_name", Input).value.strip()
|
|
linkedin_url = self.query_one("#input_linkedin", Input).value.strip()
|
|
company = self.query_one("#input_company", Input).value.strip()
|
|
email = self.query_one("#input_email", Input).value.strip()
|
|
persona = self.query_one("#input_persona", TextArea).text.strip()
|
|
form_of_address = self.query_one("#input_address", Input).value.strip()
|
|
style_guide = self.query_one("#input_style_guide", TextArea).text.strip()
|
|
topic_history_raw = self.query_one("#input_topic_history", TextArea).text.strip()
|
|
example_posts_raw = self.query_one("#input_example_posts", TextArea).text.strip()
|
|
|
|
status_widget = self.query_one("#status_message", Static)
|
|
|
|
if not name or not linkedin_url:
|
|
status_widget.update("[red]✗ Please fill in required fields (Name and LinkedIn URL)[/]")
|
|
return
|
|
|
|
# Parse topic history
|
|
topic_history = [t.strip() for t in topic_history_raw.split(",") if t.strip()]
|
|
|
|
# Parse example posts
|
|
example_posts = [p.strip() for p in example_posts_raw.split("---") if p.strip()]
|
|
|
|
# Disable buttons during setup
|
|
self.query_one("#btn_start", Button).disabled = True
|
|
self.query_one("#btn_cancel", Button).disabled = True
|
|
|
|
# Show progress steps
|
|
status_widget.update("[bold cyan]Starting setup process...[/]\n")
|
|
|
|
customer_data = {
|
|
"company_name": company,
|
|
"email": email,
|
|
"persona": persona,
|
|
"form_of_address": form_of_address,
|
|
"style_guide": style_guide,
|
|
"topic_history": topic_history,
|
|
"example_posts": example_posts
|
|
}
|
|
|
|
# Show what's happening
|
|
status_widget.update(
|
|
"[bold cyan]⏳ Step 1/5: Creating customer record...[/]\n"
|
|
"[bold cyan]⏳ Step 2/5: Creating LinkedIn profile...[/]\n"
|
|
"[bold cyan]⏳ Step 3/5: Scraping LinkedIn posts...[/]\n"
|
|
"[yellow] This may take 1-2 minutes...[/]"
|
|
)
|
|
|
|
# Run setup in background worker
|
|
self.run_worker(
|
|
self._run_setup_worker(linkedin_url, name, customer_data),
|
|
name="setup_worker",
|
|
group="setup",
|
|
exclusive=True
|
|
)
|
|
|
|
async def _run_setup_worker(self, linkedin_url: str, name: str, customer_data: dict):
|
|
"""Worker method to run setup in background."""
|
|
return await orchestrator.run_initial_setup(
|
|
linkedin_url=linkedin_url,
|
|
customer_name=name,
|
|
customer_data=customer_data
|
|
)
|
|
|
|
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
"""Handle worker state changes."""
|
|
if event.worker.name != "setup_worker":
|
|
return
|
|
|
|
status_widget = self.query_one("#status_message", Static)
|
|
|
|
if event.state == WorkerState.SUCCESS:
|
|
# Worker completed successfully
|
|
customer = event.worker.result
|
|
status_widget.update(
|
|
"[bold green]✓ Step 1/5: Customer record created[/]\n"
|
|
"[bold green]✓ Step 2/5: LinkedIn profile created[/]\n"
|
|
"[bold green]✓ Step 3/5: LinkedIn posts scraped[/]\n"
|
|
"[bold green]✓ Step 4/5: Profile analyzed[/]\n"
|
|
"[bold green]✓ Step 5/5: Topics extracted[/]\n\n"
|
|
f"[bold cyan]═══ Setup Complete! ═══[/]\n"
|
|
f"[green]Customer ID: {customer.id}[/]\n"
|
|
f"[green]Name: {customer.name}[/]\n\n"
|
|
"[yellow]You can now research topics or create posts.[/]"
|
|
)
|
|
logger.info(f"Setup completed for customer: {customer.id}")
|
|
elif event.state == WorkerState.ERROR:
|
|
# Worker failed
|
|
error = event.worker.error
|
|
logger.exception(f"Setup failed: {error}")
|
|
status_widget.update(
|
|
f"[bold red]✗ Setup Failed[/]\n\n"
|
|
f"[red]Error: {str(error)}[/]\n\n"
|
|
f"[yellow]Please check the error and try again.[/]"
|
|
)
|
|
self.query_one("#btn_start", Button).disabled = False
|
|
self.query_one("#btn_cancel", Button).disabled = False
|
|
elif event.state == WorkerState.CANCELLED:
|
|
# Worker was cancelled
|
|
status_widget.update("[yellow]Setup cancelled[/]")
|
|
self.query_one("#btn_start", Button).disabled = False
|
|
self.query_one("#btn_cancel", Button).disabled = False
|
|
|
|
|
|
class ResearchScreen(Screen):
|
|
"""Screen for researching new topics."""
|
|
|
|
BINDINGS = [
|
|
Binding("escape", "app.pop_screen", "Back"),
|
|
]
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Create child widgets."""
|
|
yield Header()
|
|
yield Container(
|
|
Static("[bold cyan]═══ Research New Topics ═══[/]\n"),
|
|
|
|
Static("[bold yellow]Select Customer[/]"),
|
|
Static("Use arrow keys to navigate, Enter to select", id="help_text"),
|
|
OptionList(id="customer_list"),
|
|
|
|
Static("\n"),
|
|
Button("Start Research", id="btn_research", variant="primary"),
|
|
|
|
Static("\n"),
|
|
Container(
|
|
Static("", id="progress_status"),
|
|
ProgressBar(id="progress_bar", total=100, show_eta=False),
|
|
id="progress_container"
|
|
),
|
|
|
|
ScrollableContainer(
|
|
Static("", id="research_results"),
|
|
id="results_container"
|
|
),
|
|
|
|
id="research_container",
|
|
)
|
|
yield Footer()
|
|
|
|
async def on_mount(self) -> None:
|
|
"""Load customers when screen mounts."""
|
|
# Hide progress container initially
|
|
self.query_one("#progress_container").display = False
|
|
await self.load_customers()
|
|
|
|
async def load_customers(self) -> None:
|
|
"""Load customer list."""
|
|
try:
|
|
customers = await db.list_customers()
|
|
customer_list = self.query_one("#customer_list", OptionList)
|
|
|
|
if customers:
|
|
for c in customers:
|
|
customer_list.add_option(
|
|
Option(f"- {c.name} - {c.company_name or 'No Company'}", id=str(c.id))
|
|
)
|
|
self._customers = {str(c.id): c for c in customers}
|
|
else:
|
|
self.query_one("#help_text", Static).update(
|
|
"[yellow]No customers found. Please create a customer first.[/]"
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to load customers: {e}")
|
|
self.query_one("#help_text", Static).update(f"[red]Error loading customers: {str(e)}[/]")
|
|
|
|
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
"""Handle customer selection."""
|
|
self._selected_customer_id = event.option.id
|
|
|
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
"""Handle button presses."""
|
|
if event.button.id == "btn_research":
|
|
if hasattr(self, "_selected_customer_id") and self._selected_customer_id:
|
|
self.start_research(self._selected_customer_id)
|
|
else:
|
|
results_widget = self.query_one("#research_results", Static)
|
|
results_widget.update("[yellow]Please select a customer first.[/]")
|
|
|
|
def start_research(self, customer_id: str) -> None:
|
|
"""Start research."""
|
|
# Clear previous results
|
|
self.query_one("#research_results", Static).update("")
|
|
|
|
# Show progress container
|
|
self.query_one("#progress_container").display = True
|
|
self.query_one("#progress_bar", ProgressBar).update(progress=0)
|
|
self.query_one("#progress_status", Static).update("[bold cyan]Starte Research...[/]")
|
|
|
|
# Disable button
|
|
self.query_one("#btn_research", Button).disabled = True
|
|
|
|
# Run research in background worker
|
|
self.run_worker(
|
|
self._run_research_worker(customer_id),
|
|
name="research_worker",
|
|
group="research",
|
|
exclusive=True
|
|
)
|
|
|
|
def _update_research_progress(self, message: str, step: int, total: int) -> None:
|
|
"""Update progress - works from both main thread and worker threads."""
|
|
def update():
|
|
progress_pct = (step / total) * 100
|
|
self.query_one("#progress_bar", ProgressBar).update(progress=progress_pct)
|
|
self.query_one("#progress_status", Static).update(f"[bold cyan]Step {step}/{total}:[/] {message}")
|
|
self.refresh()
|
|
|
|
# Check if we're on the main thread or a different thread
|
|
if self.app._thread_id == threading.get_ident():
|
|
# Same thread - schedule update for next tick to allow UI refresh
|
|
self.app.call_later(update)
|
|
else:
|
|
# Different thread - use call_from_thread
|
|
self.app.call_from_thread(update)
|
|
|
|
async def _run_research_worker(self, customer_id: str):
|
|
"""Worker method to run research in background."""
|
|
from uuid import UUID
|
|
return await orchestrator.research_new_topics(
|
|
UUID(customer_id),
|
|
progress_callback=self._update_research_progress
|
|
)
|
|
|
|
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
"""Handle worker state changes."""
|
|
if event.worker.name != "research_worker":
|
|
return
|
|
|
|
results_widget = self.query_one("#research_results", Static)
|
|
|
|
if event.state == WorkerState.SUCCESS:
|
|
# Worker completed successfully
|
|
topics = event.worker.result
|
|
|
|
# Update progress to 100%
|
|
self.query_one("#progress_bar", ProgressBar).update(progress=100)
|
|
self.query_one("#progress_status", Static).update("[bold green]✓ Abgeschlossen![/]")
|
|
|
|
# Format results
|
|
output = "[bold green]✓ Research Complete![/]\n\n"
|
|
output += f"[bold cyan]Found {len(topics)} new topic suggestions:[/]\n\n"
|
|
|
|
for i, topic in enumerate(topics, 1):
|
|
output += f"[bold]{i}. {topic.get('title', 'Unknown')}[/]\n"
|
|
output += f" [dim]Category:[/] {topic.get('category', 'N/A')}\n"
|
|
|
|
fact = topic.get('fact', '')
|
|
if fact:
|
|
if len(fact) > 200:
|
|
fact = fact[:197] + "..."
|
|
output += f" [dim]Description:[/] {fact}\n"
|
|
|
|
output += "\n"
|
|
|
|
output += "[yellow]Topics saved to research results and ready for post creation.[/]"
|
|
results_widget.update(output)
|
|
elif event.state == WorkerState.ERROR:
|
|
# Worker failed
|
|
error = event.worker.error
|
|
logger.exception(f"Research failed: {error}")
|
|
self.query_one("#progress_status", Static).update("[bold red]✗ Fehler![/]")
|
|
results_widget.update(
|
|
f"[bold red]✗ Research Failed[/]\n\n"
|
|
f"[red]Error: {str(error)}[/]\n\n"
|
|
f"[yellow]Please check the error and try again.[/]"
|
|
)
|
|
elif event.state == WorkerState.CANCELLED:
|
|
# Worker was cancelled
|
|
results_widget.update("[yellow]Research cancelled[/]")
|
|
|
|
# Hide progress container after a moment (keep visible briefly to show completion)
|
|
# self.query_one("#progress_container").display = False
|
|
|
|
# Re-enable button
|
|
self.query_one("#btn_research", Button).disabled = False
|
|
|
|
|
|
class CreatePostScreen(Screen):
|
|
"""Screen for creating posts."""
|
|
|
|
BINDINGS = [
|
|
Binding("escape", "app.pop_screen", "Back"),
|
|
]
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Create child widgets."""
|
|
yield Header()
|
|
yield Container(
|
|
Static("[bold cyan]═══ Create LinkedIn Post ═══[/]\n"),
|
|
|
|
# Customer Selection
|
|
Static("[bold yellow]1. Select Customer[/]"),
|
|
Static("Use arrow keys to navigate, Enter to select", id="help_customer"),
|
|
OptionList(id="customer_list"),
|
|
|
|
# Topic Selection
|
|
Static("\n[bold yellow]2. Select Topic[/]"),
|
|
Static("Select a customer first to load topics...", id="help_topic"),
|
|
OptionList(id="topic_list"),
|
|
|
|
Static("\n"),
|
|
Button("Create Post", id="btn_create", variant="primary"),
|
|
|
|
Static("\n"),
|
|
Container(
|
|
Static("", id="progress_status"),
|
|
ProgressBar(id="progress_bar", total=100, show_eta=False),
|
|
Static("", id="iteration_info"),
|
|
id="progress_container"
|
|
),
|
|
|
|
ScrollableContainer(
|
|
Static("", id="post_output"),
|
|
id="output_container"
|
|
),
|
|
id="create_container",
|
|
)
|
|
yield Footer()
|
|
|
|
async def on_mount(self) -> None:
|
|
"""Load data when screen mounts."""
|
|
# Hide progress container initially
|
|
self.query_one("#progress_container").display = False
|
|
await self.load_customers()
|
|
|
|
async def load_customers(self) -> None:
|
|
"""Load customer list."""
|
|
try:
|
|
customers = await db.list_customers()
|
|
customer_list = self.query_one("#customer_list", OptionList)
|
|
|
|
if customers:
|
|
for c in customers:
|
|
customer_list.add_option(
|
|
Option(f"- {c.name} - {c.company_name or 'No Company'}", id=str(c.id))
|
|
)
|
|
self._customers = {str(c.id): c for c in customers}
|
|
else:
|
|
self.query_one("#help_customer", Static).update(
|
|
"[yellow]No customers found.[/]"
|
|
)
|
|
except Exception as e:
|
|
logger.exception(f"Failed to load customers: {e}")
|
|
self.query_one("#help_customer", Static).update(
|
|
f"[red]Error loading customers: {str(e)}[/]"
|
|
)
|
|
|
|
async def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
"""Handle selection from option lists."""
|
|
if event.option_list.id == "customer_list":
|
|
# Customer selected
|
|
self._selected_customer_id = event.option.id
|
|
customer_name = self._customers[event.option.id].name
|
|
self.query_one("#help_customer", Static).update(
|
|
f"[green]✓ Selected: {customer_name}[/]"
|
|
)
|
|
# Load topics for this customer
|
|
await self.load_topics(event.option.id)
|
|
elif event.option_list.id == "topic_list":
|
|
# Topic selected
|
|
self._selected_topic_index = int(event.option.id)
|
|
topic = self._topics[self._selected_topic_index]
|
|
self.query_one("#help_topic", Static).update(
|
|
f"[green]✓ Selected: {topic.get('title', 'Unknown')}[/]"
|
|
)
|
|
|
|
async def load_topics(self, customer_id) -> None:
|
|
"""Load ALL topics for customer from ALL research results."""
|
|
try:
|
|
from uuid import UUID
|
|
# Get ALL research results, not just the latest
|
|
all_research = await db.get_all_research(UUID(customer_id))
|
|
|
|
topic_list = self.query_one("#topic_list", OptionList)
|
|
topic_list.clear_options()
|
|
|
|
# Collect all topics from all research results
|
|
all_topics = []
|
|
for research in all_research:
|
|
if research.suggested_topics:
|
|
all_topics.extend(research.suggested_topics)
|
|
|
|
if all_topics:
|
|
self._topics = all_topics
|
|
|
|
for i, t in enumerate(all_topics):
|
|
# Show title and category
|
|
display_text = f"- {t.get('title', 'Unknown')} [{t.get('category', 'N/A')}]"
|
|
topic_list.add_option(Option(display_text, id=str(i)))
|
|
|
|
self.query_one("#help_topic", Static).update(
|
|
f"[cyan]{len(all_topics)} topics available from {len(all_research)} research(es) - select one to continue[/]"
|
|
)
|
|
else:
|
|
self.query_one("#help_topic", Static).update(
|
|
"[yellow]No research topics found. Run research first.[/]"
|
|
)
|
|
except Exception as e:
|
|
logger.exception(f"Failed to load topics: {e}")
|
|
self.query_one("#help_topic", Static).update(
|
|
f"[red]Error loading topics: {str(e)}[/]"
|
|
)
|
|
|
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
"""Handle button presses."""
|
|
if event.button.id == "btn_create":
|
|
if not hasattr(self, "_selected_customer_id") or not self._selected_customer_id:
|
|
output_widget = self.query_one("#post_output", Static)
|
|
output_widget.update("[yellow]Please select a customer first.[/]")
|
|
return
|
|
|
|
if not hasattr(self, "_selected_topic_index") or self._selected_topic_index is None:
|
|
output_widget = self.query_one("#post_output", Static)
|
|
output_widget.update("[yellow]Please select a topic first.[/]")
|
|
return
|
|
|
|
from uuid import UUID
|
|
topic = self._topics[self._selected_topic_index]
|
|
self.create_post(UUID(self._selected_customer_id), topic)
|
|
|
|
def create_post(self, customer_id, topic) -> None:
|
|
"""Create a post."""
|
|
output_widget = self.query_one("#post_output", Static)
|
|
|
|
# Clear previous output
|
|
output_widget.update("")
|
|
|
|
# Show progress container
|
|
self.query_one("#progress_container").display = True
|
|
self.query_one("#progress_bar", ProgressBar).update(progress=0)
|
|
self.query_one("#progress_status", Static).update("[bold cyan]Starte Post-Erstellung...[/]")
|
|
self.query_one("#iteration_info", Static).update("")
|
|
|
|
# Disable button
|
|
self.query_one("#btn_create", Button).disabled = True
|
|
|
|
# Run post creation in background worker
|
|
self.run_worker(
|
|
self._run_create_post_worker(customer_id, topic),
|
|
name="create_post_worker",
|
|
group="create_post",
|
|
exclusive=True
|
|
)
|
|
|
|
def _update_post_progress(self, message: str, iteration: int, max_iterations: int, score: int = None) -> None:
|
|
"""Update progress - works from both main thread and worker threads."""
|
|
def update():
|
|
# Calculate progress based on iteration
|
|
if iteration == 0:
|
|
progress_pct = 0
|
|
else:
|
|
progress_pct = (iteration / max_iterations) * 100
|
|
|
|
self.query_one("#progress_bar", ProgressBar).update(progress=progress_pct)
|
|
self.query_one("#progress_status", Static).update(f"[bold cyan]{message}[/]")
|
|
|
|
if iteration > 0:
|
|
score_text = f" | Score: {score}/100" if score else ""
|
|
self.query_one("#iteration_info", Static).update(
|
|
f"[dim]Iteration {iteration}/{max_iterations}{score_text}[/]"
|
|
)
|
|
self.refresh()
|
|
|
|
# Check if we're on the main thread or a different thread
|
|
if self.app._thread_id == threading.get_ident():
|
|
# Same thread - schedule update for next tick to allow UI refresh
|
|
self.app.call_later(update)
|
|
else:
|
|
# Different thread - use call_from_thread
|
|
self.app.call_from_thread(update)
|
|
|
|
async def _run_create_post_worker(self, customer_id, topic):
|
|
"""Worker method to create post in background."""
|
|
return await orchestrator.create_post(
|
|
customer_id=customer_id,
|
|
topic=topic,
|
|
max_iterations=3,
|
|
progress_callback=self._update_post_progress
|
|
)
|
|
|
|
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
"""Handle worker state changes."""
|
|
if event.worker.name != "create_post_worker":
|
|
return
|
|
|
|
output_widget = self.query_one("#post_output", Static)
|
|
|
|
if event.state == WorkerState.SUCCESS:
|
|
# Worker completed successfully
|
|
result = event.worker.result
|
|
topic = self._topics[self._selected_topic_index]
|
|
|
|
# Update progress to 100%
|
|
self.query_one("#progress_bar", ProgressBar).update(progress=100)
|
|
self.query_one("#progress_status", Static).update("[bold green]✓ Post erstellt![/]")
|
|
self.query_one("#iteration_info", Static).update(
|
|
f"[green]Final: {result['iterations']} Iterationen | Score: {result['final_score']}/100[/]"
|
|
)
|
|
|
|
# Format output
|
|
output = f"[bold green]✓ Post Created Successfully![/]\n\n"
|
|
output += f"[bold cyan]═══ Post Details ═══[/]\n"
|
|
output += f"[bold]Topic:[/] {topic.get('title', 'Unknown')}\n"
|
|
output += f"[bold]Iterations:[/] {result['iterations']}\n"
|
|
output += f"[bold]Final Score:[/] {result['final_score']}/100\n"
|
|
output += f"[bold]Approved:[/] {'✓ Yes' if result['approved'] else '✗ No (reached max iterations)'}\n\n"
|
|
|
|
output += f"[bold cyan]═══ Final Post ═══[/]\n\n"
|
|
output += f"[white]{result['final_post']}[/]\n\n"
|
|
|
|
output += f"[bold cyan]═══════════════════[/]\n"
|
|
output += f"[yellow]Post saved to database with ID: {result['post_id']}[/]"
|
|
|
|
output_widget.update(output)
|
|
elif event.state == WorkerState.ERROR:
|
|
# Worker failed
|
|
error = event.worker.error
|
|
logger.exception(f"Post creation failed: {error}")
|
|
self.query_one("#progress_status", Static).update("[bold red]✗ Fehler![/]")
|
|
output_widget.update(
|
|
f"[bold red]✗ Post Creation Failed[/]\n\n"
|
|
f"[red]Error: {str(error)}[/]\n\n"
|
|
f"[yellow]Please check the error and try again.[/]"
|
|
)
|
|
elif event.state == WorkerState.CANCELLED:
|
|
# Worker was cancelled
|
|
output_widget.update("[yellow]Post creation cancelled[/]")
|
|
|
|
# Re-enable button
|
|
self.query_one("#btn_create", Button).disabled = False
|
|
|
|
|
|
class StatusScreen(Screen):
|
|
"""Screen for viewing customer status."""
|
|
|
|
BINDINGS = [
|
|
Binding("escape", "app.pop_screen", "Back"),
|
|
]
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Create child widgets."""
|
|
yield Header()
|
|
yield Container(
|
|
Static("[bold cyan]═══ Customer Status ═══[/]\n\n"),
|
|
ScrollableContainer(
|
|
Static("Loading...", id="status_content"),
|
|
id="status_scroll"
|
|
),
|
|
Static("\n"),
|
|
Button("Refresh", id="btn_refresh", variant="primary"),
|
|
)
|
|
yield Footer()
|
|
|
|
def on_mount(self) -> None:
|
|
"""Load status when screen mounts."""
|
|
self.load_status()
|
|
|
|
def load_status(self) -> None:
|
|
"""Load and display status."""
|
|
status_widget = self.query_one("#status_content", Static)
|
|
status_widget.update("[yellow]Loading customer data...[/]")
|
|
|
|
# Run status loading in background worker
|
|
self.run_worker(
|
|
self._run_load_status_worker(),
|
|
name="load_status_worker",
|
|
group="status",
|
|
exclusive=True
|
|
)
|
|
|
|
async def _run_load_status_worker(self):
|
|
"""Worker method to load status in background."""
|
|
customers = await db.list_customers()
|
|
if not customers:
|
|
return None
|
|
|
|
output = ""
|
|
for customer in customers:
|
|
status = await orchestrator.get_user_status(customer.id)
|
|
|
|
output += f"[bold cyan]╔═══ {customer.name} ═══╗[/]\n"
|
|
output += f"[bold]Customer ID:[/] {customer.id}\n"
|
|
output += f"[bold]LinkedIn:[/] {customer.linkedin_url}\n"
|
|
output += f"[bold]Company:[/] {customer.company_name or 'N/A'}\n\n"
|
|
|
|
output += f"[bold yellow]Status:[/]\n"
|
|
output += f" Profile: {'[green]✓ Created[/]' if status['has_profile'] else '[red]✗ Missing[/]'}\n"
|
|
output += f" Analysis: {'[green]✓ Complete[/]' if status['has_analysis'] else '[red]✗ Missing[/]'}\n\n"
|
|
|
|
output += f"[bold yellow]Content:[/]\n"
|
|
output += f" LinkedIn Posts: [cyan]{status['posts_count']}[/]\n"
|
|
output += f" Extracted Topics: [cyan]{status['topics_count']}[/]\n"
|
|
output += f" Generated Posts: [cyan]{status['generated_posts_count']}[/]\n"
|
|
|
|
output += f"[bold cyan]╚{'═' * (len(customer.name) + 8)}╝[/]\n\n"
|
|
|
|
return output
|
|
|
|
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
"""Handle worker state changes."""
|
|
if event.worker.name != "load_status_worker":
|
|
return
|
|
|
|
status_widget = self.query_one("#status_content", Static)
|
|
|
|
if event.state == WorkerState.SUCCESS:
|
|
# Worker completed successfully
|
|
output = event.worker.result
|
|
if output is None:
|
|
status_widget.update(
|
|
"[yellow]No customers found.[/]\n"
|
|
"[dim]Create a new customer to get started.[/]"
|
|
)
|
|
else:
|
|
status_widget.update(output)
|
|
elif event.state == WorkerState.ERROR:
|
|
# Worker failed
|
|
error = event.worker.error
|
|
logger.exception(f"Failed to load status: {error}")
|
|
status_widget.update(
|
|
f"[bold red]✗ Error Loading Status[/]\n\n"
|
|
f"[red]{str(error)}[/]"
|
|
)
|
|
elif event.state == WorkerState.CANCELLED:
|
|
# Worker was cancelled
|
|
status_widget.update("[yellow]Status loading cancelled[/]")
|
|
|
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
"""Handle button presses."""
|
|
if event.button.id == "btn_refresh":
|
|
self.load_status()
|
|
|
|
|
|
class LinkedInWorkflowApp(App):
|
|
"""Main Textual application."""
|
|
|
|
CSS = """
|
|
Screen {
|
|
align: center middle;
|
|
}
|
|
|
|
#menu_container {
|
|
width: 60;
|
|
height: auto;
|
|
padding: 2;
|
|
border: solid $primary;
|
|
background: $surface;
|
|
}
|
|
|
|
#menu_container Button {
|
|
width: 100%;
|
|
margin: 1;
|
|
}
|
|
|
|
#welcome_text {
|
|
text-align: center;
|
|
padding: 1;
|
|
}
|
|
|
|
#form_container {
|
|
width: 100%;
|
|
height: 100%;
|
|
padding: 2;
|
|
}
|
|
|
|
#form_container Input, #form_container TextArea {
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
#form_container Label {
|
|
margin-top: 1;
|
|
color: $text;
|
|
}
|
|
|
|
#form_container TextArea {
|
|
height: 5;
|
|
}
|
|
|
|
#button_row {
|
|
width: 100%;
|
|
height: auto;
|
|
margin: 1 0;
|
|
}
|
|
|
|
#button_row Button {
|
|
margin: 0 1;
|
|
}
|
|
|
|
#status_container, #results_container, #output_container {
|
|
min-height: 10;
|
|
border: solid $accent;
|
|
margin: 1 0;
|
|
padding: 1;
|
|
}
|
|
|
|
#status_scroll {
|
|
height: 30;
|
|
border: solid $accent;
|
|
margin-top: 1;
|
|
padding: 1;
|
|
}
|
|
|
|
#research_container, #create_container {
|
|
width: 90;
|
|
height: auto;
|
|
padding: 2;
|
|
border: solid $primary;
|
|
background: $surface;
|
|
}
|
|
|
|
#customer_list, #topic_list {
|
|
height: 10;
|
|
border: solid $accent;
|
|
margin: 1 0;
|
|
}
|
|
|
|
#customer_list > .option-list--option,
|
|
#topic_list > .option-list--option {
|
|
padding: 1 1;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
#help_text, #help_customer, #help_topic {
|
|
color: $text-muted;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
#progress_container {
|
|
height: auto;
|
|
padding: 1;
|
|
margin: 1 0;
|
|
border: solid $accent;
|
|
background: $surface-darken-1;
|
|
}
|
|
|
|
#progress_bar {
|
|
width: 100%;
|
|
margin: 1 0;
|
|
}
|
|
|
|
#progress_status {
|
|
text-align: center;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
#iteration_info {
|
|
text-align: center;
|
|
margin-top: 1;
|
|
}
|
|
|
|
#title {
|
|
text-align: center;
|
|
padding: 1;
|
|
}
|
|
"""
|
|
|
|
BINDINGS = [
|
|
Binding("q", "quit", "Quit", show=True),
|
|
]
|
|
|
|
def on_mount(self) -> None:
|
|
"""Set up the application on mount."""
|
|
self.title = "LinkedIn Post Creation System"
|
|
self.sub_title = "Multi-Agent AI Workflow"
|
|
self.push_screen(WelcomeScreen())
|
|
|
|
|
|
def run_app():
|
|
"""Run the TUI application."""
|
|
app = LinkedInWorkflowApp()
|
|
app.run()
|