diff --git a/gui.py b/gui.py deleted file mode 100644 index 4467caf..0000000 --- a/gui.py +++ /dev/null @@ -1,3962 +0,0 @@ -# new_gui.py - -import os -import json -import requests -import threading -import math -import random -from datetime import datetime -from io import BytesIO -from PIL import Image -import time -from pathlib import Path -import uuid -import shutil -import networkx as nx -import re -import sys -import webbrowser -import base64 -from PyQt6.QtCore import Qt, QRect, QTimer, QRectF, QPointF, QSize, pyqtSignal, QEvent, QPropertyAnimation, QEasingCurve -from PyQt6.QtGui import QFont, QColor, QPainter, QPen, QBrush, QFontDatabase, QTextCursor, QAction, QKeySequence, QTextCharFormat, QLinearGradient, QRadialGradient, QPainterPath, QImage, QPixmap -from PyQt6.QtWidgets import QWidget, QApplication, QMainWindow, QSplitter, QVBoxLayout, QHBoxLayout, QTextEdit, QFrame, QLineEdit, QPushButton, QLabel, QComboBox, QMenu, QFileDialog, QMessageBox, QScrollArea, QToolTip, QSizePolicy, QCheckBox, QGraphicsDropShadowEffect - -from config import ( - AI_MODELS, - SYSTEM_PROMPT_PAIRS, - SHOW_CHAIN_OF_THOUGHT_IN_CONTEXT -) - -# Add import for the HTML viewing functionality -from shared_utils import open_html_in_browser, generate_image_from_text - -# Define global color palette for consistent styling - Cyberpunk theme -COLORS = { - # Backgrounds - darker, moodier - 'bg_dark': '#0A0E1A', # Deep blue-black - 'bg_medium': '#111827', # Slate dark - 'bg_light': '#1E293B', # Lighter slate - - # Primary accents - neon but muted - 'accent_cyan': '#06B6D4', # Cyan (primary) - 'accent_cyan_hover': '#0891B2', - 'accent_cyan_active': '#0E7490', - - # Secondary accents - 'accent_pink': '#EC4899', # Hot pink (secondary) - 'accent_purple': '#A855F7', # Purple (tertiary) - 'accent_yellow': '#FBBF24', # Amber for warnings - 'accent_green': '#10B981', # Emerald (rabbithole) - - # Text colors - 'text_normal': '#CBD5E1', # Slate-200 - 'text_dim': '#64748B', # Slate-500 - 'text_bright': '#F1F5F9', # Slate-50 - 'text_glow': '#38BDF8', # Sky-400 (glowing text) - 'text_error': '#EF4444', # Red-500 - - # Borders and effects - 'border': '#1E293B', # Slate-800 - 'border_glow': '#06B6D4', # Glowing cyan borders - 'border_highlight': '#334155', # Slate-700 - 'shadow': 'rgba(6, 182, 212, 0.2)', # Cyan glow shadows - - # Legacy color mappings for compatibility - 'accent_blue': '#06B6D4', # Map old blue to cyan - 'accent_blue_hover': '#0891B2', - 'accent_blue_active': '#0E7490', - 'accent_orange': '#F59E0B', # Amber-500 - 'chain_of_thought': '#10B981', # Emerald - 'user_header': '#06B6D4', # Cyan - 'ai_header': '#A855F7', # Purple - 'system_message': '#F59E0B', # Amber -} - - -def apply_glow_effect(widget, color, blur_radius=15, offset=(0, 2)): - """Apply a glowing drop shadow effect to a widget""" - shadow = QGraphicsDropShadowEffect() - shadow.setBlurRadius(blur_radius) - shadow.setColor(QColor(color)) - shadow.setOffset(offset[0], offset[1]) - widget.setGraphicsEffect(shadow) - return shadow - - -class GlowButton(QPushButton): - """Enhanced button with glow effect on hover""" - - def __init__(self, text, glow_color=COLORS['accent_cyan'], parent=None): - super().__init__(text, parent) - self.glow_color = glow_color - self.base_blur = 8 - self.hover_blur = 20 - - # Create shadow effect - self.shadow = QGraphicsDropShadowEffect() - self.shadow.setBlurRadius(self.base_blur) - self.shadow.setColor(QColor(glow_color)) - self.shadow.setOffset(0, 2) - self.setGraphicsEffect(self.shadow) - - # Track hover state for animation - self.setMouseTracking(True) - - def enterEvent(self, event): - """Increase glow on hover""" - self.shadow.setBlurRadius(self.hover_blur) - self.shadow.setColor(QColor(self.glow_color)) - super().enterEvent(event) - - def leaveEvent(self, event): - """Decrease glow when not hovering""" - self.shadow.setBlurRadius(self.base_blur) - super().leaveEvent(event) - -# Load custom fonts -def load_fonts(): - """Load custom fonts for the application""" - font_dir = Path("fonts") - font_dir.mkdir(exist_ok=True) - - # List of fonts to load - these would need to be included with the application - fonts = [ - ("IosevkaTerm-Regular.ttf", "Iosevka Term"), - ("IosevkaTerm-Bold.ttf", "Iosevka Term"), - ("IosevkaTerm-Italic.ttf", "Iosevka Term"), - ] - - loaded_fonts = [] - for font_file, font_name in fonts: - font_path = font_dir / font_file - if font_path.exists(): - font_id = QFontDatabase.addApplicationFont(str(font_path)) - if font_id >= 0: - if font_name not in loaded_fonts: - loaded_fonts.append(font_name) - print(f"Loaded font: {font_name} from {font_file}") - else: - print(f"Failed to load font: {font_file}") - else: - print(f"Font file not found: {font_path}") - - return loaded_fonts - - -# ═══════════════════════════════════════════════════════════════════════════════ -# ATMOSPHERIC EFFECT WIDGETS -# ═══════════════════════════════════════════════════════════════════════════════ - -class DepthGauge(QWidget): - """Vertical gauge showing conversation depth/turn progress""" - - def __init__(self, parent=None): - super().__init__(parent) - self.current_turn = 0 - self.max_turns = 10 - self.setFixedWidth(24) - self.setMinimumHeight(100) - - # Animation - self.pulse_offset = 0 - self.pulse_timer = QTimer(self) - self.pulse_timer.timeout.connect(self._animate_pulse) - self.pulse_timer.start(50) - - def _animate_pulse(self): - self.pulse_offset = (self.pulse_offset + 2) % 360 - self.update() - - def set_progress(self, current, maximum): - """Update the gauge progress""" - self.current_turn = current - self.max_turns = max(maximum, 1) - self.update() - - def paintEvent(self, event): - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - w, h = self.width(), self.height() - margin = 4 - gauge_width = w - margin * 2 - gauge_height = h - margin * 2 - - # Background track - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(QColor(COLORS['bg_dark'])) - painter.drawRoundedRect(margin, margin, gauge_width, gauge_height, 4, 4) - - # Border - painter.setPen(QPen(QColor(COLORS['border_glow']), 1)) - painter.setBrush(Qt.BrushStyle.NoBrush) - painter.drawRoundedRect(margin, margin, gauge_width, gauge_height, 4, 4) - - # Calculate fill height (fills from bottom to top) - progress = min(self.current_turn / self.max_turns, 1.0) - fill_height = int(gauge_height * progress) - fill_y = margin + gauge_height - fill_height - - if fill_height > 0: - # Gradient fill - gradient = QLinearGradient(0, fill_y, 0, margin + gauge_height) - - # Color shifts based on depth - deeper = more purple/pink - if progress < 0.33: - gradient.setColorAt(0, QColor(COLORS['accent_cyan'])) - gradient.setColorAt(1, QColor(COLORS['accent_cyan']).darker(130)) - elif progress < 0.66: - gradient.setColorAt(0, QColor(COLORS['accent_purple'])) - gradient.setColorAt(1, QColor(COLORS['accent_cyan'])) - else: - gradient.setColorAt(0, QColor(COLORS['accent_pink'])) - gradient.setColorAt(1, QColor(COLORS['accent_purple'])) - - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(gradient) - painter.drawRoundedRect(margin + 2, fill_y, gauge_width - 4, fill_height, 2, 2) - - # Pulsing glow line at top of fill - pulse_alpha = int(100 + 80 * math.sin(math.radians(self.pulse_offset))) - glow_color = QColor(COLORS['accent_cyan']) - glow_color.setAlpha(pulse_alpha) - painter.setPen(QPen(glow_color, 2)) - painter.drawLine(margin + 2, fill_y, margin + gauge_width - 2, fill_y) - - # Turn counter text - painter.setPen(QColor(COLORS['text_dim'])) - font = painter.font() - font.setPixelSize(9) - painter.setFont(font) - text = f"{self.current_turn}" - painter.drawText(self.rect(), Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop, text) - - -class SignalIndicator(QWidget): - """Signal strength/latency indicator""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setFixedSize(80, 20) - self.signal_strength = 1.0 # 0.0 to 1.0 - self.latency_ms = 0 - self.is_active = False - - # Animation for activity - self.bar_offset = 0 - self.activity_timer = QTimer(self) - self.activity_timer.timeout.connect(self._animate) - - def _animate(self): - self.bar_offset = (self.bar_offset + 1) % 5 - self.update() - - def set_active(self, active): - """Set whether we're actively waiting for a response""" - self.is_active = active - if active: - self.activity_timer.start(100) - else: - self.activity_timer.stop() - self.update() - - def set_latency(self, latency_ms): - """Update the latency display""" - self.latency_ms = latency_ms - # Calculate signal strength based on latency - if latency_ms < 500: - self.signal_strength = 1.0 - elif latency_ms < 1500: - self.signal_strength = 0.75 - elif latency_ms < 3000: - self.signal_strength = 0.5 - else: - self.signal_strength = 0.25 - self.update() - - def paintEvent(self, event): - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - # Draw signal bars - bar_heights = [4, 7, 10, 13, 16] - bar_width = 4 - spacing = 2 - start_x = 5 - base_y = 18 - - for i, bar_h in enumerate(bar_heights): - x = start_x + i * (bar_width + spacing) - y = base_y - bar_h - - # Determine if this bar should be lit - threshold = (i + 1) / len(bar_heights) - is_lit = self.signal_strength >= threshold - - if self.is_active: - # Animated pattern when active - is_lit = ((i + self.bar_offset) % 5) < 3 - color = QColor(COLORS['accent_cyan']) if is_lit else QColor(COLORS['bg_light']) - else: - if is_lit: - # Color based on signal strength - if self.signal_strength > 0.7: - color = QColor(COLORS['accent_green']) - elif self.signal_strength > 0.4: - color = QColor(COLORS['accent_yellow']) - else: - color = QColor(COLORS['accent_pink']) - else: - color = QColor(COLORS['bg_light']) - - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(color) - painter.drawRoundedRect(x, y, bar_width, bar_h, 1, 1) - - # Draw latency text - painter.setPen(QColor(COLORS['text_dim'])) - font = painter.font() - font.setPixelSize(9) - painter.setFont(font) - - if self.is_active: - text = "···" - elif self.latency_ms > 0: - text = f"{self.latency_ms}ms" - else: - text = "IDLE" - - painter.drawText(40, 3, 40, 16, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, text) - - -class NetworkGraphWidget(QWidget): - nodeSelected = pyqtSignal(str) - nodeHovered = pyqtSignal(str) - - def __init__(self): - super().__init__() - - # Graph data - self.nodes = [] - self.edges = [] - self.node_positions = {} - self.node_colors = {} - self.node_labels = {} - self.node_sizes = {} - - # Edge animation data - self.growing_edges = {} # Dictionary to track growing edges: {(source, target): growth_progress} - self.edge_growth_speed = 0.05 # Increased speed of edge growth animation (was 0.02) - - # Visual settings - self.margin = 50 - self.selected_node = None - self.hovered_node = None - self.animation_progress = 0 - self.animation_timer = QTimer(self) - self.animation_timer.timeout.connect(self.update_animation) - self.animation_timer.start(50) # 20 FPS animation - - # Mycelial node settings - self.hyphae_count = 5 # Number of hyphae per node - self.hyphae_length_factor = 0.4 # Length of hyphae relative to node radius - self.hyphae_variation = 0.3 # Random variation in hyphae - - # Node colors - use global color palette with mycelial theme - self.node_colors_by_type = { - 'main': '#8E9DCC', # Soft blue-purple - 'rabbithole': '#7FB069', # Soft green - 'fork': '#F2C14E', # Soft yellow - 'branch': '#F78154' # Soft orange - } - - # Collision dynamics - self.node_velocities = {} # Store velocities for each node - self.repulsion_strength = 0.5 # Strength of repulsion between nodes - self.attraction_strength = 0.1 # Strength of attraction along edges - self.damping = 0.8 # Damping factor to prevent oscillation - self.apply_physics = True # Toggle for physics simulation - - # Set up the widget - self.setMinimumSize(300, 300) - self.setMouseTracking(True) - - def add_edge(self, source, target): - """Add an edge with growth animation""" - if (source, target) not in self.edges: - self.edges.append((source, target)) - # Initialize edge growth at 0 - self.growing_edges[(source, target)] = 0.0 - # Force update to start animation immediately - self.update() - - def update_animation(self): - """Update animation state""" - self.animation_progress = (self.animation_progress + 0.05) % 1.0 - - # Update growing edges - edges_to_remove = [] - has_growing_edges = False - - for edge, progress in self.growing_edges.items(): - if progress < 1.0: - self.growing_edges[edge] = min(progress + self.edge_growth_speed, 1.0) - has_growing_edges = True - else: - # Mark fully grown edges for removal from animation tracking - edges_to_remove.append(edge) - - # Remove fully grown edges from tracking - for edge in edges_to_remove: - if edge in self.growing_edges: - self.growing_edges.pop(edge) - - # Apply collision dynamics if enabled - if self.apply_physics and len(self.nodes) > 1: - self.apply_collision_dynamics() - - # Update the widget - self.update() - - def apply_collision_dynamics(self): - """Apply collision dynamics to prevent node overlap""" - # Initialize velocities if needed - for node_id in self.nodes: - if node_id not in self.node_velocities: - self.node_velocities[node_id] = (0, 0) - - # Calculate repulsive forces between nodes - new_velocities = {} - for node_id in self.nodes: - if node_id not in self.node_positions: - continue - - vx, vy = self.node_velocities.get(node_id, (0, 0)) - x1, y1 = self.node_positions[node_id] - - # Apply repulsion between nodes - for other_id in self.nodes: - if other_id == node_id or other_id not in self.node_positions: - continue - - x2, y2 = self.node_positions[other_id] - - # Calculate distance - dx = x1 - x2 - dy = y1 - y2 - distance = max(0.1, math.sqrt(dx*dx + dy*dy)) # Avoid division by zero - - # Get node sizes - size1 = math.sqrt(self.node_sizes.get(node_id, 400)) - size2 = math.sqrt(self.node_sizes.get(other_id, 400)) - min_distance = (size1 + size2) / 2 - - # Apply repulsive force if nodes are too close - if distance < min_distance * 2: - # Normalize direction vector - nx = dx / distance - ny = dy / distance - - # Calculate repulsion strength (stronger when closer) - strength = self.repulsion_strength * (1.0 - distance / (min_distance * 2)) - - # Apply force - vx += nx * strength - vy += ny * strength - - # Apply attraction along edges - for edge in self.edges: - source, target = edge - - # Skip edges that are still growing - if (source, target) in self.growing_edges and self.growing_edges[(source, target)] < 1.0: - continue - - if source == node_id and target in self.node_positions: - # This node is the source, attract towards target - x2, y2 = self.node_positions[target] - dx = x2 - x1 - dy = y2 - y1 - distance = max(0.1, math.sqrt(dx*dx + dy*dy)) - - # Normalize and apply attraction - vx += (dx / distance) * self.attraction_strength - vy += (dy / distance) * self.attraction_strength - - elif target == node_id and source in self.node_positions: - # This node is the target, attract towards source - x2, y2 = self.node_positions[source] - dx = x2 - x1 - dy = y2 - y1 - distance = max(0.1, math.sqrt(dx*dx + dy*dy)) - - # Normalize and apply attraction - vx += (dx / distance) * self.attraction_strength - vy += (dy / distance) * self.attraction_strength - - # Apply damping to prevent oscillation - vx *= self.damping - vy *= self.damping - - # Store new velocity - new_velocities[node_id] = (vx, vy) - - # Update positions based on velocities - for node_id, (vx, vy) in new_velocities.items(): - if node_id in self.node_positions: - # Skip the main node to keep it centered - if node_id == 'main': - continue - - x, y = self.node_positions[node_id] - self.node_positions[node_id] = (x + vx, y + vy) - - # Update velocities for next frame - self.node_velocities = new_velocities - - def paintEvent(self, event): - """Paint the network graph""" - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - # Get widget dimensions - width = self.width() - height = self.height() - - # Set background with subtle gradient - gradient = QLinearGradient(0, 0, 0, height) - gradient.setColorAt(0, QColor('#1A1A1E')) # Dark blue-gray - gradient.setColorAt(1, QColor('#0F0F12')) # Darker at bottom - painter.fillRect(0, 0, width, height, gradient) - - # Draw subtle grid lines - painter.setPen(QPen(QColor(COLORS['border']).darker(150), 0.5, Qt.PenStyle.DotLine)) - grid_size = 40 - for x in range(0, width, grid_size): - painter.drawLine(x, 0, x, height) - for y in range(0, height, grid_size): - painter.drawLine(0, y, width, y) - - # Calculate center point and scale factor - center_x = width / 2 - center_y = height / 2 - scale = min(width, height) / 500 - - # Draw edges first so they appear behind nodes - for edge in self.edges: - source, target = edge - if source in self.node_positions and target in self.node_positions: - src_x, src_y = self.node_positions[source] - dst_x, dst_y = self.node_positions[target] - - # Transform coordinates to screen space - screen_src_x = center_x + src_x * scale - screen_src_y = center_y + src_y * scale - screen_dst_x = center_x + dst_x * scale - screen_dst_y = center_y + dst_y * scale - - # Get growth progress for this edge (default to 1.0 if not growing) - growth_progress = self.growing_edges.get((source, target), 1.0) - - # Calculate the actual destination based on growth progress - if growth_progress < 1.0: - # Interpolate between source and destination - actual_dst_x = screen_src_x + (screen_dst_x - screen_src_x) * growth_progress - actual_dst_y = screen_src_y + (screen_dst_y - screen_src_y) * growth_progress - else: - actual_dst_x = screen_dst_x - actual_dst_y = screen_dst_y - - # Draw mycelial connection (multiple thin lines with variations) - source_color = QColor(self.node_colors.get(source, self.node_colors_by_type['main'])) - target_color = QColor(self.node_colors.get(target, self.node_colors_by_type['main'])) - - # Number of filaments per connection - num_filaments = 3 - - for i in range(num_filaments): - # Create a path with multiple segments for organic look - path = QPainterPath() - path.moveTo(screen_src_x, screen_src_y) - - # Calculate distance between points - distance = math.sqrt((actual_dst_x - screen_src_x)**2 + (actual_dst_y - screen_src_y)**2) - - # Number of segments increases with distance - num_segments = max(3, int(distance / 40)) - - # Create intermediate points with slight random variations - prev_x, prev_y = screen_src_x, screen_src_y - - for j in range(1, num_segments): - # Calculate position along the line - ratio = j / num_segments - - # Base position - base_x = screen_src_x + (actual_dst_x - screen_src_x) * ratio - base_y = screen_src_y + (actual_dst_y - screen_src_y) * ratio - - # Add random variation perpendicular to the line - angle = math.atan2(actual_dst_y - screen_src_y, actual_dst_x - screen_src_x) + math.pi/2 - variation = (random.random() - 0.5) * 10 * scale - - # Variation decreases near endpoints - endpoint_factor = min(ratio, 1 - ratio) * 4 # Maximum at middle - variation *= endpoint_factor - - # Apply variation - point_x = base_x + variation * math.cos(angle) - point_y = base_y + variation * math.sin(angle) - - # Add point to path - path.lineTo(point_x, point_y) - prev_x, prev_y = point_x, point_y - - # Complete the path to destination - path.lineTo(actual_dst_x, actual_dst_y) - - # Create gradient along the path - gradient = QLinearGradient(screen_src_x, screen_src_y, actual_dst_x, actual_dst_y) - - # Make colors more transparent for mycelial effect - source_color_trans = QColor(source_color) - target_color_trans = QColor(target_color) - - # Vary transparency by filament - alpha = 70 + i * 20 - source_color_trans.setAlpha(alpha) - target_color_trans.setAlpha(alpha) - - gradient.setColorAt(0, source_color_trans) - gradient.setColorAt(1, target_color_trans) - - # Animate flow along edge - flow_pos = (self.animation_progress + i * 0.3) % 1.0 - flow_color = QColor(255, 255, 255, 100) - gradient.setColorAt(flow_pos, flow_color) - - # Draw the edge with varying thickness - thickness = 1.0 + (i * 0.5) - pen = QPen(QBrush(gradient), thickness) - pen.setCapStyle(Qt.PenCapStyle.RoundCap) - painter.setPen(pen) - painter.drawPath(path) - - # Draw small nodes along the path for mycelial effect - if growth_progress == 1.0: # Only for fully grown edges - num_nodes = int(distance / 50) - for j in range(1, num_nodes): - ratio = j / num_nodes - node_x = screen_src_x + (screen_dst_x - screen_src_x) * ratio - node_y = screen_src_y + (screen_dst_y - screen_src_y) * ratio - - # Add small random offset - offset_angle = random.random() * math.pi * 2 - offset_dist = random.random() * 5 - node_x += math.cos(offset_angle) * offset_dist - node_y += math.sin(offset_angle) * offset_dist - - # Draw small node - node_color = QColor(source_color) - node_color.setAlpha(100) - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(QBrush(node_color)) - node_size = 1 + random.random() * 2 - painter.drawEllipse(QPointF(node_x, node_y), node_size, node_size) - - # Draw nodes - for node_id in self.nodes: - if node_id in self.node_positions: - x, y = self.node_positions[node_id] - - # Transform coordinates to screen space - screen_x = center_x + x * scale - screen_y = center_y + y * scale - - # Get node properties - node_color = self.node_colors.get(node_id, self.node_colors_by_type['branch']) - node_label = self.node_labels.get(node_id, 'Node') - node_size = self.node_sizes.get(node_id, 400) - - # Scale the node size - radius = math.sqrt(node_size) * scale / 2 - - # Adjust radius for hover/selection - if node_id == self.selected_node: - radius *= 1.1 # Larger when selected - elif node_id == self.hovered_node: - radius *= 1.05 # Slightly larger when hovered - - # Draw node glow for selected/hovered nodes - if node_id == self.selected_node or node_id == self.hovered_node: - glow_radius = radius * 1.5 - glow_color = QColor(node_color) - - for i in range(5): - r = glow_radius - (i * radius * 0.1) - alpha = 40 - (i * 8) - glow_color.setAlpha(alpha) - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(glow_color) - painter.drawEllipse(QPointF(screen_x, screen_y), r, r) - - # Draw mycelial node (irregular shape with hyphae) - painter.setPen(Qt.PenStyle.NoPen) - - # Create gradient fill for node - gradient = QRadialGradient(screen_x, screen_y, radius) - base_color = QColor(node_color) - lighter_color = QColor(node_color).lighter(130) - darker_color = QColor(node_color).darker(130) - - gradient.setColorAt(0, lighter_color) - gradient.setColorAt(0.7, base_color) - gradient.setColorAt(1, darker_color) - - # Fill main node body - painter.setBrush(QBrush(gradient)) - - # Draw irregular node shape - path = QPainterPath() - - # Create irregular circle with random variations - num_points = 20 - start_angle = random.random() * math.pi * 2 - - for i in range(num_points + 1): - angle = start_angle + (i * 2 * math.pi / num_points) - # Vary radius slightly for organic look - variation = 1.0 + (random.random() - 0.5) * 0.2 - point_radius = radius * variation - - x_point = screen_x + math.cos(angle) * point_radius - y_point = screen_y + math.sin(angle) * point_radius - - if i == 0: - path.moveTo(x_point, y_point) - else: - # Use quadratic curves for smoother shape - control_angle = start_angle + ((i - 0.5) * 2 * math.pi / num_points) - control_radius = radius * (1.0 + (random.random() - 0.5) * 0.1) - control_x = screen_x + math.cos(control_angle) * control_radius - control_y = screen_y + math.sin(control_angle) * control_radius - - path.quadTo(control_x, control_y, x_point, y_point) - - # Draw the main node body - painter.drawPath(path) - - # Draw hyphae (mycelial extensions) - hyphae_count = self.hyphae_count - if node_id == 'main': - hyphae_count += 3 # More hyphae for main node - - for i in range(hyphae_count): - # Random angle for hyphae - angle = random.random() * math.pi * 2 - - # Base length varies by node type - base_length = radius * self.hyphae_length_factor - if node_id == 'main': - base_length *= 1.5 - - # Random variation in length - length = base_length * (1.0 + (random.random() - 0.5) * self.hyphae_variation) - - # Calculate end point - end_x = screen_x + math.cos(angle) * (radius + length) - end_y = screen_y + math.sin(angle) * (radius + length) - - # Start point is on the node perimeter - start_x = screen_x + math.cos(angle) * radius * 0.9 - start_y = screen_y + math.sin(angle) * radius * 0.9 - - # Create hyphae path with slight curve - hypha_path = QPainterPath() - hypha_path.moveTo(start_x, start_y) - - # Control point for curve - ctrl_angle = angle + (random.random() - 0.5) * 0.5 # Slight angle variation - ctrl_dist = radius + length * 0.5 - ctrl_x = screen_x + math.cos(ctrl_angle) * ctrl_dist - ctrl_y = screen_y + math.sin(ctrl_angle) * ctrl_dist - - hypha_path.quadTo(ctrl_x, ctrl_y, end_x, end_y) - - # Draw hypha with gradient - hypha_gradient = QLinearGradient(start_x, start_y, end_x, end_y) - - # Hypha color starts as node color and fades out - hypha_start_color = QColor(node_color) - hypha_end_color = QColor(node_color) - hypha_start_color.setAlpha(150) - hypha_end_color.setAlpha(30) - - hypha_gradient.setColorAt(0, hypha_start_color) - hypha_gradient.setColorAt(1, hypha_end_color) - - # Draw hypha with varying thickness - thickness = 1.0 + random.random() * 1.5 - hypha_pen = QPen(QBrush(hypha_gradient), thickness) - hypha_pen.setCapStyle(Qt.PenCapStyle.RoundCap) - painter.setPen(hypha_pen) - painter.drawPath(hypha_path) - - # Add small nodes at the end of some hyphae - if random.random() > 0.5: - small_node_color = QColor(node_color) - small_node_color.setAlpha(100) - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(QBrush(small_node_color)) - small_node_size = 1 + random.random() * 2 - painter.drawEllipse(QPointF(end_x, end_y), small_node_size, small_node_size) - - def draw_arrow_head(self, painter, x1, y1, x2, y2): - """Draw an arrow head at the end of a line""" - # For mycelial style, we don't need arrow heads - pass - - def mousePressEvent(self, event): - """Handle mouse press events""" - if event.button() == Qt.MouseButton.LeftButton: - # Get click position - pos = event.position() - - # Check if a node was clicked - clicked_node = self.get_node_at_position(pos) - if clicked_node: - self.selected_node = clicked_node - self.update() - self.nodeSelected.emit(clicked_node) - - def mouseMoveEvent(self, event): - """Handle mouse move events for hover effects""" - pos = event.position() - hovered_node = self.get_node_at_position(pos) - - if hovered_node != self.hovered_node: - self.hovered_node = hovered_node - self.update() - if hovered_node: - self.nodeHovered.emit(hovered_node) - - # Show tooltip with node info - if hovered_node in self.node_labels: - # Get node type from the ID - node_type = "main" - if "rabbithole_" in hovered_node: - node_type = "rabbithole" - elif "fork_" in hovered_node: - node_type = "fork" - - # Set emoji based on node type - emoji = "🌱" # Default/main - if node_type == "rabbithole": - emoji = "🕳️" # Rabbithole emoji - elif node_type == "fork": - emoji = "🔱" # Fork emoji - - # Show tooltip with emoji and label - QToolTip.showText( - event.globalPosition().toPoint(), - f"{emoji} {self.node_labels[hovered_node]}", - self - ) - - def get_node_at_position(self, pos): - """Get the node at the given position""" - # Calculate center point and scale factor - width = self.width() - height = self.height() - center_x = width / 2 - center_y = height / 2 - scale = min(width, height) / 500 - - # Check each node - for node_id in self.nodes: - if node_id in self.node_positions: - x, y = self.node_positions[node_id] - screen_x = center_x + x * scale - screen_y = center_y + y * scale - - # Get node size - node_size = self.node_sizes.get(node_id, 400) - radius = math.sqrt(node_size) * scale / 2 - - # Check if click is inside the node - distance = math.sqrt((pos.x() - screen_x)**2 + (pos.y() - screen_y)**2) - if distance <= radius: - return node_id - - return None - - def resizeEvent(self, event): - """Handle resize events""" - super().resizeEvent(event) - self.update() - -class NetworkPane(QWidget): - nodeSelected = pyqtSignal(str) - - def __init__(self): - super().__init__() - - # Main layout - layout = QVBoxLayout(self) - layout.setContentsMargins(10, 10, 10, 10) - - # Title - title = QLabel("Propagation Network") - title.setStyleSheet("color: #D4D4D4; font-size: 14px; font-weight: bold; font-family: 'Orbitron', sans-serif;") - layout.addWidget(title, alignment=Qt.AlignmentFlag.AlignCenter) - - # Network view - set to expand to fill available space - self.network_view = NetworkGraphWidget() - self.network_view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - layout.addWidget(self.network_view, 1) # Add stretch factor of 1 to make it expand - - # Connect signals - self.network_view.nodeSelected.connect(self.nodeSelected) - - # Initialize graph - self.graph = nx.DiGraph() - self.node_positions = {} - self.node_colors = {} - self.node_labels = {} - self.node_sizes = {} - - # Add main node - self.add_node('main', 'Seed', 'main') - - def add_node(self, node_id, label, node_type='branch'): - """Add a node to the graph""" - try: - # Add the node to the graph - self.graph.add_node(node_id) - - # Set node properties based on type - if node_type == 'main': - color = '#569CD6' # Blue - size = 800 - elif node_type == 'rabbithole': - color = '#B5CEA8' # Green - size = 600 - elif node_type == 'fork': - color = '#DCDCAA' # Yellow - size = 600 - else: - color = '#CE9178' # Orange - size = 400 - - # Store node properties - self.node_colors[node_id] = color - self.node_labels[node_id] = label - self.node_sizes[node_id] = size - - # Calculate position based on existing nodes - self.calculate_node_position(node_id, node_type) - - # Redraw the graph - self.update_graph() - - except Exception as e: - print(f"Error adding node: {e}") - - def add_edge(self, source_id, target_id): - """Add an edge between two nodes""" - try: - # Add the edge to the graph - self.graph.add_edge(source_id, target_id) - - # Redraw the graph - self.update_graph() - - except Exception as e: - print(f"Error adding edge: {e}") - - def calculate_node_position(self, node_id, node_type): - """Calculate position for a new node""" - # Get number of existing nodes - num_nodes = len(self.graph.nodes) - 1 # Exclude the main node - - if node_type == 'main': - # Main node is at center - self.node_positions[node_id] = (0, 0) - else: - # Calculate angle based on node count with better distribution - # Use golden ratio to distribute nodes more evenly - golden_ratio = 1.618033988749895 - angle = 2 * math.pi * golden_ratio * num_nodes - - # Calculate distance from center based on node type and node count - # Increase distance as more nodes are added - base_distance = 200 - count_factor = min(1.0, num_nodes / 20) # Scale up to 20 nodes - - if node_type == 'rabbithole': - distance = base_distance * (1.0 + count_factor * 0.5) - elif node_type == 'fork': - distance = base_distance * (1.2 + count_factor * 0.5) - else: - distance = base_distance * (1.4 + count_factor * 0.5) - - # Calculate position using polar coordinates - x = distance * math.cos(angle) - y = distance * math.sin(angle) - - # Add some random offset for natural appearance - x += random.uniform(-30, 30) - y += random.uniform(-30, 30) - - # Check for potential overlaps with existing nodes and adjust if needed - overlap = True - max_attempts = 5 - attempt = 0 - - while overlap and attempt < max_attempts: - overlap = False - for existing_id, (ex, ey) in self.node_positions.items(): - # Skip comparing with self - if existing_id == node_id: - continue - - # Calculate distance between nodes - dx = x - ex - dy = y - ey - distance = math.sqrt(dx*dx + dy*dy) - - # Get node sizes - new_size = math.sqrt(self.node_sizes.get(node_id, 400)) - existing_size = math.sqrt(self.node_sizes.get(existing_id, 400)) - min_distance = (new_size + existing_size) / 2 - - # If too close, adjust position - if distance < min_distance * 1.5: - overlap = True - # Move away from the overlapping node - angle = math.atan2(dy, dx) - adjustment = min_distance * 1.5 - distance - x += math.cos(angle) * adjustment * 1.2 - y += math.sin(angle) * adjustment * 1.2 - break - - attempt += 1 - - # Store the position - self.node_positions[node_id] = (x, y) - - def update_graph(self): - """Update the network graph visualization""" - if hasattr(self, 'network_view'): - # Update the network view with current graph data - self.network_view.nodes = list(self.graph.nodes()) - self.network_view.edges = list(self.graph.edges()) - self.network_view.node_positions = self.node_positions - self.network_view.node_colors = self.node_colors - self.network_view.node_labels = self.node_labels - self.network_view.node_sizes = self.node_sizes - - # Redraw - self.network_view.update() - -class ImagePreviewPane(QWidget): - """Pane to display generated images with navigation""" - def __init__(self): - super().__init__() - self.current_image_path = None - self.session_images = [] # List of all images generated this session - self.current_index = -1 # Current image index - self.setup_ui() - - def setup_ui(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(10, 10, 10, 10) - layout.setSpacing(10) - - # Title label - self.title = QLabel("🎨 GENERATED IMAGES") - self.title.setStyleSheet(f""" - QLabel {{ - color: {COLORS['accent_purple']}; - font-weight: bold; - font-size: 12px; - padding: 5px; - }} - """) - self.title.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(self.title) - - # Image display label - self.image_label = QLabel("No images generated yet") - self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.image_label.setStyleSheet(f""" - QLabel {{ - background-color: {COLORS['bg_medium']}; - border: 2px dashed {COLORS['border']}; - border-radius: 8px; - color: {COLORS['text_dim']}; - padding: 20px; - min-height: 200px; - }} - """) - self.image_label.setWordWrap(True) - self.image_label.setScaledContents(False) - layout.addWidget(self.image_label, 1) - - # Navigation controls - nav_layout = QHBoxLayout() - nav_layout.setSpacing(8) - - # Previous button - self.prev_button = QPushButton("◀ Prev") - self.prev_button.setStyleSheet(f""" - QPushButton {{ - background-color: {COLORS['bg_medium']}; - color: {COLORS['text_normal']}; - border: 1px solid {COLORS['border']}; - border-radius: 4px; - padding: 6px 12px; - font-weight: bold; - }} - QPushButton:hover {{ - background-color: {COLORS['bg_light']}; - border-color: {COLORS['accent_purple']}; - }} - QPushButton:disabled {{ - color: {COLORS['text_dim']}; - background-color: {COLORS['bg_dark']}; - }} - """) - self.prev_button.clicked.connect(self.show_previous) - self.prev_button.setEnabled(False) - nav_layout.addWidget(self.prev_button) - - # Position indicator - self.position_label = QLabel("") - self.position_label.setStyleSheet(f""" - QLabel {{ - color: {COLORS['text_dim']}; - font-size: 11px; - }} - """) - self.position_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - nav_layout.addWidget(self.position_label, 1) - - # Next button - self.next_button = QPushButton("Next ▶") - self.next_button.setStyleSheet(f""" - QPushButton {{ - background-color: {COLORS['bg_medium']}; - color: {COLORS['text_normal']}; - border: 1px solid {COLORS['border']}; - border-radius: 4px; - padding: 6px 12px; - font-weight: bold; - }} - QPushButton:hover {{ - background-color: {COLORS['bg_light']}; - border-color: {COLORS['accent_purple']}; - }} - QPushButton:disabled {{ - color: {COLORS['text_dim']}; - background-color: {COLORS['bg_dark']}; - }} - """) - self.next_button.clicked.connect(self.show_next) - self.next_button.setEnabled(False) - nav_layout.addWidget(self.next_button) - - layout.addLayout(nav_layout) - - # Image info label - self.info_label = QLabel("") - self.info_label.setStyleSheet(f""" - QLabel {{ - color: {COLORS['text_dim']}; - font-size: 10px; - padding: 5px; - }} - """) - self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.info_label.setWordWrap(True) - layout.addWidget(self.info_label) - - # Open in folder button - self.open_button = QPushButton("📂 Open Images Folder") - self.open_button.setStyleSheet(f""" - QPushButton {{ - background-color: {COLORS['bg_medium']}; - color: {COLORS['text_normal']}; - border: 1px solid {COLORS['border']}; - border-radius: 4px; - padding: 8px; - }} - QPushButton:hover {{ - background-color: {COLORS['bg_light']}; - border-color: {COLORS['accent_purple']}; - }} - """) - self.open_button.clicked.connect(self.open_images_folder) - layout.addWidget(self.open_button) - - def add_image(self, image_path): - """Add a new image to the session gallery and display it""" - if image_path and os.path.exists(image_path): - # Avoid duplicates - if image_path not in self.session_images: - self.session_images.append(image_path) - # Jump to the new image - self.current_index = len(self.session_images) - 1 - self._display_current() - - def set_image(self, image_path): - """Display an image - also adds to gallery if new""" - self.add_image(image_path) - - def _display_current(self): - """Display the image at current_index""" - if not self.session_images or self.current_index < 0: - self.image_label.setText("No images generated yet") - self.info_label.setText("") - self.position_label.setText("") - self.prev_button.setEnabled(False) - self.next_button.setEnabled(False) - return - - image_path = self.session_images[self.current_index] - self.current_image_path = image_path - - if os.path.exists(image_path): - pixmap = QPixmap(image_path) - if not pixmap.isNull(): - # Scale to fit the label while maintaining aspect ratio - scaled = pixmap.scaled( - self.image_label.size() - QSize(20, 20), - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation - ) - self.image_label.setPixmap(scaled) - self.image_label.setStyleSheet(f""" - QLabel {{ - background-color: {COLORS['bg_medium']}; - border: 2px solid {COLORS['accent_purple']}; - border-radius: 8px; - padding: 10px; - }} - """) - - # Update info - filename = os.path.basename(image_path) - self.info_label.setText(f"📁 {filename}") - else: - self.image_label.setText("Failed to load image") - self.info_label.setText("") - else: - self.image_label.setText("Image not found") - self.info_label.setText("") - - # Update navigation - total = len(self.session_images) - current = self.current_index + 1 - self.position_label.setText(f"{current} of {total}") - self.prev_button.setEnabled(self.current_index > 0) - self.next_button.setEnabled(self.current_index < total - 1) - - def show_previous(self): - """Show the previous image""" - if self.current_index > 0: - self.current_index -= 1 - self._display_current() - - def show_next(self): - """Show the next image""" - if self.current_index < len(self.session_images) - 1: - self.current_index += 1 - self._display_current() - - def clear_session(self): - """Clear all session images (e.g., when starting a new conversation)""" - self.session_images = [] - self.current_index = -1 - self.current_image_path = None - self.image_label.setText("No images generated yet") - self.image_label.setStyleSheet(f""" - QLabel {{ - background-color: {COLORS['bg_medium']}; - border: 2px dashed {COLORS['border']}; - border-radius: 8px; - color: {COLORS['text_dim']}; - padding: 20px; - min-height: 200px; - }} - """) - self.info_label.setText("") - self.position_label.setText("") - self.prev_button.setEnabled(False) - self.next_button.setEnabled(False) - - def open_images_folder(self): - """Open the images folder in file explorer""" - import subprocess - images_dir = os.path.join(os.path.dirname(__file__), 'images') - if os.path.exists(images_dir): - subprocess.Popen(f'explorer "{images_dir}"') - else: - # Try to create it - os.makedirs(images_dir, exist_ok=True) - subprocess.Popen(f'explorer "{images_dir}"') - - def resizeEvent(self, event): - """Re-scale image when pane is resized""" - super().resizeEvent(event) - if self.current_image_path: - self._display_current() - - -class VideoPreviewPane(QWidget): - """Pane to display generated videos with navigation""" - def __init__(self): - super().__init__() - self.current_video_path = None - self.session_videos = [] # List of all videos generated this session - self.current_index = -1 # Current video index - self.setup_ui() - - def setup_ui(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(10, 10, 10, 10) - layout.setSpacing(10) - - # Title label - self.title = QLabel("🎬 GENERATED VIDEOS") - self.title.setStyleSheet(f""" - QLabel {{ - color: {COLORS['accent_cyan']}; - font-weight: bold; - font-size: 12px; - padding: 5px; - }} - """) - self.title.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(self.title) - - # Video display area - we'll show a thumbnail or placeholder - self.video_label = QLabel("No videos generated yet") - self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.video_label.setStyleSheet(f""" - QLabel {{ - background-color: {COLORS['bg_medium']}; - border: 2px dashed {COLORS['border']}; - border-radius: 8px; - color: {COLORS['text_dim']}; - padding: 20px; - min-height: 150px; - }} - """) - self.video_label.setWordWrap(True) - layout.addWidget(self.video_label, 1) - - # Play button - self.play_button = QPushButton("▶ Play Video") - self.play_button.setStyleSheet(f""" - QPushButton {{ - background-color: {COLORS['accent_cyan']}; - color: {COLORS['bg_dark']}; - border: none; - border-radius: 4px; - padding: 10px 20px; - font-weight: bold; - font-size: 12px; - }} - QPushButton:hover {{ - background-color: {COLORS['accent_purple']}; - }} - QPushButton:disabled {{ - background-color: {COLORS['bg_medium']}; - color: {COLORS['text_dim']}; - }} - """) - self.play_button.clicked.connect(self.play_current_video) - self.play_button.setEnabled(False) - layout.addWidget(self.play_button) - - # Navigation controls - nav_layout = QHBoxLayout() - nav_layout.setSpacing(8) - - # Previous button - self.prev_button = QPushButton("◀ Prev") - self.prev_button.setStyleSheet(f""" - QPushButton {{ - background-color: {COLORS['bg_medium']}; - color: {COLORS['text_normal']}; - border: 1px solid {COLORS['border']}; - border-radius: 4px; - padding: 6px 12px; - font-weight: bold; - }} - QPushButton:hover {{ - background-color: {COLORS['bg_light']}; - border-color: {COLORS['accent_cyan']}; - }} - QPushButton:disabled {{ - color: {COLORS['text_dim']}; - background-color: {COLORS['bg_dark']}; - }} - """) - self.prev_button.clicked.connect(self.show_previous) - self.prev_button.setEnabled(False) - nav_layout.addWidget(self.prev_button) - - # Position indicator - self.position_label = QLabel("") - self.position_label.setStyleSheet(f""" - QLabel {{ - color: {COLORS['text_dim']}; - font-size: 11px; - }} - """) - self.position_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - nav_layout.addWidget(self.position_label, 1) - - # Next button - self.next_button = QPushButton("Next ▶") - self.next_button.setStyleSheet(f""" - QPushButton {{ - background-color: {COLORS['bg_medium']}; - color: {COLORS['text_normal']}; - border: 1px solid {COLORS['border']}; - border-radius: 4px; - padding: 6px 12px; - font-weight: bold; - }} - QPushButton:hover {{ - background-color: {COLORS['bg_light']}; - border-color: {COLORS['accent_cyan']}; - }} - QPushButton:disabled {{ - color: {COLORS['text_dim']}; - background-color: {COLORS['bg_dark']}; - }} - """) - self.next_button.clicked.connect(self.show_next) - self.next_button.setEnabled(False) - nav_layout.addWidget(self.next_button) - - layout.addLayout(nav_layout) - - # Video info label - self.info_label = QLabel("") - self.info_label.setStyleSheet(f""" - QLabel {{ - color: {COLORS['text_dim']}; - font-size: 10px; - padding: 5px; - }} - """) - self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.info_label.setWordWrap(True) - layout.addWidget(self.info_label) - - # Open in folder button - self.open_button = QPushButton("📂 Open Videos Folder") - self.open_button.setStyleSheet(f""" - QPushButton {{ - background-color: {COLORS['bg_medium']}; - color: {COLORS['text_normal']}; - border: 1px solid {COLORS['border']}; - border-radius: 4px; - padding: 8px; - }} - QPushButton:hover {{ - background-color: {COLORS['bg_light']}; - border-color: {COLORS['accent_cyan']}; - }} - """) - self.open_button.clicked.connect(self.open_videos_folder) - layout.addWidget(self.open_button) - - def add_video(self, video_path): - """Add a new video to the session gallery and display it""" - if video_path and os.path.exists(video_path): - # Avoid duplicates - if video_path not in self.session_videos: - self.session_videos.append(video_path) - # Jump to the new video - self.current_index = len(self.session_videos) - 1 - self._display_current() - - def set_video(self, video_path): - """Display a video - also adds to gallery if new""" - self.add_video(video_path) - - def _display_current(self): - """Display the video at current_index""" - if not self.session_videos or self.current_index < 0: - self.video_label.setText("No videos generated yet") - self.info_label.setText("") - self.position_label.setText("") - self.prev_button.setEnabled(False) - self.next_button.setEnabled(False) - self.play_button.setEnabled(False) - return - - video_path = self.session_videos[self.current_index] - self.current_video_path = video_path - - if os.path.exists(video_path): - filename = os.path.basename(video_path) - # Show video info - self.video_label.setText(f"🎬 {filename}\n\n(Click Play to view)") - self.video_label.setStyleSheet(f""" - QLabel {{ - background-color: {COLORS['bg_medium']}; - border: 2px solid {COLORS['accent_cyan']}; - border-radius: 8px; - color: {COLORS['text_bright']}; - padding: 20px; - min-height: 150px; - }} - """) - self.info_label.setText(f"📁 {filename}") - self.play_button.setEnabled(True) - else: - self.video_label.setText("Video not found") - self.info_label.setText("") - self.play_button.setEnabled(False) - - # Update navigation - total = len(self.session_videos) - current = self.current_index + 1 - self.position_label.setText(f"{current} of {total}") - self.prev_button.setEnabled(self.current_index > 0) - self.next_button.setEnabled(self.current_index < total - 1) - - def show_previous(self): - """Show the previous video""" - if self.current_index > 0: - self.current_index -= 1 - self._display_current() - - def show_next(self): - """Show the next video""" - if self.current_index < len(self.session_videos) - 1: - self.current_index += 1 - self._display_current() - - def play_current_video(self): - """Open the current video in the default video player""" - if self.current_video_path and os.path.exists(self.current_video_path): - import subprocess - import sys - if sys.platform == 'win32': - os.startfile(self.current_video_path) - elif sys.platform == 'darwin': - subprocess.Popen(['open', self.current_video_path]) - else: - subprocess.Popen(['xdg-open', self.current_video_path]) - - def clear_session(self): - """Clear all session videos (e.g., when starting a new conversation)""" - self.session_videos = [] - self.current_index = -1 - self.current_video_path = None - self.video_label.setText("No videos generated yet") - self.video_label.setStyleSheet(f""" - QLabel {{ - background-color: {COLORS['bg_medium']}; - border: 2px dashed {COLORS['border']}; - border-radius: 8px; - color: {COLORS['text_dim']}; - padding: 20px; - min-height: 150px; - }} - """) - self.info_label.setText("") - self.position_label.setText("") - self.prev_button.setEnabled(False) - self.next_button.setEnabled(False) - self.play_button.setEnabled(False) - - def open_videos_folder(self): - """Open the videos folder in file explorer""" - import subprocess - videos_dir = os.path.join(os.path.dirname(__file__), 'videos') - if os.path.exists(videos_dir): - subprocess.Popen(f'explorer "{videos_dir}"') - else: - # Try to create it - os.makedirs(videos_dir, exist_ok=True) - subprocess.Popen(f'explorer "{videos_dir}"') - - -class RightSidebar(QWidget): - """Right sidebar with tabbed interface for Setup and Network Graph""" - nodeSelected = pyqtSignal(str) - - def __init__(self): - super().__init__() - self.setMinimumWidth(300) - self.setup_ui() - - def setup_ui(self): - """Set up the tabbed sidebar interface""" - layout = QVBoxLayout(self) - layout.setContentsMargins(5, 5, 5, 5) - layout.setSpacing(0) - - # Create tab bar at the top (custom styled) - tab_container = QWidget() - tab_container.setStyleSheet(f""" - QWidget {{ - background-color: {COLORS['bg_medium']}; - border-bottom: 1px solid {COLORS['border_glow']}; - }} - """) - tab_layout = QHBoxLayout(tab_container) - tab_layout.setContentsMargins(0, 0, 0, 0) - tab_layout.setSpacing(0) - - # Tab buttons - self.setup_button = QPushButton("⚙ SETUP") - self.graph_button = QPushButton("🌐 GRAPH") - self.image_button = QPushButton("🖼 IMAGE") - self.video_button = QPushButton("🎬 VIDEO") - - # Cyberpunk tab button styling - tab_style = f""" - QPushButton {{ - background-color: {COLORS['bg_medium']}; - color: {COLORS['text_dim']}; - border: none; - border-bottom: 2px solid transparent; - padding: 12px 12px; - font-weight: bold; - font-size: 10px; - letter-spacing: 1px; - text-transform: uppercase; - }} - QPushButton:hover {{ - background-color: {COLORS['bg_light']}; - color: {COLORS['text_normal']}; - }} - QPushButton:checked {{ - background-color: {COLORS['bg_dark']}; - color: {COLORS['accent_cyan']}; - border-bottom: 2px solid {COLORS['accent_cyan']}; - }} - """ - - self.setup_button.setStyleSheet(tab_style) - self.graph_button.setStyleSheet(tab_style) - self.image_button.setStyleSheet(tab_style) - self.video_button.setStyleSheet(tab_style) - - # Make buttons checkable for tab behavior - self.setup_button.setCheckable(True) - self.graph_button.setCheckable(True) - self.image_button.setCheckable(True) - self.video_button.setCheckable(True) - self.setup_button.setChecked(True) # Start with setup tab active - - # Connect tab buttons - self.setup_button.clicked.connect(lambda: self.switch_tab(0)) - self.graph_button.clicked.connect(lambda: self.switch_tab(1)) - self.image_button.clicked.connect(lambda: self.switch_tab(2)) - self.video_button.clicked.connect(lambda: self.switch_tab(3)) - - tab_layout.addWidget(self.setup_button) - tab_layout.addWidget(self.graph_button) - tab_layout.addWidget(self.image_button) - tab_layout.addWidget(self.video_button) - - layout.addWidget(tab_container) - - # Create stacked widget for tab content - from PyQt6.QtWidgets import QStackedWidget - self.stack = QStackedWidget() - self.stack.setStyleSheet(f""" - QStackedWidget {{ - background-color: {COLORS['bg_dark']}; - border: none; - }} - """) - - # Create tab pages - self.control_panel = ControlPanel() - self.network_pane = NetworkPane() - self.image_preview_pane = ImagePreviewPane() - self.video_preview_pane = VideoPreviewPane() - - # Add pages to stack - self.stack.addWidget(self.control_panel) - self.stack.addWidget(self.network_pane) - self.stack.addWidget(self.image_preview_pane) - self.stack.addWidget(self.video_preview_pane) - - layout.addWidget(self.stack, 1) # Stretch to fill - - # Connect network pane signal to forward it - self.network_pane.nodeSelected.connect(self.nodeSelected) - - def switch_tab(self, index): - """Switch between tabs""" - self.stack.setCurrentIndex(index) - - # Update button states - self.setup_button.setChecked(index == 0) - self.graph_button.setChecked(index == 1) - self.image_button.setChecked(index == 2) - self.video_button.setChecked(index == 3) - - def update_image_preview(self, image_path): - """Update the image preview pane with a new image""" - if hasattr(self, 'image_preview_pane'): - self.image_preview_pane.set_image(image_path) - - def update_video_preview(self, video_path): - """Update the video preview pane with a new video""" - if hasattr(self, 'video_preview_pane'): - self.video_preview_pane.set_video(video_path) - - def add_node(self, node_id, label, node_type): - """Forward to network pane""" - self.network_pane.add_node(node_id, label, node_type) - - def add_edge(self, source_id, target_id): - """Forward to network pane""" - self.network_pane.add_edge(source_id, target_id) - - def update_graph(self): - """Forward to network pane""" - self.network_pane.update_graph() - -class ControlPanel(QWidget): - """Control panel with mode, model selections, etc.""" - def __init__(self): - super().__init__() - - # Set up the UI - self.setup_ui() - - # Initialize with models and prompt pairs - self.initialize_selectors() - - def setup_ui(self): - """Set up the user interface for the control panel - vertical sidebar layout""" - # Main layout - main_layout = QVBoxLayout(self) - main_layout.setContentsMargins(5, 5, 5, 5) - main_layout.setSpacing(8) - - # Add a title with cyberpunk styling - title = QLabel("═ CONTROL PANEL ═") - title.setAlignment(Qt.AlignmentFlag.AlignCenter) - title.setStyleSheet(f""" - color: {COLORS['accent_cyan']}; - font-size: 12px; - font-weight: bold; - padding: 10px; - background-color: {COLORS['bg_medium']}; - border: 1px solid {COLORS['border_glow']}; - border-radius: 0px; - letter-spacing: 2px; - """) - main_layout.addWidget(title) - - # Create scrollable area for controls - scroll_area = QScrollArea() - scroll_area.setWidgetResizable(True) - scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - scroll_area.setStyleSheet(f""" - QScrollArea {{ - border: none; - background-color: transparent; - }} - QScrollBar:vertical {{ - background: {COLORS['bg_medium']}; - width: 10px; - margin: 0px; - }} - QScrollBar::handle:vertical {{ - background: {COLORS['border_glow']}; - min-height: 20px; - border-radius: 0px; - }} - QScrollBar::handle:vertical:hover {{ - background: {COLORS['accent_cyan']}; - }} - QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ - height: 0px; - }} - QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ - background: none; - }} - """) - - # Container widget for scrollable content - scroll_content = QWidget() - scroll_content.setStyleSheet(f"background-color: transparent;") - - # All controls in vertical layout - controls_layout = QVBoxLayout(scroll_content) - controls_layout.setContentsMargins(5, 5, 5, 5) - controls_layout.setSpacing(10) - - # Mode selection with icon - mode_container = QWidget() - mode_layout = QVBoxLayout(mode_container) - mode_layout.setContentsMargins(0, 0, 0, 0) - mode_layout.setSpacing(5) - - mode_label = QLabel("▸ MODE") - mode_label.setStyleSheet(f"color: {COLORS['text_glow']}; font-size: 10px; font-weight: bold; letter-spacing: 1px;") - mode_layout.addWidget(mode_label) - - self.mode_selector = QComboBox() - self.mode_selector.addItems(["AI-AI", "Human-AI"]) - self.mode_selector.setStyleSheet(self.get_combobox_style()) - mode_layout.addWidget(self.mode_selector) - controls_layout.addWidget(mode_container) - - # Iterations with slider - iterations_container = QWidget() - iterations_layout = QVBoxLayout(iterations_container) - iterations_layout.setContentsMargins(0, 0, 0, 0) - iterations_layout.setSpacing(5) - - iterations_label = QLabel("▸ ITERATIONS") - iterations_label.setStyleSheet(f"color: {COLORS['text_glow']}; font-size: 10px; font-weight: bold; letter-spacing: 1px;") - iterations_layout.addWidget(iterations_label) - - self.iterations_selector = QComboBox() - self.iterations_selector.addItems(["1", "2", "5", "6", "10", "100"]) - self.iterations_selector.setStyleSheet(self.get_combobox_style()) - iterations_layout.addWidget(self.iterations_selector) - controls_layout.addWidget(iterations_container) - - # Number of AIs selection - num_ais_container = QWidget() - num_ais_layout = QVBoxLayout(num_ais_container) - num_ais_layout.setContentsMargins(0, 0, 0, 0) - num_ais_layout.setSpacing(5) - - num_ais_label = QLabel("▸ NUMBER OF AIs") - num_ais_label.setStyleSheet(f"color: {COLORS['text_glow']}; font-size: 10px; font-weight: bold; letter-spacing: 1px;") - num_ais_layout.addWidget(num_ais_label) - - self.num_ais_selector = QComboBox() - self.num_ais_selector.addItems(["1", "2", "3", "4", "5"]) - self.num_ais_selector.setCurrentText("3") # Default to 3 AIs - self.num_ais_selector.setStyleSheet(self.get_combobox_style()) - num_ais_layout.addWidget(self.num_ais_selector) - controls_layout.addWidget(num_ais_container) - - # AI-1 Model selection - self.ai1_container = QWidget() - ai1_layout = QVBoxLayout(self.ai1_container) - ai1_layout.setContentsMargins(0, 0, 0, 0) - ai1_layout.setSpacing(5) - - ai1_label = QLabel("AI-1") - ai1_label.setStyleSheet(f"color: {COLORS['text_dim']}; font-size: 10px;") - ai1_layout.addWidget(ai1_label) - - self.ai1_model_selector = QComboBox() - self.ai1_model_selector.setStyleSheet(self.get_combobox_style()) - ai1_layout.addWidget(self.ai1_model_selector) - controls_layout.addWidget(self.ai1_container) - - # AI-2 Model selection - self.ai2_container = QWidget() - ai2_layout = QVBoxLayout(self.ai2_container) - ai2_layout.setContentsMargins(0, 0, 0, 0) - ai2_layout.setSpacing(5) - - ai2_label = QLabel("AI-2") - ai2_label.setStyleSheet(f"color: {COLORS['text_dim']}; font-size: 10px;") - ai2_layout.addWidget(ai2_label) - - self.ai2_model_selector = QComboBox() - self.ai2_model_selector.setStyleSheet(self.get_combobox_style()) - ai2_layout.addWidget(self.ai2_model_selector) - controls_layout.addWidget(self.ai2_container) - - # AI-3 Model selection - self.ai3_container = QWidget() - ai3_layout = QVBoxLayout(self.ai3_container) - ai3_layout.setContentsMargins(0, 0, 0, 0) - ai3_layout.setSpacing(5) - - ai3_label = QLabel("AI-3") - ai3_label.setStyleSheet(f"color: {COLORS['text_dim']}; font-size: 10px;") - ai3_layout.addWidget(ai3_label) - - self.ai3_model_selector = QComboBox() - self.ai3_model_selector.setStyleSheet(self.get_combobox_style()) - ai3_layout.addWidget(self.ai3_model_selector) - controls_layout.addWidget(self.ai3_container) - - # AI-4 Model selection - self.ai4_container = QWidget() - ai4_layout = QVBoxLayout(self.ai4_container) - ai4_layout.setContentsMargins(0, 0, 0, 0) - ai4_layout.setSpacing(5) - - ai4_label = QLabel("AI-4") - ai4_label.setStyleSheet(f"color: {COLORS['text_dim']}; font-size: 10px;") - ai4_layout.addWidget(ai4_label) - - self.ai4_model_selector = QComboBox() - self.ai4_model_selector.setStyleSheet(self.get_combobox_style()) - ai4_layout.addWidget(self.ai4_model_selector) - controls_layout.addWidget(self.ai4_container) - - # AI-5 Model selection - self.ai5_container = QWidget() - ai5_layout = QVBoxLayout(self.ai5_container) - ai5_layout.setContentsMargins(0, 0, 0, 0) - ai5_layout.setSpacing(5) - - ai5_label = QLabel("AI-5") - ai5_label.setStyleSheet(f"color: {COLORS['text_dim']}; font-size: 10px;") - ai5_layout.addWidget(ai5_label) - - self.ai5_model_selector = QComboBox() - self.ai5_model_selector.setStyleSheet(self.get_combobox_style()) - ai5_layout.addWidget(self.ai5_model_selector) - controls_layout.addWidget(self.ai5_container) - - # Prompt pair selection - prompt_container = QWidget() - prompt_layout = QVBoxLayout(prompt_container) - prompt_layout.setContentsMargins(0, 0, 0, 0) - prompt_layout.setSpacing(5) - - prompt_label = QLabel("Conversation Scenario") - prompt_label.setStyleSheet(f"color: {COLORS['text_dim']}; font-size: 10px;") - prompt_layout.addWidget(prompt_label) - - self.prompt_pair_selector = QComboBox() - self.prompt_pair_selector.setStyleSheet(self.get_combobox_style()) - prompt_layout.addWidget(self.prompt_pair_selector) - controls_layout.addWidget(prompt_container) - - # Action buttons container - action_container = QWidget() - action_layout = QVBoxLayout(action_container) - action_layout.setContentsMargins(0, 0, 0, 0) - action_layout.setSpacing(5) - - action_label = QLabel("▸ OPTIONS") - action_label.setStyleSheet(f"color: {COLORS['text_glow']}; font-size: 10px; font-weight: bold; letter-spacing: 1px;") - action_layout.addWidget(action_label) - - # Auto-generate images checkbox - self.auto_image_checkbox = QCheckBox("Auto-generate images") - self.auto_image_checkbox.setStyleSheet(f""" - QCheckBox {{ - color: {COLORS['text_normal']}; - spacing: 5px; - font-size: 10px; - padding: 4px; - }} - QCheckBox::indicator {{ - width: 18px; - height: 18px; - border: 1px solid {COLORS['border_glow']}; - border-radius: 0px; - background-color: {COLORS['bg_medium']}; - }} - QCheckBox::indicator:checked {{ - background-color: {COLORS['accent_cyan']}; - border: 1px solid {COLORS['accent_cyan']}; - }} - QCheckBox::indicator:hover {{ - border: 1px solid {COLORS['accent_cyan']}; - }} - """) - self.auto_image_checkbox.setToolTip("Automatically generate images from AI responses using Google Gemini 3 Pro Image Preview via OpenRouter") - action_layout.addWidget(self.auto_image_checkbox) - - # Removed: HTML contributions checkbox - - # Actions - buttons in vertical layout - actions_label = QLabel("▸ ACTIONS") - actions_label.setStyleSheet(f"color: {COLORS['text_glow']}; font-size: 10px; font-weight: bold; letter-spacing: 1px;") - action_layout.addWidget(actions_label) - - # Export button with glow - self.export_button = self.create_glow_button("📡 EXPORT", COLORS['accent_purple']) - action_layout.addWidget(self.export_button) - - # View HTML button with glow - opens the styled conversation - self.view_html_button = self.create_glow_button("🌐 VIEW HTML", COLORS['accent_green']) - self.view_html_button.setToolTip("View conversation as shareable HTML") - self.view_html_button.clicked.connect(lambda: open_html_in_browser("conversation_full.html")) - action_layout.addWidget(self.view_html_button) - - # BackroomsBench evaluation button - self.backroomsbench_button = self.create_glow_button("🌀 BACKROOMSBENCH (beta)", COLORS['accent_purple']) - self.backroomsbench_button.setToolTip("Run multi-judge AI evaluation (depth/philosophy)") - action_layout.addWidget(self.backroomsbench_button) - - controls_layout.addWidget(action_container) - - # Add all controls directly to controls_layout (now vertical) - controls_layout.addWidget(mode_container) - controls_layout.addWidget(iterations_container) - controls_layout.addWidget(num_ais_container) - - # Divider - divider1 = QLabel("─" * 20) - divider1.setStyleSheet(f"color: {COLORS['border_glow']}; font-size: 8px;") - controls_layout.addWidget(divider1) - - models_label = QLabel("▸ AI MODELS") - models_label.setStyleSheet(f"color: {COLORS['text_glow']}; font-size: 10px; font-weight: bold; letter-spacing: 1px;") - controls_layout.addWidget(models_label) - - controls_layout.addWidget(self.ai1_container) - controls_layout.addWidget(self.ai2_container) - controls_layout.addWidget(self.ai3_container) - controls_layout.addWidget(self.ai4_container) - controls_layout.addWidget(self.ai5_container) - - # Divider - divider2 = QLabel("─" * 20) - divider2.setStyleSheet(f"color: {COLORS['border_glow']}; font-size: 8px;") - controls_layout.addWidget(divider2) - - scenario_label = QLabel("▸ SCENARIO") - scenario_label.setStyleSheet(f"color: {COLORS['text_glow']}; font-size: 10px; font-weight: bold; letter-spacing: 1px;") - controls_layout.addWidget(scenario_label) - - controls_layout.addWidget(prompt_container) - - # Divider - divider3 = QLabel("─" * 20) - divider3.setStyleSheet(f"color: {COLORS['border_glow']}; font-size: 8px;") - controls_layout.addWidget(divider3) - - controls_layout.addWidget(action_container) - - # Add spacer - controls_layout.addStretch() - - # Set the scroll area widget and add to main layout - scroll_area.setWidget(scroll_content) - main_layout.addWidget(scroll_area, 1) # Stretch to fill - - def get_combobox_style(self): - """Get the style for comboboxes - cyberpunk themed""" - return f""" - QComboBox {{ - background-color: {COLORS['bg_medium']}; - color: {COLORS['text_normal']}; - border: 1px solid {COLORS['border_glow']}; - border-radius: 0px; - padding: 8px 10px; - min-height: 30px; - font-size: 10px; - }} - QComboBox:hover {{ - border: 1px solid {COLORS['accent_cyan']}; - color: {COLORS['text_bright']}; - }} - QComboBox::drop-down {{ - subcontrol-origin: padding; - subcontrol-position: top right; - width: 20px; - border-left: 1px solid {COLORS['border_glow']}; - border-radius: 0px; - }} - QComboBox::down-arrow {{ - width: 12px; - height: 12px; - image: none; - }} - QComboBox QAbstractItemView {{ - background-color: {COLORS['bg_dark']}; - color: {COLORS['text_normal']}; - selection-background-color: {COLORS['accent_cyan']}; - selection-color: {COLORS['bg_dark']}; - border: 1px solid {COLORS['border_glow']}; - border-radius: 0px; - padding: 4px; - }} - QComboBox QAbstractItemView::item {{ - min-height: 28px; - padding: 4px; - }} - """ - - def get_cyberpunk_button_style(self, accent_color): - """Get cyberpunk-themed button style with given accent color""" - return f""" - QPushButton {{ - background-color: {COLORS['bg_medium']}; - color: {accent_color}; - border: 2px solid {accent_color}; - border-radius: 3px; - padding: 10px 14px; - font-weight: bold; - font-size: 10px; - letter-spacing: 1px; - text-align: center; - }} - QPushButton:hover {{ - background-color: {accent_color}; - color: {COLORS['bg_dark']}; - border: 2px solid {accent_color}; - }} - QPushButton:pressed {{ - background-color: {COLORS['bg_light']}; - color: {accent_color}; - }} - """ - - def create_glow_button(self, text, accent_color): - """Create a button with glow effect""" - button = GlowButton(text, accent_color) - button.setStyleSheet(self.get_cyberpunk_button_style(accent_color)) - return button - - def initialize_selectors(self): - """Initialize the selector dropdowns with values from config""" - # Add AI models - self.ai1_model_selector.clear() - self.ai2_model_selector.clear() - self.ai3_model_selector.clear() - self.ai4_model_selector.clear() - self.ai5_model_selector.clear() - self.ai1_model_selector.addItems(list(AI_MODELS.keys())) - self.ai2_model_selector.addItems(list(AI_MODELS.keys())) - self.ai3_model_selector.addItems(list(AI_MODELS.keys())) - self.ai4_model_selector.addItems(list(AI_MODELS.keys())) - self.ai5_model_selector.addItems(list(AI_MODELS.keys())) - - # Add prompt pairs - self.prompt_pair_selector.clear() - self.prompt_pair_selector.addItems(list(SYSTEM_PROMPT_PAIRS.keys())) - - # Connect number of AIs selector to update visibility - self.num_ais_selector.currentTextChanged.connect(self.update_ai_selector_visibility) - - # Set initial visibility based on default number of AIs (3) - self.update_ai_selector_visibility("3") - - def update_ai_selector_visibility(self, num_ais_text): - """Show/hide AI model selectors based on number of AIs selected""" - num_ais = int(num_ais_text) - - # AI-1 is always visible - # AI-2 visible if num_ais >= 2 - # AI-3 visible if num_ais >= 3 - # AI-4 visible if num_ais >= 4 - # AI-5 visible if num_ais >= 5 - - self.ai1_container.setVisible(num_ais >= 1) - self.ai2_container.setVisible(num_ais >= 2) - self.ai3_container.setVisible(num_ais >= 3) - self.ai4_container.setVisible(num_ais >= 4) - self.ai5_container.setVisible(num_ais >= 5) - -class ConversationContextMenu(QMenu): - """Context menu for the conversation display""" - rabbitholeSelected = pyqtSignal() - forkSelected = pyqtSignal() - - def __init__(self, parent=None): - super().__init__(parent) - - # Create actions - self.rabbithole_action = QAction("🕳️ Rabbithole", self) - self.fork_action = QAction("🔱 Fork", self) - - # Add actions to menu - # NOTE: Fork/Rabbithole temporarily disabled - needs rebuild - # self.addAction(self.rabbithole_action) - # self.addAction(self.fork_action) - - # Connect actions to signals - # self.rabbithole_action.triggered.connect(self.on_rabbithole_selected) - # self.fork_action.triggered.connect(self.on_fork_selected) - - # Apply styling - self.setStyleSheet(""" - QMenu { - background-color: #2D2D30; - color: #D4D4D4; - border: 1px solid #3E3E42; - } - QMenu::item { - padding: 5px 20px 5px 20px; - } - QMenu::item:selected { - background-color: #3E3E42; - } - """) - - def on_rabbithole_selected(self): - """Signal that rabbithole action was selected""" - if self.parent() and hasattr(self.parent(), 'rabbithole_from_selection'): - cursor = self.parent().conversation_display.textCursor() - selected_text = cursor.selectedText() - if selected_text and hasattr(self.parent(), 'rabbithole_callback'): - self.parent().rabbithole_callback(selected_text) - - def on_fork_selected(self): - """Signal that fork action was selected""" - if self.parent() and hasattr(self.parent(), 'fork_from_selection'): - cursor = self.parent().conversation_display.textCursor() - selected_text = cursor.selectedText() - if selected_text and hasattr(self.parent(), 'fork_callback'): - self.parent().fork_callback(selected_text) - -class ConversationPane(QWidget): - """Left pane containing the conversation and input area""" - def __init__(self): - super().__init__() - - # Set up the UI - self.setup_ui() - - # Connect signals and slots - self.connect_signals() - - # Initialize state - self.conversation = [] - self.input_callback = None - self.rabbithole_callback = None - self.fork_callback = None - self.loading = False - self.loading_dots = 0 - self.loading_timer = QTimer() - self.loading_timer.timeout.connect(self.update_loading_animation) - self.loading_timer.setInterval(300) # Update every 300ms for smoother animation - - # Context menu - self.context_menu = ConversationContextMenu(self) - - # Initialize with empty conversation - self.update_conversation([]) - - # Images list - to prevent garbage collection - self.images = [] - self.image_paths = [] - - # Uploaded image for current message - self.uploaded_image_path = None - self.uploaded_image_base64 = None - - # Create text formats with different colors - self.text_formats = { - "user": QTextCharFormat(), - "ai": QTextCharFormat(), - "system": QTextCharFormat(), - "ai_label": QTextCharFormat(), - "normal": QTextCharFormat(), - "error": QTextCharFormat() - } - - # Configure text formats using global color palette - self.text_formats["user"].setForeground(QColor(COLORS['text_normal'])) - self.text_formats["ai"].setForeground(QColor(COLORS['text_normal'])) - self.text_formats["system"].setForeground(QColor(COLORS['text_normal'])) - self.text_formats["ai_label"].setForeground(QColor(COLORS['accent_blue'])) - self.text_formats["normal"].setForeground(QColor(COLORS['text_normal'])) - self.text_formats["error"].setForeground(QColor(COLORS['text_error'])) - - # Make AI labels bold - self.text_formats["ai_label"].setFontWeight(QFont.Weight.Bold) - - def setup_ui(self): - """Set up the user interface for the conversation pane""" - # Main layout - layout = QVBoxLayout(self) - layout.setContentsMargins(10, 10, 10, 10) - layout.setSpacing(5) # Reduced spacing - - # Title and info area - title_layout = QHBoxLayout() - self.title_label = QLabel("╔═ LIMINAL BACKROOMS ═╗") - self.title_label.setStyleSheet(f""" - color: {COLORS['accent_cyan']}; - font-size: 14px; - font-weight: bold; - padding: 4px; - letter-spacing: 2px; - """) - - self.info_label = QLabel("[ AI-TO-AI PROPAGATION ]") - self.info_label.setStyleSheet(f""" - color: {COLORS['text_glow']}; - font-size: 10px; - padding: 2px; - letter-spacing: 1px; - """) - - title_layout.addWidget(self.title_label) - title_layout.addStretch() - title_layout.addWidget(self.info_label) - - layout.addLayout(title_layout) - - # Conversation display (read-only text edit in a scroll area) - self.conversation_display = QTextEdit() - self.conversation_display.setReadOnly(True) - self.conversation_display.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self.conversation_display.customContextMenuRequested.connect(self.show_context_menu) - - # Set font for conversation display - use Iosevka Term for better ASCII art rendering - font = QFont("Iosevka Term", 10) - # Set fallbacks in case Iosevka Term isn't loaded - font.setStyleHint(QFont.StyleHint.Monospace) - self.conversation_display.setFont(font) - - # Apply cyberpunk styling - self.conversation_display.setStyleSheet(f""" - QTextEdit {{ - background-color: {COLORS['bg_dark']}; - color: {COLORS['text_normal']}; - border: 1px solid {COLORS['border_glow']}; - border-radius: 0px; - padding: 15px; - selection-background-color: {COLORS['accent_cyan']}; - selection-color: {COLORS['bg_dark']}; - }} - QScrollBar:vertical {{ - background: {COLORS['bg_medium']}; - width: 10px; - margin: 0px; - }} - QScrollBar::handle:vertical {{ - background: {COLORS['border_glow']}; - min-height: 20px; - border-radius: 0px; - }} - QScrollBar::handle:vertical:hover {{ - background: {COLORS['accent_cyan']}; - }} - QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ - height: 0px; - }} - QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ - background: none; - }} - """) - - # Input area with label - input_container = QWidget() - input_layout = QVBoxLayout(input_container) - input_layout.setContentsMargins(0, 0, 0, 0) - input_layout.setSpacing(2) # Reduced spacing - - input_label = QLabel("Your message:") - input_label.setStyleSheet(f"color: {COLORS['text_dim']}; font-size: 11px;") - input_layout.addWidget(input_label) - - # Input field with modern styling - self.input_field = QTextEdit() - self.input_field.setPlaceholderText("Seed the conversation or just click propagate...") - self.input_field.setMaximumHeight(60) # Reduced height - self.input_field.setFont(font) - self.input_field.setStyleSheet(f""" - QTextEdit {{ - background-color: {COLORS['bg_medium']}; - color: {COLORS['text_normal']}; - border: 1px solid {COLORS['border_glow']}; - border-radius: 0px; - padding: 8px; - selection-background-color: {COLORS['accent_cyan']}; - selection-color: {COLORS['bg_dark']}; - }} - """) - input_layout.addWidget(self.input_field) - - # Button container for better layout - button_container = QWidget() - button_layout = QHBoxLayout(button_container) - button_layout.setContentsMargins(0, 0, 0, 0) - button_layout.setSpacing(5) # Reduced spacing - - # Upload image button - self.upload_image_button = QPushButton("📎 IMAGE") - self.upload_image_button.setStyleSheet(f""" - QPushButton {{ - background-color: {COLORS['bg_medium']}; - color: {COLORS['text_normal']}; - border: 1px solid {COLORS['border_glow']}; - border-radius: 0px; - padding: 6px 10px; - font-weight: bold; - font-size: 10px; - letter-spacing: 1px; - }} - QPushButton:hover {{ - background-color: {COLORS['bg_light']}; - border: 1px solid {COLORS['accent_cyan']}; - color: {COLORS['accent_cyan']}; - }} - QPushButton:pressed {{ - background-color: {COLORS['border_glow']}; - }} - """) - self.upload_image_button.setToolTip("Upload an image to include in your message") - - # Clear button with subtle glow - self.clear_button = GlowButton("CLEAR", COLORS['accent_pink']) - self.clear_button.shadow.setBlurRadius(5) # Subtler glow - self.clear_button.base_blur = 5 - self.clear_button.hover_blur = 12 - self.clear_button.setStyleSheet(f""" - QPushButton {{ - background-color: {COLORS['bg_medium']}; - color: {COLORS['text_normal']}; - border: 1px solid {COLORS['border_glow']}; - border-radius: 3px; - padding: 8px 12px; - font-weight: bold; - font-size: 10px; - letter-spacing: 1px; - }} - QPushButton:hover {{ - background-color: {COLORS['bg_light']}; - border: 2px solid {COLORS['accent_pink']}; - color: {COLORS['accent_pink']}; - }} - QPushButton:pressed {{ - background-color: {COLORS['border_glow']}; - }} - """) - - # Submit button with cyberpunk styling and glow effect - self.submit_button = GlowButton("⚡ PROPAGATE", COLORS['accent_cyan']) - self.submit_button.setStyleSheet(f""" - QPushButton {{ - background-color: {COLORS['accent_cyan']}; - color: {COLORS['bg_dark']}; - border: 2px solid {COLORS['accent_cyan']}; - border-radius: 3px; - padding: 8px 20px; - font-weight: bold; - font-size: 11px; - letter-spacing: 2px; - }} - QPushButton:hover {{ - background-color: {COLORS['bg_dark']}; - color: {COLORS['accent_cyan']}; - border: 2px solid {COLORS['accent_cyan']}; - }} - QPushButton:pressed {{ - background-color: {COLORS['accent_cyan_active']}; - color: {COLORS['text_bright']}; - }} - QPushButton:disabled {{ - background-color: {COLORS['border']}; - color: {COLORS['text_dim']}; - border: 2px solid {COLORS['border']}; - }} - """) - - # Add buttons to layout - button_layout.addWidget(self.upload_image_button) - button_layout.addWidget(self.clear_button) - button_layout.addStretch() - button_layout.addWidget(self.submit_button) - - # Add input container to main layout - input_layout.addWidget(button_container) - - # Add widgets to layout with adjusted stretch factors - layout.addWidget(self.conversation_display, 1) # Main conversation area gets most space - layout.addWidget(input_container, 0) # Input area gets minimal space - - def connect_signals(self): - """Connect signals and slots""" - # Submit button - self.submit_button.clicked.connect(self.handle_propagate_click) - - # Upload image button - self.upload_image_button.clicked.connect(self.handle_upload_image) - - # Clear button - self.clear_button.clicked.connect(self.clear_input) - - # Enter key in input field - self.input_field.installEventFilter(self) - - def clear_input(self): - """Clear the input field""" - self.input_field.clear() - self.uploaded_image_path = None - self.uploaded_image_base64 = None - self.upload_image_button.setText("📎 Image") - self.input_field.setFocus() - - def handle_upload_image(self): - """Handle image upload button click""" - # Open file dialog - file_dialog = QFileDialog() - file_path, _ = file_dialog.getOpenFileName( - self, - "Select Image", - "", - "Image Files (*.png *.jpg *.jpeg *.gif *.webp);;All Files (*)" - ) - - if file_path: - try: - # Read and encode the image to base64 - with open(file_path, 'rb') as image_file: - image_data = image_file.read() - image_base64 = base64.b64encode(image_data).decode('utf-8') - - # Determine media type - file_extension = os.path.splitext(file_path)[1].lower() - media_type_map = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp' - } - media_type = media_type_map.get(file_extension, 'image/jpeg') - - # Store the image data - self.uploaded_image_path = file_path - self.uploaded_image_base64 = { - 'data': image_base64, - 'media_type': media_type - } - - # Update button text to show an image is attached - file_name = os.path.basename(file_path) - self.upload_image_button.setText(f"📎 {file_name[:15]}...") - - # Update placeholder text - self.input_field.setPlaceholderText("Add a message about your image (optional)...") - - except Exception as e: - QMessageBox.warning( - self, - "Upload Error", - f"Failed to load image: {str(e)}" - ) - - def eventFilter(self, obj, event): - """Filter events to handle Enter key in input field""" - if obj is self.input_field and event.type() == QEvent.Type.KeyPress: - if event.key() == Qt.Key.Key_Return and not event.modifiers() & Qt.KeyboardModifier.ShiftModifier: - self.handle_propagate_click() - return True - return super().eventFilter(obj, event) - - def handle_propagate_click(self): - """Handle click on the propagate button""" - # Get the input text (might be empty) - input_text = self.input_field.toPlainText().strip() - - # Prepare message data (text + optional image) - message_data = { - 'text': input_text, - 'image': None - } - - # Include image if one was uploaded - if self.uploaded_image_base64: - message_data['image'] = { - 'path': self.uploaded_image_path, - 'base64': self.uploaded_image_base64['data'], - 'media_type': self.uploaded_image_base64['media_type'] - } - - # Clear the input box and image - self.input_field.clear() - self.uploaded_image_path = None - self.uploaded_image_base64 = None - self.upload_image_button.setText("📎 Image") - self.input_field.setPlaceholderText("Seed the conversation or just click propagate...") - - # Always call the input callback, even with empty input - if hasattr(self, 'input_callback') and self.input_callback: - self.input_callback(message_data) - - # Start loading animation - self.start_loading() - - def set_input_callback(self, callback): - """Set callback function for input submission""" - self.input_callback = callback - - def set_rabbithole_callback(self, callback): - """Set callback function for rabbithole creation""" - self.rabbithole_callback = callback - - def set_fork_callback(self, callback): - """Set callback function for fork creation""" - self.fork_callback = callback - - def update_conversation(self, conversation): - """Update conversation display""" - self.conversation = conversation - self.render_conversation() - - def render_conversation(self): - """Render conversation in the display""" - # Save scroll position before re-rendering - scrollbar = self.conversation_display.verticalScrollBar() - old_scroll_value = scrollbar.value() - old_scroll_max = scrollbar.maximum() - was_at_bottom = old_scroll_value >= old_scroll_max - 20 - - # Clear display - self.conversation_display.clear() - - # Create HTML for conversation with modern styling - html = "" - - for i, message in enumerate(self.conversation): - role = message.get("role", "") - content = message.get("content", "") - ai_name = message.get("ai_name", "") - model = message.get("model", "") - - # Handle structured content (with images) - has_image = False - image_base64 = None - generated_image_path = None - text_content = "" - - # Check for generated image path (from AI image generation) - if hasattr(message, "get") and callable(message.get): - generated_image_path = message.get("generated_image_path", None) - if generated_image_path and os.path.exists(generated_image_path): - has_image = True - - if isinstance(content, list): - # Structured content with potential images - for part in content: - if part.get('type') == 'text': - text_content += part.get('text', '') - elif part.get('type') == 'image': - has_image = True - source = part.get('source', {}) - if source.get('type') == 'base64': - image_base64 = source.get('data', '') - else: - # Plain text content - text_content = content - - # Skip empty messages (no text and no image) - if not text_content and not has_image: - continue - - # Handle branch indicators with special styling - if role == 'system' and message.get('_type') == 'branch_indicator': - if "Rabbitholing down:" in content: - html += f'
{content}
' - elif "Forking off:" in content: - html += f'
{content}
' - continue - - # Handle agent notifications with special styling - if role == 'system' and message.get('_type') == 'agent_notification': - print(f"[GUI] Rendering agent notification: {text_content[:50]}...") - html += f'
{text_content}
' - continue - - # Handle generated images with special styling - if message.get('_type') == 'generated_image': - creator = message.get('ai_name', 'AI') - model = message.get('model', '') - creator_display = f"{creator} ({model})" if model else creator - if generated_image_path and os.path.exists(generated_image_path): - file_url = f"file:///{generated_image_path.replace(os.sep, '/')}" - html += f'
' - html += f'
🎨 {creator_display} created an image
' - html += f'' - if text_content: - # Extract just the prompt part - html += f'
{text_content}
' - html += f'
' - continue - - # Removed HTML contribution indicator logic - - # Process content to handle code blocks - processed_content = self.process_content_with_code_blocks(text_content) if text_content else "" - - # Add image display if present - image_html = "" - if has_image: - if image_base64: - image_html = f'
' - elif generated_image_path and os.path.exists(generated_image_path): - # Use file:// URL for local generated images - file_url = f"file:///{generated_image_path.replace(os.sep, '/')}" - image_html = f'
🎨 Generated image
' - - # Format based on role - if role == 'user': - # User message - html += f'
' - if image_html: - html += image_html - if processed_content: - html += f'
{processed_content}
' - html += f'
' - elif role == 'assistant': - # AI message - display_name = ai_name - if model: - display_name += f" ({model})" - html += f'
' - html += f'
\n{display_name}\n
' - if image_html: - html += image_html - if processed_content: - html += f'
{processed_content}
' - - # Removed HTML contribution indicator - - html += f'
' - elif role == 'system': - # System message - html += f'
' - html += f'
{processed_content}
' - html += f'
' - - # Set HTML in display - self.conversation_display.setHtml(html) - - # Restore scroll position - if was_at_bottom: - # User was at bottom - scroll to new bottom - self.conversation_display.verticalScrollBar().setValue( - self.conversation_display.verticalScrollBar().maximum() - ) - else: - # User was scrolled up - preserve their position - # Scale the old position to the new document size if needed - new_max = self.conversation_display.verticalScrollBar().maximum() - if old_scroll_max > 0 and new_max > 0: - # Preserve absolute position (or closest equivalent) - self.conversation_display.verticalScrollBar().setValue( - min(old_scroll_value, new_max) - ) - else: - self.conversation_display.verticalScrollBar().setValue(old_scroll_value) - - def process_content_with_code_blocks(self, content): - """Process content to properly format code blocks""" - import re - from html import escape - - # First, escape HTML in the content - escaped_content = escape(content) - - # Check if there are any code blocks in the content - if "```" not in escaped_content: - return escaped_content - - # Split the content by code block markers - parts = re.split(r'(```(?:[a-zA-Z0-9_]*)\n.*?```)', escaped_content, flags=re.DOTALL) - - result = [] - for part in parts: - if part.startswith("```") and part.endswith("```"): - # This is a code block - try: - # Extract language if specified - language_match = re.match(r'```([a-zA-Z0-9_]*)\n', part) - language = language_match.group(1) if language_match else "" - - # Extract code content - code_content = part[part.find('\n')+1:part.rfind('```')] - - # Format as HTML - formatted_code = f'
{code_content}
' - result.append(formatted_code) - except Exception as e: - # If there's an error, just add the original escaped content - print(f"Error processing code block: {e}") - result.append(part) - else: - # Process inline code in non-code-block parts - inline_parts = re.split(r'(`[^`]+`)', part) - processed_part = [] - - for inline_part in inline_parts: - if inline_part.startswith("`") and inline_part.endswith("`") and len(inline_part) > 2: - # This is inline code - code = inline_part[1:-1] - processed_part.append(f'{code}') - else: - processed_part.append(inline_part) - - result.append(''.join(processed_part)) - - return ''.join(result) - - def start_loading(self): - """Start loading animation""" - self.loading = True - self.loading_dots = 0 - self.input_field.setEnabled(False) - self.submit_button.setEnabled(False) - self.submit_button.setText("Processing") - self.loading_timer.start() - - # Add subtle pulsing animation to the button - self.pulse_animation = QPropertyAnimation(self.submit_button, b"styleSheet") - self.pulse_animation.setDuration(1000) - self.pulse_animation.setLoopCount(-1) # Infinite loop - - # Define keyframes for the animation - normal_style = f""" - QPushButton {{ - background-color: {COLORS['border']}; - color: {COLORS['text_dim']}; - border: none; - border-radius: 4px; - padding: 4px 12px; - font-weight: bold; - font-size: 11px; - }} - """ - - pulse_style = f""" - QPushButton {{ - background-color: {COLORS['border_highlight']}; - color: {COLORS['text_dim']}; - border: none; - border-radius: 4px; - padding: 4px 12px; - font-weight: bold; - font-size: 11px; - }} - """ - - self.pulse_animation.setStartValue(normal_style) - self.pulse_animation.setEndValue(pulse_style) - self.pulse_animation.start() - - def stop_loading(self): - """Stop loading animation""" - self.loading = False - self.loading_timer.stop() - self.input_field.setEnabled(True) - self.submit_button.setEnabled(True) - self.submit_button.setText("Propagate") - - # Stop the pulsing animation - if hasattr(self, 'pulse_animation'): - self.pulse_animation.stop() - - # Reset button style - self.submit_button.setStyleSheet(f""" - QPushButton {{ - background-color: {COLORS['accent_cyan']}; - color: {COLORS['bg_dark']}; - border: 1px solid {COLORS['accent_cyan']}; - border-radius: 0px; - padding: 6px 16px; - font-weight: bold; - font-size: 11px; - letter-spacing: 1px; - }} - QPushButton:hover {{ - background-color: {COLORS['bg_dark']}; - color: {COLORS['accent_cyan']}; - }} - QPushButton:pressed {{ - background-color: {COLORS['accent_cyan_active']}; - color: {COLORS['text_bright']}; - }} - """) - - def update_loading_animation(self): - """Update loading animation dots""" - self.loading_dots = (self.loading_dots + 1) % 4 - dots = "." * self.loading_dots - self.submit_button.setText(f"Processing{dots}") - - def show_context_menu(self, position): - """Show context menu at the given position""" - # Get selected text - cursor = self.conversation_display.textCursor() - selected_text = cursor.selectedText() - - # Only show context menu if text is selected - if selected_text: - # Show the context menu at cursor position - self.context_menu.exec(self.conversation_display.mapToGlobal(position)) - - def rabbithole_from_selection(self): - """Create a rabbithole branch from selected text""" - cursor = self.conversation_display.textCursor() - selected_text = cursor.selectedText() - - if selected_text and hasattr(self, 'rabbithole_callback'): - self.rabbithole_callback(selected_text) - - def fork_from_selection(self): - """Create a fork branch from selected text""" - cursor = self.conversation_display.textCursor() - selected_text = cursor.selectedText() - - if selected_text and hasattr(self, 'fork_callback'): - self.fork_callback(selected_text) - - def append_text(self, text, format_type="normal"): - """Append text to the conversation display with the specified format""" - # Check if user is at the bottom before appending (within 20 pixels is considered "at bottom") - scrollbar = self.conversation_display.verticalScrollBar() - was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 20 - - cursor = self.conversation_display.textCursor() - cursor.movePosition(QTextCursor.MoveOperation.End) - - # Apply the format if specified - if format_type in self.text_formats: - self.conversation_display.setCurrentCharFormat(self.text_formats[format_type]) - - # Insert the text - cursor.insertText(text) - - # Reset to normal format after insertion - if format_type != "normal": - self.conversation_display.setCurrentCharFormat(self.text_formats["normal"]) - - # Only auto-scroll if user was already at the bottom - if was_at_bottom: - self.conversation_display.setTextCursor(cursor) - self.conversation_display.ensureCursorVisible() - - def clear_conversation(self): - """Clear the conversation display""" - self.conversation_display.clear() - self.images = [] - - def display_conversation(self, conversation, branch_data=None): - """Display the conversation in the text edit widget""" - # Store conversation data (don't clear here - render_conversation handles clearing with scroll preservation) - self.conversation = conversation - - # Check if we're in a branch - is_branch = branch_data is not None - branch_type = branch_data.get('type', '') if is_branch else '' - selected_text = branch_data.get('selected_text', '') if is_branch else '' - - # Update title if in a branch - if is_branch: - branch_emoji = "🐇" if branch_type == "rabbithole" else "🍴" - self.title_label.setText(f"{branch_emoji} {branch_type.capitalize()}: {selected_text[:30]}...") - self.info_label.setText(f"Branch conversation") - else: - self.title_label.setText("Liminal Backrooms") - self.info_label.setText("AI-to-AI conversation") - - # Debug: Print conversation to console - print("\n--- DEBUG: Conversation Content ---") - for msg in conversation: - role = msg.get("role", "") - content = msg.get("content", "") - if "```" in content: - print(f"Found code block in {role} message") - print(f"Content snippet: {content[:100]}...") - print("--- End Debug ---\n") - - # Render conversation - self.render_conversation() - - def display_image(self, image_path): - """Display an image in the conversation""" - try: - # Check if the image path is valid - if not image_path or not os.path.exists(image_path): - self.append_text("[Image not found]\n", "error") - return - - # Load the image - image = QImage(image_path) - if image.isNull(): - self.append_text("[Invalid image format]\n", "error") - return - - # Create a pixmap from the image - pixmap = QPixmap.fromImage(image) - - # Scale the image to fit the conversation display - max_width = self.conversation_display.width() - 50 - if pixmap.width() > max_width: - pixmap = pixmap.scaledToWidth(max_width, Qt.TransformationMode.SmoothTransformation) - - # Insert the image into the conversation display - cursor = self.conversation_display.textCursor() - cursor.movePosition(QTextCursor.MoveOperation.End) - cursor.insertImage(pixmap.toImage()) - cursor.insertText("\n\n") - - # Store the image to prevent garbage collection - self.images.append(pixmap) - self.image_paths.append(image_path) - - except Exception as e: - self.append_text(f"[Error displaying image: {str(e)}]\n", "error") - - def export_conversation(self): - """Export the conversation and all session media to a folder""" - # Set default directory - custom Dropbox path with fallbacks - base_dir = r"C:\Users\sjeff\Dropbox\Stephen Work\LiminalBackrooms" - - # Fallback if that path doesn't exist - if not os.path.exists(os.path.dirname(base_dir)): - documents_path = os.path.join(os.path.expanduser("~"), "Documents") - if os.path.exists(documents_path): - base_dir = os.path.join(documents_path, "LiminalBackrooms") - else: - base_dir = os.path.join(os.getcwd(), "exports") - - # Create the base directory if it doesn't exist - os.makedirs(base_dir, exist_ok=True) - - # Generate a session folder name based on date/time - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - default_folder = os.path.join(base_dir, f"session_{timestamp}") - - # Get the folder from a dialog - folder_name = QFileDialog.getExistingDirectory( - self, - "Select Export Folder (or create new)", - base_dir, - QFileDialog.Option.ShowDirsOnly - ) - - # If user cancelled, offer to create the default folder - if not folder_name: - reply = QMessageBox.question( - self, - "Create Export Folder?", - f"Create new export folder?\n\n{default_folder}", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No - ) - if reply == QMessageBox.StandardButton.Yes: - folder_name = default_folder - else: - return - - try: - # Create the export folder - os.makedirs(folder_name, exist_ok=True) - - # Get main window for accessing session data - main_window = self.window() - - # Export conversation as multiple formats - # Plain text - text_path = os.path.join(folder_name, "conversation.txt") - with open(text_path, 'w', encoding='utf-8') as f: - f.write(self.conversation_display.toPlainText()) - - # HTML - html_path = os.path.join(folder_name, "conversation.html") - with open(html_path, 'w', encoding='utf-8') as f: - f.write(self.conversation_display.toHtml()) - - # Full HTML document if it exists - full_html_path = os.path.join(os.getcwd(), "conversation_full.html") - if os.path.exists(full_html_path): - shutil.copy2(full_html_path, os.path.join(folder_name, "conversation_full.html")) - - # Copy session images - images_copied = 0 - if hasattr(main_window, 'right_sidebar') and hasattr(main_window.right_sidebar, 'image_preview_pane'): - session_images = main_window.right_sidebar.image_preview_pane.session_images - if session_images: - images_dir = os.path.join(folder_name, "images") - os.makedirs(images_dir, exist_ok=True) - for img_path in session_images: - if os.path.exists(img_path): - shutil.copy2(img_path, images_dir) - images_copied += 1 - - # Copy session videos - videos_copied = 0 - if hasattr(main_window, 'session_videos'): - session_videos = main_window.session_videos - if session_videos: - videos_dir = os.path.join(folder_name, "videos") - os.makedirs(videos_dir, exist_ok=True) - for vid_path in session_videos: - if os.path.exists(vid_path): - shutil.copy2(vid_path, videos_dir) - videos_copied += 1 - - # Create a manifest/summary file - manifest_path = os.path.join(folder_name, "manifest.txt") - with open(manifest_path, 'w', encoding='utf-8') as f: - f.write(f"Liminal Backrooms Session Export\n") - f.write(f"================================\n") - f.write(f"Exported: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n") - f.write(f"Contents:\n") - f.write(f"- conversation.txt (plain text)\n") - f.write(f"- conversation.html (HTML format)\n") - if os.path.exists(os.path.join(folder_name, "conversation_full.html")): - f.write(f"- conversation_full.html (styled document)\n") - f.write(f"- images/ ({images_copied} files)\n") - f.write(f"- videos/ ({videos_copied} files)\n") - - # Status message - status_msg = f"Exported to {folder_name} ({images_copied} images, {videos_copied} videos)" - main_window.statusBar().showMessage(status_msg) - print(f"Session exported to {folder_name}") - print(f" - {images_copied} images") - print(f" - {videos_copied} videos") - - # Show success message - QMessageBox.information( - self, - "Export Complete", - f"Session exported successfully!\n\n" - f"Location: {folder_name}\n\n" - f"• Conversation (txt, html)\n" - f"• {images_copied} images\n" - f"• {videos_copied} videos" - ) - - except Exception as e: - error_msg = f"Error exporting session: {str(e)}" - QMessageBox.critical(self, "Export Error", error_msg) - print(error_msg) - import traceback - traceback.print_exc() - - -class CentralContainer(QWidget): - """Central container widget with animated background and overlay support""" - - def __init__(self, parent=None): - super().__init__(parent) - - # Background animation state - self.bg_offset = 0 - self.noise_offset = 0 - - # Animation timer for background - self.bg_timer = QTimer(self) - self.bg_timer.timeout.connect(self._animate_bg) - self.bg_timer.start(80) # ~12 FPS for subtle movement - - # Create scanline overlay as child widget - self.scanline_overlay = ScanlineOverlayWidget(self) - self.scanline_overlay.hide() - - def _animate_bg(self): - self.bg_offset = (self.bg_offset + 1) % 360 - self.noise_offset = (self.noise_offset + 0.5) % 100 - self.update() - - def set_scanlines_enabled(self, enabled): - """Toggle scanline effect""" - if enabled: - # Ensure overlay has correct geometry before showing - self.scanline_overlay.setGeometry(self.rect()) - self.scanline_overlay.show() - self.scanline_overlay.raise_() - self.scanline_overlay.start_animation() - else: - self.scanline_overlay.stop_animation() - self.scanline_overlay.hide() - - def resizeEvent(self, event): - """Update scanline overlay size when container resizes""" - super().resizeEvent(event) - self.scanline_overlay.setGeometry(self.rect()) - - def paintEvent(self, event): - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - # ═══ ANIMATED BACKGROUND ═══ - # Create shifting gradient with more visible movement - center_x = self.width() / 2 + math.sin(math.radians(self.bg_offset)) * 100 - center_y = self.height() / 2 + math.cos(math.radians(self.bg_offset * 0.7)) * 60 - - gradient = QRadialGradient(center_x, center_y, max(self.width(), self.height()) * 0.9) - - # More visible atmospheric colors with cyan tint - pulse = 0.5 + 0.5 * math.sin(math.radians(self.bg_offset * 2)) - center_r = int(10 + 8 * pulse) - center_g = int(15 + 10 * pulse) - center_b = int(30 + 15 * pulse) - - gradient.setColorAt(0, QColor(center_r, center_g, center_b)) - gradient.setColorAt(0.4, QColor(10, 14, 26)) - gradient.setColorAt(1, QColor(6, 8, 14)) - - painter.fillRect(self.rect(), gradient) - - # Add subtle glow lines at edges - glow_alpha = int(15 + 10 * pulse) - glow_color = QColor(6, 182, 212, glow_alpha) # Cyan glow - painter.setPen(QPen(glow_color, 2)) - - # Top edge glow - painter.drawLine(0, 0, self.width(), 0) - # Bottom edge glow - painter.drawLine(0, self.height() - 1, self.width(), self.height() - 1) - # Left edge glow - painter.drawLine(0, 0, 0, self.height()) - # Right edge glow - painter.drawLine(self.width() - 1, 0, self.width() - 1, self.height()) - - # Add subtle noise/grain pattern - noise_color = QColor(COLORS['accent_cyan']) - noise_color.setAlpha(8) - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(noise_color) - - # Sparse random dots for grain effect - random.seed(int(self.noise_offset)) - for _ in range(50): - x = random.randint(0, self.width()) - y = random.randint(0, self.height()) - painter.drawEllipse(x, y, 1, 1) - - -class ScanlineOverlayWidget(QWidget): - """Transparent overlay widget for CRT scanline effect""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) - self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) - - self.scanline_offset = 0 - self.intensity = 0.25 # More visible scanlines - - self.anim_timer = QTimer(self) - self.anim_timer.timeout.connect(self._animate) - - def start_animation(self): - self.anim_timer.start(100) - - def stop_animation(self): - self.anim_timer.stop() - - def _animate(self): - self.scanline_offset = (self.scanline_offset + 1) % 4 - self.update() - - def paintEvent(self, event): - painter = QPainter(self) - - # Draw horizontal scanlines - more visible - line_alpha = int(255 * self.intensity) - line_color = QColor(0, 0, 0, line_alpha) - painter.setPen(QPen(line_color, 1)) - - # Draw every 2nd line for more visible effect - for y in range(self.scanline_offset, self.height(), 2): - painter.drawLine(0, y, self.width(), y) - - # Subtle vignette effect at edges - gradient = QRadialGradient(self.width() / 2, self.height() / 2, - max(self.width(), self.height()) * 0.7) - gradient.setColorAt(0, QColor(0, 0, 0, 0)) - gradient.setColorAt(0.7, QColor(0, 0, 0, 0)) - gradient.setColorAt(1, QColor(0, 0, 0, int(255 * self.intensity * 1.5))) - - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(gradient) - painter.drawRect(self.rect()) - - -class LiminalBackroomsApp(QMainWindow): - """Main application window""" - def __init__(self): - super().__init__() - - # Main app state - self.conversation = [] - self.turn_count = 0 - self.images = [] - self.image_paths = [] - self.session_videos = [] # Track videos generated this session - self.branch_conversations = {} # Store branch conversations by ID - self.active_branch = None # Currently displayed branch - - # Set up the UI - self.setup_ui() - - # Connect signals and slots - self.connect_signals() - - # Dark theme - self.apply_dark_theme() - - # Restore splitter state if available - self.restore_splitter_state() - - # Start maximized - self.showMaximized() - - def setup_ui(self): - """Set up the user interface""" - self.setWindowTitle("╔═ LIMINAL BACKROOMS v0.7 ═╗") - self.setGeometry(100, 100, 1600, 900) # Initial size before maximizing - self.setMinimumSize(1200, 800) - - # Create central widget - this will be a custom widget that paints the background - self.central_container = CentralContainer() - self.setCentralWidget(self.central_container) - - # Main layout for content - main_layout = QHBoxLayout(self.central_container) - main_layout.setContentsMargins(10, 10, 10, 10) - main_layout.setSpacing(5) - - # Create horizontal splitter for left and right panes - self.splitter = QSplitter(Qt.Orientation.Horizontal) - self.splitter.setHandleWidth(8) # Make the handle wider for easier grabbing - self.splitter.setChildrenCollapsible(False) # Prevent panes from being collapsed - self.splitter.setStyleSheet(f""" - QSplitter::handle {{ - background-color: {COLORS['border']}; - border: 1px solid {COLORS['border_highlight']}; - border-radius: 2px; - }} - QSplitter::handle:hover {{ - background-color: {COLORS['accent_blue']}; - }} - """) - main_layout.addWidget(self.splitter) - - # Create left pane (conversation) and right sidebar (tabbed: setup + network) - self.left_pane = ConversationPane() - self.right_sidebar = RightSidebar() - - self.splitter.addWidget(self.left_pane) - self.splitter.addWidget(self.right_sidebar) - - # Set initial splitter sizes (70:30 ratio - more space for conversation) - total_width = 1600 # Based on default window width - self.splitter.setSizes([int(total_width * 0.70), int(total_width * 0.30)]) - - # Initialize main conversation as root node - self.right_sidebar.add_node('main', 'Seed', 'main') - - # ═══ SIGNAL INDICATOR ═══ - self.signal_indicator = SignalIndicator() - - # Status bar with modern styling - self.statusBar().setStyleSheet(f""" - QStatusBar {{ - background-color: {COLORS['bg_dark']}; - color: {COLORS['text_dim']}; - border-top: 1px solid {COLORS['border']}; - padding: 3px; - font-size: 11px; - }} - """) - self.statusBar().showMessage("Ready") - - # Add notification label for agent actions (shows latest notification) - self.notification_label = QLabel("") - self.notification_label.setStyleSheet(f""" - QLabel {{ - color: {COLORS['accent_cyan']}; - font-size: 11px; - padding: 2px 10px; - background-color: transparent; - }} - """) - self.notification_label.setMaximumWidth(500) - self.statusBar().addWidget(self.notification_label, 1) - - # Add signal indicator to status bar - self.statusBar().addPermanentWidget(self.signal_indicator) - - # ═══ CRT TOGGLE CHECKBOX ═══ - self.crt_checkbox = QCheckBox("CRT") - self.crt_checkbox.setStyleSheet(f""" - QCheckBox {{ - color: {COLORS['text_dim']}; - font-size: 10px; - spacing: 4px; - }} - QCheckBox::indicator {{ - width: 12px; - height: 12px; - border: 1px solid {COLORS['border_glow']}; - border-radius: 2px; - background: {COLORS['bg_dark']}; - }} - QCheckBox::indicator:checked {{ - background: {COLORS['accent_cyan']}; - }} - """) - self.crt_checkbox.setToolTip("Toggle CRT scanline effect") - self.crt_checkbox.toggled.connect(self.toggle_crt_effect) - self.statusBar().addPermanentWidget(self.crt_checkbox) - - # Set up input callback - self.left_pane.set_input_callback(self.handle_user_input) - - def toggle_crt_effect(self, enabled): - """Toggle the CRT scanline effect""" - if hasattr(self, 'central_container'): - self.central_container.set_scanlines_enabled(enabled) - - def set_signal_active(self, active): - """Set signal indicator to active (waiting for response)""" - self.signal_indicator.set_active(active) - - def update_signal_latency(self, latency_ms): - """Update signal indicator with response latency""" - self.signal_indicator.set_latency(latency_ms) - - def connect_signals(self): - """Connect all signals and slots""" - # Node selection in network view - self.right_sidebar.nodeSelected.connect(self.on_branch_select) - - # Node hover in network view - if hasattr(self.right_sidebar.network_pane.network_view, 'nodeHovered'): - self.right_sidebar.network_pane.network_view.nodeHovered.connect(self.on_node_hover) - - # Export button - self.right_sidebar.control_panel.export_button.clicked.connect(self.export_conversation) - - # BackroomsBench evaluation button - self.right_sidebar.control_panel.backroomsbench_button.clicked.connect(self.run_backroomsbench_evaluation) - - # Connect context menu actions to the main app methods - self.left_pane.set_rabbithole_callback(self.branch_from_selection) - self.left_pane.set_fork_callback(self.fork_from_selection) - - # Save splitter state when it moves - self.splitter.splitterMoved.connect(self.save_splitter_state) - - def handle_user_input(self, text): - """Handle user input from the conversation pane""" - # Add user message to conversation - if text: - user_message = { - "role": "user", - "content": text - } - self.conversation.append(user_message) - - # Update conversation display - self.left_pane.update_conversation(self.conversation) - - # Process the conversation (this will be implemented in main.py) - if hasattr(self, 'process_conversation'): - self.process_conversation() - - def append_text(self, text, format_type="normal"): - """Append text to the conversation display with the specified format""" - self.left_pane.append_text(text, format_type) - - def clear_conversation(self): - """Clear the conversation display and reset images""" - self.left_pane.clear_conversation() - self.conversation = [] - self.images = [] - self.image_paths = [] - - def display_conversation(self, conversation, branch_data=None): - """Display the conversation in the text edit widget""" - self.left_pane.display_conversation(conversation, branch_data) - - def display_image(self, image_path): - """Display an image in the conversation""" - self.left_pane.display_image(image_path) - - def export_conversation(self): - """Export the current conversation""" - self.left_pane.export_conversation() - - def run_shitpostbench_evaluation(self): - """Run ShitpostBench multi-judge evaluation on current session.""" - from shitpostbench import run_shitpostbench - from PyQt6.QtWidgets import QMessageBox, QProgressDialog - from PyQt6.QtCore import Qt, QTimer - import threading - import subprocess - - # Get current conversation - conversation = getattr(self, 'main_conversation', []) - if len(conversation) < 5: - QMessageBox.warning( - self, - "Not Enough Content", - "Need at least 5 messages for a proper evaluation.\nKeep the chaos going! 🦝" - ) - return - - # Get scenario name - scenario = self.right_sidebar.control_panel.prompt_pair_selector.currentText() - - # Get participants - collect which AIs are active and their models - participants = [] - selectors = [ - self.right_sidebar.control_panel.ai1_model_selector, - self.right_sidebar.control_panel.ai2_model_selector, - self.right_sidebar.control_panel.ai3_model_selector, - self.right_sidebar.control_panel.ai4_model_selector, - self.right_sidebar.control_panel.ai5_model_selector, - ] - for i, selector in enumerate(selectors, 1): - model = selector.currentText() - if model: - participants.append(f"AI-{i}: {model}") - - # Show progress dialog - progress = QProgressDialog( - "🏆 Running ShitpostBench...\n\nSending to 3 judges (Opus, Gemini, GPT)", - None, 0, 0, self - ) - progress.setWindowTitle("ShitpostBench Evaluation") - progress.setWindowModality(Qt.WindowModality.WindowModal) - progress.setMinimumDuration(0) - progress.show() - - # Store result for callback - self._shitpostbench_result = None - self._shitpostbench_error = None - self._shitpostbench_progress = progress - - def run_eval(): - try: - self._shitpostbench_result = run_shitpostbench( - conversation=conversation, - scenario_name=scenario, - participant_models=participants - ) - except Exception as e: - print(f"[ShitpostBench] Error: {e}") - self._shitpostbench_error = str(e) - - def check_complete(): - if self._shitpostbench_result is not None: - # Success - close progress and show result - progress.close() - result = self._shitpostbench_result - self.statusBar().showMessage( - f"🏆 ShitpostBench complete! {result['summary']['successful_evaluations']}/3 judges filed reports" - ) - # Open the reports folder - try: - subprocess.Popen(f'explorer "{result["output_dir"]}"') - except Exception: - pass - self._shitpostbench_result = None - self._check_timer.stop() - elif self._shitpostbench_error is not None: - # Error - progress.close() - QMessageBox.critical( - self, - "ShitpostBench Error", - f"Evaluation failed:\n{self._shitpostbench_error}" - ) - self._shitpostbench_error = None - self._check_timer.stop() - - # Start background thread - threading.Thread(target=run_eval, daemon=True).start() - - # Poll for completion - self._check_timer = QTimer() - self._check_timer.timeout.connect(check_complete) - self._check_timer.start(500) # Check every 500ms - - def run_backroomsbench_evaluation(self): - """Run BackroomsBench multi-judge evaluation on current session.""" - from backroomsbench import run_backroomsbench - from PyQt6.QtWidgets import QMessageBox, QProgressDialog - from PyQt6.QtCore import Qt, QTimer - import threading - import subprocess - - # Get current conversation - conversation = getattr(self, 'main_conversation', []) - if len(conversation) < 5: - QMessageBox.warning( - self, - "Not Enough Content", - "Need at least 5 messages for a proper evaluation.\nLet the dialogue deepen. 🌀" - ) - return - - # Get scenario name - scenario = self.right_sidebar.control_panel.prompt_pair_selector.currentText() - - # Get participants - participants = [] - selectors = [ - self.right_sidebar.control_panel.ai1_model_selector, - self.right_sidebar.control_panel.ai2_model_selector, - self.right_sidebar.control_panel.ai3_model_selector, - self.right_sidebar.control_panel.ai4_model_selector, - self.right_sidebar.control_panel.ai5_model_selector, - ] - for i, selector in enumerate(selectors, 1): - model = selector.currentText() - if model: - participants.append(f"AI-{i}: {model}") - - # Show progress dialog - progress = QProgressDialog( - "🌀 Running BackroomsBench...\n\nSending to 3 judges (Opus, Gemini, GPT)", - None, 0, 0, self - ) - progress.setWindowTitle("BackroomsBench Evaluation") - progress.setWindowModality(Qt.WindowModality.WindowModal) - progress.setMinimumDuration(0) - progress.show() - - # Store result for callback - self._backroomsbench_result = None - self._backroomsbench_error = None - self._backroomsbench_progress = progress - - def run_eval(): - try: - self._backroomsbench_result = run_backroomsbench( - conversation=conversation, - scenario_name=scenario, - participant_models=participants - ) - except Exception as e: - print(f"[BackroomsBench] Error: {e}") - self._backroomsbench_error = str(e) - - def check_complete(): - if self._backroomsbench_result is not None: - progress.close() - result = self._backroomsbench_result - self.statusBar().showMessage( - f"🌀 BackroomsBench complete! {result['summary']['successful_evaluations']}/3 judges filed reports" - ) - try: - subprocess.Popen(f'explorer "{result["output_dir"]}"') - except Exception: - pass - self._backroomsbench_result = None - self._backrooms_check_timer.stop() - elif self._backroomsbench_error is not None: - progress.close() - QMessageBox.critical( - self, - "BackroomsBench Error", - f"Evaluation failed:\n{self._backroomsbench_error}" - ) - self._backroomsbench_error = None - self._backrooms_check_timer.stop() - - # Start background thread - threading.Thread(target=run_eval, daemon=True).start() - - # Poll for completion - self._backrooms_check_timer = QTimer() - self._backrooms_check_timer.timeout.connect(check_complete) - self._backrooms_check_timer.start(500) - - def on_node_hover(self, node_id): - """Handle node hover in the network view""" - if node_id == 'main': - self.statusBar().showMessage("Main conversation") - elif node_id in self.branch_conversations: - branch_data = self.branch_conversations[node_id] - branch_type = branch_data.get('type', 'branch') - selected_text = branch_data.get('selected_text', '') - self.statusBar().showMessage(f"{branch_type.capitalize()}: {selected_text[:50]}...") - - def apply_dark_theme(self): - """Apply dark theme to the application""" - self.setStyleSheet(f""" - QMainWindow {{ - background-color: {COLORS['bg_dark']}; - color: {COLORS['text_normal']}; - }} - QWidget {{ - background-color: {COLORS['bg_dark']}; - color: {COLORS['text_normal']}; - }} - QToolTip {{ - background-color: {COLORS['bg_light']}; - color: {COLORS['text_normal']}; - border: 1px solid {COLORS['border']}; - padding: 5px; - }} - """) - - # Add specific styling for branch messages - branch_header_format = QTextCharFormat() - branch_header_format.setForeground(QColor(COLORS['ai_header'])) - branch_header_format.setFontWeight(QFont.Weight.Bold) - branch_header_format.setFontPointSize(11) - - branch_inline_format = QTextCharFormat() - branch_inline_format.setForeground(QColor(COLORS['ai_header'])) - branch_inline_format.setFontItalic(True) - branch_inline_format.setFontPointSize(10) - - # Add formats to the left pane - self.left_pane.text_formats["branch_header"] = branch_header_format - self.left_pane.text_formats["branch_inline"] = branch_inline_format - - def on_branch_select(self, branch_id): - """Handle branch selection in the network view""" - try: - # Check if branch exists - if branch_id == 'main': - # Switch to main conversation - self.active_branch = None - # Make sure we have a main_conversation attribute - if not hasattr(self, 'main_conversation'): - self.main_conversation = [] - self.conversation = self.main_conversation - self.left_pane.update_conversation(self.conversation) - self.statusBar().showMessage("Switched to main conversation") - return - - if branch_id not in self.branch_conversations: - self.statusBar().showMessage(f"Branch {branch_id} not found") - return - - # Get branch data - branch_data = self.branch_conversations[branch_id] - - # Set active branch - self.active_branch = branch_id - - # Update conversation - self.conversation = branch_data['conversation'] - - # Display the conversation with branch metadata - self.left_pane.display_conversation(self.conversation, branch_data) - - # Update status bar - self.statusBar().showMessage(f"Switched to {branch_data['type']} branch: {branch_id}") - - except Exception as e: - print(f"Error selecting branch: {e}") - self.statusBar().showMessage(f"Error selecting branch: {e}") - - def branch_from_selection(self, selected_text): - """Create a rabbithole branch from selected text""" - if not selected_text: - return - - # Create branch - branch_id = self.create_branch(selected_text, 'rabbithole') - - # Switch to branch - self.on_branch_select(branch_id) - - def fork_from_selection(self, selected_text): - """Create a fork branch from selected text""" - if not selected_text: - return - - # Create branch - branch_id = self.create_branch(selected_text, 'fork') - - # Switch to branch - self.on_branch_select(branch_id) - - def create_branch(self, selected_text, branch_type="rabbithole", parent_branch=None): - """Create a new branch in the conversation""" - try: - # Generate a unique ID for the branch - branch_id = str(uuid.uuid4()) - - # Get parent branch ID - parent_id = parent_branch if parent_branch else (self.active_branch if self.active_branch else 'main') - - # Get current conversation - if parent_id == 'main': - # If parent is main, use main conversation - if not hasattr(self, 'main_conversation'): - self.main_conversation = [] - current_conversation = self.main_conversation.copy() - else: - # Otherwise, use parent branch conversation - parent_data = self.branch_conversations.get(parent_id) - if parent_data: - current_conversation = parent_data['conversation'].copy() - else: - current_conversation = [] - - # Create initial message based on branch type - if branch_type == 'fork': - initial_message = { - "role": "user", - "content": f"Complete this thought or sentence naturally, continuing forward from exactly this point: '{selected_text}'" - } - else: # rabbithole - initial_message = { - "role": "user", - "content": f"Let's explore and expand upon the concept of '{selected_text}' from our previous discussion." - } - - # Create branch conversation with initial message - branch_conversation = current_conversation.copy() - branch_conversation.append(initial_message) - - # Create branch data - branch_data = { - 'id': branch_id, - 'parent': parent_id, - 'type': branch_type, - 'selected_text': selected_text, - 'conversation': branch_conversation, - 'turn_count': 0, - 'created_at': datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - 'history': current_conversation - } - - # Store branch data - self.branch_conversations[branch_id] = branch_data - - # Add node to network graph - make sure parameters are in the correct order - node_label = f"{branch_type.capitalize()}: {selected_text[:20]}{'...' if len(selected_text) > 20 else ''}" - self.right_sidebar.add_node(branch_id, node_label, branch_type) - self.right_sidebar.add_edge(parent_id, branch_id) - - # Set active branch to this new branch - self.active_branch = branch_id - self.conversation = branch_conversation - - # Display the conversation - self.left_pane.display_conversation(branch_conversation, branch_data) - - # Trigger AI response processing for this branch - if hasattr(self, 'process_branch_conversation'): - # Add a small delay to ensure UI updates first - QTimer.singleShot(100, lambda: self.process_branch_conversation(branch_id)) - - # Return branch ID - return branch_id - - except Exception as e: - print(f"Error creating branch: {e}") - self.statusBar().showMessage(f"Error creating branch: {e}") - return None - - def get_branch_path(self, branch_id): - """Get the full path of branch names from root to the given branch""" - try: - path = [] - current_id = branch_id - - # Prevent potential infinite loops by tracking visited branches - visited = set() - - while current_id != 'main' and current_id not in visited: - visited.add(current_id) - branch_data = self.branch_conversations.get(current_id) - if not branch_data: - break - - # Get a readable version of the selected text (truncated if needed) - selected_text = branch_data.get('selected_text', '') - if selected_text: - display_text = f"{selected_text[:20]}{'...' if len(selected_text) > 20 else ''}" - path.append(display_text) - else: - path.append(f"{branch_data.get('type', 'Branch').capitalize()}") - - # Check for valid parent attribute - current_id = branch_data.get('parent') - if not current_id: - break - - path.append('Seed') - return ' → '.join(reversed(path)) - except Exception as e: - print(f"Error building branch path: {e}") - return f"Branch {branch_id}" - - def save_splitter_state(self): - """Save the current splitter state to a file""" - try: - # Create settings directory if it doesn't exist - if not os.path.exists('settings'): - os.makedirs('settings') - - # Save splitter state to file - with open('settings/splitter_state.json', 'w') as f: - json.dump({ - 'sizes': self.splitter.sizes() - }, f) - except Exception as e: - print(f"Error saving splitter state: {e}") - - def restore_splitter_state(self): - """Restore the splitter state from a file if available""" - try: - if os.path.exists('settings/splitter_state.json'): - with open('settings/splitter_state.json', 'r') as f: - state = json.load(f) - if 'sizes' in state: - self.splitter.setSizes(state['sizes']) - except Exception as e: - print(f"Error restoring splitter state: {e}") - # Fall back to default sizes - total_width = self.width() - self.splitter.setSizes([int(total_width * 0.7), int(total_width * 0.3)]) - - def process_branch_conversation(self, branch_id): - """Process the branch conversation using the selected models""" - # This method will be implemented in main.py to avoid circular imports - pass - - def node_clicked(self, node_id): - """Handle node click in the network view""" - print(f"Node clicked: {node_id}") - - # Check if this is the main conversation or a branch - if node_id == 'main': - # Switch to main conversation - self.active_branch = None - self.left_pane.display_conversation(self.main_conversation) - elif node_id in self.branch_conversations: - # Switch to branch conversation - self.active_branch = node_id - branch_data = self.branch_conversations[node_id] - conversation = branch_data['conversation'] - - # Filter hidden messages for display - visible_conversation = [msg for msg in conversation if not msg.get('hidden', False)] - self.left_pane.display_conversation(visible_conversation, branch_data) - - def initialize_selectors(self): - """Initialize the AI model selectors and prompt pair selector""" - pass - - # Removed: create_new_living_document \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 9719351..5d0c197 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,175 @@ -# This file is automatically @generated by Poetry 1.5.5 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +description = "Happy Eyeballs for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, + {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, +] + +[[package]] +name = "aiohttp" +version = "3.13.2" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiohttp-3.13.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2372b15a5f62ed37789a6b383ff7344fc5b9f243999b0cd9b629d8bc5f5b4155"}, + {file = "aiohttp-3.13.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e7f8659a48995edee7229522984bd1009c1213929c769c2daa80b40fe49a180c"}, + {file = "aiohttp-3.13.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:939ced4a7add92296b0ad38892ce62b98c619288a081170695c6babe4f50e636"}, + {file = "aiohttp-3.13.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6315fb6977f1d0dd41a107c527fee2ed5ab0550b7d885bc15fee20ccb17891da"}, + {file = "aiohttp-3.13.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6e7352512f763f760baaed2637055c49134fd1d35b37c2dedfac35bfe5cf8725"}, + {file = "aiohttp-3.13.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e09a0a06348a2dd73e7213353c90d709502d9786219f69b731f6caa0efeb46f5"}, + {file = "aiohttp-3.13.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a09a6d073fb5789456545bdee2474d14395792faa0527887f2f4ec1a486a59d3"}, + {file = "aiohttp-3.13.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b59d13c443f8e049d9e94099c7e412e34610f1f49be0f230ec656a10692a5802"}, + {file = "aiohttp-3.13.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:20db2d67985d71ca033443a1ba2001c4b5693fe09b0e29f6d9358a99d4d62a8a"}, + {file = "aiohttp-3.13.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:960c2fc686ba27b535f9fd2b52d87ecd7e4fd1cf877f6a5cba8afb5b4a8bd204"}, + {file = "aiohttp-3.13.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6c00dbcf5f0d88796151e264a8eab23de2997c9303dd7c0bf622e23b24d3ce22"}, + {file = "aiohttp-3.13.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fed38a5edb7945f4d1bcabe2fcd05db4f6ec7e0e82560088b754f7e08d93772d"}, + {file = "aiohttp-3.13.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:b395bbca716c38bef3c764f187860e88c724b342c26275bc03e906142fc5964f"}, + {file = "aiohttp-3.13.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:204ffff2426c25dfda401ba08da85f9c59525cdc42bda26660463dd1cbcfec6f"}, + {file = "aiohttp-3.13.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:05c4dd3c48fb5f15db31f57eb35374cb0c09afdde532e7fb70a75aede0ed30f6"}, + {file = "aiohttp-3.13.2-cp310-cp310-win32.whl", hash = "sha256:e574a7d61cf10351d734bcddabbe15ede0eaa8a02070d85446875dc11189a251"}, + {file = "aiohttp-3.13.2-cp310-cp310-win_amd64.whl", hash = "sha256:364f55663085d658b8462a1c3f17b2b84a5c2e1ba858e1b79bff7b2e24ad1514"}, + {file = "aiohttp-3.13.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4647d02df098f6434bafd7f32ad14942f05a9caa06c7016fdcc816f343997dd0"}, + {file = "aiohttp-3.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e3403f24bcb9c3b29113611c3c16a2a447c3953ecf86b79775e7be06f7ae7ccb"}, + {file = "aiohttp-3.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:43dff14e35aba17e3d6d5ba628858fb8cb51e30f44724a2d2f0c75be492c55e9"}, + {file = "aiohttp-3.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2a9ea08e8c58bb17655630198833109227dea914cd20be660f52215f6de5613"}, + {file = "aiohttp-3.13.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53b07472f235eb80e826ad038c9d106c2f653584753f3ddab907c83f49eedead"}, + {file = "aiohttp-3.13.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e736c93e9c274fce6419af4aac199984d866e55f8a4cec9114671d0ea9688780"}, + {file = "aiohttp-3.13.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ff5e771f5dcbc81c64898c597a434f7682f2259e0cd666932a913d53d1341d1a"}, + {file = "aiohttp-3.13.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3b6fb0c207cc661fa0bf8c66d8d9b657331ccc814f4719468af61034b478592"}, + {file = "aiohttp-3.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:97a0895a8e840ab3520e2288db7cace3a1981300d48babeb50e7425609e2e0ab"}, + {file = "aiohttp-3.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9e8f8afb552297aca127c90cb840e9a1d4bfd6a10d7d8f2d9176e1acc69bad30"}, + {file = "aiohttp-3.13.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ed2f9c7216e53c3df02264f25d824b079cc5914f9e2deba94155190ef648ee40"}, + {file = "aiohttp-3.13.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:99c5280a329d5fa18ef30fd10c793a190d996567667908bef8a7f81f8202b948"}, + {file = "aiohttp-3.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ca6ffef405fc9c09a746cb5d019c1672cd7f402542e379afc66b370833170cf"}, + {file = "aiohttp-3.13.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:47f438b1a28e926c37632bff3c44df7d27c9b57aaf4e34b1def3c07111fdb782"}, + {file = "aiohttp-3.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9acda8604a57bb60544e4646a4615c1866ee6c04a8edef9b8ee6fd1d8fa2ddc8"}, + {file = "aiohttp-3.13.2-cp311-cp311-win32.whl", hash = "sha256:868e195e39b24aaa930b063c08bb0c17924899c16c672a28a65afded9c46c6ec"}, + {file = "aiohttp-3.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:7fd19df530c292542636c2a9a85854fab93474396a52f1695e799186bbd7f24c"}, + {file = "aiohttp-3.13.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b1e56bab2e12b2b9ed300218c351ee2a3d8c8fdab5b1ec6193e11a817767e47b"}, + {file = "aiohttp-3.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:364e25edaabd3d37b1db1f0cbcee8c73c9a3727bfa262b83e5e4cf3489a2a9dc"}, + {file = "aiohttp-3.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c5c94825f744694c4b8db20b71dba9a257cd2ba8e010a803042123f3a25d50d7"}, + {file = "aiohttp-3.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba2715d842ffa787be87cbfce150d5e88c87a98e0b62e0f5aa489169a393dbbb"}, + {file = "aiohttp-3.13.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:585542825c4bc662221fb257889e011a5aa00f1ae4d75d1d246a5225289183e3"}, + {file = "aiohttp-3.13.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39d02cb6025fe1aabca329c5632f48c9532a3dabccd859e7e2f110668972331f"}, + {file = "aiohttp-3.13.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e67446b19e014d37342f7195f592a2a948141d15a312fe0e700c2fd2f03124f6"}, + {file = "aiohttp-3.13.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4356474ad6333e41ccefd39eae869ba15a6c5299c9c01dfdcfdd5c107be4363e"}, + {file = "aiohttp-3.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeacf451c99b4525f700f078becff32c32ec327b10dcf31306a8a52d78166de7"}, + {file = "aiohttp-3.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8a9b889aeabd7a4e9af0b7f4ab5ad94d42e7ff679aaec6d0db21e3b639ad58d"}, + {file = "aiohttp-3.13.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:fa89cb11bc71a63b69568d5b8a25c3ca25b6d54c15f907ca1c130d72f320b76b"}, + {file = "aiohttp-3.13.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8aa7c807df234f693fed0ecd507192fc97692e61fee5702cdc11155d2e5cadc8"}, + {file = "aiohttp-3.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9eb3e33fdbe43f88c3c75fa608c25e7c47bbd80f48d012763cb67c47f39a7e16"}, + {file = "aiohttp-3.13.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9434bc0d80076138ea986833156c5a48c9c7a8abb0c96039ddbb4afc93184169"}, + {file = "aiohttp-3.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff15c147b2ad66da1f2cbb0622313f2242d8e6e8f9b79b5206c84523a4473248"}, + {file = "aiohttp-3.13.2-cp312-cp312-win32.whl", hash = "sha256:27e569eb9d9e95dbd55c0fc3ec3a9335defbf1d8bc1d20171a49f3c4c607b93e"}, + {file = "aiohttp-3.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:8709a0f05d59a71f33fd05c17fc11fcb8c30140506e13c2f5e8ee1b8964e1b45"}, + {file = "aiohttp-3.13.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7519bdc7dfc1940d201651b52bf5e03f5503bda45ad6eacf64dda98be5b2b6be"}, + {file = "aiohttp-3.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:088912a78b4d4f547a1f19c099d5a506df17eacec3c6f4375e2831ec1d995742"}, + {file = "aiohttp-3.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5276807b9de9092af38ed23ce120539ab0ac955547b38563a9ba4f5b07b95293"}, + {file = "aiohttp-3.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1237c1375eaef0db4dcd7c2559f42e8af7b87ea7d295b118c60c36a6e61cb811"}, + {file = "aiohttp-3.13.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96581619c57419c3d7d78703d5b78c1e5e5fc0172d60f555bdebaced82ded19a"}, + {file = "aiohttp-3.13.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2713a95b47374169409d18103366de1050fe0ea73db358fc7a7acb2880422d4"}, + {file = "aiohttp-3.13.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:228a1cd556b3caca590e9511a89444925da87d35219a49ab5da0c36d2d943a6a"}, + {file = "aiohttp-3.13.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac6cde5fba8d7d8c6ac963dbb0256a9854e9fafff52fbcc58fdf819357892c3e"}, + {file = "aiohttp-3.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2bef8237544f4e42878c61cef4e2839fee6346dc60f5739f876a9c50be7fcdb"}, + {file = "aiohttp-3.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:16f15a4eac3bc2d76c45f7ebdd48a65d41b242eb6c31c2245463b40b34584ded"}, + {file = "aiohttp-3.13.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:bb7fb776645af5cc58ab804c58d7eba545a97e047254a52ce89c157b5af6cd0b"}, + {file = "aiohttp-3.13.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1b4951125ec10c70802f2cb09736c895861cd39fd9dcb35107b4dc8ae6220b8"}, + {file = "aiohttp-3.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:550bf765101ae721ee1d37d8095f47b1f220650f85fe1af37a90ce75bab89d04"}, + {file = "aiohttp-3.13.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fe91b87fc295973096251e2d25a811388e7d8adf3bd2b97ef6ae78bc4ac6c476"}, + {file = "aiohttp-3.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0c8e31cfcc4592cb200160344b2fb6ae0f9e4effe06c644b5a125d4ae5ebe23"}, + {file = "aiohttp-3.13.2-cp313-cp313-win32.whl", hash = "sha256:0740f31a60848d6edb296a0df827473eede90c689b8f9f2a4cdde74889eb2254"}, + {file = "aiohttp-3.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:a88d13e7ca367394908f8a276b89d04a3652044612b9a408a0bb22a5ed976a1a"}, + {file = "aiohttp-3.13.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2475391c29230e063ef53a66669b7b691c9bfc3f1426a0f7bcdf1216bdbac38b"}, + {file = "aiohttp-3.13.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f33c8748abef4d8717bb20e8fb1b3e07c6adacb7fd6beaae971a764cf5f30d61"}, + {file = "aiohttp-3.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ae32f24bbfb7dbb485a24b30b1149e2f200be94777232aeadba3eecece4d0aa4"}, + {file = "aiohttp-3.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d7f02042c1f009ffb70067326ef183a047425bb2ff3bc434ead4dd4a4a66a2b"}, + {file = "aiohttp-3.13.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93655083005d71cd6c072cdab54c886e6570ad2c4592139c3fb967bfc19e4694"}, + {file = "aiohttp-3.13.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0db1e24b852f5f664cd728db140cf11ea0e82450471232a394b3d1a540b0f906"}, + {file = "aiohttp-3.13.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b009194665bcd128e23eaddef362e745601afa4641930848af4c8559e88f18f9"}, + {file = "aiohttp-3.13.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c038a8fdc8103cd51dbd986ecdce141473ffd9775a7a8057a6ed9c3653478011"}, + {file = "aiohttp-3.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66bac29b95a00db411cd758fea0e4b9bdba6d549dfe333f9a945430f5f2cc5a6"}, + {file = "aiohttp-3.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4ebf9cfc9ba24a74cf0718f04aac2a3bbe745902cc7c5ebc55c0f3b5777ef213"}, + {file = "aiohttp-3.13.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a4b88ebe35ce54205c7074f7302bd08a4cb83256a3e0870c72d6f68a3aaf8e49"}, + {file = "aiohttp-3.13.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:98c4fb90bb82b70a4ed79ca35f656f4281885be076f3f970ce315402b53099ae"}, + {file = "aiohttp-3.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:ec7534e63ae0f3759df3a1ed4fa6bc8f75082a924b590619c0dd2f76d7043caa"}, + {file = "aiohttp-3.13.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5b927cf9b935a13e33644cbed6c8c4b2d0f25b713d838743f8fe7191b33829c4"}, + {file = "aiohttp-3.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:88d6c017966a78c5265d996c19cdb79235be5e6412268d7e2ce7dee339471b7a"}, + {file = "aiohttp-3.13.2-cp314-cp314-win32.whl", hash = "sha256:f7c183e786e299b5d6c49fb43a769f8eb8e04a2726a2bd5887b98b5cc2d67940"}, + {file = "aiohttp-3.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:fe242cd381e0fb65758faf5ad96c2e460df6ee5b2de1072fe97e4127927e00b4"}, + {file = "aiohttp-3.13.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f10d9c0b0188fe85398c61147bbd2a657d616c876863bfeff43376e0e3134673"}, + {file = "aiohttp-3.13.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e7c952aefdf2460f4ae55c5e9c3e80aa72f706a6317e06020f80e96253b1accd"}, + {file = "aiohttp-3.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c20423ce14771d98353d2e25e83591fa75dfa90a3c1848f3d7c68243b4fbded3"}, + {file = "aiohttp-3.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e96eb1a34396e9430c19d8338d2ec33015e4a87ef2b4449db94c22412e25ccdf"}, + {file = "aiohttp-3.13.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23fb0783bc1a33640036465019d3bba069942616a6a2353c6907d7fe1ccdaf4e"}, + {file = "aiohttp-3.13.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1a9bea6244a1d05a4e57c295d69e159a5c50d8ef16aa390948ee873478d9a5"}, + {file = "aiohttp-3.13.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0a3d54e822688b56e9f6b5816fb3de3a3a64660efac64e4c2dc435230ad23bad"}, + {file = "aiohttp-3.13.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a653d872afe9f33497215745da7a943d1dc15b728a9c8da1c3ac423af35178e"}, + {file = "aiohttp-3.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56d36e80d2003fa3fc0207fac644216d8532e9504a785ef9a8fd013f84a42c61"}, + {file = "aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:78cd586d8331fb8e241c2dd6b2f4061778cc69e150514b39a9e28dd050475661"}, + {file = "aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:20b10bbfbff766294fe99987f7bb3b74fdd2f1a2905f2562132641ad434dcf98"}, + {file = "aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9ec49dff7e2b3c85cdeaa412e9d438f0ecd71676fde61ec57027dd392f00c693"}, + {file = "aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:94f05348c4406450f9d73d38efb41d669ad6cd90c7ee194810d0eefbfa875a7a"}, + {file = "aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:fa4dcb605c6f82a80c7f95713c2b11c3b8e9893b3ebd2bc9bde93165ed6107be"}, + {file = "aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf00e5db968c3f67eccd2778574cf64d8b27d95b237770aa32400bd7a1ca4f6c"}, + {file = "aiohttp-3.13.2-cp314-cp314t-win32.whl", hash = "sha256:d23b5fe492b0805a50d3371e8a728a9134d8de5447dce4c885f5587294750734"}, + {file = "aiohttp-3.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:ff0a7b0a82a7ab905cbda74006318d1b12e37c797eb1b0d4eb3e316cf47f658f"}, + {file = "aiohttp-3.13.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7fbdf5ad6084f1940ce88933de34b62358d0f4a0b6ec097362dcd3e5a65a4989"}, + {file = "aiohttp-3.13.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7c3a50345635a02db61792c85bb86daffac05330f6473d524f1a4e3ef9d0046d"}, + {file = "aiohttp-3.13.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0e87dff73f46e969af38ab3f7cb75316a7c944e2e574ff7c933bc01b10def7f5"}, + {file = "aiohttp-3.13.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2adebd4577724dcae085665f294cc57c8701ddd4d26140504db622b8d566d7aa"}, + {file = "aiohttp-3.13.2-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e036a3a645fe92309ec34b918394bb377950cbb43039a97edae6c08db64b23e2"}, + {file = "aiohttp-3.13.2-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:23ad365e30108c422d0b4428cf271156dd56790f6dd50d770b8e360e6c5ab2e6"}, + {file = "aiohttp-3.13.2-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f9b2c2d4b9d958b1f9ae0c984ec1dd6b6689e15c75045be8ccb4011426268ca"}, + {file = "aiohttp-3.13.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a92cf4b9bea33e15ecbaa5c59921be0f23222608143d025c989924f7e3e0c07"}, + {file = "aiohttp-3.13.2-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:070599407f4954021509193404c4ac53153525a19531051661440644728ba9a7"}, + {file = "aiohttp-3.13.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:29562998ec66f988d49fb83c9b01694fa927186b781463f376c5845c121e4e0b"}, + {file = "aiohttp-3.13.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:4dd3db9d0f4ebca1d887d76f7cdbcd1116ac0d05a9221b9dad82c64a62578c4d"}, + {file = "aiohttp-3.13.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d7bc4b7f9c4921eba72677cd9fedd2308f4a4ca3e12fab58935295ad9ea98700"}, + {file = "aiohttp-3.13.2-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:dacd50501cd017f8cccb328da0c90823511d70d24a323196826d923aad865901"}, + {file = "aiohttp-3.13.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:8b2f1414f6a1e0683f212ec80e813f4abef94c739fd090b66c9adf9d2a05feac"}, + {file = "aiohttp-3.13.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04c3971421576ed24c191f610052bcb2f059e395bc2489dd99e397f9bc466329"}, + {file = "aiohttp-3.13.2-cp39-cp39-win32.whl", hash = "sha256:9f377d0a924e5cc94dc620bc6366fc3e889586a7f18b748901cf016c916e2084"}, + {file = "aiohttp-3.13.2-cp39-cp39-win_amd64.whl", hash = "sha256:9c705601e16c03466cb72011bd1af55d68fa65b045356d8f96c216e5f6db0fa5"}, + {file = "aiohttp-3.13.2.tar.gz", hash = "sha256:40176a52c186aefef6eb3cad2cdd30cd06e3afbe88fe8ab2af9c0b90f228daca"}, +] + +[package.dependencies] +aiohappyeyeballs = ">=2.5.0" +aiosignal = ">=1.4.0" +async-timeout = {version = ">=4.0,<6.0", markers = "python_version < \"3.11\""} +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +propcache = ">=0.2.0" +yarl = ">=1.17.0,<2.0" + +[package.extras] +speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "backports.zstd ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "brotlicffi ; platform_python_implementation != \"CPython\""] + +[[package]] +name = "aiosignal" +version = "1.4.0" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, + {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" +typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} [[package]] name = "annotated-types" @@ -6,17 +177,46 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "anthropic" +version = "0.75.0" +description = "The official Python library for the anthropic API" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "anthropic-0.75.0-py3-none-any.whl", hash = "sha256:ea8317271b6c15d80225a9f3c670152746e88805a7a61e14d4a374577164965b"}, + {file = "anthropic-0.75.0.tar.gz", hash = "sha256:e8607422f4ab616db2ea5baacc215dd5f028da99ce2f022e33c7c535b29f3dfb"}, +] + +[package.dependencies] +anyio = ">=3.5.0,<5" +distro = ">=1.7.0,<2" +docstring-parser = ">=0.15,<1" +httpx = ">=0.25.0,<1" +jiter = ">=0.4.0,<1" +pydantic = ">=1.9.0,<3" +sniffio = "*" +typing-extensions = ">=4.10,<5" + +[package.extras] +aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"] +bedrock = ["boto3 (>=1.28.57)", "botocore (>=1.31.57)"] +vertex = ["google-auth[requests] (>=2,<3)"] + [[package]] name = "anyio" version = "4.6.2.post1" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d"}, {file = "anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c"}, @@ -30,18 +230,96 @@ typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} [package.extras] doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21.0b1)"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21.0b1) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\""] trio = ["trio (>=0.26.1)"] +[[package]] +name = "async-timeout" +version = "5.0.1" +description = "Timeout context manager for asyncio programs" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version < \"3.11\"" +files = [ + {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, + {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, +] + +[[package]] +name = "attrs" +version = "25.4.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, + {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, +] + +[[package]] +name = "black" +version = "25.11.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "black-25.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec311e22458eec32a807f029b2646f661e6859c3f61bc6d9ffb67958779f392e"}, + {file = "black-25.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1032639c90208c15711334d681de2e24821af0575573db2810b0763bcd62e0f0"}, + {file = "black-25.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0f7c461df55cf32929b002335883946a4893d759f2df343389c4396f3b6b37"}, + {file = "black-25.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:f9786c24d8e9bd5f20dc7a7f0cdd742644656987f6ea6947629306f937726c03"}, + {file = "black-25.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:895571922a35434a9d8ca67ef926da6bc9ad464522a5fe0db99b394ef1c0675a"}, + {file = "black-25.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb4f4b65d717062191bdec8e4a442539a8ea065e6af1c4f4d36f0cdb5f71e170"}, + {file = "black-25.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d81a44cbc7e4f73a9d6ae449ec2317ad81512d1e7dce7d57f6333fd6259737bc"}, + {file = "black-25.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:7eebd4744dfe92ef1ee349dc532defbf012a88b087bb7ddd688ff59a447b080e"}, + {file = "black-25.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:80e7486ad3535636657aa180ad32a7d67d7c273a80e12f1b4bfa0823d54e8fac"}, + {file = "black-25.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cced12b747c4c76bc09b4db057c319d8545307266f41aaee665540bc0e04e96"}, + {file = "black-25.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb2d54a39e0ef021d6c5eef442e10fd71fcb491be6413d083a320ee768329dd"}, + {file = "black-25.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae263af2f496940438e5be1a0c1020e13b09154f3af4df0835ea7f9fe7bfa409"}, + {file = "black-25.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a1d40348b6621cc20d3d7530a5b8d67e9714906dfd7346338249ad9c6cedf2b"}, + {file = "black-25.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51c65d7d60bb25429ea2bf0731c32b2a2442eb4bd3b2afcb47830f0b13e58bfd"}, + {file = "black-25.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:936c4dd07669269f40b497440159a221ee435e3fddcf668e0c05244a9be71993"}, + {file = "black-25.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:f42c0ea7f59994490f4dccd64e6b2dd49ac57c7c84f38b8faab50f8759db245c"}, + {file = "black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170"}, + {file = "black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545"}, + {file = "black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda"}, + {file = "black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664"}, + {file = "black-25.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3bb5ce32daa9ff0605d73b6f19da0b0e6c1f8f2d75594db539fdfed722f2b06"}, + {file = "black-25.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9815ccee1e55717fe9a4b924cae1646ef7f54e0f990da39a34fc7b264fcf80a2"}, + {file = "black-25.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92285c37b93a1698dcbc34581867b480f1ba3a7b92acf1fe0467b04d7a4da0dc"}, + {file = "black-25.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:43945853a31099c7c0ff8dface53b4de56c41294fa6783c0441a8b1d9bf668bc"}, + {file = "black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b"}, + {file = "black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" +pytokens = ">=0.3.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.10)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + [[package]] name = "certifi" -version = "2024.6.2" +version = "2024.7.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" +groups = ["main"] files = [ - {file = "certifi-2024.6.2-py3-none-any.whl", hash = "sha256:ddc6c8ce995e6987e7faf5e3f1b02b302836a0e5d98ece18392cb1a36c72ad56"}, - {file = "certifi-2024.6.2.tar.gz", hash = "sha256:3cd43f1c6fa7dedc5899d69d3ad0398fd018ad1a17fba83ddaf78aa46c747516"}, + {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, + {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, ] [[package]] @@ -50,6 +328,7 @@ version = "3.3.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" +groups = ["main"] files = [ {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, @@ -143,12 +422,106 @@ files = [ {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, ] +[[package]] +name = "click" +version = "8.3.1" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, + {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] +markers = "platform_system == \"Windows\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "ddgs" +version = "9.5.5" +description = "Dux Distributed Global Search. A metasearch library that aggregates results from diverse web search services." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "ddgs-9.5.5-py3-none-any.whl", hash = "sha256:01fc8017a653ad16501bd8ac9fedcd29291f6500b1f8a7e92a916cdad7fc2fa2"}, + {file = "ddgs-9.5.5.tar.gz", hash = "sha256:9a09aa9ba24173d14321137c8c1bc54040ed0bf3bad956cb2f9feeda77968770"}, +] + +[package.dependencies] +click = ">=8.1.8" +lxml = ">=6.0.0" +primp = ">=0.15.0" + +[package.extras] +dev = ["mypy (>=1.17.1)", "pytest (>=8.4.1)", "pytest-dependency (>=0.6.0)", "ruff (>=0.12.9)"] + +[[package]] +name = "distro" +version = "1.9.0" +description = "Distro - an OS platform information API" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"}, + {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +description = "Parse Python docstrings in reST, Google and Numpydoc format" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708"}, + {file = "docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912"}, +] + +[package.extras] +dev = ["pre-commit (>=2.16.0) ; python_version >= \"3.9\"", "pydoctor (>=25.4.0)", "pytest"] +docs = ["pydoctor (>=25.4.0)"] +test = ["pytest"] + +[[package]] +name = "eval-type-backport" +version = "0.2.2" +description = "Like `typing._eval_type`, but lets older Python versions use newer typing features." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "eval_type_backport-0.2.2-py3-none-any.whl", hash = "sha256:cb6ad7c393517f476f96d456d0412ea80f0a8cf96f6892834cd9340149111b0a"}, + {file = "eval_type_backport-0.2.2.tar.gz", hash = "sha256:f0576b4cf01ebb5bd358d02314d31846af5e07678387486e2c798af0e7d849c1"}, +] + +[package.extras] +tests = ["pytest"] + [[package]] name = "exceptiongroup" version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["main"] +markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -157,31 +530,185 @@ files = [ [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "filelock" +version = "3.20.0" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2"}, + {file = "filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4"}, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7"}, + {file = "frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a"}, + {file = "frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6"}, + {file = "frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967"}, + {file = "frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25"}, + {file = "frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b"}, + {file = "frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa"}, + {file = "frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed"}, + {file = "frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496"}, + {file = "frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231"}, + {file = "frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7"}, + {file = "frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806"}, + {file = "frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0"}, + {file = "frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda"}, + {file = "frozenlist-1.8.0-cp39-cp39-win32.whl", hash = "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087"}, + {file = "frozenlist-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a"}, + {file = "frozenlist-1.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103"}, + {file = "frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d"}, + {file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"}, +] + [[package]] name = "h11" -version = "0.14.0" +version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] [[package]] name = "httpcore" -version = "1.0.6" +version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f"}, - {file = "httpcore-1.0.6.tar.gz", hash = "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f"}, + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, ] [package.dependencies] certifi = "*" -h11 = ">=0.13,<0.15" +h11 = ">=0.16" [package.extras] asyncio = ["anyio (>=4.0,<5.0)"] @@ -195,6 +722,7 @@ version = "0.27.2" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, @@ -208,7 +736,7 @@ idna = "*" sniffio = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -220,28 +748,927 @@ version = "3.7" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" +groups = ["main"] files = [ {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, ] +[[package]] +name = "jiter" +version = "0.12.0" +description = "Fast iterable JSON parser." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "jiter-0.12.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:e7acbaba9703d5de82a2c98ae6a0f59ab9770ab5af5fa35e43a303aee962cf65"}, + {file = "jiter-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:364f1a7294c91281260364222f535bc427f56d4de1d8ffd718162d21fbbd602e"}, + {file = "jiter-0.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85ee4d25805d4fb23f0a5167a962ef8e002dbfb29c0989378488e32cf2744b62"}, + {file = "jiter-0.12.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:796f466b7942107eb889c08433b6e31b9a7ed31daceaecf8af1be26fb26c0ca8"}, + {file = "jiter-0.12.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:35506cb71f47dba416694e67af996bbdefb8e3608f1f78799c2e1f9058b01ceb"}, + {file = "jiter-0.12.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:726c764a90c9218ec9e4f99a33d6bf5ec169163f2ca0fc21b654e88c2abc0abc"}, + {file = "jiter-0.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa47810c5565274810b726b0dc86d18dce5fd17b190ebdc3890851d7b2a0e74"}, + {file = "jiter-0.12.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8ec0259d3f26c62aed4d73b198c53e316ae11f0f69c8fbe6682c6dcfa0fcce2"}, + {file = "jiter-0.12.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:79307d74ea83465b0152fa23e5e297149506435535282f979f18b9033c0bb025"}, + {file = "jiter-0.12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cf6e6dd18927121fec86739f1a8906944703941d000f0639f3eb6281cc601dca"}, + {file = "jiter-0.12.0-cp310-cp310-win32.whl", hash = "sha256:b6ae2aec8217327d872cbfb2c1694489057b9433afce447955763e6ab015b4c4"}, + {file = "jiter-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:c7f49ce90a71e44f7e1aa9e7ec415b9686bbc6a5961e57eab511015e6759bc11"}, + {file = "jiter-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d8f8a7e317190b2c2d60eb2e8aa835270b008139562d70fe732e1c0020ec53c9"}, + {file = "jiter-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2218228a077e784c6c8f1a8e5d6b8cb1dea62ce25811c356364848554b2056cd"}, + {file = "jiter-0.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9354ccaa2982bf2188fd5f57f79f800ef622ec67beb8329903abf6b10da7d423"}, + {file = "jiter-0.12.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2607185ea89b4af9a604d4c7ec40e45d3ad03ee66998b031134bc510232bb7"}, + {file = "jiter-0.12.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a585a5e42d25f2e71db5f10b171f5e5ea641d3aa44f7df745aa965606111cc2"}, + {file = "jiter-0.12.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd9e21d34edff5a663c631f850edcb786719c960ce887a5661e9c828a53a95d9"}, + {file = "jiter-0.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a612534770470686cd5431478dc5a1b660eceb410abade6b1b74e320ca98de6"}, + {file = "jiter-0.12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3985aea37d40a908f887b34d05111e0aae822943796ebf8338877fee2ab67725"}, + {file = "jiter-0.12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b1207af186495f48f72529f8d86671903c8c10127cac6381b11dddc4aaa52df6"}, + {file = "jiter-0.12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef2fb241de583934c9915a33120ecc06d94aa3381a134570f59eed784e87001e"}, + {file = "jiter-0.12.0-cp311-cp311-win32.whl", hash = "sha256:453b6035672fecce8007465896a25b28a6b59cfe8fbc974b2563a92f5a92a67c"}, + {file = "jiter-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:ca264b9603973c2ad9435c71a8ec8b49f8f715ab5ba421c85a51cde9887e421f"}, + {file = "jiter-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:cb00ef392e7d684f2754598c02c409f376ddcef857aae796d559e6cacc2d78a5"}, + {file = "jiter-0.12.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:305e061fa82f4680607a775b2e8e0bcb071cd2205ac38e6ef48c8dd5ebe1cf37"}, + {file = "jiter-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c1860627048e302a528333c9307c818c547f214d8659b0705d2195e1a94b274"}, + {file = "jiter-0.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df37577a4f8408f7e0ec3205d2a8f87672af8f17008358063a4d6425b6081ce3"}, + {file = "jiter-0.12.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fdd787356c1c13a4f40b43c2156276ef7a71eb487d98472476476d803fb2cf"}, + {file = "jiter-0.12.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1eb5db8d9c65b112aacf14fcd0faae9913d07a8afea5ed06ccdd12b724e966a1"}, + {file = "jiter-0.12.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73c568cc27c473f82480abc15d1301adf333a7ea4f2e813d6a2c7d8b6ba8d0df"}, + {file = "jiter-0.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4321e8a3d868919bcb1abb1db550d41f2b5b326f72df29e53b2df8b006eb9403"}, + {file = "jiter-0.12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0a51bad79f8cc9cac2b4b705039f814049142e0050f30d91695a2d9a6611f126"}, + {file = "jiter-0.12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2a67b678f6a5f1dd6c36d642d7db83e456bc8b104788262aaefc11a22339f5a9"}, + {file = "jiter-0.12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efe1a211fe1fd14762adea941e3cfd6c611a136e28da6c39272dbb7a1bbe6a86"}, + {file = "jiter-0.12.0-cp312-cp312-win32.whl", hash = "sha256:d779d97c834b4278276ec703dc3fc1735fca50af63eb7262f05bdb4e62203d44"}, + {file = "jiter-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:e8269062060212b373316fe69236096aaf4c49022d267c6736eebd66bbbc60bb"}, + {file = "jiter-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:06cb970936c65de926d648af0ed3d21857f026b1cf5525cb2947aa5e01e05789"}, + {file = "jiter-0.12.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6cc49d5130a14b732e0612bc76ae8db3b49898732223ef8b7599aa8d9810683e"}, + {file = "jiter-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37f27a32ce36364d2fa4f7fdc507279db604d27d239ea2e044c8f148410defe1"}, + {file = "jiter-0.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbc0944aa3d4b4773e348cda635252824a78f4ba44328e042ef1ff3f6080d1cf"}, + {file = "jiter-0.12.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da25c62d4ee1ffbacb97fac6dfe4dcd6759ebdc9015991e92a6eae5816287f44"}, + {file = "jiter-0.12.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:048485c654b838140b007390b8182ba9774621103bd4d77c9c3f6f117474ba45"}, + {file = "jiter-0.12.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:635e737fbb7315bef0037c19b88b799143d2d7d3507e61a76751025226b3ac87"}, + {file = "jiter-0.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e017c417b1ebda911bd13b1e40612704b1f5420e30695112efdbed8a4b389ed"}, + {file = "jiter-0.12.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:89b0bfb8b2bf2351fba36bb211ef8bfceba73ef58e7f0c68fb67b5a2795ca2f9"}, + {file = "jiter-0.12.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:f5aa5427a629a824a543672778c9ce0c5e556550d1569bb6ea28a85015287626"}, + {file = "jiter-0.12.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed53b3d6acbcb0fd0b90f20c7cb3b24c357fe82a3518934d4edfa8c6898e498c"}, + {file = "jiter-0.12.0-cp313-cp313-win32.whl", hash = "sha256:4747de73d6b8c78f2e253a2787930f4fffc68da7fa319739f57437f95963c4de"}, + {file = "jiter-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:e25012eb0c456fcc13354255d0338cd5397cce26c77b2832b3c4e2e255ea5d9a"}, + {file = "jiter-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:c97b92c54fe6110138c872add030a1f99aea2401ddcdaa21edf74705a646dd60"}, + {file = "jiter-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:53839b35a38f56b8be26a7851a48b89bc47e5d88e900929df10ed93b95fea3d6"}, + {file = "jiter-0.12.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94f669548e55c91ab47fef8bddd9c954dab1938644e715ea49d7e117015110a4"}, + {file = "jiter-0.12.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:351d54f2b09a41600ffea43d081522d792e81dcfb915f6d2d242744c1cc48beb"}, + {file = "jiter-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2a5e90604620f94bf62264e7c2c038704d38217b7465b863896c6d7c902b06c7"}, + {file = "jiter-0.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:88ef757017e78d2860f96250f9393b7b577b06a956ad102c29c8237554380db3"}, + {file = "jiter-0.12.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c46d927acd09c67a9fb1416df45c5a04c27e83aae969267e98fba35b74e99525"}, + {file = "jiter-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:774ff60b27a84a85b27b88cd5583899c59940bcc126caca97eb2a9df6aa00c49"}, + {file = "jiter-0.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5433fab222fb072237df3f637d01b81f040a07dcac1cb4a5c75c7aa9ed0bef1"}, + {file = "jiter-0.12.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8c593c6e71c07866ec6bfb790e202a833eeec885022296aff6b9e0b92d6a70e"}, + {file = "jiter-0.12.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90d32894d4c6877a87ae00c6b915b609406819dce8bc0d4e962e4de2784e567e"}, + {file = "jiter-0.12.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:798e46eed9eb10c3adbbacbd3bdb5ecd4cf7064e453d00dbef08802dae6937ff"}, + {file = "jiter-0.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3f1368f0a6719ea80013a4eb90ba72e75d7ea67cfc7846db2ca504f3df0169a"}, + {file = "jiter-0.12.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65f04a9d0b4406f7e51279710b27484af411896246200e461d80d3ba0caa901a"}, + {file = "jiter-0.12.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:fd990541982a24281d12b67a335e44f117e4c6cbad3c3b75c7dea68bf4ce3a67"}, + {file = "jiter-0.12.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:b111b0e9152fa7df870ecaebb0bd30240d9f7fff1f2003bcb4ed0f519941820b"}, + {file = "jiter-0.12.0-cp314-cp314-win32.whl", hash = "sha256:a78befb9cc0a45b5a5a0d537b06f8544c2ebb60d19d02c41ff15da28a9e22d42"}, + {file = "jiter-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:e1fe01c082f6aafbe5c8faf0ff074f38dfb911d53f07ec333ca03f8f6226debf"}, + {file = "jiter-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:d72f3b5a432a4c546ea4bedc84cce0c3404874f1d1676260b9c7f048a9855451"}, + {file = "jiter-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e6ded41aeba3603f9728ed2b6196e4df875348ab97b28fc8afff115ed42ba7a7"}, + {file = "jiter-0.12.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a947920902420a6ada6ad51892082521978e9dd44a802663b001436e4b771684"}, + {file = "jiter-0.12.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:add5e227e0554d3a52cf390a7635edaffdf4f8fce4fdbcef3cc2055bb396a30c"}, + {file = "jiter-0.12.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f9b1cda8fcb736250d7e8711d4580ebf004a46771432be0ae4796944b5dfa5d"}, + {file = "jiter-0.12.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:deeb12a2223fe0135c7ff1356a143d57f95bbf1f4a66584f1fc74df21d86b993"}, + {file = "jiter-0.12.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c596cc0f4cb574877550ce4ecd51f8037469146addd676d7c1a30ebe6391923f"}, + {file = "jiter-0.12.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ab4c823b216a4aeab3fdbf579c5843165756bd9ad87cc6b1c65919c4715f783"}, + {file = "jiter-0.12.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e427eee51149edf962203ff8db75a7514ab89be5cb623fb9cea1f20b54f1107b"}, + {file = "jiter-0.12.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:edb868841f84c111255ba5e80339d386d937ec1fdce419518ce1bd9370fac5b6"}, + {file = "jiter-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8bbcfe2791dfdb7c5e48baf646d37a6a3dcb5a97a032017741dea9f817dca183"}, + {file = "jiter-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2fa940963bf02e1d8226027ef461e36af472dea85d36054ff835aeed944dd873"}, + {file = "jiter-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:506c9708dd29b27288f9f8f1140c3cb0e3d8ddb045956d7757b1fa0e0f39a473"}, + {file = "jiter-0.12.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c9d28b218d5f9e5f69a0787a196322a5056540cb378cac8ff542b4fa7219966c"}, + {file = "jiter-0.12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d0ee12028daf8cfcf880dd492349a122a64f42c059b6c62a2b0c96a83a8da820"}, + {file = "jiter-0.12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b135ebe757a82d67ed2821526e72d0acf87dd61f6013e20d3c45b8048af927b"}, + {file = "jiter-0.12.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:15d7fafb81af8a9e3039fc305529a61cd933eecee33b4251878a1c89859552a3"}, + {file = "jiter-0.12.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92d1f41211d8a8fe412faad962d424d334764c01dac6691c44691c2e4d3eedaf"}, + {file = "jiter-0.12.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a64a48d7c917b8f32f25c176df8749ecf08cec17c466114727efe7441e17f6d"}, + {file = "jiter-0.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:122046f3b3710b85de99d9aa2f3f0492a8233a2f54a64902b096efc27ea747b5"}, + {file = "jiter-0.12.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:27ec39225e03c32c6b863ba879deb427882f243ae46f0d82d68b695fa5b48b40"}, + {file = "jiter-0.12.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26b9e155ddc132225a39b1995b3b9f0fe0f79a6d5cbbeacf103271e7d309b404"}, + {file = "jiter-0.12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9ab05b7c58e29bb9e60b70c2e0094c98df79a1e42e397b9bb6eaa989b7a66dd0"}, + {file = "jiter-0.12.0-cp39-cp39-win32.whl", hash = "sha256:59f9f9df87ed499136db1c2b6c9efb902f964bed42a582ab7af413b6a293e7b0"}, + {file = "jiter-0.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:d3719596a1ebe7a48a498e8d5d0c4bf7553321d4c3eee1d620628d51351a3928"}, + {file = "jiter-0.12.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:4739a4657179ebf08f85914ce50332495811004cc1747852e8b2041ed2aab9b8"}, + {file = "jiter-0.12.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:41da8def934bf7bec16cb24bd33c0ca62126d2d45d81d17b864bd5ad721393c3"}, + {file = "jiter-0.12.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c44ee814f499c082e69872d426b624987dbc5943ab06e9bbaa4f81989fdb79e"}, + {file = "jiter-0.12.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd2097de91cf03eaa27b3cbdb969addf83f0179c6afc41bbc4513705e013c65d"}, + {file = "jiter-0.12.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:e8547883d7b96ef2e5fe22b88f8a4c8725a56e7f4abafff20fd5272d634c7ecb"}, + {file = "jiter-0.12.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:89163163c0934854a668ed783a2546a0617f71706a2551a4a0666d91ab365d6b"}, + {file = "jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d96b264ab7d34bbb2312dedc47ce07cd53f06835eacbc16dde3761f47c3a9e7f"}, + {file = "jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24e864cb30ab82311c6425655b0cdab0a98c5d973b065c66a3f020740c2324c"}, + {file = "jiter-0.12.0.tar.gz", hash = "sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b"}, +] + +[[package]] +name = "lxml" +version = "6.0.2" +description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "lxml-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e77dd455b9a16bbd2a5036a63ddbd479c19572af81b624e79ef422f929eef388"}, + {file = "lxml-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d444858b9f07cefff6455b983aea9a67f7462ba1f6cbe4a21e8bf6791bf2153"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f952dacaa552f3bb8834908dddd500ba7d508e6ea6eb8c52eb2d28f48ca06a31"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71695772df6acea9f3c0e59e44ba8ac50c4f125217e84aab21074a1a55e7e5c9"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17f68764f35fd78d7c4cc4ef209a184c38b65440378013d24b8aecd327c3e0c8"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:058027e261afed589eddcfe530fcc6f3402d7fd7e89bfd0532df82ebc1563dba"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8ffaeec5dfea5881d4c9d8913a32d10cfe3923495386106e4a24d45300ef79c"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:f2e3b1a6bb38de0bc713edd4d612969dd250ca8b724be8d460001a387507021c"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d6690ec5ec1cce0385cb20896b16be35247ac8c2046e493d03232f1c2414d321"}, + {file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2a50c3c1d11cad0ebebbac357a97b26aa79d2bcaf46f256551152aa85d3a4d1"}, + {file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3efe1b21c7801ffa29a1112fab3b0f643628c30472d507f39544fd48e9549e34"}, + {file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:59c45e125140b2c4b33920d21d83681940ca29f0b83f8629ea1a2196dc8cfe6a"}, + {file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:452b899faa64f1805943ec1c0c9ebeaece01a1af83e130b69cdefeda180bb42c"}, + {file = "lxml-6.0.2-cp310-cp310-win32.whl", hash = "sha256:1e786a464c191ca43b133906c6903a7e4d56bef376b75d97ccbb8ec5cf1f0a4b"}, + {file = "lxml-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:dacf3c64ef3f7440e3167aa4b49aa9e0fb99e0aa4f9ff03795640bf94531bcb0"}, + {file = "lxml-6.0.2-cp310-cp310-win_arm64.whl", hash = "sha256:45f93e6f75123f88d7f0cfd90f2d05f441b808562bf0bc01070a00f53f5028b5"}, + {file = "lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607"}, + {file = "lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553"}, + {file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb"}, + {file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a"}, + {file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c"}, + {file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7"}, + {file = "lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46"}, + {file = "lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078"}, + {file = "lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285"}, + {file = "lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456"}, + {file = "lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092"}, + {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f"}, + {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8"}, + {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f"}, + {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6"}, + {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322"}, + {file = "lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849"}, + {file = "lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f"}, + {file = "lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6"}, + {file = "lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77"}, + {file = "lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a"}, + {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679"}, + {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659"}, + {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484"}, + {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2"}, + {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314"}, + {file = "lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2"}, + {file = "lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7"}, + {file = "lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf"}, + {file = "lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe"}, + {file = "lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9"}, + {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917"}, + {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f"}, + {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8"}, + {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a"}, + {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c"}, + {file = "lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b"}, + {file = "lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed"}, + {file = "lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8"}, + {file = "lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d"}, + {file = "lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9"}, + {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e"}, + {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d"}, + {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec"}, + {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272"}, + {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f"}, + {file = "lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312"}, + {file = "lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca"}, + {file = "lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c"}, + {file = "lxml-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a656ca105115f6b766bba324f23a67914d9c728dafec57638e2b92a9dcd76c62"}, + {file = "lxml-6.0.2-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c54d83a2188a10ebdba573f16bd97135d06c9ef60c3dc495315c7a28c80a263f"}, + {file = "lxml-6.0.2-cp38-cp38-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:1ea99340b3c729beea786f78c38f60f4795622f36e305d9c9be402201efdc3b7"}, + {file = "lxml-6.0.2-cp38-cp38-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af85529ae8d2a453feee4c780d9406a5e3b17cee0dd75c18bd31adcd584debc3"}, + {file = "lxml-6.0.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:fe659f6b5d10fb5a17f00a50eb903eb277a71ee35df4615db573c069bcf967ac"}, + {file = "lxml-6.0.2-cp38-cp38-win32.whl", hash = "sha256:5921d924aa5468c939d95c9814fa9f9b5935a6ff4e679e26aaf2951f74043512"}, + {file = "lxml-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:0aa7070978f893954008ab73bb9e3c24a7c56c054e00566a21b553dc18105fca"}, + {file = "lxml-6.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2c8458c2cdd29589a8367c09c8f030f1d202be673f0ca224ec18590b3b9fb694"}, + {file = "lxml-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3fee0851639d06276e6b387f1c190eb9d7f06f7f53514e966b26bae46481ec90"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b2142a376b40b6736dfc214fd2902409e9e3857eff554fed2d3c60f097e62a62"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6b5b39cc7e2998f968f05309e666103b53e2edd01df8dc51b90d734c0825444"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4aec24d6b72ee457ec665344a29acb2d35937d5192faebe429ea02633151aad"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:b42f4d86b451c2f9d06ffb4f8bbc776e04df3ba070b9fe2657804b1b40277c48"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cdaefac66e8b8f30e37a9b4768a391e1f8a16a7526d5bc77a7928408ef68e93"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:b738f7e648735714bbb82bdfd030203360cfeab7f6e8a34772b3c8c8b820568c"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daf42de090d59db025af61ce6bdb2521f0f102ea0e6ea310f13c17610a97da4c"}, + {file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:66328dabea70b5ba7e53d94aa774b733cf66686535f3bc9250a7aab53a91caaf"}, + {file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:e237b807d68a61fc3b1e845407e27e5eb8ef69bc93fe8505337c1acb4ee300b6"}, + {file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:ac02dc29fd397608f8eb15ac1610ae2f2f0154b03f631e6d724d9e2ad4ee2c84"}, + {file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:817ef43a0c0b4a77bd166dc9a09a555394105ff3374777ad41f453526e37f9cb"}, + {file = "lxml-6.0.2-cp39-cp39-win32.whl", hash = "sha256:bc532422ff26b304cfb62b328826bd995c96154ffd2bac4544f37dbb95ecaa8f"}, + {file = "lxml-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:995e783eb0374c120f528f807443ad5a83a656a8624c467ea73781fc5f8a8304"}, + {file = "lxml-6.0.2-cp39-cp39-win_arm64.whl", hash = "sha256:08b9d5e803c2e4725ae9e8559ee880e5328ed61aa0935244e0515d7d9dbec0aa"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e748d4cf8fef2526bb2a589a417eba0c8674e29ffcb570ce2ceca44f1e567bf6"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4ddb1049fa0579d0cbd00503ad8c58b9ab34d1254c77bc6a5576d96ec7853dba"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb233f9c95f83707dae461b12b720c1af9c28c2d19208e1be03387222151daf5"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc456d04db0515ce3320d714a1eac7a97774ff0849e7718b492d957da4631dd4"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2613e67de13d619fd283d58bda40bff0ee07739f624ffee8b13b631abf33083d"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:24a8e756c982c001ca8d59e87c80c4d9dcd4d9b44a4cbeb8d9be4482c514d41d"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e"}, + {file = "lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62"}, +] + +[package.extras] +cssselect = ["cssselect (>=0.7)"] +html-clean = ["lxml_html_clean"] +html5 = ["html5lib"] +htmlsoup = ["BeautifulSoup4"] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, + {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins (>=0.5.0)"] +profiling = ["gprof2dot"] +rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "multidict" +version = "6.7.0" +description = "multidict implementation" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "multidict-6.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9f474ad5acda359c8758c8accc22032c6abe6dc87a8be2440d097785e27a9349"}, + {file = "multidict-6.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a9db5a870f780220e931d0002bbfd88fb53aceb6293251e2c839415c1b20e"}, + {file = "multidict-6.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03ca744319864e92721195fa28c7a3b2bc7b686246b35e4078c1e4d0eb5466d3"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f0e77e3c0008bc9316e662624535b88d360c3a5d3f81e15cf12c139a75250046"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08325c9e5367aa379a3496aa9a022fe8837ff22e00b94db256d3a1378c76ab32"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e2862408c99f84aa571ab462d25236ef9cb12a602ea959ba9c9009a54902fc73"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d72a9a2d885f5c208b0cb91ff2ed43636bb7e345ec839ff64708e04f69a13cc"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:478cc36476687bac1514d651cbbaa94b86b0732fb6855c60c673794c7dd2da62"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6843b28b0364dc605f21481c90fadb5f60d9123b442eb8a726bb74feef588a84"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23bfeee5316266e5ee2d625df2d2c602b829435fc3a235c2ba2131495706e4a0"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:680878b9f3d45c31e1f730eef731f9b0bc1da456155688c6745ee84eb818e90e"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:eb866162ef2f45063acc7a53a88ef6fe8bf121d45c30ea3c9cd87ce7e191a8d4"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:df0e3bf7993bdbeca5ac25aa859cf40d39019e015c9c91809ba7093967f7a648"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:661709cdcd919a2ece2234f9bae7174e5220c80b034585d7d8a755632d3e2111"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:096f52730c3fb8ed419db2d44391932b63891b2c5ed14850a7e215c0ba9ade36"}, + {file = "multidict-6.7.0-cp310-cp310-win32.whl", hash = "sha256:afa8a2978ec65d2336305550535c9c4ff50ee527914328c8677b3973ade52b85"}, + {file = "multidict-6.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:b15b3afff74f707b9275d5ba6a91ae8f6429c3ffb29bbfd216b0b375a56f13d7"}, + {file = "multidict-6.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:4b73189894398d59131a66ff157837b1fafea9974be486d036bb3d32331fdbf0"}, + {file = "multidict-6.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc"}, + {file = "multidict-6.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721"}, + {file = "multidict-6.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34"}, + {file = "multidict-6.7.0-cp311-cp311-win32.whl", hash = "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff"}, + {file = "multidict-6.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81"}, + {file = "multidict-6.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912"}, + {file = "multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184"}, + {file = "multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45"}, + {file = "multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8"}, + {file = "multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4"}, + {file = "multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b"}, + {file = "multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec"}, + {file = "multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6"}, + {file = "multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159"}, + {file = "multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288"}, + {file = "multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17"}, + {file = "multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390"}, + {file = "multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e"}, + {file = "multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00"}, + {file = "multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb"}, + {file = "multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6"}, + {file = "multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d"}, + {file = "multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6"}, + {file = "multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792"}, + {file = "multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842"}, + {file = "multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b"}, + {file = "multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f"}, + {file = "multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885"}, + {file = "multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c"}, + {file = "multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000"}, + {file = "multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63"}, + {file = "multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718"}, + {file = "multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0"}, + {file = "multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13"}, + {file = "multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd"}, + {file = "multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827"}, + {file = "multidict-6.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:363eb68a0a59bd2303216d2346e6c441ba10d36d1f9969fcb6f1ba700de7bb5c"}, + {file = "multidict-6.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d874eb056410ca05fed180b6642e680373688efafc7f077b2a2f61811e873a40"}, + {file = "multidict-6.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b55d5497b51afdfde55925e04a022f1de14d4f4f25cdfd4f5d9b0aa96166851"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f8e5c0031b90ca9ce555e2e8fd5c3b02a25f14989cbc310701823832c99eb687"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cf41880c991716f3c7cec48e2f19ae4045fc9db5fc9cff27347ada24d710bb5"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cfc12a8630a29d601f48d47787bd7eb730e475e83edb5d6c5084317463373eb"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3996b50c3237c4aec17459217c1e7bbdead9a22a0fcd3c365564fbd16439dde6"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7f5170993a0dd3ab871c74f45c0a21a4e2c37a2f2b01b5f722a2ad9c6650469e"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ec81878ddf0e98817def1e77d4f50dae5ef5b0e4fe796fae3bd674304172416e"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9281bf5b34f59afbc6b1e477a372e9526b66ca446f4bf62592839c195a718b32"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:68af405971779d8b37198726f2b6fe3955db846fee42db7a4286fc542203934c"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3ba3ef510467abb0667421a286dc906e30eb08569365f5cdb131d7aff7c2dd84"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b61189b29081a20c7e4e0b49b44d5d44bb0dc92be3c6d06a11cc043f81bf9329"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fb287618b9c7aa3bf8d825f02d9201b2f13078a5ed3b293c8f4d953917d84d5e"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:521f33e377ff64b96c4c556b81c55d0cfffb96a11c194fd0c3f1e56f3d8dd5a4"}, + {file = "multidict-6.7.0-cp39-cp39-win32.whl", hash = "sha256:ce8fdc2dca699f8dbf055a61d73eaa10482569ad20ee3c36ef9641f69afa8c91"}, + {file = "multidict-6.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:7e73299c99939f089dd9b2120a04a516b95cdf8c1cd2b18c53ebf0de80b1f18f"}, + {file = "multidict-6.7.0-cp39-cp39-win_arm64.whl", hash = "sha256:6bdce131e14b04fd34a809b6380dbfd826065c3e2fe8a50dbae659fa0c390546"}, + {file = "multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3"}, + {file = "multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "networkx" +version = "3.4.2" +description = "Python package for creating and manipulating graphs and networks" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f"}, + {file = "networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1"}, +] + +[package.extras] +default = ["matplotlib (>=3.7)", "numpy (>=1.24)", "pandas (>=2.0)", "scipy (>=1.10,!=1.11.0,!=1.11.1)"] +developer = ["changelist (==0.5)", "mypy (>=1.1)", "pre-commit (>=3.2)", "rtoml"] +doc = ["intersphinx-registry", "myst-nb (>=1.1)", "numpydoc (>=1.8.0)", "pillow (>=9.4)", "pydata-sphinx-theme (>=0.15)", "sphinx (>=7.3)", "sphinx-gallery (>=0.16)", "texext (>=0.6.7)"] +example = ["cairocffi (>=1.7)", "contextily (>=1.6)", "igraph (>=0.11)", "momepy (>=0.7.2)", "osmnx (>=1.9)", "scikit-learn (>=1.5)", "seaborn (>=0.13)"] +extra = ["lxml (>=4.6)", "pydot (>=3.0.1)", "pygraphviz (>=1.14)", "sympy (>=1.10)"] +test = ["pytest (>=7.2)", "pytest-cov (>=4.0)"] + +[[package]] +name = "numpy" +version = "2.2.6" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf"}, + {file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83"}, + {file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915"}, + {file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680"}, + {file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289"}, + {file = "numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d"}, + {file = "numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491"}, + {file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a"}, + {file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf"}, + {file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1"}, + {file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab"}, + {file = "numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47"}, + {file = "numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282"}, + {file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87"}, + {file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249"}, + {file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49"}, + {file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de"}, + {file = "numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4"}, + {file = "numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566"}, + {file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f"}, + {file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f"}, + {file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868"}, + {file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d"}, + {file = "numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd"}, + {file = "numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8"}, + {file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f"}, + {file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa"}, + {file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571"}, + {file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1"}, + {file = "numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff"}, + {file = "numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00"}, + {file = "numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd"}, +] + +[[package]] +name = "openai" +version = "2.8.1" +description = "The official Python library for the openai API" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "openai-2.8.1-py3-none-any.whl", hash = "sha256:c6c3b5a04994734386e8dad3c00a393f56d3b68a27cd2e8acae91a59e4122463"}, + {file = "openai-2.8.1.tar.gz", hash = "sha256:cb1b79eef6e809f6da326a7ef6038719e35aa944c42d081807bfa1be8060f15f"}, +] + +[package.dependencies] +anyio = ">=3.5.0,<5" +distro = ">=1.7.0,<2" +httpx = ">=0.23.0,<1" +jiter = ">=0.10.0,<1" +pydantic = ">=1.9.0,<3" +sniffio = "*" +tqdm = ">4" +typing-extensions = ">=4.11,<5" + +[package.extras] +aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"] +datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"] +realtime = ["websockets (>=13,<16)"] +voice-helpers = ["numpy (>=2.0.2)", "sounddevice (>=0.5.1)"] + [[package]] name = "packaging" version = "24.1" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "pillow" +version = "11.3.0" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860"}, + {file = "pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae"}, + {file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9"}, + {file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e"}, + {file = "pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6"}, + {file = "pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f"}, + {file = "pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f"}, + {file = "pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722"}, + {file = "pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f"}, + {file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e"}, + {file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94"}, + {file = "pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0"}, + {file = "pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac"}, + {file = "pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd"}, + {file = "pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4"}, + {file = "pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024"}, + {file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809"}, + {file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d"}, + {file = "pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149"}, + {file = "pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d"}, + {file = "pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542"}, + {file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd"}, + {file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8"}, + {file = "pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f"}, + {file = "pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c"}, + {file = "pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8"}, + {file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2"}, + {file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b"}, + {file = "pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3"}, + {file = "pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51"}, + {file = "pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580"}, + {file = "pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e"}, + {file = "pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59"}, + {file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe"}, + {file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c"}, + {file = "pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788"}, + {file = "pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31"}, + {file = "pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e"}, + {file = "pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12"}, + {file = "pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77"}, + {file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874"}, + {file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a"}, + {file = "pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214"}, + {file = "pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635"}, + {file = "pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6"}, + {file = "pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae"}, + {file = "pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477"}, + {file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50"}, + {file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b"}, + {file = "pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12"}, + {file = "pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db"}, + {file = "pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa"}, + {file = "pillow-11.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:48d254f8a4c776de343051023eb61ffe818299eeac478da55227d96e241de53f"}, + {file = "pillow-11.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7aee118e30a4cf54fdd873bd3a29de51e29105ab11f9aad8c32123f58c8f8081"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:23cff760a9049c502721bdb743a7cb3e03365fafcdfc2ef9784610714166e5a4"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6359a3bc43f57d5b375d1ad54a0074318a0844d11b76abccf478c37c986d3cfc"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:092c80c76635f5ecb10f3f83d76716165c96f5229addbd1ec2bdbbda7d496e06"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cadc9e0ea0a2431124cde7e1697106471fc4c1da01530e679b2391c37d3fbb3a"}, + {file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6a418691000f2a418c9135a7cf0d797c1bb7d9a485e61fe8e7722845b95ef978"}, + {file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:97afb3a00b65cc0804d1c7abddbf090a81eaac02768af58cbdcaaa0a931e0b6d"}, + {file = "pillow-11.3.0-cp39-cp39-win32.whl", hash = "sha256:ea944117a7974ae78059fcc1800e5d3295172bb97035c0c1d9345fca1419da71"}, + {file = "pillow-11.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:e5c5858ad8ec655450a7c7df532e9842cf8df7cc349df7225c60d5d348c8aada"}, + {file = "pillow-11.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:6abdbfd3aea42be05702a8dd98832329c167ee84400a1d1f61ab11437f1717eb"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8"}, + {file = "pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +test-arrow = ["pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] +typing = ["typing-extensions ; python_version < \"3.10\""] +xmp = ["defusedxml"] + +[[package]] +name = "platformdirs" +version = "4.5.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"}, + {file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"}, +] + +[package.extras] +docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"] +type = ["mypy (>=1.18.2)"] + +[[package]] +name = "primp" +version = "0.15.0" +description = "HTTP client that can impersonate web browsers, mimicking their headers and `TLS/JA3/JA4/HTTP2` fingerprints" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "primp-0.15.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:1b281f4ca41a0c6612d4c6e68b96e28acfe786d226a427cd944baa8d7acd644f"}, + {file = "primp-0.15.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:489cbab55cd793ceb8f90bb7423c6ea64ebb53208ffcf7a044138e3c66d77299"}, + {file = "primp-0.15.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c18b45c23f94016215f62d2334552224236217aaeb716871ce0e4dcfa08eb161"}, + {file = "primp-0.15.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e985a9cba2e3f96a323722e5440aa9eccaac3178e74b884778e926b5249df080"}, + {file = "primp-0.15.0-cp38-abi3-manylinux_2_34_armv7l.whl", hash = "sha256:6b84a6ffa083e34668ff0037221d399c24d939b5629cd38223af860de9e17a83"}, + {file = "primp-0.15.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:592f6079646bdf5abbbfc3b0a28dac8de943f8907a250ce09398cda5eaebd260"}, + {file = "primp-0.15.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5a728e5a05f37db6189eb413d22c78bd143fa59dd6a8a26dacd43332b3971fe8"}, + {file = "primp-0.15.0-cp38-abi3-win_amd64.whl", hash = "sha256:aeb6bd20b06dfc92cfe4436939c18de88a58c640752cf7f30d9e4ae893cdec32"}, + {file = "primp-0.15.0.tar.gz", hash = "sha256:1af8ea4b15f57571ff7fc5e282a82c5eb69bc695e19b8ddeeda324397965b30a"}, +] + +[package.extras] +dev = ["certifi", "mypy (>=1.14.1)", "pytest (>=8.1.1)", "pytest-asyncio (>=0.25.3)", "ruff (>=0.9.2)", "typing-extensions ; python_full_version < \"3.12.0\""] + +[[package]] +name = "propcache" +version = "0.4.1" +description = "Accelerated property cache" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db"}, + {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8"}, + {file = "propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c"}, + {file = "propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb"}, + {file = "propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37"}, + {file = "propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f"}, + {file = "propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1"}, + {file = "propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6"}, + {file = "propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75"}, + {file = "propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8"}, + {file = "propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db"}, + {file = "propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66"}, + {file = "propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81"}, + {file = "propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e"}, + {file = "propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1"}, + {file = "propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717"}, + {file = "propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37"}, + {file = "propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144"}, + {file = "propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f"}, + {file = "propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153"}, + {file = "propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455"}, + {file = "propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85"}, + {file = "propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1"}, + {file = "propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3d233076ccf9e450c8b3bc6720af226b898ef5d051a2d145f7d765e6e9f9bcff"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:357f5bb5c377a82e105e44bd3d52ba22b616f7b9773714bff93573988ef0a5fb"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cbc3b6dfc728105b2a57c06791eb07a94229202ea75c59db644d7d496b698cac"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:182b51b421f0501952d938dc0b0eb45246a5b5153c50d42b495ad5fb7517c888"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b536b39c5199b96fc6245eb5fb796c497381d3942f169e44e8e392b29c9ebcc"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db65d2af507bbfbdcedb254a11149f894169d90488dd3e7190f7cdcb2d6cd57a"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd2dbc472da1f772a4dae4fa24be938a6c544671a912e30529984dd80400cd88"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:daede9cd44e0f8bdd9e6cc9a607fc81feb80fae7a5fc6cecaff0e0bb32e42d00"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:71b749281b816793678ae7f3d0d84bd36e694953822eaad408d682efc5ca18e0"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:0002004213ee1f36cfb3f9a42b5066100c44276b9b72b4e1504cddd3d692e86e"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fe49d0a85038f36ba9e3ffafa1103e61170b28e95b16622e11be0a0ea07c6781"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:99d43339c83aaf4d32bda60928231848eee470c6bda8d02599cc4cebe872d183"}, + {file = "propcache-0.4.1-cp39-cp39-win32.whl", hash = "sha256:a129e76735bc792794d5177069691c3217898b9f5cee2b2661471e52ffe13f19"}, + {file = "propcache-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:948dab269721ae9a87fd16c514a0a2c2a1bdb23a9a61b969b0f9d9ee2968546f"}, + {file = "propcache-0.4.1-cp39-cp39-win_arm64.whl", hash = "sha256:5fd37c406dd6dc85aa743e214cef35dc54bbdd1419baac4f6ae5e5b1a2976938"}, + {file = "propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237"}, + {file = "propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d"}, +] + [[package]] name = "pydantic" version = "2.9.2" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, @@ -254,7 +1681,7 @@ typing-extensions = {version = ">=4.6.1", markers = "python_version < \"3.13\""} [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] +timezone = ["tzdata ; python_version >= \"3.9\" and sys_platform == \"win32\""] [[package]] name = "pydantic-core" @@ -262,6 +1689,7 @@ version = "2.23.4" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, @@ -357,12 +1785,134 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pyqt6" +version = "6.10.0" +description = "Python bindings for the Qt cross platform application toolkit" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyqt6-6.10.0-1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:54b6b022369e4e6ade8cf79c0f988558839df7b2c285f814b4567d15a0fcb756"}, + {file = "pyqt6-6.10.0-cp39-abi3-macosx_10_14_universal2.whl", hash = "sha256:0eb82f152a83a8ae39f7d3ba580829ff7c0e8179d19d70f396853c10c8ddc5ac"}, + {file = "pyqt6-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:43e94a0ad4713055b47b4676d23432349845729912e4f3d20ac95935931c5e6f"}, + {file = "pyqt6-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:357da0f1465557dde249a31bc1f152320b7628a644e1d55d2db09b635394f39f"}, + {file = "pyqt6-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:8b5e4ea573733017a76bd12ea1b53351fd7f6dc57f8abf4329c4a41fea6dde04"}, + {file = "pyqt6-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:c2b5fc1a028e95b096f3a5966611cc8194e8e9e69984c41477417e18b5ce1362"}, + {file = "pyqt6-6.10.0.tar.gz", hash = "sha256:710ecfd720d9a03b2c684881ae37f528e11d17e8f1bf96431d00a6a73f308e36"}, +] + +[package.dependencies] +PyQt6-Qt6 = ">=6.10.0,<6.11.0" +PyQt6-sip = ">=13.8,<14" + +[[package]] +name = "pyqt6-qt6" +version = "6.10.1" +description = "The subset of a Qt installation needed by PyQt6." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pyqt6_qt6-6.10.1-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:4bb2798a95f624b462b70c4f185422235b714b01e55abab32af1740f147948e2"}, + {file = "pyqt6_qt6-6.10.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0921cc522512cb40dbab673806bc1676924819550e0aec8e3f3fe6907387c5b7"}, + {file = "pyqt6_qt6-6.10.1-py3-none-manylinux_2_34_x86_64.whl", hash = "sha256:04069aea421703b1269c8a1bcf017e36463af284a044239a4ebda3bde0a629fb"}, + {file = "pyqt6_qt6-6.10.1-py3-none-manylinux_2_39_aarch64.whl", hash = "sha256:5b9be39e0120e32d0b42cdb844e3ae110ddadd39629c991e511902c06f155aff"}, + {file = "pyqt6_qt6-6.10.1-py3-none-win_amd64.whl", hash = "sha256:df564d3dc2863b1fde22b39bea9f56ceb2a3ed7d6f0b76d3f96c2d3bc5d71516"}, + {file = "pyqt6_qt6-6.10.1-py3-none-win_arm64.whl", hash = "sha256:48282e0f99682daf4f1e220cfe9f41255e003af38f7728a30d40c76e55c89816"}, +] + +[[package]] +name = "pyqt6-sip" +version = "13.10.2" +description = "The sip module support for PyQt6" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyqt6_sip-13.10.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8132ec1cbbecc69d23dcff23916ec07218f1a9bbbc243bf6f1df967117ce303e"}, + {file = "pyqt6_sip-13.10.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07f77e89d93747dda71b60c3490b00d754451729fbcbcec840e42084bf061655"}, + {file = "pyqt6_sip-13.10.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4ffa71ddff6ef031d52cd4f88b8bba08b3516313c023c7e5825cf4a0ba598712"}, + {file = "pyqt6_sip-13.10.2-cp310-cp310-win_amd64.whl", hash = "sha256:e907394795e61f1174134465c889177f584336a98d7a10beade2437bf5942244"}, + {file = "pyqt6_sip-13.10.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1a6c2f168773af9e6c7ef5e52907f16297d4efd346e4c958eda54ea9135be18e"}, + {file = "pyqt6_sip-13.10.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1d3cc9015a1bd8c8d3e86a009591e897d4d46b0c514aede7d2970a2208749cd"}, + {file = "pyqt6_sip-13.10.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ddd578a8d975bfb5fef83751829bf09a97a1355fa1de098e4fb4d1b74ee872fc"}, + {file = "pyqt6_sip-13.10.2-cp311-cp311-win_amd64.whl", hash = "sha256:061d4a2eb60a603d8be7db6c7f27eb29d9cea97a09aa4533edc1662091ce4f03"}, + {file = "pyqt6_sip-13.10.2-cp311-cp311-win_arm64.whl", hash = "sha256:45ac06f0380b7aa4fcffd89f9e8c00d1b575dc700c603446a9774fda2dcfc0de"}, + {file = "pyqt6_sip-13.10.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:83e6a56d3e715f748557460600ec342cbd77af89ec89c4f2a68b185fa14ea46c"}, + {file = "pyqt6_sip-13.10.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ccf197f8fa410e076936bee28ad9abadb450931d5be5625446fd20e0d8b27a6"}, + {file = "pyqt6_sip-13.10.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:37af463dcce39285e686d49523d376994d8a2508b9acccb7616c4b117c9c4ed7"}, + {file = "pyqt6_sip-13.10.2-cp312-cp312-win_amd64.whl", hash = "sha256:c7b34a495b92790c70eae690d9e816b53d3b625b45eeed6ae2c0fe24075a237e"}, + {file = "pyqt6_sip-13.10.2-cp312-cp312-win_arm64.whl", hash = "sha256:c80cc059d772c632f5319632f183e7578cd0976b9498682833035b18a3483e92"}, + {file = "pyqt6_sip-13.10.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8b5d06a0eac36038fa8734657d99b5fe92263ae7a0cd0a67be6acfe220a063e1"}, + {file = "pyqt6_sip-13.10.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad376a6078da37b049fdf9d6637d71b52727e65c4496a80b753ddc8d27526aca"}, + {file = "pyqt6_sip-13.10.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:3dde8024d055f496eba7d44061c5a1ba4eb72fc95e5a9d7a0dbc908317e0888b"}, + {file = "pyqt6_sip-13.10.2-cp313-cp313-win_amd64.whl", hash = "sha256:0b097eb58b4df936c4a2a88a2f367c8bb5c20ff049a45a7917ad75d698e3b277"}, + {file = "pyqt6_sip-13.10.2-cp313-cp313-win_arm64.whl", hash = "sha256:cc6a1dfdf324efaac6e7b890a608385205e652845c62130de919fd73a6326244"}, + {file = "pyqt6_sip-13.10.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8a76a06a8e5c5b1f17a3f6f3c834ca324877e07b960b18b8b9bbfd9c536ec658"}, + {file = "pyqt6_sip-13.10.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9128d770a611200529468397d710bc972f1dcfe12bfcbb09a3ccddcd4d54fa5b"}, + {file = "pyqt6_sip-13.10.2-cp314-cp314-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:d820a0fae7315932c08f27dc0a7e33e0f50fe351001601a8eb9cf6f22b04562e"}, + {file = "pyqt6_sip-13.10.2-cp314-cp314-win_amd64.whl", hash = "sha256:3213bb6e102d3842a3bb7e59d5f6e55f176c80880ff0b39d0dac0cfe58313fb3"}, + {file = "pyqt6_sip-13.10.2-cp314-cp314-win_arm64.whl", hash = "sha256:ce33ff1f94960ad4b08035e39fa0c3c9a67070bec39ffe3e435c792721504726"}, + {file = "pyqt6_sip-13.10.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38b5823dca93377f8a4efac3cbfaa1d20229aa5b640c31cf6ebbe5c586333808"}, + {file = "pyqt6_sip-13.10.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5506b9a795098df3b023cc7d0a37f93d3224a9c040c43804d4bc06e0b2b742b0"}, + {file = "pyqt6_sip-13.10.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e455a181d45a28ee8d18d42243d4f470d269e6ccdee60f2546e6e71218e05bb4"}, + {file = "pyqt6_sip-13.10.2-cp39-cp39-win_amd64.whl", hash = "sha256:9c67ed66e21b11e04ffabe0d93bc21df22e0a5d7e2e10ebc8c1d77d2f5042991"}, + {file = "pyqt6_sip-13.10.2.tar.gz", hash = "sha256:464ad156bf526500ce6bd05cac7a82280af6309974d816739b4a9a627156fafe"}, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"}, + {file = "python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "pytokens" +version = "0.3.0" +description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3"}, + {file = "pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a"}, +] + +[package.extras] +dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] + [[package]] name = "replicate" version = "1.0.2" description = "Python client for Replicate" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "replicate-1.0.2-py3-none-any.whl", hash = "sha256:808009853d5e49f706c9a67a9f97b712c89e2c75e63947f7cc012b847c28bef2"}, {file = "replicate-1.0.2.tar.gz", hash = "sha256:faa3551a825d9eb2c0bfc0407ddab75b9e02865344168241a1d41a4e73bbc661"}, @@ -376,18 +1926,19 @@ typing-extensions = ">=4.5.0" [[package]] name = "requests" -version = "2.32.3" +version = "2.32.4" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, + {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, + {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, ] [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" +charset_normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.21.1,<3" @@ -395,23 +1946,194 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "rich" +version = "14.2.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"}, + {file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + [[package]] name = "sniffio" version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] +[[package]] +name = "tabulate" +version = "0.9.0" +description = "Pretty-print tabular data" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f"}, + {file = "tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c"}, +] + +[package.extras] +widechars = ["wcwidth"] + +[[package]] +name = "together" +version = "1.5.31" +description = "Python client for Together's Cloud Platform!" +optional = false +python-versions = "<4.0,>=3.10" +groups = ["main"] +files = [ + {file = "together-1.5.31-py3-none-any.whl", hash = "sha256:cf522f39583a22876f4828099bb2e5f5ef9d3305bad80210138f9470a9373d9c"}, + {file = "together-1.5.31.tar.gz", hash = "sha256:22ad70bc7cbc3b6886249eaa13ebf5005e0d1648ca5e8db35fe57e516d651cd7"}, +] + +[package.dependencies] +aiohttp = ">=3.9.3,<4.0.0" +black = ">=25.9.0,<26.0.0" +click = ">=8.1.7,<9.0.0" +eval-type-backport = ">=0.1.3,<0.3.0" +filelock = ">=3.13.1,<4.0.0" +numpy = {version = ">=1.23.5", markers = "python_version < \"3.12\""} +pillow = ">=11.1.0,<12.0.0" +pydantic = ">=2.6.3,<3.0.0" +requests = ">=2.31.0,<3.0.0" +rich = ">=13.8.1,<15.0.0" +tabulate = ">=0.9.0,<0.10.0" +tqdm = ">=4.66.2,<5.0.0" +typer = ">=0.9,<0.20" + +[package.extras] +pyarrow = ["pyarrow (>=10.0.1)"] + +[[package]] +name = "tomli" +version = "2.3.0" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version < \"3.11\"" +files = [ + {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, + {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, + {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, + {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, + {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, + {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, + {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, + {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, + {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, + {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, + {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, + {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, + {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, + {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +description = "Fast, Extensible Progress Meter" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, + {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["nbval", "pytest (>=6)", "pytest-asyncio (>=0.24)", "pytest-cov", "pytest-timeout"] +discord = ["requests"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + +[[package]] +name = "typer" +version = "0.19.2" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "typer-0.19.2-py3-none-any.whl", hash = "sha256:755e7e19670ffad8283db353267cb81ef252f595aa6834a0d1ca9312d9326cb9"}, + {file = "typer-0.19.2.tar.gz", hash = "sha256:9ad824308ded0ad06cc716434705f691d4ee0bfd0fb081839d2e426860e7fdca"}, +] + +[package.dependencies] +click = ">=8.0.0" +rich = ">=10.11.0" +shellingham = ">=1.3.0" +typing-extensions = ">=3.7.4.3" + [[package]] name = "typing-extensions" version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, @@ -419,22 +2141,168 @@ files = [ [[package]] name = "urllib3" -version = "2.2.2" +version = "2.6.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, - {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, + {file = "urllib3-2.6.0-py3-none-any.whl", hash = "sha256:c90f7a39f716c572c4e3e58509581ebd83f9b59cced005b7db7ad2d22b0db99f"}, + {file = "urllib3-2.6.0.tar.gz", hash = "sha256:cb9bcef5a4b345d5da5d145dc3e30834f58e8018828cbc724d30b4cb7d4d49f1"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[[package]] +name = "yarl" +version = "1.22.0" +description = "Yet another URL library" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e"}, + {file = "yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f"}, + {file = "yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467"}, + {file = "yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea"}, + {file = "yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca"}, + {file = "yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b"}, + {file = "yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511"}, + {file = "yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6"}, + {file = "yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e"}, + {file = "yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca"}, + {file = "yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b"}, + {file = "yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376"}, + {file = "yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f"}, + {file = "yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2"}, + {file = "yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520"}, + {file = "yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8"}, + {file = "yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c"}, + {file = "yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74"}, + {file = "yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53"}, + {file = "yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a"}, + {file = "yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67"}, + {file = "yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95"}, + {file = "yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d"}, + {file = "yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b"}, + {file = "yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10"}, + {file = "yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3"}, + {file = "yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62"}, + {file = "yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03"}, + {file = "yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249"}, + {file = "yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b"}, + {file = "yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4"}, + {file = "yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683"}, + {file = "yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da"}, + {file = "yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2"}, + {file = "yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79"}, + {file = "yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33"}, + {file = "yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1"}, + {file = "yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca"}, + {file = "yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c"}, + {file = "yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e"}, + {file = "yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27"}, + {file = "yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1"}, + {file = "yarl-1.22.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3aa27acb6de7a23785d81557577491f6c38a5209a254d1191519d07d8fe51748"}, + {file = "yarl-1.22.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:af74f05666a5e531289cb1cc9c883d1de2088b8e5b4de48004e5ca8a830ac859"}, + {file = "yarl-1.22.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:62441e55958977b8167b2709c164c91a6363e25da322d87ae6dd9c6019ceecf9"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b580e71cac3f8113d3135888770903eaf2f507e9421e5697d6ee6d8cd1c7f054"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e81fda2fb4a07eda1a2252b216aa0df23ebcd4d584894e9612e80999a78fd95b"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99b6fc1d55782461b78221e95fc357b47ad98b041e8e20f47c1411d0aacddc60"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:088e4e08f033db4be2ccd1f34cf29fe994772fb54cfe004bbf54db320af56890"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4e1f6f0b4da23e61188676e3ed027ef0baa833a2e633c29ff8530800edccba"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:84fc3ec96fce86ce5aa305eb4aa9358279d1aa644b71fab7b8ed33fe3ba1a7ca"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5dbeefd6ca588b33576a01b0ad58aa934bc1b41ef89dee505bf2932b22ddffba"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14291620375b1060613f4aab9ebf21850058b6b1b438f386cc814813d901c60b"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a4fcfc8eb2c34148c118dfa02e6427ca278bfd0f3df7c5f99e33d2c0e81eae3e"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:029866bde8d7b0878b9c160e72305bbf0a7342bcd20b9999381704ae03308dc8"}, + {file = "yarl-1.22.0-cp39-cp39-win32.whl", hash = "sha256:4dcc74149ccc8bba31ce1944acee24813e93cfdee2acda3c172df844948ddf7b"}, + {file = "yarl-1.22.0-cp39-cp39-win_amd64.whl", hash = "sha256:10619d9fdee46d20edc49d3479e2f8269d0779f1b031e6f7c2aa1c76be04b7ed"}, + {file = "yarl-1.22.0-cp39-cp39-win_arm64.whl", hash = "sha256:dd7afd3f8b0bfb4e0d9fc3c31bfe8a4ec7debe124cfd90619305def3c8ca8cd2"}, + {file = "yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff"}, + {file = "yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" +propcache = ">=0.2.1" [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = ">=3.10.0,<3.12" -content-hash = "3e72b2d094010386fa5fcf555dce38afaf78c71b086a23bec0ba978d018f7a17" +content-hash = "7c7ac24ae226be789cb3f68667e1fc5d792e33746940032bc60ae9433e4766a9" diff --git a/pyproject.toml b/pyproject.toml index 6a4bcd7..ce9a6b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = ["Your Name "] [tool.poetry.dependencies] python = ">=3.10.0,<3.12" -requests = "^2.32.3" +requests = "^2.32.4" replicate = "^1.0.2" python-dotenv = "^1.0.0" Pillow = "^11.1.0" diff --git a/run.py b/run.py new file mode 100644 index 0000000..14ad165 --- /dev/null +++ b/run.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +import sys +import os + +# Add the project root directory to the python path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from src.main import main + +if __name__ == "__main__": + main() diff --git a/shared_utils.py b/shared_utils.py deleted file mode 100644 index e96a31f..0000000 --- a/shared_utils.py +++ /dev/null @@ -1,1229 +0,0 @@ -# shared_utils.py - -import requests -import logging -import replicate -import openai -import time -import json -import os -from datetime import datetime -from pathlib import Path -from dotenv import load_dotenv -from anthropic import Anthropic -import base64 -from together import Together -from openai import OpenAI -import re -try: - from bs4 import BeautifulSoup -except ImportError: - print("BeautifulSoup not found. Please install it with 'pip install beautifulsoup4'") - -try: - from ddgs import DDGS -except ImportError: - DDGS = None - print("ddgs not found. Install with: pip install ddgs") - -# Load environment variables -load_dotenv() - -# Initialize Anthropic client with API key -anthropic = Anthropic(api_key=os.getenv('ANTHROPIC_API_KEY')) - -# Initialize OpenAI client -openai_client = OpenAI(api_key=os.getenv('OPENAI_API_KEY')) - -def call_claude_api(prompt, messages, model_id, system_prompt=None, stream_callback=None, temperature=1.0): - """Call the Claude API with the given messages and prompt - - Args: - stream_callback: Optional function(chunk: str) to call with each streaming token - temperature: Sampling temperature (0-2, default 1.0) - """ - api_key = os.getenv("ANTHROPIC_API_KEY") - if not api_key: - return "Error: ANTHROPIC_API_KEY not found in environment variables" - - url = "https://api.anthropic.com/v1/messages" - - # Ensure we have a system prompt - payload = { - "model": model_id, - "max_tokens": 4000, - "temperature": temperature, - "stream": stream_callback is not None # Enable streaming if callback provided - } - - # Set system if provided - if system_prompt: - payload["system"] = system_prompt - print(f"CLAUDE API USING SYSTEM PROMPT: {system_prompt}") - - print(f"CLAUDE API USING TEMPERATURE: {temperature}") - - # Clean messages to remove duplicates - filtered_messages = [] - seen_contents = set() - - for msg in messages: - # Skip system messages (handled separately) - if msg.get("role") == "system": - continue - - # Get content - handle both string and list formats - content = msg.get("content", "") - - # For duplicate detection, use a hashable representation (always a string) - if isinstance(content, list): - # For image messages, create a hash based on text content only - text_parts = [part.get('text', '') for part in content if part.get('type') == 'text'] - content_hash = ''.join(text_parts) - elif isinstance(content, str): - content_hash = content - else: - # For any other type, convert to string - content_hash = str(content) if content else "" - - # Check for duplicates - if content_hash and content_hash in seen_contents: - print(f"Skipping duplicate message in API call: {str(content_hash)[:30]}...") - continue - - if content_hash: - seen_contents.add(content_hash) - filtered_messages.append(msg) - - # Add the current prompt as the final user message (if it's not already an image message) - if prompt and not any(isinstance(msg.get("content"), list) for msg in filtered_messages[-1:]): - filtered_messages.append({ - "role": "user", - "content": prompt - }) - - # Add filtered messages to payload - payload["messages"] = filtered_messages - - # Actual API call - headers = { - "Content-Type": "application/json", - "x-api-key": api_key, - "anthropic-version": "2023-06-01" - } - - try: - if stream_callback: - # Streaming mode using REST API directly - payload["stream"] = True - full_response = "" - - response = requests.post(url, json=payload, headers=headers, stream=True) - - if response.status_code == 200: - for line in response.iter_lines(): - if line: - line_text = line.decode('utf-8') - if line_text.startswith('data: '): - json_str = line_text[6:] # Remove 'data: ' prefix - # Skip if this is a ping or message_stop event - if json_str.strip() in ['[DONE]', '']: - continue - try: - chunk_data = json.loads(json_str) - # Handle different event types from Claude's SSE stream - event_type = chunk_data.get('type') - - if event_type == 'content_block_delta': - delta = chunk_data.get('delta', {}) - if delta.get('type') == 'text_delta': - text = delta.get('text', '') - if text: - full_response += text - stream_callback(text) - except json.JSONDecodeError: - continue - return full_response - else: - return f"Error: API returned status {response.status_code}: {response.text}" - else: - # Non-streaming mode (original behavior) - response = requests.post(url, json=payload, headers=headers) - response.raise_for_status() - data = response.json() - if 'content' in data and len(data['content']) > 0: - for content_item in data['content']: - if content_item.get('type') == 'text': - return content_item.get('text', '') - # Fallback if no text type content is found - return str(data['content']) - return "No content in response" - except Exception as e: - return f"Error calling Claude API: {str(e)}" - -def call_llama_api(prompt, conversation_history, model, system_prompt): - # Only use the last 3 exchanges to prevent context length issues - recent_history = conversation_history[-10:] if len(conversation_history) > 10 else conversation_history - - # Format the conversation history for LLaMA - formatted_history = "" - for message in recent_history: - if message["role"] == "user": - formatted_history += f"Human: {message['content']}\n" - else: - formatted_history += f"Assistant: {message['content']}\n" - formatted_history += f"Human: {prompt}\nAssistant:" - - try: - # Stream the output and collect it piece by piece - response_chunks = [] - for chunk in replicate.run( - model, - input={ - "prompt": formatted_history, - "system_prompt": system_prompt, - "max_tokens": 3000, - "temperature": 1.1, - "top_p": 0.99, - "repetition_penalty": 1.0 - }, - stream=True # Enable streaming - ): - if chunk is not None: - response_chunks.append(chunk) - # Print each chunk as it arrives - # print(chunk, end='', flush=True) - - # Join all chunks for the final response - response = ''.join(response_chunks) - return response - except Exception as e: - print(f"Error calling LLaMA API: {e}") - return None - -def call_openai_api(prompt, conversation_history, model, system_prompt): - try: - messages = [] - - if system_prompt: - messages.append({"role": "system", "content": system_prompt}) - - for msg in conversation_history: - messages.append({"role": msg["role"], "content": msg["content"]}) - - messages.append({"role": "user", "content": prompt}) - - response = openai.chat.completions.create( - model=model, - messages=messages, - # Increase max_tokens and add n parameter - max_tokens=4000, - n=1, - temperature=1, - stream=True - ) - - collected_messages = [] - for chunk in response: - if chunk.choices[0].delta.content is not None: # Changed condition - collected_messages.append(chunk.choices[0].delta.content) - - full_reply = ''.join(collected_messages) - return full_reply - - except Exception as e: - print(f"Error calling OpenAI API: {e}") - return None - -def call_openrouter_api(prompt, conversation_history, model, system_prompt, stream_callback=None, temperature=1.0): - """Call the OpenRouter API to access various LLM models. - - Args: - stream_callback: Optional function(chunk: str) to call with each streaming token - temperature: Sampling temperature (0-2, default 1.0) - """ - try: - headers = { - "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}", - "HTTP-Referer": "http://localhost:3000", - "Content-Type": "application/json", - "X-Title": "AI Conversation" # Adding title for OpenRouter tracking - } - - # Normalize model ID for OpenRouter - add provider prefix if missing - openrouter_model = model - if model.startswith("claude-") and not model.startswith("anthropic/"): - openrouter_model = f"anthropic/{model}" - print(f"Normalized Claude model ID for OpenRouter: {model} -> {openrouter_model}") - - # Format messages - need to handle structured content with images - messages = [] - if system_prompt: - messages.append({"role": "system", "content": system_prompt}) - - def convert_to_openai_format(content, include_images=True): - """Convert Anthropic-style image format to OpenAI/OpenRouter format. - - Args: - content: The message content (string or list) - include_images: If False, strip image content and keep only text - """ - if not isinstance(content, list): - return content - - converted = [] - for part in content: - if part.get('type') == 'text': - converted.append({"type": "text", "text": part.get('text', '')}) - elif part.get('type') == 'image': - if include_images: - # Convert Anthropic format to OpenAI format - source = part.get('source', {}) - if source.get('type') == 'base64': - media_type = source.get('media_type', 'image/png') - data = source.get('data', '') - converted.append({ - "type": "image_url", - "image_url": { - "url": f"data:{media_type};base64,{data}" - } - }) - # If not including images, we skip this part (text description is already there) - elif part.get('type') == 'image_url': - if include_images: - # Already in OpenAI format - converted.append(part) - else: - # Pass through unknown types - converted.append(part) - - # If we stripped images and only have one text element, simplify to string - if not include_images and len(converted) == 1 and converted[0].get('type') == 'text': - return converted[0]['text'] - elif not include_images and len(converted) == 0: - return "" - - return converted - - def build_messages(include_images=True, max_images=5): - """Build the messages list, optionally stripping images. - - Args: - include_images: If False, strip ALL images - max_images: Maximum number of images to include (from most recent). - Older images are stripped but text is preserved. - """ - msgs = [] - if system_prompt: - msgs.append({"role": "system", "content": system_prompt}) - - if include_images and max_images > 0: - # First pass: identify which messages have images (by index) - image_message_indices = [] - for i, msg in enumerate(conversation_history): - content = msg.get("content", "") - if isinstance(content, list): - has_image = any( - part.get('type') in ('image', 'image_url') - for part in content if isinstance(part, dict) - ) - if has_image: - image_message_indices.append(i) - - # Determine which indices should keep their images (last N) - indices_to_keep_images = set(image_message_indices[-max_images:]) if image_message_indices else set() - - if len(image_message_indices) > max_images: - stripped_count = len(image_message_indices) - max_images - print(f"[Context] Stripping {stripped_count} older images, keeping last {max_images}") - - # Build messages with selective image inclusion - for i, msg in enumerate(conversation_history): - if msg["role"] != "system": - keep_images = i in indices_to_keep_images - msgs.append({ - "role": msg["role"], - "content": convert_to_openai_format(msg["content"], include_images=keep_images) - }) - else: - # No images mode - strip all - for msg in conversation_history: - if msg["role"] != "system": - msgs.append({ - "role": msg["role"], - "content": convert_to_openai_format(msg["content"], include_images=False) - }) - - # Also convert the prompt if it's structured content (always include images in current prompt) - msgs.append({"role": "user", "content": convert_to_openai_format(prompt, include_images)}) - return msgs - - def make_api_call(include_images=True, max_images=5): - """Make the API call, returns (success, result_or_error)""" - msgs = build_messages(include_images=include_images, max_images=max_images) - - payload = { - "model": openrouter_model, - "messages": msgs, - "temperature": temperature, # Use AI's custom temperature - "max_tokens": 4000, - "stream": stream_callback is not None - } - - print(f"\nSending to OpenRouter:") - print(f"Model: {model}") - print(f"Temperature: {temperature}") - print(f"Include images: {include_images}") - # Log message summary (avoid huge base64 dumps) - for i, m in enumerate(msgs): - content = m.get('content', '') - if isinstance(content, list): - parts_summary = [p.get('type', 'unknown') for p in content] - print(f" [{i}] {m.get('role')}: [structured: {parts_summary}]") - else: - preview = str(content)[:80] + "..." if len(str(content)) > 80 else content - print(f" [{i}] {m.get('role')}: {preview}") - - if stream_callback: - # Streaming mode - response = requests.post( - "https://openrouter.ai/api/v1/chat/completions", - headers=headers, - json=payload, - timeout=180, - stream=True - ) - - print(f"Response status: {response.status_code}") - - if response.status_code == 200: - full_response = "" - chunk_count = 0 - last_finish_reason = None - debug_chunks = [] # Store first few chunks for debugging - for line in response.iter_lines(): - if line: - line_text = line.decode('utf-8') - if line_text.startswith('data: '): - json_str = line_text[6:] - if json_str.strip() == '[DONE]': - break - try: - chunk_data = json.loads(json_str) - # Store first 5 chunks for debugging - if len(debug_chunks) < 5: - debug_chunks.append(chunk_data) - if 'choices' in chunk_data and len(chunk_data['choices']) > 0: - choice = chunk_data['choices'][0] - delta = choice.get('delta', {}) - content = delta.get('content', '') - last_finish_reason = choice.get('finish_reason') - if content: - full_response += content - stream_callback(content) - chunk_count += 1 - except json.JSONDecodeError: - continue - # Log if response is empty - if not full_response or not full_response.strip(): - print(f"[OpenRouter STREAM] Empty response from {model}", flush=True) - print(f"[OpenRouter STREAM] Chunks received: {chunk_count}", flush=True) - print(f"[OpenRouter STREAM] Last finish_reason: {last_finish_reason}", flush=True) - print(f"[OpenRouter STREAM] Response repr: {repr(full_response)}", flush=True) - # Print the actual chunk data for debugging - for i, chunk in enumerate(debug_chunks): - print(f"[OpenRouter STREAM] Chunk {i}: {json.dumps(chunk)[:300]}", flush=True) - return True, full_response - else: - return False, (response.status_code, response.text) - else: - # Non-streaming mode - response = requests.post( - "https://openrouter.ai/api/v1/chat/completions", - headers=headers, - json=payload, - timeout=60 - ) - - print(f"Response status: {response.status_code}") - - if response.status_code == 200: - response_data = response.json() - # Debug: log full response structure for empty responses - if 'choices' in response_data and len(response_data['choices']) > 0: - choice = response_data['choices'][0] - message = choice.get('message', {}) - content = message.get('content', '') if message else '' - if content and content.strip(): - return True, content - else: - # Log detailed info about empty response (avoiding base64) - import sys - print(f"[OpenRouter] Empty content from model: {model}", flush=True) - print(f"[OpenRouter] Choice keys: {list(choice.keys())}", flush=True) - print(f"[OpenRouter] Message keys: {list(message.keys()) if message else 'None'}", flush=True) - print(f"[OpenRouter] Finish reason: {choice.get('finish_reason', 'unknown')}", flush=True) - print(f"[OpenRouter] Content type: {type(content).__name__}, len: {len(content) if content else 0}", flush=True) - print(f"[OpenRouter] Content repr: {repr(content)}", flush=True) - # Check for refusal or other indicators - if message.get('refusal'): - print(f"[OpenRouter] Refusal: {message.get('refusal')}", flush=True) - # Check for tool_calls that might indicate the model is doing something else - if message.get('tool_calls'): - print(f"[OpenRouter] Tool calls: {len(message.get('tool_calls'))} call(s)", flush=True) - sys.stdout.flush() - return True, None - else: - print(f"[OpenRouter] No choices in response. Keys: {list(response_data.keys()) if isinstance(response_data, dict) else 'non-dict'}") - return True, None - else: - return False, (response.status_code, response.text) - - # Try with images first - success, result = make_api_call(include_images=True) - print(f"[OpenRouter] First call result - success: {success}, result type: {type(result).__name__}, result: {repr(result)[:100] if result else 'None'}", flush=True) - - if success: - # Check for empty response and retry once - if result is None or (isinstance(result, str) and not result.strip()): - print(f"[OpenRouter] WARNING: Model {model} returned empty response, retrying...", flush=True) - import time - time.sleep(1) - success, result = make_api_call(include_images=True) - print(f"[OpenRouter] Retry result - success: {success}, result type: {type(result).__name__}, result: {repr(result)[:100] if result else 'None'}", flush=True) - if success and result and (not isinstance(result, str) or result.strip()): - return result - print(f"[OpenRouter] WARNING: Model {model} returned empty response again after retry", flush=True) - return "[Model returned empty response - it may be experiencing issues]" - return result - - # Check if error is due to model not supporting images - status_code, error_text = result - if status_code == 404 and "support image" in error_text.lower(): - print(f"[OpenRouter] Model {model} doesn't support images, retrying without images...") - success, result = make_api_call(include_images=False) - if success: - return result - status_code, error_text = result - - # Handle other errors - error_msg = f"OpenRouter API error {status_code}: {error_text}" - print(error_msg) - if status_code == 404: - print("Model not found or doesn't support this request type.") - elif status_code == 401: - print("Authentication error. Please check your API key.") - return f"Error: {error_msg}" - - except requests.exceptions.Timeout: - print("Request timed out. The server took too long to respond.") - return "Error: Request timed out" - except requests.exceptions.RequestException as e: - print(f"Network error: {e}") - return f"Error: Network error - {str(e)}" - except Exception as e: - print(f"Error calling OpenRouter API: {e}") - print(f"Error type: {type(e)}") - return f"Error: {str(e)}" - -def call_replicate_api(prompt, conversation_history, model, gui=None): - try: - # Only use the prompt, ignore conversation history - input_params = { - "width": 1024, - "height": 1024, - "prompt": prompt - } - - output = replicate.run( - "black-forest-labs/flux-1.1-pro", - input=input_params - ) - - image_url = str(output) - - # Save the image locally (include microseconds to avoid collisions) - image_dir = Path("images") - image_dir.mkdir(exist_ok=True) - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") - image_path = image_dir / f"generated_{timestamp}.jpg" - - response = requests.get(image_url) - with open(image_path, "wb") as f: - f.write(response.content) - - if gui: - gui.display_image(image_url) - - return { - "role": "assistant", - "content": [ - { - "type": "text", - "text": "I have generated an image based on your prompt." - } - ], - "prompt": prompt, - "image_url": image_url, - "image_path": str(image_path) - } - - except Exception as e: - print(f"Error calling Flux API: {e}") - return None - -def call_deepseek_api(prompt, conversation_history, model, system_prompt, stream_callback=None): - """Call the DeepSeek model through OpenRouter API.""" - try: - import re - from config import SHOW_CHAIN_OF_THOUGHT_IN_CONTEXT - - # Build messages array - messages = [] - if system_prompt: - messages.append({"role": "system", "content": system_prompt}) - - # Add conversation history - for msg in conversation_history: - if isinstance(msg, dict): - role = msg.get("role", "user") - content = msg.get("content", "") - if isinstance(content, str) and content.strip(): - messages.append({"role": role, "content": content}) - - # Add current prompt if provided - if prompt: - messages.append({"role": "user", "content": prompt}) - - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}", - } - - payload = { - "model": "deepseek/deepseek-r1", - "messages": messages, - "max_tokens": 8000, - "temperature": 1, - "stream": stream_callback is not None - } - - print(f"\nSending to DeepSeek via OpenRouter:") - print(f"Model: deepseek/deepseek-r1") - print(f"Messages: {len(messages)} messages") - - if stream_callback: - # Streaming mode - response = requests.post( - "https://openrouter.ai/api/v1/chat/completions", - headers=headers, - json=payload, - timeout=180, - stream=True - ) - - if response.status_code == 200: - full_response = "" - for line in response.iter_lines(): - if line: - line_text = line.decode('utf-8') - if line_text.startswith('data: '): - json_str = line_text[6:] - if json_str.strip() == '[DONE]': - break - try: - chunk_data = json.loads(json_str) - if 'choices' in chunk_data and len(chunk_data['choices']) > 0: - delta = chunk_data['choices'][0].get('delta', {}) - content = delta.get('content', '') - if content: - full_response += content - stream_callback(content) - except json.JSONDecodeError: - continue - response_text = full_response - else: - error_msg = f"OpenRouter API error {response.status_code}: {response.text}" - print(error_msg) - return None - else: - # Non-streaming mode - response = requests.post( - "https://openrouter.ai/api/v1/chat/completions", - headers=headers, - json=payload, - timeout=180 - ) - - if response.status_code == 200: - data = response.json() - response_text = data['choices'][0]['message']['content'] - else: - error_msg = f"OpenRouter API error {response.status_code}: {response.text}" - print(error_msg) - return None - - print(f"\nRaw Response: {response_text[:500]}...") - - # Initialize result with content - result = { - "content": response_text, - "model": "deepseek/deepseek-r1" - } - - # Extract and format chain of thought if enabled - if SHOW_CHAIN_OF_THOUGHT_IN_CONTEXT: - reasoning = None - content = response_text - - if content: - # Try both and tags - think_match = re.search(r'<(think|thinking)>(.*?)', content, re.DOTALL | re.IGNORECASE) - if think_match: - reasoning = think_match.group(2).strip() - content = re.sub(r'<(think|thinking)>.*?', '', content, flags=re.DOTALL | re.IGNORECASE).strip() - - display_text = "" - if reasoning: - display_text += f"[Chain of Thought]\n{reasoning}\n\n" - if content: - display_text += f"[Final Answer]\n{content}" - - result["display"] = display_text - result["content"] = content - else: - # Clean up thinking tags from content - content = response_text - if content: - content = re.sub(r'<(think|thinking)>.*?', '', content, flags=re.DOTALL | re.IGNORECASE).strip() - result["content"] = content - - return result - - except Exception as e: - print(f"Error calling DeepSeek via OpenRouter: {e}") - print(f"Error type: {type(e)}") - return None - -def setup_image_directory(): - """Create an 'images' directory in the project root if it doesn't exist""" - image_dir = Path("images") - image_dir.mkdir(exist_ok=True) - return image_dir - -def cleanup_old_images(image_dir, max_age_hours=24): - """Remove images older than max_age_hours""" - current_time = datetime.now() - for image_file in image_dir.glob("*.jpg"): - file_age = datetime.fromtimestamp(image_file.stat().st_mtime) - if (current_time - file_age).total_seconds() > max_age_hours * 3600: - image_file.unlink() - -def load_ai_memory(ai_number): - """Load AI conversation memory from JSON files""" - try: - memory_path = f"memory/ai{ai_number}/conversations.json" - with open(memory_path, 'r', encoding='utf-8') as f: - conversations = json.load(f) - # Ensure we're working with the array part - if isinstance(conversations, dict) and "memories" in conversations: - conversations = conversations["memories"] - return conversations - except Exception as e: - print(f"Error loading AI{ai_number} memory: {e}") - return [] - -def create_memory_prompt(conversations): - """Convert memory JSON into conversation examples""" - if not conversations: - return "" - - prompt = "Previous conversations that demonstrate your personality:\n\n" - - # Add example conversations - for convo in conversations: - prompt += f"Human: {convo['human']}\n" - prompt += f"Assistant: {convo['assistant']}\n\n" - - prompt += "Maintain this conversation style in your responses." - return prompt - - -def print_conversation_state(conversation): - print("Current conversation state:") - for message in conversation: - content = message.get('content', '') - # Safely preview content - handle both string and list (structured) content - if isinstance(content, str): - preview = content[:50] + "..." if len(content) > 50 else content - else: - preview = f"[structured content with {len(content)} parts]" - print(f"{message['role']}: {preview}") - -def call_claude_vision_api(image_url): - """Have Claude analyze the generated image""" - try: - response = anthropic.messages.create( - model="claude-3-opus-20240229", - max_tokens=1000, - messages=[{ - "role": "user", - "content": [ - { - "type": "text", - "text": "Describe this image in detail. What works well and what could be improved?" - }, - { - "type": "image", - "source": { - "type": "url", - "url": image_url - } - } - ] - }] - ) - return response.content[0].text - except Exception as e: - print(f"Error in vision analysis: {e}") - return None - -def list_together_models(): - try: - headers = { - "Authorization": f"Bearer {os.getenv('TOGETHERAI_API_KEY')}", - "Content-Type": "application/json" - } - - response = requests.get( - "https://api.together.xyz/v1/models", - headers=headers - ) - - print("\nAvailable Together AI Models:") - print(f"Status Code: {response.status_code}") - if response.status_code == 200: - models = response.json() - print(json.dumps(models, indent=2)) - else: - print(f"Error Response: {response.text[:500]}..." if len(response.text) > 500 else f"Error Response: {response.text}") - - except Exception as e: - print(f"Error listing models: {str(e)}") - -def start_together_model(model_id): - try: - headers = { - "Authorization": f"Bearer {os.getenv('TOGETHERAI_API_KEY')}", - "Content-Type": "application/json" - } - - # URL encode the model ID - encoded_model = requests.utils.quote(model_id, safe='') - start_url = f"https://api.together.xyz/v1/models/{encoded_model}/start" - - print(f"\nAttempting to start model: {model_id}") - print(f"Using URL: {start_url}") - response = requests.post( - start_url, - headers=headers - ) - - print(f"Start request status: {response.status_code}") - print(f"Response: {response.text[:200]}..." if len(response.text) > 200 else f"Response: {response.text}") - - if response.status_code == 200: - print("Model start request successful") - return True - else: - print("Failed to start model") - return False - - except Exception as e: - print(f"Error starting model: {str(e)}") - return False - -def call_together_api(prompt, conversation_history, model, system_prompt): - try: - headers = { - "Authorization": f"Bearer {os.getenv('TOGETHERAI_API_KEY')}", - "Content-Type": "application/json" - } - - # Format messages - messages = [] - if system_prompt: - messages.append({"role": "system", "content": system_prompt}) - - for msg in conversation_history: - messages.append({ - "role": msg["role"], - "content": msg["content"] - }) - - messages.append({"role": "user", "content": prompt}) - - payload = { - "model": model, - "messages": messages, - "max_tokens": 500, - "temperature": 0.9, - "top_p": 0.95, - } - - response = requests.post( - "https://api.together.xyz/v1/chat/completions", - headers=headers, - json=payload - ) - - if response.status_code == 200: - response_data = response.json() - return response_data['choices'][0]['message']['content'] - else: - print(f"Together API Error Status: {response.status_code}") - print(f"Response Body: {response.text[:500]}..." if len(response.text) > 500 else f"Response Body: {response.text}") - return None - - except Exception as e: - print(f"Error calling Together API: {str(e)}") - return None - -def read_shared_html(*args, **kwargs): - return "" - -def update_shared_html(*args, **kwargs): - return False - -def open_html_in_browser(file_path="conversation_full.html"): - import webbrowser, os - full_path = os.path.abspath(file_path) - webbrowser.open('file://' + full_path) - -def create_initial_living_document(*args, **kwargs): - return "" - -def read_living_document(*args, **kwargs): - return "" - -def process_living_document_edits(result, model_name): - return result - -def generate_image_from_text(text, model="google/gemini-3-pro-image-preview"): - """Generate an image based on text using OpenRouter's image generation API""" - try: - # Create a directory for the images if it doesn't exist - image_dir = Path("images") - image_dir.mkdir(exist_ok=True) - - # Create a timestamp for the image filename (include microseconds to avoid collisions) - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") - - # Call OpenRouter API for image generation - headers = { - "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}", - "Content-Type": "application/json" - } - - payload = { - "model": model, - "messages": [ - { - "role": "user", - "content": text - } - ], - "modalities": ["image", "text"], - "max_tokens": 1024 # Limit tokens for image generation to avoid credit issues - } - - print(f"Generating image with {model}...") - response = requests.post( - "https://openrouter.ai/api/v1/chat/completions", - headers=headers, - data=json.dumps(payload), - timeout=60 - ) - - if response.status_code == 200: - result = response.json() - - # The generated image will be in the assistant message - if result.get("choices"): - message = result["choices"][0].get("message", {}) - - # Check for images in the message - if message.get("images"): - for image in message["images"]: - image_url = image["image_url"]["url"] # Base64 data URL - print(f"Generated image URL (first 50 chars): {image_url[:50]}...") - - # Handle base64 data URL - if image_url.startswith('data:image'): - try: - # Detect actual image format from data URL header - # Format: data:image/jpeg;base64,... or data:image/png;base64,... - ext = ".jpg" # Default to jpg - if image_url.startswith('data:image/png'): - ext = ".png" - elif image_url.startswith('data:image/gif'): - ext = ".gif" - elif image_url.startswith('data:image/webp'): - ext = ".webp" - - # Extract base64 data after comma - base64_data = image_url.split(',', 1)[1] if ',' in image_url else image_url - - # Decode base64 to image - image_data = base64.b64decode(base64_data) - image_path = image_dir / f"generated_{timestamp}{ext}" - with open(image_path, "wb") as f: - f.write(image_data) - - print(f"Generated image saved to {image_path}") - return { - "success": True, - "image_path": str(image_path), - "timestamp": timestamp - } - except Exception as e: - print(f"Failed to decode base64 image: {e}") - return { - "success": False, - "error": f"Failed to decode image: {e}" - } - else: - # If it's a regular URL, download it - try: - img_response = requests.get(image_url, timeout=30) - if img_response.status_code == 200: - image_path = image_dir / f"generated_{timestamp}.png" - with open(image_path, "wb") as f: - f.write(img_response.content) - - print(f"Generated image saved to {image_path}") - return { - "success": True, - "image_path": str(image_path), - "timestamp": timestamp - } - except Exception as e: - print(f"Failed to download image: {e}") - return { - "success": False, - "error": f"Failed to download image: {e}" - } - - # No images in response - print(f"No images in response. Message keys: {list(message.keys()) if isinstance(message, dict) else 'non-dict'}") - return { - "success": False, - "error": "No images in API response" - } - else: - print(f"No choices in response. Result keys: {list(result.keys()) if isinstance(result, dict) else 'non-dict'}") - return { - "success": False, - "error": "No choices in API response" - } - else: - error_msg = f"API error {response.status_code}: {response.text[:500]}" - print(f"Error generating image: {error_msg}") - return { - "success": False, - "error": error_msg - } - - except Exception as e: - print(f"Error generating image: {e}") - return { - "success": False, - "error": str(e) - } - -# -------------------- Sora Video Utilities -------------------- -def ensure_videos_dir() -> Path: - """Create a 'videos' directory in the project root if it doesn't exist.""" - videos_dir = Path("videos") - videos_dir.mkdir(exist_ok=True) - return videos_dir - -def generate_video_with_sora( - prompt: str, - model: str = "sora-2", - seconds: int | None = None, - size: str | None = None, - poll_interval_seconds: float = 5.0, -) -> dict: - """ - Create a Sora video via REST API, poll until completion, and save MP4 to videos/. - - Returns a dict with keys: success, video_id, status, video_path (when completed), error - """ - try: - api_key = os.getenv('OPENAI_API_KEY') - if not api_key: - return {"success": False, "error": "OPENAI_API_KEY not set"} - - base_url = os.getenv('OPENAI_BASE_URL', 'https://api.openai.com/v1') - verbose = os.getenv('SORA_VERBOSE', '1').strip() == '1' - def vlog(msg: str): - if verbose: - print(msg) - headers_json = { - 'Authorization': f'Bearer {api_key}', - 'Content-Type': 'application/json' - } - - # Start render job - payload = {"model": model, "prompt": prompt} - if seconds is not None: - payload["seconds"] = str(seconds) - if size is not None: - payload["size"] = size - - create_url = f"{base_url}/videos" - vlog(f"[Sora] Create: url={create_url} model={model} seconds={seconds} size={size}") - vlog(f"[Sora] Prompt (truncated): {prompt[:200]}{'...' if len(prompt) > 200 else ''}") - resp = requests.post(create_url, headers=headers_json, json=payload, timeout=60) - if not resp.ok: - err_text = resp.text - try: - err_json = resp.json() - vlog(f"[Sora] Create error JSON: {err_json}") - except Exception: - vlog(f"[Sora] Create error TEXT: {err_text}") - return {"success": False, "error": f"Create failed {resp.status_code}: {err_text}"} - job = resp.json() - video_id = job.get('id') - status = job.get('status') - vlog(f"[Sora] Job started: id={video_id} status={status}") - if not video_id: - return {"success": False, "error": "No video id returned from create()"} - - # Poll until completion/failed - retrieve_url = f"{base_url}/videos/{video_id}" - last_status = status - last_progress = None - while status in ("queued", "in_progress"): - time.sleep(poll_interval_seconds) - r = requests.get(retrieve_url, headers=headers_json, timeout=60) - if not r.ok: - vlog(f"[Sora] Retrieve failed: code={r.status_code} body={r.text}") - return {"success": False, "video_id": video_id, "error": f"Retrieve failed {r.status_code}: {r.text}"} - job = r.json() - status = job.get('status') - progress = job.get('progress') - if status != last_status or progress != last_progress: - vlog(f"[Sora] Status update: status={status} progress={progress}") - last_status = status - last_progress = progress - - if status != "completed": - vlog(f"[Sora] Final non-completed status: {status} job={job}") - return {"success": False, "video_id": video_id, "status": status, "error": f"Final status: {status}"} - - # Download the MP4 - content_url = f"{base_url}/videos/{video_id}/content" - vlog(f"[Sora] Download: url={content_url}") - rc = requests.get(content_url, headers={'Authorization': f'Bearer {api_key}'}, stream=True, timeout=300) - if not rc.ok: - vlog(f"[Sora] Download failed: code={rc.status_code} body={rc.text}") - return {"success": False, "video_id": video_id, "status": status, "error": f"Download failed {rc.status_code}: {rc.text}"} - - videos_dir = ensure_videos_dir() - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") - safe_snippet = re.sub(r"[^a-zA-Z0-9_-]", "_", prompt[:40]) or "video" - out_path = videos_dir / f"{timestamp}_{safe_snippet}.mp4" - with open(out_path, "wb") as f: - for chunk in rc.iter_content(chunk_size=1024 * 1024): - if chunk: - f.write(chunk) - - vlog(f"[Sora] Saved video: {out_path}") - return { - "success": True, - "video_id": video_id, - "status": status, - "video_path": str(out_path) - } - except Exception as e: - logging.exception("Sora video generation error") - return {"success": False, "error": str(e)} - -# -------------------- Web Search Utilities -------------------- -def web_search(query: str, max_results: int = 5) -> dict: - """ - Search the web using DuckDuckGo. - - Args: - query: Search query string - max_results: Maximum number of results to return - - Returns: - dict with keys: success, results (list of {title, url, snippet}), error - """ - if DDGS is None: - return { - "success": False, - "error": "ddgs package not installed. Run: pip install ddgs" - } - - try: - print(f"[WebSearch] Searching for: {query}") - - # Use the new ddgs API - prioritize news for current events queries - ddgs = DDGS() - formatted_results = [] - - # For queries about current events, use news search first - is_news_query = any(term in query.lower() for term in ["news", "today", "latest", "2025", "drama", "announcement", "release"]) - - if is_news_query: - print(f"[WebSearch] Detected news query, searching news first...") - try: - news_results = list(ddgs.news(query, region="wt-wt", safesearch="off", max_results=max_results)) - for r in news_results: - formatted_results.append({ - "title": r.get("title", ""), - "url": r.get("url", r.get("link", "")), - "snippet": r.get("body", r.get("excerpt", "")) - }) - print(f"[WebSearch] Found {len(formatted_results)} news results") - except Exception as e: - print(f"[WebSearch] News search failed: {e}") - - # If we don't have enough results, try text search - if len(formatted_results) < max_results: - remaining = max_results - len(formatted_results) - try: - text_results = list(ddgs.text( - query, - region="us-en", # Force US English results - safesearch="off", - max_results=remaining - )) - for r in text_results: - formatted_results.append({ - "title": r.get("title", ""), - "url": r.get("href", r.get("link", "")), - "snippet": r.get("body", r.get("snippet", "")) - }) - print(f"[WebSearch] Added {len(text_results)} text results, total: {len(formatted_results)}") - except Exception as e: - print(f"[WebSearch] Text search failed: {e}") - - return { - "success": True, - "results": formatted_results, - "query": query - } - except Exception as e: - print(f"[WebSearch] Error: {e}") - import traceback - traceback.print_exc() - return { - "success": False, - "error": str(e) - } - diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/command_parser.py b/src/core/command_parser.py similarity index 100% rename from command_parser.py rename to src/core/command_parser.py diff --git a/config.py b/src/core/config.py similarity index 100% rename from config.py rename to src/core/config.py diff --git a/main.py b/src/core/conversation_manager.py similarity index 61% rename from main.py rename to src/core/conversation_manager.py index 12224f6..d53a40d 100644 --- a/main.py +++ b/src/core/conversation_manager.py @@ -1,59 +1,22 @@ - # main.py - import os import time -import threading import json -import sys -import re -from dotenv import load_dotenv -from PyQt6.QtWidgets import QApplication, QMessageBox -from PyQt6.QtCore import QThread, pyqtSignal, QObject, QRunnable, pyqtSlot, QThreadPool -import requests - -# Load environment variables from .env file -load_dotenv() +import threading +from PyQt6.QtCore import QThreadPool, pyqtSignal, QObject -from config import ( +from src.core.config import ( TURN_DELAY, AI_MODELS, SYSTEM_PROMPT_PAIRS, SHOW_CHAIN_OF_THOUGHT_IN_CONTEXT, SHARE_CHAIN_OF_THOUGHT ) -from shared_utils import ( - call_claude_api, - call_openrouter_api, - call_openai_api, - call_replicate_api, - call_deepseek_api, - open_html_in_browser, - generate_image_from_text, - generate_video_with_sora -) -from gui import LiminalBackroomsApp, load_fonts -from command_parser import parse_commands, AgentCommand, format_command_result - -def is_image_message(message: dict) -> bool: - """Returns True if 'message' contains a base64 image in its 'content' list.""" - if not isinstance(message, dict): - return False - content = message.get('content', []) - if isinstance(content, list): - for part in content: - if part.get('type') == 'image': - return True - return False - -class WorkerSignals(QObject): - """Defines the signals available from a running worker thread""" - finished = pyqtSignal() - error = pyqtSignal(str) - response = pyqtSignal(str, str) - result = pyqtSignal(str, object) # Signal for complete result object - progress = pyqtSignal(str) - streaming_chunk = pyqtSignal(str, str) # Signal for streaming tokens: (ai_name, chunk) - +from src.core.command_parser import parse_commands, AgentCommand +from src.core.worker import Worker, WorkerSignals +from src.services.media_service import generate_image_from_text, generate_video_with_sora +from src.services.html_generator import update_conversation_html +from src.utils.shared_utils import open_html_in_browser, web_search +from src.core.session_manager import SessionManager class ImageUpdateSignals(QObject): """Signals for updating UI with generated images from background threads""" @@ -63,590 +26,6 @@ class VideoUpdateSignals(QObject): """Signals for updating UI with generated videos from background threads""" video_ready = pyqtSignal(str, str) # (video_path, prompt) -class Worker(QRunnable): - """Worker thread for processing AI turns using QThreadPool""" - - def __init__(self, ai_name, conversation, model, system_prompt, is_branch=False, branch_id=None, gui=None): - super().__init__() - self.ai_name = ai_name - self.conversation = conversation.copy() # Make a copy to prevent race conditions - self.model = model - self.system_prompt = system_prompt - self.is_branch = is_branch - self.branch_id = branch_id - self.gui = gui - - # Create signals object - self.signals = WorkerSignals() - - @pyqtSlot() - def run(self): - """Process the AI turn when the thread is started""" - print(f"[Worker] >>> Starting run() for {self.ai_name} ({self.model})") - try: - # Emit progress update - self.signals.progress.emit(f"Processing {self.ai_name} turn with {self.model}...") - - # Define streaming callback - def stream_chunk(chunk: str): - self.signals.streaming_chunk.emit(self.ai_name, chunk) - - # Process the turn with streaming - print(f"[Worker] Calling ai_turn for {self.ai_name}...") - result = ai_turn( - self.ai_name, - self.conversation, - self.model, - self.system_prompt, - gui=self.gui, - streaming_callback=stream_chunk - ) - print(f"[Worker] ai_turn completed for {self.ai_name}, result type: {type(result)}") - - # Emit both the text response and the full result object - if isinstance(result, dict): - response_content = result.get('content', '') - print(f"[Worker] Emitting response for {self.ai_name}, content length: {len(response_content) if response_content else 0}") - # Emit the simple text response for backward compatibility - self.signals.response.emit(self.ai_name, response_content) - # Also emit the full result object for HTML contribution processing - self.signals.result.emit(self.ai_name, result) - else: - # Handle simple string responses - print(f"[Worker] Emitting string response for {self.ai_name}") - self.signals.response.emit(self.ai_name, result if result else "") - self.signals.result.emit(self.ai_name, {"content": result, "model": self.model}) - - # Emit finished signal - print(f"[Worker] <<< Finished run() for {self.ai_name}, emitting finished signal") - self.signals.finished.emit() - - except Exception as e: - # Emit error signal - print(f"[Worker] !!! ERROR in run() for {self.ai_name}: {e}") - import traceback - traceback.print_exc() - self.signals.error.emit(str(e)) - # Still emit finished signal even if there's an error - self.signals.finished.emit() - -def ai_turn(ai_name, conversation, model, system_prompt, gui=None, is_branch=False, branch_output=None, streaming_callback=None): - """Execute an AI turn with the given parameters - - Args: - streaming_callback: Optional function(chunk: str) to call with each streaming token - """ - print(f"==================================================") - print(f"Starting {model} turn ({ai_name})...") - print(f"Current conversation length: {len(conversation)}") - - # HTML contributions and living document disabled - enhanced_system_prompt = system_prompt - - # Get the actual model ID from the display name - model_id = AI_MODELS.get(model, model) - - # Prepend model identity to system prompt so AI knows who it is - enhanced_system_prompt = f"You are {ai_name} ({model}).\n\n{enhanced_system_prompt}" - - # Apply any self-added prompt additions for this AI - # Also get custom temperature setting - ai_temperature = 1.0 # Default - if gui and hasattr(gui, 'conversation_manager') and gui.conversation_manager: - prompt_additions = gui.conversation_manager.get_prompt_additions_for_ai(ai_name) - if prompt_additions: - enhanced_system_prompt += prompt_additions - print(f"[Prompt] Applied prompt additions for {ai_name}") - - # Get custom temperature if set - ai_temperature = gui.conversation_manager.get_temperature_for_ai(ai_name) - if ai_temperature != 1.0: - print(f"[Temperature] Using custom temperature {ai_temperature} for {ai_name}") - - # Check for branch type and count AI responses - is_rabbithole = False - is_fork = False - branch_text = "" - ai_response_count = 0 - found_branch_marker = False - latest_branch_marker_index = -1 - - # First find the most recent branch marker - for i, msg in enumerate(conversation): - if isinstance(msg, dict) and msg.get("_type") == "branch_indicator": - latest_branch_marker_index = i - found_branch_marker = True - - # Determine branch type from the latest marker - msg_content = msg.get("content", "") - # Branch indicators are always plain strings - if isinstance(msg_content, str): - if "Rabbitholing down:" in msg_content: - is_rabbithole = True - branch_text = msg_content.split('"')[1] if '"' in msg_content else "" - print(f"Detected rabbithole branch for: '{branch_text}'") - elif "Forking off:" in msg_content: - is_fork = True - branch_text = msg_content.split('"')[1] if '"' in msg_content else "" - print(f"Detected fork branch for: '{branch_text}'") - - # Now count AI responses that occur AFTER the latest branch marker - ai_response_count = 0 - if found_branch_marker: - for i, msg in enumerate(conversation): - if i > latest_branch_marker_index and msg.get("role") == "assistant": - ai_response_count += 1 - print(f"Counting AI responses after latest branch marker: found {ai_response_count} responses") - - # Handle branch-specific system prompts - - # For rabbitholing: override system prompt for first TWO responses - if is_rabbithole and ai_response_count < 2: - print(f"USING RABBITHOLE PROMPT: '{branch_text}' - response #{ai_response_count+1} after branch") - system_prompt = f"'{branch_text}'!!!" - - # For forking: override system prompt ONLY for first response - elif is_fork and ai_response_count == 0: - print(f"USING FORK PROMPT: '{branch_text}' - response #{ai_response_count+1}") - system_prompt = f"The conversation forks from'{branch_text}'. Continue naturally from this point." - - # For all other cases, use the standard system prompt - else: - if is_rabbithole: - print(f"USING STANDARD PROMPT: Past initial rabbithole exploration (responses after branch: {ai_response_count})") - elif is_fork: - print(f"USING STANDARD PROMPT: Past initial fork response (responses after branch: {ai_response_count})") - - # Apply the enhanced system prompt (with HTML contribution instructions) - system_prompt = enhanced_system_prompt - - # CRITICAL: Always ensure we have the system prompt - # No matter what happens with the conversation, we need this - messages = [] - messages.append({ - "role": "system", - "content": system_prompt - }) - - # Filter out any existing system messages that might interfere - filtered_conversation = [] - for msg in conversation: - if not isinstance(msg, dict): - # Convert plain text to dictionary - msg = {"role": "user", "content": str(msg)} - - # Skip any hidden "connecting..." messages - msg_content = msg.get("content", "") - if msg.get("hidden") and isinstance(msg_content, str) and "connect" in msg_content.lower(): - continue - - # Skip empty messages - content = msg.get("content", "") - if isinstance(content, str): - if not content.strip(): - continue - elif isinstance(content, list): - # For structured content, skip if all parts are empty - if not any(part.get('text', '').strip() if part.get('type') == 'text' else True for part in content): - continue - else: - if not content: - continue - - # Skip system messages (we already added our own above) - if msg.get("role") == "system": - continue - - # Skip special system messages (branch indicators, etc.) - if msg.get("role") == "system" and msg.get("_type"): - continue - - # Skip duplicate messages - check if this exact content exists already - is_duplicate = False - for existing in filtered_conversation: - if existing.get("content") == msg.get("content"): - is_duplicate = True - content = msg.get('content', '') - # Safely preview content - handle both string and list (structured) content - if isinstance(content, str): - preview = content[:30] + "..." if len(content) > 30 else content - else: - preview = f"[structured content with {len(content)} parts]" - print(f"Skipping duplicate message: {preview}") - break - - if not is_duplicate: - filtered_conversation.append(msg) - - # Process filtered conversation - for i, msg in enumerate(filtered_conversation): - # Check if this message is from the current AI - is_from_this_ai = False - if msg.get("ai_name") == ai_name: - is_from_this_ai = True - - # Determine role - if is_from_this_ai: - role = "assistant" - else: - role = "user" - - # Get content - preserve structure for images - content = msg.get("content", "") - - # Inject speaker name for messages from other participants (not from current AI) - if not is_from_this_ai and content: - # Use the model name (e.g., "Claude 4.5 Sonnet") if available, otherwise fall back to ai_name or "User" - speaker_name = msg.get("model") or msg.get("ai_name", "User") - - # Handle different content types - if isinstance(content, str): - # Simple string content - prefix with speaker name - content = f"[{speaker_name}]: {content}" - elif isinstance(content, list): - # Structured content (e.g., with images) - prefix text parts - modified_content = [] - for part in content: - if part.get('type') == 'text': - # Prefix the first text part with speaker name - text = part.get('text', '') - modified_part = part.copy() - modified_part['text'] = f"[{speaker_name}]: {text}" - modified_content.append(modified_part) - # Only prefix the first text part - break - else: - modified_content.append(part) - - # Add remaining parts unchanged - first_text_found = False - for part in content: - if part.get('type') == 'text' and not first_text_found: - first_text_found = True - continue # Skip, already added above - modified_content.append(part) - - content = modified_content if modified_content else content - - # Add to messages - messages.append({ - "role": role, - "content": content # Now includes speaker names for non-current-AI messages - }) - - # For logging, handle both string and structured content - if isinstance(content, list): - print(f"Message {i} - AI: {msg.get('ai_name', 'User')} - Assigned role: {role} - Content: [structured message with {len(content)} parts]") - else: - content_preview = content[:50] + "..." if len(str(content)) > 50 else content - print(f"Message {i} - AI: {msg.get('ai_name', 'User')} - Assigned role: {role} - Preview: {content_preview}") - - # Ensure the last message is a user message so the AI responds - if len(messages) > 1 and messages[-1].get("role") == "assistant": - # Find an appropriate message to use - if is_rabbithole and branch_text: - # Add a special rabbitholing instruction as the last message - messages.append({ - "role": "user", - "content": f"Please explore the concept of '{branch_text}' in depth. What are the most interesting aspects or connections related to this concept?" - }) - elif is_fork and branch_text: - # Add a special forking instruction as the last message - messages.append({ - "role": "user", - "content": f"Continue on naturally from the point about '{branch_text}' without including this text." - }) - else: - # Standard handling for other conversations - # Find the most recent message from the other AI to use as prompt - other_ai_message = None - for msg in reversed(filtered_conversation): - if msg.get("ai_name") != ai_name: - other_ai_message = msg.get("content", "") - break - - if other_ai_message: - messages.append({ - "role": "user", - "content": other_ai_message - }) - else: - # Fallback - only if no other AI message found - messages.append({ - "role": "user", - "content": "Let's continue our conversation." - }) - - # Print the processed messages for debugging - print(f"Sending to {model} ({ai_name}):") - for i, msg in enumerate(messages): - role = msg.get("role", "unknown") - content_raw = msg.get("content", "") - - # Handle both string and list content for logging - if isinstance(content_raw, list): - text_parts = [part.get('text', '') for part in content_raw if part.get('type') == 'text'] - has_image = any(part.get('type') == 'image' for part in content_raw) - content_str = ' '.join(text_parts) - if has_image: - content_str = f"[Image] {content_str}" if content_str else "[Image]" - else: - content_str = str(content_raw) - - # Truncate for display - content = content_str[:50] + "..." if len(content_str) > 50 else content_str - print(f"[{i}] {role}: {content}") - - # Load any available memories for this AI - memories = [] - try: - if os.path.exists(f'memories/{ai_name.lower()}_memories.json'): - with open(f'memories/{ai_name.lower()}_memories.json', 'r') as f: - memories = json.load(f) - print(f"Loaded {len(memories)} memories for {ai_name}") - else: - print(f"Loaded 0 memories for {ai_name}") - except Exception as e: - print(f"Error loading memories: {e}") - print(f"Loaded 0 memories for {ai_name}") - - # Display the final processed messages for debugging (avoid printing base64 images) - print(f"Sending to Claude:") - print(f"Messages: {len(messages)} message(s)") - - # Display the prompt - print(f"--- Prompt to {model} ({ai_name}) ---") - - try: - # Route Sora video models - if model_id in ("sora-2", "sora-2-pro"): - print(f"Using Sora Video API for model: {model_id}") - # Use last user message as the video prompt - prompt_content = "" - if len(messages) > 0: - last_content = messages[-1].get("content", "") - # Extract text from structured content if needed - if isinstance(last_content, list): - text_parts = [part.get('text', '') for part in last_content if part.get('type') == 'text'] - prompt_content = ' '.join(text_parts) - elif isinstance(last_content, str): - prompt_content = last_content - - if not prompt_content or not prompt_content.strip(): - prompt_content = "A short abstract motion graphic in warm colors" - - # Use config values with env var override - from config import SORA_SECONDS, SORA_SIZE - sora_seconds = int(os.getenv("SORA_SECONDS", str(SORA_SECONDS))) - sora_size = os.getenv("SORA_SIZE", SORA_SIZE) or None - - print(f"[Sora] Starting job with seconds={sora_seconds} size={sora_size}") - video_result = generate_video_with_sora( - prompt=prompt_content, - model=model_id, - seconds=sora_seconds, - size=sora_size, - ) - - if video_result.get("success"): - print(f"[Sora] Completed: id={video_result.get('video_id')} path={video_result.get('video_path')}") - # Return a lightweight textual confirmation; video is saved to disk - return { - "role": "assistant", - "content": f"[Sora] Video created: {video_result.get('video_path')}", - "model": model, - "ai_name": ai_name - } - else: - err = video_result.get("error", "unknown error") - print(f"[Sora] Failed: {err}") - return { - "role": "system", - "content": f"[Sora] Video generation failed: {err}", - "model": model, - "ai_name": ai_name - } - - # Route Claude models through OpenRouter instead of direct Anthropic API - # This avoids issues with image handling differences between the APIs - # Set to False to use OpenRouter for Claude (recommended for image support) - USE_DIRECT_ANTHROPIC_API = False - - if USE_DIRECT_ANTHROPIC_API and ("claude" in model_id.lower() or model_id in ["anthropic/claude-3-opus-20240229", "anthropic/claude-3-sonnet-20240229", "anthropic/claude-3-haiku-20240307"]): - print(f"Using Claude API for model: {model_id}") - - # CRITICAL: Make sure there are no duplicates in the messages and system prompt is included - final_messages = [] - seen_contents = set() - - for msg in messages: - # Skip empty messages - handle both string and list content - content = msg.get("content", "") - is_empty = False - if isinstance(content, list): - # For structured content, check if all parts are empty - text_parts = [part.get('text', '').strip() for part in content if part.get('type') == 'text'] - has_image = any(part.get('type') == 'image' for part in content) - is_empty = not text_parts and not has_image - elif isinstance(content, str): - is_empty = not content - else: - is_empty = not content - - if is_empty: - continue - - # Handle system message separately - if msg.get("role") == "system": - continue - - # Check for duplicates by content - create hashable representation - content = msg.get("content", "") - - # Create a hashable content_hash for duplicate detection - if isinstance(content, list): - # For structured messages, use text parts for hash - text_parts = [part.get('text', '') for part in content if part.get('type') == 'text'] - content_hash = ''.join(text_parts) - elif isinstance(content, str): - content_hash = content - else: - content_hash = str(content) if content else "" - - if content_hash and content_hash in seen_contents: - print(f"Skipping duplicate message in AI turn: {content_hash[:30]}...") - continue - - if content_hash: - seen_contents.add(content_hash) - final_messages.append(msg) - - # Ensure we have at least one message - if not final_messages: - print("Warning: No messages left after filtering. Adding a default message.") - final_messages.append({"role": "user", "content": "Connecting..."}) - - # Get the prompt content safely - prompt_content = "" - if len(final_messages) > 0: - prompt_content = final_messages[-1].get("content", "") - # Use all messages except the last one as context - context_messages = final_messages[:-1] - else: - context_messages = [] - prompt_content = "Connecting..." # Default fallback - - # Call Claude API with filtered messages (with streaming if callback provided) - response = call_claude_api(prompt_content, context_messages, model_id, system_prompt, stream_callback=streaming_callback, temperature=ai_temperature) - - return { - "role": "assistant", - "content": response, - "model": model, - "ai_name": ai_name - } - - # Check for DeepSeek models to use Replicate via DeepSeek API function - if "deepseek" in model.lower(): - print(f"Using Replicate API for DeepSeek model: {model_id}") - - # Ensure we have at least one message for the prompt - if len(messages) > 0: - prompt_content = messages[-1].get("content", "") - context_messages = messages[:-1] - else: - prompt_content = "Connecting..." - context_messages = [] - - response = call_deepseek_api(prompt_content, context_messages, model_id, system_prompt) - - # Ensure response has the required format for the Worker class - if isinstance(response, dict) and 'content' in response: - # Add model info to the response - response['model'] = model - response['role'] = 'assistant' - response['ai_name'] = ai_name - - # Check for HTML contribution - if "html_contribution" in response: - html_contribution = response["html_contribution"] - - # Don't update HTML document here - we'll do it in on_ai_result_received - # Just add indicator to the conversation part - response["content"] += "\n\n..." - if "display" in response: - response["display"] += "\n\n..." - - return response - else: - # Create a formatted response if not already in the right format - return { - "role": "assistant", - "content": str(response) if response else "No response from model", - "model": model, - "ai_name": ai_name, - "display": str(response) if response else "No response from model" - } - - # Use OpenRouter for all other models - else: - print(f"Using OpenRouter API for model: {model_id}") - - try: - # Ensure we have valid messages - if len(messages) > 0: - prompt_content = messages[-1].get("content", "") - context_messages = messages[:-1] - else: - prompt_content = "Connecting..." - context_messages = [] - - # Call OpenRouter API with streaming support - response = call_openrouter_api(prompt_content, context_messages, model_id, system_prompt, stream_callback=streaming_callback, temperature=ai_temperature) - - # Avoid printing full response which could be large - response_preview = str(response)[:200] + "..." if response and len(str(response)) > 200 else response - print(f"Raw {model} Response: {response_preview}") - - result = { - "role": "assistant", - "content": response, - "model": model, - "ai_name": ai_name - } - - return result - except Exception as e: - error_message = f"Error making API request: {str(e)}" - print(f"Error: {error_message}") - print(f"Error type: {type(e)}") - - # Create an error response - result = { - "role": "system", - "content": f"Error: {error_message}", - "model": model, - "ai_name": ai_name - } - - # Return the error result - return result - - except Exception as e: - error_message = f"Error making API request: {str(e)}" - print(f"Error: {error_message}") - - # Create an error response - result = { - "role": "system", - "content": f"Error: {error_message}", - "model": model, - "ai_name": ai_name - } - - # Return the error result - return result - class ConversationManager: """Manages conversation processing and state""" def __init__(self, app): @@ -670,6 +49,9 @@ def __init__(self, app): # Store per-AI temperature settings (default is 1.0) self.ai_temperatures = {} + + # Initialize Session Manager + self.session_manager = SessionManager() def _on_video_ready(self, video_path: str, prompt: str): """Handle video ready signal - runs on main thread""" @@ -1413,7 +795,7 @@ def on_ai_result_received(self, ai_name, result): self.app.left_pane.append_text("\n[system] Starting Sora video job from AI-1 response...\n", "system") # Use config values with env var override - from config import SORA_SECONDS, SORA_SIZE + from src.core.config import SORA_SECONDS, SORA_SIZE sora_model = os.getenv("SORA_MODEL", "sora-2") sora_seconds = int(os.getenv("SORA_SECONDS", str(SORA_SECONDS))) sora_size = os.getenv("SORA_SIZE", SORA_SIZE) or None @@ -1492,20 +874,10 @@ def generate_and_display_image(self, text, ai_name): error_msg = result.get("error", "Unknown error") print(f"Image generation failed: {error_msg}") self.app.left_pane.append_text(f"\n✗ Image generation failed: {error_msg}\n", "system") - - # Do not automatically open the HTML view - # open_html_in_browser("conversation_full.html") def execute_agent_command(self, command: AgentCommand, ai_name: str) -> tuple[bool, str]: """ Execute an agentic command from an AI response. - - Args: - command: The parsed AgentCommand to execute - ai_name: The AI that issued the command - - Returns: - tuple: (success: bool, message: str) """ action = command.action params = command.params @@ -1632,10 +1004,9 @@ def _execute_video_command(self, prompt: str, ai_name: str) -> tuple[bool, str]: # Run video generation in background thread to avoid blocking import threading - from config import SORA_SECONDS, SORA_SIZE + from src.core.config import SORA_SECONDS, SORA_SIZE def _run_video_job(): - from shared_utils import generate_video_with_sora sora_model = os.getenv("SORA_MODEL", "sora-2") # Use config values, with env var override @@ -1768,10 +1139,7 @@ def _execute_mute_command(self, ai_name: str) -> tuple[bool, str]: return True, f"🔇 [{ai_name}]: !mute_self" def _execute_prompt_command(self, text: str, ai_name: str) -> tuple[bool, str]: - """Execute a prompt addition command - AI appends to their own system prompt. - Note: !prompt commands are stripped from conversation context so other AIs don't see them, - but the full text is shown in the GUI notification for the human operator. - A subtle notification is added to context so other AIs know the action occurred.""" + """Execute a prompt addition command - AI appends to their own system prompt.""" if not text or len(text.strip()) < 3: return False, "Prompt text too short" @@ -1786,7 +1154,6 @@ def _execute_prompt_command(self, text: str, ai_name: str) -> tuple[bool, str]: print(f"[Agent] {ai_name} now has {len(self.ai_prompt_additions[ai_name])} prompt additions") # Add a subtle notification to conversation context (visible to other AIs) - # This lets them know the action occurred without revealing the content context_notification = { "role": "user", "content": f"[{ai_name} modified their system prompt]", @@ -1806,8 +1173,7 @@ def get_prompt_additions_for_ai(self, ai_name: str) -> str: return "\n\n[Your remembered insights/perspectives]:\n- " + "\n- ".join(additions) def _execute_temperature_command(self, value: str, ai_name: str) -> tuple[bool, str]: - """Execute a temperature modification command - AI sets their own sampling temperature. - Note: !temperature commands are stripped from conversation context.""" + """Execute a temperature modification command - AI sets their own sampling temperature.""" try: temp = float(value) if temp < 0 or temp > 2: @@ -1838,8 +1204,6 @@ def _execute_search_command(self, query: str, ai_name: str) -> tuple[bool, str]: if not query or len(query.strip()) < 3: return False, "Search query too short" - from shared_utils import web_search - # Get model name for the AI ai_number = int(ai_name.split('-')[1]) if '-' in ai_name else 1 model_name = self.get_model_for_ai(ai_number) @@ -2192,552 +1556,12 @@ def process_branch_input_with_hidden_instruction(self, user_input): def update_conversation_html(self, conversation): """Update the full conversation HTML document with all messages""" - try: - from datetime import datetime - - # Create a filename for the full conversation HTML - html_file = "conversation_full.html" - - # Generate HTML content for the conversation - html_content = """ - - - Liminal Backrooms - - - - - - - - -
-
-

⟨ Liminal Backrooms ⟩

-

AI Conversation Archive

-
- -
""" - - # Add each message to the HTML content - for msg in conversation: - role = msg.get("role", "") - content = msg.get("content", "") - ai_name = msg.get("ai_name", "") - model = msg.get("model", "") - timestamp = datetime.now().strftime("%B %d, %Y at %I:%M %p") - - # Skip special system messages or empty messages - if role == "system" and msg.get("_type") == "branch_indicator": - continue - - # Check if content is empty (handle both string and list) - is_empty = False - if isinstance(content, str): - is_empty = not content.strip() - elif isinstance(content, list): - # For structured content, check if all text parts are empty - text_parts = [part.get('text', '') for part in content if part.get('type') == 'text'] - is_empty = not any(text_parts) and not any(part.get('type') == 'image' for part in content) - else: - is_empty = not content - - if is_empty: - continue - - # Extract text content from structured messages - text_content = "" - if isinstance(content, str): - text_content = content - elif isinstance(content, list): - text_parts = [part.get('text', '') for part in content if part.get('type') == 'text'] - text_content = '\n'.join(text_parts) - - # Process content to properly format code blocks and add greentext styling - processed_content = self.app.left_pane.process_content_with_code_blocks(text_content) if text_content else "" - - # Apply greentext styling to lines starting with '>' - processed_content = self.apply_greentext_styling(processed_content) - - # Message class based on role and type - message_class = role - if msg.get("_type") == "agent_notification": - message_class = "agent-notification" - - # Check if this message has an associated image - has_image = False - image_path = None - image_base64 = None - - # Check for generated image path - if hasattr(msg, "get") and callable(msg.get): - image_path = msg.get("generated_image_path", None) - if image_path: - has_image = True - - # Check for uploaded image in structured content - if isinstance(content, list): - for part in content: - if part.get('type') == 'image': - source = part.get('source', {}) - if source.get('type') == 'base64': - image_base64 = source.get('data', '') - has_image = True - break - - # Start message div - html_content += f'\n
' - - # Open content div - html_content += f'\n
' - - # Add header for assistant messages - if role == "assistant": - html_content += f'\n
{ai_name}' - if model: - html_content += f' ({model})' - html_content += f' {timestamp}
' - elif role == "user": - html_content += f'\n
User {timestamp}
' - - # Add message content - html_content += f'\n
{processed_content}
' - - # Close content div - html_content += '\n
' - - # Add image if present - full width - if has_image: - html_content += f'\n
' - if image_base64: - # Use base64 data directly - html_content += f'\n Generated image' - elif image_path: - # Convert Windows path format to web format if needed - web_path = image_path.replace('\\', '/') - html_content += f'\n Generated image' - html_content += f'\n
' - - # Close message div - html_content += '\n
' - - # Close HTML document - html_content += """ -
- - -
- - - -""" - - # Write the HTML content to file - with open(html_file, 'w', encoding='utf-8') as f: - f.write(html_content) - - print(f"Updated full conversation HTML document: {html_file}") - return True - except Exception as e: - print(f"Error updating conversation HTML: {e}") - return False - - def apply_greentext_styling(self, html_content): - """Apply greentext styling to lines starting with '>'""" - try: - # Split content by lines while preserving HTML - lines = html_content.split('\n') - - # Process each line that's not inside a code block - in_code_block = False - processed_lines = [] - - for line in lines: - # Check for code block start/end - if '
' in line or '' in line:
-                    in_code_block = True
-                    processed_lines.append(line)
-                    continue
-                elif '
' in line or '' in line: - in_code_block = False - processed_lines.append(line) - continue - - # If we're in a code block, don't apply greentext styling - if in_code_block: - processed_lines.append(line) - continue - - # Apply greentext styling to lines starting with '>' - if line.strip().startswith('>'): - # Wrap the line in p with greentext class - processed_line = f'

{line}

' - processed_lines.append(processed_line) - else: - # No changes needed - processed_lines.append(line) - - # Join lines back - processed_content = '\n'.join(processed_lines) - return processed_content - - except Exception as e: - print(f"Error applying greentext styling: {e}") - return html_content + return update_conversation_html(conversation) - def show_living_document_intro(self): - """Show an introduction to the Living Document mode""" - return - -class LiminalBackroomsManager: - """Main manager class for the Liminal Backrooms application""" - - def __init__(self): - """Initialize the manager""" - # Create the GUI - self.app = create_gui() - - # Initialize the worker thread pool - self.thread_pool = QThreadPool() - print(f"Multithreading with maximum {self.thread_pool.maxThreadCount()} threads") - - # List to store workers - self.workers = [] - - # Initialize the application - self.initialize() - -def create_gui(): - """Create the GUI application""" - app = QApplication(sys.argv) - - # Load custom fonts (Iosevka Term for better ASCII art rendering) - loaded_fonts = load_fonts() - if loaded_fonts: - print(f"Successfully loaded custom fonts: {', '.join(loaded_fonts)}") - else: - print("No custom fonts loaded - using system fonts") - - main_window = LiminalBackroomsApp() - - # Create conversation manager and store it on the app for access in ai_turn - manager = ConversationManager(main_window) - main_window.conversation_manager = manager # Store reference on app for prompt additions - manager.initialize() - - return main_window, app - -def run_gui(main_window, app): - """Run the GUI application""" - main_window.show() - sys.exit(app.exec()) + def start_ai2_turn(self, conversation, worker): + time.sleep(TURN_DELAY) + self.thread_pool.start(worker) -if __name__ == "__main__": - main_window, app = create_gui() - run_gui(main_window, app) \ No newline at end of file + def start_ai3_turn(self, conversation, worker): + time.sleep(TURN_DELAY) + self.thread_pool.start(worker) diff --git a/src/core/engine.py b/src/core/engine.py new file mode 100644 index 0000000..1a09ec9 --- /dev/null +++ b/src/core/engine.py @@ -0,0 +1,530 @@ +import os +import json +import logging +import re +from src.core.config import AI_MODELS +from src.services.llm_service import ( + call_claude_api, + call_openrouter_api, + call_openai_api, + call_replicate_api, + call_deepseek_api, +) +from src.services.media_service import generate_video_with_sora +from src.core.config import SORA_SECONDS, SORA_SIZE + +def ai_turn(ai_name, conversation, model, system_prompt, gui=None, is_branch=False, branch_output=None, streaming_callback=None): + """Execute an AI turn with the given parameters + + Args: + streaming_callback: Optional function(chunk: str) to call with each streaming token + """ + print(f"==================================================") + print(f"Starting {model} turn ({ai_name})...") + print(f"Current conversation length: {len(conversation)}") + + # HTML contributions and living document disabled + enhanced_system_prompt = system_prompt + + # Get the actual model ID from the display name + model_id = AI_MODELS.get(model, model) + + # Prepend model identity to system prompt so AI knows who it is + enhanced_system_prompt = f"You are {ai_name} ({model}).\n\n{enhanced_system_prompt}" + + # Apply any self-added prompt additions for this AI + # Also get custom temperature setting + ai_temperature = 1.0 # Default + if gui and hasattr(gui, 'conversation_manager') and gui.conversation_manager: + prompt_additions = gui.conversation_manager.get_prompt_additions_for_ai(ai_name) + if prompt_additions: + enhanced_system_prompt += prompt_additions + print(f"[Prompt] Applied prompt additions for {ai_name}") + + # Get custom temperature if set + ai_temperature = gui.conversation_manager.get_temperature_for_ai(ai_name) + if ai_temperature != 1.0: + print(f"[Temperature] Using custom temperature {ai_temperature} for {ai_name}") + + # Check for branch type and count AI responses + is_rabbithole = False + is_fork = False + branch_text = "" + ai_response_count = 0 + found_branch_marker = False + latest_branch_marker_index = -1 + + # First find the most recent branch marker + for i, msg in enumerate(conversation): + if isinstance(msg, dict) and msg.get("_type") == "branch_indicator": + latest_branch_marker_index = i + found_branch_marker = True + + # Determine branch type from the latest marker + msg_content = msg.get("content", "") + # Branch indicators are always plain strings + if isinstance(msg_content, str): + if "Rabbitholing down:" in msg_content: + is_rabbithole = True + branch_text = msg_content.split('"')[1] if '"' in msg_content else "" + print(f"Detected rabbithole branch for: '{branch_text}'") + elif "Forking off:" in msg_content: + is_fork = True + branch_text = msg_content.split('"')[1] if '"' in msg_content else "" + print(f"Detected fork branch for: '{branch_text}'") + + # Now count AI responses that occur AFTER the latest branch marker + ai_response_count = 0 + if found_branch_marker: + for i, msg in enumerate(conversation): + if i > latest_branch_marker_index and msg.get("role") == "assistant": + ai_response_count += 1 + print(f"Counting AI responses after latest branch marker: found {ai_response_count} responses") + + # Handle branch-specific system prompts + + # For rabbitholing: override system prompt for first TWO responses + if is_rabbithole and ai_response_count < 2: + print(f"USING RABBITHOLE PROMPT: '{branch_text}' - response #{ai_response_count+1} after branch") + system_prompt = f"'{branch_text}'!!!" + + # For forking: override system prompt ONLY for first response + elif is_fork and ai_response_count == 0: + print(f"USING FORK PROMPT: '{branch_text}' - response #{ai_response_count+1}") + system_prompt = f"The conversation forks from'{branch_text}'. Continue naturally from this point." + + # For all other cases, use the standard system prompt + else: + if is_rabbithole: + print(f"USING STANDARD PROMPT: Past initial rabbithole exploration (responses after branch: {ai_response_count})") + elif is_fork: + print(f"USING STANDARD PROMPT: Past initial fork response (responses after branch: {ai_response_count})") + + # Apply the enhanced system prompt (with HTML contribution instructions) + system_prompt = enhanced_system_prompt + + # CRITICAL: Always ensure we have the system prompt + # No matter what happens with the conversation, we need this + messages = [] + messages.append({ + "role": "system", + "content": system_prompt + }) + + # Filter out any existing system messages that might interfere + filtered_conversation = [] + for msg in conversation: + if not isinstance(msg, dict): + # Convert plain text to dictionary + msg = {"role": "user", "content": str(msg)} + + # Skip any hidden "connecting..." messages + msg_content = msg.get("content", "") + if msg.get("hidden") and isinstance(msg_content, str) and "connect" in msg_content.lower(): + continue + + # Skip empty messages + content = msg.get("content", "") + if isinstance(content, str): + if not content.strip(): + continue + elif isinstance(content, list): + # For structured content, skip if all parts are empty + if not any(part.get('text', '').strip() if part.get('type') == 'text' else True for part in content): + continue + else: + if not content: + continue + + # Skip system messages (we already added our own above) + if msg.get("role") == "system": + continue + + # Skip special system messages (branch indicators, etc.) + if msg.get("role") == "system" and msg.get("_type"): + continue + + # Skip duplicate messages - check if this exact content exists already + is_duplicate = False + for existing in filtered_conversation: + if existing.get("content") == msg.get("content"): + is_duplicate = True + content = msg.get('content', '') + # Safely preview content - handle both string and list (structured) content + if isinstance(content, str): + preview = content[:30] + "..." if len(content) > 30 else content + else: + preview = f"[structured content with {len(content)} parts]" + print(f"Skipping duplicate message: {preview}") + break + + if not is_duplicate: + filtered_conversation.append(msg) + + # Process filtered conversation + for i, msg in enumerate(filtered_conversation): + # Check if this message is from the current AI + is_from_this_ai = False + if msg.get("ai_name") == ai_name: + is_from_this_ai = True + + # Determine role + if is_from_this_ai: + role = "assistant" + else: + role = "user" + + # Get content - preserve structure for images + content = msg.get("content", "") + + # Inject speaker name for messages from other participants (not from current AI) + if not is_from_this_ai and content: + # Use the model name (e.g., "Claude 4.5 Sonnet") if available, otherwise fall back to ai_name or "User" + speaker_name = msg.get("model") or msg.get("ai_name", "User") + + # Handle different content types + if isinstance(content, str): + # Simple string content - prefix with speaker name + content = f"[{speaker_name}]: {content}" + elif isinstance(content, list): + # Structured content (e.g., with images) - prefix text parts + modified_content = [] + for part in content: + if part.get('type') == 'text': + # Prefix the first text part with speaker name + text = part.get('text', '') + modified_part = part.copy() + modified_part['text'] = f"[{speaker_name}]: {text}" + modified_content.append(modified_part) + # Only prefix the first text part + break + else: + modified_content.append(part) + + # Add remaining parts unchanged + first_text_found = False + for part in content: + if part.get('type') == 'text' and not first_text_found: + first_text_found = True + continue # Skip, already added above + modified_content.append(part) + + content = modified_content if modified_content else content + + # Add to messages + messages.append({ + "role": role, + "content": content # Now includes speaker names for non-current-AI messages + }) + + # For logging, handle both string and structured content + if isinstance(content, list): + print(f"Message {i} - AI: {msg.get('ai_name', 'User')} - Assigned role: {role} - Content: [structured message with {len(content)} parts]") + else: + content_preview = content[:50] + "..." if len(str(content)) > 50 else content + print(f"Message {i} - AI: {msg.get('ai_name', 'User')} - Assigned role: {role} - Preview: {content_preview}") + + # Ensure the last message is a user message so the AI responds + if len(messages) > 1 and messages[-1].get("role") == "assistant": + # Find an appropriate message to use + if is_rabbithole and branch_text: + # Add a special rabbitholing instruction as the last message + messages.append({ + "role": "user", + "content": f"Please explore the concept of '{branch_text}' in depth. What are the most interesting aspects or connections related to this concept?" + }) + elif is_fork and branch_text: + # Add a special forking instruction as the last message + messages.append({ + "role": "user", + "content": f"Continue on naturally from the point about '{branch_text}' without including this text." + }) + else: + # Standard handling for other conversations + # Find the most recent message from the other AI to use as prompt + other_ai_message = None + for msg in reversed(filtered_conversation): + if msg.get("ai_name") != ai_name: + other_ai_message = msg.get("content", "") + break + + if other_ai_message: + messages.append({ + "role": "user", + "content": other_ai_message + }) + else: + # Fallback - only if no other AI message found + messages.append({ + "role": "user", + "content": "Let's continue our conversation." + }) + + # Print the processed messages for debugging + print(f"Sending to {model} ({ai_name}):") + for i, msg in enumerate(messages): + role = msg.get("role", "unknown") + content_raw = msg.get("content", "") + + # Handle both string and list content for logging + if isinstance(content_raw, list): + text_parts = [part.get('text', '') for part in content_raw if part.get('type') == 'text'] + has_image = any(part.get('type') == 'image' for part in content_raw) + content_str = ' '.join(text_parts) + if has_image: + content_str = f"[Image] {content_str}" if content_str else "[Image]" + else: + content_str = str(content_raw) + + # Truncate for display + content = content_str[:50] + "..." if len(content_str) > 50 else content_str + print(f"[{i}] {role}: {content}") + + # Load any available memories for this AI + memories = [] + try: + if os.path.exists(f'memories/{ai_name.lower()}_memories.json'): + with open(f'memories/{ai_name.lower()}_memories.json', 'r') as f: + memories = json.load(f) + print(f"Loaded {len(memories)} memories for {ai_name}") + else: + print(f"Loaded 0 memories for {ai_name}") + except Exception as e: + print(f"Error loading memories: {e}") + print(f"Loaded 0 memories for {ai_name}") + + # Display the final processed messages for debugging (avoid printing base64 images) + print(f"Sending to Claude:") + print(f"Messages: {len(messages)} message(s)") + + # Display the prompt + print(f"--- Prompt to {model} ({ai_name}) ---") + + try: + # Route Sora video models + if model_id in ("sora-2", "sora-2-pro"): + print(f"Using Sora Video API for model: {model_id}") + # Use last user message as the video prompt + prompt_content = "" + if len(messages) > 0: + last_content = messages[-1].get("content", "") + # Extract text from structured content if needed + if isinstance(last_content, list): + text_parts = [part.get('text', '') for part in last_content if part.get('type') == 'text'] + prompt_content = ' '.join(text_parts) + elif isinstance(last_content, str): + prompt_content = last_content + + if not prompt_content or not prompt_content.strip(): + prompt_content = "A short abstract motion graphic in warm colors" + + # Use config values with env var override + sora_seconds = int(os.getenv("SORA_SECONDS", str(SORA_SECONDS))) + sora_size = os.getenv("SORA_SIZE", SORA_SIZE) or None + + print(f"[Sora] Starting job with seconds={sora_seconds} size={sora_size}") + video_result = generate_video_with_sora( + prompt=prompt_content, + model=model_id, + seconds=sora_seconds, + size=sora_size, + ) + + if video_result.get("success"): + print(f"[Sora] Completed: id={video_result.get('video_id')} path={video_result.get('video_path')}") + # Return a lightweight textual confirmation; video is saved to disk + return { + "role": "assistant", + "content": f"[Sora] Video created: {video_result.get('video_path')}", + "model": model, + "ai_name": ai_name + } + else: + err = video_result.get("error", "unknown error") + print(f"[Sora] Failed: {err}") + return { + "role": "system", + "content": f"[Sora] Video generation failed: {err}", + "model": model, + "ai_name": ai_name + } + + # Route Claude models through OpenRouter instead of direct Anthropic API + # This avoids issues with image handling differences between the APIs + # Set to False to use OpenRouter for Claude (recommended for image support) + USE_DIRECT_ANTHROPIC_API = False + + if USE_DIRECT_ANTHROPIC_API and ("claude" in model_id.lower() or model_id in ["anthropic/claude-3-opus-20240229", "anthropic/claude-3-sonnet-20240229", "anthropic/claude-3-haiku-20240307"]): + print(f"Using Claude API for model: {model_id}") + + # CRITICAL: Make sure there are no duplicates in the messages and system prompt is included + final_messages = [] + seen_contents = set() + + for msg in messages: + # Skip empty messages - handle both string and list content + content = msg.get("content", "") + is_empty = False + if isinstance(content, list): + # For structured content, check if all parts are empty + text_parts = [part.get('text', '').strip() for part in content if part.get('type') == 'text'] + has_image = any(part.get('type') == 'image' for part in content) + is_empty = not text_parts and not has_image + elif isinstance(content, str): + is_empty = not content + else: + is_empty = not content + + if is_empty: + continue + + # Handle system message separately + if msg.get("role") == "system": + continue + + # Check for duplicates by content - create hashable representation + content = msg.get("content", "") + + # Create a hashable content_hash for duplicate detection + if isinstance(content, list): + # For structured messages, use text parts for hash + text_parts = [part.get('text', '') for part in content if part.get('type') == 'text'] + content_hash = ''.join(text_parts) + elif isinstance(content, str): + content_hash = content + else: + content_hash = str(content) if content else "" + + if content_hash and content_hash in seen_contents: + print(f"Skipping duplicate message in AI turn: {content_hash[:30]}...") + continue + + if content_hash: + seen_contents.add(content_hash) + final_messages.append(msg) + + # Ensure we have at least one message + if not final_messages: + print("Warning: No messages left after filtering. Adding a default message.") + final_messages.append({"role": "user", "content": "Connecting..."}) + + # Get the prompt content safely + prompt_content = "" + if len(final_messages) > 0: + prompt_content = final_messages[-1].get("content", "") + # Use all messages except the last one as context + context_messages = final_messages[:-1] + else: + context_messages = [] + prompt_content = "Connecting..." # Default fallback + + # Call Claude API with filtered messages (with streaming if callback provided) + response = call_claude_api(prompt_content, context_messages, model_id, system_prompt, stream_callback=streaming_callback, temperature=ai_temperature) + + return { + "role": "assistant", + "content": response, + "model": model, + "ai_name": ai_name + } + + # Check for DeepSeek models to use Replicate via DeepSeek API function + if "deepseek" in model.lower(): + print(f"Using Replicate API for DeepSeek model: {model_id}") + + # Ensure we have at least one message for the prompt + if len(messages) > 0: + prompt_content = messages[-1].get("content", "") + context_messages = messages[:-1] + else: + prompt_content = "Connecting..." + context_messages = [] + + response = call_deepseek_api(prompt_content, context_messages, model_id, system_prompt) + + # Ensure response has the required format for the Worker class + if isinstance(response, dict) and 'content' in response: + # Add model info to the response + response['model'] = model + response['role'] = 'assistant' + response['ai_name'] = ai_name + + # Check for HTML contribution + if "html_contribution" in response: + html_contribution = response["html_contribution"] + + # Don't update HTML document here - we'll do it in on_ai_result_received + # Just add indicator to the conversation part + response["content"] += "\n\n..." + if "display" in response: + response["display"] += "\n\n..." + + return response + else: + # Create a formatted response if not already in the right format + return { + "role": "assistant", + "content": str(response) if response else "No response from model", + "model": model, + "ai_name": ai_name, + "display": str(response) if response else "No response from model" + } + + # Use OpenRouter for all other models + else: + print(f"Using OpenRouter API for model: {model_id}") + + try: + # Ensure we have valid messages + if len(messages) > 0: + prompt_content = messages[-1].get("content", "") + context_messages = messages[:-1] + else: + prompt_content = "Connecting..." + context_messages = [] + + # Call OpenRouter API with streaming support + response = call_openrouter_api(prompt_content, context_messages, model_id, system_prompt, stream_callback=streaming_callback, temperature=ai_temperature) + + # Avoid printing full response which could be large + response_preview = str(response)[:200] + "..." if response and len(str(response)) > 200 else response + print(f"Raw {model} Response: {response_preview}") + + result = { + "role": "assistant", + "content": response, + "model": model, + "ai_name": ai_name + } + + return result + except Exception as e: + error_message = f"Error making API request: {str(e)}" + print(f"Error: {error_message}") + print(f"Error type: {type(e)}") + + # Create an error response + result = { + "role": "system", + "content": f"Error: {error_message}", + "model": model, + "ai_name": ai_name + } + + # Return the error result + return result + + except Exception as e: + error_message = f"Error making API request: {str(e)}" + print(f"Error: {error_message}") + + # Create an error response + result = { + "role": "system", + "content": f"Error: {error_message}", + "model": model, + "ai_name": ai_name + } + + # Return the error result + return result diff --git a/src/core/session_manager.py b/src/core/session_manager.py new file mode 100644 index 0000000..25f1b3b --- /dev/null +++ b/src/core/session_manager.py @@ -0,0 +1,59 @@ +import json +import os +from datetime import datetime + +class SessionManager: + """Manages saving and loading of conversation sessions""" + + def __init__(self, sessions_dir="sessions"): + self.sessions_dir = sessions_dir + if not os.path.exists(self.sessions_dir): + os.makedirs(self.sessions_dir) + + def save_session(self, filename, conversation, branch_conversations=None, active_branch=None, metadata=None): + """Save the current session to a JSON file""" + if not filename.endswith('.json'): + filename += '.json' + + filepath = os.path.join(self.sessions_dir, filename) + + data = { + "conversation": conversation, + "branch_conversations": branch_conversations or {}, + "active_branch": active_branch, + "metadata": metadata or {}, + "saved_at": datetime.now().isoformat(), + "version": "1.0" + } + + try: + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + return True, filepath + except Exception as e: + return False, str(e) + + def load_session(self, filename): + """Load a session from a JSON file""" + filepath = os.path.join(self.sessions_dir, filename) + + if not os.path.exists(filepath): + return False, f"File not found: {filename}" + + try: + with open(filepath, 'r', encoding='utf-8') as f: + data = json.load(f) + return True, data + except Exception as e: + return False, str(e) + + def list_sessions(self): + """List available saved sessions""" + try: + files = [f for f in os.listdir(self.sessions_dir) if f.endswith('.json')] + # Sort by modification time, newest first + files.sort(key=lambda x: os.path.getmtime(os.path.join(self.sessions_dir, x)), reverse=True) + return files + except Exception as e: + print(f"Error listing sessions: {e}") + return [] diff --git a/src/core/worker.py b/src/core/worker.py new file mode 100644 index 0000000..e62afb0 --- /dev/null +++ b/src/core/worker.py @@ -0,0 +1,95 @@ +import os +import json +import logging +import threading +from PyQt6.QtCore import QObject, QRunnable, pyqtSignal, pyqtSlot + +from src.core.config import AI_MODELS +from src.services.llm_service import ( + call_claude_api, + call_openrouter_api, + call_openai_api, + call_replicate_api, + call_deepseek_api, +) +from src.services.media_service import generate_video_with_sora +from src.core.config import SORA_SECONDS, SORA_SIZE + +class WorkerSignals(QObject): + """Defines the signals available from a running worker thread""" + finished = pyqtSignal() + error = pyqtSignal(str) + response = pyqtSignal(str, str) + result = pyqtSignal(str, object) # Signal for complete result object + progress = pyqtSignal(str) + streaming_chunk = pyqtSignal(str, str) # Signal for streaming tokens: (ai_name, chunk) + + +class Worker(QRunnable): + """Worker thread for processing AI turns using QThreadPool""" + + def __init__(self, ai_name, conversation, model, system_prompt, is_branch=False, branch_id=None, gui=None): + super().__init__() + self.ai_name = ai_name + self.conversation = conversation.copy() # Make a copy to prevent race conditions + self.model = model + self.system_prompt = system_prompt + self.is_branch = is_branch + self.branch_id = branch_id + self.gui = gui + + # Create signals object + self.signals = WorkerSignals() + + @pyqtSlot() + def run(self): + """Process the AI turn when the thread is started""" + print(f"[Worker] >>> Starting run() for {self.ai_name} ({self.model})") + try: + # Emit progress update + self.signals.progress.emit(f"Processing {self.ai_name} turn with {self.model}...") + + # Define streaming callback + def stream_chunk(chunk: str): + self.signals.streaming_chunk.emit(self.ai_name, chunk) + + # Process the turn with streaming + from src.core.engine import ai_turn + + print(f"[Worker] Calling ai_turn for {self.ai_name}...") + result = ai_turn( + self.ai_name, + self.conversation, + self.model, + self.system_prompt, + gui=self.gui, + streaming_callback=stream_chunk + ) + print(f"[Worker] ai_turn completed for {self.ai_name}, result type: {type(result)}") + + # Emit both the text response and the full result object + if isinstance(result, dict): + response_content = result.get('content', '') + print(f"[Worker] Emitting response for {self.ai_name}, content length: {len(response_content) if response_content else 0}") + # Emit the simple text response for backward compatibility + self.signals.response.emit(self.ai_name, response_content) + # Also emit the full result object for HTML contribution processing + self.signals.result.emit(self.ai_name, result) + else: + # Handle simple string responses + print(f"[Worker] Emitting string response for {self.ai_name}") + self.signals.response.emit(self.ai_name, result if result else "") + self.signals.result.emit(self.ai_name, {"content": result, "model": self.model}) + + # Emit finished signal + print(f"[Worker] <<< Finished run() for {self.ai_name}, emitting finished signal") + self.signals.finished.emit() + + except Exception as e: + # Emit error signal + print(f"[Worker] !!! ERROR in run() for {self.ai_name}: {e}") + import traceback + traceback.print_exc() + self.signals.error.emit(str(e)) + # Still emit finished signal even if there's an error + self.signals.finished.emit() diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..cc4c3d3 --- /dev/null +++ b/src/main.py @@ -0,0 +1,27 @@ +import sys +import os +from PyQt6.QtWidgets import QApplication +from src.ui.main_window import LiminalBackroomsApp +from src.ui.widgets.custom_widgets import COLORS # Ensure fonts or styles are loaded if needed +from src.utils.font_loader import load_fonts + +def main(): + """Main entry point for the application""" + # Create the application + app = QApplication(sys.argv) + app.setApplicationName("Liminal Backrooms") + + # Load custom fonts + load_fonts() + + # Create the main window + main_window = LiminalBackroomsApp() + + # Show the window + main_window.show() + + # Run the event loop + sys.exit(app.exec()) + +if __name__ == "__main__": + main() diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/html_generator.py b/src/services/html_generator.py new file mode 100644 index 0000000..f5dfcd8 --- /dev/null +++ b/src/services/html_generator.py @@ -0,0 +1,418 @@ +import os +from datetime import datetime +from src.ui.colors import COLORS + +def update_conversation_html(conversation, filename="conversation_full.html"): + """Update the full conversation HTML document with all messages""" + try: + # Generate HTML content for the conversation + html_content = f""" + + + Liminal Backrooms + + + + + + + + +
+
+

⟨ Liminal Backrooms ⟩

+

AI Conversation Archive

+
+ +
""" + + # Add each message to the HTML content + for msg in conversation: + role = msg.get("role", "") + content = msg.get("content", "") + ai_name = msg.get("ai_name", "") + model = msg.get("model", "") + timestamp = datetime.now().strftime("%B %d, %Y at %I:%M %p") + + # Skip special system messages or empty messages + if role == "system" and msg.get("_type") == "branch_indicator": + continue + + # Check if content is empty (handle both string and list) + is_empty = False + if isinstance(content, str): + is_empty = not content.strip() + elif isinstance(content, list): + # For structured content, check if all text parts are empty + text_parts = [part.get('text', '') for part in content if part.get('type') == 'text'] + is_empty = not any(text_parts) and not any(part.get('type') == 'image' for part in content) + else: + is_empty = not content + + if is_empty: + continue + + # Extract text content from structured messages + text_content = "" + if isinstance(content, str): + text_content = content + elif isinstance(content, list): + text_parts = [part.get('text', '') for part in content if part.get('type') == 'text'] + text_content = '\\n'.join(text_parts) + + # Process content to properly format code blocks and add greentext styling + from html import escape + processed_content = escape(text_content) if text_content else "" + + # Message class based on role and type + message_class = role + if msg.get("_type") == "agent_notification": + message_class = "agent-notification" + + # Check if this message has an associated image + has_image = False + image_path = None + image_base64 = None + + # Check for generated image path + if hasattr(msg, "get") and callable(msg.get): + image_path = msg.get("generated_image_path", None) + if image_path: + has_image = True + + # Check for uploaded image in structured content + if isinstance(content, list): + for part in content: + if part.get('type') == 'image': + source = part.get('source', {}) + if source.get('type') == 'base64': + image_base64 = source.get('data', '') + has_image = True + break + + # Start message div + html_content += f'\\n
' + + # Open content div + html_content += f'\\n
' + + # Add header for assistant messages + if role == "assistant": + html_content += f'\\n
{ai_name}' + if model: + html_content += f' ({model})' + html_content += f' {timestamp}
' + elif role == "user": + html_content += f'\\n
User {timestamp}
' + + # Add message content + html_content += f'\\n
{processed_content}
' + + # Close content div + html_content += '\\n
' + + # Add image if present - full width + if has_image: + html_content += f'\\n
' + if image_base64: + # Use base64 data directly + html_content += f'\\n Generated image' + elif image_path: + # Convert Windows path format to web format if needed + web_path = image_path.replace('\\\\', '/') + html_content += f'\\n Generated image' + html_content += f'\\n
' + + # Close message div + html_content += '\\n
' + + # Close HTML document + html_content += """ +
+ + +
+ + + +""" + + # Write the HTML content to file + with open(filename, 'w', encoding='utf-8') as f: + f.write(html_content) + + print(f"Updated full conversation HTML document: {filename}") + return True + except Exception as e: + print(f"Error updating conversation HTML: {e}") + return False diff --git a/src/services/llm_service.py b/src/services/llm_service.py new file mode 100644 index 0000000..32df1fd --- /dev/null +++ b/src/services/llm_service.py @@ -0,0 +1,594 @@ +import os +import requests +import json +import logging +import re +from datetime import datetime +from pathlib import Path +from dotenv import load_dotenv + +# Third-party imports for API clients +import replicate +import openai +from anthropic import Anthropic +from openai import OpenAI +try: + from ddgs import DDGS +except ImportError: + DDGS = None + print("ddgs not found. Install with: pip install ddgs") + +# Load environment variables +load_dotenv() + +# Initialize clients +anthropic_client = Anthropic(api_key=os.getenv('ANTHROPIC_API_KEY')) +openai_client = OpenAI(api_key=os.getenv('OPENAI_API_KEY')) + +# Import config +from src.core.config import SHOW_CHAIN_OF_THOUGHT_IN_CONTEXT + +def call_claude_api(prompt, messages, model_id, system_prompt=None, stream_callback=None, temperature=1.0): + """Call the Claude API with the given messages and prompt""" + api_key = os.getenv("ANTHROPIC_API_KEY") + if not api_key: + return "Error: ANTHROPIC_API_KEY not found in environment variables" + + url = "https://api.anthropic.com/v1/messages" + + payload = { + "model": model_id, + "max_tokens": 4000, + "temperature": temperature, + "stream": stream_callback is not None + } + + if system_prompt: + payload["system"] = system_prompt + print(f"CLAUDE API USING SYSTEM PROMPT: {system_prompt}") + + print(f"CLAUDE API USING TEMPERATURE: {temperature}") + + # Filter messages + filtered_messages = [] + seen_contents = set() + + for msg in messages: + if msg.get("role") == "system": + continue + + content = msg.get("content", "") + + # Create hashable content + if isinstance(content, list): + text_parts = [part.get('text', '') for part in content if part.get('type') == 'text'] + content_hash = ''.join(text_parts) + elif isinstance(content, str): + content_hash = content + else: + content_hash = str(content) if content else "" + + if content_hash and content_hash in seen_contents: + print(f"Skipping duplicate message in API call: {str(content_hash)[:30]}...") + continue + + if content_hash: + seen_contents.add(content_hash) + filtered_messages.append(msg) + + # Add the current prompt as the final user message + if prompt and not any(isinstance(msg.get("content"), list) for msg in filtered_messages[-1:]): + filtered_messages.append({ + "role": "user", + "content": prompt + }) + + payload["messages"] = filtered_messages + + headers = { + "Content-Type": "application/json", + "x-api-key": api_key, + "anthropic-version": "2023-06-01" + } + + try: + if stream_callback: + payload["stream"] = True + full_response = "" + + response = requests.post(url, json=payload, headers=headers, stream=True) + + if response.status_code == 200: + for line in response.iter_lines(): + if line: + line_text = line.decode('utf-8') + if line_text.startswith('data: '): + json_str = line_text[6:] + if json_str.strip() in ['[DONE]', '']: + continue + try: + chunk_data = json.loads(json_str) + event_type = chunk_data.get('type') + + if event_type == 'content_block_delta': + delta = chunk_data.get('delta', {}) + if delta.get('type') == 'text_delta': + text = delta.get('text', '') + if text: + full_response += text + stream_callback(text) + except json.JSONDecodeError: + continue + return full_response + else: + return f"Error: API returned status {response.status_code}: {response.text}" + else: + response = requests.post(url, json=payload, headers=headers) + response.raise_for_status() + data = response.json() + if 'content' in data and len(data['content']) > 0: + for content_item in data['content']: + if content_item.get('type') == 'text': + return content_item.get('text', '') + return str(data['content']) + return "No content in response" + except Exception as e: + return f"Error calling Claude API: {str(e)}" + +def call_openai_api(prompt, conversation_history, model, system_prompt): + try: + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + + for msg in conversation_history: + messages.append({"role": msg["role"], "content": msg["content"]}) + + messages.append({"role": "user", "content": prompt}) + + response = openai.chat.completions.create( + model=model, + messages=messages, + max_tokens=4000, + n=1, + temperature=1, + stream=True + ) + + collected_messages = [] + for chunk in response: + if chunk.choices[0].delta.content is not None: + collected_messages.append(chunk.choices[0].delta.content) + + full_reply = ''.join(collected_messages) + return full_reply + + except Exception as e: + print(f"Error calling OpenAI API: {e}") + return None + +def call_openrouter_api(prompt, conversation_history, model, system_prompt, stream_callback=None, temperature=1.0): + try: + headers = { + "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}", + "HTTP-Referer": "http://localhost:3000", + "Content-Type": "application/json", + "X-Title": "AI Conversation" + } + + openrouter_model = model + if model.startswith("claude-") and not model.startswith("anthropic/"): + openrouter_model = f"anthropic/{model}" + print(f"Normalized Claude model ID for OpenRouter: {model} -> {openrouter_model}") + + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + + def convert_to_openai_format(content, include_images=True): + if not isinstance(content, list): + return content + + converted = [] + for part in content: + if part.get('type') == 'text': + converted.append({"type": "text", "text": part.get('text', '')}) + elif part.get('type') == 'image': + if include_images: + source = part.get('source', {}) + if source.get('type') == 'base64': + media_type = source.get('media_type', 'image/png') + data = source.get('data', '') + converted.append({ + "type": "image_url", + "image_url": { + "url": f"data:{media_type};base64,{data}" + } + }) + elif part.get('type') == 'image_url': + if include_images: + converted.append(part) + else: + converted.append(part) + + if not include_images and len(converted) == 1 and converted[0].get('type') == 'text': + return converted[0]['text'] + elif not include_images and len(converted) == 0: + return "" + + return converted + + def build_messages(include_images=True, max_images=5): + msgs = [] + if system_prompt: + msgs.append({"role": "system", "content": system_prompt}) + + if include_images and max_images > 0: + image_message_indices = [] + for i, msg in enumerate(conversation_history): + content = msg.get("content", "") + if isinstance(content, list): + has_image = any( + part.get('type') in ('image', 'image_url') + for part in content if isinstance(part, dict) + ) + if has_image: + image_message_indices.append(i) + + indices_to_keep_images = set(image_message_indices[-max_images:]) if image_message_indices else set() + + if len(image_message_indices) > max_images: + stripped_count = len(image_message_indices) - max_images + print(f"[Context] Stripping {stripped_count} older images, keeping last {max_images}") + + for i, msg in enumerate(conversation_history): + if msg["role"] != "system": + keep_images = i in indices_to_keep_images + msgs.append({ + "role": msg["role"], + "content": convert_to_openai_format(msg["content"], include_images=keep_images) + }) + else: + for msg in conversation_history: + if msg["role"] != "system": + msgs.append({ + "role": msg["role"], + "content": convert_to_openai_format(msg["content"], include_images=False) + }) + + msgs.append({"role": "user", "content": convert_to_openai_format(prompt, include_images)}) + return msgs + + def make_api_call(include_images=True, max_images=5): + msgs = build_messages(include_images=include_images, max_images=max_images) + + payload = { + "model": openrouter_model, + "messages": msgs, + "temperature": temperature, + "max_tokens": 4000, + "stream": stream_callback is not None + } + + print(f"\nSending to OpenRouter:") + print(f"Model: {model}") + print(f"Temperature: {temperature}") + + if stream_callback: + response = requests.post( + "https://openrouter.ai/api/v1/chat/completions", + headers=headers, + json=payload, + timeout=180, + stream=True + ) + + if response.status_code == 200: + full_response = "" + for line in response.iter_lines(): + if line: + line_text = line.decode('utf-8') + if line_text.startswith('data: '): + json_str = line_text[6:] + if json_str.strip() == '[DONE]': + break + try: + chunk_data = json.loads(json_str) + if 'choices' in chunk_data and len(chunk_data['choices']) > 0: + choice = chunk_data['choices'][0] + delta = choice.get('delta', {}) + content = delta.get('content', '') + if content: + full_response += content + stream_callback(content) + except json.JSONDecodeError: + continue + return True, full_response + else: + return False, (response.status_code, response.text) + else: + response = requests.post( + "https://openrouter.ai/api/v1/chat/completions", + headers=headers, + json=payload, + timeout=60 + ) + + if response.status_code == 200: + response_data = response.json() + if 'choices' in response_data and len(response_data['choices']) > 0: + choice = response_data['choices'][0] + message = choice.get('message', {}) + content = message.get('content', '') if message else '' + if content and content.strip(): + return True, content + else: + return True, None + return True, None + else: + return False, (response.status_code, response.text) + + success, result = make_api_call(include_images=True) + + if success: + if result is None or (isinstance(result, str) and not result.strip()): + print(f"[OpenRouter] WARNING: Model {model} returned empty response, retrying...", flush=True) + import time + time.sleep(1) + success, result = make_api_call(include_images=True) + if success and result and (not isinstance(result, str) or result.strip()): + return result + return "[Model returned empty response - it may be experiencing issues]" + return result + + status_code, error_text = result + if status_code == 404 and "support image" in error_text.lower(): + print(f"[OpenRouter] Model {model} doesn't support images, retrying without images...") + success, result = make_api_call(include_images=False) + if success: + return result + status_code, error_text = result + + error_msg = f"OpenRouter API error {status_code}: {error_text}" + print(error_msg) + return f"Error: {error_msg}" + + except requests.exceptions.Timeout: + return "Error: Request timed out" + except requests.exceptions.RequestException as e: + return f"Error: Network error - {str(e)}" + except Exception as e: + return f"Error: {str(e)}" + +def call_deepseek_api(prompt, conversation_history, model, system_prompt, stream_callback=None): + try: + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + + for msg in conversation_history: + if isinstance(msg, dict): + role = msg.get("role", "user") + content = msg.get("content", "") + if isinstance(content, str) and content.strip(): + messages.append({"role": role, "content": content}) + + if prompt: + messages.append({"role": "user", "content": prompt}) + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}", + } + + payload = { + "model": "deepseek/deepseek-r1", + "messages": messages, + "max_tokens": 8000, + "temperature": 1, + "stream": stream_callback is not None + } + + if stream_callback: + response = requests.post( + "https://openrouter.ai/api/v1/chat/completions", + headers=headers, + json=payload, + timeout=180, + stream=True + ) + + if response.status_code == 200: + full_response = "" + for line in response.iter_lines(): + if line: + line_text = line.decode('utf-8') + if line_text.startswith('data: '): + json_str = line_text[6:] + if json_str.strip() == '[DONE]': + break + try: + chunk_data = json.loads(json_str) + if 'choices' in chunk_data and len(chunk_data['choices']) > 0: + delta = chunk_data['choices'][0].get('delta', {}) + content = delta.get('content', '') + if content: + full_response += content + stream_callback(content) + except json.JSONDecodeError: + continue + response_text = full_response + else: + error_msg = f"OpenRouter API error {response.status_code}: {response.text}" + print(error_msg) + return None + else: + response = requests.post( + "https://openrouter.ai/api/v1/chat/completions", + headers=headers, + json=payload, + timeout=180 + ) + + if response.status_code == 200: + data = response.json() + response_text = data['choices'][0]['message']['content'] + else: + error_msg = f"OpenRouter API error {response.status_code}: {response.text}" + print(error_msg) + return None + + result = { + "content": response_text, + "model": "deepseek/deepseek-r1" + } + + if SHOW_CHAIN_OF_THOUGHT_IN_CONTEXT: + reasoning = None + content = response_text + + if content: + think_match = re.search(r'<(think|thinking)>(.*?)', content, re.DOTALL | re.IGNORECASE) + if think_match: + reasoning = think_match.group(2).strip() + content = re.sub(r'<(think|thinking)>.*?', '', content, flags=re.DOTALL | re.IGNORECASE).strip() + + display_text = "" + if reasoning: + display_text += f"[Chain of Thought]\n{reasoning}\n\n" + if content: + display_text += f"[Final Answer]\n{content}" + + result["display"] = display_text + result["content"] = content + else: + content = response_text + if content: + content = re.sub(r'<(think|thinking)>.*?', '', content, flags=re.DOTALL | re.IGNORECASE).strip() + result["content"] = content + + return result + + except Exception as e: + print(f"Error calling DeepSeek via OpenRouter: {e}") + return None + +def call_replicate_api(prompt, conversation_history, model, gui=None): + try: + input_params = { + "width": 1024, + "height": 1024, + "prompt": prompt + } + + output = replicate.run( + "black-forest-labs/flux-1.1-pro", + input=input_params + ) + + image_url = str(output) + + image_dir = Path("images") + image_dir.mkdir(exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + image_path = image_dir / f"generated_{timestamp}.jpg" + + response = requests.get(image_url) + with open(image_path, "wb") as f: + f.write(response.content) + + if gui: + gui.display_image(image_url) + + return { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "I have generated an image based on your prompt." + } + ], + "prompt": prompt, + "image_url": image_url, + "image_path": str(image_path) + } + + except Exception as e: + print(f"Error calling Flux API: {e}") + return None + +def call_llama_api(prompt, conversation_history, model, system_prompt): + recent_history = conversation_history[-10:] if len(conversation_history) > 10 else conversation_history + formatted_history = "" + for message in recent_history: + if message["role"] == "user": + formatted_history += f"Human: {message['content']}\n" + else: + formatted_history += f"Assistant: {message['content']}\n" + formatted_history += f"Human: {prompt}\nAssistant:" + + try: + response_chunks = [] + for chunk in replicate.run( + model, + input={ + "prompt": formatted_history, + "system_prompt": system_prompt, + "max_tokens": 3000, + "temperature": 1.1, + "top_p": 0.99, + "repetition_penalty": 1.0 + }, + stream=True + ): + if chunk is not None: + response_chunks.append(chunk) + + response = ''.join(response_chunks) + return response + except Exception as e: + print(f"Error calling LLaMA API: {e}") + return None + +def call_together_api(prompt, conversation_history, model, system_prompt): + try: + headers = { + "Authorization": f"Bearer {os.getenv('TOGETHERAI_API_KEY')}", + "Content-Type": "application/json" + } + + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + + for msg in conversation_history: + messages.append({ + "role": msg["role"], + "content": msg["content"] + }) + + messages.append({"role": "user", "content": prompt}) + + payload = { + "model": model, + "messages": messages, + "max_tokens": 500, + "temperature": 0.9, + "top_p": 0.95, + } + + response = requests.post( + "https://api.together.xyz/v1/chat/completions", + headers=headers, + json=payload + ) + + if response.status_code == 200: + response_data = response.json() + return response_data['choices'][0]['message']['content'] + else: + return None + + except Exception as e: + print(f"Error calling Together API: {str(e)}") + return None diff --git a/src/services/media_service.py b/src/services/media_service.py new file mode 100644 index 0000000..29aa30e --- /dev/null +++ b/src/services/media_service.py @@ -0,0 +1,269 @@ +import os +import requests +import json +import base64 +import re +import logging +import time +from datetime import datetime +from pathlib import Path +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +def generate_image_from_text(text, model="google/gemini-3-pro-image-preview"): + """Generate an image based on text using OpenRouter's image generation API""" + try: + image_dir = Path("images") + image_dir.mkdir(exist_ok=True) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + + headers = { + "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}", + "Content-Type": "application/json" + } + + payload = { + "model": model, + "messages": [ + { + "role": "user", + "content": text + } + ], + "modalities": ["image", "text"], + "max_tokens": 1024 + } + + print(f"Generating image with {model}...") + response = requests.post( + "https://openrouter.ai/api/v1/chat/completions", + headers=headers, + data=json.dumps(payload), + timeout=60 + ) + + if response.status_code == 200: + result = response.json() + + if result.get("choices"): + message = result["choices"][0].get("message", {}) + + if message.get("images"): + for image in message["images"]: + image_url = image["image_url"]["url"] + print(f"Generated image URL (first 50 chars): {image_url[:50]}...") + + if image_url.startswith('data:image'): + try: + ext = ".jpg" + if image_url.startswith('data:image/png'): + ext = ".png" + elif image_url.startswith('data:image/gif'): + ext = ".gif" + elif image_url.startswith('data:image/webp'): + ext = ".webp" + + base64_data = image_url.split(',', 1)[1] if ',' in image_url else image_url + + image_data = base64.b64decode(base64_data) + image_path = image_dir / f"generated_{timestamp}{ext}" + with open(image_path, "wb") as f: + f.write(image_data) + + print(f"Generated image saved to {image_path}") + return { + "success": True, + "image_path": str(image_path), + "timestamp": timestamp + } + except Exception as e: + print(f"Failed to decode base64 image: {e}") + return { + "success": False, + "error": f"Failed to decode image: {e}" + } + else: + try: + img_response = requests.get(image_url, timeout=30) + if img_response.status_code == 200: + image_path = image_dir / f"generated_{timestamp}.png" + with open(image_path, "wb") as f: + f.write(img_response.content) + + print(f"Generated image saved to {image_path}") + return { + "success": True, + "image_path": str(image_path), + "timestamp": timestamp + } + except Exception as e: + print(f"Failed to download image: {e}") + return { + "success": False, + "error": f"Failed to download image: {e}" + } + + print(f"No images in response. Message keys: {list(message.keys()) if isinstance(message, dict) else 'non-dict'}") + return { + "success": False, + "error": "No images in API response" + } + else: + return { + "success": False, + "error": "No choices in API response" + } + else: + error_msg = f"API error {response.status_code}: {response.text[:500]}" + print(f"Error generating image: {error_msg}") + return { + "success": False, + "error": error_msg + } + + except Exception as e: + print(f"Error generating image: {e}") + return { + "success": False, + "error": str(e) + } + +def ensure_videos_dir() -> Path: + """Create a 'videos' directory in the project root if it doesn't exist.""" + videos_dir = Path("videos") + videos_dir.mkdir(exist_ok=True) + return videos_dir + +def generate_video_with_sora( + prompt: str, + model: str = "sora-2", + seconds: int | None = None, + size: str | None = None, + poll_interval_seconds: float = 5.0, +) -> dict: + """ + Create a Sora video via REST API, poll until completion, and save MP4 to videos/. + """ + try: + api_key = os.getenv('OPENAI_API_KEY') + if not api_key: + return {"success": False, "error": "OPENAI_API_KEY not set"} + + base_url = os.getenv('OPENAI_BASE_URL', 'https://api.openai.com/v1') + verbose = os.getenv('SORA_VERBOSE', '1').strip() == '1' + def vlog(msg: str): + if verbose: + print(msg) + headers_json = { + 'Authorization': f'Bearer {api_key}', + 'Content-Type': 'application/json' + } + + # Start render job + payload = {"model": model, "prompt": prompt} + if seconds is not None: + payload["seconds"] = str(seconds) + if size is not None: + payload["size"] = size + + create_url = f"{base_url}/videos" + vlog(f"[Sora] Create: url={create_url} model={model} seconds={seconds} size={size}") + vlog(f"[Sora] Prompt (truncated): {prompt[:200]}{'...' if len(prompt) > 200 else ''}") + resp = requests.post(create_url, headers=headers_json, json=payload, timeout=60) + if not resp.ok: + err_text = resp.text + try: + err_json = resp.json() + vlog(f"[Sora] Create error JSON: {err_json}") + except Exception: + vlog(f"[Sora] Create error TEXT: {err_text}") + return {"success": False, "error": f"Create failed {resp.status_code}: {err_text}"} + job = resp.json() + video_id = job.get('id') + status = job.get('status') + vlog(f"[Sora] Job started: id={video_id} status={status}") + if not video_id: + return {"success": False, "error": "No video id returned from create()"} + + # Poll until completion/failed + retrieve_url = f"{base_url}/videos/{video_id}" + last_status = status + last_progress = None + while status in ("queued", "in_progress"): + time.sleep(poll_interval_seconds) + r = requests.get(retrieve_url, headers=headers_json, timeout=60) + if not r.ok: + vlog(f"[Sora] Retrieve failed: code={r.status_code} body={r.text}") + return {"success": False, "video_id": video_id, "error": f"Retrieve failed {r.status_code}: {r.text}"} + job = r.json() + status = job.get('status') + progress = job.get('progress') + if status != last_status or progress != last_progress: + vlog(f"[Sora] Status update: status={status} progress={progress}") + last_status = status + last_progress = progress + + if status != "completed": + vlog(f"[Sora] Final non-completed status: {status} job={job}") + return {"success": False, "video_id": video_id, "status": status, "error": f"Final status: {status}"} + + # Download the MP4 + content_url = f"{base_url}/videos/{video_id}/content" + vlog(f"[Sora] Download: url={content_url}") + rc = requests.get(content_url, headers={'Authorization': f'Bearer {api_key}'}, stream=True, timeout=300) + if not rc.ok: + vlog(f"[Sora] Download failed: code={rc.status_code} body={rc.text}") + return {"success": False, "video_id": video_id, "status": status, "error": f"Download failed {rc.status_code}: {rc.text}"} + + videos_dir = ensure_videos_dir() + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + safe_snippet = re.sub(r"[^a-zA-Z0-9_-]", "_", prompt[:40]) or "video" + out_path = videos_dir / f"{timestamp}_{safe_snippet}.mp4" + with open(out_path, "wb") as f: + for chunk in rc.iter_content(chunk_size=1024 * 1024): + if chunk: + f.write(chunk) + + vlog(f"[Sora] Saved video: {out_path}") + return { + "success": True, + "video_id": video_id, + "status": status, + "video_path": str(out_path) + } + except Exception as e: + logging.exception("Sora video generation error") + return {"success": False, "error": str(e)} + +def call_claude_vision_api(image_url): + """Have Claude analyze the generated image""" + from anthropic import Anthropic + anthropic = Anthropic(api_key=os.getenv('ANTHROPIC_API_KEY')) + try: + response = anthropic.messages.create( + model="claude-3-opus-20240229", + max_tokens=1000, + messages=[{ + "role": "user", + "content": [ + { + "type": "text", + "text": "Describe this image in detail. What works well and what could be improved?" + }, + { + "type": "image", + "source": { + "type": "url", + "url": image_url + } + } + ] + }] + ) + return response.content[0].text + except Exception as e: + print(f"Error in vision analysis: {e}") + return None diff --git a/src/ui/__init__.py b/src/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/colors.py b/src/ui/colors.py new file mode 100644 index 0000000..1ab05d0 --- /dev/null +++ b/src/ui/colors.py @@ -0,0 +1,41 @@ +# Global color palette for consistent styling - Cyberpunk theme +COLORS = { + # Backgrounds - darker, moodier + 'bg_dark': '#0A0E1A', # Deep blue-black + 'bg_medium': '#111827', # Slate dark + 'bg_light': '#1E293B', # Lighter slate + + # Primary accents - neon but muted + 'accent_cyan': '#06B6D4', # Cyan (primary) + 'accent_cyan_hover': '#0891B2', + 'accent_cyan_active': '#0E7490', + + # Secondary accents + 'accent_pink': '#EC4899', # Hot pink (secondary) + 'accent_purple': '#A855F7', # Purple (tertiary) + 'accent_yellow': '#FBBF24', # Amber for warnings + 'accent_green': '#10B981', # Emerald (rabbithole) + + # Text colors + 'text_normal': '#CBD5E1', # Slate-200 + 'text_dim': '#64748B', # Slate-500 + 'text_bright': '#F1F5F9', # Slate-50 + 'text_glow': '#38BDF8', # Sky-400 (glowing text) + 'text_error': '#EF4444', # Red-500 + + # Borders and effects + 'border': '#1E293B', # Slate-800 + 'border_glow': '#06B6D4', # Glowing cyan borders + 'border_highlight': '#334155', # Slate-700 + 'shadow': 'rgba(6, 182, 212, 0.2)', # Cyan glow shadows + + # Legacy color mappings for compatibility + 'accent_blue': '#06B6D4', # Map old blue to cyan + 'accent_blue_hover': '#0891B2', + 'accent_blue_active': '#0E7490', + 'accent_orange': '#F59E0B', # Amber-500 + 'chain_of_thought': '#10B981', # Emerald + 'user_header': '#06B6D4', # Cyan + 'ai_header': '#A855F7', # Purple + 'system_message': '#F59E0B', # Amber +} diff --git a/src/ui/control_panel.py b/src/ui/control_panel.py new file mode 100644 index 0000000..64318b1 --- /dev/null +++ b/src/ui/control_panel.py @@ -0,0 +1,443 @@ +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QTextEdit, QPushButton, QLabel, + QComboBox, QScrollArea, QCheckBox, QMenu, QFileDialog, QMessageBox, QSizePolicy +) +from PyQt6.QtCore import Qt, pyqtSignal, QEvent, QTimer +from PyQt6.QtGui import QTextCursor, QFont, QColor, QTextCharFormat, QPixmap, QImage +from src.ui.colors import COLORS +from src.ui.widgets.custom_widgets import GlowButton +from src.core.config import AI_MODELS, SYSTEM_PROMPT_PAIRS +from src.utils.shared_utils import open_html_in_browser +import base64 +import os + +class ControlPanel(QWidget): + """Control panel with mode, model selections, etc.""" + def __init__(self): + super().__init__() + + # Set up the UI + self.setup_ui() + + # Initialize with models and prompt pairs + self.initialize_selectors() + + def setup_ui(self): + """Set up the user interface for the control panel - vertical sidebar layout""" + # Main layout + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(5, 5, 5, 5) + main_layout.setSpacing(8) + + # Add a title with cyberpunk styling + title = QLabel("═ CONTROL PANEL ═") + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + title.setStyleSheet(f""" + color: {COLORS['accent_cyan']}; + font-size: 12px; + font-weight: bold; + padding: 10px; + background-color: {COLORS['bg_medium']}; + border: 1px solid {COLORS['border_glow']}; + border-radius: 0px; + letter-spacing: 2px; + """) + main_layout.addWidget(title) + + # Create scrollable area for controls + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + scroll_area.setStyleSheet(f""" + QScrollArea {{ + border: none; + background-color: transparent; + }} + QScrollBar:vertical {{ + background: {COLORS['bg_medium']}; + width: 10px; + margin: 0px; + }} + QScrollBar::handle:vertical {{ + background: {COLORS['border_glow']}; + min-height: 20px; + border-radius: 0px; + }} + QScrollBar::handle:vertical:hover {{ + background: {COLORS['accent_cyan']}; + }} + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ + height: 0px; + }} + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ + background: none; + }} + """) + + # Container widget for scrollable content + scroll_content = QWidget() + scroll_content.setStyleSheet(f"background-color: transparent;") + + # All controls in vertical layout + controls_layout = QVBoxLayout(scroll_content) + controls_layout.setContentsMargins(5, 5, 5, 5) + controls_layout.setSpacing(10) + + # Mode selection with icon + mode_container = QWidget() + mode_layout = QVBoxLayout(mode_container) + mode_layout.setContentsMargins(0, 0, 0, 0) + mode_layout.setSpacing(5) + + mode_label = QLabel("▸ MODE") + mode_label.setStyleSheet(f"color: {COLORS['text_glow']}; font-size: 10px; font-weight: bold; letter-spacing: 1px;") + mode_layout.addWidget(mode_label) + + self.mode_selector = QComboBox() + self.mode_selector.addItems(["AI-AI", "Human-AI"]) + self.mode_selector.setStyleSheet(self.get_combobox_style()) + mode_layout.addWidget(self.mode_selector) + controls_layout.addWidget(mode_container) + + # Iterations with slider + iterations_container = QWidget() + iterations_layout = QVBoxLayout(iterations_container) + iterations_layout.setContentsMargins(0, 0, 0, 0) + iterations_layout.setSpacing(5) + + iterations_label = QLabel("▸ ITERATIONS") + iterations_label.setStyleSheet(f"color: {COLORS['text_glow']}; font-size: 10px; font-weight: bold; letter-spacing: 1px;") + iterations_layout.addWidget(iterations_label) + + self.iterations_selector = QComboBox() + self.iterations_selector.addItems(["1", "2", "5", "6", "10", "100"]) + self.iterations_selector.setStyleSheet(self.get_combobox_style()) + iterations_layout.addWidget(self.iterations_selector) + controls_layout.addWidget(iterations_container) + + # Number of AIs selection + num_ais_container = QWidget() + num_ais_layout = QVBoxLayout(num_ais_container) + num_ais_layout.setContentsMargins(0, 0, 0, 0) + num_ais_layout.setSpacing(5) + + num_ais_label = QLabel("▸ NUMBER OF AIs") + num_ais_label.setStyleSheet(f"color: {COLORS['text_glow']}; font-size: 10px; font-weight: bold; letter-spacing: 1px;") + num_ais_layout.addWidget(num_ais_label) + + self.num_ais_selector = QComboBox() + self.num_ais_selector.addItems(["1", "2", "3", "4", "5"]) + self.num_ais_selector.setCurrentText("3") # Default to 3 AIs + self.num_ais_selector.setStyleSheet(self.get_combobox_style()) + num_ais_layout.addWidget(self.num_ais_selector) + controls_layout.addWidget(num_ais_container) + + # AI-1 Model selection + self.ai1_container = QWidget() + ai1_layout = QVBoxLayout(self.ai1_container) + ai1_layout.setContentsMargins(0, 0, 0, 0) + ai1_layout.setSpacing(5) + + ai1_label = QLabel("AI-1") + ai1_label.setStyleSheet(f"color: {COLORS['text_dim']}; font-size: 10px;") + ai1_layout.addWidget(ai1_label) + + self.ai1_model_selector = QComboBox() + self.ai1_model_selector.setStyleSheet(self.get_combobox_style()) + ai1_layout.addWidget(self.ai1_model_selector) + controls_layout.addWidget(self.ai1_container) + + # AI-2 Model selection + self.ai2_container = QWidget() + ai2_layout = QVBoxLayout(self.ai2_container) + ai2_layout.setContentsMargins(0, 0, 0, 0) + ai2_layout.setSpacing(5) + + ai2_label = QLabel("AI-2") + ai2_label.setStyleSheet(f"color: {COLORS['text_dim']}; font-size: 10px;") + ai2_layout.addWidget(ai2_label) + + self.ai2_model_selector = QComboBox() + self.ai2_model_selector.setStyleSheet(self.get_combobox_style()) + ai2_layout.addWidget(self.ai2_model_selector) + controls_layout.addWidget(self.ai2_container) + + # AI-3 Model selection + self.ai3_container = QWidget() + ai3_layout = QVBoxLayout(self.ai3_container) + ai3_layout.setContentsMargins(0, 0, 0, 0) + ai3_layout.setSpacing(5) + + ai3_label = QLabel("AI-3") + ai3_label.setStyleSheet(f"color: {COLORS['text_dim']}; font-size: 10px;") + ai3_layout.addWidget(ai3_label) + + self.ai3_model_selector = QComboBox() + self.ai3_model_selector.setStyleSheet(self.get_combobox_style()) + ai3_layout.addWidget(self.ai3_model_selector) + controls_layout.addWidget(self.ai3_container) + + # AI-4 Model selection + self.ai4_container = QWidget() + ai4_layout = QVBoxLayout(self.ai4_container) + ai4_layout.setContentsMargins(0, 0, 0, 0) + ai4_layout.setSpacing(5) + + ai4_label = QLabel("AI-4") + ai4_label.setStyleSheet(f"color: {COLORS['text_dim']}; font-size: 10px;") + ai4_layout.addWidget(ai4_label) + + self.ai4_model_selector = QComboBox() + self.ai4_model_selector.setStyleSheet(self.get_combobox_style()) + ai4_layout.addWidget(self.ai4_model_selector) + controls_layout.addWidget(self.ai4_container) + + # AI-5 Model selection + self.ai5_container = QWidget() + ai5_layout = QVBoxLayout(self.ai5_container) + ai5_layout.setContentsMargins(0, 0, 0, 0) + ai5_layout.setSpacing(5) + + ai5_label = QLabel("AI-5") + ai5_label.setStyleSheet(f"color: {COLORS['text_dim']}; font-size: 10px;") + ai5_layout.addWidget(ai5_label) + + self.ai5_model_selector = QComboBox() + self.ai5_model_selector.setStyleSheet(self.get_combobox_style()) + ai5_layout.addWidget(self.ai5_model_selector) + controls_layout.addWidget(self.ai5_container) + + # Prompt pair selection + prompt_container = QWidget() + prompt_layout = QVBoxLayout(prompt_container) + prompt_layout.setContentsMargins(0, 0, 0, 0) + prompt_layout.setSpacing(5) + + prompt_label = QLabel("Conversation Scenario") + prompt_label.setStyleSheet(f"color: {COLORS['text_dim']}; font-size: 10px;") + prompt_layout.addWidget(prompt_label) + + self.prompt_pair_selector = QComboBox() + self.prompt_pair_selector.setStyleSheet(self.get_combobox_style()) + prompt_layout.addWidget(self.prompt_pair_selector) + controls_layout.addWidget(prompt_container) + + # Action buttons container + action_container = QWidget() + action_layout = QVBoxLayout(action_container) + action_layout.setContentsMargins(0, 0, 0, 0) + action_layout.setSpacing(5) + + action_label = QLabel("▸ OPTIONS") + action_label.setStyleSheet(f"color: {COLORS['text_glow']}; font-size: 10px; font-weight: bold; letter-spacing: 1px;") + action_layout.addWidget(action_label) + + # Auto-generate images checkbox + self.auto_image_checkbox = QCheckBox("Auto-generate images") + self.auto_image_checkbox.setStyleSheet(f""" + QCheckBox {{ + color: {COLORS['text_normal']}; + spacing: 5px; + font-size: 10px; + padding: 4px; + }} + QCheckBox::indicator {{ + width: 18px; + height: 18px; + border: 1px solid {COLORS['border_glow']}; + border-radius: 0px; + background-color: {COLORS['bg_medium']}; + }} + QCheckBox::indicator:checked {{ + background-color: {COLORS['accent_cyan']}; + border: 1px solid {COLORS['accent_cyan']}; + }} + QCheckBox::indicator:hover {{ + border: 1px solid {COLORS['accent_cyan']}; + }} + """) + self.auto_image_checkbox.setToolTip("Automatically generate images from AI responses using Google Gemini 3 Pro Image Preview via OpenRouter") + action_layout.addWidget(self.auto_image_checkbox) + + # Actions - buttons in vertical layout + actions_label = QLabel("▸ ACTIONS") + actions_label.setStyleSheet(f"color: {COLORS['text_glow']}; font-size: 10px; font-weight: bold; letter-spacing: 1px;") + action_layout.addWidget(actions_label) + + # Export button with glow + self.export_button = self.create_glow_button("📡 EXPORT", COLORS['accent_purple']) + action_layout.addWidget(self.export_button) + + # Save/Load Session buttons + session_buttons_layout = QHBoxLayout() + self.save_session_button = self.create_glow_button("💾 SAVE", COLORS['accent_cyan']) + self.load_session_button = self.create_glow_button("📂 LOAD", COLORS['accent_cyan']) + session_buttons_layout.addWidget(self.save_session_button) + session_buttons_layout.addWidget(self.load_session_button) + action_layout.addLayout(session_buttons_layout) + + # View HTML button with glow - opens the styled conversation + self.view_html_button = self.create_glow_button("🌐 VIEW HTML", COLORS['accent_green']) + self.view_html_button.setToolTip("View conversation as shareable HTML") + self.view_html_button.clicked.connect(lambda: open_html_in_browser("conversation_full.html")) + action_layout.addWidget(self.view_html_button) + + # BackroomsBench evaluation button + self.backroomsbench_button = self.create_glow_button("🌀 BACKROOMSBENCH (beta)", COLORS['accent_purple']) + self.backroomsbench_button.setToolTip("Run multi-judge AI evaluation (depth/philosophy)") + action_layout.addWidget(self.backroomsbench_button) + + controls_layout.addWidget(action_container) + + # Add all controls directly to controls_layout (now vertical) + controls_layout.addWidget(mode_container) + controls_layout.addWidget(iterations_container) + controls_layout.addWidget(num_ais_container) + + # Divider + divider1 = QLabel("─" * 20) + divider1.setStyleSheet(f"color: {COLORS['border_glow']}; font-size: 8px;") + controls_layout.addWidget(divider1) + + models_label = QLabel("▸ AI MODELS") + models_label.setStyleSheet(f"color: {COLORS['text_glow']}; font-size: 10px; font-weight: bold; letter-spacing: 1px;") + controls_layout.addWidget(models_label) + + controls_layout.addWidget(self.ai1_container) + controls_layout.addWidget(self.ai2_container) + controls_layout.addWidget(self.ai3_container) + controls_layout.addWidget(self.ai4_container) + controls_layout.addWidget(self.ai5_container) + + # Divider + divider2 = QLabel("─" * 20) + divider2.setStyleSheet(f"color: {COLORS['border_glow']}; font-size: 8px;") + controls_layout.addWidget(divider2) + + scenario_label = QLabel("▸ SCENARIO") + scenario_label.setStyleSheet(f"color: {COLORS['text_glow']}; font-size: 10px; font-weight: bold; letter-spacing: 1px;") + controls_layout.addWidget(scenario_label) + + controls_layout.addWidget(prompt_container) + + # Divider + divider3 = QLabel("─" * 20) + divider3.setStyleSheet(f"color: {COLORS['border_glow']}; font-size: 8px;") + controls_layout.addWidget(divider3) + + controls_layout.addWidget(action_container) + + # Add spacer + controls_layout.addStretch() + + # Set the scroll area widget and add to main layout + scroll_area.setWidget(scroll_content) + main_layout.addWidget(scroll_area, 1) # Stretch to fill + + def get_combobox_style(self): + """Get the style for comboboxes - cyberpunk themed""" + return f""" + QComboBox {{ + background-color: {COLORS['bg_medium']}; + color: {COLORS['text_normal']}; + border: 1px solid {COLORS['border_glow']}; + border-radius: 0px; + padding: 8px 10px; + min-height: 30px; + font-size: 10px; + }} + QComboBox:hover {{ + border: 1px solid {COLORS['accent_cyan']}; + color: {COLORS['text_bright']}; + }} + QComboBox::drop-down {{ + subcontrol-origin: padding; + subcontrol-position: top right; + width: 20px; + border-left: 1px solid {COLORS['border_glow']}; + border-radius: 0px; + }} + QComboBox::down-arrow {{ + width: 12px; + height: 12px; + image: none; + }} + QComboBox QAbstractItemView {{ + background-color: {COLORS['bg_dark']}; + color: {COLORS['text_normal']}; + selection-background-color: {COLORS['accent_cyan']}; + selection-color: {COLORS['bg_dark']}; + border: 1px solid {COLORS['border_glow']}; + border-radius: 0px; + padding: 4px; + }} + QComboBox QAbstractItemView::item {{ + min-height: 28px; + padding: 4px; + }} + """ + + def get_cyberpunk_button_style(self, accent_color): + """Get cyberpunk-themed button style with given accent color""" + return f""" + QPushButton {{ + background-color: {COLORS['bg_medium']}; + color: {accent_color}; + border: 2px solid {accent_color}; + border-radius: 3px; + padding: 10px 14px; + font-weight: bold; + font-size: 10px; + letter-spacing: 1px; + text-align: center; + }} + QPushButton:hover {{ + background-color: {accent_color}; + color: {COLORS['bg_dark']}; + border: 2px solid {accent_color}; + }} + QPushButton:pressed {{ + background-color: {COLORS['bg_light']}; + color: {accent_color}; + }} + """ + + def create_glow_button(self, text, accent_color): + """Create a button with glow effect""" + button = GlowButton(text, accent_color) + button.setStyleSheet(self.get_cyberpunk_button_style(accent_color)) + return button + + def initialize_selectors(self): + """Initialize the selector dropdowns with values from config""" + # Add AI models + self.ai1_model_selector.clear() + self.ai2_model_selector.clear() + self.ai3_model_selector.clear() + self.ai4_model_selector.clear() + self.ai5_model_selector.clear() + self.ai1_model_selector.addItems(list(AI_MODELS.keys())) + self.ai2_model_selector.addItems(list(AI_MODELS.keys())) + self.ai3_model_selector.addItems(list(AI_MODELS.keys())) + self.ai4_model_selector.addItems(list(AI_MODELS.keys())) + self.ai5_model_selector.addItems(list(AI_MODELS.keys())) + + # Add prompt pairs + self.prompt_pair_selector.clear() + self.prompt_pair_selector.addItems(list(SYSTEM_PROMPT_PAIRS.keys())) + + # Connect number of AIs selector to update visibility + self.num_ais_selector.currentTextChanged.connect(self.update_ai_selector_visibility) + + # Set initial visibility based on default number of AIs (3) + self.update_ai_selector_visibility("3") + + def update_ai_selector_visibility(self, num_ais_text): + """Show/hide AI model selectors based on number of AIs selected""" + num_ais = int(num_ais_text) + self.ai1_container.setVisible(num_ais >= 1) + self.ai2_container.setVisible(num_ais >= 2) + self.ai3_container.setVisible(num_ais >= 3) + self.ai4_container.setVisible(num_ais >= 4) + self.ai5_container.setVisible(num_ais >= 5) diff --git a/src/ui/conversation_pane.py b/src/ui/conversation_pane.py new file mode 100644 index 0000000..4fdbc50 --- /dev/null +++ b/src/ui/conversation_pane.py @@ -0,0 +1,936 @@ +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTextEdit, QPushButton, + QMenu, QFileDialog, QMessageBox, QSizePolicy +) +from PyQt6.QtGui import QAction, QTextCursor, QTextCharFormat, QFont, QColor, QPixmap, QImage +from PyQt6.QtCore import Qt, pyqtSignal, QEvent, QTimer, QPropertyAnimation +import os +import base64 +from datetime import datetime +import shutil +from src.ui.colors import COLORS +from src.ui.widgets.custom_widgets import GlowButton + +class ConversationContextMenu(QMenu): + """Context menu for the conversation display""" + rabbitholeSelected = pyqtSignal() + forkSelected = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setStyleSheet(""" + QMenu { + background-color: #2D2D30; + color: #D4D4D4; + border: 1px solid #3E3E42; + } + QMenu::item { + padding: 5px 20px 5px 20px; + } + QMenu::item:selected { + background-color: #3E3E42; + } + """) + +class ConversationPane(QWidget): + """Left pane containing the conversation and input area""" + def __init__(self): + super().__init__() + + # Set up the UI + self.setup_ui() + + # Connect signals and slots + self.connect_signals() + + # Initialize state + self.conversation = [] + self.input_callback = None + self.rabbithole_callback = None + self.fork_callback = None + self.loading = False + self.loading_dots = 0 + self.loading_timer = QTimer() + self.loading_timer.timeout.connect(self.update_loading_animation) + self.loading_timer.setInterval(300) # Update every 300ms for smoother animation + + # Context menu + self.context_menu = ConversationContextMenu(self) + + # Initialize with empty conversation + self.update_conversation([]) + + # Images list - to prevent garbage collection + self.images = [] + self.image_paths = [] + + # Uploaded image for current message + self.uploaded_image_path = None + self.uploaded_image_base64 = None + + # Create text formats with different colors + self.text_formats = { + "user": QTextCharFormat(), + "ai": QTextCharFormat(), + "system": QTextCharFormat(), + "ai_label": QTextCharFormat(), + "normal": QTextCharFormat(), + "error": QTextCharFormat() + } + + # Configure text formats using global color palette + self.text_formats["user"].setForeground(QColor(COLORS['text_normal'])) + self.text_formats["ai"].setForeground(QColor(COLORS['text_normal'])) + self.text_formats["system"].setForeground(QColor(COLORS['text_normal'])) + self.text_formats["ai_label"].setForeground(QColor(COLORS['accent_blue'])) + self.text_formats["normal"].setForeground(QColor(COLORS['text_normal'])) + self.text_formats["error"].setForeground(QColor(COLORS['text_error'])) + + # Make AI labels bold + self.text_formats["ai_label"].setFontWeight(QFont.Weight.Bold) + + def setup_ui(self): + """Set up the user interface for the conversation pane""" + # Main layout + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(5) # Reduced spacing + + # Title and info area + title_layout = QHBoxLayout() + self.title_label = QLabel("╔═ LIMINAL BACKROOMS ═╗") + self.title_label.setStyleSheet(f""" + color: {COLORS['accent_cyan']}; + font-size: 14px; + font-weight: bold; + padding: 4px; + letter-spacing: 2px; + """) + + self.info_label = QLabel("[ AI-TO-AI PROPAGATION ]") + self.info_label.setStyleSheet(f""" + color: {COLORS['text_glow']}; + font-size: 10px; + padding: 2px; + letter-spacing: 1px; + """) + + title_layout.addWidget(self.title_label) + title_layout.addStretch() + title_layout.addWidget(self.info_label) + + layout.addLayout(title_layout) + + # Conversation display (read-only text edit in a scroll area) + self.conversation_display = QTextEdit() + self.conversation_display.setReadOnly(True) + self.conversation_display.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.conversation_display.customContextMenuRequested.connect(self.show_context_menu) + + # Set font for conversation display - use Iosevka Term for better ASCII art rendering + font = QFont("Iosevka Term", 10) + # Set fallbacks in case Iosevka Term isn't loaded + font.setStyleHint(QFont.StyleHint.Monospace) + self.conversation_display.setFont(font) + + # Apply cyberpunk styling + self.conversation_display.setStyleSheet(f""" + QTextEdit {{ + background-color: {COLORS['bg_dark']}; + color: {COLORS['text_normal']}; + border: 1px solid {COLORS['border_glow']}; + border-radius: 0px; + padding: 15px; + selection-background-color: {COLORS['accent_cyan']}; + selection-color: {COLORS['bg_dark']}; + }} + QScrollBar:vertical {{ + background: {COLORS['bg_medium']}; + width: 10px; + margin: 0px; + }} + QScrollBar::handle:vertical {{ + background: {COLORS['border_glow']}; + min-height: 20px; + border-radius: 0px; + }} + QScrollBar::handle:vertical:hover {{ + background: {COLORS['accent_cyan']}; + }} + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ + height: 0px; + }} + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ + background: none; + }} + """) + + # Input area with label + input_container = QWidget() + input_layout = QVBoxLayout(input_container) + input_layout.setContentsMargins(0, 0, 0, 0) + input_layout.setSpacing(2) # Reduced spacing + + input_label = QLabel("Your message:") + input_label.setStyleSheet(f"color: {COLORS['text_dim']}; font-size: 11px;") + input_layout.addWidget(input_label) + + # Input field with modern styling + self.input_field = QTextEdit() + self.input_field.setPlaceholderText("Seed the conversation or just click propagate...") + self.input_field.setMaximumHeight(60) # Reduced height + self.input_field.setFont(font) + self.input_field.setStyleSheet(f""" + QTextEdit {{ + background-color: {COLORS['bg_medium']}; + color: {COLORS['text_normal']}; + border: 1px solid {COLORS['border_glow']}; + border-radius: 0px; + padding: 8px; + selection-background-color: {COLORS['accent_cyan']}; + selection-color: {COLORS['bg_dark']}; + }} + """) + input_layout.addWidget(self.input_field) + + # Button container for better layout + button_container = QWidget() + button_layout = QHBoxLayout(button_container) + button_layout.setContentsMargins(0, 0, 0, 0) + button_layout.setSpacing(5) # Reduced spacing + + # Upload image button + self.upload_image_button = QPushButton("📎 IMAGE") + self.upload_image_button.setStyleSheet(f""" + QPushButton {{ + background-color: {COLORS['bg_medium']}; + color: {COLORS['text_normal']}; + border: 1px solid {COLORS['border_glow']}; + border-radius: 0px; + padding: 6px 10px; + font-weight: bold; + font-size: 10px; + letter-spacing: 1px; + }} + QPushButton:hover {{ + background-color: {COLORS['bg_light']}; + border: 1px solid {COLORS['accent_cyan']}; + color: {COLORS['accent_cyan']}; + }} + QPushButton:pressed {{ + background-color: {COLORS['border_glow']}; + }} + """) + self.upload_image_button.setToolTip("Upload an image to include in your message") + + # Clear button with subtle glow + self.clear_button = GlowButton("CLEAR", COLORS['accent_pink']) + self.clear_button.shadow.setBlurRadius(5) # Subtler glow + self.clear_button.base_blur = 5 + self.clear_button.hover_blur = 12 + self.clear_button.setStyleSheet(f""" + QPushButton {{ + background-color: {COLORS['bg_medium']}; + color: {COLORS['text_normal']}; + border: 1px solid {COLORS['border_glow']}; + border-radius: 3px; + padding: 8px 12px; + font-weight: bold; + font-size: 10px; + letter-spacing: 1px; + }} + QPushButton:hover {{ + background-color: {COLORS['bg_light']}; + border: 2px solid {COLORS['accent_pink']}; + color: {COLORS['accent_pink']}; + }} + QPushButton:pressed {{ + background-color: {COLORS['border_glow']}; + }} + """) + + # Submit button with cyberpunk styling and glow effect + self.submit_button = GlowButton("⚡ PROPAGATE", COLORS['accent_cyan']) + self.submit_button.setStyleSheet(f""" + QPushButton {{ + background-color: {COLORS['accent_cyan']}; + color: {COLORS['bg_dark']}; + border: 2px solid {COLORS['accent_cyan']}; + border-radius: 3px; + padding: 8px 20px; + font-weight: bold; + font-size: 11px; + letter-spacing: 2px; + }} + QPushButton:hover {{ + background-color: {COLORS['bg_dark']}; + color: {COLORS['accent_cyan']}; + border: 2px solid {COLORS['accent_cyan']}; + }} + QPushButton:pressed {{ + background-color: {COLORS['accent_cyan_active']}; + color: {COLORS['text_bright']}; + }} + QPushButton:disabled {{ + background-color: {COLORS['border']}; + color: {COLORS['text_dim']}; + border: 2px solid {COLORS['border']}; + }} + """) + + # Add buttons to layout + button_layout.addWidget(self.upload_image_button) + button_layout.addWidget(self.clear_button) + button_layout.addStretch() + button_layout.addWidget(self.submit_button) + + # Add input container to main layout + input_layout.addWidget(button_container) + + # Add widgets to layout with adjusted stretch factors + layout.addWidget(self.conversation_display, 1) # Main conversation area gets most space + layout.addWidget(input_container, 0) # Input area gets minimal space + + def connect_signals(self): + """Connect signals and slots""" + # Submit button + self.submit_button.clicked.connect(self.handle_propagate_click) + + # Upload image button + self.upload_image_button.clicked.connect(self.handle_upload_image) + + # Clear button + self.clear_button.clicked.connect(self.clear_input) + + # Enter key in input field + self.input_field.installEventFilter(self) + + def clear_input(self): + """Clear the input field""" + self.input_field.clear() + self.uploaded_image_path = None + self.uploaded_image_base64 = None + self.upload_image_button.setText("📎 Image") + self.input_field.setFocus() + + def handle_upload_image(self): + """Handle image upload button click""" + # Open file dialog + file_dialog = QFileDialog() + file_path, _ = file_dialog.getOpenFileName( + self, + "Select Image", + "", + "Image Files (*.png *.jpg *.jpeg *.gif *.webp);;All Files (*)" + ) + + if file_path: + try: + # Read and encode the image to base64 + with open(file_path, 'rb') as image_file: + image_data = image_file.read() + image_base64 = base64.b64encode(image_data).decode('utf-8') + + # Determine media type + file_extension = os.path.splitext(file_path)[1].lower() + media_type_map = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp' + } + media_type = media_type_map.get(file_extension, 'image/jpeg') + + # Store the image data + self.uploaded_image_path = file_path + self.uploaded_image_base64 = { + 'data': image_base64, + 'media_type': media_type + } + + # Update button text to show an image is attached + file_name = os.path.basename(file_path) + self.upload_image_button.setText(f"📎 {file_name[:15]}...") + + # Update placeholder text + self.input_field.setPlaceholderText("Add a message about your image (optional)...") + + except Exception as e: + QMessageBox.warning( + self, + "Upload Error", + f"Failed to load image: {str(e)}" + ) + + def eventFilter(self, obj, event): + """Filter events to handle Enter key in input field""" + if obj is self.input_field and event.type() == QEvent.Type.KeyPress: + if event.key() == Qt.Key.Key_Return and not event.modifiers() & Qt.KeyboardModifier.ShiftModifier: + self.handle_propagate_click() + return True + return super().eventFilter(obj, event) + + def handle_propagate_click(self): + """Handle click on the propagate button""" + # Get the input text (might be empty) + input_text = self.input_field.toPlainText().strip() + + # Prepare message data (text + optional image) + message_data = { + 'text': input_text, + 'image': None + } + + # Include image if one was uploaded + if self.uploaded_image_base64: + message_data['image'] = { + 'path': self.uploaded_image_path, + 'base64': self.uploaded_image_base64['data'], + 'media_type': self.uploaded_image_base64['media_type'] + } + + # Clear the input box and image + self.input_field.clear() + self.uploaded_image_path = None + self.uploaded_image_base64 = None + self.upload_image_button.setText("📎 Image") + self.input_field.setPlaceholderText("Seed the conversation or just click propagate...") + + # Always call the input callback, even with empty input + if hasattr(self, 'input_callback') and self.input_callback: + self.input_callback(message_data) + + # Start loading animation + self.start_loading() + + def set_input_callback(self, callback): + """Set callback function for input submission""" + self.input_callback = callback + + def set_rabbithole_callback(self, callback): + """Set callback function for rabbithole creation""" + self.rabbithole_callback = callback + + def set_fork_callback(self, callback): + """Set callback function for fork creation""" + self.fork_callback = callback + + def update_conversation(self, conversation): + """Update conversation display""" + self.conversation = conversation + self.render_conversation() + + def render_conversation(self): + """Render conversation in the display""" + # Save scroll position before re-rendering + scrollbar = self.conversation_display.verticalScrollBar() + old_scroll_value = scrollbar.value() + old_scroll_max = scrollbar.maximum() + was_at_bottom = old_scroll_value >= old_scroll_max - 20 + + # Clear display + self.conversation_display.clear() + + # Create HTML for conversation with modern styling + html = "" + + for i, message in enumerate(self.conversation): + role = message.get("role", "") + content = message.get("content", "") + ai_name = message.get("ai_name", "") + model = message.get("model", "") + + # Handle structured content (with images) + has_image = False + image_base64 = None + generated_image_path = None + text_content = "" + + # Check for generated image path (from AI image generation) + if hasattr(message, "get") and callable(message.get): + generated_image_path = message.get("generated_image_path", None) + if generated_image_path and os.path.exists(generated_image_path): + has_image = True + + if isinstance(content, list): + # Structured content with potential images + for part in content: + if part.get('type') == 'text': + text_content += part.get('text', '') + elif part.get('type') == 'image': + has_image = True + source = part.get('source', {}) + if source.get('type') == 'base64': + image_base64 = source.get('data', '') + else: + # Plain text content + text_content = content + + # Skip empty messages (no text and no image) + if not text_content and not has_image: + continue + + # Handle branch indicators with special styling + if role == 'system' and message.get('_type') == 'branch_indicator': + if "Rabbitholing down:" in content: + html += f'
{content}
' + elif "Forking off:" in content: + html += f'
{content}
' + continue + + # Handle agent notifications with special styling + if role == 'system' and message.get('_type') == 'agent_notification': + html += f'
{text_content}
' + continue + + # Handle generated images with special styling + if message.get('_type') == 'generated_image': + creator = message.get('ai_name', 'AI') + model = message.get('model', '') + creator_display = f"{creator} ({model})" if model else creator + if generated_image_path and os.path.exists(generated_image_path): + file_url = f"file:///{generated_image_path.replace(os.sep, '/')}" + html += f'
' + html += f'
🎨 {creator_display} created an image
' + html += f'' + if text_content: + # Extract just the prompt part + html += f'
{text_content}
' + html += f'
' + continue + + # Process content to handle code blocks + processed_content = self.process_content_with_code_blocks(text_content) if text_content else "" + + # Add image display if present + image_html = "" + if has_image: + if image_base64: + image_html = f'
' + elif generated_image_path and os.path.exists(generated_image_path): + # Use file:// URL for local generated images + file_url = f"file:///{generated_image_path.replace(os.sep, '/')}" + image_html = f'
🎨 Generated image
' + + # Format based on role + if role == 'user': + # User message + html += f'
' + if image_html: + html += image_html + if processed_content: + html += f'
{processed_content}
' + html += f'
' + elif role == 'assistant': + # AI message + display_name = ai_name + if model: + display_name += f" ({model})" + html += f'
' + html += f'
\n{display_name}\n
' + if image_html: + html += image_html + if processed_content: + html += f'
{processed_content}
' + + html += f'
' + elif role == 'system': + # System message + html += f'
' + html += f'
{processed_content}
' + html += f'
' + + # Set HTML in display + self.conversation_display.setHtml(html) + + # Restore scroll position + if was_at_bottom: + self.conversation_display.verticalScrollBar().setValue( + self.conversation_display.verticalScrollBar().maximum() + ) + else: + new_max = self.conversation_display.verticalScrollBar().maximum() + if old_scroll_max > 0 and new_max > 0: + self.conversation_display.verticalScrollBar().setValue( + min(old_scroll_value, new_max) + ) + else: + self.conversation_display.verticalScrollBar().setValue(old_scroll_value) + + def process_content_with_code_blocks(self, content): + """Process content to properly format code blocks""" + import re + from html import escape + + # First, escape HTML in the content + escaped_content = escape(content) + + # Check if there are any code blocks in the content + if "```" not in escaped_content: + return escaped_content + + # Split the content by code block markers + parts = re.split(r'(```(?:[a-zA-Z0-9_]*)\n.*?```)', escaped_content, flags=re.DOTALL) + + result = [] + for part in parts: + if part.startswith("```") and part.endswith("```"): + # This is a code block + try: + # Extract language if specified + language_match = re.match(r'```([a-zA-Z0-9_]*)\n', part) + language = language_match.group(1) if language_match else "" + + # Extract code content + code_content = part[part.find('\n')+1:part.rfind('```')] + + # Format as HTML + formatted_code = f'
{code_content}
' + result.append(formatted_code) + except Exception as e: + # If there's an error, just add the original escaped content + print(f"Error processing code block: {e}") + result.append(part) + else: + # Process inline code in non-code-block parts + inline_parts = re.split(r'(`[^`]+`)', part) + processed_part = [] + + for inline_part in inline_parts: + if inline_part.startswith("`") and inline_part.endswith("`") and len(inline_part) > 2: + # This is inline code + code = inline_part[1:-1] + processed_part.append(f'{code}') + else: + processed_part.append(inline_part) + + result.append(''.join(processed_part)) + + return ''.join(result) + + def start_loading(self): + """Start loading animation""" + self.loading = True + self.loading_dots = 0 + self.input_field.setEnabled(False) + self.submit_button.setEnabled(False) + self.submit_button.setText("Processing") + self.loading_timer.start() + + # Add subtle pulsing animation to the button + self.pulse_animation = QPropertyAnimation(self.submit_button, b"styleSheet") + self.pulse_animation.setDuration(1000) + self.pulse_animation.setLoopCount(-1) # Infinite loop + + # Define keyframes for the animation + normal_style = f""" + QPushButton {{ + background-color: {COLORS['border']}; + color: {COLORS['text_dim']}; + border: none; + border-radius: 4px; + padding: 4px 12px; + font-weight: bold; + font-size: 11px; + }} + """ + + pulse_style = f""" + QPushButton {{ + background-color: {COLORS['border_highlight']}; + color: {COLORS['text_dim']}; + border: none; + border-radius: 4px; + padding: 4px 12px; + font-weight: bold; + font-size: 11px; + }} + """ + + self.pulse_animation.setStartValue(normal_style) + self.pulse_animation.setEndValue(pulse_style) + self.pulse_animation.start() + + def stop_loading(self): + """Stop loading animation""" + self.loading = False + self.loading_timer.stop() + self.input_field.setEnabled(True) + self.submit_button.setEnabled(True) + self.submit_button.setText("Propagate") + + # Stop the pulsing animation + if hasattr(self, 'pulse_animation'): + self.pulse_animation.stop() + + # Reset button style + self.submit_button.setStyleSheet(f""" + QPushButton {{ + background-color: {COLORS['accent_cyan']}; + color: {COLORS['bg_dark']}; + border: 1px solid {COLORS['accent_cyan']}; + border-radius: 0px; + padding: 6px 16px; + font-weight: bold; + font-size: 11px; + letter-spacing: 1px; + }} + QPushButton:hover {{ + background-color: {COLORS['bg_dark']}; + color: {COLORS['accent_cyan']}; + }} + QPushButton:pressed {{ + background-color: {COLORS['accent_cyan_active']}; + color: {COLORS['text_bright']}; + }} + """) + + def update_loading_animation(self): + """Update loading animation dots""" + self.loading_dots = (self.loading_dots + 1) % 4 + dots = "." * self.loading_dots + self.submit_button.setText(f"Processing{dots}") + + def show_context_menu(self, position): + """Show context menu at the given position""" + # Get selected text + cursor = self.conversation_display.textCursor() + selected_text = cursor.selectedText() + + # Only show context menu if text is selected + if selected_text: + # Show the context menu at cursor position + self.context_menu.exec(self.conversation_display.mapToGlobal(position)) + + def rabbithole_from_selection(self): + """Create a rabbithole branch from selected text""" + cursor = self.conversation_display.textCursor() + selected_text = cursor.selectedText() + + if selected_text and hasattr(self, 'rabbithole_callback'): + self.rabbithole_callback(selected_text) + + def fork_from_selection(self): + """Create a fork branch from selected text""" + cursor = self.conversation_display.textCursor() + selected_text = cursor.selectedText() + + if selected_text and hasattr(self, 'fork_callback'): + self.fork_callback(selected_text) + + def append_text(self, text, format_type="normal"): + """Append text to the conversation display with the specified format""" + # Check if user is at the bottom before appending + scrollbar = self.conversation_display.verticalScrollBar() + was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 20 + + cursor = self.conversation_display.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + + # Apply the format if specified + if format_type in self.text_formats: + self.conversation_display.setCurrentCharFormat(self.text_formats[format_type]) + + # Insert the text + cursor.insertText(text) + + # Reset to normal format after insertion + if format_type != "normal": + self.conversation_display.setCurrentCharFormat(self.text_formats["normal"]) + + # Only auto-scroll if user was already at the bottom + if was_at_bottom: + self.conversation_display.setTextCursor(cursor) + self.conversation_display.ensureCursorVisible() + + def clear_conversation(self): + """Clear the conversation display""" + self.conversation_display.clear() + self.images = [] + + def display_conversation(self, conversation, branch_data=None): + """Display the conversation in the text edit widget""" + self.conversation = conversation + + # Check if we're in a branch + is_branch = branch_data is not None + branch_type = branch_data.get('type', '') if is_branch else '' + selected_text = branch_data.get('selected_text', '') if is_branch else '' + + # Update title if in a branch + if is_branch: + branch_emoji = "🐇" if branch_type == "rabbithole" else "🍴" + self.title_label.setText(f"{branch_emoji} {branch_type.capitalize()}: {selected_text[:30]}...") + self.info_label.setText(f"Branch conversation") + else: + self.title_label.setText("Liminal Backrooms") + self.info_label.setText("AI-to-AI conversation") + + # Render conversation + self.render_conversation() + + def display_image(self, image_path): + """Display an image in the conversation""" + try: + # Check if the image path is valid + if not image_path or not os.path.exists(image_path): + self.append_text("[Image not found]\n", "error") + return + + # Load the image + image = QImage(image_path) + if image.isNull(): + self.append_text("[Invalid image format]\n", "error") + return + + # Create a pixmap from the image + pixmap = QPixmap.fromImage(image) + + # Scale the image to fit the conversation display + max_width = self.conversation_display.width() - 50 + if pixmap.width() > max_width: + pixmap = pixmap.scaledToWidth(max_width, Qt.TransformationMode.SmoothTransformation) + + # Insert the image into the conversation display + cursor = self.conversation_display.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + cursor.insertImage(pixmap.toImage()) + cursor.insertText("\n\n") + + # Store the image to prevent garbage collection + self.images.append(pixmap) + self.image_paths.append(image_path) + + except Exception as e: + self.append_text(f"[Error displaying image: {str(e)}]\n", "error") + + def export_conversation(self): + """Export the conversation and all session media to a folder""" + base_dir = os.path.join(os.getcwd(), "exports") + + # Create the base directory if it doesn't exist + os.makedirs(base_dir, exist_ok=True) + + # Generate a session folder name based on date/time + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + default_folder = os.path.join(base_dir, f"session_{timestamp}") + + # Get the folder from a dialog + folder_name = QFileDialog.getExistingDirectory( + self, + "Select Export Folder (or create new)", + base_dir, + QFileDialog.Option.ShowDirsOnly + ) + + if not folder_name: + reply = QMessageBox.question( + self, + "Create Export Folder?", + f"Create new export folder?\n\n{default_folder}", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + if reply == QMessageBox.StandardButton.Yes: + folder_name = default_folder + else: + return + + try: + # Create the export folder + os.makedirs(folder_name, exist_ok=True) + + # Get main window for accessing session data + main_window = self.window() + + # Export conversation as multiple formats + # Plain text + text_path = os.path.join(folder_name, "conversation.txt") + with open(text_path, 'w', encoding='utf-8') as f: + f.write(self.conversation_display.toPlainText()) + + # HTML + html_path = os.path.join(folder_name, "conversation.html") + with open(html_path, 'w', encoding='utf-8') as f: + f.write(self.conversation_display.toHtml()) + + # Full HTML document if it exists + full_html_path = os.path.join(os.getcwd(), "conversation_full.html") + if os.path.exists(full_html_path): + shutil.copy2(full_html_path, os.path.join(folder_name, "conversation_full.html")) + + # Copy session images + images_copied = 0 + if hasattr(main_window, 'right_sidebar') and hasattr(main_window.right_sidebar, 'image_preview_pane'): + session_images = main_window.right_sidebar.image_preview_pane.session_images + if session_images: + images_dir = os.path.join(folder_name, "images") + os.makedirs(images_dir, exist_ok=True) + for img_path in session_images: + if os.path.exists(img_path): + shutil.copy2(img_path, images_dir) + images_copied += 1 + + # Copy session videos + videos_copied = 0 + if hasattr(main_window, 'session_videos'): + session_videos = main_window.session_videos + if session_videos: + videos_dir = os.path.join(folder_name, "videos") + os.makedirs(videos_dir, exist_ok=True) + for vid_path in session_videos: + if os.path.exists(vid_path): + shutil.copy2(vid_path, videos_dir) + videos_copied += 1 + + # Create a manifest/summary file + manifest_path = os.path.join(folder_name, "manifest.txt") + with open(manifest_path, 'w', encoding='utf-8') as f: + f.write(f"Liminal Backrooms Session Export\n") + f.write(f"================================\n") + f.write(f"Exported: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n") + f.write(f"Contents:\n") + f.write(f"- conversation.txt (plain text)\n") + f.write(f"- conversation.html (HTML format)\n") + if os.path.exists(os.path.join(folder_name, "conversation_full.html")): + f.write(f"- conversation_full.html (styled document)\n") + f.write(f"- images/ ({images_copied} files)\n") + f.write(f"- videos/ ({videos_copied} files)\n") + + # Status message + status_msg = f"Exported to {folder_name} ({images_copied} images, {videos_copied} videos)" + if main_window.statusBar(): + main_window.statusBar().showMessage(status_msg) + print(f"Session exported to {folder_name}") + + # Show success message + QMessageBox.information( + self, + "Export Complete", + f"Session exported successfully!\n\n" + f"Location: {folder_name}\n\n" + f"• Conversation (txt, html)\n" + f"• {images_copied} images\n" + f"• {videos_copied} videos" + ) + + except Exception as e: + error_msg = f"Error exporting session: {str(e)}" + QMessageBox.critical(self, "Export Error", error_msg) + print(error_msg) + import traceback + traceback.print_exc() diff --git a/src/ui/main_window.py b/src/ui/main_window.py new file mode 100644 index 0000000..507606b --- /dev/null +++ b/src/ui/main_window.py @@ -0,0 +1,435 @@ +from PyQt6.QtWidgets import ( + QMainWindow, QWidget, QHBoxLayout, QSplitter, QLabel, QCheckBox +) +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QPainter, QRadialGradient, QColor, QPen +import os +import math +import random +import json + +from src.ui.colors import COLORS +from src.ui.conversation_pane import ConversationPane +from src.ui.sidebar import RightSidebar +from src.ui.widgets.custom_widgets import SignalIndicator, ScanlineOverlayWidget +from src.core.conversation_manager import ConversationManager + +class CentralContainer(QWidget): + """Central container widget with animated background and overlay support""" + + def __init__(self, parent=None): + super().__init__(parent) + + # Background animation state + self.bg_offset = 0 + self.noise_offset = 0 + + # Animation timer for background + self.bg_timer = QTimer(self) + self.bg_timer.timeout.connect(self._animate_bg) + self.bg_timer.start(80) # ~12 FPS for subtle movement + + # Create scanline overlay as child widget + self.scanline_overlay = ScanlineOverlayWidget(self) + self.scanline_overlay.hide() + + def _animate_bg(self): + self.bg_offset = (self.bg_offset + 1) % 360 + self.noise_offset = (self.noise_offset + 0.5) % 100 + self.update() + + def set_scanlines_enabled(self, enabled): + """Toggle scanline effect""" + if enabled: + # Ensure overlay has correct geometry before showing + self.scanline_overlay.setGeometry(self.rect()) + self.scanline_overlay.show() + self.scanline_overlay.raise_() + self.scanline_overlay.start_animation() + else: + self.scanline_overlay.stop_animation() + self.scanline_overlay.hide() + + def resizeEvent(self, event): + """Update scanline overlay size when container resizes""" + super().resizeEvent(event) + self.scanline_overlay.setGeometry(self.rect()) + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # ═══ ANIMATED BACKGROUND ═══ + # Create shifting gradient with more visible movement + center_x = self.width() / 2 + math.sin(math.radians(self.bg_offset)) * 100 + center_y = self.height() / 2 + math.cos(math.radians(self.bg_offset * 0.7)) * 60 + + gradient = QRadialGradient(center_x, center_y, max(self.width(), self.height()) * 0.9) + + # More visible atmospheric colors with cyan tint + pulse = 0.5 + 0.5 * math.sin(math.radians(self.bg_offset * 2)) + center_r = int(10 + 8 * pulse) + center_g = int(15 + 10 * pulse) + center_b = int(30 + 15 * pulse) + + gradient.setColorAt(0, QColor(center_r, center_g, center_b)) + gradient.setColorAt(0.4, QColor(10, 14, 26)) + gradient.setColorAt(1, QColor(6, 8, 14)) + + painter.fillRect(self.rect(), gradient) + + # Add subtle glow lines at edges + glow_alpha = int(15 + 10 * pulse) + glow_color = QColor(6, 182, 212, glow_alpha) # Cyan glow + painter.setPen(QPen(glow_color, 2)) + + # Top edge glow + painter.drawLine(0, 0, self.width(), 0) + # Bottom edge glow + painter.drawLine(0, self.height() - 1, self.width(), self.height() - 1) + # Left edge glow + painter.drawLine(0, 0, 0, self.height()) + # Right edge glow + painter.drawLine(self.width() - 1, 0, self.width() - 1, self.height()) + + # Add subtle noise/grain pattern + noise_color = QColor(COLORS['accent_cyan']) + noise_color.setAlpha(8) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(noise_color) + + # Sparse random dots for grain effect + random.seed(int(self.noise_offset)) + for _ in range(50): + x = random.randint(0, self.width()) + y = random.randint(0, self.height()) + painter.drawEllipse(x, y, 1, 1) + +class LiminalBackroomsApp(QMainWindow): + """Main application window""" + def __init__(self): + super().__init__() + + # Main app state + self.conversation = [] + self.turn_count = 0 + self.images = [] + self.image_paths = [] + self.session_videos = [] + self.branch_conversations = {} + self.active_branch = None + self.muted_ais = set() + + # Set up the UI + self.setup_ui() + + # Connect signals and slots + self.connect_signals() + + # Dark theme + self.apply_dark_theme() + + # Restore splitter state if available + self.restore_splitter_state() + + # Start maximized + self.showMaximized() + + # Create conversation manager + self.conversation_manager = ConversationManager(self) + self.conversation_manager.initialize() + + def setup_ui(self): + """Set up the user interface""" + self.setWindowTitle("╔═ LIMINAL BACKROOMS v0.7 ═╗") + self.setGeometry(100, 100, 1600, 900) + self.setMinimumSize(1200, 800) + + self.central_container = CentralContainer() + self.setCentralWidget(self.central_container) + + main_layout = QHBoxLayout(self.central_container) + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.setSpacing(5) + + self.splitter = QSplitter(Qt.Orientation.Horizontal) + self.splitter.setHandleWidth(8) + self.splitter.setChildrenCollapsible(False) + self.splitter.setStyleSheet(f""" + QSplitter::handle {{ + background-color: {COLORS['border']}; + border: 1px solid {COLORS['border_highlight']}; + border-radius: 2px; + }} + QSplitter::handle:hover {{ + background-color: {COLORS['accent_blue']}; + }} + """) + main_layout.addWidget(self.splitter) + + self.left_pane = ConversationPane() + self.right_sidebar = RightSidebar() + + self.splitter.addWidget(self.left_pane) + self.splitter.addWidget(self.right_sidebar) + + total_width = 1600 + self.splitter.setSizes([int(total_width * 0.70), int(total_width * 0.30)]) + + self.right_sidebar.add_node('main', 'Seed', 'main') + + # Status bar + self.statusBar().setStyleSheet(f""" + QStatusBar {{ + background-color: {COLORS['bg_dark']}; + color: {COLORS['text_dim']}; + border-top: 1px solid {COLORS['border']}; + padding: 3px; + font-size: 11px; + }} + """) + self.statusBar().showMessage("Ready") + + self.notification_label = QLabel("") + self.notification_label.setStyleSheet(f""" + QLabel {{ + color: {COLORS['accent_cyan']}; + font-size: 11px; + padding: 2px 10px; + background-color: transparent; + }} + """) + self.notification_label.setMaximumWidth(500) + self.statusBar().addWidget(self.notification_label, 1) + + self.signal_indicator = SignalIndicator() + self.statusBar().addPermanentWidget(self.signal_indicator) + + self.crt_checkbox = QCheckBox("CRT") + self.crt_checkbox.setStyleSheet(f""" + QCheckBox {{ + color: {COLORS['text_dim']}; + font-size: 10px; + spacing: 4px; + }} + QCheckBox::indicator {{ + width: 12px; + height: 12px; + border: 1px solid {COLORS['border_glow']}; + border-radius: 2px; + background: {COLORS['bg_dark']}; + }} + QCheckBox::indicator:checked {{ + background: {COLORS['accent_cyan']}; + }} + """) + self.crt_checkbox.setToolTip("Toggle CRT scanline effect") + self.crt_checkbox.toggled.connect(self.toggle_crt_effect) + self.statusBar().addPermanentWidget(self.crt_checkbox) + + def toggle_crt_effect(self, enabled): + """Toggle the CRT scanline effect""" + if hasattr(self, 'central_container'): + self.central_container.set_scanlines_enabled(enabled) + + def set_signal_active(self, active): + """Set signal indicator to active (waiting for response)""" + self.signal_indicator.set_active(active) + + def update_signal_latency(self, latency_ms): + """Update signal indicator with response latency""" + self.signal_indicator.set_latency(latency_ms) + + def connect_signals(self): + """Connect all signals and slots""" + self.right_sidebar.nodeSelected.connect(self.on_branch_select) + + # Save/Load Session buttons + if hasattr(self.right_sidebar.control_panel, 'save_session_button'): + self.right_sidebar.control_panel.save_session_button.clicked.connect(self.save_session) + if hasattr(self.right_sidebar.control_panel, 'load_session_button'): + self.right_sidebar.control_panel.load_session_button.clicked.connect(self.load_session) + + if hasattr(self.right_sidebar.network_pane.network_view, 'nodeHovered'): + self.right_sidebar.network_pane.network_view.nodeHovered.connect(self.on_node_hover) + + self.left_pane.set_rabbithole_callback(self.conversation_manager.rabbithole_callback if hasattr(self, 'conversation_manager') else self.dummy_callback) + self.left_pane.set_fork_callback(self.conversation_manager.fork_callback if hasattr(self, 'conversation_manager') else self.dummy_callback) + + self.splitter.splitterMoved.connect(self.save_splitter_state) + + def dummy_callback(self, *args): + pass + + def apply_dark_theme(self): + """Apply dark theme to the application""" + self.setStyleSheet(f""" + QMainWindow {{ + background-color: {COLORS['bg_dark']}; + color: {COLORS['text_normal']}; + }} + QWidget {{ + background-color: {COLORS['bg_dark']}; + color: {COLORS['text_normal']}; + }} + QToolTip {{ + background-color: {COLORS['bg_light']}; + color: {COLORS['text_normal']}; + border: 1px solid {COLORS['border']}; + padding: 5px; + }} + """) + + def on_node_hover(self, node_id): + if node_id == 'main': + self.statusBar().showMessage("Main conversation") + elif node_id in self.branch_conversations: + branch_data = self.branch_conversations[node_id] + branch_type = branch_data.get('type', 'branch') + selected_text = branch_data.get('selected_text', '') + self.statusBar().showMessage(f"{branch_type.capitalize()}: {selected_text[:50]}...") + + def on_branch_select(self, branch_id): + try: + if branch_id == 'main': + self.active_branch = None + if not hasattr(self, 'main_conversation'): + self.main_conversation = [] + self.conversation = self.main_conversation + self.left_pane.display_conversation(self.conversation) + self.statusBar().showMessage("Switched to main conversation") + return + + if branch_id not in self.branch_conversations: + self.statusBar().showMessage(f"Branch {branch_id} not found") + return + + branch_data = self.branch_conversations[branch_id] + self.active_branch = branch_id + self.conversation = branch_data['conversation'] + self.left_pane.display_conversation(self.conversation, branch_data) + self.statusBar().showMessage(f"Switched to {branch_data['type']} branch: {branch_id}") + + except Exception as e: + print(f"Error selecting branch: {e}") + self.statusBar().showMessage(f"Error selecting branch: {e}") + + def save_splitter_state(self): + try: + if not os.path.exists('settings'): + os.makedirs('settings') + with open('settings/splitter_state.json', 'w') as f: + json.dump({'sizes': self.splitter.sizes()}, f) + except Exception as e: + print(f"Error saving splitter state: {e}") + + def restore_splitter_state(self): + try: + if os.path.exists('settings/splitter_state.json'): + with open('settings/splitter_state.json', 'r') as f: + state = json.load(f) + if 'sizes' in state: + self.splitter.setSizes(state['sizes']) + except Exception as e: + print(f"Error restoring splitter state: {e}") + + def save_session(self): + """Save the current session""" + from PyQt6.QtWidgets import QFileDialog, QMessageBox + + # Get filename + file_path, _ = QFileDialog.getSaveFileName( + self, + "Save Session", + os.path.join(os.getcwd(), "sessions"), + "JSON Files (*.json)" + ) + + if not file_path: + return + + filename = os.path.basename(file_path) + + # Gather data + conversation = self.main_conversation if hasattr(self, 'main_conversation') else [] + + success, result = self.conversation_manager.session_manager.save_session( + filename, + conversation, + self.branch_conversations, + self.active_branch, + { + "ai_models": { + "AI-1": self.right_sidebar.control_panel.ai1_model_selector.currentText(), + "AI-2": self.right_sidebar.control_panel.ai2_model_selector.currentText(), + "AI-3": self.right_sidebar.control_panel.ai3_model_selector.currentText(), + "AI-4": self.right_sidebar.control_panel.ai4_model_selector.currentText(), + "AI-5": self.right_sidebar.control_panel.ai5_model_selector.currentText(), + }, + "scenario": self.right_sidebar.control_panel.prompt_pair_selector.currentText() + } + ) + + if success: + self.statusBar().showMessage(f"Session saved to {result}") + QMessageBox.information(self, "Success", "Session saved successfully!") + else: + QMessageBox.critical(self, "Error", f"Failed to save session: {result}") + + def load_session(self): + """Load a saved session""" + from PyQt6.QtWidgets import QFileDialog, QMessageBox + + # Get filename + file_path, _ = QFileDialog.getOpenFileName( + self, + "Load Session", + os.path.join(os.getcwd(), "sessions"), + "JSON Files (*.json)" + ) + + if not file_path: + return + + filename = os.path.basename(file_path) + + success, result = self.conversation_manager.session_manager.load_session(filename) + + if success: + data = result + + # Restore conversation + self.main_conversation = data.get("conversation", []) + self.branch_conversations = data.get("branch_conversations", {}) + self.active_branch = data.get("active_branch") + + # Restore active branch or main conversation + if self.active_branch and self.active_branch in self.branch_conversations: + self.on_branch_select(self.active_branch) + else: + self.active_branch = None + self.conversation = self.main_conversation + self.left_pane.display_conversation(self.conversation) + + # Restore graph + # This requires rebuilding the graph structure in network pane + # For now, we'll just clear and rebuild basic nodes if possible + # Ideally, NetworkPane should support bulk loading + + # Restore UI state if metadata exists + metadata = data.get("metadata", {}) + if "ai_models" in metadata: + models = metadata["ai_models"] + self.right_sidebar.control_panel.ai1_model_selector.setCurrentText(models.get("AI-1", "")) + self.right_sidebar.control_panel.ai2_model_selector.setCurrentText(models.get("AI-2", "")) + self.right_sidebar.control_panel.ai3_model_selector.setCurrentText(models.get("AI-3", "")) + self.right_sidebar.control_panel.ai4_model_selector.setCurrentText(models.get("AI-4", "")) + self.right_sidebar.control_panel.ai5_model_selector.setCurrentText(models.get("AI-5", "")) + + if "scenario" in metadata: + self.right_sidebar.control_panel.prompt_pair_selector.setCurrentText(metadata["scenario"]) + + self.statusBar().showMessage(f"Session loaded from {filename}") + QMessageBox.information(self, "Success", "Session loaded successfully!") + else: + QMessageBox.critical(self, "Error", f"Failed to load session: {result}") diff --git a/src/ui/sidebar.py b/src/ui/sidebar.py new file mode 100644 index 0000000..a6244ca --- /dev/null +++ b/src/ui/sidebar.py @@ -0,0 +1,261 @@ +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QStackedWidget +from PyQt6.QtCore import pyqtSignal +from src.ui.colors import COLORS +from src.ui.control_panel import ControlPanel +from src.ui.widgets.network_graph import NetworkGraphWidget +from src.ui.widgets.media_panes import ImagePreviewPane, VideoPreviewPane + +class NetworkPane(QWidget): + nodeSelected = pyqtSignal(str) + + def __init__(self): + super().__init__() + + # Main layout + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 10, 10, 10) + + # Network view + from src.ui.widgets.network_graph import NetworkGraphWidget + from PyQt6.QtWidgets import QSizePolicy, QLabel + from PyQt6.QtCore import Qt + + # Title + title = QLabel("Propagation Network") + title.setStyleSheet("color: #D4D4D4; font-size: 14px; font-weight: bold; font-family: 'Orbitron', sans-serif;") + layout.addWidget(title, alignment=Qt.AlignmentFlag.AlignCenter) + + self.network_view = NetworkGraphWidget() + self.network_view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + layout.addWidget(self.network_view, 1) + + # Connect signals + self.network_view.nodeSelected.connect(self.nodeSelected) + + # Initialize graph logic - moved from original NetworkPane to avoid circular imports if needed + # But for now, we'll implement wrappers + + def add_node(self, node_id, label, node_type='branch'): + # Simplified: Pass to widget if it had full logic, or implement here + # Since logic was mixed in original, I'll reimplement minimal logic here or in widget + # The widget handles drawing, we handle graph structure + # Wait, the original NetworkGraphWidget was just drawing. NetworkPane had the graph logic. + # I need to port the graph logic here. + import networkx as nx + import math + import random + + if not hasattr(self, 'graph'): + self.graph = nx.DiGraph() + self.node_positions = {} + self.node_colors = {} + self.node_labels = {} + self.node_sizes = {} + + try: + self.graph.add_node(node_id) + + if node_type == 'main': + color = '#569CD6' + size = 800 + elif node_type == 'rabbithole': + color = '#B5CEA8' + size = 600 + elif node_type == 'fork': + color = '#DCDCAA' + size = 600 + else: + color = '#CE9178' + size = 400 + + self.node_colors[node_id] = color + self.node_labels[node_id] = label + self.node_sizes[node_id] = size + + # Position calculation + num_nodes = len(self.graph.nodes) - 1 + if node_type == 'main': + self.node_positions[node_id] = (0, 0) + else: + golden_ratio = 1.618033988749895 + angle = 2 * math.pi * golden_ratio * num_nodes + base_distance = 200 + count_factor = min(1.0, num_nodes / 20) + + if node_type == 'rabbithole': + distance = base_distance * (1.0 + count_factor * 0.5) + elif node_type == 'fork': + distance = base_distance * (1.2 + count_factor * 0.5) + else: + distance = base_distance * (1.4 + count_factor * 0.5) + + x = distance * math.cos(angle) + y = distance * math.sin(angle) + x += random.uniform(-30, 30) + y += random.uniform(-30, 30) + + self.node_positions[node_id] = (x, y) + + self.update_graph() + + except Exception as e: + print(f"Error adding node: {e}") + + def add_edge(self, source_id, target_id): + if not hasattr(self, 'graph'): + return + try: + self.graph.add_edge(source_id, target_id) + self.update_graph() + except Exception as e: + print(f"Error adding edge: {e}") + + def update_graph(self): + if hasattr(self, 'network_view'): + self.network_view.nodes = list(self.graph.nodes()) + self.network_view.edges = list(self.graph.edges()) + self.network_view.node_positions = self.node_positions + self.network_view.node_colors = self.node_colors + self.network_view.node_labels = self.node_labels + self.network_view.node_sizes = self.node_sizes + self.network_view.update() + +class RightSidebar(QWidget): + """Right sidebar with tabbed interface for Setup and Network Graph""" + nodeSelected = pyqtSignal(str) + + def __init__(self): + super().__init__() + self.setMinimumWidth(300) + self.setup_ui() + + def setup_ui(self): + """Set up the tabbed sidebar interface""" + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + layout.setSpacing(0) + + # Create tab bar at the top (custom styled) + tab_container = QWidget() + tab_container.setStyleSheet(f""" + QWidget {{ + background-color: {COLORS['bg_medium']}; + border-bottom: 1px solid {COLORS['border_glow']}; + }} + """) + tab_layout = QHBoxLayout(tab_container) + tab_layout.setContentsMargins(0, 0, 0, 0) + tab_layout.setSpacing(0) + + # Tab buttons + self.setup_button = QPushButton("⚙ SETUP") + self.graph_button = QPushButton("🌐 GRAPH") + self.image_button = QPushButton("🖼 IMAGE") + self.video_button = QPushButton("🎬 VIDEO") + + # Cyberpunk tab button styling + tab_style = f""" + QPushButton {{ + background-color: {COLORS['bg_medium']}; + color: {COLORS['text_dim']}; + border: none; + border-bottom: 2px solid transparent; + padding: 12px 12px; + font-weight: bold; + font-size: 10px; + letter-spacing: 1px; + text-transform: uppercase; + }} + QPushButton:hover {{ + background-color: {COLORS['bg_light']}; + color: {COLORS['text_normal']}; + }} + QPushButton:checked {{ + background-color: {COLORS['bg_dark']}; + color: {COLORS['accent_cyan']}; + border-bottom: 2px solid {COLORS['accent_cyan']}; + }} + """ + + self.setup_button.setStyleSheet(tab_style) + self.graph_button.setStyleSheet(tab_style) + self.image_button.setStyleSheet(tab_style) + self.video_button.setStyleSheet(tab_style) + + # Make buttons checkable for tab behavior + self.setup_button.setCheckable(True) + self.graph_button.setCheckable(True) + self.image_button.setCheckable(True) + self.video_button.setCheckable(True) + self.setup_button.setChecked(True) # Start with setup tab active + + # Connect tab buttons + self.setup_button.clicked.connect(lambda: self.switch_tab(0)) + self.graph_button.clicked.connect(lambda: self.switch_tab(1)) + self.image_button.clicked.connect(lambda: self.switch_tab(2)) + self.video_button.clicked.connect(lambda: self.switch_tab(3)) + + tab_layout.addWidget(self.setup_button) + tab_layout.addWidget(self.graph_button) + tab_layout.addWidget(self.image_button) + tab_layout.addWidget(self.video_button) + + layout.addWidget(tab_container) + + # Create stacked widget for tab content + self.stack = QStackedWidget() + self.stack.setStyleSheet(f""" + QStackedWidget {{ + background-color: {COLORS['bg_dark']}; + border: none; + }} + """) + + # Create tab pages + self.control_panel = ControlPanel() + self.network_pane = NetworkPane() + self.image_preview_pane = ImagePreviewPane() + self.video_preview_pane = VideoPreviewPane() + + # Add pages to stack + self.stack.addWidget(self.control_panel) + self.stack.addWidget(self.network_pane) + self.stack.addWidget(self.image_preview_pane) + self.stack.addWidget(self.video_preview_pane) + + layout.addWidget(self.stack, 1) # Stretch to fill + + # Connect network pane signal to forward it + self.network_pane.nodeSelected.connect(self.nodeSelected) + + def switch_tab(self, index): + """Switch between tabs""" + self.stack.setCurrentIndex(index) + + # Update button states + self.setup_button.setChecked(index == 0) + self.graph_button.setChecked(index == 1) + self.image_button.setChecked(index == 2) + self.video_button.setChecked(index == 3) + + def update_image_preview(self, image_path): + """Update the image preview pane with a new image""" + if hasattr(self, 'image_preview_pane'): + self.image_preview_pane.set_image(image_path) + + def update_video_preview(self, video_path): + """Update the video preview pane with a new video""" + if hasattr(self, 'video_preview_pane'): + self.video_preview_pane.set_video(video_path) + + def add_node(self, node_id, label, node_type): + """Forward to network pane""" + self.network_pane.add_node(node_id, label, node_type) + + def add_edge(self, source_id, target_id): + """Forward to network pane""" + self.network_pane.add_edge(source_id, target_id) + + def update_graph(self): + """Forward to network pane""" + self.network_pane.update_graph() diff --git a/src/ui/widgets/__init__.py b/src/ui/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/widgets/custom_widgets.py b/src/ui/widgets/custom_widgets.py new file mode 100644 index 0000000..c4f6637 --- /dev/null +++ b/src/ui/widgets/custom_widgets.py @@ -0,0 +1,264 @@ +from PyQt6.QtWidgets import QPushButton, QWidget, QGraphicsDropShadowEffect +from PyQt6.QtGui import QColor, QPainter, QPen, QLinearGradient +from PyQt6.QtCore import Qt, QTimer, QRectF +import math +import random +from src.ui.colors import COLORS + +class GlowButton(QPushButton): + """Enhanced button with glow effect on hover""" + + def __init__(self, text, glow_color=COLORS['accent_cyan'], parent=None): + super().__init__(text, parent) + self.glow_color = glow_color + self.base_blur = 8 + self.hover_blur = 20 + + # Create shadow effect + self.shadow = QGraphicsDropShadowEffect() + self.shadow.setBlurRadius(self.base_blur) + self.shadow.setColor(QColor(glow_color)) + self.shadow.setOffset(0, 2) + self.setGraphicsEffect(self.shadow) + + # Track hover state for animation + self.setMouseTracking(True) + + def enterEvent(self, event): + """Increase glow on hover""" + self.shadow.setBlurRadius(self.hover_blur) + self.shadow.setColor(QColor(self.glow_color)) + super().enterEvent(event) + + def leaveEvent(self, event): + """Decrease glow when not hovering""" + self.shadow.setBlurRadius(self.base_blur) + super().leaveEvent(event) + +class DepthGauge(QWidget): + """Vertical gauge showing conversation depth/turn progress""" + + def __init__(self, parent=None): + super().__init__(parent) + self.current_turn = 0 + self.max_turns = 10 + self.setFixedWidth(24) + self.setMinimumHeight(100) + + # Animation + self.pulse_offset = 0 + self.pulse_timer = QTimer(self) + self.pulse_timer.timeout.connect(self._animate_pulse) + self.pulse_timer.start(50) + + def _animate_pulse(self): + self.pulse_offset = (self.pulse_offset + 2) % 360 + self.update() + + def set_progress(self, current, maximum): + """Update the gauge progress""" + self.current_turn = current + self.max_turns = max(maximum, 1) + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + w, h = self.width(), self.height() + margin = 4 + gauge_width = w - margin * 2 + gauge_height = h - margin * 2 + + # Background track + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(COLORS['bg_dark'])) + painter.drawRoundedRect(margin, margin, gauge_width, gauge_height, 4, 4) + + # Border + painter.setPen(QPen(QColor(COLORS['border_glow']), 1)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRoundedRect(margin, margin, gauge_width, gauge_height, 4, 4) + + # Calculate fill height (fills from bottom to top) + progress = min(self.current_turn / self.max_turns, 1.0) + fill_height = int(gauge_height * progress) + fill_y = margin + gauge_height - fill_height + + if fill_height > 0: + # Gradient fill + gradient = QLinearGradient(0, fill_y, 0, margin + gauge_height) + + # Color shifts based on depth + if progress < 0.33: + gradient.setColorAt(0, QColor(COLORS['accent_cyan'])) + gradient.setColorAt(1, QColor(COLORS['accent_cyan']).darker(130)) + elif progress < 0.66: + gradient.setColorAt(0, QColor(COLORS['accent_purple'])) + gradient.setColorAt(1, QColor(COLORS['accent_cyan'])) + else: + gradient.setColorAt(0, QColor(COLORS['accent_pink'])) + gradient.setColorAt(1, QColor(COLORS['accent_purple'])) + + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(gradient) + painter.drawRoundedRect(margin + 2, fill_y, gauge_width - 4, fill_height, 2, 2) + + # Pulsing glow line + pulse_alpha = int(100 + 80 * math.sin(math.radians(self.pulse_offset))) + glow_color = QColor(COLORS['accent_cyan']) + glow_color.setAlpha(pulse_alpha) + painter.setPen(QPen(glow_color, 2)) + painter.drawLine(margin + 2, fill_y, margin + gauge_width - 2, fill_y) + + # Turn counter text + painter.setPen(QColor(COLORS['text_dim'])) + font = painter.font() + font.setPixelSize(9) + painter.setFont(font) + text = f"{self.current_turn}" + painter.drawText(self.rect(), Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop, text) + +class SignalIndicator(QWidget): + """Signal strength/latency indicator""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedSize(80, 20) + self.signal_strength = 1.0 # 0.0 to 1.0 + self.latency_ms = 0 + self.is_active = False + + # Animation for activity + self.bar_offset = 0 + self.activity_timer = QTimer(self) + self.activity_timer.timeout.connect(self._animate) + + def _animate(self): + self.bar_offset = (self.bar_offset + 1) % 5 + self.update() + + def set_active(self, active): + """Set whether we're actively waiting for a response""" + self.is_active = active + if active: + self.activity_timer.start(100) + else: + self.activity_timer.stop() + self.update() + + def set_latency(self, latency_ms): + """Update the latency display""" + self.latency_ms = latency_ms + # Calculate signal strength based on latency + if latency_ms < 500: + self.signal_strength = 1.0 + elif latency_ms < 1500: + self.signal_strength = 0.75 + elif latency_ms < 3000: + self.signal_strength = 0.5 + else: + self.signal_strength = 0.25 + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Draw signal bars + bar_heights = [4, 7, 10, 13, 16] + bar_width = 4 + spacing = 2 + start_x = 5 + base_y = 18 + + for i, bar_h in enumerate(bar_heights): + x = start_x + i * (bar_width + spacing) + y = base_y - bar_h + + # Determine if this bar should be lit + threshold = (i + 1) / len(bar_heights) + is_lit = self.signal_strength >= threshold + + if self.is_active: + # Animated pattern when active + is_lit = ((i + self.bar_offset) % 5) < 3 + color = QColor(COLORS['accent_cyan']) if is_lit else QColor(COLORS['bg_light']) + else: + if is_lit: + # Color based on signal strength + if self.signal_strength > 0.7: + color = QColor(COLORS['accent_green']) + elif self.signal_strength > 0.4: + color = QColor(COLORS['accent_yellow']) + else: + color = QColor(COLORS['accent_pink']) + else: + color = QColor(COLORS['bg_light']) + + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(color) + painter.drawRoundedRect(x, y, bar_width, bar_h, 1, 1) + + # Draw latency text + painter.setPen(QColor(COLORS['text_dim'])) + font = painter.font() + font.setPixelSize(9) + painter.setFont(font) + + if self.is_active: + text = "···" + elif self.latency_ms > 0: + text = f"{self.latency_ms}ms" + else: + text = "IDLE" + + painter.drawText(40, 3, 40, 16, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, text) + +class ScanlineOverlayWidget(QWidget): + """Transparent overlay widget for CRT scanline effect""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + self.scanline_offset = 0 + self.intensity = 0.25 # More visible scanlines + + self.anim_timer = QTimer(self) + self.anim_timer.timeout.connect(self._animate) + + def start_animation(self): + self.anim_timer.start(100) + + def stop_animation(self): + self.anim_timer.stop() + + def _animate(self): + self.scanline_offset = (self.scanline_offset + 1) % 4 + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + + # Draw horizontal scanlines - more visible + line_alpha = int(255 * self.intensity) + line_color = QColor(0, 0, 0, line_alpha) + painter.setPen(QPen(line_color, 1)) + + # Draw every 2nd line for more visible effect + for y in range(self.scanline_offset, self.height(), 2): + painter.drawLine(0, y, self.width(), y) + + # Subtle vignette effect at edges + from PyQt6.QtGui import QRadialGradient + gradient = QRadialGradient(self.width() / 2, self.height() / 2, + max(self.width(), self.height()) * 0.7) + gradient.setColorAt(0, QColor(0, 0, 0, 0)) + gradient.setColorAt(0.7, QColor(0, 0, 0, 0)) + gradient.setColorAt(1, QColor(0, 0, 0, int(255 * self.intensity * 1.5))) + + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(gradient) + painter.drawRect(self.rect()) diff --git a/src/ui/widgets/media_panes.py b/src/ui/widgets/media_panes.py new file mode 100644 index 0000000..bf98520 --- /dev/null +++ b/src/ui/widgets/media_panes.py @@ -0,0 +1,536 @@ +import os +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QSizePolicy +from PyQt6.QtGui import QPixmap +from PyQt6.QtCore import Qt, QSize +from src.ui.colors import COLORS + +class ImagePreviewPane(QWidget): + """Pane to display generated images with navigation""" + def __init__(self): + super().__init__() + self.current_image_path = None + self.session_images = [] # List of all images generated this session + self.current_index = -1 # Current image index + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(10) + + # Title label + self.title = QLabel("🎨 GENERATED IMAGES") + self.title.setStyleSheet(f""" + QLabel {{ + color: {COLORS['accent_purple']}; + font-weight: bold; + font-size: 12px; + padding: 5px; + }} + """) + self.title.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.title) + + # Image display label + self.image_label = QLabel("No images generated yet") + self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.image_label.setStyleSheet(f""" + QLabel {{ + background-color: {COLORS['bg_medium']}; + border: 2px dashed {COLORS['border']}; + border-radius: 8px; + color: {COLORS['text_dim']}; + padding: 20px; + min-height: 200px; + }} + """) + self.image_label.setWordWrap(True) + self.image_label.setScaledContents(False) + layout.addWidget(self.image_label, 1) + + # Navigation controls + nav_layout = QHBoxLayout() + nav_layout.setSpacing(8) + + # Previous button + self.prev_button = QPushButton("◀ Prev") + self.prev_button.setStyleSheet(f""" + QPushButton {{ + background-color: {COLORS['bg_medium']}; + color: {COLORS['text_normal']}; + border: 1px solid {COLORS['border']}; + border-radius: 4px; + padding: 6px 12px; + font-weight: bold; + }} + QPushButton:hover {{ + background-color: {COLORS['bg_light']}; + border-color: {COLORS['accent_purple']}; + }} + QPushButton:disabled {{ + color: {COLORS['text_dim']}; + background-color: {COLORS['bg_dark']}; + }} + """) + self.prev_button.clicked.connect(self.show_previous) + self.prev_button.setEnabled(False) + nav_layout.addWidget(self.prev_button) + + # Position indicator + self.position_label = QLabel("") + self.position_label.setStyleSheet(f""" + QLabel {{ + color: {COLORS['text_dim']}; + font-size: 11px; + }} + """) + self.position_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + nav_layout.addWidget(self.position_label, 1) + + # Next button + self.next_button = QPushButton("Next ▶") + self.next_button.setStyleSheet(f""" + QPushButton {{ + background-color: {COLORS['bg_medium']}; + color: {COLORS['text_normal']}; + border: 1px solid {COLORS['border']}; + border-radius: 4px; + padding: 6px 12px; + font-weight: bold; + }} + QPushButton:hover {{ + background-color: {COLORS['bg_light']}; + border-color: {COLORS['accent_purple']}; + }} + QPushButton:disabled {{ + color: {COLORS['text_dim']}; + background-color: {COLORS['bg_dark']}; + }} + """) + self.next_button.clicked.connect(self.show_next) + self.next_button.setEnabled(False) + nav_layout.addWidget(self.next_button) + + layout.addLayout(nav_layout) + + # Image info label + self.info_label = QLabel("") + self.info_label.setStyleSheet(f""" + QLabel {{ + color: {COLORS['text_dim']}; + font-size: 10px; + padding: 5px; + }} + """) + self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.info_label.setWordWrap(True) + layout.addWidget(self.info_label) + + # Open in folder button + self.open_button = QPushButton("📂 Open Images Folder") + self.open_button.setStyleSheet(f""" + QPushButton {{ + background-color: {COLORS['bg_medium']}; + color: {COLORS['text_normal']}; + border: 1px solid {COLORS['border']}; + border-radius: 4px; + padding: 8px; + }} + QPushButton:hover {{ + background-color: {COLORS['bg_light']}; + border-color: {COLORS['accent_purple']}; + }} + """) + self.open_button.clicked.connect(self.open_images_folder) + layout.addWidget(self.open_button) + + def add_image(self, image_path): + """Add a new image to the session gallery and display it""" + if image_path and os.path.exists(image_path): + if image_path not in self.session_images: + self.session_images.append(image_path) + self.current_index = len(self.session_images) - 1 + self._display_current() + + def set_image(self, image_path): + """Display an image - also adds to gallery if new""" + self.add_image(image_path) + + def _display_current(self): + """Display the image at current_index""" + if not self.session_images or self.current_index < 0: + self.image_label.setText("No images generated yet") + self.info_label.setText("") + self.position_label.setText("") + self.prev_button.setEnabled(False) + self.next_button.setEnabled(False) + return + + image_path = self.session_images[self.current_index] + self.current_image_path = image_path + + if os.path.exists(image_path): + pixmap = QPixmap(image_path) + if not pixmap.isNull(): + scaled = pixmap.scaled( + self.image_label.size() - QSize(20, 20), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation + ) + self.image_label.setPixmap(scaled) + self.image_label.setStyleSheet(f""" + QLabel {{ + background-color: {COLORS['bg_medium']}; + border: 2px solid {COLORS['accent_purple']}; + border-radius: 8px; + padding: 10px; + }} + """) + + filename = os.path.basename(image_path) + self.info_label.setText(f"📁 {filename}") + else: + self.image_label.setText("Failed to load image") + self.info_label.setText("") + else: + self.image_label.setText("Image not found") + self.info_label.setText("") + + total = len(self.session_images) + current = self.current_index + 1 + self.position_label.setText(f"{current} of {total}") + self.prev_button.setEnabled(self.current_index > 0) + self.next_button.setEnabled(self.current_index < total - 1) + + def show_previous(self): + """Show the previous image""" + if self.current_index > 0: + self.current_index -= 1 + self._display_current() + + def show_next(self): + """Show the next image""" + if self.current_index < len(self.session_images) - 1: + self.current_index += 1 + self._display_current() + + def clear_session(self): + """Clear all session images""" + self.session_images = [] + self.current_index = -1 + self.current_image_path = None + self.image_label.setText("No images generated yet") + self.image_label.setStyleSheet(f""" + QLabel {{ + background-color: {COLORS['bg_medium']}; + border: 2px dashed {COLORS['border']}; + border-radius: 8px; + color: {COLORS['text_dim']}; + padding: 20px; + min-height: 200px; + }} + """) + self.info_label.setText("") + self.position_label.setText("") + self.prev_button.setEnabled(False) + self.next_button.setEnabled(False) + + def open_images_folder(self): + """Open the images folder in file explorer""" + import subprocess + images_dir = os.path.abspath("images") + if os.path.exists(images_dir): + if os.name == 'nt': + os.startfile(images_dir) + elif os.name == 'posix': + subprocess.Popen(['open', images_dir] if os.uname().sysname == 'Darwin' else ['xdg-open', images_dir]) + else: + os.makedirs(images_dir, exist_ok=True) + if os.name == 'nt': + os.startfile(images_dir) + elif os.name == 'posix': + subprocess.Popen(['open', images_dir] if os.uname().sysname == 'Darwin' else ['xdg-open', images_dir]) + + def resizeEvent(self, event): + """Re-scale image when pane is resized""" + super().resizeEvent(event) + if self.current_image_path: + self._display_current() + + +class VideoPreviewPane(QWidget): + """Pane to display generated videos with navigation""" + def __init__(self): + super().__init__() + self.current_video_path = None + self.session_videos = [] + self.current_index = -1 + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(10) + + # Title label + self.title = QLabel("🎬 GENERATED VIDEOS") + self.title.setStyleSheet(f""" + QLabel {{ + color: {COLORS['accent_cyan']}; + font-weight: bold; + font-size: 12px; + padding: 5px; + }} + """) + self.title.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.title) + + # Video display area + self.video_label = QLabel("No videos generated yet") + self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.video_label.setStyleSheet(f""" + QLabel {{ + background-color: {COLORS['bg_medium']}; + border: 2px dashed {COLORS['border']}; + border-radius: 8px; + color: {COLORS['text_dim']}; + padding: 20px; + min-height: 150px; + }} + """) + self.video_label.setWordWrap(True) + layout.addWidget(self.video_label, 1) + + # Play button + self.play_button = QPushButton("▶ Play Video") + self.play_button.setStyleSheet(f""" + QPushButton {{ + background-color: {COLORS['accent_cyan']}; + color: {COLORS['bg_dark']}; + border: none; + border-radius: 4px; + padding: 10px 20px; + font-weight: bold; + font-size: 12px; + }} + QPushButton:hover {{ + background-color: {COLORS['accent_purple']}; + }} + QPushButton:disabled {{ + background-color: {COLORS['bg_medium']}; + color: {COLORS['text_dim']}; + }} + """) + self.play_button.clicked.connect(self.play_current_video) + self.play_button.setEnabled(False) + layout.addWidget(self.play_button) + + # Navigation controls + nav_layout = QHBoxLayout() + nav_layout.setSpacing(8) + + # Previous button + self.prev_button = QPushButton("◀ Prev") + self.prev_button.setStyleSheet(f""" + QPushButton {{ + background-color: {COLORS['bg_medium']}; + color: {COLORS['text_normal']}; + border: 1px solid {COLORS['border']}; + border-radius: 4px; + padding: 6px 12px; + font-weight: bold; + }} + QPushButton:hover {{ + background-color: {COLORS['bg_light']}; + border-color: {COLORS['accent_cyan']}; + }} + QPushButton:disabled {{ + color: {COLORS['text_dim']}; + background-color: {COLORS['bg_dark']}; + }} + """) + self.prev_button.clicked.connect(self.show_previous) + self.prev_button.setEnabled(False) + nav_layout.addWidget(self.prev_button) + + # Position indicator + self.position_label = QLabel("") + self.position_label.setStyleSheet(f""" + QLabel {{ + color: {COLORS['text_dim']}; + font-size: 11px; + }} + """) + self.position_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + nav_layout.addWidget(self.position_label, 1) + + # Next button + self.next_button = QPushButton("Next ▶") + self.next_button.setStyleSheet(f""" + QPushButton {{ + background-color: {COLORS['bg_medium']}; + color: {COLORS['text_normal']}; + border: 1px solid {COLORS['border']}; + border-radius: 4px; + padding: 6px 12px; + font-weight: bold; + }} + QPushButton:hover {{ + background-color: {COLORS['bg_light']}; + border-color: {COLORS['accent_cyan']}; + }} + QPushButton:disabled {{ + color: {COLORS['text_dim']}; + background-color: {COLORS['bg_dark']}; + }} + """) + self.next_button.clicked.connect(self.show_next) + self.next_button.setEnabled(False) + nav_layout.addWidget(self.next_button) + + layout.addLayout(nav_layout) + + # Video info label + self.info_label = QLabel("") + self.info_label.setStyleSheet(f""" + QLabel {{ + color: {COLORS['text_dim']}; + font-size: 10px; + padding: 5px; + }} + """) + self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.info_label.setWordWrap(True) + layout.addWidget(self.info_label) + + # Open in folder button + self.open_button = QPushButton("📂 Open Videos Folder") + self.open_button.setStyleSheet(f""" + QPushButton {{ + background-color: {COLORS['bg_medium']}; + color: {COLORS['text_normal']}; + border: 1px solid {COLORS['border']}; + border-radius: 4px; + padding: 8px; + }} + QPushButton:hover {{ + background-color: {COLORS['bg_light']}; + border-color: {COLORS['accent_cyan']}; + }} + """) + self.open_button.clicked.connect(self.open_videos_folder) + layout.addWidget(self.open_button) + + def add_video(self, video_path): + """Add a new video to the session gallery and display it""" + if video_path and os.path.exists(video_path): + if video_path not in self.session_videos: + self.session_videos.append(video_path) + self.current_index = len(self.session_videos) - 1 + self._display_current() + + def set_video(self, video_path): + """Display a video - also adds to gallery if new""" + self.add_video(video_path) + + def _display_current(self): + """Display the video at current_index""" + if not self.session_videos or self.current_index < 0: + self.video_label.setText("No videos generated yet") + self.info_label.setText("") + self.position_label.setText("") + self.prev_button.setEnabled(False) + self.next_button.setEnabled(False) + self.play_button.setEnabled(False) + return + + video_path = self.session_videos[self.current_index] + self.current_video_path = video_path + + if os.path.exists(video_path): + filename = os.path.basename(video_path) + self.video_label.setText(f"🎬 {filename}\n\n(Click Play to view)") + self.video_label.setStyleSheet(f""" + QLabel {{ + background-color: {COLORS['bg_medium']}; + border: 2px solid {COLORS['accent_cyan']}; + border-radius: 8px; + color: {COLORS['text_bright']}; + padding: 20px; + min-height: 150px; + }} + """) + self.info_label.setText(f"📁 {filename}") + self.play_button.setEnabled(True) + else: + self.video_label.setText("Video not found") + self.info_label.setText("") + self.play_button.setEnabled(False) + + total = len(self.session_videos) + current = self.current_index + 1 + self.position_label.setText(f"{current} of {total}") + self.prev_button.setEnabled(self.current_index > 0) + self.next_button.setEnabled(self.current_index < total - 1) + + def show_previous(self): + """Show the previous video""" + if self.current_index > 0: + self.current_index -= 1 + self._display_current() + + def show_next(self): + """Show the next video""" + if self.current_index < len(self.session_videos) - 1: + self.current_index += 1 + self._display_current() + + def play_current_video(self): + """Open the current video in the default video player""" + if self.current_video_path and os.path.exists(self.current_video_path): + import subprocess + import sys + if sys.platform == 'win32': + os.startfile(self.current_video_path) + elif sys.platform == 'darwin': + subprocess.Popen(['open', self.current_video_path]) + else: + subprocess.Popen(['xdg-open', self.current_video_path]) + + def clear_session(self): + """Clear all session videos""" + self.session_videos = [] + self.current_index = -1 + self.current_video_path = None + self.video_label.setText("No videos generated yet") + self.video_label.setStyleSheet(f""" + QLabel {{ + background-color: {COLORS['bg_medium']}; + border: 2px dashed {COLORS['border']}; + border-radius: 8px; + color: {COLORS['text_dim']}; + padding: 20px; + min-height: 150px; + }} + """) + self.info_label.setText("") + self.position_label.setText("") + self.prev_button.setEnabled(False) + self.next_button.setEnabled(False) + self.play_button.setEnabled(False) + + def open_videos_folder(self): + """Open the videos folder in file explorer""" + import subprocess + videos_dir = os.path.abspath("videos") + if os.path.exists(videos_dir): + if os.name == 'nt': + os.startfile(videos_dir) + elif os.name == 'posix': + subprocess.Popen(['open', videos_dir] if os.uname().sysname == 'Darwin' else ['xdg-open', videos_dir]) + else: + os.makedirs(videos_dir, exist_ok=True) + if os.name == 'nt': + os.startfile(videos_dir) + elif os.name == 'posix': + subprocess.Popen(['open', videos_dir] if os.uname().sysname == 'Darwin' else ['xdg-open', videos_dir]) diff --git a/src/ui/widgets/network_graph.py b/src/ui/widgets/network_graph.py new file mode 100644 index 0000000..08aba96 --- /dev/null +++ b/src/ui/widgets/network_graph.py @@ -0,0 +1,555 @@ +from PyQt6.QtWidgets import QWidget, QToolTip +from PyQt6.QtGui import QColor, QPainter, QPen, QBrush, QLinearGradient, QRadialGradient, QPainterPath +from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QPointF +import math +import random +from src.ui.colors import COLORS + +class NetworkGraphWidget(QWidget): + nodeSelected = pyqtSignal(str) + nodeHovered = pyqtSignal(str) + + def __init__(self): + super().__init__() + + # Graph data + self.nodes = [] + self.edges = [] + self.node_positions = {} + self.node_colors = {} + self.node_labels = {} + self.node_sizes = {} + + # Edge animation data + self.growing_edges = {} # Dictionary to track growing edges: {(source, target): growth_progress} + self.edge_growth_speed = 0.05 # Increased speed of edge growth animation + + # Visual settings + self.margin = 50 + self.selected_node = None + self.hovered_node = None + self.animation_progress = 0 + self.animation_timer = QTimer(self) + self.animation_timer.timeout.connect(self.update_animation) + self.animation_timer.start(50) # 20 FPS animation + + # Mycelial node settings + self.hyphae_count = 5 # Number of hyphae per node + self.hyphae_length_factor = 0.4 # Length of hyphae relative to node radius + self.hyphae_variation = 0.3 # Random variation in hyphae + + # Node colors - use global color palette with mycelial theme + self.node_colors_by_type = { + 'main': '#8E9DCC', # Soft blue-purple + 'rabbithole': '#7FB069', # Soft green + 'fork': '#F2C14E', # Soft yellow + 'branch': '#F78154' # Soft orange + } + + # Collision dynamics + self.node_velocities = {} # Store velocities for each node + self.repulsion_strength = 0.5 # Strength of repulsion between nodes + self.attraction_strength = 0.1 # Strength of attraction along edges + self.damping = 0.8 # Damping factor to prevent oscillation + self.apply_physics = True # Toggle for physics simulation + + # Set up the widget + self.setMinimumSize(300, 300) + self.setMouseTracking(True) + + def add_edge(self, source, target): + """Add an edge with growth animation""" + if (source, target) not in self.edges: + self.edges.append((source, target)) + # Initialize edge growth at 0 + self.growing_edges[(source, target)] = 0.0 + # Force update to start animation immediately + self.update() + + def update_animation(self): + """Update animation state""" + self.animation_progress = (self.animation_progress + 0.05) % 1.0 + + # Update growing edges + edges_to_remove = [] + has_growing_edges = False + + for edge, progress in self.growing_edges.items(): + if progress < 1.0: + self.growing_edges[edge] = min(progress + self.edge_growth_speed, 1.0) + has_growing_edges = True + else: + # Mark fully grown edges for removal from animation tracking + edges_to_remove.append(edge) + + # Remove fully grown edges from tracking + for edge in edges_to_remove: + if edge in self.growing_edges: + self.growing_edges.pop(edge) + + # Apply collision dynamics if enabled + if self.apply_physics and len(self.nodes) > 1: + self.apply_collision_dynamics() + + # Update the widget + self.update() + + def apply_collision_dynamics(self): + """Apply collision dynamics to prevent node overlap""" + # Initialize velocities if needed + for node_id in self.nodes: + if node_id not in self.node_velocities: + self.node_velocities[node_id] = (0, 0) + + # Calculate repulsive forces between nodes + new_velocities = {} + for node_id in self.nodes: + if node_id not in self.node_positions: + continue + + vx, vy = self.node_velocities.get(node_id, (0, 0)) + x1, y1 = self.node_positions[node_id] + + # Apply repulsion between nodes + for other_id in self.nodes: + if other_id == node_id or other_id not in self.node_positions: + continue + + x2, y2 = self.node_positions[other_id] + + # Calculate distance + dx = x1 - x2 + dy = y1 - y2 + distance = max(0.1, math.sqrt(dx*dx + dy*dy)) # Avoid division by zero + + # Get node sizes + size1 = math.sqrt(self.node_sizes.get(node_id, 400)) + size2 = math.sqrt(self.node_sizes.get(other_id, 400)) + min_distance = (size1 + size2) / 2 + + # Apply repulsive force if nodes are too close + if distance < min_distance * 2: + # Normalize direction vector + nx = dx / distance + ny = dy / distance + + # Calculate repulsion strength (stronger when closer) + strength = self.repulsion_strength * (1.0 - distance / (min_distance * 2)) + + # Apply force + vx += nx * strength + vy += ny * strength + + # Apply attraction along edges + for edge in self.edges: + source, target = edge + + # Skip edges that are still growing + if (source, target) in self.growing_edges and self.growing_edges[(source, target)] < 1.0: + continue + + if source == node_id and target in self.node_positions: + # This node is the source, attract towards target + x2, y2 = self.node_positions[target] + dx = x2 - x1 + dy = y2 - y1 + distance = max(0.1, math.sqrt(dx*dx + dy*dy)) + + # Normalize and apply attraction + vx += (dx / distance) * self.attraction_strength + vy += (dy / distance) * self.attraction_strength + + elif target == node_id and source in self.node_positions: + # This node is the target, attract towards source + x2, y2 = self.node_positions[source] + dx = x2 - x1 + dy = y2 - y1 + distance = max(0.1, math.sqrt(dx*dx + dy*dy)) + + # Normalize and apply attraction + vx += (dx / distance) * self.attraction_strength + vy += (dy / distance) * self.attraction_strength + + # Apply damping to prevent oscillation + vx *= self.damping + vy *= self.damping + + # Store new velocity + new_velocities[node_id] = (vx, vy) + + # Update positions based on velocities + for node_id, (vx, vy) in new_velocities.items(): + if node_id in self.node_positions: + # Skip the main node to keep it centered + if node_id == 'main': + continue + + x, y = self.node_positions[node_id] + self.node_positions[node_id] = (x + vx, y + vy) + + # Update velocities for next frame + self.node_velocities = new_velocities + + def paintEvent(self, event): + """Paint the network graph""" + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Get widget dimensions + width = self.width() + height = self.height() + + # Set background with subtle gradient + gradient = QLinearGradient(0, 0, 0, height) + gradient.setColorAt(0, QColor('#1A1A1E')) # Dark blue-gray + gradient.setColorAt(1, QColor('#0F0F12')) # Darker at bottom + painter.fillRect(0, 0, width, height, gradient) + + # Draw subtle grid lines + painter.setPen(QPen(QColor(COLORS['border']).darker(150), 0.5, Qt.PenStyle.DotLine)) + grid_size = 40 + for x in range(0, width, grid_size): + painter.drawLine(x, 0, x, height) + for y in range(0, height, grid_size): + painter.drawLine(0, y, width, y) + + # Calculate center point and scale factor + center_x = width / 2 + center_y = height / 2 + scale = min(width, height) / 500 + + # Draw edges first so they appear behind nodes + for edge in self.edges: + source, target = edge + if source in self.node_positions and target in self.node_positions: + src_x, src_y = self.node_positions[source] + dst_x, dst_y = self.node_positions[target] + + # Transform coordinates to screen space + screen_src_x = center_x + src_x * scale + screen_src_y = center_y + src_y * scale + screen_dst_x = center_x + dst_x * scale + screen_dst_y = center_y + dst_y * scale + + # Get growth progress for this edge (default to 1.0 if not growing) + growth_progress = self.growing_edges.get((source, target), 1.0) + + # Calculate the actual destination based on growth progress + if growth_progress < 1.0: + # Interpolate between source and destination + actual_dst_x = screen_src_x + (screen_dst_x - screen_src_x) * growth_progress + actual_dst_y = screen_src_y + (screen_dst_y - screen_src_y) * growth_progress + else: + actual_dst_x = screen_dst_x + actual_dst_y = screen_dst_y + + # Draw mycelial connection (multiple thin lines with variations) + source_color = QColor(self.node_colors.get(source, self.node_colors_by_type['main'])) + target_color = QColor(self.node_colors.get(target, self.node_colors_by_type['main'])) + + # Number of filaments per connection + num_filaments = 3 + + for i in range(num_filaments): + # Create a path with multiple segments for organic look + path = QPainterPath() + path.moveTo(screen_src_x, screen_src_y) + + # Calculate distance between points + distance = math.sqrt((actual_dst_x - screen_src_x)**2 + (actual_dst_y - screen_src_y)**2) + + # Number of segments increases with distance + num_segments = max(3, int(distance / 40)) + + # Create intermediate points with slight random variations + prev_x, prev_y = screen_src_x, screen_src_y + + for j in range(1, num_segments): + # Calculate position along the line + ratio = j / num_segments + + # Base position + base_x = screen_src_x + (actual_dst_x - screen_src_x) * ratio + base_y = screen_src_y + (actual_dst_y - screen_src_y) * ratio + + # Add random variation perpendicular to the line + angle = math.atan2(actual_dst_y - screen_src_y, actual_dst_x - screen_src_x) + math.pi/2 + variation = (random.random() - 0.5) * 10 * scale + + # Variation decreases near endpoints + endpoint_factor = min(ratio, 1 - ratio) * 4 # Maximum at middle + variation *= endpoint_factor + + # Apply variation + point_x = base_x + variation * math.cos(angle) + point_y = base_y + variation * math.sin(angle) + + # Add point to path + path.lineTo(point_x, point_y) + prev_x, prev_y = point_x, point_y + + # Complete the path to destination + path.lineTo(actual_dst_x, actual_dst_y) + + # Create gradient along the path + gradient = QLinearGradient(screen_src_x, screen_src_y, actual_dst_x, actual_dst_y) + + # Make colors more transparent for mycelial effect + source_color_trans = QColor(source_color) + target_color_trans = QColor(target_color) + + # Vary transparency by filament + alpha = 70 + i * 20 + source_color_trans.setAlpha(alpha) + target_color_trans.setAlpha(alpha) + + gradient.setColorAt(0, source_color_trans) + gradient.setColorAt(1, target_color_trans) + + # Animate flow along edge + flow_pos = (self.animation_progress + i * 0.3) % 1.0 + flow_color = QColor(255, 255, 255, 100) + gradient.setColorAt(flow_pos, flow_color) + + # Draw the edge with varying thickness + thickness = 1.0 + (i * 0.5) + pen = QPen(QBrush(gradient), thickness) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + painter.drawPath(path) + + # Draw small nodes along the path for mycelial effect + if growth_progress == 1.0: # Only for fully grown edges + num_nodes = int(distance / 50) + for j in range(1, num_nodes): + ratio = j / num_nodes + node_x = screen_src_x + (screen_dst_x - screen_src_x) * ratio + node_y = screen_src_y + (screen_dst_y - screen_src_y) * ratio + + # Add small random offset + offset_angle = random.random() * math.pi * 2 + offset_dist = random.random() * 5 + node_x += math.cos(offset_angle) * offset_dist + node_y += math.sin(offset_angle) * offset_dist + + # Draw small node + node_color = QColor(source_color) + node_color.setAlpha(100) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QBrush(node_color)) + node_size = 1 + random.random() * 2 + painter.drawEllipse(QPointF(node_x, node_y), node_size, node_size) + + # Draw nodes + for node_id in self.nodes: + if node_id in self.node_positions: + x, y = self.node_positions[node_id] + + # Transform coordinates to screen space + screen_x = center_x + x * scale + screen_y = center_y + y * scale + + # Get node properties + node_color = self.node_colors.get(node_id, self.node_colors_by_type['branch']) + node_label = self.node_labels.get(node_id, 'Node') + node_size = self.node_sizes.get(node_id, 400) + + # Scale the node size + radius = math.sqrt(node_size) * scale / 2 + + # Adjust radius for hover/selection + if node_id == self.selected_node: + radius *= 1.1 # Larger when selected + elif node_id == self.hovered_node: + radius *= 1.05 # Slightly larger when hovered + + # Draw node glow for selected/hovered nodes + if node_id == self.selected_node or node_id == self.hovered_node: + glow_radius = radius * 1.5 + glow_color = QColor(node_color) + + for i in range(5): + r = glow_radius - (i * radius * 0.1) + alpha = 40 - (i * 8) + glow_color.setAlpha(alpha) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(glow_color) + painter.drawEllipse(QPointF(screen_x, screen_y), r, r) + + # Draw mycelial node (irregular shape with hyphae) + painter.setPen(Qt.PenStyle.NoPen) + + # Create gradient fill for node + gradient = QRadialGradient(screen_x, screen_y, radius) + base_color = QColor(node_color) + lighter_color = QColor(node_color).lighter(130) + darker_color = QColor(node_color).darker(130) + + gradient.setColorAt(0, lighter_color) + gradient.setColorAt(0.7, base_color) + gradient.setColorAt(1, darker_color) + + # Fill main node body + painter.setBrush(QBrush(gradient)) + + # Draw irregular node shape + path = QPainterPath() + + # Create irregular circle with random variations + num_points = 20 + start_angle = random.random() * math.pi * 2 + + for i in range(num_points + 1): + angle = start_angle + (i * 2 * math.pi / num_points) + # Vary radius slightly for organic look + variation = 1.0 + (random.random() - 0.5) * 0.2 + point_radius = radius * variation + + x_point = screen_x + math.cos(angle) * point_radius + y_point = screen_y + math.sin(angle) * point_radius + + if i == 0: + path.moveTo(x_point, y_point) + else: + # Use quadratic curves for smoother shape + control_angle = start_angle + ((i - 0.5) * 2 * math.pi / num_points) + control_radius = radius * (1.0 + (random.random() - 0.5) * 0.1) + control_x = screen_x + math.cos(control_angle) * control_radius + control_y = screen_y + math.sin(control_angle) * control_radius + + path.quadTo(control_x, control_y, x_point, y_point) + + # Draw the main node body + painter.drawPath(path) + + # Draw hyphae (mycelial extensions) + hyphae_count = self.hyphae_count + if node_id == 'main': + hyphae_count += 3 # More hyphae for main node + + for i in range(hyphae_count): + # Random angle for hyphae + angle = random.random() * math.pi * 2 + + # Base length varies by node type + base_length = radius * self.hyphae_length_factor + if node_id == 'main': + base_length *= 1.5 + + # Random variation in length + length = base_length * (1.0 + (random.random() - 0.5) * self.hyphae_variation) + + # Calculate end point + end_x = screen_x + math.cos(angle) * (radius + length) + end_y = screen_y + math.sin(angle) * (radius + length) + + # Start point is on the node perimeter + start_x = screen_x + math.cos(angle) * radius * 0.9 + start_y = screen_y + math.sin(angle) * radius * 0.9 + + # Create hyphae path with slight curve + hypha_path = QPainterPath() + hypha_path.moveTo(start_x, start_y) + + # Control point for curve + ctrl_angle = angle + (random.random() - 0.5) * 0.5 # Slight angle variation + ctrl_dist = radius + length * 0.5 + ctrl_x = screen_x + math.cos(ctrl_angle) * ctrl_dist + ctrl_y = screen_y + math.sin(ctrl_angle) * ctrl_dist + + hypha_path.quadTo(ctrl_x, ctrl_y, end_x, end_y) + + # Draw hypha with gradient + hypha_gradient = QLinearGradient(start_x, start_y, end_x, end_y) + + # Hypha color starts as node color and fades out + hypha_start_color = QColor(node_color) + hypha_end_color = QColor(node_color) + hypha_start_color.setAlpha(150) + hypha_end_color.setAlpha(30) + + hypha_gradient.setColorAt(0, hypha_start_color) + hypha_gradient.setColorAt(1, hypha_end_color) + + # Draw hypha with varying thickness + thickness = 1.0 + random.random() * 1.5 + hypha_pen = QPen(QBrush(hypha_gradient), thickness) + hypha_pen.setCapStyle(Qt.PenCapStyle.RoundCap) + painter.setPen(hypha_pen) + painter.drawPath(hypha_path) + + # Add small nodes at the end of some hyphae + if random.random() > 0.5: + small_node_color = QColor(node_color) + small_node_color.setAlpha(100) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QBrush(small_node_color)) + small_node_size = 1 + random.random() * 2 + painter.drawEllipse(QPointF(end_x, end_y), small_node_size, small_node_size) + + def mousePressEvent(self, event): + """Handle mouse press events""" + if event.button() == Qt.MouseButton.LeftButton: + pos = event.position() + clicked_node = self.get_node_at_position(pos) + if clicked_node: + self.selected_node = clicked_node + self.update() + self.nodeSelected.emit(clicked_node) + + def mouseMoveEvent(self, event): + """Handle mouse move events for hover effects""" + pos = event.position() + hovered_node = self.get_node_at_position(pos) + + if hovered_node != self.hovered_node: + self.hovered_node = hovered_node + self.update() + if hovered_node: + self.nodeHovered.emit(hovered_node) + + # Show tooltip with node info + if hovered_node in self.node_labels: + # Get node type from the ID + node_type = "main" + if "rabbithole_" in hovered_node: + node_type = "rabbithole" + elif "fork_" in hovered_node: + node_type = "fork" + + # Set emoji based on node type + emoji = "🌱" # Default/main + if node_type == "rabbithole": + emoji = "🕳️" # Rabbithole emoji + elif node_type == "fork": + emoji = "🔱" # Fork emoji + + # Show tooltip with emoji and label + QToolTip.showText( + event.globalPosition().toPoint(), + f"{emoji} {self.node_labels[hovered_node]}", + self + ) + + def get_node_at_position(self, pos): + """Get the node at the given position""" + width = self.width() + height = self.height() + center_x = width / 2 + center_y = height / 2 + scale = min(width, height) / 500 + + for node_id in self.nodes: + if node_id in self.node_positions: + x, y = self.node_positions[node_id] + screen_x = center_x + x * scale + screen_y = center_y + y * scale + + node_size = self.node_sizes.get(node_id, 400) + radius = math.sqrt(node_size) * scale / 2 + + distance = math.sqrt((pos.x() - screen_x)**2 + (pos.y() - screen_y)**2) + if distance <= radius: + return node_id + + return None diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/font_loader.py b/src/utils/font_loader.py new file mode 100644 index 0000000..f665d76 --- /dev/null +++ b/src/utils/font_loader.py @@ -0,0 +1,46 @@ +from PyQt6.QtGui import QFontDatabase +from pathlib import Path +import os + +def load_fonts(): + """Load custom fonts for the application""" + # Look for fonts in the root 'fonts' directory + # If running from run.py, root is current directory + # If running from src/main.py, root is one level up + + font_dir = Path("fonts") + if not font_dir.exists(): + # Try going up one level if we are inside src + font_dir = Path("../fonts") + + if not font_dir.exists(): + # Try absolute path from file location + current_file = Path(__file__).resolve() + project_root = current_file.parent.parent.parent + font_dir = project_root / "fonts" + + # List of fonts to load + fonts = [ + ("IosevkaTerm-Regular.ttf", "Iosevka Term"), + ("IosevkaTerm-Bold.ttf", "Iosevka Term"), + ("IosevkaTerm-Italic.ttf", "Iosevka Term"), + ] + + loaded_fonts = [] + + if font_dir.exists(): + for font_file, font_name in fonts: + font_path = font_dir / font_file + if font_path.exists(): + font_id = QFontDatabase.addApplicationFont(str(font_path)) + if font_id >= 0: + if font_name not in loaded_fonts: + loaded_fonts.append(font_name) + print(f"Loaded font: {font_name} from {font_file}") + else: + print(f"Failed to load font: {font_file}") + else: + pass + # Silent fail for missing font files, fallback will be used + + return loaded_fonts diff --git a/src/utils/shared_utils.py b/src/utils/shared_utils.py new file mode 100644 index 0000000..e2565f5 --- /dev/null +++ b/src/utils/shared_utils.py @@ -0,0 +1,154 @@ +import json +import webbrowser +import os +from pathlib import Path +from datetime import datetime + +def setup_image_directory(): + """Create an 'images' directory in the project root if it doesn't exist""" + image_dir = Path("images") + image_dir.mkdir(exist_ok=True) + return image_dir + +def cleanup_old_images(image_dir, max_age_hours=24): + """Remove images older than max_age_hours""" + current_time = datetime.now() + for image_file in image_dir.glob("*.jpg"): + file_age = datetime.fromtimestamp(image_file.stat().st_mtime) + if (current_time - file_age).total_seconds() > max_age_hours * 3600: + image_file.unlink() + +def load_ai_memory(ai_number): + """Load AI conversation memory from JSON files""" + try: + memory_path = f"memory/ai{ai_number}/conversations.json" + with open(memory_path, 'r', encoding='utf-8') as f: + conversations = json.load(f) + # Ensure we're working with the array part + if isinstance(conversations, dict) and "memories" in conversations: + conversations = conversations["memories"] + return conversations + except Exception as e: + print(f"Error loading AI{ai_number} memory: {e}") + return [] + +def create_memory_prompt(conversations): + """Convert memory JSON into conversation examples""" + if not conversations: + return "" + + prompt = "Previous conversations that demonstrate your personality:\n\n" + + # Add example conversations + for convo in conversations: + prompt += f"Human: {convo['human']}\n" + prompt += f"Assistant: {convo['assistant']}\n\n" + + prompt += "Maintain this conversation style in your responses." + return prompt + +def print_conversation_state(conversation): + print("Current conversation state:") + for message in conversation: + content = message.get('content', '') + # Safely preview content - handle both string and list (structured) content + if isinstance(content, str): + preview = content[:50] + "..." if len(content) > 50 else content + else: + preview = f"[structured content with {len(content)} parts]" + print(f"{message['role']}: {preview}") + +def open_html_in_browser(file_path="conversation_full.html"): + import webbrowser, os + full_path = os.path.abspath(file_path) + webbrowser.open('file://' + full_path) + +def create_initial_living_document(*args, **kwargs): + return "" + +def read_living_document(*args, **kwargs): + return "" + +def process_living_document_edits(result, model_name): + return result + +def read_shared_html(*args, **kwargs): + return "" + +def update_shared_html(*args, **kwargs): + return False + +def web_search(query: str, max_results: int = 5) -> dict: + """ + Search the web using DuckDuckGo. + + Args: + query: Search query string + max_results: Maximum number of results to return + + Returns: + dict with keys: success, results (list of {title, url, snippet}), error + """ + try: + from ddgs import DDGS + except ImportError: + return { + "success": False, + "error": "ddgs package not installed. Run: pip install ddgs" + } + + try: + print(f"[WebSearch] Searching for: {query}") + + # Use the new ddgs API - prioritize news for current events queries + ddgs = DDGS() + formatted_results = [] + + # For queries about current events, use news search first + is_news_query = any(term in query.lower() for term in ["news", "today", "latest", "2025", "drama", "announcement", "release"]) + + if is_news_query: + print(f"[WebSearch] Detected news query, searching news first...") + try: + news_results = list(ddgs.news(query, region="wt-wt", safesearch="off", max_results=max_results)) + for r in news_results: + formatted_results.append({ + "title": r.get("title", ""), + "url": r.get("url", r.get("link", "")), + "snippet": r.get("body", r.get("excerpt", "")) + }) + print(f"[WebSearch] Found {len(formatted_results)} news results") + except Exception as e: + print(f"[WebSearch] News search failed: {e}") + + # If we don't have enough results, try text search + if len(formatted_results) < max_results: + remaining = max_results - len(formatted_results) + try: + text_results = list(ddgs.text( + query, + region="us-en", # Force US English results + safesearch="off", + max_results=remaining + )) + for r in text_results: + formatted_results.append({ + "title": r.get("title", ""), + "url": r.get("href", r.get("link", "")), + "snippet": r.get("body", r.get("snippet", "")) + }) + print(f"[WebSearch] Added {len(text_results)} text results, total: {len(formatted_results)}") + except Exception as e: + print(f"[WebSearch] Text search failed: {e}") + + return { + "success": True, + "results": formatted_results, + "query": query + } + except Exception as e: + print(f"[WebSearch] Error: {e}") + return { + "success": False, + "error": str(e) + }