Spaces:
Running
Running
| from __future__ import annotations | |
| import html | |
| from collections import deque | |
| from threading import Lock | |
| from typing import Any, Callable, Deque, Dict, Optional, Tuple | |
| from queue import Queue, LifoQueue | |
| import numpy as np | |
| import os | |
| import gradio as gr # type: ignore | |
| from third_party_tools.text_to_audio_file import text_to_audio_file | |
| from .cv_interface import CVInterface | |
| from .render_plan_html import render_plan_html | |
| should_narrate_events = os.getenv("SHOULD_NARRATE_EVENTS", "False").lower() == "true" | |
| new_events_check_interval_seconds = 3 | |
| class EastSyncInterface: | |
| """ | |
| EASTSYNC ENTERPRISE INTERFACE | |
| Aesthetic: High Contrast / Dark Mode / Professional Analytics. | |
| """ | |
| SAMPLE_PROMPT = ( | |
| "PROJECT: Data Analytics Dashboard\n" | |
| "SCOPE: Develop a real-time visualization layer for regional sales data.\n" | |
| "TEAM: Data Science Team Alpha (2 Juniors, 1 Senior).\n" | |
| "OBJECTIVE: Analyze current team capabilities and generate a training roadmap to close skill gaps." | |
| ) | |
| def __init__(self): | |
| self._action_log: Deque[str] = deque(maxlen=200) | |
| self._action_log_lock = Lock() | |
| # Queues for live audio narration | |
| self.audio_queue: Queue[Tuple[int, np.ndarray]] = Queue() | |
| self.event_queue: Queue[str] = LifoQueue() | |
| self.init_message = ( | |
| '<div class="console-line">>> SYSTEM INITIALIZED. WAITING FOR PROJECT INPUT...</div>' | |
| ) | |
| self._app_css = self._compose_css() | |
| self._cv_interface = CVInterface(self) | |
| self._analysis_result: Optional[Any] = None # Store analysis result for async updates | |
| self._analysis_error: Optional[str] = None # Store analysis error if any | |
| self._analysis_running: bool = False # Track if analysis is currently running | |
| self._cached_processing_state: Optional[str] = None # Cache processing state HTML | |
| # Dynamic processing steps tracking | |
| self._processing_steps: list[str] = [] # Current processing steps | |
| self._processing_steps_lock = Lock() | |
| self._processing_mode: Optional[str] = None # "project", "extract", or "match" | |
| # ---------------------- HELPER METHODS ---------------------- | |
| def start_processing(self, mode: str): | |
| """Start processing mode and reset steps. Mode: 'project', 'extract', or 'match'.""" | |
| with self._processing_steps_lock: | |
| self._processing_steps = [] | |
| self._processing_mode = mode | |
| self._analysis_running = True | |
| self._analysis_result = None | |
| self._analysis_error = None | |
| self._cached_processing_state = None | |
| def stop_processing(self): | |
| """Stop processing mode and clear steps.""" | |
| with self._processing_steps_lock: | |
| self._processing_mode = None | |
| self._analysis_running = False | |
| def add_processing_step(self, step: str): | |
| """Add a new processing step to the dynamic list.""" | |
| with self._processing_steps_lock: | |
| # Avoid duplicates | |
| if step not in self._processing_steps: | |
| self._processing_steps.append(step) | |
| # Invalidate cache so next render picks up new steps | |
| self._cached_processing_state = None | |
| def get_processing_steps(self) -> list[str]: | |
| """Get current processing steps.""" | |
| with self._processing_steps_lock: | |
| return list(self._processing_steps) | |
| def register_agent_action(self, action: str, args: Optional[Dict[str, Any]] = None): | |
| import datetime | |
| timestamp = datetime.datetime.now().strftime("%H:%M:%S") | |
| with self._action_log_lock: | |
| # Keep the high-contrast aesthetic but clean up the formatting | |
| msg = f'<span class="console-timestamp">{timestamp}</span> <span style="color:var(--arc-yellow)">>></span> <span style="color:var(--text-main); opacity:0.9;">{html.escape(str(action))}</span>' | |
| if args: | |
| args_str = str(args) | |
| if len(args_str) > 80: | |
| args_str = args_str[:80] + "..." | |
| msg += f' <span style="color:var(--text-main); opacity:0.7;">:: {html.escape(args_str)}</span>' | |
| self._action_log.appendleft(f'<div class="console-line">{msg}</div>') | |
| # Add to processing steps if we're in processing mode (check INSIDE lock to avoid race condition) | |
| with self._processing_steps_lock: | |
| if self._processing_mode is not None: | |
| action_str = str(action) | |
| if action_str not in self._processing_steps: | |
| self._processing_steps.append(action_str) | |
| # Push to event queue for narrator | |
| self.event_queue.put_nowait(f"{action_str} {args if args else ''}") | |
| self._cached_processing_state = None | |
| def get_action_log_text(self) -> str: | |
| with self._action_log_lock: | |
| body = "".join(self._action_log) if self._action_log else self.init_message | |
| return f'<div class="console-wrapper">{body}</div>' | |
| def clear_action_log(self) -> str: | |
| with self._action_log_lock: | |
| self._action_log.clear() | |
| return self.get_action_log_text() | |
| def render_analysis_result(self, result: Any) -> str: | |
| """Render the analysis result as HTML only. Audio is handled by narrator.""" | |
| html_out = render_plan_html(result) | |
| return html_out | |
| def set_analysis_result(self, result: Any): | |
| """Store analysis result for async display.""" | |
| self._analysis_result = result | |
| self._analysis_error = None | |
| def set_analysis_error(self, error: str): | |
| """Store analysis error for async display.""" | |
| self._analysis_error = error | |
| self._analysis_result = None | |
| def get_analysis_output(self) -> Optional[str]: | |
| """Get the current analysis output (result, error, or processing state). | |
| Returns None if no update is needed.""" | |
| # Check result/error first (these are set by the agent thread) | |
| if self._analysis_result is not None: | |
| self.stop_processing() # Mark as complete | |
| return self.render_analysis_result(self._analysis_result) | |
| elif self._analysis_error is not None: | |
| self.stop_processing() # Mark as complete | |
| return self.render_error_state(self._analysis_error) | |
| # Check processing mode inside the lock for thread safety | |
| with self._processing_steps_lock: | |
| mode = self._processing_mode | |
| is_running = self._analysis_running | |
| # If not running and no result/error, don't update | |
| if not is_running: | |
| return None | |
| # Render dynamic processing state based on mode | |
| if mode == "project": | |
| return self.render_project_processing_state() | |
| elif mode in ("extract", "match"): | |
| return self.render_processing_state(mode) | |
| elif mode is not None: | |
| return self.render_project_processing_state() # fallback | |
| else: | |
| return None # No processing mode set | |
| summary_text = result.get('corny_summary', '') | |
| audio_path = text_to_audio_file(summary_text) | |
| is_audio = audio_path is not None | |
| audio_out = gr.update(value=audio_path, visible=is_audio) | |
| html_out = render_plan_html(result) | |
| return html_out, audio_out | |
| def render_idle_state(self) -> str: | |
| # The style is handled inside render_plan_html.py CSS for consistency. | |
| return "<div class='sec-status-offline'>// ENTER PROJECT DETAILS TO GENERATE ROADMAP</div>" | |
| def render_error_state(self, reason: str) -> str: | |
| safe_reason = html.escape(reason) | |
| return f""" | |
| <div class='error-container'> | |
| <div class='error-header'> | |
| <span>⚠️</span> PROCESSING ERROR | |
| </div> | |
| <div class='error-body'> | |
| {safe_reason} | |
| </div> | |
| </div> | |
| """, None | |
| def reset_prompt_value(self) -> str: | |
| return self.SAMPLE_PROMPT | |
| def render_project_processing_state(self) -> str: | |
| """Render animated processing state for project analysis with dynamic steps.""" | |
| # Get current processing steps (dynamic) | |
| current_steps = self.get_processing_steps() | |
| # Build steps HTML - show only actual steps that have been added | |
| if current_steps: | |
| steps_html = "" | |
| # Current step (in progress) - with animation - ON TOP | |
| current_step = current_steps[-1] | |
| steps_html += f'<div style="padding: 12px; margin: 8px 0; background: rgba(255,127,0,0.15); border-left: 3px solid var(--arc-orange); color: var(--arc-orange); font-size: 14px; font-weight: 600; animation: pulse 1.5s ease-in-out infinite;">⏳ {html.escape(current_step)}</div>' | |
| # Previous steps (completed) - WHITE text - REVERSED (Newest first) | |
| steps_html += "".join([ | |
| f'<div style="padding: 12px; margin: 8px 0; background: rgba(85,255,0,0.08); border-left: 3px solid var(--arc-green); color: #FFFFFF; font-size: 14px; font-weight: 500;">✓ {html.escape(step)}</div>' | |
| for step in reversed(current_steps[:-1]) | |
| ]) | |
| else: | |
| steps_html = '<div style="padding: 12px; margin: 8px 0; background: rgba(255,255,255,0.02); border-left: 3px solid var(--arc-cyan); color: var(--text-main); opacity:0.9; font-size: 14px; animation: pulse 1.5s ease-in-out infinite;">⏳ Initializing analysis...</div>' | |
| step_count = len(current_steps) if current_steps else 0 | |
| return f""" | |
| <style> | |
| @keyframes pulse {{ | |
| 0%, 100% {{ opacity: 1; }} | |
| 50% {{ opacity: 0.5; }} | |
| }} | |
| @keyframes spin {{ | |
| from {{ transform: rotate(0deg); }} | |
| to {{ transform: rotate(360deg); }} | |
| }} | |
| @keyframes progress {{ | |
| 0% {{ width: 0%; }} | |
| 100% {{ width: 100%; }} | |
| }} | |
| </style> | |
| <div style="display: flex; align-items: center; justify-content: center; min-height: 70vh; padding: 40px;"> | |
| <div style="max-width: 800px; width: 100%;"> | |
| <div style="background: var(--bg-panel); border: 2px solid var(--arc-orange); border-radius: 4px; padding: 48px; text-align: center;"> | |
| <!-- Animated Icon --> | |
| <div style="margin-bottom: 32px;"> | |
| <div style="width: 80px; height: 80px; margin: 0 auto; border: 4px solid var(--arc-orange); border-top-color: transparent; border-radius: 50%; animation: spin 1s linear infinite;"></div> | |
| </div> | |
| <!-- Title --> | |
| <h2 style="color: var(--arc-orange); margin: 0 0 16px 0; font-size: 24px; font-weight: 700; animation: pulse 2s ease-in-out infinite;"> | |
| 🚀 INITIATING ANALYSIS | |
| </h2> | |
| <!-- Subtitle --> | |
| <p style="color: var(--text-main); font-size: 15px; margin-bottom: 32px; opacity: 0.9;"> | |
| Analyzing project requirements and generating deployment plan... Monitor system logs for real-time updates. | |
| </p> | |
| <!-- Progress Bar --> | |
| <div style="width: 100%; height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; overflow: hidden; margin-bottom: 32px;"> | |
| <div style="height: 100%; background: linear-gradient(90deg, var(--arc-red), var(--arc-orange), var(--arc-yellow), var(--arc-green)); animation: progress {new_events_check_interval_seconds}s ease-in-out infinite;"></div> | |
| </div> | |
| <!-- Processing Steps (DYNAMIC) --> | |
| <div style="text-align: left; max-width: 600px; margin: 0 auto; padding: 24px; background: rgba(0,0,0,0.3); border-radius: 4px; min-height: 150px;"> | |
| <div style="color: var(--arc-orange); font-size: 12px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px; font-weight: 600; display: flex; justify-content: space-between;"> | |
| <span>ANALYSIS PIPELINE:</span> | |
| <span style="color: var(--arc-cyan);">{step_count} step{"s" if step_count != 1 else ""}</span> | |
| </div> | |
| <div style="max-height: 300px; overflow-y: auto;"> | |
| {steps_html} | |
| </div> | |
| </div> | |
| <!-- Info Note --> | |
| <div style="margin-top: 32px; padding: 16px; background: rgba(255,127,0,0.05); border: 1px solid rgba(255,127,0,0.2); border-radius: 4px;"> | |
| <div style="color: var(--arc-orange); font-size: 13px;"> | |
| ⏱️ <strong style="color: var(--text-main);">Estimated Time:</strong> <span style="color: var(--text-main); opacity: 0.9;">45-90 seconds (depending on project complexity)</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| """, None | |
| def render_processing_state(self, mode: str = "extract") -> str: | |
| """Render animated processing state for CV analysis with dynamic steps.""" | |
| title = "📊 EXTRACTING SKILLS" if mode == "extract" else "🎯 ANALYZING CV + MATCHING PROJECTS" | |
| # Get current processing steps (dynamic) | |
| current_steps = self.get_processing_steps() | |
| # Build steps HTML - show only actual steps that have been added | |
| if current_steps: | |
| steps_html = "" | |
| # Current step (in progress) - with animation - ON TOP | |
| current_step = current_steps[-1] | |
| steps_html += f'<div style="padding: 12px; margin: 8px 0; background: rgba(0,255,255,0.15); border-left: 3px solid var(--arc-cyan); color: var(--arc-cyan); font-size: 14px; font-weight: 600; animation: pulse 1.5s ease-in-out infinite;">⏳ {html.escape(current_step)}</div>' | |
| # Previous steps (completed) - WHITE text - REVERSED (Newest first) | |
| steps_html += "".join([ | |
| f'<div style="padding: 12px; margin: 8px 0; background: rgba(85,255,0,0.08); border-left: 3px solid var(--arc-green); color: #FFFFFF; font-size: 14px; font-weight: 500;">✓ {html.escape(step)}</div>' | |
| for step in reversed(current_steps[:-1]) | |
| ]) | |
| else: | |
| steps_html = '<div style="padding: 12px; margin: 8px 0; background: rgba(255,255,255,0.02); border-left: 3px solid var(--arc-cyan); color: var(--text-main); opacity:0.9; font-size: 14px; animation: pulse 1.5s ease-in-out infinite;">⏳ Initializing...</div>' | |
| step_count = len(current_steps) if current_steps else 0 | |
| return f""" | |
| <style> | |
| @keyframes pulse {{ | |
| 0%, 100% {{ opacity: 1; }} | |
| 50% {{ opacity: 0.5; }} | |
| }} | |
| @keyframes spin {{ | |
| from {{ transform: rotate(0deg); }} | |
| to {{ transform: rotate(360deg); }} | |
| }} | |
| @keyframes progress {{ | |
| 0% {{ width: 0%; }} | |
| 100% {{ width: 100%; }} | |
| }} | |
| </style> | |
| <div style="display: flex; align-items: center; justify-content: center; min-height: 70vh; padding: 40px;"> | |
| <div style="max-width: 800px; width: 100%;"> | |
| <div style="background: var(--bg-panel); border: 2px solid var(--arc-orange); border-radius: 4px; padding: 48px; text-align: center;"> | |
| <!-- Animated Icon --> | |
| <div style="margin-bottom: 32px;"> | |
| <div style="width: 80px; height: 80px; margin: 0 auto; border: 4px solid var(--arc-orange); border-top-color: transparent; border-radius: 50%; animation: spin 1s linear infinite;"></div> | |
| </div> | |
| <!-- Title --> | |
| <h2 style="color: var(--arc-orange); margin: 0 0 16px 0; font-size: 24px; font-weight: 700; animation: pulse 2s ease-in-out infinite;"> | |
| {title} | |
| </h2> | |
| <!-- Subtitle --> | |
| <p style="color: var(--text-main); font-size: 15px; margin-bottom: 32px; opacity: 0.9;"> | |
| Processing document... Please monitor the system logs for real-time updates. | |
| </p> | |
| <!-- Progress Bar --> | |
| <div style="width: 100%; height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; overflow: hidden; margin-bottom: 32px;"> | |
| <div style="height: 100%; background: linear-gradient(90deg, var(--arc-orange), var(--arc-yellow), var(--arc-green)); animation: progress {new_events_check_interval_seconds}s ease-in-out infinite;"></div> | |
| </div> | |
| <!-- Processing Steps (DYNAMIC) --> | |
| <div style="text-align: left; max-width: 600px; margin: 0 auto; padding: 24px; background: rgba(0,0,0,0.3); border-radius: 4px; min-height: 150px;"> | |
| <div style="color: var(--arc-cyan); font-size: 12px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px; font-weight: 600; display: flex; justify-content: space-between;"> | |
| <span>PROCESSING PIPELINE:</span> | |
| <span style="color: var(--arc-orange);">{step_count} step{"s" if step_count != 1 else ""}</span> | |
| </div> | |
| <div style="max-height: 300px; overflow-y: auto;"> | |
| {steps_html} | |
| </div> | |
| </div> | |
| <!-- Info Note --> | |
| <div style="margin-top: 32px; padding: 16px; background: rgba(0,255,255,0.05); border: 1px solid rgba(0,255,255,0.2); border-radius: 4px;"> | |
| <div style="color: var(--arc-cyan); font-size: 13px;"> | |
| ⏱️ <strong style="color: var(--text-main);">Estimated Time:</strong> <span style="color: var(--text-main); opacity: 0.9;">{('20-30 seconds' if mode == 'extract' else '30-45 seconds')}</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| # ---------------------- TACTICAL CSS ---------------------- | |
| def _token_css(self) -> str: | |
| return """ | |
| :root { | |
| /* ARC RAIDERS SPECTRUM PALETTE */ | |
| --arc-red: #FF2A2A; | |
| --arc-orange: #FF7F00; | |
| --arc-yellow: #FFD400; | |
| --arc-green: #55FF00; | |
| --arc-cyan: #00FFFF; | |
| /* SURFACES */ | |
| --bg-void: #090B10; /* Deep Black/Navy */ | |
| --bg-panel: #12141A; /* Slightly lighter panel */ | |
| --bg-card: #181B24; /* Card background */ | |
| /* TEXT - IMPROVED CONTRAST */ | |
| --text-main: #FFFFFF; /* Pure White for readability */ | |
| --text-dim: #AABBC9; /* Lighter grey for secondary text */ | |
| /* BORDERS */ | |
| --border-dim: #2A303C; | |
| --border-bright: #FF7F00; | |
| /* FONTS */ | |
| --font-header: "Inter", "Segoe UI", sans-serif; | |
| --font-mono: "JetBrains Mono", "Consolas", monospace; | |
| } | |
| """ | |
| def _base_typography_css(self) -> str: | |
| return """ | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap'); | |
| body { | |
| background-color: var(--bg-void); | |
| color: var(--text-main); | |
| font-family: var(--font-header); | |
| font-size: 16px; | |
| line-height: 1.5; | |
| -webkit-font-smoothing: antialiased; | |
| margin: 0; | |
| padding: 0; | |
| min-height: 100vh; | |
| } | |
| /* FORCE FULL HEIGHT ON GRADIO CONTAINERS */ | |
| .gradio-container { | |
| max-width: 1920px !important; | |
| padding: 0 !important; | |
| min-height: 100vh !important; | |
| background-color: var(--bg-void); /* Ensure bg extends even if content is short */ | |
| } | |
| /* Fix for the prose/markdown wrapper messing up heights */ | |
| .prose { | |
| max-width: none !important; | |
| } | |
| h1, h2, h3, h4 { | |
| font-family: var(--font-header); | |
| letter-spacing: -0.02em; | |
| font-weight: 800; | |
| color: white; | |
| } | |
| /* COMPONENT OVERRIDES */ | |
| .gr-button { | |
| border-radius: 2px !important; | |
| font-family: var(--font-header) !important; | |
| font-weight: 700 !important; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| font-size: 14px !important; | |
| padding: 10px 16px !important; | |
| } | |
| .gr-box, .gr-panel, .gr-group { | |
| border-radius: 2px !important; | |
| border: 1px solid var(--border-dim) !important; | |
| background: var(--bg-panel) !important; | |
| } | |
| .gr-input, textarea { | |
| background: #0D1017 !important; | |
| border: 1px solid var(--border-dim) !important; | |
| color: var(--text-main) !important; | |
| font-family: var(--font-mono) !important; | |
| font-size: 15px !important; | |
| line-height: 1.6 !important; | |
| } | |
| .gr-form { background: transparent !important; } | |
| .gr-block { background: transparent !important; border: none !important; } | |
| span.svelte-1gfkn6j { font-size: 13px !important; font-weight: 600 !important; color: var(--arc-yellow) !important; } | |
| """ | |
| def _components_css(self) -> str: | |
| return """ | |
| /* --- TOP SPECTRUM STRIPE --- */ | |
| .status-bar-spectrum { | |
| height: 6px; | |
| width: 100%; | |
| background: linear-gradient(90deg, | |
| var(--arc-red) 0%, | |
| var(--arc-orange) 25%, | |
| var(--arc-yellow) 50%, | |
| var(--arc-green) 75%, | |
| var(--arc-cyan) 100%); | |
| box-shadow: 0 2px 15px rgba(255, 127, 0, 0.3); | |
| } | |
| /* --- BUTTON VARIANTS --- */ | |
| .btn-tac-primary { | |
| background: var(--arc-orange) !important; | |
| color: #000 !important; | |
| border: 1px solid var(--arc-orange) !important; | |
| } | |
| .btn-tac-primary:hover { | |
| background: #FF9500 !important; | |
| box-shadow: 0 0 15px rgba(255, 127, 0, 0.5); | |
| } | |
| .btn-tac-secondary { | |
| background: transparent !important; | |
| border: 1px solid var(--border-dim) !important; | |
| color: var(--text-dim) !important; | |
| } | |
| .btn-tac-secondary:hover { | |
| border-color: var(--text-main) !important; | |
| color: var(--text-main) !important; | |
| background: rgba(255,255,255,0.05) !important; | |
| } | |
| /* --- LAYOUT PANELS --- */ | |
| /* Use fill_height=True in Gradio Blocks, but CSS reinforces it */ | |
| .main-container { | |
| min-height: calc(100vh - 80px); /* Account for header height approx */ | |
| display: flex; | |
| align-items: stretch; | |
| } | |
| .input-panel { | |
| padding: 32px; | |
| border-right: 1px solid var(--border-dim); | |
| background: var(--bg-panel); | |
| height: auto !important; /* Let it grow */ | |
| min-height: 100%; | |
| } | |
| .output-panel { | |
| padding: 32px; | |
| background: #0C0E14; /* Darker background for content */ | |
| height: auto !important; | |
| min-height: 100%; | |
| flex-grow: 1; | |
| } | |
| .ent-header-label { | |
| font-size: 13px; | |
| color: var(--arc-yellow); | |
| text-transform: uppercase; | |
| letter-spacing: 1.5px; | |
| margin-bottom: 12px; | |
| font-weight: 700; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .ent-header-label::before { | |
| content: ""; | |
| display: block; | |
| width: 4px; height: 16px; | |
| background: var(--arc-yellow); | |
| box-shadow: 0 0 8px var(--arc-yellow); | |
| } | |
| /* --- ERROR STATE --- */ | |
| .error-container { | |
| border: 1px solid var(--arc-red); | |
| background: rgba(255, 42, 42, 0.05); | |
| border-left: 4px solid var(--arc-red); | |
| padding: 0; | |
| margin-top: 20px; | |
| font-family: var(--font-mono); | |
| } | |
| .error-header { | |
| background: rgba(255, 42, 42, 0.1); | |
| padding: 12px 20px; | |
| color: var(--arc-red); | |
| font-weight: 700; | |
| border-bottom: 1px solid rgba(255, 42, 42, 0.2); | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| letter-spacing: 1px; | |
| } | |
| .error-body { | |
| padding: 20px; | |
| color: var(--text-main); | |
| font-size: 14px; | |
| line-height: 1.6; | |
| } | |
| """ | |
| def _console_css(self) -> str: | |
| return """ | |
| .console-wrapper { | |
| background: #08090D; | |
| border: 1px solid var(--border-dim); | |
| padding: 16px; | |
| font-family: var(--font-mono); | |
| font-size: 13px; | |
| min-height: 300px; | |
| max-height: 40vh; | |
| overflow-y: auto; | |
| color: var(--text-main); | |
| } | |
| .console-line { | |
| margin-bottom: 8px; | |
| border-bottom: 1px solid rgba(255,255,255,0.05); | |
| padding-bottom: 4px; | |
| line-height: 1.4; | |
| } | |
| .console-timestamp { color: var(--arc-cyan); margin-right: 8px; font-weight:600; } | |
| .console-wrapper::-webkit-scrollbar { width: 8px; } | |
| .console-wrapper::-webkit-scrollbar-track { background: #08090D; } | |
| .console-wrapper::-webkit-scrollbar-thumb { background: var(--border-dim); border-radius: 4px; } | |
| /* DISABLE GRADIO DEFAULT LOADING OVERLAY */ | |
| .generating { | |
| display: none !important; | |
| } | |
| .pending { | |
| opacity: 1 !important; | |
| } | |
| .eta-bar { | |
| display: none !important; | |
| } | |
| /* LIVE NARRATION STYLING */ | |
| /* Target the audio component's container */ | |
| audio { | |
| width: 100% !important; | |
| background: #1A1D24 !important; | |
| border-radius: 4px !important; | |
| border: 1px solid rgba(255, 127, 0, 0.4) !important; | |
| } | |
| /* Style the audio player controls */ | |
| audio::-webkit-media-controls-panel { | |
| background: linear-gradient(to bottom, rgba(30, 30, 40, 0.9), rgba(20, 20, 30, 0.95)) !important; | |
| border-radius: 4px !important; | |
| } | |
| audio::-webkit-media-controls-play-button, | |
| audio::-webkit-media-controls-mute-button { | |
| border-radius: 50% !important; | |
| } | |
| audio::-webkit-media-controls-timeline { | |
| border-radius: 2px !important; | |
| height: 6px !important; | |
| } | |
| audio::-webkit-media-controls-current-time-display, | |
| audio::-webkit-media-controls-time-remaining-display { | |
| color: #FFA94D !important; | |
| font-family: var(--font-mono) !important; | |
| font-size: 12px !important; | |
| font-weight: 600 !important; | |
| text-shadow: 0 0 3px rgba(255, 127, 0, 0.4) !important; | |
| } | |
| /* Add glow effect to live narration container */ | |
| .live-narration-wrapper { | |
| padding: 16px; | |
| background: linear-gradient(135deg, rgba(26, 29, 36, 0.8), rgba(20, 23, 30, 0.9)); | |
| border: 2px solid var(--arc-orange); | |
| border-radius: 4px; | |
| box-shadow: 0 0 20px rgba(255, 127, 0, 0.3); | |
| margin-bottom: 20px; | |
| } | |
| .live-narration-label { | |
| color: var(--arc-orange); | |
| background: rgb(18, 20, 26); | |
| font-size: 12px; | |
| font-weight: 700; | |
| text-transform: uppercase; | |
| letter-spacing: 1.5px; | |
| padding-bottom: 8px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .live-narration-label::before { | |
| content: ""; | |
| display: inline-block; | |
| width: 8px; | |
| height: 8px; | |
| background: var(--arc-red); | |
| border-radius: 50%; | |
| animation: pulse-red 1.5s ease-in-out infinite; | |
| } | |
| @keyframes pulse-red { | |
| 0%, 100% { opacity: 1; box-shadow: 0 0 8px var(--arc-red); } | |
| 50% { opacity: 0.5; box-shadow: 0 0 4px var(--arc-red); } | |
| } | |
| """ | |
| def _compose_css(self) -> str: | |
| return "\n".join([ | |
| self._token_css(), | |
| self._base_typography_css(), | |
| self._components_css(), | |
| self._console_css(), | |
| ]) | |
| # --- UI Builders --- | |
| def _build_hero(self) -> str: | |
| return """ | |
| <div style="margin-bottom: 0px;"> | |
| <div class="status-bar-spectrum"></div> | |
| <div style="background: var(--bg-panel); padding: 24px 32px; display:flex; justify-content:space-between; align-items:center; border-bottom: 1px solid var(--border-dim); flex-wrap: wrap; gap: 20px;"> | |
| <div style="display:flex; align-items:center; gap: 16px;"> | |
| <div style="width:40px; height:40px; background: var(--arc-orange); border-radius: 2px; display:flex; align-items:center; justify-content:center; font-weight:800; color:black; font-size: 20px;">E</div> | |
| <div> | |
| <h1 style="margin:0; font-size: 28px; line-height:1; color:white;">EASTSYNC <span style="font-weight:400; color:var(--text-dim);">ENTERPRISE</span></h1> | |
| <div style="color:var(--text-dim); font-size:13px; letter-spacing:1px; margin-top:4px;">CAPABILITY INTELLIGENCE PLATFORM</div> | |
| </div> | |
| </div> | |
| <div style="text-align:right; font-family:var(--font-mono); color:var(--text-dim); font-size: 12px;"> | |
| <div><span style="color:var(--arc-green)">●</span> SYSTEM ONLINE</div> | |
| <div style="color:var(--arc-cyan)">VER: 4.2.0-ENT</div> | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| def build_interface(self, analyze_callback: Callable[[str], str], cancel_run_callback: Callable[[], None], start_audio_stream_callback: Callable[[], Any]) -> gr.Blocks: | |
| theme = gr.themes.Base( | |
| primary_hue="orange", | |
| neutral_hue="slate", | |
| ) | |
| # Use fill_height=True on Blocks to encourage full-screen layout | |
| with gr.Blocks(theme=theme, css=self._app_css, title="EastSync Enterprise", fill_height=True) as demo: | |
| gr.HTML(self._build_hero()) | |
| # Live Narration with custom wrapper | |
| with gr.Group(elem_classes=["live-narration-wrapper"]) as live_narration_group: | |
| gr.HTML('<div class="live-narration-label">🔴 LIVE AI NARRATION</div>') | |
| live_audio = gr.Audio( | |
| label="", | |
| streaming=True, | |
| autoplay=True, | |
| buttons=None, | |
| visible=should_narrate_events, | |
| show_label=False, | |
| elem_id="live-narrator-audio" | |
| ) | |
| # Hide the wrapper if narration is disabled | |
| if not should_narrate_events: | |
| live_narration_group.visible = False | |
| with gr.Row(equal_height=True, elem_classes=["main-container"]): | |
| # --- LEFT COLUMN: INPUTS --- | |
| with gr.Column(scale=3, elem_classes=["input-panel"]) as mission_panel: | |
| gr.HTML("<div class='ent-header-label'>PROJECT PARAMETERS</div>") | |
| input_box = gr.TextArea( | |
| label="PROJECT REQUIREMENTS", | |
| show_label=False, | |
| value=self.SAMPLE_PROMPT, | |
| lines=12, | |
| placeholder="Define project scope, technical requirements, and current team composition..." | |
| ) | |
| with gr.Row(): | |
| btn_run = gr.Button("GENERATE ROADMAP", elem_classes=["btn-tac-primary"]) | |
| with gr.Row(): | |
| btn_reset = gr.Button("RESET FORM", elem_classes=["btn-tac-secondary"]) | |
| with gr.Row(): | |
| btn_cancel = gr.Button("STOP ANALYSIS", elem_classes=["btn-tac-secondary"]) | |
| with gr.Row(): | |
| btn_cv = gr.Button("📄 CV ANALYSIS", elem_classes=["btn-tac-primary"]) | |
| gr.HTML("<div style='height:30px'></div>") # Flexible Spacer | |
| gr.HTML("<div class='ent-header-label'>ACTIVITY LOG</div>") | |
| console = gr.HTML(self.get_action_log_text()) | |
| # --- LEFT COLUMN: CV UPLOAD (hidden by default) --- | |
| with gr.Column(scale=3, elem_classes=["input-panel"], visible=False) as cv_upload_panel: | |
| gr.HTML("<div class='ent-header-label'>📄 CV ANALYSIS</div>") | |
| gr.HTML(""" | |
| <div style="padding: 16px; background: rgba(255,127,0,0.1); border: 1px solid var(--arc-orange); border-radius: 4px; margin-bottom: 16px;"> | |
| <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;"> | |
| <div style="padding: 10px; background: rgba(255,255,255,0.05); border-radius: 4px; border-left: 3px solid var(--arc-cyan);"> | |
| <div style="color: var(--arc-cyan); font-weight: 600; margin-bottom: 6px; font-size: 12px;">📊 EXTRACT SKILLS</div> | |
| <div style="color: var(--text-dim); font-size: 10px;"> | |
| Parse CV to identify technical skills, experience, certifications. | |
| </div> | |
| </div> | |
| <div style="padding: 10px; background: rgba(255,255,255,0.05); border-radius: 4px; border-left: 3px solid var(--arc-orange);"> | |
| <div style="color: var(--arc-orange); font-weight: 600; margin-bottom: 6px; font-size: 12px;">🎯 EXTRACT + MATCH</div> | |
| <div style="color: var(--text-dim); font-size: 10px;"> | |
| Parse CV, rank projects, identify skill gaps. | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| """) | |
| cv_file_input = gr.File( | |
| label="SELECT CV FILE", | |
| file_types=[".pdf", ".docx", ".doc"], | |
| type="filepath" | |
| ) | |
| with gr.Row(): | |
| btn_process_cv = gr.Button("📊 EXTRACT SKILLS", elem_classes=["btn-tac-secondary"]) | |
| btn_process_cv_match = gr.Button("🎯 EXTRACT + MATCH PROJECTS", elem_classes=["btn-tac-primary"]) | |
| with gr.Row(): | |
| btn_close_cv = gr.Button("← BACK", elem_classes=["btn-tac-secondary"]) | |
| gr.HTML("<div style='height:30px'></div>") | |
| gr.HTML("<div class='ent-header-label'>SYSTEM LOGS</div>") | |
| console_cv = gr.HTML(self.get_action_log_text()) | |
| # --- RIGHT COLUMN: OUTPUT --- | |
| with gr.Column(scale=7, elem_classes=["output-panel"]): | |
| output_display = gr.HTML(self.render_idle_state()) | |
| # --- Event Bindings --- | |
| # Project Analysis | |
| def start_project_analysis(): | |
| self.start_processing("project") | |
| return self.render_project_processing_state() | |
| # Trigger audio stream independently so it doesn't block analysis | |
| btn_run.click(start_audio_stream_callback, outputs=live_audio) | |
| btn_run.click( | |
| start_project_analysis, | |
| outputs=output_display, | |
| queue=False | |
| ).then( | |
| self.clear_action_log, outputs=console, queue=False | |
| ).then( | |
| lambda: self.get_action_log_text(), outputs=console | |
| ).then( | |
| analyze_callback, inputs=input_box, outputs=output_display | |
| ).then( | |
| self.get_action_log_text, outputs=console | |
| ) | |
| btn_reset.click(self.reset_prompt_value, outputs=input_box) | |
| # CV Button - Toggle panels | |
| def show_cv_interface(): | |
| return ( | |
| gr.update(visible=False), # Hide mission_panel | |
| gr.update(visible=True), # Show cv_upload_panel | |
| self._cv_interface.render_cv_upload_interface() # Update output_display | |
| ) | |
| btn_cv.click( | |
| show_cv_interface, | |
| outputs=[mission_panel, cv_upload_panel, output_display] | |
| ) | |
| # Close CV Section - Back to mission panel | |
| def close_cv_interface(): | |
| return ( | |
| gr.update(visible=True), # Show mission_panel | |
| gr.update(visible=False), # Hide cv_upload_panel | |
| self.render_idle_state() # Reset output_display | |
| ) | |
| btn_close_cv.click( | |
| close_cv_interface, | |
| outputs=[mission_panel, cv_upload_panel, output_display] | |
| ) | |
| # Standard CV analysis (no project matching) | |
| def start_cv_extract(): | |
| self.start_processing("extract") | |
| return self.render_processing_state("extract") | |
| def finish_cv_processing(): | |
| self.stop_processing() | |
| return self.get_action_log_text() | |
| btn_process_cv.click( | |
| start_cv_extract, | |
| outputs=output_display, | |
| queue=False | |
| ).then( | |
| self.clear_action_log, outputs=console_cv, queue=False | |
| ).then( | |
| self._cv_interface.process_cv_upload, | |
| inputs=cv_file_input, | |
| outputs=output_display | |
| ).then( | |
| finish_cv_processing, outputs=console_cv | |
| ) | |
| # CV + Project Matching | |
| def start_cv_match(): | |
| self.start_processing("match") | |
| return self.render_processing_state("match") | |
| btn_process_cv_match.click( | |
| start_cv_match, | |
| outputs=output_display, | |
| queue=False | |
| ).then( | |
| self.clear_action_log, outputs=console_cv, queue=False | |
| ).then( | |
| self._cv_interface.process_cv_with_matching, | |
| inputs=cv_file_input, | |
| outputs=output_display | |
| ).then( | |
| finish_cv_processing, outputs=console_cv | |
| ) | |
| btn_cancel.click(cancel_run_callback) | |
| # Live log updates (both consoles) | |
| def update_both_consoles(): | |
| log_text = self.get_action_log_text() | |
| return (log_text, log_text) # Return same log for both consoles | |
| gr.Timer(new_events_check_interval_seconds).tick(update_both_consoles, outputs=[console, console_cv]) | |
| # Check for analysis result updates (poll every 1 second) | |
| def check_analysis_result(): | |
| """Check if analysis is complete and update output display.""" | |
| output = self.get_analysis_output() | |
| # Only return if there's an update (None means no update needed) | |
| if output is not None: | |
| return output | |
| # Return None to skip update (Gradio will handle this) | |
| return None | |
| # Poll for results - Gradio will skip None returns | |
| def poll_with_skip(): | |
| result = check_analysis_result() | |
| # Return the result if not None, otherwise skip update | |
| return result if result is not None else gr.update() | |
| gr.Timer(new_events_check_interval_seconds).tick(poll_with_skip, outputs=output_display) | |
| return demo |