aktueller stand
This commit is contained in:
912
src/tui/app.py
Normal file
912
src/tui/app.py
Normal file
@@ -0,0 +1,912 @@
|
||||
"""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_customer_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()
|
||||
Reference in New Issue
Block a user