File size: 18,686 Bytes
a5784e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
"""
GUI Launcher Environment Manager

Manages reading/writing .env file settings with hot reload support.
"""

import os
import re
import shutil
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple


class EnvManager:
    """
    Manages .env file operations with structured parsing and hot reload support.

    Features:
    - Preserves comments and file structure when writing
    - Tracks modified values for dirty state detection
    - Supports hot reload callbacks
    - Validates values based on type hints
    """

    # Type definitions for env variables
    # Format: (default_value, type, description, category)
    ENV_SCHEMA: Dict[str, Tuple[Any, str, str, str]] = {
        # Server Configuration
        "PORT": (2048, "int", "FastAPI Main Service Port", "server"),
        "STREAM_PORT": (
            3120,
            "int",
            "Streaming Proxy Service Port (0 to disable)",
            "server",
        ),
        "DEFAULT_FASTAPI_PORT": (2048, "int", "GUI Default FastAPI Port", "server"),
        "DEFAULT_CAMOUFOX_PORT": (9222, "int", "GUI Default Camoufox Port", "server"),
        # Logging & Debugging
        "SERVER_LOG_LEVEL": (
            "INFO",
            "choice:DEBUG,INFO,WARNING,ERROR,CRITICAL",
            "Server Log Level",
            "logging",
        ),
        "SERVER_REDIRECT_PRINT": (
            False,
            "bool",
            "Redirect print output to logs",
            "logging",
        ),
        "DEBUG_LOGS_ENABLED": (False, "bool", "Enable Debug Logs", "logging"),
        "TRACE_LOGS_ENABLED": (False, "bool", "Enable Trace Logs", "logging"),
        "JSON_LOGS": (False, "bool", "JSON Structured Logging", "logging"),
        "LOG_FILE_MAX_BYTES": (10485760, "int", "Log File Max Size (bytes)", "logging"),
        "LOG_FILE_BACKUP_COUNT": (5, "int", "Log Backup File Count", "logging"),
        # Authentication
        "AUTO_SAVE_AUTH": (False, "bool", "Auto-save Authentication", "auth"),
        "AUTH_SAVE_TIMEOUT": (30, "int", "Auth Save Timeout (seconds)", "auth"),
        "AUTO_ROTATE_AUTH_PROFILE": (True, "bool", "Auto Rotate Auth Profile", "auth"),
        "AUTO_AUTH_ROTATION_ON_STARTUP": (
            False,
            "bool",
            "Auto Auth Rotation on Startup",
            "auth",
        ),
        "AUTO_CONFIRM_LOGIN": (True, "bool", "Auto Confirm Login", "auth"),
        "QUOTA_SOFT_LIMIT": (850000, "int", "Quota Soft Limit (tokens)", "auth"),
        "QUOTA_HARD_LIMIT": (950000, "int", "Quota Hard Limit (tokens)", "auth"),
        # Cookie Refresh
        "COOKIE_REFRESH_ENABLED": (True, "bool", "Enable Cookie Refresh", "cookie"),
        "COOKIE_REFRESH_INTERVAL_SECONDS": (
            1800,
            "int",
            "Cookie Refresh Interval (seconds)",
            "cookie",
        ),
        "COOKIE_REFRESH_ON_REQUEST_ENABLED": (
            True,
            "bool",
            "Cookie Refresh on Request",
            "cookie",
        ),
        "COOKIE_REFRESH_REQUEST_INTERVAL": (
            10,
            "int",
            "Cookie Refresh Request Interval",
            "cookie",
        ),
        "COOKIE_REFRESH_ON_SHUTDOWN": (
            True,
            "bool",
            "Cookie Refresh on Shutdown",
            "cookie",
        ),
        # Browser & Model
        "LAUNCH_MODE": (
            "normal",
            "choice:normal,debug,headless,virtual_display,direct_debug_no_browser",
            "Launch Mode",
            "browser",
        ),
        "DIRECT_LAUNCH": (
            False,
            "bool",
            "Quick Launch (Skip Launcher Menu)",
            "browser",
        ),
        "ONLY_COLLECT_CURRENT_USER_ATTACHMENTS": (
            False,
            "bool",
            "Only Collect Current User Attachments",
            "browser",
        ),
        "ENDPOINT_CAPTURE_TIMEOUT": (
            45,
            "int",
            "Camoufox Endpoint Capture Timeout (seconds)",
            "browser",
        ),
        # API Defaults
        "DEFAULT_TEMPERATURE": (1.0, "float", "Default Temperature", "api"),
        "DEFAULT_MAX_OUTPUT_TOKENS": (65536, "int", "Default Max Output Tokens", "api"),
        "DEFAULT_TOP_P": (0.95, "float", "Default Top P", "api"),
        "ENABLE_THINKING_BUDGET": (True, "bool", "Enable Thinking Budget", "api"),
        "DEFAULT_THINKING_BUDGET": (
            8192,
            "int",
            "Default Thinking Budget (tokens)",
            "api",
        ),
        "THINKING_BUDGET_LOW": (10923, "int", "Thinking Budget Low Level", "api"),
        "THINKING_BUDGET_MEDIUM": (21845, "int", "Thinking Budget Medium Level", "api"),
        "THINKING_BUDGET_HIGH": (32768, "int", "Thinking Budget High Level", "api"),
        "DEFAULT_THINKING_LEVEL_PRO": (
            "high",
            "choice:low,medium,high",
            "Default Thinking Level (Pro)",
            "api",
        ),
        "DEFAULT_THINKING_LEVEL_FLASH": (
            "high",
            "choice:low,medium,high",
            "Default Thinking Level (Flash)",
            "api",
        ),
        "DISABLE_THINKING_BUDGET_ON_STREAMING_DISABLE": (
            False,
            "bool",
            "Disable Thinking on Streaming Disable",
            "api",
        ),
        "ENABLE_GOOGLE_SEARCH": (False, "bool", "Enable Google Search", "api"),
        "ENABLE_URL_CONTEXT": (False, "bool", "Enable URL Context", "api"),
        # Function Calling
        "FUNCTION_CALLING_MODE": (
            "auto",
            "choice:auto,native,emulated",
            "Function Calling Mode",
            "function_calling",
        ),
        "FUNCTION_CALLING_NATIVE_FALLBACK": (
            True,
            "bool",
            "Native Mode Fallback to Emulated",
            "function_calling",
        ),
        "FUNCTION_CALLING_UI_TIMEOUT": (
            10000,
            "int",
            "Function Calling UI Timeout (ms)",
            "function_calling",
        ),
        "FUNCTION_CALLING_NATIVE_RETRY_COUNT": (
            3,
            "int",
            "Native Mode Retry Count",
            "function_calling",
        ),
        "FUNCTION_CALLING_CLEAR_BETWEEN_REQUESTS": (
            True,
            "bool",
            "Clear Functions Between Requests",
            "function_calling",
        ),
        "FUNCTION_CALLING_DEBUG": (
            False,
            "bool",
            "Function Calling Debug (Master Switch)",
            "function_calling",
        ),
        "FUNCTION_CALLING_CACHE_ENABLED": (
            True,
            "bool",
            "Function Calling Cache Enabled",
            "function_calling",
        ),
        "FUNCTION_CALLING_CACHE_TTL": (
            0,
            "int",
            "Function Calling Cache TTL (seconds)",
            "function_calling",
        ),
        # Timeouts
        "RESPONSE_COMPLETION_TIMEOUT": (
            600000,
            "int",
            "Response Completion Timeout (ms)",
            "timeouts",
        ),
        "INITIAL_WAIT_MS_BEFORE_POLLING": (
            500,
            "int",
            "Initial Wait Before Polling (ms)",
            "timeouts",
        ),
        "POLLING_INTERVAL": (300, "int", "Polling Interval (ms)", "timeouts"),
        "POLLING_INTERVAL_STREAM": (
            180,
            "int",
            "Streaming Polling Interval (ms)",
            "timeouts",
        ),
        "SILENCE_TIMEOUT_MS": (60000, "int", "Silence Timeout (ms)", "timeouts"),
        "CLICK_TIMEOUT_MS": (3000, "int", "Click Timeout (ms)", "timeouts"),
        "WAIT_FOR_ELEMENT_TIMEOUT_MS": (
            10000,
            "int",
            "Wait for Element Timeout (ms)",
            "timeouts",
        ),
        "PSEUDO_STREAM_DELAY": (
            0.01,
            "float",
            "Pseudo Stream Delay (seconds)",
            "timeouts",
        ),
        # Miscellaneous
        "SKIP_FRONTEND_BUILD": (False, "bool", "Skip Frontend Build Check", "misc"),
        "ENABLE_SCRIPT_INJECTION": (
            False,
            "bool",
            "Enable Script Injection (Deprecated)",
            "misc",
        ),
    }

    # Category display order and names
    CATEGORIES: Dict[str, str] = {
        "server": "Server Configuration",
        "logging": "Logging & Debugging",
        "auth": "Authentication",
        "cookie": "Cookie Refresh",
        "browser": "Browser & Model",
        "api": "API Defaults",
        "function_calling": "Function Calling",
        "timeouts": "Timeouts",
        "misc": "Miscellaneous",
    }

    def __init__(self, env_path: Path, example_path: Optional[Path] = None):
        """
        Initialize the EnvManager.

        Args:
            env_path: Path to the .env file
            example_path: Path to .env.example (used for initialization)
        """
        self.env_path = env_path
        self.example_path = example_path
        self._values: Dict[str, str] = {}
        self._original_values: Dict[str, str] = {}
        self._file_lines: List[str] = []
        self._hot_reload_callbacks: List[Callable[[Dict[str, str]], None]] = []

        # Load the file
        self.load()

    def load(self) -> None:
        """Load and parse the .env file."""
        self._values = {}
        self._file_lines = []

        # If .env doesn't exist, try to copy from .env.example
        if not self.env_path.exists():
            if self.example_path and self.example_path.exists():
                shutil.copy(self.example_path, self.env_path)
            else:
                # Create an empty .env
                self.env_path.touch()

        # Read the file
        try:
            with open(self.env_path, "r", encoding="utf-8") as f:
                self._file_lines = f.readlines()
        except Exception as e:
            print(f"Error reading .env file: {e}")
            self._file_lines = []

        # Parse key-value pairs
        for line in self._file_lines:
            stripped = line.strip()

            # Skip empty lines and comments
            if not stripped or stripped.startswith("#"):
                continue

            # Parse key=value
            match = re.match(
                r"^([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$", stripped, re.IGNORECASE
            )
            if match:
                key = match.group(1)
                value = match.group(2)

                # Remove surrounding quotes if present
                if (value.startswith('"') and value.endswith('"')) or (
                    value.startswith("'") and value.endswith("'")
                ):
                    value = value[1:-1]

                self._values[key] = value

        # Store original values for dirty detection
        self._original_values = self._values.copy()

    def get(self, key: str, default: Any = None) -> Any:
        """
        Get a value from the env, with type conversion.

        Args:
            key: Environment variable name
            default: Default value if not found

        Returns:
            The typed value
        """
        raw_value = self._values.get(key)

        if raw_value is None:
            # Return schema default if available
            if key in self.ENV_SCHEMA:
                return self.ENV_SCHEMA[key][0]
            return default

        # Get type from schema
        if key in self.ENV_SCHEMA:
            _, type_hint, _, _ = self.ENV_SCHEMA[key]
            return self._convert_value(raw_value, type_hint)

        return raw_value

    def get_raw(self, key: str) -> Optional[str]:
        """Get raw string value without type conversion."""
        return self._values.get(key)

    def set(self, key: str, value: Any) -> None:
        """
        Set a value in the env.

        Args:
            key: Environment variable name
            value: Value to set (will be converted to string)
        """
        # Convert boolean to lowercase string
        if isinstance(value, bool):
            str_value = "true" if value else "false"
        else:
            str_value = str(value)

        self._values[key] = str_value

    def is_dirty(self) -> bool:
        """Check if any values have been modified since last load/save."""
        return self._values != self._original_values

    def get_modified_keys(self) -> List[str]:
        """Get list of keys that have been modified."""
        modified = []
        for key in self._values:
            if (
                key not in self._original_values
                or self._values[key] != self._original_values[key]
            ):
                modified.append(key)
        for key in self._original_values:
            if key not in self._values:
                modified.append(key)
        return list(set(modified))

    def save(self) -> bool:
        """
        Save changes to the .env file.

        Preserves comments and structure, only updating changed values.

        Returns:
            True if save was successful
        """
        try:
            new_lines = []
            keys_written = set()

            for line in self._file_lines:
                stripped = line.strip()

                # Keep empty lines and comments
                if not stripped or stripped.startswith("#"):
                    new_lines.append(line)
                    continue

                # Check if this is a key=value line
                match = re.match(r"^([A-Z_][A-Z0-9_]*)\s*=", stripped, re.IGNORECASE)
                if match:
                    key = match.group(1)
                    if key in self._values:
                        # Update the value, preserve comment if any
                        comment_match = re.search(r"#.*$", line)
                        comment = comment_match.group(0) if comment_match else ""

                        value = self._values[key]
                        # Quote values with spaces
                        if " " in value and not (
                            value.startswith('"') or value.startswith("'")
                        ):
                            value = f'"{value}"'

                        new_line = f"{key}={value}"
                        if comment:
                            new_line += f"  {comment}"
                        new_lines.append(new_line + "\n")
                        keys_written.add(key)
                    else:
                        new_lines.append(line)
                else:
                    new_lines.append(line)

            # Add any new keys at the end
            for key, value in self._values.items():
                if key not in keys_written:
                    new_lines.append(f"\n{key}={value}\n")

            # Write the file
            with open(self.env_path, "w", encoding="utf-8") as f:
                f.writelines(new_lines)

            # Reload to update line cache
            self.load()
            return True

        except Exception as e:
            print(f"Error saving .env file: {e}")
            return False

    def reset_to_defaults(self) -> None:
        """Reset all values to their schema defaults."""
        for key, (default, _, _, _) in self.ENV_SCHEMA.items():
            self.set(key, default)

    def discard_changes(self) -> None:
        """Discard all unsaved changes."""
        self._values = self._original_values.copy()

    def get_category_keys(self, category: str) -> List[str]:
        """Get all keys belonging to a category."""
        return [
            key for key, (_, _, _, cat) in self.ENV_SCHEMA.items() if cat == category
        ]

    def get_schema_info(self, key: str) -> Optional[Tuple[Any, str, str, str]]:
        """Get schema info for a key: (default, type, description, category)."""
        return self.ENV_SCHEMA.get(key)

    def register_hot_reload_callback(
        self, callback: Callable[[Dict[str, str]], None]
    ) -> None:
        """
        Register a callback for hot reload notifications.

        Args:
            callback: Function that receives dict of changed keys and values
        """
        self._hot_reload_callbacks.append(callback)

    def unregister_hot_reload_callback(
        self, callback: Callable[[Dict[str, str]], None]
    ) -> None:
        """Unregister a hot reload callback."""
        if callback in self._hot_reload_callbacks:
            self._hot_reload_callbacks.remove(callback)

    def trigger_hot_reload(self) -> None:
        """Trigger hot reload callbacks with modified values."""
        modified = {key: self._values.get(key, "") for key in self.get_modified_keys()}
        for callback in self._hot_reload_callbacks:
            try:
                callback(modified)
            except Exception as e:
                print(f"Hot reload callback error: {e}")

    def apply_to_environment(self) -> None:
        """Apply current values to os.environ for hot reload."""
        for key, value in self._values.items():
            os.environ[key] = value

    def _convert_value(self, value: str, type_hint: str) -> Any:
        """Convert a string value based on type hint."""
        try:
            if type_hint == "bool":
                return value.lower() in ("true", "1", "yes", "on")
            elif type_hint == "int":
                return int(value)
            elif type_hint == "float":
                return float(value)
            elif type_hint.startswith("choice:"):
                # Return as-is, validation is done elsewhere
                return value
            else:
                return value
        except (ValueError, TypeError):
            # Return schema default on conversion error
            if type_hint == "bool":
                return False
            elif type_hint == "int":
                return 0
            elif type_hint == "float":
                return 0.0
            return value


# Singleton instance
_env_manager: Optional[EnvManager] = None


def get_env_manager(
    env_path: Optional[Path] = None, example_path: Optional[Path] = None
) -> EnvManager:
    """
    Get the singleton EnvManager instance.

    Args:
        env_path: Path to .env file (only used on first call)
        example_path: Path to .env.example (only used on first call)

    Returns:
        The EnvManager singleton
    """
    global _env_manager
    if _env_manager is None:
        if env_path is None:
            raise ValueError("env_path must be provided on first call")
        _env_manager = EnvManager(env_path, example_path)
    return _env_manager


def reset_env_manager() -> None:
    """Reset the singleton (mainly for testing)."""
    global _env_manager
    _env_manager = None