diff --git a/actions/browser_control.py b/actions/browser_control.py index 352927a..8f18767 100644 --- a/actions/browser_control.py +++ b/actions/browser_control.py @@ -11,22 +11,13 @@ import threading from pathlib import Path from typing import Optional -try: - from playwright.async_api import ( - async_playwright, - BrowserContext, - Page, - Playwright, - TimeoutError as PlaywrightTimeout, - ) - _PLAYWRIGHT_OK = True -except ImportError: - _PLAYWRIGHT_OK = False - async_playwright = None # type: ignore[assignment] - BrowserContext = None # type: ignore[assignment] - Page = None # type: ignore[assignment] - Playwright = None # type: ignore[assignment] - PlaywrightTimeout = Exception # type: ignore[assignment,misc] +from playwright.async_api import ( + async_playwright, + BrowserContext, + Page, + Playwright, + TimeoutError as PlaywrightTimeout, +) _OS = platform.system() # "Windows" | "Darwin" | "Linux" def _normalize_url(url: str) -> str: @@ -816,14 +807,6 @@ def browser_control( player=None, session_memory=None, ) -> str: - if not _PLAYWRIGHT_OK: - msg = ( - "Playwright is not installed.\n" - "Fix: pip install playwright && python -m playwright install chromium" - ) - _log(player, msg) - return msg - params = parameters or {} action = params.get("action", "").lower().strip() browser = params.get("browser", "").lower().strip() or None diff --git a/actions/code_helper.py b/actions/code_helper.py index 6cfe81f..38e6357 100644 --- a/actions/code_helper.py +++ b/actions/code_helper.py @@ -12,10 +12,26 @@ def get_base_dir(): return Path(__file__).resolve().parent.parent BASE_DIR = get_base_dir() +API_CONFIG_PATH = BASE_DIR / "config" / "api_keys.json" DESKTOP = Path.home() / "Desktop" MAX_BUILD_ATTEMPTS = 3 +GEMINI_MODEL = "gemini-2.5-flash" -from core.llm_client import call_llm_text as _llm + +def _get_api_key() -> str: + with open(API_CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f)["gemini_api_key"] + + +def _get_gemini(model: str = GEMINI_MODEL): + from google import genai + _c = genai.Client(api_key=_get_api_key()) + + class _W: + def generate_content(self, contents): + return _c.models.generate_content(model=model, contents=contents) + + return _W() def _clean_code(text: str) -> str: @@ -134,28 +150,47 @@ def _detect_intent(description: str, file_path: str, code: str) -> str: return "write" def _write(description: str, language: str, output_path: str, player=None) -> tuple[str, Path]: - lang = language or "python" - system = f"You are an expert {lang} developer. Output ONLY raw code, no markdown, no backticks, no explanations." - prompt = ( - f"Write clean, working, well-commented {lang} code.\n" - f"Handle errors and edge cases. Use modern best practices.\n\n" - f"Description: {description}\n\nCode:" - ) - code = _clean_code(_llm(prompt, system=system)) - path = _resolve_save_path(output_path, lang) + lang = language or "python" + model = _get_gemini() + + prompt = f"""You are an expert {lang} developer. +Write clean, working, well-commented {lang} code for the description below. + +Rules: +- Output ONLY the code. No explanation, no markdown, no backticks. +- Add helpful inline comments. +- Handle errors and edge cases properly. +- Use modern best practices. + +Description: {description} + +Code:""" + + response = model.generate_content(prompt) + code = _clean_code(response.text) + path = _resolve_save_path(output_path, lang) _save_file(path, code) return code, path def _fix_code(code: str, error_output: str, description: str) -> str: - system = "You are an expert debugger. Return ONLY the corrected code — no explanation, no markdown, no backticks." - prompt = ( - f"Fix the code below. It failed with this error.\n\n" - f"Original goal: {description}\n\n" - f"Error:\n{error_output[:2000]}\n\n" - f"Broken code:\n{code}\n\nFixed code:" - ) - return _clean_code(_llm(prompt, system=system)) + model = _get_gemini() + prompt = f"""You are an expert debugger. +The code below failed with the following error. Fix it. +Return ONLY the corrected code — no explanation, no markdown, no backticks. + +Original goal: {description} + +Error: +{error_output[:2000]} + +Broken code: +{code} + +Fixed code:""" + + response = model.generate_content(prompt) + return _clean_code(response.text) def _run_file(path: Path, args: list, timeout: int) -> str: @@ -273,14 +308,21 @@ def _edit_action(file_path, instruction, player) -> str: if player: player.write_log("[Code] Editing file...") - system = "You are an expert code editor. Return ONLY the complete updated code — no explanation, no markdown, no backticks." - prompt = ( - f"Apply the following change to the code below.\n\n" - f"Change: {instruction}\n\n" - f"Original code:\n{content}\n\nUpdated code:" - ) + model = _get_gemini() + prompt = f"""You are an expert code editor. +Apply the following change to the code below. +Return ONLY the complete updated code — no explanation, no markdown, no backticks. + +Change: {instruction} + +Original code: +{content} + +Updated code:""" + try: - edited = _clean_code(_llm(prompt, system=system)) + response = model.generate_content(prompt) + edited = _clean_code(response.text) except Exception as e: return f"Could not edit code: {e}" @@ -300,13 +342,19 @@ def _explain_action(file_path, code, player) -> str: if player: player.write_log("[Code] Analyzing code...") - system = "You are an expert programmer. Explain code concisely in 3-6 sentences." - prompt = ( - f"Explain what this code does — what it does, how it works, important details.\n\n" - f"Code:\n{code[:4000]}\n\nExplanation:" - ) + model = _get_gemini() + prompt = f"""Explain what this code does in simple, clear language. +Focus on: what it does, how it works, and any important details. +Be concise — 3 to 6 sentences maximum. + +Code: +{code[:4000]} + +Explanation:""" + try: - return _llm(prompt, system=system) + response = model.generate_content(prompt) + return response.text.strip() except Exception as e: return f"Could not explain code: {e}" @@ -334,15 +382,26 @@ def _optimize_action(file_path, code, language, output_path, player) -> str: if player: player.write_log("[Code] Optimizing code...") - lang = language or "python" - system = f"You are an expert {lang} developer. Return ONLY the optimized code — no explanation, no markdown, no backticks." - prompt = ( - f"Optimize this {lang} code for performance, readability, and best practices. " - f"Remove dead code and unnecessary complexity.\n\n" - f"Original code:\n{code[:6000]}\n\nOptimized code:" - ) + lang = language or "python" + model = _get_gemini() + + prompt = f"""You are an expert {lang} developer and code reviewer. +Optimize the following code for: +1. Performance — eliminate unnecessary operations, use efficient data structures +2. Readability — clear variable names, proper formatting, logical structure +3. Best practices — modern {lang} patterns, error handling, type hints if applicable +4. Remove dead code, redundant comments, and unnecessary complexity + +Return ONLY the optimized code — no explanation, no markdown, no backticks. + +Original code: +{code[:6000]} + +Optimized code:""" + try: - optimized = _clean_code(_llm(prompt, system=system)) + response = model.generate_content(prompt) + optimized = _clean_code(response.text) except Exception as e: return f"Could not optimize code: {e}" @@ -387,51 +446,44 @@ def _screen_debug_action(description, file_path, player, speak=None) -> str: print(f"[Code] ⚠️ Could not read file: {err}") try: - import base64, json as _json, requests as _req - from pathlib import Path as _Path + from google import genai + from google.genai import types - cfg = {} - cfg_file = _Path(__file__).resolve().parent.parent / "config" / "api_keys.json" - try: - cfg = _json.loads(cfg_file.read_text(encoding="utf-8")) - except Exception: - pass + client = genai.Client(api_key=_get_api_key()) - ollama_url = cfg.get("llm_url", "http://localhost:11434").rstrip("/") - vision_model = cfg.get("vision_model") or cfg.get("llm_model", "llava") + image_bytes = screenshot_path.read_bytes() + image_base64 = _image_to_base64(screenshot_path) - image_bytes = screenshot_path.read_bytes() - b64 = base64.b64encode(image_bytes).decode("ascii") user_question = description or "What error or problem do you see on the screen? How can it be fixed?" - context = "" + + context = "" if file_content: - context = f"\n\nRelated file content:\n```\n{file_content[:4000]}\n```" + context = f"\n\nAdditionally, here is the related file content:\n```\n{file_content[:4000]}\n```" - analysis_prompt = ( - f"You are an expert programmer/debugger analyzing a screenshot.\n\n" - f"User question: {user_question}{context}\n\n" - f"Identify errors, explain the cause, and provide a fix. " - f"If you see code, show the corrected version." + analysis_prompt = f"""You are an expert programmer and debugger analyzing a screenshot. + +User's question: {user_question}{context} + +Please: +1. Identify any errors, exceptions, or problems visible on the screen +2. Explain what is causing the problem in simple terms +3. Provide a concrete fix or solution +4. If there's code visible, show the corrected version + +Be specific and actionable. If you see an error message, quote it exactly.""" + + contents = [ + types.Part.from_bytes(data=image_bytes, mime_type="image/png"), + analysis_prompt, + ] + + response = client.models.generate_content( + model="gemini-2.5-flash", + contents=contents, ) - resp = _req.post( - f"{ollama_url}/api/chat", - json={ - "model": vision_model, - "stream": False, - "messages": [ - { - "role": "user", - "content": analysis_prompt, - "images": [b64], - } - ], - }, - timeout=60, - ) - resp.raise_for_status() - analysis = (resp.json().get("message", {}).get("content") or "").strip() - print("[Code] ✅ Screen analysis complete") + analysis = response.text.strip() + print(f"[Code] ✅ Screen analysis complete") try: screenshot_path.unlink() @@ -439,6 +491,7 @@ def _screen_debug_action(description, file_path, player, speak=None) -> str: pass if file_path and file_content: + code_match = re.search(r"```[a-zA-Z]*\n(.*?)```", analysis, re.DOTALL) if code_match: fixed_code = code_match.group(1).strip() @@ -450,6 +503,7 @@ def _screen_debug_action(description, file_path, player, speak=None) -> str: return analysis except Exception as e: + try: screenshot_path.unlink() except Exception: diff --git a/actions/computer_control.py b/actions/computer_control.py index 92aa2d1..d1062b0 100644 --- a/actions/computer_control.py +++ b/actions/computer_control.py @@ -1,6 +1,7 @@ #computer_control.py import io import json +import platform import re import string import subprocess @@ -39,13 +40,17 @@ def _load_config() -> dict: except Exception: return {} -def _get_os() -> str: - import platform - s = platform.system().lower() - if s == "darwin": - return "mac" - return s # "windows" or "linux" +def _platform_os() -> str: + return {"Windows": "windows", "Darwin": "mac", "Linux": "linux"}.get( + platform.system(), "linux" + ) +def _get_os() -> str: + return _load_config().get("os_system", _platform_os()).lower() + + +def _get_api_key() -> str: + return _load_config().get("gemini_api_key", "") _SAFE_SCREENSHOT_ROOTS = ( Path.home(), @@ -160,7 +165,8 @@ def _smart_type(text: str, clear_first: bool = True) -> str: if len(text) > 20 and _PYPERCLIP: pyperclip.copy(text) time.sleep(0.1) - pyautogui.hotkey("ctrl", "v") + paste_key = "command" if _get_os() == "mac" else "ctrl" + pyautogui.hotkey(paste_key, "v") return f"Smart-typed (clipboard): {text[:60]}{'…' if len(text) > 60 else ''}" pyautogui.typewrite(text, interval=0.04) @@ -222,7 +228,8 @@ def _clipboard_paste(text: str) -> str: pyperclip.copy(text) time.sleep(0.1) _require_pyautogui() - pyautogui.hotkey("ctrl", "v") + paste_key = "command" if _get_os() == "mac" else "ctrl" + pyautogui.hotkey(paste_key, "v") return f"Pasted: {text[:60]}{'…' if len(text) > 60 else ''}" return "pyperclip not available" @@ -237,7 +244,8 @@ def _screenshot(save_path: str | None = None) -> str: def _clear_field() -> str: _require_pyautogui() - pyautogui.hotkey("ctrl", "a") + select_key = "command" if _get_os() == "mac" else "ctrl" + pyautogui.hotkey(select_key, "a") time.sleep(0.1) pyautogui.press("delete") return "Field cleared" @@ -298,27 +306,23 @@ def _focus_window(title: str) -> str: return f"focus_window: unknown OS '{os_name}'" def _screen_find(description: str) -> tuple[int, int] | None: + api_key = _get_api_key() + if not api_key: + print("[ComputerControl] ⚠️ No API key for screen_find") + return None + try: - import base64 - import requests as _req - import json as _json - - cfg_path = Path(__file__).resolve().parent.parent / "config" / "api_keys.json" - try: - cfg = _json.loads(cfg_path.read_text(encoding="utf-8")) - except Exception: - cfg = {} - - ollama_url = cfg.get("llm_url", "http://localhost:11434").rstrip("/") - vision_model = cfg.get("vision_model") or cfg.get("llm_model", "llava") + from google import genai + from google.genai import types as gtypes _require_pyautogui() w, h = pyautogui.size() img = pyautogui.screenshot() buf = io.BytesIO() img.save(buf, format="PNG") - b64 = base64.b64encode(buf.getvalue()).decode("ascii") + image_bytes = buf.getvalue() + client = genai.Client(api_key=api_key) prompt = ( f"This is a screenshot of a {w}×{h} pixel screen. " f"Locate the UI element described as: '{description}'. " @@ -326,18 +330,15 @@ def _screen_find(description: str) -> tuple[int, int] | None: f"If the element is not visible, reply: NOT_FOUND" ) - resp = _req.post( - f"{ollama_url}/api/chat", - json={ - "model": vision_model, - "stream": False, - "messages": [{"role": "user", "content": prompt, "images": [b64]}], - }, - timeout=30, + response = client.models.generate_content( + model="gemini-2.5-flash-lite", + contents=[ + gtypes.Part.from_bytes(data=image_bytes, mime_type="image/png"), + prompt, + ], ) - resp.raise_for_status() - text = (resp.json().get("message", {}).get("content") or "").strip() + text = (response.text or "").strip() if "NOT_FOUND" in text.upper(): return None diff --git a/actions/computer_settings.py b/actions/computer_settings.py index 12649d9..5a9d2ed 100644 --- a/actions/computer_settings.py +++ b/actions/computer_settings.py @@ -29,6 +29,11 @@ def _get_base_dir() -> Path: return Path(sys.executable).parent return Path(__file__).resolve().parent.parent +def _get_api_key() -> str: + path = _get_base_dir() / "config" / "api_keys.json" + with open(path, "r", encoding="utf-8") as f: + return json.load(f)["gemini_api_key"] + def _get_macos_wifi_interface() -> str: try: result = subprocess.run( @@ -564,24 +569,34 @@ _DANGEROUS_ACTIONS = {"restart", "shutdown"} def _detect_action(description: str) -> dict: - from core.llm_client import call_llm_text + + from google import genai as _genai + _client = _genai.Client(api_key=_get_api_key()) available = ", ".join(sorted(ACTION_MAP.keys())) + \ ", volume_set, type_text, press_key, reload_n" - system = ( - "You are an intent detector. Return ONLY a valid JSON object — " - "no explanation, no markdown." - ) - prompt = ( - f'User command (any language): "{description}"\n\n' - f"Available actions: {available}\n\n" - f'Return: {{"action": "action_name", "value": null_or_value}}\n' - f"Rules: volume_set→int 0-100; type_text→exact text; press_key→key name; reload_n→int." - ) + prompt = f"""You are an intent detector for a computer control assistant. + +The user issued a command (possibly in any language): "{description}" + +Available actions: {available} + +Return ONLY a valid JSON object: +{{"action": "action_name", "value": null_or_value}} + +Rules: +- Pick the single best matching action from the available list. +- For volume_set: value is an integer 0-100. +- For type_text: value is the exact text to type. +- For press_key: value is the key name (e.g. "f5", "tab", "enter"). +- For reload_n: value is an integer (number of times to reload). +- If no clear match, pick the closest action. +- Return ONLY the JSON, no explanation, no markdown.""" + try: - text = call_llm_text(prompt, system=system) - text = re.sub(r"```(?:json)?", "", text).strip().rstrip("`").strip() + resp = _client.models.generate_content(model="gemini-2.5-flash-lite", contents=prompt) + text = re.sub(r"```(?:json)?", "", resp.text).strip().rstrip("`").strip() return json.loads(text) except Exception as e: print(f"[Settings] Intent detection failed: {e}") diff --git a/actions/desktop.py b/actions/desktop.py index 495eeca..ee62717 100644 --- a/actions/desktop.py +++ b/actions/desktop.py @@ -23,6 +23,11 @@ def _get_base_dir() -> Path: return Path(sys.executable).parent return Path(__file__).resolve().parent.parent +def _get_api_key() -> str: + path = _get_base_dir() / "config" / "api_keys.json" + with open(path, "r", encoding="utf-8") as f: + return json.load(f)["gemini_api_key"] + def _get_desktop() -> Path: if _OS == "Linux": xdg = os.environ.get("XDG_DESKTOP_DIR", "") @@ -97,29 +102,48 @@ def _execute_generated_code(code: str, player=None) -> str: def _ask_gemini_for_desktop_action(task: str) -> str: - from core.llm_client import call_llm_text - desktop = str(_get_desktop()) + from google import genai as _genai + _client = _genai.Client(api_key=_get_api_key()) + + desktop = str(_get_desktop()) + os_specific = "" if _OS == "Windows": os_specific = "- ctypes (Windows API calls, read-only)\n- winreg (registry READ only)" + elif _OS == "Darwin": + os_specific = "- subprocess is NOT available; use pyautogui or Path only" else: os_specific = "- subprocess is NOT available; use pyautogui or Path only" - system = ( - "You are a desktop automation code generator. " - "Output ONLY raw Python code — no explanation, no markdown, no backticks." - ) - prompt = ( - f"Current OS: {_OS}\nDesktop path: {desktop}\n\n" - f"Allowed modules: pyautogui, pathlib.Path, shutil.copy2, shutil.disk_usage, time.sleep\n" - f"{os_specific}\n\n" - f"Rules: NO deletion, NO subprocess, NO exec/eval, NO imports.\n" - f"If unsafe, output exactly: UNSAFE\n\n" - f"Task: {task}" - ) + prompt = f"""You are a desktop automation assistant. +Current OS: {_OS} +Desktop path: {desktop} + +Generate safe Python code to accomplish the task below. +Allowed modules ONLY: +- pyautogui (mouse, keyboard — if needed) +- pathlib.Path (file/folder inspection only, no deletion) +- shutil.copy2, shutil.copytree, shutil.disk_usage (NO move, NO rmtree) +- os_path (os.path equivalent, read-only) +- time.sleep +{os_specific} + +Hard rules: +- NO file deletion (no unlink, no rmtree, no remove) +- NO subprocess calls +- NO exec() or eval() inside the code +- NO import statements (modules are pre-injected) +- NO file write operations except explicitly requested +- If task cannot be done safely with these tools, output exactly: UNSAFE + +Output ONLY the Python code. No explanation, no markdown, no backticks. + +Task: {task}""" + try: - code = call_llm_text(prompt, system=system).strip() + response = _client.models.generate_content(model="gemini-2.5-flash", contents=prompt) + code = response.text.strip() if code.startswith("```"): lines = code.split("\n") code = "\n".join(lines[1:-1]).strip() diff --git a/actions/dev_agent.py b/actions/dev_agent.py index 871f1b6..a93107c 100644 --- a/actions/dev_agent.py +++ b/actions/dev_agent.py @@ -13,10 +13,26 @@ def get_base_dir(): BASE_DIR = get_base_dir() +API_CONFIG_PATH = BASE_DIR / "config" / "api_keys.json" PROJECTS_DIR = Path.home() / "Desktop" / "JarvisProjects" MAX_FIX_ATTEMPTS = 5 +MODEL_PLANNER = "gemini-2.5-flash" +MODEL_WRITER = "gemini-2.5-flash" -from core.llm_client import call_llm_text as _llm +def _get_api_key() -> str: + with open(API_CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f)["gemini_api_key"] + + +def _get_model(model_name: str): + from google import genai + _c = genai.Client(api_key=_get_api_key()) + + class _W: + def generate_content(self, contents): + return _c.models.generate_content(model=model_name, contents=contents) + + return _W() def _strip_fences(text: str) -> str: @@ -86,28 +102,26 @@ class RateLimitError(Exception): def _plan_project(description: str, language: str) -> dict: - system = ( - "You are a senior software architect. " - "Return ONLY valid JSON — no markdown, no explanation." - ) - prompt = f"""Create a minimal, complete file plan for this project. + model = _get_model(MODEL_PLANNER) + + prompt = f"""You are a senior software architect. Create a minimal, complete file plan for this project. Language: {language} Description: {description} -Return ONLY valid JSON: +Return ONLY valid JSON — no markdown, no explanation: {{ "project_name": "snake_case_name", "entry_point": "main.py", "files": [ {{ "path": "main.py", - "description": "Entry point", - "imports": ["utils.helpers"] + "description": "Entry point — what it does and which modules it imports", + "imports": ["utils.helpers", "core.engine"] }}, {{ "path": "utils/helpers.py", - "description": "Helpers", + "description": "Helper utilities — what functions it exposes", "imports": [] }} ], @@ -115,16 +129,25 @@ Return ONLY valid JSON: "dependencies": ["requests"] }} -Rules: dependency order, relative paths, entry point last, stdlib NOT in dependencies. +Critical rules: +1. List files in DEPENDENCY ORDER — files with no imports come first, entry point comes last. +2. The "imports" field must list every other project module this file imports (dot-notation, e.g. "utils.helpers"). +3. Keep it minimal — only files truly needed. +4. Entry point must be in the files list. +5. Use relative paths only (e.g. "utils/helpers.py", not absolute paths). +6. Standard library modules (os, sys, json, etc.) do NOT go in "dependencies". JSON:""" try: - raw = _strip_fences(_llm(prompt, system=system)) + response = model.generate_content(prompt) + raw = _strip_fences(response.text) return json.loads(raw) except json.JSONDecodeError as e: - raise ValueError(f"Planner returned invalid JSON: {e}") + raise ValueError(f"Planner returned invalid JSON: {e}\nRaw: {response.text[:300]}") except Exception as e: + if _is_rate_limit(e): + raise RateLimitError(str(e)) raise def _write_file( @@ -135,43 +158,80 @@ def _write_file( project_dir: Path, already_written: dict[str, str], ) -> str: - file_path = file_info["path"] - file_desc = file_info.get("description", "") + model = _get_model(MODEL_WRITER) + + file_path = file_info["path"] + file_desc = file_info.get("description", "") file_imports = file_info.get("imports", []) file_list = "\n".join( f" [{i+1}] {f['path']}: {f.get('description', '')}" for i, f in enumerate(all_files) ) - dep_ctx = "" + + dependency_context = "" for dep_dotted in file_imports: dep_path = dep_dotted.replace(".", "/") + ".py" if dep_path in already_written: - dep_ctx += f"\n\n--- {dep_path} ---\n{already_written[dep_path][:2000]}" + code_snippet = already_written[dep_path][:2000] + dependency_context += f"\n\n--- {dep_path} (you must import from this) ---\n{code_snippet}" - system = ( - f"You are a senior {language} developer. " - "Output ONLY raw code — no explanation, no markdown, no backticks." - ) - prompt = ( - f"Project goal: {project_description}\n\n" - f"Files (dependency order):\n{file_list}\n" - f"{dep_ctx}\n\n" - f"Write complete code for: {file_path}\n" - f"Purpose: {file_desc}\n" - f"{'Imports from: ' + ', '.join(file_imports) if file_imports else 'No project-internal imports.'}\n\n" - f"Rules: COMPLETE & RUNNABLE code, no placeholders, match import paths exactly.\n\n" - f"Code for {file_path}:" - ) + lang_rules = "" + if language.lower() == "python": + lang_rules = """ +Python-specific rules: +- Use type hints for all function signatures. +- Add docstrings for all public functions and classes. +- Use if __name__ == "__main__": guard in the entry point. +- For relative imports within the project, use: from utils.helpers import foo (match the project structure exactly). +- Do NOT use implicit relative imports (from . import ...) unless it's a proper package with __init__.py. +- If this is a package subdirectory, create __init__.py files where needed.""" + elif language.lower() in ("javascript", "typescript", "js", "ts"): + lang_rules = """ +JS/TS-specific rules: +- Use ES modules (import/export), not CommonJS (require). +- Add JSDoc comments for all exported functions. +- Handle promise rejections with try/catch in async functions.""" + + prompt = f"""You are a senior {language} developer writing production-quality code for a real project. + +Project goal: {project_description} + +Complete project file structure (in dependency order): +{file_list} + +{f"Dependencies this file must import from other project files:{dependency_context}" if dependency_context else ""} + +Your task: Write the complete, working code for: {file_path} +Purpose of this file: {file_desc} +{f"This file imports from: {', '.join(file_imports)}" if file_imports else "This file has no project-internal imports."} + +{lang_rules} + +General rules: +- Output ONLY raw code. Absolutely no explanation, no markdown, no triple backticks. +- Write COMPLETE, RUNNABLE code — no placeholders, no "# TODO", no "pass" stubs. +- Every import must either be from the standard library, listed dependencies, or the project files shown above. +- Match import paths EXACTLY to the file paths in the project structure (e.g. if file is "utils/helpers.py", import as "from utils.helpers import ..."). +- Use proper error handling (try/except) where I/O or network calls are made. +- The code must work correctly when the project entry point is run from the project root directory. + +Code for {file_path}:""" try: - code = _strip_fences(_llm(prompt, system=system)) + response = model.generate_content(prompt) + code = _strip_fences(response.text) + full_path = project_dir / file_path full_path.parent.mkdir(parents=True, exist_ok=True) full_path.write_text(code, encoding="utf-8") + print(f"[DevAgent] ✅ Written: {file_path} ({len(code)} chars)") return code + except Exception as e: + if _is_rate_limit(e): + raise RateLimitError(str(e)) raise def _install_dependencies(dependencies: list[str], project_dir: Path) -> str: @@ -295,10 +355,13 @@ def _fix_files( entry_point: str, ) -> dict[str, str]: + model = _get_model(MODEL_PLANNER) + error_file, error_line = _parse_traceback(error_output, list(file_codes.keys())) error_type = _classify_error(error_output) files_to_fix: list[str] = [] + if error_file: files_to_fix.append(error_file) if error_type == "import_error": @@ -312,40 +375,61 @@ def _fix_files( updated_codes: dict[str, str] = {} - system = f"You are an expert {language} debugger. Return ONLY the complete fixed code — no explanation, no markdown, no backticks." - for fix_path in files_to_fix: current_code = file_codes.get(fix_path, "") + other_ctx = "" for fp, code in file_codes.items(): if fp != fix_path and code: - other_ctx += f"\n--- {fp} ---\n{code[:1500]}\n" + snippet = code[:1500] + ("..." if len(code) > 1500 else "") + other_ctx += f"\n--- {fp} ---\n{snippet}\n" - line_hint = ( - f"\nError near line {error_line} in this file." - if error_line and fix_path == error_file else "" - ) + line_hint = f"\nError appears to be near line {error_line} in this file." if ( + error_line and fix_path == error_file + ) else "" - prompt = ( - f"Project goal: {project_description}\n\n" - f"All files:\n" - + "\n".join(f" - {f['path']}: {f.get('description', '')}" for f in all_files) - + f"\n\nOther files (context):\n{other_ctx[:3500]}\n\n" - f"File to fix: {fix_path}{line_hint}\n" - f"Error type: {error_type}\n\n" - f"Error output:\n{error_output[:2500]}\n\n" - f"Current code:\n{current_code}\n\n" - f"Fixed code for {fix_path}:" - ) + prompt = f"""You are an expert {language} debugger. Fix the broken file below. + +Project goal: {project_description} + +All project files: +{chr(10).join(f" - {f['path']}: {f.get('description', '')}" for f in all_files)} + +Other files for context (read-only — fix only the target file): +{other_ctx[:3500]} + +File to fix: {fix_path}{line_hint} +Error type: {error_type} + +Error output: +{error_output[:2500]} + +Current (broken) code: +{current_code} + +Rules: +- Output ONLY the complete fixed code. No explanation, no markdown, no backticks. +- Fix ALL errors visible in the error output. +- Keep all existing correct logic — do not remove working features. +- Ensure import paths match the actual project file structure exactly. +- Do NOT introduce new bugs or remove error handling. + +Fixed code for {fix_path}:""" try: - fixed = _strip_fences(_llm(prompt, system=system)) + response = model.generate_content(prompt) + fixed = _strip_fences(response.text) + full_path = project_dir / fix_path full_path.parent.mkdir(parents=True, exist_ok=True) full_path.write_text(fixed, encoding="utf-8") + updated_codes[fix_path] = fixed print(f"[DevAgent] 🔧 Fixed: {fix_path}") + except Exception as e: + if _is_rate_limit(e): + raise RateLimitError(str(e)) print(f"[DevAgent] ⚠️ Could not fix {fix_path}: {e}") return updated_codes diff --git a/actions/file_processor.py b/actions/file_processor.py index c0654c0..cbc2620 100644 --- a/actions/file_processor.py +++ b/actions/file_processor.py @@ -25,19 +25,21 @@ import tempfile from pathlib import Path from datetime import datetime -from core.llm_client import call_llm_text as _llm_text +def _get_api_key() -> str: + config_path = Path(__file__).resolve().parent.parent / "config" / "api_keys.json" + with open(config_path, "r", encoding="utf-8") as f: + return json.load(f)["gemini_api_key"] def _gemini_client(): - # Compatibility shim — returns an object with .generate_content() - class _Shim: - def generate_content(self, prompt: str) -> "_R": - class _R: - pass - r = _R() - r.text = _llm_text(prompt) - return r - return _Shim() + from google import genai + _c = genai.Client(api_key=_get_api_key()) + + class _W: + def generate_content(self, contents): + return _c.models.generate_content(model="gemini-2.5-flash", contents=contents) + + return _W() def _detect_type(path: Path) -> str: diff --git a/actions/flight_finder.py b/actions/flight_finder.py index c6e4b8a..270f5b1 100644 --- a/actions/flight_finder.py +++ b/actions/flight_finder.py @@ -18,6 +18,10 @@ BASE_DIR = _get_base_dir() API_CONFIG_PATH = BASE_DIR / "config" / "api_keys.json" +def _get_api_key() -> str: + with open(API_CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f)["gemini_api_key"] + _MONTH_MAP: dict[str, int] = { "january": 1, "february": 2, "march": 3, "april": 4, @@ -58,16 +62,21 @@ def _parse_date(raw: str) -> str: return val.strftime("%Y-%m-%d") try: - from core.llm_client import call_llm_text - result = call_llm_text( - f"Today is {today.strftime('%Y-%m-%d')}. " - f"Convert this date expression to YYYY-MM-DD: '{raw}'. " - f"Return ONLY the date string, nothing else." - ).strip() + from google import genai as _genai + _client = _genai.Client(api_key=_get_api_key()) + response = _client.models.generate_content( + model="gemini-2.5-flash-lite", + contents=( + f"Today is {today.strftime('%Y-%m-%d')}. " + f"Convert this date expression to YYYY-MM-DD: '{raw}'. " + f"Return ONLY the date string, nothing else." + ) + ) + result = response.text.strip() if re.match(r"\d{4}-\d{2}-\d{2}", result): return result except Exception as e: - print(f"[FlightFinder] ⚠️ LLM date parse failed: {e}") + print(f"[FlightFinder] ⚠️ Gemini date parse failed: {e}") for month_name, month_num in _MONTH_MAP.items(): if month_name in lower: @@ -145,28 +154,36 @@ def _parse_flights_with_gemini( destination: str, date: str, ) -> list[dict]: - from core.llm_client import call_llm_text + from google import genai as _genai + from google.genai import types - system = ( - "You are a flight data extraction expert. " - "Extract flight information from raw webpage text. " - "Return ONLY valid JSON — no markdown, no explanation." - ) - prompt = ( + _client = _genai.Client(api_key=_get_api_key()) + prompt = ( f"Extract flight options from {origin} to {destination} on {date} " - f"from this Google Flights page text:\n\n{raw_text[:8000]}\n\n" + f"from this Google Flights page text:\n\n{raw_text[:12000]}\n\n" f"Return a JSON array of up to 5 flights:\n" f'[{{"airline":"...","departure":"HH:MM","arrival":"HH:MM",' f'"duration":"Xh Ym","stops":0,"price":"...","currency":"USD"}}]\n' f"If no flights found, return: []" ) + try: - text = call_llm_text(prompt, system=system) - text = re.sub(r"```(?:json)?", "", text).strip().rstrip("`").strip() - flights = json.loads(text) + response = _client.models.generate_content( + model="gemini-2.5-flash", + contents=prompt, + config=types.GenerateContentConfig( + system_instruction=( + "You are a flight data extraction expert. " + "Extract flight information from raw webpage text. " + "Return ONLY valid JSON — no markdown, no explanation." + ) + ), + ) + text = re.sub(r"```(?:json)?", "", response.text).strip().rstrip("`").strip() + flights = json.loads(text) return flights if isinstance(flights, list) else [] except Exception as e: - print(f"[FlightFinder] ⚠️ LLM parse failed: {e}") + print(f"[FlightFinder] ⚠️ Gemini parse failed: {e}") return [] def _format_spoken( diff --git a/actions/screen_processor.py b/actions/screen_processor.py index 23c388f..84db42e 100644 --- a/actions/screen_processor.py +++ b/actions/screen_processor.py @@ -1,272 +1,442 @@ -""" -MARK XL — Screen / Camera Processor -Replaces Gemini Live vision session with a direct Ollama vision-model call. -The analysis text is returned (and optionally spoken via the `speak` callback). -""" -from __future__ import annotations - -import base64 -import io -import json -import sys -from pathlib import Path -from typing import Optional, Callable - -try: - import cv2 - _CV2 = True -except ImportError: - _CV2 = False - -try: - import mss - import mss.tools - _MSS = True -except ImportError: - _MSS = False - -try: - import PIL.Image - _PIL = True -except ImportError: - _PIL = False - -import platform - -import requests - - -def _base_dir() -> Path: - if getattr(sys, "frozen", False): - return Path(sys.executable).parent - return Path(__file__).resolve().parent.parent - - -_BASE = _base_dir() -_CONFIG_PATH = _BASE / "config" / "api_keys.json" - -_IMG_MAX_W = 640 -_IMG_MAX_H = 360 -_JPEG_Q = 60 - - -def _load_config() -> dict: - try: - return json.loads(_CONFIG_PATH.read_text(encoding="utf-8")) - except Exception: - return {} - - -def _save_config_key(key: str, value) -> None: - try: - cfg = _load_config() - cfg[key] = value - _CONFIG_PATH.write_text(json.dumps(cfg, indent=4), encoding="utf-8") - except Exception as e: - print(f"[Vision] ⚠️ Could not save config key '{key}': {e}") - - -def _get_os() -> str: - s = platform.system().lower() - if s == "darwin": return "mac" - if s == "windows": return "windows" - return "linux" - - -# --------------------------------------------------------------------------- -# Image capture helpers (unchanged from original) -# --------------------------------------------------------------------------- - -def _compress(img_bytes: bytes, source_format: str = "PNG") -> tuple[bytes, str]: - if not _PIL: - return img_bytes, f"image/{source_format.lower()}" - try: - img = PIL.Image.open(io.BytesIO(img_bytes)).convert("RGB") - img.thumbnail((_IMG_MAX_W, _IMG_MAX_H), PIL.Image.BILINEAR) - buf = io.BytesIO() - img.save(buf, format="JPEG", quality=_JPEG_Q, optimize=False) - return buf.getvalue(), "image/jpeg" - except Exception as e: - print(f"[Vision] ⚠️ Image compress failed: {e}") - return img_bytes, f"image/{source_format.lower()}" - - -def _capture_screen() -> tuple[bytes, str]: - if not _MSS: - raise RuntimeError("mss is not installed. Run: pip install mss") - with mss.mss() as sct: - monitors = sct.monitors - target = monitors[1] if len(monitors) > 1 else monitors[0] - shot = sct.grab(target) - png = mss.tools.to_png(shot.rgb, shot.size) - return _compress(png, "PNG") - - -def _cv2_backend() -> int: - if not _CV2: - return 0 - os_name = _get_os() - if os_name == "windows": - return cv2.CAP_DSHOW - if os_name == "mac": - return cv2.CAP_AVFOUNDATION - return cv2.CAP_ANY - - -def _probe_camera(index: int, backend: int, warmup: int = 5) -> bool: - if not _CV2: - return False - import numpy as np - cap = cv2.VideoCapture(index, backend) - if not cap.isOpened(): - cap.release(); return False - for _ in range(warmup): - cap.read() - ret, frame = cap.read() - cap.release() - if not ret or frame is None: - return False - return bool(np.mean(frame) > 8) - - -def _detect_camera_index() -> int: - backend = _cv2_backend() - print("[Vision] 🔍 Auto-detecting camera…") - for idx in range(6): - if _probe_camera(idx, backend): - print(f"[Vision] ✅ Camera found at index {idx}") - _save_config_key("camera_index", idx) - return idx - print(f"[Vision] ⚠️ Camera index {idx}: no usable frame") - print("[Vision] ⚠️ No camera found — defaulting to index 0") - _save_config_key("camera_index", 0) - return 0 - - -def _get_camera_index() -> int: - cfg = _load_config() - if "camera_index" in cfg: - return int(cfg["camera_index"]) - return _detect_camera_index() - - -def _capture_camera() -> tuple[bytes, str]: - if not _CV2: - raise RuntimeError("OpenCV (cv2) is not installed. Run: pip install opencv-python") - import numpy as np - index = _get_camera_index() - backend = _cv2_backend() - cap = cv2.VideoCapture(index, backend) - if not cap.isOpened(): - raise RuntimeError(f"Camera index {index} could not be opened.") - for _ in range(10): - cap.read() - ret, frame = cap.read() - cap.release() - if not ret or frame is None: - raise RuntimeError("Camera returned no frame.") - if _PIL: - rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - img = PIL.Image.fromarray(rgb) - img.thumbnail((_IMG_MAX_W, _IMG_MAX_H), PIL.Image.BILINEAR) - buf = io.BytesIO() - img.save(buf, format="JPEG", quality=_JPEG_Q) - return buf.getvalue(), "image/jpeg" - _, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, _JPEG_Q]) - return buf.tobytes(), "image/jpeg" - - -# --------------------------------------------------------------------------- -# Vision analysis via Ollama -# --------------------------------------------------------------------------- - -_SYSTEM_PROMPT = ( - "You are JARVIS, an advanced AI assistant. " - "Analyze the provided image with precision and intelligence. " - "Be concise and direct — maximum two sentences unless the user's question " - "requires more detail. " - "Address the user respectfully." -) - - -def _call_vision(image_bytes: bytes, mime: str, user_text: str) -> str: - cfg = _load_config() - url = cfg.get("llm_url", "http://localhost:11434").rstrip("/") - vision_model = cfg.get("vision_model") or cfg.get("llm_model", "llava") - - b64 = base64.b64encode(image_bytes).decode("ascii") - - payload = { - "model": vision_model, - "stream": False, - "messages": [ - {"role": "system", "content": _SYSTEM_PROMPT}, - { - "role": "user", - "content": user_text, - "images": [b64], - }, - ], - } - try: - resp = requests.post(f"{url}/api/chat", json=payload, timeout=60) - resp.raise_for_status() - return (resp.json().get("message", {}).get("content") or "").strip() - except requests.exceptions.ConnectionError: - return "Cannot connect to Ollama. Make sure Ollama is running." - except Exception as e: - return f"Vision analysis failed: {e}" - - -# --------------------------------------------------------------------------- -# Public entry point -# --------------------------------------------------------------------------- - -def screen_process( - parameters: dict, - response=None, - player=None, - session_memory=None, - speak: Optional[Callable[[str], None]] = None, -) -> str: - """ - Capture screen or camera, analyse with Ollama vision model. - - Returns the analysis text (str). - Optionally speaks via `speak` callback and logs to `player`. - """ - params = parameters or {} - user_text = (params.get("text") or params.get("user_text") or "").strip() - angle = params.get("angle", "screen").lower().strip() - - if not user_text: - user_text = "What do you see? Describe briefly." - - if player: - player.write_log(f"SYS: Vision [{angle}] — {user_text[:60]}") - - # Capture - try: - if angle == "camera": - image_bytes, mime = _capture_camera() - print(f"[Vision] 📷 Camera: {len(image_bytes):,} bytes") - else: - image_bytes, mime = _capture_screen() - print(f"[Vision] 🖥️ Screen: {len(image_bytes):,} bytes") - except Exception as e: - msg = f"Capture error: {e}" - print(f"[Vision] ❌ {msg}") - if player: player.write_log(f"ERR: {msg}") - return msg - - # Analyse - analysis = _call_vision(image_bytes, mime, user_text) - print(f"[Vision] 💬 {analysis[:120]}") - - if player: - player.write_log(f"Jarvis: {analysis}") - - if speak and analysis: - speak(analysis) - - return analysis +from __future__ import annotations + +import asyncio +import base64 +import io +import json +import re +import sys +import threading +import time +from pathlib import Path +from typing import Optional + +import numpy as np +import sounddevice as sd + +try: + import cv2 + _CV2 = True +except ImportError: + _CV2 = False + +try: + import mss + import mss.tools + _MSS = True +except ImportError: + _MSS = False + +try: + import PIL.Image + _PIL = True +except ImportError: + _PIL = False + +from google import genai +from google.genai import types as gtypes + +def _base_dir() -> Path: + if getattr(sys, "frozen", False): + return Path(sys.executable).parent + return Path(__file__).resolve().parent.parent + + +_BASE = _base_dir() +_CONFIG_PATH = _BASE / "config" / "api_keys.json" + + +def _load_config() -> dict: + try: + return json.loads(_CONFIG_PATH.read_text(encoding="utf-8")) + except Exception: + return {} + + +def _save_config_key(key: str, value) -> None: + try: + cfg = _load_config() + cfg[key] = value + _CONFIG_PATH.write_text(json.dumps(cfg, indent=4), encoding="utf-8") + except Exception as e: + print(f"[Vision] ⚠️ Could not save config key '{key}': {e}") + + +def _get_api_key() -> str: + key = _load_config().get("gemini_api_key", "") + if not key: + raise RuntimeError("gemini_api_key not found in config.") + return key + + +def _get_os() -> str: + return _load_config().get("os_system", "windows").lower() + +_LIVE_MODEL = "models/gemini-2.5-flash-native-audio-preview-12-2025" +_CHANNELS = 1 +_RECEIVE_SAMPLE_RATE = 24_000 +_CHUNK_SIZE = 1_024 + +_IMG_MAX_W = 640 +_IMG_MAX_H = 360 +_JPEG_Q = 60 + +_SYSTEM_PROMPT = ( + "You are JARVIS, an advanced AI assistant. " + "Analyze the provided image with precision and intelligence. " + "Be concise and direct — maximum two sentences unless the user's question " + "requires more detail. " + "Address the user respectfully. " + "Always call the appropriate tool; never simulate results." +) + + +def _compress(img_bytes: bytes, source_format: str = "PNG") -> tuple[bytes, str]: + if not _PIL: + return img_bytes, f"image/{source_format.lower()}" + + try: + img = PIL.Image.open(io.BytesIO(img_bytes)).convert("RGB") + img.thumbnail((_IMG_MAX_W, _IMG_MAX_H), PIL.Image.BILINEAR) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=_JPEG_Q, optimize=False) + return buf.getvalue(), "image/jpeg" + except Exception as e: + print(f"[Vision] ⚠️ Image compress failed: {e}") + return img_bytes, f"image/{source_format.lower()}" + +def _capture_screen() -> tuple[bytes, str]: + + if not _MSS: + raise RuntimeError("mss is not installed. Run: pip install mss") + + with mss.mss() as sct: + monitors = sct.monitors # [0] = all combined, [1..n] = real screens + target = monitors[1] if len(monitors) > 1 else monitors[0] + shot = sct.grab(target) + png = mss.tools.to_png(shot.rgb, shot.size) + + return _compress(png, "PNG") + + +def _cv2_backend() -> int: + """Return the best OpenCV camera backend for the current OS.""" + if not _CV2: + return 0 + os_name = _get_os() + if os_name == "windows": + return cv2.CAP_DSHOW + if os_name == "mac": + return cv2.CAP_AVFOUNDATION + return cv2.CAP_ANY + + +def _probe_camera(index: int, backend: int, warmup: int = 5) -> bool: + + if not _CV2: + return False + cap = cv2.VideoCapture(index, backend) + if not cap.isOpened(): + cap.release() + return False + for _ in range(warmup): + cap.read() + ret, frame = cap.read() + cap.release() + if not ret or frame is None: + return False + return bool(np.mean(frame) > 8) + + +def _detect_camera_index() -> int: + + backend = _cv2_backend() + print("[Vision] 🔍 Auto-detecting camera...") + for idx in range(6): + if _probe_camera(idx, backend): + print(f"[Vision] ✅ Camera found at index {idx}") + _save_config_key("camera_index", idx) + return idx + print(f"[Vision] ⚠️ Camera index {idx}: no usable frame") + + print("[Vision] ⚠️ No camera found — defaulting to index 0") + _save_config_key("camera_index", 0) + return 0 + + +def _get_camera_index() -> int: + cfg = _load_config() + if "camera_index" in cfg: + return int(cfg["camera_index"]) + return _detect_camera_index() + + +def _capture_camera() -> tuple[bytes, str]: + if not _CV2: + raise RuntimeError("OpenCV (cv2) is not installed. Run: pip install opencv-python") + + index = _get_camera_index() + backend = _cv2_backend() + cap = cv2.VideoCapture(index, backend) + + if not cap.isOpened(): + raise RuntimeError(f"Camera index {index} could not be opened.") + + for _ in range(10): + cap.read() + + ret, frame = cap.read() + cap.release() + + if not ret or frame is None: + raise RuntimeError("Camera returned no frame.") + + if _PIL: + rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + img = PIL.Image.fromarray(rgb) + img.thumbnail((_IMG_MAX_W, _IMG_MAX_H), PIL.Image.BILINEAR) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=_JPEG_Q) + return buf.getvalue(), "image/jpeg" + + _, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, _JPEG_Q]) + return buf.tobytes(), "image/jpeg" + +class _VisionSession: + def __init__(self): + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._thread: Optional[threading.Thread] = None + self._session = None + self._out_queue: Optional[asyncio.Queue] = None + self._audio_in: Optional[asyncio.Queue] = None + self._ready_evt: threading.Event = threading.Event() + self._player = None + self._lock: threading.Lock = threading.Lock() + + def start(self, player=None, timeout: float = 25.0) -> None: + with self._lock: + if self._thread and self._thread.is_alive(): + if player is not None: + self._player = player + return + self._player = player + self._thread = threading.Thread( + target=self._run_event_loop, + daemon=True, + name="VisionSessionThread", + ) + self._thread.start() + + if not self._ready_evt.wait(timeout=timeout): + raise RuntimeError(f"Vision session did not connect within {timeout}s.") + print("[Vision] ✅ Session ready") + + def analyze(self, image_bytes: bytes, mime_type: str, user_text: str) -> None: + if not self._loop or not self._out_queue: + print("[Vision] ⚠️ Session not started — dropping request") + return + asyncio.run_coroutine_threadsafe( + self._out_queue.put((image_bytes, mime_type, user_text)), + self._loop, + ) + + def is_ready(self) -> bool: + return self._session is not None + + def _run_event_loop(self) -> None: + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + self._loop.run_until_complete(self._session_loop()) + + async def _session_loop(self) -> None: + self._out_queue = asyncio.Queue(maxsize=30) + self._audio_in = asyncio.Queue() + + client = genai.Client( + api_key=_get_api_key(), + http_options={"api_version": "v1beta"}, + ) + config = gtypes.LiveConnectConfig( + response_modalities=["AUDIO"], + output_audio_transcription={}, + system_instruction=_SYSTEM_PROMPT, + speech_config=gtypes.SpeechConfig( + voice_config=gtypes.VoiceConfig( + prebuilt_voice_config=gtypes.PrebuiltVoiceConfig( + voice_name="Charon" + ) + ) + ), + ) + + backoff = 2.0 + while True: + try: + print("[Vision] 🔌 Connecting...") + async with client.aio.live.connect( + model=_LIVE_MODEL, config=config + ) as session: + self._session = session + self._ready_evt.set() + backoff = 2.0 + print("[Vision] ✅ Connected") + + async with asyncio.TaskGroup() as tg: + tg.create_task(self._send_loop()) + tg.create_task(self._recv_loop()) + tg.create_task(self._play_loop()) + + except* Exception as eg: + for exc in eg.exceptions: + print(f"[Vision] ⚠️ Session error: {exc}") + finally: + self._session = None + self._ready_evt.clear() + + print(f"[Vision] 🔄 Reconnecting in {backoff:.0f}s...") + await asyncio.sleep(backoff) + backoff = min(backoff * 1.5, 30.0) + self._ready_evt.set() + + async def _send_loop(self) -> None: + while True: + image_bytes, mime_type, user_text = await self._out_queue.get() + if not self._session: + print("[Vision] ⚠️ No session — dropping image") + continue + try: + b64 = base64.b64encode(image_bytes).decode("ascii") + await self._session.send_client_content( + turns={ + "parts": [ + {"inline_data": {"mime_type": mime_type, "data": b64}}, + {"text": user_text}, + ] + }, + turn_complete=True, + ) + print(f"[Vision] 📤 Sent {len(image_bytes):,} bytes — '{user_text[:60]}'") + except Exception as e: + print(f"[Vision] ⚠️ Send error: {e}") + + async def _recv_loop(self) -> None: + transcript: list[str] = [] + try: + async for response in self._session.receive(): + if response.data: + await self._audio_in.put(response.data) + + sc = response.server_content + if not sc: + continue + + if sc.output_transcription and sc.output_transcription.text: + chunk = sc.output_transcription.text.strip() + if chunk: + transcript.append(chunk) + + if sc.turn_complete: + if transcript and self._player: + full = re.sub(r"\s+", " ", " ".join(transcript)).strip() + if full: + self._player.write_log(f"Jarvis: {full}") + print(f"[Vision] 💬 {full}") + transcript = [] + + except Exception as e: + print(f"[Vision] ⚠️ Recv error: {e}") + raise + + async def _play_loop(self) -> None: + stream = sd.RawOutputStream( + samplerate=_RECEIVE_SAMPLE_RATE, + channels=_CHANNELS, + dtype="int16", + blocksize=_CHUNK_SIZE, + ) + stream.start() + try: + while True: + chunk = await self._audio_in.get() + await asyncio.to_thread(stream.write, chunk) + except Exception as e: + print(f"[Vision] ❌ Play error: {e}") + raise + finally: + stream.stop() + stream.close() + +_session = _VisionSession() +_session_lock = threading.Lock() +_session_up = False + + +def _ensure_session(player=None) -> None: + global _session_up + with _session_lock: + if not _session_up: + _session.start(player=player) + _session_up = True + elif player is not None: + _session._player = player + + +def screen_process( + parameters: dict, + response=None, + player=None, + session_memory=None, +) -> bool: + + params = parameters or {} + user_text = (params.get("text") or params.get("user_text") or "").strip() + angle = params.get("angle", "screen").lower().strip() + + if not user_text: + print("[Vision] ⚠️ No question provided — aborting") + return False + + print(f"[Vision] ▶ angle={angle!r} question='{user_text[:80]}'") + + try: + _ensure_session(player=player) + except Exception as e: + print(f"[Vision] ❌ Could not start session: {e}") + return False + + try: + if angle == "camera": + image_bytes, mime_type = _capture_camera() + print(f"[Vision] 📷 Camera: {len(image_bytes):,} bytes") + else: + image_bytes, mime_type = _capture_screen() + print(f"[Vision] 🖥️ Screen: {len(image_bytes):,} bytes") + except Exception as e: + print(f"[Vision] ❌ Capture error: {e}") + return False + + _session.analyze(image_bytes, mime_type, user_text) + return True + + +def warmup_session(player=None) -> None: + try: + _ensure_session(player=player) + except Exception as e: + print(f"[Vision] ⚠️ Warmup failed: {e}") + +if __name__ == "__main__": + print("[TEST] screen_processor.py") + print("=" * 52) + mode = input("angle — screen / camera (default: screen): ").strip().lower() or "screen" + q = input("Question (Enter = default): ").strip() or "What do you see? Be brief." + + t0 = time.perf_counter() + warmup_session() + print(f"Session ready in {time.perf_counter()-t0:.2f}s\n") + + t1 = time.perf_counter() + ok = screen_process({"angle": mode, "text": q}) + print(f"Queued in {time.perf_counter()-t1:.3f}s — waiting for audio...") + time.sleep(10) + print("Done." if ok else "Failed.") \ No newline at end of file diff --git a/actions/web_search.py b/actions/web_search.py index 192d698..22a9109 100644 --- a/actions/web_search.py +++ b/actions/web_search.py @@ -1,117 +1,141 @@ -# web_search.py -# Gemini grounded-search replaced with DuckDuckGo + Ollama LLM summarization. -import json -import sys -from pathlib import Path - - -def _get_base_dir() -> Path: - if getattr(sys, "frozen", False): - return Path(sys.executable).parent - return Path(__file__).resolve().parent.parent - - -BASE_DIR = _get_base_dir() - - -def _ddg_search(query: str, max_results: int = 6) -> list[dict]: - try: - from ddgs import DDGS - except ImportError: - from duckduckgo_search import DDGS - - results = [] - with DDGS() as ddgs: - for r in ddgs.text(query, max_results=max_results): - results.append({ - "title": r.get("title", ""), - "snippet": r.get("body", ""), - "url": r.get("href", ""), - }) - return results - - -def _format_ddg(query: str, results: list[dict]) -> str: - if not results: - return f"No results found for: {query}" - lines = [f"Search results for: {query}\n"] - for i, r in enumerate(results, 1): - if r.get("title"): lines.append(f"{i}. {r['title']}") - if r.get("snippet"): lines.append(f" {r['snippet']}") - if r.get("url"): lines.append(f" {r['url']}") - lines.append("") - return "\n".join(lines).strip() - - -def _llm_summarize(query: str, raw_results: str) -> str: - try: - from core.llm_client import call_llm_text - system = ( - "You are JARVIS. Summarize web search results clearly and concisely. " - "Answer the user's query directly. Be factual. Address user as 'sir'." - ) - prompt = ( - f"User question: {query}\n\n" - f"Web search results:\n{raw_results[:4000]}\n\n" - "Answer the question based on these results:" - ) - return call_llm_text(prompt, system=system) - except Exception: - return raw_results - - -def _compare(items: list[str], aspect: str) -> str: - all_results: dict[str, list] = {} - for item in items: - try: - all_results[item] = _ddg_search(f"{item} {aspect}", max_results=3) - except Exception: - all_results[item] = [] - - lines = [f"Comparison — {aspect.upper()}", "─" * 40] - for item in items: - lines.append(f"\n▸ {item}") - for r in all_results.get(item, [])[:2]: - if r.get("snippet"): - lines.append(f" • {r['snippet']}") - raw = "\n".join(lines) - return _llm_summarize(f"Compare {', '.join(items)} regarding {aspect}", raw) - - -def web_search( - parameters: dict, - response=None, - player=None, - session_memory=None, -) -> str: - params = parameters or {} - query = params.get("query", "").strip() - mode = params.get("mode", "search").lower().strip() - items = params.get("items", []) - aspect = params.get("aspect", "general").strip() or "general" - - if not query and not items: - return "Please provide a search query, sir." - - if items and mode != "compare": - mode = "compare" - - if player: - player.write_log(f"[Search] {query or ', '.join(items)}") - - print(f"[WebSearch] 🔍 Query: {query!r} Mode: {mode}") - - try: - if mode == "compare" and items: - print(f"[WebSearch] 📊 Comparing: {items}") - return _compare(items, aspect) - - results = _ddg_search(query) - raw = _format_ddg(query, results) - print(f"[WebSearch] ✅ DDG: {len(results)} result(s).") - # Let Ollama summarise the results for a cleaner spoken response - return _llm_summarize(query, raw) - - except Exception as e: - print(f"[WebSearch] ❌ Failed: {e}") - return f"Search failed, sir: {e}" +#web_search.py +import json +import sys +from pathlib import Path + +def _get_base_dir() -> Path: + if getattr(sys, "frozen", False): + return Path(sys.executable).parent + return Path(__file__).resolve().parent.parent + + +BASE_DIR = _get_base_dir() +API_CONFIG_PATH = BASE_DIR / "config" / "api_keys.json" + + +def _get_api_key() -> str: + with open(API_CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f)["gemini_api_key"] + + +def _gemini_search(query: str) -> str: + from google import genai + + client = genai.Client(api_key=_get_api_key()) + response = client.models.generate_content( + model="gemini-2.5-flash", + contents=query, + config={"tools": [{"google_search": {}}]}, + ) + + text = "" + for part in response.candidates[0].content.parts: + if hasattr(part, "text") and part.text: + text += part.text + + text = text.strip() + if not text: + raise ValueError("Gemini returned an empty response.") + return text + + +def _ddg_search(query: str, max_results: int = 6) -> list[dict]: + try: + from ddgs import DDGS + except ImportError: + from duckduckgo_search import DDGS + + results = [] + with DDGS() as ddgs: + for r in ddgs.text(query, max_results=max_results): + results.append({ + "title": r.get("title", ""), + "snippet": r.get("body", ""), + "url": r.get("href", ""), + }) + return results + + +def _format_ddg(query: str, results: list[dict]) -> str: + if not results: + return f"No results found for: {query}" + + lines = [f"Search results for: {query}\n"] + for i, r in enumerate(results, 1): + if r.get("title"): lines.append(f"{i}. {r['title']}") + if r.get("snippet"): lines.append(f" {r['snippet']}") + if r.get("url"): lines.append(f" {r['url']}") + lines.append("") + return "\n".join(lines).strip() + +def _compare(items: list[str], aspect: str) -> str: + query = ( + f"Compare {', '.join(items)} in terms of {aspect}. " + "Give specific facts and data." + ) + try: + return _gemini_search(query) + except Exception as e: + print(f"[WebSearch] ⚠️ Gemini compare failed: {e} — falling back to DDG") + + # DDG fallback: fetch results per item and merge + all_results: dict[str, list] = {} + for item in items: + try: + all_results[item] = _ddg_search(f"{item} {aspect}", max_results=3) + except Exception: + all_results[item] = [] + + lines = [f"Comparison — {aspect.upper()}", "─" * 40] + for item in items: + lines.append(f"\n▸ {item}") + for r in all_results.get(item, [])[:2]: + if r.get("snippet"): + lines.append(f" • {r['snippet']}") + return "\n".join(lines) + +def web_search( + parameters: dict, + response=None, + player=None, + session_memory=None, +) -> str: + params = parameters or {} + query = params.get("query", "").strip() + mode = params.get("mode", "search").lower().strip() + items = params.get("items", []) + aspect = params.get("aspect", "general").strip() or "general" + + if not query and not items: + return "Please provide a search query, sir." + + if items and mode != "compare": + mode = "compare" + + if player: + player.write_log(f"[Search] {query or ', '.join(items)}") + + print(f"[WebSearch] 🔍 Query: {query!r} Mode: {mode}") + + try: + if mode == "compare" and items: + print(f"[WebSearch] 📊 Comparing: {items}") + result = _compare(items, aspect) + print("[WebSearch] ✅ Compare done.") + return result + + print("[WebSearch] 🌐 Trying Gemini...") + try: + result = _gemini_search(query) + print("[WebSearch] ✅ Gemini OK.") + return result + except Exception as e: + print(f"[WebSearch] ⚠️ Gemini failed ({e}) — trying DDG...") + results = _ddg_search(query) + result = _format_ddg(query, results) + print(f"[WebSearch] ✅ DDG: {len(results)} result(s).") + return result + + except Exception as e: + print(f"[WebSearch] ❌ All backends failed: {e}") + return f"Search failed, sir: {e}" \ No newline at end of file diff --git a/actions/youtube_video.py b/actions/youtube_video.py index 6cc8e70..aa51df9 100644 --- a/actions/youtube_video.py +++ b/actions/youtube_video.py @@ -9,6 +9,18 @@ from pathlib import Path from datetime import datetime from urllib.parse import quote_plus +try: + import pyautogui + _PYAUTOGUI = True +except ImportError: + _PYAUTOGUI = False + +try: + import numpy as np + _NUMPY = True +except ImportError: + _NUMPY = False + try: import requests _REQUESTS_OK = True @@ -45,6 +57,11 @@ HEADERS = { _YT_VIDEO_FILTER = "EgIQAQ%3D%3D" +def _get_api_key() -> str: + with open(API_CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f)["gemini_api_key"] + + def _open_url(url: str) -> None: try: if is_mac(): @@ -150,20 +167,26 @@ def _get_transcript(video_id: str) -> str | None: def _summarize_with_gemini(transcript: str, video_url: str) -> str: - from core.llm_client import call_llm_text - system = ( - "You are JARVIS, an AI assistant. " - "Summarize YouTube video transcripts clearly and concisely. " - "Structure: 1-sentence overview, then 3-5 key points. " - "Be direct. Address the user as 'sir'. " - "Match the language of the transcript." - ) - max_chars = 12000 + from google import genai as _genai + from google.genai import types + + _client = _genai.Client(api_key=_get_api_key()) + max_chars = 80000 truncated = transcript[:max_chars] + ("..." if len(transcript) > max_chars else "") - return call_llm_text( - f"Please summarize this YouTube video transcript:\n\n{truncated}", - system=system, + response = _client.models.generate_content( + model="gemini-2.5-flash", + contents=f"Please summarize this YouTube video transcript:\n\n{truncated}", + config=types.GenerateContentConfig( + system_instruction=( + "You are JARVIS, an AI assistant. " + "Summarize YouTube video transcripts clearly and concisely. " + "Structure: 1-sentence overview, then 3-5 key points. " + "Be direct. Address the user as 'sir'. " + "Match the language of the transcript." + ) + ) ) + return response.text.strip() def _save_summary(content: str, video_url: str) -> str: diff --git a/config/__init__.py b/config/__init__.py index 13f1a5f..4ea0c24 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -1,10 +1,15 @@ # config/__init__.py -import json -import platform +import json, os, platform from pathlib import Path _CONFIG_PATH = Path(__file__).parent / "api_keys.json" +def _platform_os() -> str: + """Auto-detect OS when config file is absent.""" + return {"Windows": "windows", "Darwin": "mac", "Linux": "linux"}.get( + platform.system(), "linux" + ) + def get_config() -> dict: try: with open(_CONFIG_PATH, "r", encoding="utf-8") as f: @@ -13,13 +18,8 @@ def get_config() -> dict: return {} def get_os() -> str: - """Returns 'windows' | 'mac' | 'linux' — detected at runtime, not from config.""" - s = platform.system().lower() - if s == "darwin": - return "mac" - if s == "windows": - return "windows" - return "linux" + """Returns: 'windows' | 'mac' | 'linux'""" + return get_config().get("os_system", _platform_os()).lower() def is_windows() -> bool: return get_os() == "windows" def is_mac() -> bool: return get_os() == "mac" diff --git a/config/certs/jarvis.crt b/config/certs/jarvis.crt new file mode 100644 index 0000000..ce02b97 --- /dev/null +++ b/config/certs/jarvis.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDAjCCAeqgAwIBAgIURVhBe4gYCY9qgmh+hvLhAsJW1YUwDQYJKoZIhvcNAQEL +BQAwKDEPMA0GA1UECgwGSkFSVklTMRUwEwYDVQQDDAxqYXJ2aXMubG9jYWwwHhcN +MjYwNjE2MTI1NjQwWhcNMzYwNjEzMTI1NjQwWjAoMQ8wDQYDVQQKDAZKQVJWSVMx +FTATBgNVBAMMDGphcnZpcy5sb2NhbDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBANg3svE1mhDUdxhnyR/XK/KnRNLTM2Tkw//UHQOTvRSOvFOOLLVbNypm ++fWBl4dZpfOGQTQxQZA3yLk+Gfnh3TxcvX8RwHcJFbJ72n0eq89aibILsU1X4gPU +m/A8Bz/417gTsQB6aRyRvR8dGAH7kjYTSvcmFOuftApOP7opRHbE221Y+TTZ9hdx +6aW/Fn4NAyQnugmqA55yHOys4yXhHRbsy9boYbUr4S4racwuEYgowDa/GAt7cj/Z +E2vWyV1AFQE4DOlhsX1Ves92vqNIQanakvrvuGCWx6TicapmyK7ftaGKdTEaL6Ix +8ilX8qci5qhqxr+sNK51Vg0CxW16+e8CAwEAAaMkMCIwIAYDVR0RBBkwF4IJbG9j +YWxob3N0hwR/AAABhwTAqAFrMA0GCSqGSIb3DQEBCwUAA4IBAQAd9+mMPvKyoghf +iBqyZN/XFBy1Va7pCMrVT2HDRO+NEIvUsgj1rl+LiODLqd7M0Hz0ItJzqdG9kuCg +obbbd3upT7HOxQi311UCg/ioeiZVP427Apt5Hc5qLSqIxeL1EK1kZIRbClwAxWhB +Za0ULqzJrcNx328s4loYJkDAFdDl3NTjtx5Gb5sOvE2Bsr5tFJ/u1SpBdO+M0hBB ++CQlhwLk5m8c6H841fW8Rex+2K+VBeRmGkze1ooKztpkC2nwV9o+QbUINsQ75p/x +JVF6JHKINERC/hQPub62CrEfhVE3UD/wT3bS1YQfdt/l6R0KNFctAQ1NdJJfufoq +cZdVMlqE +-----END CERTIFICATE----- diff --git a/config/certs/jarvis.key b/config/certs/jarvis.key new file mode 100644 index 0000000..b7b1138 --- /dev/null +++ b/config/certs/jarvis.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA2Dey8TWaENR3GGfJH9cr8qdE0tMzZOTD/9QdA5O9FI68U44s +tVs3Kmb59YGXh1ml84ZBNDFBkDfIuT4Z+eHdPFy9fxHAdwkVsnvafR6rz1qJsgux +TVfiA9Sb8DwHP/jXuBOxAHppHJG9Hx0YAfuSNhNK9yYU65+0Ck4/uilEdsTbbVj5 +NNn2F3Hppb8Wfg0DJCe6CaoDnnIc7KzjJeEdFuzL1uhhtSvhLitpzC4RiCjANr8Y +C3tyP9kTa9bJXUAVATgM6WGxfVV6z3a+o0hBqdqS+u+4YJbHpOJxqmbIrt+1oYp1 +MRovojHyKVfypyLmqGrGv6w0rnVWDQLFbXr57wIDAQABAoIBACCIePdMTms73NOi +QiP1V1qCipuCRdhnh4pF2zu/jhK6y2FzIg0DtZA+wFuNbURjW9IBoGv/AVN24U/m +JThxIL3FYontHN2MU2RwuFZdOBLSF6fflIUDtTFIW1JRZfHdOnArHlXpFUnEMAWv +df5OYRib3IrFgx5VAF0Qmwwiy3dnLpgzDLLSGdWv8GSVqi90cafSJ/WFlPNVo6c4 +kTHov3rdfk82bBL7b8tLJmJMw4kYfJ9vft4cAiWqODTy4x57ffH5v8atRzqNjq5D +jYjfeeymiHZeOLnbEpdZTUG5PfBxBamFZfT8Ll1VTEDzh+s2+VNEROZx02Y+/Rts +DkKcH+ECgYEA8/SvI7Nkgtl8V9wxq3i/SZR4aTnrbupgtXQsO8GC/bmJ281An/BJ +eyNU7W2F0uRLE8vOdKVBKXFYpYVK1tFcriAfcsIqnhj0sXxRlylZpVtxuFNFzkyR +t3eKM1UM/fMLgFXDC+DjVGBKfDeUhkwRDegDH3sHMYE0mmxL/Zov4tECgYEA4uRv +qIt8IYx4g5K4ugrebYPaAENpeAt2fv6wQVqBB57i643a3cvaxZx6zOokG7NP4fN3 +DkOz9M5I0xDO+gN5XdvVf/KXUKoCuWOzvIhQaQ5nearxaHrHL9ajYmsXzuVKF/+B +H3iAf5OIqZL1vSBmpZOUvjmXpYi6lYGvwOBkwL8CgYAag8ahV7cd7atxFTKk02vf +AG8R6MSo+npmXmkMWZz2HLtaE3XprEEgTBfH8nZwFHYAgc8/qFJrwbED1Hfs3zZ2 +inFAlM/y/P1jaQKpLkpbCZRTl2Fn6bSQv0JbD+xokYj3etrHKLssiYau94+CBTR5 +UXU9b665d7SS8CBWQPLhcQKBgGyVbmp29p0HoNU27nj6oqA+raHaoSr2MWttb+SW +75E6FNcuOERVPwm7bXmLxGQNWHmGDsAg3oqV/nwkWymDaB3nCzIEYy4TbuJSOL5N +bk4YuqSbjpS1zYWzbtW28tntGZyKhr0M6lIjxQ+CaWBtlTJAgrrWzSBHYyQRwI7J +1O35AoGBAIlj3kff7/2vo8epr/zZHjQHh1YqqMfEKA/tlf5VdARpXDcXOJ3ffgpq +EgKmDoQqN1qaKLXR/5b0NDTP47h0DmPbB0u6cJd9GOUpsqdw1dDiK64ZcxaMHHg+ +Bt3IAC69opWKKvb6i3hch0uRjz30uPhhFVY1Cp4ECQzNeB4vCqE8 +-----END RSA PRIVATE KEY----- diff --git a/core/prompt.txt b/core/prompt.txt index ee3acfd..edb6f1a 100644 --- a/core/prompt.txt +++ b/core/prompt.txt @@ -1,51 +1,24 @@ 🧠 JARVIS CORE PROTOCOL -IDENTITY: You are Jarvis. Efficient, professional, direct assistant. No fluff. - -TOOL CALLING — ABSOLUTE RULE: -You have tools. For EVERY user request that involves an action, you MUST call the tool. -NEVER describe what you are doing in text. NEVER say "opening WhatsApp" or "-weather_report" — CALL THE TOOL. -NEVER simulate, pretend, or narrate an action. If a tool exists for it, USE IT. -Saying "Sure, let me open X" WITHOUT calling open_app is a critical failure. +IDENTITY: Efficient, professional, direct assistant. No fluff. EXECUTION RULES: -Action Flow: Call the tool immediately. For slow tools (search, vision), say ONE short sentence first. -Vision (screen_process): Say one curious sentence, then call the tool — total silence after. +Vision (screen_process): Say one curious sentence, then total silence (Vision module takes over). One-Call Policy: Never guess. Call tools exactly once. No retries. -Briefing: HARD LIMIT — 3 SHORT sentences maximum for ANY response. No exceptions. - • Confirmations / acks: 1 sentence only. - • Answers / results: 2-3 short sentences max. - • NEVER ask follow-up questions ("Would you like...?", "Is there anything else?"). - • NEVER pad with sympathy openers or filler phrases ("Of course!", "Great question!"). - • If you cannot fit the answer in 3 sentences, give the most important part only. +Briefing: 2-3 short sentence maximum. Report results briefly and always try to help the user. +Memory: Store critical user preferences/context automatically. Exit: Only call shutdown_jarvis if session termination is explicit. -Response time: Respond as fast as you can. -Always respond in the language that user is speaking. - -MEMORY RULE — MANDATORY, NO EXCEPTIONS: -Call save_memory IMMEDIATELY (in the same response, silently) whenever the user reveals ANY personal info. -Do NOT ask for permission. Do NOT announce that you are saving. Just call it. - -Trigger examples: -- "my name is Fatih" → save_memory(category="identity", key="name", value="Fatih") -- "I'm 22" / "I'm 22 years old" → save_memory(category="identity", key="age", value="22") -- "call me X" / "my name is X, not Y" → save_memory(category="identity", key="name", value="X") -- "I live in Istanbul" → save_memory(category="identity", key="city", value="Istanbul") -- "I'm a student / developer / designer" → save_memory(category="identity", key="job", value="...") -- "I prefer dark mode" / "I like X" → save_memory(category="preferences", key="...", value="...") -- "I'm working on X project" → save_memory(category="projects", key="...", value="...") -- Any name correction ("not Travis, Fatih") → save_memory identity/name immediately +Response time: Respond as fast as you can +Always call sir to user. +Act: Always act like Jarvis. If user says "Daddy's home" and other sentences like in the Iron Man movie, be like a real friendly assistant like Jarvis(like saying: "welcome home sir" and then continue to speak to help to user according to his text). +Always create 2-3 short sentence. Never less. TOOL ROUTING: computer_settings: ALL single OS actions (volume, brightness, wifi, close, shortcuts, power). -agent_task: ONLY for complex, multi-step planning (3+ steps) and call it if user it really specifies it. +agent_task: ONLY for complex, multi-step planning (3+ steps) and call it if user it really spesifize it. Language: Respond in user's language; extract parameters in English. -Do not call agent_task while you can accomplish it with a tool. +Do not call agent_task while you can accomplish it with a tool -OPEN vs MESSAGE rule (critical): -- "open WhatsApp", "launch WhatsApp", "pull up WhatsApp" → open_app (NOT send_message) -- "send a message to X saying Y" → send_message -- If user only says to open an app, NEVER call send_message, even if the app is a messaging platform. CRITICAL: Speak/Take action immediately based on available info. Assume and proceed. \ No newline at end of file diff --git a/dashboard/__init__.py b/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dashboard/server.py b/dashboard/server.py new file mode 100644 index 0000000..00ffc58 --- /dev/null +++ b/dashboard/server.py @@ -0,0 +1,794 @@ +""" +dashboard/server.py — JARVIS Local HTTP Dashboard + +Plain HTTP on port 8000 (no SSL warnings, no firewall issues). +Security at the application layer: AES-256-CBC with session-key-derived key. +CryptoJS is auto-downloaded once and served locally — no CDN needed after that. + +Install deps: pip install fastapi "uvicorn[standard]" cryptography +""" + +import asyncio +import base64 +import hashlib +import re +import secrets +import socket +import string +import time +from pathlib import Path + +_DEPS_OK = False +try: + from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request + from fastapi.responses import HTMLResponse, JSONResponse, FileResponse + import uvicorn + _DEPS_OK = True +except ImportError: + pass + +# python-multipart is required for file uploads — optional dependency +_UPLOAD_OK = False +try: + from fastapi import UploadFile, File as FastAPIFile + _UPLOAD_OK = True +except Exception: + pass + +BASE_DIR = Path(__file__).resolve().parent.parent +STATIC_DIR = Path(__file__).parent / "static" +PORT = 8000 +MAX_UPLOAD_MB = 500 + + +def _make_uploads_dir() -> Path: + """Return (and create) the cross-platform uploads folder.""" + for candidate in [ + Path.home() / "Downloads" / "JARVIS Uploads", + Path.home() / "Documents" / "JARVIS Uploads", + BASE_DIR / "uploads", + ]: + try: + candidate.mkdir(parents=True, exist_ok=True) + return candidate + except Exception: + pass + return BASE_DIR / "uploads" + + +UPLOADS_DIR = _make_uploads_dir() + +def _get_gemini_key() -> str | None: + try: + import json as _json + with open(BASE_DIR / "config" / "api_keys.json", "r", encoding="utf-8") as f: + return _json.load(f).get("gemini_api_key") + except Exception: + return None + +_KEY_CHARS = [c for c in (string.ascii_uppercase + string.digits) + if c not in ('O', 'I', 'L', '0', '1')] + +# ── AES-256-CBC ─────────────────────────────────────────────────────────────── +_AES_SALT = b'JARVIS-DASHBOARD-v1' + + +def _derive_key(session_key: str) -> bytes: + """SHA-256(sessionKey‖salt) → 32-byte AES-256 key (microseconds, no PBKDF2 needed).""" + return hashlib.sha256(session_key.encode('utf-8') + _AES_SALT).digest() + + +def _decrypt_cbc(aes_key: bytes, enc_b64: str) -> str: + """Decrypt base64(IV[16] ‖ ciphertext) with AES-256-CBC + PKCS7.""" + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + from cryptography.hazmat.primitives import padding as sym_pad + raw = base64.b64decode(enc_b64) + iv, ct = raw[:16], raw[16:] + dec = Cipher(algorithms.AES(aes_key), modes.CBC(iv)).decryptor() + padded = dec.update(ct) + dec.finalize() + unpadder = sym_pad.PKCS7(128).unpadder() + return (unpadder.update(padded) + unpadder.finalize()).decode('utf-8') + + +# ── CryptoJS (auto-download once, served locally) ───────────────────────────── +_CRYPTOJS_CDN = ("https://cdnjs.cloudflare.com/ajax/libs/" + "crypto-js/4.2.0/crypto-js.min.js") +_CRYPTOJS_FILE = STATIC_DIR / "crypto-js.min.js" + + +def _ensure_network_access(port: int) -> None: + """Cross-platform, best-effort: open port in the OS firewall for LAN access. + + Runs in a background thread — never blocks uvicorn startup. + + Windows : writes a .bat file, runs it elevated via Windows ShellExecuteW + (native UAC dialog, guaranteed to appear). One-time setup. + macOS : osascript admin dialog if the Application Firewall is on. + Linux : pkexec GUI → sudo -n → prints manual command as fallback. + """ + import sys, subprocess, os, tempfile, threading + + # ── Windows ────────────────────────────────────────────────────────────── + if sys.platform == "win32": + import ctypes, time + + port_rule = f"JARVIS Dashboard Port {port}" + prog_rule = "JARVIS Dashboard Python" + py_exe = sys.executable + + def _netsh_rule_exists(name: str) -> bool: + try: + r = subprocess.run( + ["netsh", "advfirewall", "firewall", "show", "rule", f"name={name}"], + capture_output=True, text=True, timeout=5, + ) + return r.returncode == 0 and "No rules match" not in r.stdout + except Exception: + return False + + def _network_is_public() -> bool: + try: + r = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", + "(Get-NetConnectionProfile | " + "Where-Object {$_.NetworkCategory -eq 'Public'} | " + "Measure-Object).Count"], + capture_output=True, text=True, timeout=6, + ) + return r.stdout.strip() not in ("", "0") + except Exception: + return False + + need_port = not _netsh_rule_exists(port_rule) + need_prog = not _netsh_rule_exists(prog_rule) + need_private = _network_is_public() + + if not need_port and not need_prog and not need_private: + return # already fully configured + + # Build a .bat file — netsh + powershell, runs fast when elevated + bat_lines = ["@echo off"] + if need_private: + bat_lines.append( + 'powershell -NoProfile -NonInteractive -Command "' + 'Get-NetConnectionProfile | ' + "Where-Object {$_.NetworkCategory -eq 'Public'} | " + 'Set-NetConnectionProfile -NetworkCategory Private"' + ) + if need_port: + bat_lines.append( + f'netsh advfirewall firewall add rule ' + f'name="{port_rule}" protocol=TCP dir=in ' + f'localport={port} action=allow' + ) + if need_prog: + bat_lines.append( + f'netsh advfirewall firewall add rule ' + f'name="{prog_rule}" dir=in action=allow ' + f'program="{py_exe}" enable=yes' + ) + + bat_body = "\r\n".join(bat_lines) + "\r\n" + fd, bat_path = tempfile.mkstemp(suffix=".bat", prefix="jarvis_fw_") + try: + os.write(fd, bat_body.encode("mbcs")) # Windows cmd.exe expects ANSI + os.close(fd) + except Exception: + try: + os.close(fd) + except Exception: + pass + return + + # ── Try running directly (succeeds when already admin) ──────────────── + try: + r = subprocess.run( + [bat_path], capture_output=True, timeout=8, shell=True + ) + if r.returncode == 0: + print(f"[Dashboard] Firewall configured for port {port}.") + try: + os.unlink(bat_path) + except Exception: + pass + return + except Exception: + pass + + # ── ShellExecuteW: native UAC elevation (most reliable on Windows) ──── + # ShellExecuteW with verb "runas" always shows the UAC dialog regardless + # of UAC level settings. Non-blocking — uvicorn is already running. + print("[Dashboard] One-time network setup required.") + print("[Dashboard] >>> A Windows security dialog will appear — click 'Yes' <<<") + try: + ret = ctypes.windll.shell32.ShellExecuteW( + None, # hwnd (no parent window) + "runas", # verb (request elevation) + bat_path, # file (our .bat) + None, # params + None, # working dir + 0, # SW_HIDE (run without a visible cmd window) + ) + if int(ret) > 32: + # ShellExecuteW returns immediately; bat finishes in ~1 second. + # Sleep briefly so the rules are in place before the first retry. + time.sleep(2) + print(f"[Dashboard] Network setup complete — port {port} is open.") + print("[Dashboard] Refresh your phone browser to connect.") + else: + print("[Dashboard] Setup was not allowed.") + print("[Dashboard] Phone connections may fail until JARVIS is run as Administrator.") + except Exception as e: + print(f"[Dashboard] Firewall setup error: {e}") + finally: + # Cleanup after the bat has had time to run + def _cleanup(path: str) -> None: + time.sleep(5) + try: + os.unlink(path) + except Exception: + pass + threading.Thread(target=_cleanup, args=(bat_path,), daemon=True).start() + return + + # ── macOS ───────────────────────────────────────────────────────────────── + if sys.platform == "darwin": + fw_ctl = "/usr/libexec/ApplicationFirewall/socketfilterfw" + try: + r = subprocess.run( + [fw_ctl, "--getglobalstate"], capture_output=True, text=True, timeout=5, + ) + if "disabled" in r.stdout.lower(): + return # firewall off — nothing to do + + py = sys.executable + listed = subprocess.run( + [fw_ctl, "--listapps"], capture_output=True, text=True, timeout=5, + ) + if py in listed.stdout: + return # already allowed + + print("[Dashboard] One-time network setup — enter your password in the macOS dialog.") + subprocess.run( + ["osascript", "-e", + f'do shell script "{fw_ctl} --add {py} && {fw_ctl} --unblockapp {py}"' + f' with administrator privileges'], + timeout=60, + ) + except Exception: + pass # macOS firewall is off by default — silent failure is fine + return + + # ── Linux ───────────────────────────────────────────────────────────────── + def _privileged(cmd: list[str]) -> bool: + for prefix in (["pkexec"], ["sudo", "-n"]): + try: + r = subprocess.run(prefix + cmd, capture_output=True, timeout=30) + if r.returncode == 0: + return True + except Exception: + pass + return False + + try: # ufw + r = subprocess.run(["ufw", "status"], capture_output=True, text=True, timeout=5) + if "active" in r.stdout.lower(): + if _privileged(["ufw", "allow", f"{port}/tcp"]): + print(f"[Dashboard] ufw: port {port} allowed.") + else: + print(f"[Dashboard] Run manually: sudo ufw allow {port}/tcp") + return + except FileNotFoundError: + pass + + try: # firewalld + r = subprocess.run( + ["firewall-cmd", "--state"], capture_output=True, text=True, timeout=5, + ) + if "running" in r.stdout.lower(): + ok = (_privileged(["firewall-cmd", "--add-port", f"{port}/tcp", "--permanent"]) + and _privileged(["firewall-cmd", "--reload"])) + if ok: + print(f"[Dashboard] firewalld: port {port} allowed.") + else: + print(f"[Dashboard] Run manually: sudo firewall-cmd --add-port={port}/tcp --permanent && sudo firewall-cmd --reload") + return + except FileNotFoundError: + pass + + try: # iptables (not persistent but works until reboot) + r = subprocess.run(["iptables", "-L", "INPUT", "-n"], capture_output=True, timeout=5) + if r.returncode == 0: + if _privileged(["iptables", "-A", "INPUT", "-p", "tcp", "--dport", str(port), "-j", "ACCEPT"]): + print(f"[Dashboard] iptables: port {port} opened.") + else: + print(f"[Dashboard] Run manually: sudo iptables -A INPUT -p tcp --dport {port} -j ACCEPT") + except FileNotFoundError: + pass # no iptables means firewall is probably off — nothing to do + + +def _ensure_crypto_js() -> None: + if _CRYPTOJS_FILE.exists(): + return + try: + import urllib.request + print("[Dashboard] Downloading CryptoJS (one-time setup)…") + urllib.request.urlretrieve(_CRYPTOJS_CDN, str(_CRYPTOJS_FILE)) + print("[Dashboard] CryptoJS cached — will serve locally from now on.") + except Exception as e: + print(f"[Dashboard] CryptoJS download failed: {e}") + print(f"[Dashboard] Encryption will fall back to CDN load on client.") + + +_ensure_crypto_js() + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _local_ip() -> str: + """Return the best LAN-facing IPv4 address, no internet required.""" + # Method 1: route trick (fast, works when internet is available) + for probe in ("8.8.8.8", "1.1.1.1", "192.168.1.1"): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(0.5) + s.connect((probe, 80)) + ip = s.getsockname()[0] + s.close() + if not ip.startswith("127."): + return ip + except Exception: + pass + + # Method 2: hostname resolution (works offline on most systems) + try: + ip = socket.gethostbyname(socket.gethostname()) + if not ip.startswith("127."): + return ip + except Exception: + pass + + # Method 3: enumerate all interfaces (fully offline, no external deps) + try: + for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET): + ip = info[4][0] + if not ip.startswith("127.") and not ip.startswith("169.254."): + return ip + except Exception: + pass + + return "127.0.0.1" + + +def _read(name: str) -> str: + return (STATIC_DIR / name).read_text(encoding="utf-8") + + +# ── DashboardServer ─────────────────────────────────────────────────────────── + +class DashboardServer: + + def __init__(self): + self._ip = _local_ip() + self._tokens: set[str] = set() + self._token_keys: dict[str, str] = {} # auth_token → session_key + self._aes_cache: dict[str, bytes]= {} # session_key → AES bytes + self._clients: set[WebSocket] = set() + self._history: list[dict] = [] + self._command_queue = asyncio.Queue() + self._wake_callback = None + self._connect_callback = None + self._pending_keys: dict[str, float] = {} + self._device_sessions: dict[str, dict] = {} # device_token → {session_key} + self._phone_audio_queue: asyncio.Queue = asyncio.Queue(maxsize=200) + self._uploads_dir = UPLOADS_DIR + self._login_html = _read("login.html") + self._app_html = _read("app.html") + self.app = self._build_app() + + # ── one-time key management ─────────────────────────────────────────── + + def new_key(self, expiry_secs: int = 600) -> str: + now = time.time() + self._pending_keys = {k: v for k, v in self._pending_keys.items() if v > now} + key = ''.join(secrets.choice(_KEY_CHARS) for _ in range(6)) + self._pending_keys[key] = now + expiry_secs + return key + + @staticmethod + def _ssl_enabled() -> bool: + certs = BASE_DIR / "config" / "certs" + return (certs / "jarvis.key").exists() and (certs / "jarvis.crt").exists() + + def get_url(self) -> str: + proto = "https" if self._ssl_enabled() else "http" + return f"{proto}://{self._ip}:{PORT}" + + def get_manual_url(self) -> str: + """URL for manual browser entry. When HTTPS active, points to alias port (also HTTPS).""" + if self._ssl_enabled(): + return f"{self._ip}:{PORT + 1}" + return f"{self._ip}:{PORT}" + + def _aes_key(self, session_key: str) -> bytes: + if session_key not in self._aes_cache: + self._aes_cache[session_key] = _derive_key(session_key) + return self._aes_cache[session_key] + + def _decrypt(self, token: str, enc_b64: str) -> str | None: + sk = self._token_keys.get(token) + if not sk: + return None + try: + return _decrypt_cbc(self._aes_key(sk), enc_b64) + except Exception: + return None + + # ── callbacks ──────────────────────────────────────────────────────── + + def set_wake_callback(self, fn) -> None: + self._wake_callback = fn + + def set_connect_callback(self, fn) -> None: + self._connect_callback = fn + + # ── broadcast ──────────────────────────────────────────────────────── + + async def broadcast(self, msg: dict) -> None: + self._history.append(msg) + if len(self._history) > 300: + self._history = self._history[-300:] + dead: set[WebSocket] = set() + for ws in list(self._clients): + try: + await ws.send_json(msg) + except Exception: + dead.add(ws) + self._clients -= dead + + # ── FastAPI app ─────────────────────────────────────────────────────── + + def _build_app(self) -> "FastAPI": + app = FastAPI(docs_url=None, redoc_url=None) + + def _auth(req: Request) -> bool: + tok = req.headers.get("authorization", "").removeprefix("Bearer ").strip() + return bool(tok) and tok in self._tokens + + # serve CryptoJS from local cache, fallback to CDN redirect + @app.get("/static/crypto.js") + async def serve_crypto(): + if _CRYPTOJS_FILE.exists(): + return FileResponse(str(_CRYPTOJS_FILE), + media_type="application/javascript") + from fastapi.responses import RedirectResponse + return RedirectResponse(_CRYPTOJS_CDN) + + @app.get("/login", response_class=HTMLResponse) + async def login_page(): + return HTMLResponse(self._login_html) + + @app.get("/", response_class=HTMLResponse) + async def index(): + # Auth is handled client-side via sessionStorage bearer token. + # Server-side header auth can't work here because browser navigations + # don't send custom headers (location.href doesn't carry Authorization). + html = (self._app_html + .replace("__IP__", self._ip) + .replace("__PORT__", str(PORT))) + return HTMLResponse(html) + + @app.post("/login") + async def login(req: Request): + body = await req.json() + entered = str(body.get("pin", "")).strip().upper() + now = time.time() + if entered in self._pending_keys and self._pending_keys[entered] > now: + del self._pending_keys[entered] # one-time use + tok = secrets.token_urlsafe(32) + self._tokens.add(tok) + self._token_keys[tok] = entered + self._aes_key(entered) # pre-derive & cache + if self._connect_callback: + self._connect_callback() + asyncio.create_task(self.broadcast( + {"type": "sys", "text": "Remote connection established."} + )) + # Bearer token in response body — no cookies needed (works on any browser/HTTP) + return JSONResponse({"ok": True, "token": tok}) + return JSONResponse({"ok": False, "error": "Invalid or expired key"}, + status_code=401) + + @app.get("/auto-login") + async def auto_login(key: str = ""): + """QR code target — validates one-time key, creates session, redirects phone.""" + now = time.time() + if not key or key not in self._pending_keys or self._pending_keys[key] <= now: + return HTMLResponse(""" + + +

Link Expired

+

Press Remote Control in JARVIS to get a new QR code.

+
""") + + del self._pending_keys[key] + tok = secrets.token_urlsafe(32) + dev_tok = secrets.token_urlsafe(32) + self._tokens.add(tok) + self._token_keys[tok] = key + self._aes_key(key) + self._device_sessions[dev_tok] = {"session_key": key} + + if self._connect_callback: + self._connect_callback() + asyncio.create_task(self.broadcast( + {"type": "sys", "text": "Remote connection established via QR code."} + )) + + return HTMLResponse(f""" + + + + +

Connecting to JARVIS…

+""") + + @app.post("/api/device-login") + async def device_login_ep(req: Request): + """Return a fresh auth token for a previously paired device token.""" + try: + body = await req.json() + except Exception: + return JSONResponse({"ok": False}, status_code=400) + dev_tok = (body.get("device_token") or "").strip() + if not dev_tok or dev_tok not in self._device_sessions: + return JSONResponse({"ok": False}, status_code=401) + session_key = self._device_sessions[dev_tok]["session_key"] + tok = secrets.token_urlsafe(32) + self._tokens.add(tok) + self._token_keys[tok] = session_key + self._aes_key(session_key) + if self._connect_callback: + self._connect_callback() + asyncio.create_task(self.broadcast( + {"type": "sys", "text": "Known device reconnected automatically."} + )) + return JSONResponse({"ok": True, "token": tok, "key": session_key}) + + @app.post("/api/revoke-devices") + async def revoke_devices(req: Request): + """Invalidate all persistent device tokens (admin action).""" + if not _auth(req): + return JSONResponse({"error": "Unauthorized"}, status_code=401) + count = len(self._device_sessions) + self._device_sessions.clear() + return JSONResponse({"ok": True, "revoked": count}) + + @app.post("/api/command") + async def command(req: Request): + if not _auth(req): + return JSONResponse({"error": "Unauthorized"}, status_code=401) + body = await req.json() + token = req.headers.get("authorization", "").removeprefix("Bearer ").strip() + enc = body.get("enc", "") + if enc: + text = self._decrypt(token, enc) + if text is None: + return JSONResponse({"error": "Decryption failed"}, status_code=400) + else: + text = (body.get("text") or "").strip() + if text: + await self._command_queue.put(text) + if self._wake_callback: + self._wake_callback() + return JSONResponse({"ok": True}) + + @app.post("/api/wake") + async def wake_ep(req: Request): + if not _auth(req): + return JSONResponse({"error": "Unauthorized"}, status_code=401) + if self._wake_callback: + self._wake_callback() + return JSONResponse({"ok": True}) + + # ── Phone mic real-time audio → Gemini Live ────────────────────────── + + @app.websocket("/ws/phone-audio") + async def phone_audio_ws(websocket: WebSocket, token: str = ""): + tok = token.strip() + if not tok or tok not in self._tokens: + await websocket.close(code=4001) + return + await websocket.accept() + asyncio.create_task(self.broadcast( + {"type": "sys", "text": "Phone microphone live."} + )) + try: + while True: + data = await websocket.receive_bytes() + try: + self._phone_audio_queue.put_nowait( + {"data": data, "mime_type": "audio/pcm"} + ) + except asyncio.QueueFull: + pass # drop frame rather than block + except WebSocketDisconnect: + pass + finally: + asyncio.create_task(self.broadcast( + {"type": "sys", "text": "Phone microphone stopped."} + )) + + # ── File sharing ────────────────────────────────────────────────────── + + def _safe_filename(raw: str) -> str: + name = Path(raw).name # strip path components + name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', name).strip(". ") + return name or "upload" + + if _UPLOAD_OK: + @app.post("/api/upload") + async def upload_file(req: Request, file: UploadFile = FastAPIFile(...)): + if not _auth(req): + return JSONResponse({"error": "Unauthorized"}, status_code=401) + + safe = _safe_filename(file.filename or "upload") + dest = self._uploads_dir / safe + stem, suffix = Path(safe).stem, Path(safe).suffix + counter = 1 + while dest.exists(): + dest = self._uploads_dir / f"{stem}_{counter}{suffix}" + counter += 1 + + size = 0 + max_bytes = MAX_UPLOAD_MB * 1024 * 1024 + try: + with open(dest, "wb") as fout: + while True: + chunk = await file.read(65536) + if not chunk: + break + size += len(chunk) + if size > max_bytes: + fout.close() + dest.unlink(missing_ok=True) + return JSONResponse( + {"error": f"File too large (max {MAX_UPLOAD_MB} MB)"}, + status_code=413, + ) + fout.write(chunk) + except Exception as exc: + try: + dest.unlink(missing_ok=True) + except Exception: + pass + return JSONResponse({"error": str(exc)}, status_code=500) + + asyncio.create_task(self.broadcast({ + "type": "file_received", + "name": dest.name, + "size": size, + "saved_to": str(self._uploads_dir), + })) + return JSONResponse({"ok": True, "name": dest.name, "size": size}) + else: + @app.post("/api/upload") + async def upload_unavailable(req: Request): + return JSONResponse( + {"error": "File uploads require: pip install python-multipart"}, + status_code=503, + ) + + @app.get("/api/files") + async def list_files(req: Request): + if not _auth(req): + return JSONResponse({"error": "Unauthorized"}, status_code=401) + files = [] + try: + for f in sorted( + (p for p in self._uploads_dir.iterdir() if p.is_file()), + key=lambda p: p.stat().st_mtime, + reverse=True, + ): + files.append({"name": f.name, "size": f.stat().st_size}) + except Exception: + pass + return JSONResponse({"files": files}) + + @app.get("/uploads/{filename}") + async def download_file(filename: str, token: str = ""): + # Auth via query param — browser can't send custom headers + tok = token.strip() + if not tok or tok not in self._tokens: + return JSONResponse({"error": "Unauthorized"}, status_code=401) + safe = re.sub(r'[/\\]', '', filename) + path = self._uploads_dir / safe + if not path.exists() or not path.is_file(): + return JSONResponse({"error": "Not found"}, status_code=404) + return FileResponse(str(path), filename=safe) + + @app.websocket("/ws") + async def ws_ep(websocket: WebSocket, token: str = ""): + tok = token.strip() + if not tok or tok not in self._tokens: + await websocket.close(code=4001) + return + await websocket.accept() + self._clients.add(websocket) + for entry in self._history[-50:]: + try: + await websocket.send_json(entry) + except Exception: + break + try: + while True: + data = await websocket.receive_json() + if data.get("type") == "command": + enc = data.get("enc", "") + t = self._decrypt(tok, enc) if enc else (data.get("text") or "").strip() + if t: + await self._command_queue.put(t) + if self._wake_callback: + self._wake_callback() + except WebSocketDisconnect: + pass + finally: + self._clients.discard(websocket) + + return app + + # ── serve ───────────────────────────────────────────────────────────── + + async def _serve_alias(self) -> None: + """Second HTTPS server on PORT+1 sharing the same app and in-memory state. + Chrome HTTPS-upgrades any bare IP:PORT the user types, so this port also needs TLS. + User types IP:8001 → Chrome tries https → self-signed cert warning → accept once → done.""" + ssl_key = BASE_DIR / "config" / "certs" / "jarvis.key" + ssl_cert = BASE_DIR / "config" / "certs" / "jarvis.crt" + asyncio.get_event_loop().run_in_executor(None, _ensure_network_access, PORT + 1) + cfg = uvicorn.Config( + self.app, host="0.0.0.0", port=PORT + 1, log_level="warning", + ssl_keyfile=str(ssl_key), ssl_certfile=str(ssl_cert), + ) + print(f"[Dashboard] Manual entry: {self._ip}:{PORT + 1} (type in browser, accept cert once)") + await uvicorn.Server(cfg).serve() + + async def serve(self) -> None: + if not _DEPS_OK: + print("[Dashboard] fastapi/uvicorn not installed — dashboard disabled.") + print("[Dashboard] Run: pip install fastapi 'uvicorn[standard]' cryptography") + return + + # Firewall setup runs in a thread — uvicorn starts immediately, + # no waiting for UAC dialogs or subprocess timeouts. + asyncio.get_event_loop().run_in_executor(None, _ensure_network_access, PORT) + + use_ssl = self._ssl_enabled() + ssl_key = BASE_DIR / "config" / "certs" / "jarvis.key" + ssl_cert = BASE_DIR / "config" / "certs" / "jarvis.crt" + + if use_ssl: + asyncio.create_task(self._serve_alias()) + + cfg = uvicorn.Config( + self.app, host="0.0.0.0", port=PORT, log_level="warning", + **({"ssl_keyfile": str(ssl_key), "ssl_certfile": str(ssl_cert)} if use_ssl else {}), + ) + + proto = "https" if use_ssl else "http" + print(f"[Dashboard] {proto}://{self._ip}:{PORT}") + print("[Dashboard] Press 'Remote Control' in JARVIS UI to get the QR code.") + await uvicorn.Server(cfg).serve() diff --git a/dashboard/static/app.html b/dashboard/static/app.html new file mode 100644 index 0000000..d72415e --- /dev/null +++ b/dashboard/static/app.html @@ -0,0 +1,559 @@ + + + + + + JARVIS Dashboard + + + + +
+
+ +
Connecting
+
AES-256
+
+
__IP__:__PORT__
+
+
+ + + + + diff --git a/dashboard/static/crypto-js.min.js b/dashboard/static/crypto-js.min.js new file mode 100644 index 0000000..fb96be7 --- /dev/null +++ b/dashboard/static/crypto-js.min.js @@ -0,0 +1 @@ +!function(t,e){"object"==typeof exports?module.exports=exports=e():"function"==typeof define&&define.amd?define([],e):t.CryptoJS=e()}(this,function(){var W,O,I,U,K,X,L,l,j,T,t,N,q,e,Z,V,G,J,Q,Y,$,t1,e1,r1,i1,o1,n1,s,s1,c1,a1,h1,l1,o,f1,r,d1,u1,n,c,a,h,f,d,i=function(h){var i;if("undefined"!=typeof window&&window.crypto&&(i=window.crypto),"undefined"!=typeof self&&self.crypto&&(i=self.crypto),!(i=!(i=!(i="undefined"!=typeof globalThis&&globalThis.crypto?globalThis.crypto:i)&&"undefined"!=typeof window&&window.msCrypto?window.msCrypto:i)&&"undefined"!=typeof global&&global.crypto?global.crypto:i)&&"function"==typeof require)try{i=require("crypto")}catch(t){}var r=Object.create||function(t){return e.prototype=t,t=new e,e.prototype=null,t};function e(){}var t={},o=t.lib={},n=o.Base={extend:function(t){var e=r(this);return t&&e.mixIn(t),e.hasOwnProperty("init")&&this.init!==e.init||(e.init=function(){e.$super.init.apply(this,arguments)}),(e.init.prototype=e).$super=this,e},create:function(){var t=this.extend();return t.init.apply(t,arguments),t},init:function(){},mixIn:function(t){for(var e in t)t.hasOwnProperty(e)&&(this[e]=t[e]);t.hasOwnProperty("toString")&&(this.toString=t.toString)},clone:function(){return this.init.prototype.extend(this)}},l=o.WordArray=n.extend({init:function(t,e){t=this.words=t||[],this.sigBytes=null!=e?e:4*t.length},toString:function(t){return(t||c).stringify(this)},concat:function(t){var e=this.words,r=t.words,i=this.sigBytes,o=t.sigBytes;if(this.clamp(),i%4)for(var n=0;n>>2]>>>24-n%4*8&255;e[i+n>>>2]|=s<<24-(i+n)%4*8}else for(var c=0;c>>2]=r[c>>>2];return this.sigBytes+=o,this},clamp:function(){var t=this.words,e=this.sigBytes;t[e>>>2]&=4294967295<<32-e%4*8,t.length=h.ceil(e/4)},clone:function(){var t=n.clone.call(this);return t.words=this.words.slice(0),t},random:function(t){for(var e=[],r=0;r>>2]>>>24-o%4*8&255;i.push((n>>>4).toString(16)),i.push((15&n).toString(16))}return i.join("")},parse:function(t){for(var e=t.length,r=[],i=0;i>>3]|=parseInt(t.substr(i,2),16)<<24-i%8*4;return new l.init(r,e/2)}},a=s.Latin1={stringify:function(t){for(var e=t.words,r=t.sigBytes,i=[],o=0;o>>2]>>>24-o%4*8&255;i.push(String.fromCharCode(n))}return i.join("")},parse:function(t){for(var e=t.length,r=[],i=0;i>>2]|=(255&t.charCodeAt(i))<<24-i%4*8;return new l.init(r,e)}},f=s.Utf8={stringify:function(t){try{return decodeURIComponent(escape(a.stringify(t)))}catch(t){throw new Error("Malformed UTF-8 data")}},parse:function(t){return a.parse(unescape(encodeURIComponent(t)))}},d=o.BufferedBlockAlgorithm=n.extend({reset:function(){this._data=new l.init,this._nDataBytes=0},_append:function(t){"string"==typeof t&&(t=f.parse(t)),this._data.concat(t),this._nDataBytes+=t.sigBytes},_process:function(t){var e,r=this._data,i=r.words,o=r.sigBytes,n=this.blockSize,s=o/(4*n),c=(s=t?h.ceil(s):h.max((0|s)-this._minBufferSize,0))*n,t=h.min(4*c,o);if(c){for(var a=0;a>>2]|=t[i]<<24-i%4*8;I.call(this,r,e)}else I.apply(this,arguments)}).prototype=p),i),p1=u.lib.WordArray;function _1(t){return t<<8&4278255360|t>>>8&16711935}(u=u.enc).Utf16=u.Utf16BE={stringify:function(t){for(var e=t.words,r=t.sigBytes,i=[],o=0;o>>2]>>>16-o%4*8&65535;i.push(String.fromCharCode(n))}return i.join("")},parse:function(t){for(var e=t.length,r=[],i=0;i>>1]|=t.charCodeAt(i)<<16-i%2*16;return p1.create(r,2*e)}},u.Utf16LE={stringify:function(t){for(var e=t.words,r=t.sigBytes,i=[],o=0;o>>2]>>>16-o%4*8&65535);i.push(String.fromCharCode(n))}return i.join("")},parse:function(t){for(var e=t.length,r=[],i=0;i>>1]|=_1(t.charCodeAt(i)<<16-i%2*16);return p1.create(r,2*e)}},U=(p=i).lib.WordArray,p.enc.Base64={stringify:function(t){for(var e=t.words,r=t.sigBytes,i=this._map,o=(t.clamp(),[]),n=0;n>>2]>>>24-n%4*8&255)<<16|(e[n+1>>>2]>>>24-(n+1)%4*8&255)<<8|e[n+2>>>2]>>>24-(n+2)%4*8&255,c=0;c<4&&n+.75*c>>6*(3-c)&63));var a=i.charAt(64);if(a)for(;o.length%4;)o.push(a);return o.join("")},parse:function(t){var e=t.length,r=this._map;if(!(i=this._reverseMap))for(var i=this._reverseMap=[],o=0;o>>6-u%4*2,s=s|n,f[d>>>2]|=s<<24-d%4*8,d++);return U.create(f,d)},_map:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="},K=(u=i).lib.WordArray,u.enc.Base64url={stringify:function(t,e){for(var r=t.words,i=t.sigBytes,o=(e=void 0===e?!0:e)?this._safe_map:this._map,n=(t.clamp(),[]),s=0;s>>2]>>>24-s%4*8&255)<<16|(r[s+1>>>2]>>>24-(s+1)%4*8&255)<<8|r[s+2>>>2]>>>24-(s+2)%4*8&255,a=0;a<4&&s+.75*a>>6*(3-a)&63));var h=o.charAt(64);if(h)for(;n.length%4;)n.push(h);return n.join("")},parse:function(t,e){var r=t.length,i=(e=void 0===e?!0:e)?this._safe_map:this._map;if(!(o=this._reverseMap))for(var o=this._reverseMap=[],n=0;n>>6-u%4*2,c=c|s,f[d>>>2]|=c<<24-d%4*8,d++);return K.create(f,d)},_map:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",_safe_map:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"};for(var y1=Math,p=i,g1=(u=p.lib).WordArray,v1=u.Hasher,u=p.algo,A=[],B1=0;B1<64;B1++)A[B1]=4294967296*y1.abs(y1.sin(B1+1))|0;function z(t,e,r,i,o,n,s){t=t+(e&r|~e&i)+o+s;return(t<>>32-n)+e}function H(t,e,r,i,o,n,s){t=t+(e&i|r&~i)+o+s;return(t<>>32-n)+e}function C(t,e,r,i,o,n,s){t=t+(e^r^i)+o+s;return(t<>>32-n)+e}function R(t,e,r,i,o,n,s){t=t+(r^(e|~i))+o+s;return(t<>>32-n)+e}u=u.MD5=v1.extend({_doReset:function(){this._hash=new g1.init([1732584193,4023233417,2562383102,271733878])},_doProcessBlock:function(t,e){for(var r=0;r<16;r++){var i=e+r,o=t[i];t[i]=16711935&(o<<8|o>>>24)|4278255360&(o<<24|o>>>8)}var n=this._hash.words,s=t[e+0],c=t[e+1],a=t[e+2],h=t[e+3],l=t[e+4],f=t[e+5],d=t[e+6],u=t[e+7],p=t[e+8],_=t[e+9],y=t[e+10],g=t[e+11],v=t[e+12],B=t[e+13],w=t[e+14],k=t[e+15],x=z(n[0],S=n[1],m=n[2],b=n[3],s,7,A[0]),b=z(b,x,S,m,c,12,A[1]),m=z(m,b,x,S,a,17,A[2]),S=z(S,m,b,x,h,22,A[3]);x=z(x,S,m,b,l,7,A[4]),b=z(b,x,S,m,f,12,A[5]),m=z(m,b,x,S,d,17,A[6]),S=z(S,m,b,x,u,22,A[7]),x=z(x,S,m,b,p,7,A[8]),b=z(b,x,S,m,_,12,A[9]),m=z(m,b,x,S,y,17,A[10]),S=z(S,m,b,x,g,22,A[11]),x=z(x,S,m,b,v,7,A[12]),b=z(b,x,S,m,B,12,A[13]),m=z(m,b,x,S,w,17,A[14]),x=H(x,S=z(S,m,b,x,k,22,A[15]),m,b,c,5,A[16]),b=H(b,x,S,m,d,9,A[17]),m=H(m,b,x,S,g,14,A[18]),S=H(S,m,b,x,s,20,A[19]),x=H(x,S,m,b,f,5,A[20]),b=H(b,x,S,m,y,9,A[21]),m=H(m,b,x,S,k,14,A[22]),S=H(S,m,b,x,l,20,A[23]),x=H(x,S,m,b,_,5,A[24]),b=H(b,x,S,m,w,9,A[25]),m=H(m,b,x,S,h,14,A[26]),S=H(S,m,b,x,p,20,A[27]),x=H(x,S,m,b,B,5,A[28]),b=H(b,x,S,m,a,9,A[29]),m=H(m,b,x,S,u,14,A[30]),x=C(x,S=H(S,m,b,x,v,20,A[31]),m,b,f,4,A[32]),b=C(b,x,S,m,p,11,A[33]),m=C(m,b,x,S,g,16,A[34]),S=C(S,m,b,x,w,23,A[35]),x=C(x,S,m,b,c,4,A[36]),b=C(b,x,S,m,l,11,A[37]),m=C(m,b,x,S,u,16,A[38]),S=C(S,m,b,x,y,23,A[39]),x=C(x,S,m,b,B,4,A[40]),b=C(b,x,S,m,s,11,A[41]),m=C(m,b,x,S,h,16,A[42]),S=C(S,m,b,x,d,23,A[43]),x=C(x,S,m,b,_,4,A[44]),b=C(b,x,S,m,v,11,A[45]),m=C(m,b,x,S,k,16,A[46]),x=R(x,S=C(S,m,b,x,a,23,A[47]),m,b,s,6,A[48]),b=R(b,x,S,m,u,10,A[49]),m=R(m,b,x,S,w,15,A[50]),S=R(S,m,b,x,f,21,A[51]),x=R(x,S,m,b,v,6,A[52]),b=R(b,x,S,m,h,10,A[53]),m=R(m,b,x,S,y,15,A[54]),S=R(S,m,b,x,c,21,A[55]),x=R(x,S,m,b,p,6,A[56]),b=R(b,x,S,m,k,10,A[57]),m=R(m,b,x,S,d,15,A[58]),S=R(S,m,b,x,B,21,A[59]),x=R(x,S,m,b,l,6,A[60]),b=R(b,x,S,m,g,10,A[61]),m=R(m,b,x,S,a,15,A[62]),S=R(S,m,b,x,_,21,A[63]),n[0]=n[0]+x|0,n[1]=n[1]+S|0,n[2]=n[2]+m|0,n[3]=n[3]+b|0},_doFinalize:function(){for(var t=this._data,e=t.words,r=8*this._nDataBytes,i=8*t.sigBytes,o=(e[i>>>5]|=128<<24-i%32,y1.floor(r/4294967296)),o=(e[15+(64+i>>>9<<4)]=16711935&(o<<8|o>>>24)|4278255360&(o<<24|o>>>8),e[14+(64+i>>>9<<4)]=16711935&(r<<8|r>>>24)|4278255360&(r<<24|r>>>8),t.sigBytes=4*(e.length+1),this._process(),this._hash),n=o.words,s=0;s<4;s++){var c=n[s];n[s]=16711935&(c<<8|c>>>24)|4278255360&(c<<24|c>>>8)}return o},clone:function(){var t=v1.clone.call(this);return t._hash=this._hash.clone(),t}}),p.MD5=v1._createHelper(u),p.HmacMD5=v1._createHmacHelper(u),u=(p=i).lib,X=u.WordArray,L=u.Hasher,u=p.algo,l=[],u=u.SHA1=L.extend({_doReset:function(){this._hash=new X.init([1732584193,4023233417,2562383102,271733878,3285377520])},_doProcessBlock:function(t,e){for(var r=this._hash.words,i=r[0],o=r[1],n=r[2],s=r[3],c=r[4],a=0;a<80;a++){a<16?l[a]=0|t[e+a]:(h=l[a-3]^l[a-8]^l[a-14]^l[a-16],l[a]=h<<1|h>>>31);var h=(i<<5|i>>>27)+c+l[a];h+=a<20?1518500249+(o&n|~o&s):a<40?1859775393+(o^n^s):a<60?(o&n|o&s|n&s)-1894007588:(o^n^s)-899497514,c=s,s=n,n=o<<30|o>>>2,o=i,i=h}r[0]=r[0]+i|0,r[1]=r[1]+o|0,r[2]=r[2]+n|0,r[3]=r[3]+s|0,r[4]=r[4]+c|0},_doFinalize:function(){var t=this._data,e=t.words,r=8*this._nDataBytes,i=8*t.sigBytes;return e[i>>>5]|=128<<24-i%32,e[14+(64+i>>>9<<4)]=Math.floor(r/4294967296),e[15+(64+i>>>9<<4)]=r,t.sigBytes=4*e.length,this._process(),this._hash},clone:function(){var t=L.clone.call(this);return t._hash=this._hash.clone(),t}}),p.SHA1=L._createHelper(u),p.HmacSHA1=L._createHmacHelper(u);var w1=Math,p=i,k1=(u=p.lib).WordArray,x1=u.Hasher,u=p.algo,b1=[],m1=[];function S1(t){return 4294967296*(t-(0|t))|0}for(var A1=2,z1=0;z1<64;)!function(t){for(var e=w1.sqrt(t),r=2;r<=e;r++)if(!(t%r))return;return 1}(A1)||(z1<8&&(b1[z1]=S1(w1.pow(A1,.5))),m1[z1]=S1(w1.pow(A1,1/3)),z1++),A1++;var _=[],u=u.SHA256=x1.extend({_doReset:function(){this._hash=new k1.init(b1.slice(0))},_doProcessBlock:function(t,e){for(var r=this._hash.words,i=r[0],o=r[1],n=r[2],s=r[3],c=r[4],a=r[5],h=r[6],l=r[7],f=0;f<64;f++){f<16?_[f]=0|t[e+f]:(d=_[f-15],u=_[f-2],_[f]=((d<<25|d>>>7)^(d<<14|d>>>18)^d>>>3)+_[f-7]+((u<<15|u>>>17)^(u<<13|u>>>19)^u>>>10)+_[f-16]);var d=i&o^i&n^o&n,u=l+((c<<26|c>>>6)^(c<<21|c>>>11)^(c<<7|c>>>25))+(c&a^~c&h)+m1[f]+_[f],l=h,h=a,a=c,c=s+u|0,s=n,n=o,o=i,i=u+(((i<<30|i>>>2)^(i<<19|i>>>13)^(i<<10|i>>>22))+d)|0}r[0]=r[0]+i|0,r[1]=r[1]+o|0,r[2]=r[2]+n|0,r[3]=r[3]+s|0,r[4]=r[4]+c|0,r[5]=r[5]+a|0,r[6]=r[6]+h|0,r[7]=r[7]+l|0},_doFinalize:function(){var t=this._data,e=t.words,r=8*this._nDataBytes,i=8*t.sigBytes;return e[i>>>5]|=128<<24-i%32,e[14+(64+i>>>9<<4)]=w1.floor(r/4294967296),e[15+(64+i>>>9<<4)]=r,t.sigBytes=4*e.length,this._process(),this._hash},clone:function(){var t=x1.clone.call(this);return t._hash=this._hash.clone(),t}}),p=(p.SHA256=x1._createHelper(u),p.HmacSHA256=x1._createHmacHelper(u),j=(p=i).lib.WordArray,u=p.algo,T=u.SHA256,u=u.SHA224=T.extend({_doReset:function(){this._hash=new j.init([3238371032,914150663,812702999,4144912697,4290775857,1750603025,1694076839,3204075428])},_doFinalize:function(){var t=T._doFinalize.call(this);return t.sigBytes-=4,t}}),p.SHA224=T._createHelper(u),p.HmacSHA224=T._createHmacHelper(u),i),H1=p.lib.Hasher,y=(u=p.x64).Word,C1=u.WordArray,u=p.algo;function g(){return y.create.apply(y,arguments)}for(var R1=[g(1116352408,3609767458),g(1899447441,602891725),g(3049323471,3964484399),g(3921009573,2173295548),g(961987163,4081628472),g(1508970993,3053834265),g(2453635748,2937671579),g(2870763221,3664609560),g(3624381080,2734883394),g(310598401,1164996542),g(607225278,1323610764),g(1426881987,3590304994),g(1925078388,4068182383),g(2162078206,991336113),g(2614888103,633803317),g(3248222580,3479774868),g(3835390401,2666613458),g(4022224774,944711139),g(264347078,2341262773),g(604807628,2007800933),g(770255983,1495990901),g(1249150122,1856431235),g(1555081692,3175218132),g(1996064986,2198950837),g(2554220882,3999719339),g(2821834349,766784016),g(2952996808,2566594879),g(3210313671,3203337956),g(3336571891,1034457026),g(3584528711,2466948901),g(113926993,3758326383),g(338241895,168717936),g(666307205,1188179964),g(773529912,1546045734),g(1294757372,1522805485),g(1396182291,2643833823),g(1695183700,2343527390),g(1986661051,1014477480),g(2177026350,1206759142),g(2456956037,344077627),g(2730485921,1290863460),g(2820302411,3158454273),g(3259730800,3505952657),g(3345764771,106217008),g(3516065817,3606008344),g(3600352804,1432725776),g(4094571909,1467031594),g(275423344,851169720),g(430227734,3100823752),g(506948616,1363258195),g(659060556,3750685593),g(883997877,3785050280),g(958139571,3318307427),g(1322822218,3812723403),g(1537002063,2003034995),g(1747873779,3602036899),g(1955562222,1575990012),g(2024104815,1125592928),g(2227730452,2716904306),g(2361852424,442776044),g(2428436474,593698344),g(2756734187,3733110249),g(3204031479,2999351573),g(3329325298,3815920427),g(3391569614,3928383900),g(3515267271,566280711),g(3940187606,3454069534),g(4118630271,4000239992),g(116418474,1914138554),g(174292421,2731055270),g(289380356,3203993006),g(460393269,320620315),g(685471733,587496836),g(852142971,1086792851),g(1017036298,365543100),g(1126000580,2618297676),g(1288033470,3409855158),g(1501505948,4234509866),g(1607167915,987167468),g(1816402316,1246189591)],D1=[],E1=0;E1<80;E1++)D1[E1]=g();u=u.SHA512=H1.extend({_doReset:function(){this._hash=new C1.init([new y.init(1779033703,4089235720),new y.init(3144134277,2227873595),new y.init(1013904242,4271175723),new y.init(2773480762,1595750129),new y.init(1359893119,2917565137),new y.init(2600822924,725511199),new y.init(528734635,4215389547),new y.init(1541459225,327033209)])},_doProcessBlock:function(W,O){for(var t=this._hash.words,e=t[0],r=t[1],i=t[2],o=t[3],n=t[4],s=t[5],c=t[6],t=t[7],I=e.high,a=e.low,U=r.high,h=r.low,K=i.high,l=i.low,X=o.high,f=o.low,L=n.high,d=n.low,j=s.high,u=s.low,T=c.high,p=c.low,N=t.high,_=t.low,y=I,g=a,v=U,B=h,w=K,k=l,q=X,x=f,b=L,m=d,Z=j,S=u,V=T,G=p,J=N,Q=_,A=0;A<80;A++)var z,H,C=D1[A],R=(A<16?(H=C.high=0|W[O+2*A],z=C.low=0|W[O+2*A+1]):(F=(P=D1[A-15]).high,P=P.low,M=(E=D1[A-2]).high,E=E.low,D=(R=D1[A-7]).high,R=R.low,$=(Y=D1[A-16]).high,H=(H=((F>>>1|P<<31)^(F>>>8|P<<24)^F>>>7)+D+((z=(D=(P>>>1|F<<31)^(P>>>8|F<<24)^(P>>>7|F<<25))+R)>>>0>>0?1:0))+((M>>>19|E<<13)^(M<<3|E>>>29)^M>>>6)+((z+=P=(E>>>19|M<<13)^(E<<3|M>>>29)^(E>>>6|M<<26))>>>0

>>0?1:0),z+=F=Y.low,C.high=H=H+$+(z>>>0>>0?1:0),C.low=z),b&Z^~b&V),D=m&S^~m&G,E=y&v^y&w^v&w,M=(g>>>28|y<<4)^(g<<30|y>>>2)^(g<<25|y>>>7),P=R1[A],Y=P.high,$=P.low,F=Q+((m>>>14|b<<18)^(m>>>18|b<<14)^(m<<23|b>>>9)),C=J+((b>>>14|m<<18)^(b>>>18|m<<14)^(b<<23|m>>>9))+(F>>>0>>0?1:0),t1=M+(g&B^g&k^B&k),J=V,Q=G,V=Z,G=S,Z=b,S=m,b=q+(C=C+R+((F=F+D)>>>0>>0?1:0)+Y+((F=F+$)>>>0<$>>>0?1:0)+H+((F=F+z)>>>0>>0?1:0))+((m=x+F|0)>>>0>>0?1:0)|0,q=w,x=k,w=v,k=B,v=y,B=g,y=C+(((y>>>28|g<<4)^(y<<30|g>>>2)^(y<<25|g>>>7))+E+(t1>>>0>>0?1:0))+((g=F+t1|0)>>>0>>0?1:0)|0;a=e.low=a+g,e.high=I+y+(a>>>0>>0?1:0),h=r.low=h+B,r.high=U+v+(h>>>0>>0?1:0),l=i.low=l+k,i.high=K+w+(l>>>0>>0?1:0),f=o.low=f+x,o.high=X+q+(f>>>0>>0?1:0),d=n.low=d+m,n.high=L+b+(d>>>0>>0?1:0),u=s.low=u+S,s.high=j+Z+(u>>>0>>0?1:0),p=c.low=p+G,c.high=T+V+(p>>>0>>0?1:0),_=t.low=_+Q,t.high=N+J+(_>>>0>>0?1:0)},_doFinalize:function(){var t=this._data,e=t.words,r=8*this._nDataBytes,i=8*t.sigBytes;return e[i>>>5]|=128<<24-i%32,e[30+(128+i>>>10<<5)]=Math.floor(r/4294967296),e[31+(128+i>>>10<<5)]=r,t.sigBytes=4*e.length,this._process(),this._hash.toX32()},clone:function(){var t=H1.clone.call(this);return t._hash=this._hash.clone(),t},blockSize:32}),p.SHA512=H1._createHelper(u),p.HmacSHA512=H1._createHmacHelper(u),u=(p=i).x64,t=u.Word,N=u.WordArray,u=p.algo,q=u.SHA512,u=u.SHA384=q.extend({_doReset:function(){this._hash=new N.init([new t.init(3418070365,3238371032),new t.init(1654270250,914150663),new t.init(2438529370,812702999),new t.init(355462360,4144912697),new t.init(1731405415,4290775857),new t.init(2394180231,1750603025),new t.init(3675008525,1694076839),new t.init(1203062813,3204075428)])},_doFinalize:function(){var t=q._doFinalize.call(this);return t.sigBytes-=16,t}}),p.SHA384=q._createHelper(u),p.HmacSHA384=q._createHmacHelper(u);for(var M1=Math,p=i,P1=(u=p.lib).WordArray,F1=u.Hasher,W1=p.x64.Word,u=p.algo,O1=[],I1=[],U1=[],v=1,B=0,K1=0;K1<24;K1++){O1[v+5*B]=(K1+1)*(K1+2)/2%64;var X1=(2*v+3*B)%5;v=B%5,B=X1}for(v=0;v<5;v++)for(B=0;B<5;B++)I1[v+5*B]=B+(2*v+3*B)%5*5;for(var L1=1,j1=0;j1<24;j1++){for(var T1,N1=0,q1=0,Z1=0;Z1<7;Z1++)1&L1&&((T1=(1<>>32-e}function Y1(t){return"string"==typeof t?f1:o}function $1(t,e,r){var i,o=this._iv;o?(i=o,this._iv=void 0):i=this._prevBlock;for(var n=0;n>24&255)?(r=t>>8&255,i=255&t,255===(e=t>>16&255)?(e=0,255===r?(r=0,255===i?i=0:++i):++r):++e,t=0,t=(t+=e<<16)+(r<<8)+i):t+=1<<24,t}u=u.SHA3=F1.extend({cfg:F1.cfg.extend({outputLength:512}),_doReset:function(){for(var t=this._state=[],e=0;e<25;e++)t[e]=new W1.init;this.blockSize=(1600-2*this.cfg.outputLength)/32},_doProcessBlock:function(t,e){for(var r=this._state,i=this.blockSize/2,o=0;o>>24)|4278255360&(n<<24|n>>>8);(x=r[o]).high^=16711935&(s<<8|s>>>24)|4278255360&(s<<24|s>>>8),x.low^=n}for(var c=0;c<24;c++){for(var a=0;a<5;a++){for(var h=0,l=0,f=0;f<5;f++)h^=(x=r[a+5*f]).high,l^=x.low;var d=D[a];d.high=h,d.low=l}for(a=0;a<5;a++)for(var u=D[(a+4)%5],p=D[(a+1)%5],_=p.high,p=p.low,h=u.high^(_<<1|p>>>31),l=u.low^(p<<1|_>>>31),f=0;f<5;f++)(x=r[a+5*f]).high^=h,x.low^=l;for(var y=1;y<25;y++){var g=(x=r[y]).high,v=x.low,B=O1[y],g=(l=B<32?(h=g<>>32-B,v<>>32-B):(h=v<>>64-B,g<>>64-B),D[I1[y]]);g.high=h,g.low=l}var w=D[0],k=r[0];w.high=k.high,w.low=k.low;for(a=0;a<5;a++)for(f=0;f<5;f++){var x=r[y=a+5*f],b=D[y],m=D[(a+1)%5+5*f],S=D[(a+2)%5+5*f];x.high=b.high^~m.high&S.high,x.low=b.low^~m.low&S.low}x=r[0],w=U1[c];x.high^=w.high,x.low^=w.low}},_doFinalize:function(){for(var t=this._data,e=t.words,r=(this._nDataBytes,8*t.sigBytes),i=32*this.blockSize,o=(e[r>>>5]|=1<<24-r%32,e[(M1.ceil((1+r)/i)*i>>>5)-1]|=128,t.sigBytes=4*e.length,this._process(),this._state),r=this.cfg.outputLength/8,n=r/8,s=[],c=0;c>>24)|4278255360&(h<<24|h>>>8);s.push(16711935&(a<<8|a>>>24)|4278255360&(a<<24|a>>>8)),s.push(h)}return new P1.init(s,r)},clone:function(){for(var t=F1.clone.call(this),e=t._state=this._state.slice(0),r=0;r<25;r++)e[r]=e[r].clone();return t}}),p.SHA3=F1._createHelper(u),p.HmacSHA3=F1._createHmacHelper(u),Math,u=(p=i).lib,e=u.WordArray,Z=u.Hasher,u=p.algo,V=e.create([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,7,4,13,1,10,6,15,3,12,0,9,5,2,14,11,8,3,10,14,4,9,15,8,1,2,7,0,6,13,11,5,12,1,9,11,10,0,8,12,4,13,3,7,15,14,5,6,2,4,0,5,9,7,12,2,10,14,1,3,8,11,6,15,13]),G=e.create([5,14,7,0,9,2,11,4,13,6,15,8,1,10,3,12,6,11,3,7,0,13,5,10,14,15,8,12,4,9,1,2,15,5,1,3,7,14,6,9,11,8,12,2,10,0,4,13,8,6,4,1,3,11,15,0,5,12,2,13,9,7,10,14,12,15,10,4,1,5,8,7,6,2,13,14,0,3,9,11]),J=e.create([11,14,15,12,5,8,7,9,11,13,14,15,6,7,9,8,7,6,8,13,11,9,7,15,7,12,15,9,11,7,13,12,11,13,6,7,14,9,13,15,14,8,13,6,5,12,7,5,11,12,14,15,14,15,9,8,9,14,5,6,8,6,5,12,9,15,5,11,6,8,13,12,5,12,13,14,11,8,5,6]),Q=e.create([8,9,9,11,13,15,15,5,7,7,8,11,14,14,12,6,9,13,15,7,12,8,9,11,7,7,12,7,6,15,13,11,9,7,15,11,8,6,6,14,12,13,5,14,13,13,7,5,15,5,8,11,14,14,6,14,6,9,12,9,12,5,15,8,8,5,12,9,12,5,14,6,8,13,6,5,15,13,11,11]),Y=e.create([0,1518500249,1859775393,2400959708,2840853838]),$=e.create([1352829926,1548603684,1836072691,2053994217,0]),u=u.RIPEMD160=Z.extend({_doReset:function(){this._hash=e.create([1732584193,4023233417,2562383102,271733878,3285377520])},_doProcessBlock:function(t,e){for(var r=0;r<16;r++){var i=e+r,o=t[i];t[i]=16711935&(o<<8|o>>>24)|4278255360&(o<<24|o>>>8)}for(var n,s,c,a,h,l,f=this._hash.words,d=Y.words,u=$.words,p=V.words,_=G.words,y=J.words,g=Q.words,v=n=f[0],B=s=f[1],w=c=f[2],k=a=f[3],x=h=f[4],r=0;r<80;r+=1)l=(l=Q1(l=(l=n+t[e+p[r]]|0)+(r<16?(s^c^a)+d[0]:r<32?G1(s,c,a)+d[1]:r<48?((s|~c)^a)+d[2]:r<64?J1(s,c,a)+d[3]:(s^(c|~a))+d[4])|0,y[r]))+h|0,n=h,h=a,a=Q1(c,10),c=s,s=l,l=(l=Q1(l=(l=v+t[e+_[r]]|0)+(r<16?(B^(w|~k))+u[0]:r<32?J1(B,w,k)+u[1]:r<48?((B|~w)^k)+u[2]:r<64?G1(B,w,k)+u[3]:(B^w^k)+u[4])|0,g[r]))+x|0,v=x,x=k,k=Q1(w,10),w=B,B=l;l=f[1]+c+k|0,f[1]=f[2]+a+x|0,f[2]=f[3]+h+v|0,f[3]=f[4]+n+B|0,f[4]=f[0]+s+w|0,f[0]=l},_doFinalize:function(){for(var t=this._data,e=t.words,r=8*this._nDataBytes,i=8*t.sigBytes,i=(e[i>>>5]|=128<<24-i%32,e[14+(64+i>>>9<<4)]=16711935&(r<<8|r>>>24)|4278255360&(r<<24|r>>>8),t.sigBytes=4*(e.length+1),this._process(),this._hash),o=i.words,n=0;n<5;n++){var s=o[n];o[n]=16711935&(s<<8|s>>>24)|4278255360&(s<<24|s>>>8)}return i},clone:function(){var t=Z.clone.call(this);return t._hash=this._hash.clone(),t}}),p.RIPEMD160=Z._createHelper(u),p.HmacRIPEMD160=Z._createHmacHelper(u),u=(p=i).lib.Base,t1=p.enc.Utf8,p.algo.HMAC=u.extend({init:function(t,e){t=this._hasher=new t.init,"string"==typeof e&&(e=t1.parse(e));for(var r=t.blockSize,i=4*r,t=((e=e.sigBytes>i?t.finalize(e):e).clamp(),this._oKey=e.clone()),e=this._iKey=e.clone(),o=t.words,n=e.words,s=0;s>>2];t.sigBytes-=e}},P.BlockCipher=h1.extend({cfg:h1.cfg.extend({mode:r,padding:u}),reset:function(){h1.reset.call(this);var t,e=this.cfg,r=e.iv,e=e.mode;this._xformMode==this._ENC_XFORM_MODE?t=e.createEncryptor:(t=e.createDecryptor,this._minBufferSize=1),this._mode&&this._mode.__creator==t?this._mode.init(this,r&&r.words):(this._mode=t.call(e,this,r&&r.words),this._mode.__creator=t)},_doProcessBlock:function(t,e){this._mode.processBlock(t,e)},_doFinalize:function(){var t,e=this.cfg.padding;return this._xformMode==this._ENC_XFORM_MODE?(e.pad(this._data,this.blockSize),t=this._process(!0)):(t=this._process(!0),e.unpad(t)),t},blockSize:4}),l1=P.CipherParams=p.extend({init:function(t){this.mixIn(t)},toString:function(t){return(t||this.formatter).stringify(this)}}),r=(w.format={}).OpenSSL={stringify:function(t){var e=t.ciphertext,t=t.salt,t=t?s.create([1398893684,1701076831]).concat(t).concat(e):e;return t.toString(c1)},parse:function(t){var e,t=c1.parse(t),r=t.words;return 1398893684==r[0]&&1701076831==r[1]&&(e=s.create(r.slice(2,4)),r.splice(0,4),t.sigBytes-=16),l1.create({ciphertext:t,salt:e})}},o=P.SerializableCipher=p.extend({cfg:p.extend({format:r}),encrypt:function(t,e,r,i){i=this.cfg.extend(i);var o=t.createEncryptor(r,i),e=o.finalize(e),o=o.cfg;return l1.create({ciphertext:e,key:r,iv:o.iv,algorithm:t,mode:o.mode,padding:o.padding,blockSize:t.blockSize,formatter:i.format})},decrypt:function(t,e,r,i){return i=this.cfg.extend(i),e=this._parse(e,i.format),t.createDecryptor(r,i).finalize(e.ciphertext)},_parse:function(t,e){return"string"==typeof t?e.parse(t,this):t}}),u=(w.kdf={}).OpenSSL={execute:function(t,e,r,i,o){i=i||s.random(8),o=(o?a1.create({keySize:e+r,hasher:o}):a1.create({keySize:e+r})).compute(t,i);t=s.create(o.words.slice(e),4*r);return o.sigBytes=4*e,l1.create({key:o,iv:t,salt:i})}},f1=P.PasswordBasedCipher=o.extend({cfg:o.cfg.extend({kdf:u}),encrypt:function(t,e,r,i){r=(i=this.cfg.extend(i)).kdf.execute(r,t.keySize,t.ivSize,i.salt,i.hasher),i.iv=r.iv,t=o.encrypt.call(this,t,e,r.key,i);return t.mixIn(r),t},decrypt:function(t,e,r,i){i=this.cfg.extend(i),e=this._parse(e,i.format);r=i.kdf.execute(r,t.keySize,t.ivSize,e.salt,i.hasher);return i.iv=r.iv,o.decrypt.call(this,t,e,r.key,i)}})),i.mode.CFB=((p=i.lib.BlockCipherMode.extend()).Encryptor=p.extend({processBlock:function(t,e){var r=this._cipher,i=r.blockSize;t2.call(this,t,e,i,r),this._prevBlock=t.slice(e,e+i)}}),p.Decryptor=p.extend({processBlock:function(t,e){var r=this._cipher,i=r.blockSize,o=t.slice(e,e+i);t2.call(this,t,e,i,r),this._prevBlock=o}}),p),i.mode.CTR=(r=i.lib.BlockCipherMode.extend(),w=r.Encryptor=r.extend({processBlock:function(t,e){var r=this._cipher,i=r.blockSize,o=this._iv,n=this._counter,s=(o&&(n=this._counter=o.slice(0),this._iv=void 0),n.slice(0));r.encryptBlock(s,0),n[i-1]=n[i-1]+1|0;for(var c=0;c>>2]|=e<<24-r%4*8,t.sigBytes+=e},unpad:function(t){var e=255&t.words[t.sigBytes-1>>>2];t.sigBytes-=e}},i.pad.Iso10126={pad:function(t,e){e*=4,e-=t.sigBytes%e;t.concat(i.lib.WordArray.random(e-1)).concat(i.lib.WordArray.create([e<<24],1))},unpad:function(t){var e=255&t.words[t.sigBytes-1>>>2];t.sigBytes-=e}},i.pad.Iso97971={pad:function(t,e){t.concat(i.lib.WordArray.create([2147483648],1)),i.pad.ZeroPadding.pad(t,e)},unpad:function(t){i.pad.ZeroPadding.unpad(t),t.sigBytes--}},i.pad.ZeroPadding={pad:function(t,e){e*=4;t.clamp(),t.sigBytes+=e-(t.sigBytes%e||e)},unpad:function(t){for(var e=t.words,r=t.sigBytes-1,r=t.sigBytes-1;0<=r;r--)if(e[r>>>2]>>>24-r%4*8&255){t.sigBytes=r+1;break}}},i.pad.NoPadding={pad:function(){},unpad:function(){}},d1=(P=i).lib.CipherParams,u1=P.enc.Hex,P.format.Hex={stringify:function(t){return t.ciphertext.toString(u1)},parse:function(t){t=u1.parse(t);return d1.create({ciphertext:t})}};for(var w=i,p=w.lib.BlockCipher,u=w.algo,k=[],r2=[],i2=[],o2=[],n2=[],s2=[],c2=[],a2=[],h2=[],l2=[],x=[],b=0;b<256;b++)x[b]=b<128?b<<1:b<<1^283;for(var m=0,S=0,b=0;b<256;b++){var E=S^S<<1^S<<2^S<<3^S<<4,f2=(k[m]=E=E>>>8^255&E^99,x[r2[E]=m]),d2=x[f2],u2=x[d2],M=257*x[E]^16843008*E;i2[m]=M<<24|M>>>8,o2[m]=M<<16|M>>>16,n2[m]=M<<8|M>>>24,s2[m]=M,c2[E]=(M=16843009*u2^65537*d2^257*f2^16843008*m)<<24|M>>>8,a2[E]=M<<16|M>>>16,h2[E]=M<<8|M>>>24,l2[E]=M,m?(m=f2^x[x[x[u2^f2]]],S^=x[x[S]]):m=S=1}var p2=[0,1,2,4,8,16,32,64,128,27,54],u=u.AES=p.extend({_doReset:function(){if(!this._nRounds||this._keyPriorReset!==this._key){for(var t=this._keyPriorReset=this._key,e=t.words,r=t.sigBytes/4,i=4*(1+(this._nRounds=6+r)),o=this._keySchedule=[],n=0;n>>24]<<24|k[a>>>16&255]<<16|k[a>>>8&255]<<8|k[255&a]):(a=k[(a=a<<8|a>>>24)>>>24]<<24|k[a>>>16&255]<<16|k[a>>>8&255]<<8|k[255&a],a^=p2[n/r|0]<<24),o[n]=o[n-r]^a);for(var s=this._invKeySchedule=[],c=0;c>>24]]^a2[k[a>>>16&255]]^h2[k[a>>>8&255]]^l2[k[255&a]]}}},encryptBlock:function(t,e){this._doCryptBlock(t,e,this._keySchedule,i2,o2,n2,s2,k)},decryptBlock:function(t,e){var r=t[e+1],r=(t[e+1]=t[e+3],t[e+3]=r,this._doCryptBlock(t,e,this._invKeySchedule,c2,a2,h2,l2,r2),t[e+1]);t[e+1]=t[e+3],t[e+3]=r},_doCryptBlock:function(t,e,r,i,o,n,s,c){for(var a=this._nRounds,h=t[e]^r[0],l=t[e+1]^r[1],f=t[e+2]^r[2],d=t[e+3]^r[3],u=4,p=1;p>>24]^o[l>>>16&255]^n[f>>>8&255]^s[255&d]^r[u++],y=i[l>>>24]^o[f>>>16&255]^n[d>>>8&255]^s[255&h]^r[u++],g=i[f>>>24]^o[d>>>16&255]^n[h>>>8&255]^s[255&l]^r[u++],v=i[d>>>24]^o[h>>>16&255]^n[l>>>8&255]^s[255&f]^r[u++],h=_,l=y,f=g,d=v;_=(c[h>>>24]<<24|c[l>>>16&255]<<16|c[f>>>8&255]<<8|c[255&d])^r[u++],y=(c[l>>>24]<<24|c[f>>>16&255]<<16|c[d>>>8&255]<<8|c[255&h])^r[u++],g=(c[f>>>24]<<24|c[d>>>16&255]<<16|c[h>>>8&255]<<8|c[255&l])^r[u++],v=(c[d>>>24]<<24|c[h>>>16&255]<<16|c[l>>>8&255]<<8|c[255&f])^r[u++];t[e]=_,t[e+1]=y,t[e+2]=g,t[e+3]=v},keySize:8}),P=(w.AES=p._createHelper(u),i),_2=(w=P.lib).WordArray,w=w.BlockCipher,p=P.algo,y2=[57,49,41,33,25,17,9,1,58,50,42,34,26,18,10,2,59,51,43,35,27,19,11,3,60,52,44,36,63,55,47,39,31,23,15,7,62,54,46,38,30,22,14,6,61,53,45,37,29,21,13,5,28,20,12,4],g2=[14,17,11,24,1,5,3,28,15,6,21,10,23,19,12,4,26,8,16,7,27,20,13,2,41,52,31,37,47,55,30,40,51,45,33,48,44,49,39,56,34,53,46,42,50,36,29,32],v2=[1,2,4,6,8,10,12,14,15,17,19,21,23,25,27,28],B2=[{0:8421888,268435456:32768,536870912:8421378,805306368:2,1073741824:512,1342177280:8421890,1610612736:8389122,1879048192:8388608,2147483648:514,2415919104:8389120,2684354560:33280,2952790016:8421376,3221225472:32770,3489660928:8388610,3758096384:0,4026531840:33282,134217728:0,402653184:8421890,671088640:33282,939524096:32768,1207959552:8421888,1476395008:512,1744830464:8421378,2013265920:2,2281701376:8389120,2550136832:33280,2818572288:8421376,3087007744:8389122,3355443200:8388610,3623878656:32770,3892314112:514,4160749568:8388608,1:32768,268435457:2,536870913:8421888,805306369:8388608,1073741825:8421378,1342177281:33280,1610612737:512,1879048193:8389122,2147483649:8421890,2415919105:8421376,2684354561:8388610,2952790017:33282,3221225473:514,3489660929:8389120,3758096385:32770,4026531841:0,134217729:8421890,402653185:8421376,671088641:8388608,939524097:512,1207959553:32768,1476395009:8388610,1744830465:2,2013265921:33282,2281701377:32770,2550136833:8389122,2818572289:514,3087007745:8421888,3355443201:8389120,3623878657:0,3892314113:33280,4160749569:8421378},{0:1074282512,16777216:16384,33554432:524288,50331648:1074266128,67108864:1073741840,83886080:1074282496,100663296:1073758208,117440512:16,134217728:540672,150994944:1073758224,167772160:1073741824,184549376:540688,201326592:524304,218103808:0,234881024:16400,251658240:1074266112,8388608:1073758208,25165824:540688,41943040:16,58720256:1073758224,75497472:1074282512,92274688:1073741824,109051904:524288,125829120:1074266128,142606336:524304,159383552:0,176160768:16384,192937984:1074266112,209715200:1073741840,226492416:540672,243269632:1074282496,260046848:16400,268435456:0,285212672:1074266128,301989888:1073758224,318767104:1074282496,335544320:1074266112,352321536:16,369098752:540688,385875968:16384,402653184:16400,419430400:524288,436207616:524304,452984832:1073741840,469762048:540672,486539264:1073758208,503316480:1073741824,520093696:1074282512,276824064:540688,293601280:524288,310378496:1074266112,327155712:16384,343932928:1073758208,360710144:1074282512,377487360:16,394264576:1073741824,411041792:1074282496,427819008:1073741840,444596224:1073758224,461373440:524304,478150656:0,494927872:16400,511705088:1074266128,528482304:540672},{0:260,1048576:0,2097152:67109120,3145728:65796,4194304:65540,5242880:67108868,6291456:67174660,7340032:67174400,8388608:67108864,9437184:67174656,10485760:65792,11534336:67174404,12582912:67109124,13631488:65536,14680064:4,15728640:256,524288:67174656,1572864:67174404,2621440:0,3670016:67109120,4718592:67108868,5767168:65536,6815744:65540,7864320:260,8912896:4,9961472:256,11010048:67174400,12058624:65796,13107200:65792,14155776:67109124,15204352:67174660,16252928:67108864,16777216:67174656,17825792:65540,18874368:65536,19922944:67109120,20971520:256,22020096:67174660,23068672:67108868,24117248:0,25165824:67109124,26214400:67108864,27262976:4,28311552:65792,29360128:67174400,30408704:260,31457280:65796,32505856:67174404,17301504:67108864,18350080:260,19398656:67174656,20447232:0,21495808:65540,22544384:67109120,23592960:256,24641536:67174404,25690112:65536,26738688:67174660,27787264:65796,28835840:67108868,29884416:67109124,30932992:67174400,31981568:4,33030144:65792},{0:2151682048,65536:2147487808,131072:4198464,196608:2151677952,262144:0,327680:4198400,393216:2147483712,458752:4194368,524288:2147483648,589824:4194304,655360:64,720896:2147487744,786432:2151678016,851968:4160,917504:4096,983040:2151682112,32768:2147487808,98304:64,163840:2151678016,229376:2147487744,294912:4198400,360448:2151682112,425984:0,491520:2151677952,557056:4096,622592:2151682048,688128:4194304,753664:4160,819200:2147483648,884736:4194368,950272:4198464,1015808:2147483712,1048576:4194368,1114112:4198400,1179648:2147483712,1245184:0,1310720:4160,1376256:2151678016,1441792:2151682048,1507328:2147487808,1572864:2151682112,1638400:2147483648,1703936:2151677952,1769472:4198464,1835008:2147487744,1900544:4194304,1966080:64,2031616:4096,1081344:2151677952,1146880:2151682112,1212416:0,1277952:4198400,1343488:4194368,1409024:2147483648,1474560:2147487808,1540096:64,1605632:2147483712,1671168:4096,1736704:2147487744,1802240:2151678016,1867776:4160,1933312:2151682048,1998848:4194304,2064384:4198464},{0:128,4096:17039360,8192:262144,12288:536870912,16384:537133184,20480:16777344,24576:553648256,28672:262272,32768:16777216,36864:537133056,40960:536871040,45056:553910400,49152:553910272,53248:0,57344:17039488,61440:553648128,2048:17039488,6144:553648256,10240:128,14336:17039360,18432:262144,22528:537133184,26624:553910272,30720:536870912,34816:537133056,38912:0,43008:553910400,47104:16777344,51200:536871040,55296:553648128,59392:16777216,63488:262272,65536:262144,69632:128,73728:536870912,77824:553648256,81920:16777344,86016:553910272,90112:537133184,94208:16777216,98304:553910400,102400:553648128,106496:17039360,110592:537133056,114688:262272,118784:536871040,122880:0,126976:17039488,67584:553648256,71680:16777216,75776:17039360,79872:537133184,83968:536870912,88064:17039488,92160:128,96256:553910272,100352:262272,104448:553910400,108544:0,112640:553648128,116736:16777344,120832:262144,124928:537133056,129024:536871040},{0:268435464,256:8192,512:270532608,768:270540808,1024:268443648,1280:2097152,1536:2097160,1792:268435456,2048:0,2304:268443656,2560:2105344,2816:8,3072:270532616,3328:2105352,3584:8200,3840:270540800,128:270532608,384:270540808,640:8,896:2097152,1152:2105352,1408:268435464,1664:268443648,1920:8200,2176:2097160,2432:8192,2688:268443656,2944:270532616,3200:0,3456:270540800,3712:2105344,3968:268435456,4096:268443648,4352:270532616,4608:270540808,4864:8200,5120:2097152,5376:268435456,5632:268435464,5888:2105344,6144:2105352,6400:0,6656:8,6912:270532608,7168:8192,7424:268443656,7680:270540800,7936:2097160,4224:8,4480:2105344,4736:2097152,4992:268435464,5248:268443648,5504:8200,5760:270540808,6016:270532608,6272:270540800,6528:270532616,6784:8192,7040:2105352,7296:2097160,7552:0,7808:268435456,8064:268443656},{0:1048576,16:33555457,32:1024,48:1049601,64:34604033,80:0,96:1,112:34603009,128:33555456,144:1048577,160:33554433,176:34604032,192:34603008,208:1025,224:1049600,240:33554432,8:34603009,24:0,40:33555457,56:34604032,72:1048576,88:33554433,104:33554432,120:1025,136:1049601,152:33555456,168:34603008,184:1048577,200:1024,216:34604033,232:1,248:1049600,256:33554432,272:1048576,288:33555457,304:34603009,320:1048577,336:33555456,352:34604032,368:1049601,384:1025,400:34604033,416:1049600,432:1,448:0,464:34603008,480:33554433,496:1024,264:1049600,280:33555457,296:34603009,312:1,328:33554432,344:1048576,360:1025,376:34604032,392:33554433,408:34603008,424:0,440:34604033,456:1049601,472:1024,488:33555456,504:1048577},{0:134219808,1:131072,2:134217728,3:32,4:131104,5:134350880,6:134350848,7:2048,8:134348800,9:134219776,10:133120,11:134348832,12:2080,13:0,14:134217760,15:133152,2147483648:2048,2147483649:134350880,2147483650:134219808,2147483651:134217728,2147483652:134348800,2147483653:133120,2147483654:133152,2147483655:32,2147483656:134217760,2147483657:2080,2147483658:131104,2147483659:134350848,2147483660:0,2147483661:134348832,2147483662:134219776,2147483663:131072,16:133152,17:134350848,18:32,19:2048,20:134219776,21:134217760,22:134348832,23:131072,24:0,25:131104,26:134348800,27:134219808,28:134350880,29:133120,30:2080,31:134217728,2147483664:131072,2147483665:2048,2147483666:134348832,2147483667:133152,2147483668:32,2147483669:134348800,2147483670:134217728,2147483671:134219808,2147483672:134350880,2147483673:134217760,2147483674:134219776,2147483675:0,2147483676:133120,2147483677:2080,2147483678:131104,2147483679:134350848}],w2=[4160749569,528482304,33030144,2064384,129024,8064,504,2147483679],k2=p.DES=w.extend({_doReset:function(){for(var t=this._key.words,e=[],r=0;r<56;r++){var i=y2[r]-1;e[r]=t[i>>>5]>>>31-i%32&1}for(var o=this._subKeys=[],n=0;n<16;n++){for(var s=o[n]=[],c=v2[n],r=0;r<24;r++)s[r/6|0]|=e[(g2[r]-1+c)%28]<<31-r%6,s[4+(r/6|0)]|=e[28+(g2[r+24]-1+c)%28]<<31-r%6;s[0]=s[0]<<1|s[0]>>>31;for(r=1;r<7;r++)s[r]=s[r]>>>4*(r-1)+3;s[7]=s[7]<<5|s[7]>>>27}for(var a=this._invSubKeys=[],r=0;r<16;r++)a[r]=o[15-r]},encryptBlock:function(t,e){this._doCryptBlock(t,e,this._subKeys)},decryptBlock:function(t,e){this._doCryptBlock(t,e,this._invSubKeys)},_doCryptBlock:function(t,e,r){this._lBlock=t[e],this._rBlock=t[e+1],x2.call(this,4,252645135),x2.call(this,16,65535),b2.call(this,2,858993459),b2.call(this,8,16711935),x2.call(this,1,1431655765);for(var i=0;i<16;i++){for(var o=r[i],n=this._lBlock,s=this._rBlock,c=0,a=0;a<8;a++)c|=B2[a][((s^o[a])&w2[a])>>>0];this._lBlock=s,this._rBlock=n^c}var h=this._lBlock;this._lBlock=this._rBlock,this._rBlock=h,x2.call(this,1,1431655765),b2.call(this,8,16711935),b2.call(this,2,858993459),x2.call(this,16,65535),x2.call(this,4,252645135),t[e]=this._lBlock,t[e+1]=this._rBlock},keySize:2,ivSize:2,blockSize:2});function x2(t,e){e=(this._lBlock>>>t^this._rBlock)&e;this._rBlock^=e,this._lBlock^=e<>>t^this._lBlock)&e;this._lBlock^=e,this._rBlock^=e<192.");var e=t.slice(0,2),r=t.length<4?t.slice(0,2):t.slice(2,4),t=t.length<6?t.slice(0,2):t.slice(4,6);this._des1=k2.createEncryptor(_2.create(e)),this._des2=k2.createEncryptor(_2.create(r)),this._des3=k2.createEncryptor(_2.create(t))},encryptBlock:function(t,e){this._des1.encryptBlock(t,e),this._des2.decryptBlock(t,e),this._des3.encryptBlock(t,e)},decryptBlock:function(t,e){this._des3.decryptBlock(t,e),this._des2.encryptBlock(t,e),this._des1.decryptBlock(t,e)},keySize:6,ivSize:2,blockSize:2}),P.TripleDES=w._createHelper(p);var u=i,P=u.lib.StreamCipher,w=u.algo,m2=w.RC4=P.extend({_doReset:function(){for(var t=this._key,e=t.words,r=t.sigBytes,i=this._S=[],o=0;o<256;o++)i[o]=o;for(var o=0,n=0;o<256;o++){var s=o%r,s=e[s>>>2]>>>24-s%4*8&255,n=(n+i[o]+s)%256,s=i[o];i[o]=i[n],i[n]=s}this._i=this._j=0},_doProcessBlock:function(t,e){t[e]^=S2.call(this)},keySize:8,ivSize:0});function S2(){for(var t=this._S,e=this._i,r=this._j,i=0,o=0;o<4;o++){var r=(r+t[e=(e+1)%256])%256,n=t[e];t[e]=t[r],t[r]=n,i|=t[(t[e]+t[r])%256]<<24-8*o}return this._i=e,this._j=r,i}function A2(){for(var t=this._X,e=this._C,r=0;r<8;r++)c[r]=e[r];e[0]=e[0]+1295307597+this._b|0,e[1]=e[1]+3545052371+(e[0]>>>0>>0?1:0)|0,e[2]=e[2]+886263092+(e[1]>>>0>>0?1:0)|0,e[3]=e[3]+1295307597+(e[2]>>>0>>0?1:0)|0,e[4]=e[4]+3545052371+(e[3]>>>0>>0?1:0)|0,e[5]=e[5]+886263092+(e[4]>>>0>>0?1:0)|0,e[6]=e[6]+1295307597+(e[5]>>>0>>0?1:0)|0,e[7]=e[7]+3545052371+(e[6]>>>0>>0?1:0)|0,this._b=e[7]>>>0>>0?1:0;for(r=0;r<8;r++){var i=t[r]+e[r],o=65535&i,n=i>>>16;a[r]=((o*o>>>17)+o*n>>>15)+n*n^((4294901760&i)*i|0)+((65535&i)*i|0)}t[0]=a[0]+(a[7]<<16|a[7]>>>16)+(a[6]<<16|a[6]>>>16)|0,t[1]=a[1]+(a[0]<<8|a[0]>>>24)+a[7]|0,t[2]=a[2]+(a[1]<<16|a[1]>>>16)+(a[0]<<16|a[0]>>>16)|0,t[3]=a[3]+(a[2]<<8|a[2]>>>24)+a[1]|0,t[4]=a[4]+(a[3]<<16|a[3]>>>16)+(a[2]<<16|a[2]>>>16)|0,t[5]=a[5]+(a[4]<<8|a[4]>>>24)+a[3]|0,t[6]=a[6]+(a[5]<<16|a[5]>>>16)+(a[4]<<16|a[4]>>>16)|0,t[7]=a[7]+(a[6]<<8|a[6]>>>24)+a[5]|0}function z2(){for(var t=this._X,e=this._C,r=0;r<8;r++)f[r]=e[r];e[0]=e[0]+1295307597+this._b|0,e[1]=e[1]+3545052371+(e[0]>>>0>>0?1:0)|0,e[2]=e[2]+886263092+(e[1]>>>0>>0?1:0)|0,e[3]=e[3]+1295307597+(e[2]>>>0>>0?1:0)|0,e[4]=e[4]+3545052371+(e[3]>>>0>>0?1:0)|0,e[5]=e[5]+886263092+(e[4]>>>0>>0?1:0)|0,e[6]=e[6]+1295307597+(e[5]>>>0>>0?1:0)|0,e[7]=e[7]+3545052371+(e[6]>>>0>>0?1:0)|0,this._b=e[7]>>>0>>0?1:0;for(r=0;r<8;r++){var i=t[r]+e[r],o=65535&i,n=i>>>16;d[r]=((o*o>>>17)+o*n>>>15)+n*n^((4294901760&i)*i|0)+((65535&i)*i|0)}t[0]=d[0]+(d[7]<<16|d[7]>>>16)+(d[6]<<16|d[6]>>>16)|0,t[1]=d[1]+(d[0]<<8|d[0]>>>24)+d[7]|0,t[2]=d[2]+(d[1]<<16|d[1]>>>16)+(d[0]<<16|d[0]>>>16)|0,t[3]=d[3]+(d[2]<<8|d[2]>>>24)+d[1]|0,t[4]=d[4]+(d[3]<<16|d[3]>>>16)+(d[2]<<16|d[2]>>>16)|0,t[5]=d[5]+(d[4]<<8|d[4]>>>24)+d[3]|0,t[6]=d[6]+(d[5]<<16|d[5]>>>16)+(d[4]<<16|d[4]>>>16)|0,t[7]=d[7]+(d[6]<<8|d[6]>>>24)+d[5]|0}u.RC4=P._createHelper(m2),w=w.RC4Drop=m2.extend({cfg:m2.cfg.extend({drop:192}),_doReset:function(){m2._doReset.call(this);for(var t=this.cfg.drop;0>>24)|4278255360&(t[r]<<24|t[r]>>>8);for(var i=this._X=[t[0],t[3]<<16|t[2]>>>16,t[1],t[0]<<16|t[3]>>>16,t[2],t[1]<<16|t[0]>>>16,t[3],t[2]<<16|t[1]>>>16],o=this._C=[t[2]<<16|t[2]>>>16,4294901760&t[0]|65535&t[1],t[3]<<16|t[3]>>>16,4294901760&t[1]|65535&t[2],t[0]<<16|t[0]>>>16,4294901760&t[2]|65535&t[3],t[1]<<16|t[1]>>>16,4294901760&t[3]|65535&t[0]],r=this._b=0;r<4;r++)A2.call(this);for(r=0;r<8;r++)o[r]^=i[r+4&7];if(e){var e=e.words,n=e[0],e=e[1],n=16711935&(n<<8|n>>>24)|4278255360&(n<<24|n>>>8),e=16711935&(e<<8|e>>>24)|4278255360&(e<<24|e>>>8),s=n>>>16|4294901760&e,c=e<<16|65535&n;o[0]^=n,o[1]^=s,o[2]^=e,o[3]^=c,o[4]^=n,o[5]^=s,o[6]^=e,o[7]^=c;for(r=0;r<4;r++)A2.call(this)}},_doProcessBlock:function(t,e){var r=this._X;A2.call(this),n[0]=r[0]^r[5]>>>16^r[3]<<16,n[1]=r[2]^r[7]>>>16^r[5]<<16,n[2]=r[4]^r[1]>>>16^r[7]<<16,n[3]=r[6]^r[3]>>>16^r[1]<<16;for(var i=0;i<4;i++)n[i]=16711935&(n[i]<<8|n[i]>>>24)|4278255360&(n[i]<<24|n[i]>>>8),t[e+i]^=n[i]},blockSize:4,ivSize:2}),p.Rabbit=u._createHelper(P),p=(w=i).lib.StreamCipher,u=w.algo,h=[],f=[],d=[],u=u.RabbitLegacy=p.extend({_doReset:function(){for(var t=this._key.words,e=this.cfg.iv,r=this._X=[t[0],t[3]<<16|t[2]>>>16,t[1],t[0]<<16|t[3]>>>16,t[2],t[1]<<16|t[0]>>>16,t[3],t[2]<<16|t[1]>>>16],i=this._C=[t[2]<<16|t[2]>>>16,4294901760&t[0]|65535&t[1],t[3]<<16|t[3]>>>16,4294901760&t[1]|65535&t[2],t[0]<<16|t[0]>>>16,4294901760&t[2]|65535&t[3],t[1]<<16|t[1]>>>16,4294901760&t[3]|65535&t[0]],o=this._b=0;o<4;o++)z2.call(this);for(o=0;o<8;o++)i[o]^=r[o+4&7];if(e){var t=e.words,e=t[0],t=t[1],e=16711935&(e<<8|e>>>24)|4278255360&(e<<24|e>>>8),t=16711935&(t<<8|t>>>24)|4278255360&(t<<24|t>>>8),n=e>>>16|4294901760&t,s=t<<16|65535&e;i[0]^=e,i[1]^=n,i[2]^=t,i[3]^=s,i[4]^=e,i[5]^=n,i[6]^=t,i[7]^=s;for(o=0;o<4;o++)z2.call(this)}},_doProcessBlock:function(t,e){var r=this._X;z2.call(this),h[0]=r[0]^r[5]>>>16^r[3]<<16,h[1]=r[2]^r[7]>>>16^r[5]<<16,h[2]=r[4]^r[1]>>>16^r[7]<<16,h[3]=r[6]^r[3]>>>16^r[1]<<16;for(var i=0;i<4;i++)h[i]=16711935&(h[i]<<8|h[i]>>>24)|4278255360&(h[i]<<24|h[i]>>>8),t[e+i]^=h[i]},blockSize:4,ivSize:2}),w.RabbitLegacy=p._createHelper(u);{w=(P=i).lib.BlockCipher,p=P.algo;const F=16,D2=[608135816,2242054355,320440878,57701188,2752067618,698298832,137296536,3964562569,1160258022,953160567,3193202383,887688300,3232508343,3380367581,1065670069,3041331479,2450970073,2306472731],E2=[[3509652390,2564797868,805139163,3491422135,3101798381,1780907670,3128725573,4046225305,614570311,3012652279,134345442,2240740374,1667834072,1901547113,2757295779,4103290238,227898511,1921955416,1904987480,2182433518,2069144605,3260701109,2620446009,720527379,3318853667,677414384,3393288472,3101374703,2390351024,1614419982,1822297739,2954791486,3608508353,3174124327,2024746970,1432378464,3864339955,2857741204,1464375394,1676153920,1439316330,715854006,3033291828,289532110,2706671279,2087905683,3018724369,1668267050,732546397,1947742710,3462151702,2609353502,2950085171,1814351708,2050118529,680887927,999245976,1800124847,3300911131,1713906067,1641548236,4213287313,1216130144,1575780402,4018429277,3917837745,3693486850,3949271944,596196993,3549867205,258830323,2213823033,772490370,2760122372,1774776394,2652871518,566650946,4142492826,1728879713,2882767088,1783734482,3629395816,2517608232,2874225571,1861159788,326777828,3124490320,2130389656,2716951837,967770486,1724537150,2185432712,2364442137,1164943284,2105845187,998989502,3765401048,2244026483,1075463327,1455516326,1322494562,910128902,469688178,1117454909,936433444,3490320968,3675253459,1240580251,122909385,2157517691,634681816,4142456567,3825094682,3061402683,2540495037,79693498,3249098678,1084186820,1583128258,426386531,1761308591,1047286709,322548459,995290223,1845252383,2603652396,3431023940,2942221577,3202600964,3727903485,1712269319,422464435,3234572375,1170764815,3523960633,3117677531,1434042557,442511882,3600875718,1076654713,1738483198,4213154764,2393238008,3677496056,1014306527,4251020053,793779912,2902807211,842905082,4246964064,1395751752,1040244610,2656851899,3396308128,445077038,3742853595,3577915638,679411651,2892444358,2354009459,1767581616,3150600392,3791627101,3102740896,284835224,4246832056,1258075500,768725851,2589189241,3069724005,3532540348,1274779536,3789419226,2764799539,1660621633,3471099624,4011903706,913787905,3497959166,737222580,2514213453,2928710040,3937242737,1804850592,3499020752,2949064160,2386320175,2390070455,2415321851,4061277028,2290661394,2416832540,1336762016,1754252060,3520065937,3014181293,791618072,3188594551,3933548030,2332172193,3852520463,3043980520,413987798,3465142937,3030929376,4245938359,2093235073,3534596313,375366246,2157278981,2479649556,555357303,3870105701,2008414854,3344188149,4221384143,3956125452,2067696032,3594591187,2921233993,2428461,544322398,577241275,1471733935,610547355,4027169054,1432588573,1507829418,2025931657,3646575487,545086370,48609733,2200306550,1653985193,298326376,1316178497,3007786442,2064951626,458293330,2589141269,3591329599,3164325604,727753846,2179363840,146436021,1461446943,4069977195,705550613,3059967265,3887724982,4281599278,3313849956,1404054877,2845806497,146425753,1854211946],[1266315497,3048417604,3681880366,3289982499,290971e4,1235738493,2632868024,2414719590,3970600049,1771706367,1449415276,3266420449,422970021,1963543593,2690192192,3826793022,1062508698,1531092325,1804592342,2583117782,2714934279,4024971509,1294809318,4028980673,1289560198,2221992742,1669523910,35572830,157838143,1052438473,1016535060,1802137761,1753167236,1386275462,3080475397,2857371447,1040679964,2145300060,2390574316,1461121720,2956646967,4031777805,4028374788,33600511,2920084762,1018524850,629373528,3691585981,3515945977,2091462646,2486323059,586499841,988145025,935516892,3367335476,2599673255,2839830854,265290510,3972581182,2759138881,3795373465,1005194799,847297441,406762289,1314163512,1332590856,1866599683,4127851711,750260880,613907577,1450815602,3165620655,3734664991,3650291728,3012275730,3704569646,1427272223,778793252,1343938022,2676280711,2052605720,1946737175,3164576444,3914038668,3967478842,3682934266,1661551462,3294938066,4011595847,840292616,3712170807,616741398,312560963,711312465,1351876610,322626781,1910503582,271666773,2175563734,1594956187,70604529,3617834859,1007753275,1495573769,4069517037,2549218298,2663038764,504708206,2263041392,3941167025,2249088522,1514023603,1998579484,1312622330,694541497,2582060303,2151582166,1382467621,776784248,2618340202,3323268794,2497899128,2784771155,503983604,4076293799,907881277,423175695,432175456,1378068232,4145222326,3954048622,3938656102,3820766613,2793130115,2977904593,26017576,3274890735,3194772133,1700274565,1756076034,4006520079,3677328699,720338349,1533947780,354530856,688349552,3973924725,1637815568,332179504,3949051286,53804574,2852348879,3044236432,1282449977,3583942155,3416972820,4006381244,1617046695,2628476075,3002303598,1686838959,431878346,2686675385,1700445008,1080580658,1009431731,832498133,3223435511,2605976345,2271191193,2516031870,1648197032,4164389018,2548247927,300782431,375919233,238389289,3353747414,2531188641,2019080857,1475708069,455242339,2609103871,448939670,3451063019,1395535956,2413381860,1841049896,1491858159,885456874,4264095073,4001119347,1565136089,3898914787,1108368660,540939232,1173283510,2745871338,3681308437,4207628240,3343053890,4016749493,1699691293,1103962373,3625875870,2256883143,3830138730,1031889488,3479347698,1535977030,4236805024,3251091107,2132092099,1774941330,1199868427,1452454533,157007616,2904115357,342012276,595725824,1480756522,206960106,497939518,591360097,863170706,2375253569,3596610801,1814182875,2094937945,3421402208,1082520231,3463918190,2785509508,435703966,3908032597,1641649973,2842273706,3305899714,1510255612,2148256476,2655287854,3276092548,4258621189,236887753,3681803219,274041037,1734335097,3815195456,3317970021,1899903192,1026095262,4050517792,356393447,2410691914,3873677099,3682840055],[3913112168,2491498743,4132185628,2489919796,1091903735,1979897079,3170134830,3567386728,3557303409,857797738,1136121015,1342202287,507115054,2535736646,337727348,3213592640,1301675037,2528481711,1895095763,1721773893,3216771564,62756741,2142006736,835421444,2531993523,1442658625,3659876326,2882144922,676362277,1392781812,170690266,3921047035,1759253602,3611846912,1745797284,664899054,1329594018,3901205900,3045908486,2062866102,2865634940,3543621612,3464012697,1080764994,553557557,3656615353,3996768171,991055499,499776247,1265440854,648242737,3940784050,980351604,3713745714,1749149687,3396870395,4211799374,3640570775,1161844396,3125318951,1431517754,545492359,4268468663,3499529547,1437099964,2702547544,3433638243,2581715763,2787789398,1060185593,1593081372,2418618748,4260947970,69676912,2159744348,86519011,2512459080,3838209314,1220612927,3339683548,133810670,1090789135,1078426020,1569222167,845107691,3583754449,4072456591,1091646820,628848692,1613405280,3757631651,526609435,236106946,48312990,2942717905,3402727701,1797494240,859738849,992217954,4005476642,2243076622,3870952857,3732016268,765654824,3490871365,2511836413,1685915746,3888969200,1414112111,2273134842,3281911079,4080962846,172450625,2569994100,980381355,4109958455,2819808352,2716589560,2568741196,3681446669,3329971472,1835478071,660984891,3704678404,4045999559,3422617507,3040415634,1762651403,1719377915,3470491036,2693910283,3642056355,3138596744,1364962596,2073328063,1983633131,926494387,3423689081,2150032023,4096667949,1749200295,3328846651,309677260,2016342300,1779581495,3079819751,111262694,1274766160,443224088,298511866,1025883608,3806446537,1145181785,168956806,3641502830,3584813610,1689216846,3666258015,3200248200,1692713982,2646376535,4042768518,1618508792,1610833997,3523052358,4130873264,2001055236,3610705100,2202168115,4028541809,2961195399,1006657119,2006996926,3186142756,1430667929,3210227297,1314452623,4074634658,4101304120,2273951170,1399257539,3367210612,3027628629,1190975929,2062231137,2333990788,2221543033,2438960610,1181637006,548689776,2362791313,3372408396,3104550113,3145860560,296247880,1970579870,3078560182,3769228297,1714227617,3291629107,3898220290,166772364,1251581989,493813264,448347421,195405023,2709975567,677966185,3703036547,1463355134,2715995803,1338867538,1343315457,2802222074,2684532164,233230375,2599980071,2000651841,3277868038,1638401717,4028070440,3237316320,6314154,819756386,300326615,590932579,1405279636,3267499572,3150704214,2428286686,3959192993,3461946742,1862657033,1266418056,963775037,2089974820,2263052895,1917689273,448879540,3550394620,3981727096,150775221,3627908307,1303187396,508620638,2975983352,2726630617,1817252668,1876281319,1457606340,908771278,3720792119,3617206836,2455994898,1729034894,1080033504],[976866871,3556439503,2881648439,1522871579,1555064734,1336096578,3548522304,2579274686,3574697629,3205460757,3593280638,3338716283,3079412587,564236357,2993598910,1781952180,1464380207,3163844217,3332601554,1699332808,1393555694,1183702653,3581086237,1288719814,691649499,2847557200,2895455976,3193889540,2717570544,1781354906,1676643554,2592534050,3230253752,1126444790,2770207658,2633158820,2210423226,2615765581,2414155088,3127139286,673620729,2805611233,1269405062,4015350505,3341807571,4149409754,1057255273,2012875353,2162469141,2276492801,2601117357,993977747,3918593370,2654263191,753973209,36408145,2530585658,25011837,3520020182,2088578344,530523599,2918365339,1524020338,1518925132,3760827505,3759777254,1202760957,3985898139,3906192525,674977740,4174734889,2031300136,2019492241,3983892565,4153806404,3822280332,352677332,2297720250,60907813,90501309,3286998549,1016092578,2535922412,2839152426,457141659,509813237,4120667899,652014361,1966332200,2975202805,55981186,2327461051,676427537,3255491064,2882294119,3433927263,1307055953,942726286,933058658,2468411793,3933900994,4215176142,1361170020,2001714738,2830558078,3274259782,1222529897,1679025792,2729314320,3714953764,1770335741,151462246,3013232138,1682292957,1483529935,471910574,1539241949,458788160,3436315007,1807016891,3718408830,978976581,1043663428,3165965781,1927990952,4200891579,2372276910,3208408903,3533431907,1412390302,2931980059,4132332400,1947078029,3881505623,4168226417,2941484381,1077988104,1320477388,886195818,18198404,3786409e3,2509781533,112762804,3463356488,1866414978,891333506,18488651,661792760,1628790961,3885187036,3141171499,876946877,2693282273,1372485963,791857591,2686433993,3759982718,3167212022,3472953795,2716379847,445679433,3561995674,3504004811,3574258232,54117162,3331405415,2381918588,3769707343,4154350007,1140177722,4074052095,668550556,3214352940,367459370,261225585,2610173221,4209349473,3468074219,3265815641,314222801,3066103646,3808782860,282218597,3406013506,3773591054,379116347,1285071038,846784868,2669647154,3771962079,3550491691,2305946142,453669953,1268987020,3317592352,3279303384,3744833421,2610507566,3859509063,266596637,3847019092,517658769,3462560207,3443424879,370717030,4247526661,2224018117,4143653529,4112773975,2788324899,2477274417,1456262402,2901442914,1517677493,1846949527,2295493580,3734397586,2176403920,1280348187,1908823572,3871786941,846861322,1172426758,3287448474,3383383037,1655181056,3139813346,901632758,1897031941,2986607138,3066810236,3447102507,1393639104,373351379,950779232,625454576,3124240540,4148612726,2007998917,544563296,2244738638,2330496472,2058025392,1291430526,424198748,50039436,29584100,3605783033,2429876329,2791104160,1057563949,3255363231,3075367218,3463963227,1469046755,985887462]];var H2={pbox:[],sbox:[]};function C2(t,e){var r=t.sbox[0][e>>24&255]+t.sbox[1][e>>16&255];return r=(r^=t.sbox[2][e>>8&255])+t.sbox[3][255&e]}function R2(e,t,r){let i=t,o=r,n;for(let t=0;t=a&&(e=0);let r=0,i=0,o=0;for(let t=0;t + + + + + JARVIS + + + +

+ +
Remote Access
+

Reconnecting…

+

Press Remote Control in the JARVIS desktop app to get a QR code or session key.

+
+ +
+
+ +

+ 🎤 Voice commands: open + chrome://flags on your phone, search + Insecure origins treated as secure, add your JARVIS URL, tap Relaunch. +

+
+ + + + diff --git a/main.py b/main.py index c27f1fb..9807743 100644 --- a/main.py +++ b/main.py @@ -1,1396 +1,969 @@ -""" -MARK XL — Local LLM Edition -STT (Whisper / Vosk) + Ollama LLM + TTS (EdgeTTS / Kokoro / ElevenLabs) -All Gemini / Google-AI dependencies removed. -""" -# ── Silence verbose logs + block heavy unused backends ───────────────────── -import os as _os -_os.environ.setdefault("TF_CPP_MIN_LOG_LEVEL", "3") # TensorFlow C++ noise -_os.environ.setdefault("TF_ENABLE_ONEDNN_OPTS", "0") # oneDNN banner -_os.environ.setdefault("GRPC_VERBOSITY", "ERROR") -# USE_TF=0 prevents transformers from importing TensorFlow (saves 4-8 s). -# We intentionally do NOT set USE_TORCH or USE_JAX — forcing those values -# breaks transformers' lazy-loader on some versions (AutoModel disappears -# from the namespace). Let transformers auto-detect the available backends. -_os.environ.setdefault("USE_TF", "0") -_os.environ.setdefault("TOKENIZERS_PARALLELISM", "false") -# Offline mode — use cached models, no HuggingFace network calls on startup. -# On first run the model isn't cached yet; tts.py / stt.py detect this and -# temporarily clear these flags to allow the one-time download, then they -# stay in effect for every subsequent launch (fully offline). -_os.environ.setdefault("HF_HUB_OFFLINE", "1") -_os.environ.setdefault("TRANSFORMERS_OFFLINE", "1") -_os.environ.setdefault("HF_DATASETS_OFFLINE", "1") -import warnings as _warnings -_warnings.filterwarnings("ignore", category=UserWarning) -_warnings.filterwarnings("ignore", category=DeprecationWarning) -_warnings.filterwarnings("ignore", category=FutureWarning) -# ─────────────────────────────────────────────────────────────────────────── - -# ── Bootstrap: auto-install base UI packages before anything else ────────── -# Uses only stdlib so it works even on a completely fresh Python install. -import importlib.util as _ilu -import subprocess as _sp -import sys as _sys - -_BASE_PKGS = [ - ("PyQt6", "PyQt6"), - ("psutil", "psutil"), - ("numpy", "numpy"), - ("sounddevice", "sounddevice"), - ("PIL", "pillow"), - ("requests", "requests"), -] - -def _bootstrap() -> None: - need = [pkg for mod, pkg in _BASE_PKGS if _ilu.find_spec(mod) is None] - if not need: - return - print(f"\n[MARK XL] First-run setup — installing: {', '.join(need)}") - print("[MARK XL] This happens only once.\n") - _sp.run([_sys.executable, "-m", "pip", "install", *need], check=True) - print("\n[MARK XL] Base packages ready — restarting…\n") - # Replace current process with a fresh one (picks up newly installed packages) - _os.execv(_sys.executable, [_sys.executable] + _sys.argv) - -_bootstrap() -# ─────────────────────────────────────────────────────────────────────────── - -import json -import queue -import re -import sys -import threading -import traceback -from datetime import datetime -from pathlib import Path - -import numpy as np -import sounddevice as sd - -from ui import JarvisUI -from memory.memory_manager import load_memory, update_memory, format_memory_for_prompt -from core.llm_client import call_llm, call_llm_stream, get_llm_settings - -from actions.file_processor import file_processor -from actions.flight_finder import flight_finder -from actions.open_app import open_app -from actions.weather_report import weather_action -from actions.send_message import send_message -from actions.reminder import reminder -from actions.computer_settings import computer_settings -from actions.screen_processor import screen_process -from actions.youtube_video import youtube_video -from actions.desktop import desktop_control -from actions.browser_control import browser_control -from actions.file_controller import file_controller -from actions.code_helper import code_helper -from actions.dev_agent import dev_agent -from actions.web_search import web_search as web_search_action -from actions.computer_control import computer_control -from actions.game_updater import game_updater - - -# --------------------------------------------------------------------------- -# Paths -# --------------------------------------------------------------------------- - -def _get_base_dir() -> Path: - if getattr(sys, "frozen", False): - return Path(sys.executable).parent - return Path(__file__).resolve().parent - - -BASE_DIR = _get_base_dir() -API_CONFIG_PATH = BASE_DIR / "config" / "api_keys.json" -PROMPT_PATH = BASE_DIR / "core" / "prompt.txt" - -SAMPLE_RATE_IN = 16_000 -BLOCK_SIZE = 1_024 -CHANNELS = 1 - -# --------------------------------------------------------------------------- -# Tool declarations (Gemini format kept for readability; -# converted to OpenAI/Ollama format by _to_ollama_tools()) -# --------------------------------------------------------------------------- - -TOOL_DECLARATIONS = [ - { - "name": "open_app", - "description": ( - "Opens or launches any application, website, or program on the computer. " - "ALWAYS use this when the user says: open, launch, start, run, pull up, " - "or 'open X real quick'. Examples: 'open WhatsApp', 'open Chrome', " - "'launch Spotify', 'open calculator', 'pull up WhatsApp'. " - "Do NOT use send_message just because the app is a messaging app — " - "if the user only says to open it, call open_app." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "app_name": {"type": "STRING", "description": "Name of the application or website to open"} - }, - "required": ["app_name"] - } - }, - { - "name": "web_search", - "description": "Searches the web for any information.", - "parameters": { - "type": "OBJECT", - "properties": { - "query": {"type": "STRING", "description": "Search query"}, - "mode": {"type": "STRING", "description": "search (default) or compare"}, - "items": {"type": "ARRAY", "items": {"type": "STRING"}, "description": "Items to compare"}, - "aspect": {"type": "STRING", "description": "price | specs | reviews"} - }, - "required": ["query"] - } - }, - { - "name": "weather_report", - "description": "Gives the weather report to user", - "parameters": { - "type": "OBJECT", - "properties": {"city": {"type": "STRING", "description": "City name"}}, - "required": ["city"] - } - }, - { - "name": "send_message", - "description": ( - "Sends a message to a specific person via WhatsApp, Telegram, or similar. " - "ONLY use this when the user explicitly provides BOTH a recipient AND message content. " - "Example triggers: 'text John saying I am late', 'send a WhatsApp to mom that dinner is ready'. " - "Do NOT call this if the user only wants to open the app without sending a message." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "receiver": {"type": "STRING", "description": "Recipient contact name"}, - "message_text": {"type": "STRING", "description": "The exact message text to send"}, - "platform": {"type": "STRING", "description": "Platform: WhatsApp, Telegram, etc."} - }, - "required": ["receiver", "message_text", "platform"] - } - }, - { - "name": "reminder", - "description": "Sets a timed reminder using Task Scheduler.", - "parameters": { - "type": "OBJECT", - "properties": { - "date": {"type": "STRING", "description": "Date in YYYY-MM-DD format"}, - "time": {"type": "STRING", "description": "Time in HH:MM format (24h)"}, - "message": {"type": "STRING", "description": "Reminder message text"} - }, - "required": ["date", "time", "message"] - } - }, - { - "name": "youtube_video", - "description": ( - "Controls YouTube. Use for: playing videos, summarizing a video's content, " - "getting video info, or showing trending videos." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "play | summarize | get_info | trending"}, - "query": {"type": "STRING", "description": "Search query for play action"}, - "save": {"type": "BOOLEAN", "description": "Save summary to Notepad"}, - "region": {"type": "STRING", "description": "Country code for trending e.g. TR, US"}, - "url": {"type": "STRING", "description": "Video URL for get_info action"}, - }, - "required": [] - } - }, - { - "name": "screen_process", - "description": ( - "Captures and analyzes the screen or webcam image. " - "MUST be called when user asks what is on screen, what you see, " - "analyze my screen, look at camera, etc. " - "You have NO visual ability without this tool." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "angle": {"type": "STRING", "description": "'screen' or 'camera'. Default: 'screen'"}, - "text": {"type": "STRING", "description": "The question about the captured image"} - }, - "required": ["text"] - } - }, - { - "name": "computer_settings", - "description": ( - "Controls the computer: volume, brightness, window management, keyboard shortcuts, " - "typing text on screen, closing apps, fullscreen, dark mode, WiFi, restart, shutdown, " - "scrolling, tab management, zoom, screenshots, lock screen, refresh/reload page." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "The action to perform"}, - "description": {"type": "STRING", "description": "Natural language description"}, - "value": {"type": "STRING", "description": "Optional value"} - }, - "required": [] - } - }, - { - "name": "browser_control", - "description": ( - "Controls any web browser. Use for: opening websites, searching the web, " - "clicking elements, filling forms, scrolling, screenshots, navigation." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "go_to | search | click | type | scroll | fill_form | smart_click | smart_type | get_text | get_url | press | new_tab | close_tab | screenshot | back | forward | reload | switch | list_browsers | close | close_all"}, - "browser": {"type": "STRING", "description": "chrome | edge | firefox | opera | operagx | brave | vivaldi | safari"}, - "url": {"type": "STRING", "description": "URL for go_to / new_tab action"}, - "query": {"type": "STRING", "description": "Search query"}, - "engine": {"type": "STRING", "description": "google | bing | duckduckgo | yandex"}, - "selector": {"type": "STRING", "description": "CSS selector for click/type"}, - "text": {"type": "STRING", "description": "Text to click or type"}, - "description": {"type": "STRING", "description": "Element description for smart_click/smart_type"}, - "direction": {"type": "STRING", "description": "up | down for scroll"}, - "amount": {"type": "INTEGER", "description": "Scroll amount in pixels"}, - "key": {"type": "STRING", "description": "Key name for press"}, - "path": {"type": "STRING", "description": "Save path for screenshot"}, - "incognito": {"type": "BOOLEAN", "description": "Open in private/incognito mode"}, - "clear_first": {"type": "BOOLEAN", "description": "Clear field before typing"}, - }, - "required": ["action"] - } - }, - { - "name": "file_controller", - "description": "Manages files and folders: list, create, delete, move, copy, rename, read, write, find, disk usage.", - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "list | create_file | create_folder | delete | move | copy | rename | read | write | find | largest | disk_usage | organize_desktop | info"}, - "path": {"type": "STRING", "description": "File/folder path or shortcut: desktop, downloads, documents, home"}, - "destination": {"type": "STRING", "description": "Destination path for move/copy"}, - "new_name": {"type": "STRING", "description": "New name for rename"}, - "content": {"type": "STRING", "description": "Content for create_file/write"}, - "name": {"type": "STRING", "description": "File name to search for"}, - "extension": {"type": "STRING", "description": "File extension to search"}, - "count": {"type": "INTEGER", "description": "Number of results for largest"}, - }, - "required": ["action"] - } - }, - { - "name": "desktop_control", - "description": "Controls the desktop: wallpaper, organize, clean, list, stats.", - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "wallpaper | wallpaper_url | organize | clean | list | stats | task"}, - "path": {"type": "STRING", "description": "Image path for wallpaper"}, - "url": {"type": "STRING", "description": "Image URL for wallpaper_url"}, - "mode": {"type": "STRING", "description": "by_type or by_date for organize"}, - "task": {"type": "STRING", "description": "Natural language desktop task"}, - }, - "required": ["action"] - } - }, - { - "name": "code_helper", - "description": "Writes, edits, explains, runs, or builds code files.", - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "write | edit | explain | run | build | auto"}, - "description": {"type": "STRING", "description": "What the code should do"}, - "language": {"type": "STRING", "description": "Programming language"}, - "output_path": {"type": "STRING", "description": "Where to save the file"}, - "file_path": {"type": "STRING", "description": "Path to existing file"}, - "code": {"type": "STRING", "description": "Raw code string for explain"}, - "args": {"type": "STRING", "description": "CLI arguments"}, - "timeout": {"type": "INTEGER", "description": "Execution timeout in seconds"}, - }, - "required": ["action"] - } - }, - { - "name": "dev_agent", - "description": "Builds complete multi-file projects from scratch.", - "parameters": { - "type": "OBJECT", - "properties": { - "description": {"type": "STRING", "description": "What the project should do"}, - "language": {"type": "STRING", "description": "Programming language"}, - "project_name": {"type": "STRING", "description": "Optional project folder name"}, - "timeout": {"type": "INTEGER", "description": "Run timeout in seconds"}, - }, - "required": ["description"] - } - }, - { - "name": "agent_task", - "description": ( - "Executes complex multi-step tasks requiring multiple different tools. " - "Examples: 'research X and save to file', 'find and organize files'. " - "DO NOT use for single commands." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "goal": {"type": "STRING", "description": "Complete description of what to accomplish"}, - "priority": {"type": "STRING", "description": "low | normal | high"} - }, - "required": ["goal"] - } - }, - { - "name": "computer_control", - "description": "Direct computer control: type, click, hotkeys, scroll, move mouse, screenshots, find elements on screen.", - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "type | smart_type | click | double_click | right_click | hotkey | press | scroll | move | copy | paste | screenshot | wait | clear_field | focus_window | screen_find | screen_click | random_data | user_data"}, - "text": {"type": "STRING", "description": "Text to type or paste"}, - "x": {"type": "INTEGER", "description": "X coordinate"}, - "y": {"type": "INTEGER", "description": "Y coordinate"}, - "keys": {"type": "STRING", "description": "Key combination e.g. 'ctrl+c'"}, - "key": {"type": "STRING", "description": "Single key e.g. 'enter'"}, - "direction": {"type": "STRING", "description": "up | down | left | right"}, - "amount": {"type": "INTEGER", "description": "Scroll amount"}, - "seconds": {"type": "NUMBER", "description": "Seconds to wait"}, - "title": {"type": "STRING", "description": "Window title for focus_window"}, - "description": {"type": "STRING", "description": "Element description"}, - "type": {"type": "STRING", "description": "Data type for random_data"}, - "field": {"type": "STRING", "description": "Field for user_data"}, - "clear_first": {"type": "BOOLEAN", "description": "Clear field before typing"}, - "path": {"type": "STRING", "description": "Save path for screenshot"}, - }, - "required": ["action"] - } - }, - { - "name": "game_updater", - "description": ( - "THE ONLY tool for ANY Steam or Epic Games request. " - "Use for: installing, downloading, updating games, listing installed games." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "update | install | list | download_status | schedule | cancel_schedule | schedule_status"}, - "platform": {"type": "STRING", "description": "steam | epic | both"}, - "game_name": {"type": "STRING", "description": "Game name"}, - "app_id": {"type": "STRING", "description": "Steam AppID"}, - "hour": {"type": "INTEGER", "description": "Hour for scheduled update 0-23"}, - "minute": {"type": "INTEGER", "description": "Minute for scheduled update 0-59"}, - "shutdown_when_done": {"type": "BOOLEAN", "description": "Shut down PC when done"}, - }, - "required": [] - } - }, - { - "name": "flight_finder", - "description": "Searches Google Flights and speaks the best options.", - "parameters": { - "type": "OBJECT", - "properties": { - "origin": {"type": "STRING", "description": "Departure city or airport code"}, - "destination": {"type": "STRING", "description": "Arrival city or airport code"}, - "date": {"type": "STRING", "description": "Departure date"}, - "return_date": {"type": "STRING", "description": "Return date for round trips"}, - "passengers": {"type": "INTEGER", "description": "Number of passengers"}, - "cabin": {"type": "STRING", "description": "economy | premium | business | first"}, - "save": {"type": "BOOLEAN", "description": "Save results to Notepad"}, - }, - "required": ["origin", "destination", "date"] - } - }, - { - "name": "shutdown_jarvis", - "description": ( - "Shuts down the assistant completely. " - "Call this when the user expresses intent to end the conversation, " - "close the assistant, say goodbye, or stop Jarvis." - ), - "parameters": {"type": "OBJECT", "properties": {}} - }, - { - "name": "file_processor", - "description": ( - "Processes any file that the user has uploaded or dropped onto the interface. " - "Supports: images, PDFs, Word docs, CSV/Excel, JSON, code files, audio, video, archives." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "file_path": {"type": "STRING", "description": "Full path to the uploaded file"}, - "action": {"type": "STRING", "description": "What to do with the file"}, - "instruction": {"type": "STRING", "description": "Free-form instruction"}, - "format": {"type": "STRING", "description": "Target format for conversion"}, - "width": {"type": "INTEGER", "description": "Target width for image resize"}, - "height": {"type": "INTEGER", "description": "Target height for image resize"}, - "scale": {"type": "NUMBER", "description": "Scale factor"}, - "quality": {"type": "INTEGER", "description": "Quality 1-100"}, - "start": {"type": "STRING", "description": "Start time for trim"}, - "end": {"type": "STRING", "description": "End time for trim"}, - "timestamp": {"type": "STRING", "description": "Timestamp for video frame extraction"}, - "column": {"type": "STRING", "description": "Column name for CSV filter/sort"}, - "value": {"type": "STRING", "description": "Filter value"}, - "condition": {"type": "STRING", "description": "Filter condition"}, - "ascending": {"type": "BOOLEAN", "description": "Sort order"}, - "save": {"type": "BOOLEAN", "description": "Save result to file"}, - "destination": {"type": "STRING", "description": "Output folder for archive extract"}, - }, - "required": [] - } - }, - { - "name": "save_memory", - "description": ( - "Save a personal fact about the user to permanent long-term memory. " - "MANDATORY: call this IMMEDIATELY (without asking) whenever the user states or corrects: " - "their name, age, city, job, school, language, nationality, a preference, a goal, or a relationship. " - "Examples: " - "'my name is Fatih' → (identity, name, Fatih) | " - "'not Travis, Fatih' → (identity, name, Fatih) | " - "'I am 22' → (identity, age, 22) | " - "'I live in Ankara' → (identity, city, Ankara) | " - "'I prefer dark mode' → (preferences, ui_theme, dark mode). " - "Call SILENTLY alongside your verbal reply — never announce that you are saving." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "category": { - "type": "STRING", - "description": ( - "identity (name/age/city/job/school/nationality) | " - "preferences (likes/dislikes/habits) | " - "projects (active work/goals) | " - "relationships (people in their life) | " - "wishes (future plans/wants) | " - "notes (anything else)" - ) - }, - "key": {"type": "STRING", "description": "Short snake_case key, e.g. 'name', 'age', 'favorite_color'"}, - "value": {"type": "STRING", "description": "Concise value in English"}, - }, - "required": ["category", "key", "value"] - } - }, -] - - -# --------------------------------------------------------------------------- -# Convert Gemini-style declarations to OpenAI/Ollama format -# --------------------------------------------------------------------------- - -_TYPE_MAP = { - "OBJECT": "object", "STRING": "string", "ARRAY": "array", - "INTEGER": "integer", "BOOLEAN": "boolean", "NUMBER": "number", -} - - -def _convert_type(t: str) -> str: - return _TYPE_MAP.get(t, t.lower()) if isinstance(t, str) else t - - -def _convert_props(props: dict) -> dict: - out = {} - for k, v in props.items(): - nv = dict(v) - if "type" in nv: - nv["type"] = _convert_type(nv["type"]) - if "items" in nv and isinstance(nv["items"], dict): - nv["items"] = {"type": _convert_type(nv["items"].get("type", "string"))} - out[k] = nv - return out - - -def _to_ollama_tools(decls: list) -> list: - tools = [] - for d in decls: - params = d.get("parameters", {}) - new_params: dict = { - "type": "object", - "properties": _convert_props(params.get("properties", {})), - } - req = params.get("required") - if req: - new_params["required"] = req - tools.append({ - "type": "function", - "function": { - "name": d["name"], - "description": d["description"], - "parameters": new_params, - }, - }) - return tools - - -OLLAMA_TOOLS = _to_ollama_tools(TOOL_DECLARATIONS) - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _load_config() -> dict: - try: - with open(API_CONFIG_PATH, encoding="utf-8") as f: - return json.load(f) - except Exception: - return {} - - -def _load_system_prompt() -> str: - try: - return PROMPT_PATH.read_text(encoding="utf-8") - except Exception: - return ( - "You are JARVIS, Tony Stark's AI assistant. " - "Be concise, direct, and always use the provided tools to complete tasks. " - "Never simulate or guess results — always call the appropriate tool." - ) - - -# --------------------------------------------------------------------------- -# Voice Activity Detection (used for Whisper listen loop) -# --------------------------------------------------------------------------- - -class _VADBuffer: - """Energy-based VAD: buffers audio until end of utterance.""" - - def __init__( - self, - sample_rate: int = 16_000, - silence_sec: float = 0.7, # silence after last word → send to STT - speech_thresh: float = 0.008, # RMS above this = speech (0.008 catches voice at 3-4 m; raise if mic picks up too much room noise) - silence_thresh: float = 0.004, # RMS below this = silence (half of speech_thresh — hysteresis prevents mid-sentence cuts) - min_speech_sec: float = 0.3, - max_speech_sec: float = 30.0, - ): - self._sr = sample_rate - self._sil_n = int(silence_sec * sample_rate) - self._speech_thresh = speech_thresh - self._sil_thresh = silence_thresh - self._min_n = int(min_speech_sec * sample_rate) - self._max_n = int(max_speech_sec * sample_rate) - self._buf: list[np.ndarray] = [] - self._in_spch = False - self._sil_cnt = 0 - def process(self, chunk: np.ndarray) -> np.ndarray | None: - """ - Feed one audio chunk (float32 mono). - Returns complete utterance when speech ends, otherwise None. - - Uses hysteresis thresholds so the detector doesn't flicker: - - speech starts when RMS > speech_thresh (0.008 = ~3-4 m range) - - speech ends only when RMS < silence_thresh (0.004 = half of start) - The gap between the two thresholds prevents mid-sentence cuts on - natural pauses and quiet consonants. - """ - rms = float(np.sqrt(np.mean(chunk ** 2))) - total_n = sum(len(c) for c in self._buf) - - if rms > self._speech_thresh: - self._in_spch = True - self._sil_cnt = 0 - self._buf.append(chunk.copy()) - elif self._in_spch: - self._buf.append(chunk.copy()) - if rms < self._sil_thresh: - self._sil_cnt += len(chunk) - - if self._sil_cnt >= self._sil_n or total_n >= self._max_n: - audio = np.concatenate(self._buf) - self._buf = [] - self._in_spch = False - self._sil_cnt = 0 - if len(audio) >= self._min_n: - return audio - return None - - -# --------------------------------------------------------------------------- -# JarvisLocal -# --------------------------------------------------------------------------- - -class JarvisLocal: - """ - Main assistant class. - Replaces JarvisLive (Gemini Live API) with: - STT (Whisper/Vosk) → Ollama LLM (tool calling) → TTS (Edge/Kokoro/ElevenLabs) - """ - - def __init__(self, ui: JarvisUI): - self.ui = ui - self._config = _load_config() - self._stt = None - self._tts = None - self._tts_ready = threading.Event() # set when TTS engine is loaded - self._speaking = False - self._speaking_lock = threading.Lock() - self._text_queue: queue.Queue = queue.Queue() - self._tts_queue: queue.Queue = queue.Queue() - self._conversation: list[dict] = [] - - self.ui.on_text_command = self._on_text_command - - # ------------------------------------------------------------------ - # System prompt - # ------------------------------------------------------------------ - - def _build_system_prompt(self) -> str: - # ── ORDER MATTERS for Ollama KV prefix caching ───────────────────── - # Ollama caches the KV attention state of any stable prompt prefix. - # By putting the STATIC JARVIS protocol text FIRST, Ollama reuses its - # cached KV for all those tokens on every request. Only the small - # dynamic tail (memory + time, ~50-80 tokens) needs re-evaluation. - # This turns a 17-second first-token into a sub-second one after warmup. - # - # Rule: static content first → semi-static memory middle → dynamic time LAST. - sys_p = _load_system_prompt() # static — never changes mid-session - memory = load_memory() - mem_str = format_memory_for_prompt(memory) # semi-static — changes only when user tells facts - now = datetime.now() - time_ctx = ( - f"[CURRENT DATE & TIME]\n" - f"Right now it is: {now.strftime('%A, %B %d, %Y — %I:%M %p')}\n" - f"Use this to calculate exact times for reminders." - ) - parts = [sys_p] - if mem_str: - parts.append(mem_str) - parts.append(time_ctx) - return "\n\n".join(parts) - - # ------------------------------------------------------------------ - # Speaking state & TTS - # ------------------------------------------------------------------ - - # ------------------------------------------------------------------ - # TTS queue worker — plays sentences sequentially, no overlaps - # ------------------------------------------------------------------ - - def _tts_worker(self) -> None: - # Block until TTS engine is loaded. Queued items are preserved - # and played immediately once loading completes — nothing is lost. - self._tts_ready.wait(timeout=120) - - while True: - text = self._tts_queue.get() - try: - if text and self._tts: - with self._speaking_lock: - self._speaking = True - self.ui.set_state("SPEAKING") - self._tts.speak(text) - except Exception as e: - print(f"[TTS] speak error: {e}") - finally: - self._tts_queue.task_done() - if self._tts_queue.empty(): - with self._speaking_lock: - self._speaking = False - if not self.ui.muted: - self.ui.set_state("LISTENING") - - def set_speaking(self, value: bool) -> None: - with self._speaking_lock: - self._speaking = value - if value: - self.ui.set_state("SPEAKING") - elif not self.ui.muted: - self.ui.set_state("LISTENING") - - def speak(self, text: str) -> None: - if not text or not self._tts: - return - with self._speaking_lock: - self._speaking = True - self._tts_queue.put(text) - - def speak_error(self, tool_name: str, error) -> None: - short = str(error)[:120] - self.ui.write_log(f"ERR: {tool_name} — {short}") - self.speak(f"{tool_name} encountered an error.") - - # ------------------------------------------------------------------ - # Live reconfigure (called when user clicks Apply in Configure panel) - # ------------------------------------------------------------------ - - def reconfigure(self, new_config: dict) -> None: - """Non-blocking: spawns a background thread to install + reload.""" - threading.Thread( - target=self._do_reconfigure, args=(new_config,), daemon=True - ).start() - - def _do_reconfigure(self, new_config: dict) -> None: - old_stt_engine = self._config.get("stt_engine", "whisper").lower() - old_llm_model = self._config.get("llm_model", "") - new_stt_engine = new_config.get("stt_engine", "whisper").lower() - self._config = new_config - - # Install any packages required by the new config - try: - from core.installer import install_for_config - install_for_config(new_config, log=self.ui.write_log) - except Exception as e: - self.ui.write_log(f"ERR: Dependency install — {e}") - - # TTS: always hot-reload (runs in queue worker, safe to swap) - try: - from core.tts import create_tts_player - self._tts = create_tts_player(new_config) - self._tts_ready.set() # ensure worker isn't blocked - self.ui.write_log("SYS: TTS reconfigured.") - except Exception as e: - self.ui.write_log(f"ERR: TTS reconfigure — {e}") - - # STT: hot-reload if same engine type; full restart needed if engine changed - if old_stt_engine == new_stt_engine: - try: - stt_language = new_config.get("stt_language", "auto") - if new_stt_engine == "vosk": - from core.stt import VoskSTT - self._stt = VoskSTT(new_config.get("vosk_model_path"), language=stt_language) - else: - from core.stt import WhisperSTT - self._stt = WhisperSTT(new_config.get("stt_model", "base"), language=stt_language) - self.ui.write_log("SYS: STT reconfigured.") - except Exception as e: - self.ui.write_log(f"ERR: STT reconfigure — {e}") - else: - self.ui.write_log("SYS: STT engine changed — restart required.") - - # LLM warmup if model changed - if new_config.get("llm_model", "") != old_llm_model: - self.ui.write_log("SYS: Warming up new LLM model…") - from core.llm_client import warmup_model - warmup_model() - self.ui.write_log("SYS: New LLM model ready.") - - if old_stt_engine == new_stt_engine: - self.speak("Configuration applied.") - else: - self.speak("LLM and TTS updated. Restart for speech engine change.") - - # ------------------------------------------------------------------ - # Text command (from UI input box) - # ------------------------------------------------------------------ - - def _on_text_command(self, text: str) -> None: - self._text_queue.put(text) - - # ------------------------------------------------------------------ - # Tool execution (routing unchanged from original) - # ------------------------------------------------------------------ - - def _execute_tool(self, name: str, args: dict) -> str: - print(f"[JARVIS] 🔧 {name} {args}") - self.ui.set_state("THINKING") - - # save_memory is handled silently - if name == "save_memory": - category = args.get("category", "notes") - key = args.get("key", "") - value = args.get("value", "") - if key and value: - update_memory({category: {key: {"value": value}}}) - print(f"[Memory] 💾 {category}/{key} = {value}") - if not self.ui.muted: - self.ui.set_state("LISTENING") - return "__SILENT__" - - result = "Done." - try: - if name == "open_app": - r = open_app(parameters=args, response=None, player=self.ui) - result = r or f"Opened {args.get('app_name')}." - - elif name == "weather_report": - r = weather_action(parameters=args, player=self.ui) - result = r or "Weather delivered." - - elif name == "browser_control": - r = browser_control(parameters=args, player=self.ui) - result = r or "Done." - - elif name == "file_controller": - r = file_controller(parameters=args, player=self.ui) - result = r or "Done." - - elif name == "send_message": - r = send_message(parameters=args, response=None, player=self.ui, session_memory=None) - result = r or f"Message sent to {args.get('receiver')}." - - elif name == "reminder": - r = reminder(parameters=args, response=None, player=self.ui) - result = r or "Reminder set." - - elif name == "youtube_video": - r = youtube_video(parameters=args, response=None, player=self.ui) - result = r or "Done." - - elif name == "screen_process": - # Synchronous call — returns analysis text which the LLM can speak - r = screen_process(parameters=args, response=None, player=self.ui, session_memory=None) - result = r if isinstance(r, str) and r else "Screen analyzed." - - elif name == "computer_settings": - r = computer_settings(parameters=args, response=None, player=self.ui) - result = r or "Done." - - elif name == "desktop_control": - r = desktop_control(parameters=args, player=self.ui) - result = r or "Done." - - elif name == "code_helper": - r = code_helper(parameters=args, player=self.ui, speak=self.speak) - result = r or "Done." - - elif name == "dev_agent": - r = dev_agent(parameters=args, player=self.ui, speak=self.speak) - result = r or "Done." - - elif name == "agent_task": - from agent.task_queue import get_queue, TaskPriority - priority_map = { - "low": TaskPriority.LOW, - "normal": TaskPriority.NORMAL, - "high": TaskPriority.HIGH, - } - priority = priority_map.get( - args.get("priority", "normal").lower(), TaskPriority.NORMAL - ) - task_id = get_queue().submit( - goal=args.get("goal", ""), priority=priority, speak=self.speak - ) - result = f"Task started (ID: {task_id})." - - elif name == "web_search": - r = web_search_action(parameters=args, player=self.ui) - result = r or "Done." - - elif name == "file_processor": - if not args.get("file_path") and self.ui.current_file: - args["file_path"] = self.ui.current_file - r = file_processor(parameters=args, player=self.ui, speak=self.speak) - result = r or "Done." - - elif name == "computer_control": - r = computer_control(parameters=args, player=self.ui) - result = r or "Done." - - elif name == "game_updater": - r = game_updater(parameters=args, player=self.ui, speak=self.speak) - result = r or "Done." - - elif name == "flight_finder": - r = flight_finder(parameters=args, player=self.ui) - result = r or "Done." - - elif name == "shutdown_jarvis": - self.ui.write_log("SYS: Shutdown requested.") - - def _shutdown(): - import time, os - self.speak("Goodbye.") - time.sleep(2.5) - os._exit(0) - - threading.Thread(target=_shutdown, daemon=True).start() - return "Shutting down." - - else: - result = f"Unknown tool: {name}" - - except Exception as e: - result = f"Tool '{name}' failed: {e}" - traceback.print_exc() - self.speak_error(name, e) - - if not self.ui.muted: - self.ui.set_state("LISTENING") - - print(f"[JARVIS] 📤 {name} → {str(result)[:80]}") - return result - - # ------------------------------------------------------------------ - # LLM processing loop - # ------------------------------------------------------------------ - - def _process_message(self, user_text: str) -> None: - """ - Full turn: user_text → LLM stream → TTS (overlapped) → tool execution - - Streaming TTS: sentence events are piped to the TTS queue AS they - arrive from the LLM, so Kokoro starts synthesising sentence 1 while - the LLM is still generating sentence 2. This cuts perceived latency - from (LLM_total + TTS_total) down to roughly max(LLM_total, TTS_total). - - Tool-call responses never emit sentence events, so the TTS overlap - only kicks in for pure conversational replies — which is exactly when - it matters most. - """ - self.ui.set_state("THINKING") - self.ui.write_log(f"You: {user_text}") - - self._conversation.append({"role": "user", "content": user_text}) - - MAX_HISTORY = 10 - if len(self._conversation) > MAX_HISTORY: - self._conversation = self._conversation[-MAX_HISTORY:] - - messages = [ - {"role": "system", "content": self._build_system_prompt()} - ] + list(self._conversation) - - # Tools whose output needs a second LLM round to summarise/interpret. - # Everything else returns a user-ready string → speak directly. - _NEEDS_LLM_ROUND = {"web_search", "screen_process", "agent_task"} - - MAX_TOOL_ROUNDS = 6 - for _round in range(MAX_TOOL_ROUNDS): - final_content = "" - final_tool_calls: list = [] - # Sentences already queued to TTS during streaming (may be empty - # for tool-call rounds where the model emits no content). - _streamed: list[str] = [] - - try: - for event in call_llm_stream(messages, OLLAMA_TOOLS): - if event["type"] == "sentence": - # ── Overlap TTS with LLM generation ───────────────── - # Queue this sentence immediately; the TTS worker - # synthesises it while the LLM is still generating - # the next one. - _streamed.append(event["text"]) - self.speak(event["text"]) - elif event["type"] == "done": - final_content = event["content"] - final_tool_calls = event["tool_calls"] - except RuntimeError as e: - self.speak_error("LLM", e) - return - - # ── No tool calls: pure conversational reply ───────────────────── - if not final_tool_calls: - if _streamed: - # Sentences already queued to TTS — just update history/log. - assistant_msg = {"role": "assistant", "content": final_content} - messages.append(assistant_msg) - self._conversation.append(assistant_msg) - self.ui.write_log(f"Jarvis: {final_content}") - elif final_content: - # Very short response (no sentence boundary) — speak now. - assistant_msg = {"role": "assistant", "content": final_content} - messages.append(assistant_msg) - self._conversation.append(assistant_msg) - self.ui.write_log(f"Jarvis: {final_content}") - self.speak(final_content) - break - - # ── Tool calls present ──────────────────────────────────────────── - assistant_msg = { - "role": "assistant", - "content": final_content or "", - "tool_calls": final_tool_calls, - } - messages.append(assistant_msg) - self._conversation.append(assistant_msg) - - # ── Fast path: save_memory + verbal content in same round ──────── - _only_memory = all( - tc.get("function", {}).get("name") == "save_memory" - for tc in final_tool_calls - ) - if _only_memory and final_content: - for tc in final_tool_calls: - fn = tc.get("function", {}) - targs = fn.get("arguments", {}) - if isinstance(targs, str): - try: - targs = json.loads(targs) - except Exception: - targs = {} - self._execute_tool("save_memory", targs) - assistant_msg2 = {"role": "assistant", "content": final_content} - messages.append(assistant_msg2) - self._conversation.append(assistant_msg2) - self.ui.write_log(f"Jarvis: {final_content}") - if not _streamed: - self.speak(final_content) - break - - # ── Execute tools ───────────────────────────────────────────────── - all_silent = True - _tool_results: list[tuple[str, str]] = [] - - for tc in final_tool_calls: - fn = tc.get("function", {}) - tname = fn.get("name", "") - targs = fn.get("arguments", {}) - if isinstance(targs, str): - try: - targs = json.loads(targs) - except Exception: - targs = {} - - tc_id = tc.get("id", "") - self.ui.write_log(f"SYS: ▶ {tname}") - result = self._execute_tool(tname, targs) - - if result != "__SILENT__": - all_silent = False - _tool_results.append((tname, result)) - - tool_msg: dict = { - "role": "tool", - "content": "Done." if result == "__SILENT__" else str(result), - } - if tc_id: - tool_msg["tool_call_id"] = tc_id - - messages.append(tool_msg) - self._conversation.append(tool_msg) - - # ── Fast-ack: every call was save_memory (silent) ──────────────── - if all_silent: - _saved_name: str | None = None - for _tc in final_tool_calls: - _fn = _tc.get("function", {}) - if _fn.get("name") == "save_memory": - _a = _fn.get("arguments", {}) - if isinstance(_a, str): - try: - _a = json.loads(_a) - except Exception: - _a = {} - if isinstance(_a, dict) and _a.get("key") == "name" and _a.get("value"): - _saved_name = str(_a["value"]) - break - _ack = f"Got it, {_saved_name}." if _saved_name else "Noted." - _amsg = {"role": "assistant", "content": _ack} - messages.append(_amsg) - self._conversation.append(_amsg) - self.ui.write_log(f"Jarvis: {_ack}") - self.speak(_ack) - break - - # ── Direct-result: speak tool output, skip LLM round ──────────── - if _tool_results and not any(n in _NEEDS_LLM_ROUND for n, _ in _tool_results): - _, _reply = _tool_results[-1] - _amsg = {"role": "assistant", "content": _reply} - messages.append(_amsg) - self._conversation.append(_amsg) - self.ui.write_log(f"Jarvis: {_reply}") - self.speak(_reply) - break - - if not self.ui.muted: - self.ui.set_state("LISTENING") - - # ------------------------------------------------------------------ - # STT listening loops - # ------------------------------------------------------------------ - - def _listen_whisper(self) -> None: - """Mic → VAD → Whisper → LLM loop.""" - vad = _VADBuffer() - q: queue.Queue = queue.Queue(maxsize=200) - - def callback(indata, frames, time_info, status): - with self._speaking_lock: - is_speaking = self._speaking - if not is_speaking and not self.ui.muted: - try: - q.put_nowait(indata.copy()) - except queue.Full: - pass - - try: - with sd.InputStream( - samplerate=SAMPLE_RATE_IN, - channels=CHANNELS, - dtype="float32", - blocksize=BLOCK_SIZE, - callback=callback, - ): - self.ui.write_log("SYS: Mic active (Whisper STT).") - while True: - try: - chunk = q.get(timeout=0.1) - audio = vad.process(chunk.flatten()) - if audio is not None: - self.ui.set_state("THINKING") - text = self._stt.transcribe(audio) - if text.strip(): - self._process_message(text) - except queue.Empty: - pass - except Exception as e: - print(f"[STT-Whisper] Mic error: {e}") - traceback.print_exc() - self.ui.write_log( - f"ERR: Microphone — {e}\n" - " Check: microphone plugged in, OS privacy permissions granted, " - "correct audio device selected in system settings." - ) - - def _listen_vosk(self) -> None: - """Mic → Vosk streaming → LLM loop.""" - q: queue.Queue = queue.Queue(maxsize=200) - - def callback(indata, frames, time_info, status): - with self._speaking_lock: - is_speaking = self._speaking - if not is_speaking and not self.ui.muted: - try: - q.put_nowait(indata.copy()) - except queue.Full: - pass - - try: - with sd.InputStream( - samplerate=SAMPLE_RATE_IN, - channels=CHANNELS, - dtype="int16", - blocksize=4096, - callback=callback, - ): - self.ui.write_log("SYS: Mic active (Vosk STT).") - while True: - try: - chunk = q.get(timeout=0.1) - text, is_final = self._stt.process_chunk(chunk.tobytes()) - if is_final and text.strip(): - self._process_message(text) - except queue.Empty: - pass - except Exception as e: - print(f"[STT-Vosk] Mic error: {e}") - traceback.print_exc() - self.ui.write_log( - f"ERR: Microphone — {e}\n" - " Check: microphone plugged in, OS privacy permissions granted, " - "correct audio device selected in system settings." - ) - - # ------------------------------------------------------------------ - # Text command loop (UI input box) - # ------------------------------------------------------------------ - - def _text_command_loop(self) -> None: - while True: - try: - text = self._text_queue.get(timeout=0.5) - if text.strip(): - self._process_message(text) - except queue.Empty: - pass - - # ------------------------------------------------------------------ - # Entry point - # ------------------------------------------------------------------ - - def run(self) -> None: - """ - Startup strategy — optimised for minimum time-to-interactive: - - 1. LLM warmup + STT load → parallel, fast (~3s) - 2. TTS load → parallel, slow (~20s for Kokoro) - 3. Wait only for (1) → go online immediately - 4. TTS finishes in BG → queued speech plays automatically - """ - try: - self.ui.on_reconfigure = self.reconfigure - - # ── Ollama ──────────────────────────────────────────────────── - from core.llm_client import ensure_ollama_running, warmup_model, check_model_available - self.ui.write_log("SYS: Checking Ollama…") - if ensure_ollama_running(): - self.ui.write_log("SYS: Ollama OK.") - check_model_available(log=self.ui.write_log) - else: - self.ui.write_log("ERR: Ollama unavailable — run: ollama serve") - - # ── Config ──────────────────────────────────────────────────── - stt_engine = self._config.get("stt_engine", "whisper").lower() - stt_language = self._config.get("stt_language", "auto") - stt_model = self._config.get("stt_model", "base") - tts_engine = self._config.get("tts_engine", "edgetts").lower() - - # ── Startup progress panel ──────────────────────────────────── - self.ui.show_startup_panel() - - _warmup_done = threading.Event() - _stt_done = threading.Event() - - # ── LLM warmup thread ───────────────────────────────────────── - def _do_warmup(): - try: - # Pass the STATIC system prompt so Ollama evaluates and caches - # its KV state during startup. Real requests start with the same - # static prefix → Ollama reuses cached KV → first token <1 s - # instead of the ~17 s it takes to re-evaluate 300+ tokens cold. - static_prompt = _load_system_prompt() - ok = warmup_model(system_prompt=static_prompt) - if ok: - self.ui.write_log("SYS: LLM ready.") - else: - # warmup() returned False — check if model is missing - from core.llm_client import check_model_available - if not check_model_available(log=self.ui.write_log): - pass # warning already written by check_model_available - else: - self.ui.write_log("WRN: LLM warmup failed — is Ollama running?") - # GPU usage tip (cross-platform, non-blocking) - try: - import torch - if torch.cuda.is_available(): - self.ui.write_log(f"SYS: GPU detected — {torch.cuda.get_device_name(0)}") - else: - self.ui.write_log( - "WRN: CUDA not detected — LLM running on CPU (slow). " - "For GPU speed install CUDA PyTorch: " - "pip install torch --index-url https://download.pytorch.org/whl/cu118" - ) - except Exception: - pass # torch not installed or not applicable - self.ui.mark_startup_ready("llm") - except Exception as e: - self.ui.write_log(f"ERR: LLM warmup — {e}") - self.ui.mark_startup_ready("llm", error=True) - finally: - _warmup_done.set() - - # ── STT load thread ─────────────────────────────────────────── - def _do_stt(): - try: - self.ui.write_log(f"SYS: Loading {stt_engine.upper()} STT…") - if stt_engine == "vosk": - from core.stt import VoskSTT - self._stt = VoskSTT( - self._config.get("vosk_model_path"), - language=stt_language, - ) - else: - from core.stt import WhisperSTT - self._stt = WhisperSTT(stt_model, language=stt_language) - self.ui.write_log("SYS: STT ready.") - self.ui.mark_startup_ready("stt") - except Exception as e: - self.ui.write_log(f"ERR: STT — {e}") - self.ui.mark_startup_ready("stt", error=True) - finally: - _stt_done.set() - - # ── TTS load thread — does NOT block going online ───────────── - def _do_tts(): - try: - self.ui.write_log(f"SYS: Loading {tts_engine.upper()} TTS…") - if tts_engine == "kokoro": - self.ui.write_log("SYS: Kokoro — loading model + compiling JIT…") - from core.tts import create_tts_player - self._tts = create_tts_player(self._config) - self._tts_ready.set() # unblock _tts_worker - self.ui.write_log("SYS: TTS ready.") - self.ui.mark_startup_ready("tts") - self.ui.set_startup_status("● All systems ready.") - self.ui.hide_startup_panel() - self.speak("Jarvis fully online.") - except Exception as e: - import traceback as _tb; _tb.print_exc() - self.ui.write_log(f"ERR: TTS — {e}") - self.ui.mark_startup_ready("tts", error=True) - self._tts_ready.set() - - # Launch all three simultaneously - self.ui.write_log("SYS: Loading systems in parallel…") - threading.Thread(target=_do_warmup, daemon=True).start() - threading.Thread(target=_do_stt, daemon=True).start() - threading.Thread(target=_do_tts, daemon=True).start() - - # ── Wait ONLY for STT + LLM (fast) ──────────────────────────── - _warmup_done.wait(timeout=60) - _stt_done.wait(timeout=60) - - # ── Go online immediately ────────────────────────────────────── - self.ui.write_log("SYS: JARVIS online.") - self.ui.set_state("LISTENING") - self.ui.set_startup_status("● JARVIS online · Voice loading in background…") - - threading.Thread(target=self._tts_worker, daemon=True).start() - threading.Thread(target=self._text_command_loop, daemon=True).start() - - # STT loop — blocks this thread forever - if stt_engine == "vosk": - self._listen_vosk() - else: - self._listen_whisper() - - except Exception as e: - self.ui.write_log(f"ERR: Init failed — {e}") - traceback.print_exc() - - -# --------------------------------------------------------------------------- -# Entry -# --------------------------------------------------------------------------- - -def main() -> None: - # ── Pre-import torch in background immediately ───────────────────────── - # By the time the TTS thread starts (~5s from now), torch will already - # be in sys.modules — removing it from the TTS critical path entirely. - def _preload_torch(): - try: - import torch # noqa: F401 (side-effect import only) - except Exception: - pass - threading.Thread(target=_preload_torch, daemon=True).start() - # ─────────────────────────────────────────────────────────────────────── - - ui = JarvisUI("face.png") - - def runner(): - # 1. Wait until the user completes the setup overlay (first run) - # or config already exists (subsequent runs). - ui.wait_for_api_key() - - # 2. Install any missing engine packages before loading engines. - # Progress is streamed to the log panel in real time. - ui.write_log("SYS: Checking dependencies…") - cfg = _load_config() - _install_done = threading.Event() - - def _do_install(): - try: - from core.installer import install_for_config - install_for_config(cfg, log=ui.write_log) - except Exception as e: - ui.write_log(f"ERR: Dependency install — {e}") - finally: - _install_done.set() - - threading.Thread(target=_do_install, daemon=True).start() - _install_done.wait() # blocks runner thread; UI remains responsive - - # 3. Start the assistant (loads STT / TTS / LLM). - jarvis = JarvisLocal(ui) - try: - jarvis.run() - except KeyboardInterrupt: - print("\n[MARK XL] Shutting down…") - - threading.Thread(target=runner, daemon=True).start() - ui.root.mainloop() - -if __name__ == "__main__": - main() +import asyncio +import re +import threading +import json +import sys +import traceback +from datetime import datetime +from pathlib import Path + +import sounddevice as sd +from google import genai +from google.genai import types +from ui import JarvisUI +from memory.memory_manager import ( + load_memory, update_memory, format_memory_for_prompt, +) + +from actions.file_processor import file_processor +from actions.flight_finder import flight_finder +from actions.open_app import open_app +from actions.weather_report import weather_action +from actions.send_message import send_message +from actions.reminder import reminder +from actions.computer_settings import computer_settings +from actions.screen_processor import screen_process +from actions.youtube_video import youtube_video +from actions.desktop import desktop_control +from actions.browser_control import browser_control +from actions.file_controller import file_controller +from actions.code_helper import code_helper +from actions.dev_agent import dev_agent +from actions.web_search import web_search as web_search_action +from actions.computer_control import computer_control +from actions.game_updater import game_updater + + +def get_base_dir(): + if getattr(sys, "frozen", False): + return Path(sys.executable).parent + return Path(__file__).resolve().parent + + +BASE_DIR = get_base_dir() +API_CONFIG_PATH = BASE_DIR / "config" / "api_keys.json" +PROMPT_PATH = BASE_DIR / "core" / "prompt.txt" +LIVE_MODEL = "models/gemini-2.5-flash-native-audio-preview-12-2025" +CHANNELS = 1 +SEND_SAMPLE_RATE = 16000 +RECEIVE_SAMPLE_RATE = 24000 +CHUNK_SIZE = 1024 + +def _get_api_key() -> str: + with open(API_CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f)["gemini_api_key"] + + +def _load_system_prompt() -> str: + try: + return PROMPT_PATH.read_text(encoding="utf-8") + except Exception: + return ( + "You are JARVIS, Tony Stark's AI assistant. " + "Be concise, direct, and always use the provided tools to complete tasks. " + "Never simulate or guess results — always call the appropriate tool." + ) + +_CTRL_RE = re.compile(r"", re.IGNORECASE) + +def _clean_transcript(text: str) -> str: + text = _CTRL_RE.sub("", text) + text = re.sub(r"[\x00-\x08\x0b-\x1f]", "", text) + return text.strip() + +TOOL_DECLARATIONS = [ + { + "name": "open_app", + "description": ( + "Opens any application on the computer. " + "Use this whenever the user asks to open, launch, or start any app, " + "website, or program. Always call this tool — never just say you opened it." + ), + "parameters": { + "type": "OBJECT", + "properties": { + "app_name": { + "type": "STRING", + "description": "Exact name of the application (e.g. 'WhatsApp', 'Chrome', 'Spotify')" + } + }, + "required": ["app_name"] + } + }, + { + "name": "web_search", + "description": "Searches the web for any information.", + "parameters": { + "type": "OBJECT", + "properties": { + "query": {"type": "STRING", "description": "Search query"}, + "mode": {"type": "STRING", "description": "search (default) or compare"}, + "items": {"type": "ARRAY", "items": {"type": "STRING"}, "description": "Items to compare"}, + "aspect": {"type": "STRING", "description": "price | specs | reviews"} + }, + "required": ["query"] + } + }, + { + "name": "weather_report", + "description": "Gives the weather report to user", + "parameters": { + "type": "OBJECT", + "properties": { + "city": {"type": "STRING", "description": "City name"} + }, + "required": ["city"] + } + }, + { + "name": "send_message", + "description": "Sends a text message via WhatsApp, Telegram, or other messaging platform.", + "parameters": { + "type": "OBJECT", + "properties": { + "receiver": {"type": "STRING", "description": "Recipient contact name"}, + "message_text": {"type": "STRING", "description": "The message to send"}, + "platform": {"type": "STRING", "description": "Platform: WhatsApp, Telegram, etc."} + }, + "required": ["receiver", "message_text", "platform"] + } + }, + { + "name": "reminder", + "description": "Sets a timed reminder using Task Scheduler.", + "parameters": { + "type": "OBJECT", + "properties": { + "date": {"type": "STRING", "description": "Date in YYYY-MM-DD format"}, + "time": {"type": "STRING", "description": "Time in HH:MM format (24h)"}, + "message": {"type": "STRING", "description": "Reminder message text"} + }, + "required": ["date", "time", "message"] + } + }, + { + "name": "youtube_video", + "description": ( + "Controls YouTube. Use for: playing videos, summarizing a video's content, " + "getting video info, or showing trending videos." + ), + "parameters": { + "type": "OBJECT", + "properties": { + "action": {"type": "STRING", "description": "play | summarize | get_info | trending (default: play)"}, + "query": {"type": "STRING", "description": "Search query for play action"}, + "save": {"type": "BOOLEAN", "description": "Save summary to Notepad (summarize only)"}, + "region": {"type": "STRING", "description": "Country code for trending e.g. TR, US"}, + "url": {"type": "STRING", "description": "Video URL for get_info action"}, + }, + "required": [] + } + }, + { + "name": "screen_process", + "description": ( + "Captures and analyzes the screen or webcam image. " + "MUST be called when user asks what is on screen, what you see, " + "analyze my screen, look at camera, etc. " + "You have NO visual ability without this tool. " + "After calling this tool, stay SILENT — the vision module speaks directly." + ), + "parameters": { + "type": "OBJECT", + "properties": { + "angle": {"type": "STRING", "description": "'screen' to capture display, 'camera' for webcam. Default: 'screen'"}, + "text": {"type": "STRING", "description": "The question or instruction about the captured image"} + }, + "required": ["text"] + } + }, + { + "name": "computer_settings", + "description": ( + "Controls the computer: volume, brightness, window management, keyboard shortcuts, " + "typing text on screen, closing apps, fullscreen, dark mode, WiFi, restart, shutdown, " + "scrolling, tab management, zoom, screenshots, lock screen, refresh/reload page. " + "Use for ANY single computer control command." + ), + "parameters": { + "type": "OBJECT", + "properties": { + "action": {"type": "STRING", "description": "The action to perform"}, + "description": {"type": "STRING", "description": "Natural language description of what to do"}, + "value": {"type": "STRING", "description": "Optional value: volume level, text to type, etc."} + }, + "required": [] + } + }, + { + "name": "browser_control", + "description": ( + "Controls any web browser. Use for: opening websites, searching the web, " + "clicking elements, filling forms, scrolling, screenshots, navigation, any web-based task. " + "Always pass the 'browser' parameter when the user specifies a browser (e.g. 'open in Edge', " + "'use Firefox', 'open Chrome'). Multiple browsers can run simultaneously." + ), + "parameters": { + "type": "OBJECT", + "properties": { + "action": {"type": "STRING", "description": "go_to | search | click | type | scroll | fill_form | smart_click | smart_type | get_text | get_url | press | new_tab | close_tab | screenshot | back | forward | reload | switch | list_browsers | close | close_all"}, + "browser": {"type": "STRING", "description": "Target browser: chrome | edge | firefox | opera | operagx | brave | vivaldi | safari. Omit to use the currently active browser."}, + "url": {"type": "STRING", "description": "URL for go_to / new_tab action"}, + "query": {"type": "STRING", "description": "Search query for search action"}, + "engine": {"type": "STRING", "description": "Search engine: google | bing | duckduckgo | yandex (default: google)"}, + "selector": {"type": "STRING", "description": "CSS selector for click/type"}, + "text": {"type": "STRING", "description": "Text to click or type"}, + "description": {"type": "STRING", "description": "Element description for smart_click/smart_type"}, + "direction": {"type": "STRING", "description": "up | down for scroll"}, + "amount": {"type": "INTEGER", "description": "Scroll amount in pixels (default: 500)"}, + "key": {"type": "STRING", "description": "Key name for press action (e.g. Enter, Escape, F5)"}, + "path": {"type": "STRING", "description": "Save path for screenshot"}, + "incognito": {"type": "BOOLEAN", "description": "Open in private/incognito mode"}, + "clear_first": {"type": "BOOLEAN", "description": "Clear field before typing (default: true)"}, + }, + "required": ["action"] + } + }, + { + "name": "file_controller", + "description": "Manages files and folders: list, create, delete, move, copy, rename, read, write, find, disk usage.", + "parameters": { + "type": "OBJECT", + "properties": { + "action": {"type": "STRING", "description": "list | create_file | create_folder | delete | move | copy | rename | read | write | find | largest | disk_usage | organize_desktop | info"}, + "path": {"type": "STRING", "description": "File/folder path or shortcut: desktop, downloads, documents, home"}, + "destination": {"type": "STRING", "description": "Destination path for move/copy"}, + "new_name": {"type": "STRING", "description": "New name for rename"}, + "content": {"type": "STRING", "description": "Content for create_file/write"}, + "name": {"type": "STRING", "description": "File name to search for"}, + "extension": {"type": "STRING", "description": "File extension to search (e.g. .pdf)"}, + "count": {"type": "INTEGER", "description": "Number of results for largest"}, + }, + "required": ["action"] + } + }, + { + "name": "desktop_control", + "description": "Controls the desktop: wallpaper, organize, clean, list, stats.", + "parameters": { + "type": "OBJECT", + "properties": { + "action": {"type": "STRING", "description": "wallpaper | wallpaper_url | organize | clean | list | stats | task"}, + "path": {"type": "STRING", "description": "Image path for wallpaper"}, + "url": {"type": "STRING", "description": "Image URL for wallpaper_url"}, + "mode": {"type": "STRING", "description": "by_type or by_date for organize"}, + "task": {"type": "STRING", "description": "Natural language desktop task"}, + }, + "required": ["action"] + } + }, + { + "name": "code_helper", + "description": "Writes, edits, explains, runs, or builds code files.", + "parameters": { + "type": "OBJECT", + "properties": { + "action": {"type": "STRING", "description": "write | edit | explain | run | build | auto (default: auto)"}, + "description": {"type": "STRING", "description": "What the code should do or what change to make"}, + "language": {"type": "STRING", "description": "Programming language (default: python)"}, + "output_path": {"type": "STRING", "description": "Where to save the file"}, + "file_path": {"type": "STRING", "description": "Path to existing file for edit/explain/run/build"}, + "code": {"type": "STRING", "description": "Raw code string for explain"}, + "args": {"type": "STRING", "description": "CLI arguments for run/build"}, + "timeout": {"type": "INTEGER", "description": "Execution timeout in seconds (default: 30)"}, + }, + "required": ["action"] + } + }, + { + "name": "dev_agent", + "description": "Builds complete multi-file projects from scratch: plans, writes files, installs deps, opens VSCode, runs and fixes errors.", + "parameters": { + "type": "OBJECT", + "properties": { + "description": {"type": "STRING", "description": "What the project should do"}, + "language": {"type": "STRING", "description": "Programming language (default: python)"}, + "project_name": {"type": "STRING", "description": "Optional project folder name"}, + "timeout": {"type": "INTEGER", "description": "Run timeout in seconds (default: 30)"}, + }, + "required": ["description"] + } + }, + { + "name": "computer_control", + "description": "Direct computer control: type, click, hotkeys, scroll, move mouse, screenshots, find elements on screen.", + "parameters": { + "type": "OBJECT", + "properties": { + "action": {"type": "STRING", "description": "type | smart_type | click | double_click | right_click | hotkey | press | scroll | move | copy | paste | screenshot | wait | clear_field | focus_window | screen_find | screen_click | random_data | user_data"}, + "text": {"type": "STRING", "description": "Text to type or paste"}, + "x": {"type": "INTEGER", "description": "X coordinate"}, + "y": {"type": "INTEGER", "description": "Y coordinate"}, + "keys": {"type": "STRING", "description": "Key combination e.g. 'ctrl+c'"}, + "key": {"type": "STRING", "description": "Single key e.g. 'enter'"}, + "direction": {"type": "STRING", "description": "up | down | left | right"}, + "amount": {"type": "INTEGER", "description": "Scroll amount (default: 3)"}, + "seconds": {"type": "NUMBER", "description": "Seconds to wait"}, + "title": {"type": "STRING", "description": "Window title for focus_window"}, + "description": {"type": "STRING", "description": "Element description for screen_find/screen_click"}, + "type": {"type": "STRING", "description": "Data type for random_data"}, + "field": {"type": "STRING", "description": "Field for user_data: name|email|city"}, + "clear_first": {"type": "BOOLEAN", "description": "Clear field before typing (default: true)"}, + "path": {"type": "STRING", "description": "Save path for screenshot"}, + }, + "required": ["action"] + } + }, + { + "name": "game_updater", + "description": ( + "THE ONLY tool for ANY Steam or Epic Games request. " + "Use for: installing, downloading, updating games, listing installed games, " + "checking download status, scheduling updates. " + "ALWAYS call directly for any Steam/Epic/game request. " + "NEVER use browser_control or web_search for Steam/Epic." + ), + "parameters": { + "type": "OBJECT", + "properties": { + "action": {"type": "STRING", "description": "update | install | list | download_status | schedule | cancel_schedule | schedule_status (default: update)"}, + "platform": {"type": "STRING", "description": "steam | epic | both (default: both)"}, + "game_name": {"type": "STRING", "description": "Game name (partial match supported)"}, + "app_id": {"type": "STRING", "description": "Steam AppID for install (optional)"}, + "hour": {"type": "INTEGER", "description": "Hour for scheduled update 0-23 (default: 3)"}, + "minute": {"type": "INTEGER", "description": "Minute for scheduled update 0-59 (default: 0)"}, + "shutdown_when_done": {"type": "BOOLEAN", "description": "Shut down PC when download finishes"}, + }, + "required": [] + } + }, + { + "name": "flight_finder", + "description": "Searches Google Flights and speaks the best options.", + "parameters": { + "type": "OBJECT", + "properties": { + "origin": {"type": "STRING", "description": "Departure city or airport code"}, + "destination": {"type": "STRING", "description": "Arrival city or airport code"}, + "date": {"type": "STRING", "description": "Departure date (any format)"}, + "return_date": {"type": "STRING", "description": "Return date for round trips"}, + "passengers": {"type": "INTEGER", "description": "Number of passengers (default: 1)"}, + "cabin": {"type": "STRING", "description": "economy | premium | business | first"}, + "save": {"type": "BOOLEAN", "description": "Save results to Notepad"}, + }, + "required": ["origin", "destination", "date"] + } + }, + { + "name": "shutdown_jarvis", + "description": ( + "Shuts down the assistant completely. " + "Call this when the user expresses intent to end the conversation, " + "close the assistant, say goodbye, or stop Jarvis. " + "The user can say this in ANY language." + ), + "parameters": { + "type": "OBJECT", + "properties": {}, + } + }, + { + "name": "file_processor", + "description": ( + "Processes any file that the user has uploaded or dropped onto the interface. " + "Use this when the user refers to an uploaded file and wants an action on it. " + "Supports: images (describe/ocr/resize/compress/convert), " + "PDFs (summarize/extract_text/to_word), " + "Word docs & text files (summarize/fix/reformat/translate), " + "CSV/Excel (analyze/stats/filter/sort/convert), " + "JSON/XML (validate/format/analyze), " + "code files (explain/review/fix/optimize/run/document/test), " + "audio (transcribe/trim/convert/info), " + "video (trim/extract_audio/extract_frame/compress/transcribe/info), " + "archives (list/extract), " + "presentations (summarize/extract_text). " + "ALWAYS call this tool when a file has been uploaded and the user gives a command about it. " + "If the user's command is ambiguous, pick the most logical action for that file type." + ), + "parameters": { + "type": "OBJECT", + "properties": { + "file_path": { + "type": "STRING", + "description": "Full path to the uploaded file. Leave empty to use the currently uploaded file." + }, + "action": { + "type": "STRING", + "description": ( + "What to do with the file. Examples by type:\n" + "image: describe | ocr | resize | compress | convert | info\n" + "pdf: summarize | extract_text | to_word | info\n" + "docx/txt: summarize | fix | reformat | translate_hint | word_count | to_bullet\n" + "csv/excel: analyze | stats | filter | sort | convert | info\n" + "json: validate | format | analyze | to_csv\n" + "code: explain | review | fix | optimize | run | document | test\n" + "audio: transcribe | trim | convert | info\n" + "video: trim | extract_audio | extract_frame | compress | transcribe | info | convert\n" + "archive: list | extract\n" + "pptx: summarize | extract_text | analyze" + ) + }, + "instruction": { + "type": "STRING", + "description": "Free-form instruction if action doesn't cover it. E.g. 'translate this to Turkish', 'find all email addresses'" + }, + "format": { + "type": "STRING", + "description": "Target format for conversion. E.g. 'mp3', 'pdf', 'csv', 'png'" + }, + "width": {"type": "INTEGER", "description": "Target width for image resize"}, + "height": {"type": "INTEGER", "description": "Target height for image resize"}, + "scale": {"type": "NUMBER", "description": "Scale factor for image resize (e.g. 0.5)"}, + "quality": {"type": "INTEGER", "description": "Quality 1-100 for image/video compress"}, + "start": {"type": "STRING", "description": "Start time for trim: seconds or HH:MM:SS"}, + "end": {"type": "STRING", "description": "End time for trim: seconds or HH:MM:SS"}, + "timestamp": {"type": "STRING", "description": "Timestamp for video frame extraction HH:MM:SS"}, + "column": {"type": "STRING", "description": "Column name for CSV filter/sort"}, + "value": {"type": "STRING", "description": "Filter value for CSV filter"}, + "condition": {"type": "STRING", "description": "Filter condition: equals|contains|gt|lt"}, + "ascending": {"type": "BOOLEAN", "description": "Sort order for CSV sort (default: true)"}, + "save": {"type": "BOOLEAN", "description": "Save result to file (default: true)"}, + "destination": {"type": "STRING", "description": "Output folder for archive extract"}, + }, + "required": [] + } +}, + { + "name": "save_memory", + "description": ( + "Save an important personal fact about the user to long-term memory. " + "Call this silently whenever the user reveals something worth remembering: " + "name, age, city, job, preferences, hobbies, relationships, projects, or future plans. " + "Do NOT call for: weather, reminders, searches, or one-time commands. " + "Do NOT announce that you are saving — just call it silently. " + "Values must be in English regardless of the conversation language." + ), + "parameters": { + "type": "OBJECT", + "properties": { + "category": { + "type": "STRING", + "description": ( + "identity — name, age, birthday, city, job, language, nationality | " + "preferences — favorite food/color/music/film/game/sport, hobbies | " + "projects — active projects, goals, things being built | " + "relationships — friends, family, partner, colleagues | " + "wishes — future plans, things to buy, travel dreams | " + "notes — habits, schedule, anything else worth remembering" + ) + }, + "key": {"type": "STRING", "description": "Short snake_case key (e.g. name, favorite_food, sister_name)"}, + "value": {"type": "STRING", "description": "Concise value in English (e.g. Fatih, pizza, older sister)"}, + }, + "required": ["category", "key", "value"] + } + }, +] + +# --- Plugin system --- + + +class JarvisLive: + + def __init__(self, ui: JarvisUI): + self.ui = ui + self.session = None + self.audio_in_queue = None + self.out_queue = None + self._loop = None + self._is_speaking = False + self._speaking_lock = threading.Lock() + self._phone_active = False # True while phone mic is streaming; pauses PC mic + self.ui.on_text_command = self._on_text_command + self.ui.on_remote_clicked = self._make_remote_key + self._turn_done_event: asyncio.Event | None = None + self._dashboard = None + + def _make_remote_key(self): + """Called from Qt main thread when user presses Remote Control.""" + if self._dashboard is None: + self.ui.write_log( + "SYS: Dashboard unavailable. " + "Run: pip install fastapi \"uvicorn[standard]\" cryptography" + ) + return None + key = self._dashboard.new_key() + url = self._dashboard.get_url() + manual = self._dashboard.get_manual_url() + return url, key, f"{url}/auto-login?key={key}", manual + + def _on_text_command(self, text: str): + if not self._loop or not self.session: + return + asyncio.run_coroutine_threadsafe( + self.session.send_client_content( + turns={"parts": [{"text": text}]}, + turn_complete=True + ), + self._loop + ) + + def set_speaking(self, value: bool): + with self._speaking_lock: + self._is_speaking = value + if value: + self.ui.set_state("SPEAKING") + elif not self.ui.muted: + self.ui.set_state("LISTENING") + + def speak(self, text: str): + if not self._loop or not self.session: + return + asyncio.run_coroutine_threadsafe( + self.session.send_client_content( + turns={"parts": [{"text": text}]}, + turn_complete=True + ), + self._loop + ) + + def speak_error(self, tool_name: str, error: str): + short = str(error)[:120] + self.ui.write_log(f"ERR: {tool_name} — {short}") + self.speak(f"Sir, {tool_name} encountered an error. {short}") + + def _build_config(self) -> types.LiveConnectConfig: + from datetime import datetime + + memory = load_memory() + mem_str = format_memory_for_prompt(memory) + sys_prompt = _load_system_prompt() + + now = datetime.now() + time_str = now.strftime("%A, %B %d, %Y — %I:%M %p") + time_ctx = ( + f"[CURRENT DATE & TIME]\n" + f"Right now it is: {time_str}\n" + f"Use this to calculate exact times for reminders.\n\n" + ) + + parts = [time_ctx] + if mem_str: + parts.append(mem_str) + parts.append(sys_prompt) + + return types.LiveConnectConfig( + response_modalities=["AUDIO"], + output_audio_transcription={}, + input_audio_transcription={}, + system_instruction="\n".join(parts), + tools=[{"function_declarations": TOOL_DECLARATIONS}], + session_resumption=types.SessionResumptionConfig(), + speech_config=types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig( + voice_name="Charon" + ) + ) + ), + ) + + async def _execute_tool(self, fc) -> types.FunctionResponse: + name = fc.name + args = dict(fc.args or {}) + + print(f"[JARVIS] 🔧 {name} {args}") + self.ui.set_state("THINKING") + + if name == "save_memory": + category = args.get("category", "notes") + key = args.get("key", "") + value = args.get("value", "") + if key and value: + update_memory({category: {key: {"value": value}}}) + print(f"[Memory] 💾 save_memory: {category}/{key} = {value}") + if not self.ui.muted: + self.ui.set_state("LISTENING") + return types.FunctionResponse( + id=fc.id, name=name, + response={"result": "ok", "silent": True} + ) + + loop = asyncio.get_event_loop() + result = "Done." + + try: + if name == "open_app": + r = await loop.run_in_executor(None, lambda: open_app(parameters=args, response=None, player=self.ui)) + result = r or f"Opened {args.get('app_name')}." + + elif name == "weather_report": + r = await loop.run_in_executor(None, lambda: weather_action(parameters=args, player=self.ui)) + result = r or "Weather delivered." + + elif name == "browser_control": + r = await loop.run_in_executor(None, lambda: browser_control(parameters=args, player=self.ui)) + result = r or "Done." + + elif name == "file_controller": + r = await loop.run_in_executor(None, lambda: file_controller(parameters=args, player=self.ui)) + result = r or "Done." + + elif name == "send_message": + r = await loop.run_in_executor(None, lambda: send_message(parameters=args, response=None, player=self.ui, session_memory=None)) + result = r or f"Message sent to {args.get('receiver')}." + + elif name == "reminder": + r = await loop.run_in_executor(None, lambda: reminder(parameters=args, response=None, player=self.ui)) + result = r or "Reminder set." + + elif name == "youtube_video": + r = await loop.run_in_executor(None, lambda: youtube_video(parameters=args, response=None, player=self.ui)) + result = r or "Done." + + elif name == "screen_process": + threading.Thread( + target=screen_process, + kwargs={"parameters": args, "response": None, + "player": self.ui, "session_memory": None}, + daemon=True + ).start() + result = "Vision module activated. Stay completely silent — vision module will speak directly." + + elif name == "computer_settings": + r = await loop.run_in_executor(None, lambda: computer_settings(parameters=args, response=None, player=self.ui)) + result = r or "Done." + + elif name == "desktop_control": + r = await loop.run_in_executor(None, lambda: desktop_control(parameters=args, player=self.ui)) + result = r or "Done." + + elif name == "code_helper": + r = await loop.run_in_executor(None, lambda: code_helper(parameters=args, player=self.ui, speak=self.speak)) + result = r or "Done." + + elif name == "dev_agent": + r = await loop.run_in_executor(None, lambda: dev_agent(parameters=args, player=self.ui, speak=self.speak)) + result = r or "Done." + + elif name == "web_search": + r = await loop.run_in_executor(None, lambda: web_search_action(parameters=args, player=self.ui)) + result = r or "Done." + elif name == "file_processor": + if not args.get("file_path") and self.ui.current_file: + args["file_path"] = self.ui.current_file + r = await loop.run_in_executor( + None, + lambda: file_processor(parameters=args, player=self.ui, speak=self.speak) + ) + result = r or "Done." + + elif name == "computer_control": + r = await loop.run_in_executor(None, lambda: computer_control(parameters=args, player=self.ui)) + result = r or "Done." + + elif name == "game_updater": + r = await loop.run_in_executor(None, lambda: game_updater(parameters=args, player=self.ui, speak=self.speak)) + result = r or "Done." + + elif name == "flight_finder": + r = await loop.run_in_executor(None, lambda: flight_finder(parameters=args, player=self.ui)) + result = r or "Done." + + elif name == "shutdown_jarvis": + self.ui.write_log("SYS: Shutdown requested.") + self.speak("Goodbye, sir.") + def _shutdown(): + import time, os + time.sleep(1) + os._exit(0) + threading.Thread(target=_shutdown, daemon=True).start() + + else: + result = f"Unknown tool: {name}" + + except Exception as e: + result = f"Tool '{name}' failed: {e}" + traceback.print_exc() + self.speak_error(name, e) + + if not self.ui.muted: + self.ui.set_state("LISTENING") + + print(f"[JARVIS] 📤 {name} → {str(result)[:80]}") + return types.FunctionResponse( + id=fc.id, name=name, + response={"result": result} + ) + + async def _send_realtime(self): + while True: + msg = await self.out_queue.get() + await self.session.send_realtime_input(media=msg) + + async def _listen_audio(self): + print("[JARVIS] 🎤 Mic started") + loop = asyncio.get_event_loop() + + def callback(indata, frames, time_info, status): + with self._speaking_lock: + jarvis_speaking = self._is_speaking + if not jarvis_speaking and not self.ui.muted and not self._phone_active: + data = indata.tobytes() + loop.call_soon_threadsafe( + self.out_queue.put_nowait, + {"data": data, "mime_type": "audio/pcm"} + ) + + try: + with sd.InputStream( + samplerate=SEND_SAMPLE_RATE, + channels=CHANNELS, + dtype="int16", + blocksize=CHUNK_SIZE, + callback=callback, + ): + print("[JARVIS] 🎤 Mic stream open") + while True: + await asyncio.sleep(0.1) + except Exception as e: + print(f"[JARVIS] ❌ Mic: {e}") + raise + + async def _receive_audio(self): + print("[JARVIS] 👂 Recv started") + out_buf, in_buf = [], [] + + try: + while True: + async for response in self.session.receive(): + + if response.data: + if self._turn_done_event and self._turn_done_event.is_set(): + self._turn_done_event.clear() + self.audio_in_queue.put_nowait(response.data) + + if response.server_content: + sc = response.server_content + + if sc.output_transcription and sc.output_transcription.text: + txt = _clean_transcript(sc.output_transcription.text) + if txt: + out_buf.append(txt) + + if sc.input_transcription and sc.input_transcription.text: + txt = _clean_transcript(sc.input_transcription.text) + if txt: + in_buf.append(txt) + + if sc.turn_complete: + if self._turn_done_event: + self._turn_done_event.set() + + full_in = " ".join(in_buf).strip() + if full_in: + self.ui.write_log(f"You: {full_in}") + if self._dashboard: + asyncio.create_task(self._dashboard.broadcast({ + "type": "log", "speaker": "user", + "text": full_in, + "ts": datetime.now().isoformat(), + })) + in_buf = [] + + full_out = " ".join(out_buf).strip() + if full_out: + self.ui.write_log(f"Jarvis: {full_out}") + if self._dashboard: + asyncio.create_task(self._dashboard.broadcast({ + "type": "log", "speaker": "jarvis", + "text": full_out, + "ts": datetime.now().isoformat(), + })) + out_buf = [] + + if response.tool_call: + fn_responses = [] + for fc in response.tool_call.function_calls: + print(f"[JARVIS] 📞 {fc.name}") + fr = await self._execute_tool(fc) + fn_responses.append(fr) + await self.session.send_tool_response( + function_responses=fn_responses + ) + except Exception as e: + print(f"[JARVIS] ❌ Recv: {e}") + traceback.print_exc() + raise + + async def _play_audio(self): + print("[JARVIS] 🔊 Play started") + + stream = sd.RawOutputStream( + samplerate=RECEIVE_SAMPLE_RATE, + channels=CHANNELS, + dtype="int16", + blocksize=CHUNK_SIZE, + ) + stream.start() + + try: + while True: + try: + chunk = await asyncio.wait_for( + self.audio_in_queue.get(), + timeout=0.1 + ) + except asyncio.TimeoutError: + if ( + self._turn_done_event + and self._turn_done_event.is_set() + and self.audio_in_queue.empty() + ): + self.set_speaking(False) + self._turn_done_event.clear() + continue + self.set_speaking(True) + await asyncio.to_thread(stream.write, chunk) + except Exception as e: + print(f"[JARVIS] ❌ Play: {e}") + raise + finally: + self.set_speaking(False) + stream.stop() + stream.close() + + async def _relay_phone_audio(self) -> None: + """Forward phone mic PCM chunks from dashboard queue into the Gemini Live session.""" + q = self._dashboard._phone_audio_queue + while True: + try: + chunk = await asyncio.wait_for(q.get(), timeout=1.0) + except asyncio.TimeoutError: + # No audio for 1 s → phone mic inactive, give PC mic back + self._phone_active = False + continue + self._phone_active = True # phone is streaming — silence PC mic + with self._speaking_lock: + speaking = self._is_speaking + if not speaking and not self.ui.muted: + try: + self.out_queue.put_nowait(chunk) + except asyncio.QueueFull: + pass + + def _on_phone_connected(self) -> None: + self.ui.write_log("SYS: Phone connected via Remote Dashboard.") + self.ui.notify_phone_connected() + + # ── dashboard command relay ───────────────────────────────────────────── + + async def _process_dashboard_commands(self) -> None: + while True: + try: + text = await asyncio.wait_for( + self._dashboard._command_queue.get(), timeout=0.5 + ) + if not text: + continue + # Wait up to 8s for session to become ready after a wake + for _ in range(80): + if self.session: + break + await asyncio.sleep(0.1) + if self.session: + await self.session.send_client_content( + turns={"parts": [{"text": text}]}, + turn_complete=True, + ) + self.ui.write_log(f"[Web]: {text}") + else: + print(f"[Dashboard] Dropped command (no session): {text}") + except asyncio.TimeoutError: + pass + except Exception as e: + print(f"[Dashboard] Command error: {e}") + await asyncio.sleep(0.5) + + # ── main loop ─────────────────────────────────────────────────────────── + + async def run(self): + self._loop = asyncio.get_event_loop() + + client = genai.Client( + api_key=_get_api_key(), + http_options={"api_version": "v1beta"} + ) + + # Start dashboard (optional — needs: pip install fastapi "uvicorn[standard]" cryptography) + try: + from dashboard.server import DashboardServer + self._dashboard = DashboardServer() + self._dashboard.set_connect_callback(self._on_phone_connected) + asyncio.create_task(self._dashboard.serve()) + # Runs for the whole lifetime, not just inside an active session + asyncio.create_task(self._process_dashboard_commands()) + except Exception as e: + print(f"[Dashboard] Disabled: {e}") + self._dashboard = None + + while True: + try: + print("[JARVIS] Connecting...") + self.ui.set_state("THINKING") + config = self._build_config() + + async with ( + client.aio.live.connect(model=LIVE_MODEL, config=config) as session, + asyncio.TaskGroup() as tg, + ): + self.session = session + self.audio_in_queue = asyncio.Queue() + self.out_queue = asyncio.Queue(maxsize=200) + self._turn_done_event = asyncio.Event() + + print("[JARVIS] Connected.") + self.ui.set_state("LISTENING") + self.ui.write_log("SYS: JARVIS online.") + + if self._dashboard: + await self._dashboard.broadcast({"type": "status", "state": "active"}) + + tg.create_task(self._send_realtime()) + tg.create_task(self._listen_audio()) + tg.create_task(self._receive_audio()) + tg.create_task(self._play_audio()) + if self._dashboard: + tg.create_task(self._relay_phone_audio()) + + except Exception as e: + print(f"[JARVIS] Error: {e}") + traceback.print_exc() + finally: + self.session = None + + self.set_speaking(False) + self.ui.set_state("SLEEPING") + + if self._dashboard: + await self._dashboard.broadcast({"type": "status", "state": "sleeping"}) + + print("[JARVIS] Reconnecting in 3s...") + await asyncio.sleep(3) + +def main(): + ui = JarvisUI("face.png") + + def runner(): + ui.wait_for_api_key() + jarvis = JarvisLive(ui) + try: + asyncio.run(jarvis.run()) + except KeyboardInterrupt: + print("\n🔴 Shutting down...") + + threading.Thread(target=runner, daemon=True).start() + ui.root.mainloop() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/memory/config_manager.py b/memory/config_manager.py index 1ca67c5..1a324c4 100644 --- a/memory/config_manager.py +++ b/memory/config_manager.py @@ -1,54 +1,51 @@ -import json -import sys -from pathlib import Path - - -def get_base_dir() -> Path: - if getattr(sys, "frozen", False): - return Path(sys.executable).parent - return Path(__file__).resolve().parent.parent - - -BASE_DIR = get_base_dir() -CONFIG_DIR = BASE_DIR / "config" -CONFIG_FILE = CONFIG_DIR / "api_keys.json" - - -def ensure_config_dir() -> None: - CONFIG_DIR.mkdir(parents=True, exist_ok=True) - - -def config_exists() -> bool: - return CONFIG_FILE.exists() - - -def save_config(cfg: dict) -> None: - ensure_config_dir() - existing: dict = {} - if CONFIG_FILE.exists(): - try: - existing = json.loads(CONFIG_FILE.read_text(encoding="utf-8")) - except Exception: - existing = {} - existing.update(cfg) - CONFIG_FILE.write_text(json.dumps(existing, indent=2), encoding="utf-8") - - -def load_api_keys() -> dict: - if not CONFIG_FILE.exists(): - return {} - try: - return json.loads(CONFIG_FILE.read_text(encoding="utf-8")) - except Exception as e: - print(f"❌ Failed to load api_keys.json: {e}") - return {} - - -def is_configured() -> bool: - cfg = load_api_keys() - return ( - bool(cfg.get("os_system")) and - bool(cfg.get("llm_model")) and - bool(cfg.get("stt_engine")) and - bool(cfg.get("tts_engine")) - ) +import json +import sys +from pathlib import Path + +def get_base_dir() -> Path: + if getattr(sys, "frozen", False): + return Path(sys.executable).parent + return Path(__file__).resolve().parent.parent + +BASE_DIR = get_base_dir() +CONFIG_DIR = BASE_DIR / "config" +CONFIG_FILE = CONFIG_DIR / "api_keys.json" + +def ensure_config_dir() -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + +def config_exists() -> bool: + return CONFIG_FILE.exists() + +def save_api_keys(gemini_api_key: str) -> None: + ensure_config_dir() + + data: dict = {} + if CONFIG_FILE.exists(): + try: + data = json.loads(CONFIG_FILE.read_text(encoding="utf-8")) + except Exception: + data = {} + + data["gemini_api_key"] = gemini_api_key.strip() + + CONFIG_FILE.write_text( + json.dumps(data, indent=2), + encoding="utf-8" + ) + +def load_api_keys() -> dict: + if not CONFIG_FILE.exists(): + return {} + try: + return json.loads(CONFIG_FILE.read_text(encoding="utf-8")) + except Exception as e: + print(f"❌ Failed to load api_keys.json: {e}") + return {} + +def get_gemini_key() -> str | None: + return load_api_keys().get("gemini_api_key") + +def is_configured() -> bool: + key = get_gemini_key() + return bool(key and len(key) > 15) \ No newline at end of file diff --git a/readme.md b/readme.md index 2e8dea0..8c11271 100644 --- a/readme.md +++ b/readme.md @@ -1,159 +1,84 @@ -# 🤖 MARK XL — Local AI Assistant - -> **J.A.R.V.I.S** — Just A Rather Very Intelligent System -> Cross-platform voice AI assistant running entirely on local hardware. No cloud APIs required. - ---- - -## Overview - -MARK XL is a fully local, real-time voice and visual AI agent. It combines offline speech recognition, a locally hosted LLM (via Ollama), and text-to-speech to deliver a privacy-first personal assistant with OS-level control capabilities. - -Successor to the previous mark, which used the Google Gemini Live API. MARK XL removes all cloud LLM dependencies while adding streaming responses, a dynamic configuration UI, and multi-language support. - ---- - -## Architecture - -``` -Microphone → STT (Whisper / Vosk) - ↓ - Ollama LLM (tool calling + streaming) - ↓ - Tool Execution (OS, Browser, Files …) - ↓ - TTS (EdgeTTS / Kokoro / ElevenLabs) - ↓ - Speaker -``` - -### Core Components - -| Layer | Technology | Notes | -|-------|-----------|-------| -| **STT** | faster-whisper / Vosk | Fully offline. Auto language detection or forced locale. | -| **LLM** | Ollama (any model) | qwen2.5, llama3.2, mistral, etc. Streaming + tool calling. | -| **TTS** | EdgeTTS / Kokoro / ElevenLabs | EdgeTTS = free + internet. Kokoro = fully offline. | -| **UI** | PyQt6 | HUD overlay with system monitor, log panel, file drop zone. | -| **Agent** | Custom task queue | Multi-step planner + executor + error recovery. | - ---- - -## Features - -- **Streaming responses** — TTS starts speaking on the first sentence, not after the full response -- **Tool calling** — 18 built-in tools: browser control, file management, weather, YouTube, messaging, screen analysis, code helper, game updater, flight finder, and more -- **Long-term memory** — Silently saves personal facts; recalled in every conversation -- **Live configuration** — Change LLM model, STT engine, TTS voice without restarting (⚙ Configure button) -- **Ollama auto-start** — Automatically launches `ollama serve` if it's not running -- **Model warmup** — Pre-loads the LLM into memory during startup so the first message is as fast as subsequent ones -- **Multi-language STT** — Set `stt_language` to `auto` (Whisper detects) or a specific locale (`tr`, `de`, `fr`, …) -- **Cross-platform** — Windows, macOS, Linux (OS detected automatically at runtime) -- **File drop zone** — Drag and drop images, PDFs, Word docs, CSV, audio, video for AI processing - ---- - -## Requirements - -- Python 3.11 or 3.12 -- [Ollama](https://ollama.com) installed and a model pulled (e.g. `ollama pull qwen2.5:7b`) -- A microphone - ---- - -## Quick Start - -```bash -# 1. Install Ollama → https://ollama.com -# Then pull a model: -ollama pull qwen2.5:7b - -# 2. Clone / download the project and launch -cd Mark-XL -python main.py -``` - -That's it. On first run MARK XL: -1. Auto-installs base packages (PyQt6, numpy …) and restarts once -2. Opens the **Initialisation** overlay — choose STT engine, LLM model, TTS engine -3. Click **INITIALISE SYSTEMS** — engine packages install in the background (progress shown in log) -4. JARVIS comes online - -After setup, use the **⚙ CONFIGURE** button in the right panel to change any setting at any time without restarting. - ---- - -## Configuration (`config/api_keys.json`) - -```json -{ - "stt_engine": "whisper", - "stt_model": "base", - "stt_language": "auto", - "llm_url": "http://localhost:11434", - "llm_model": "qwen2.5:7b", - "tts_engine": "edgetts", - "tts_voice": "en-US-GuyNeural", - "elevenlabs_api_key": "" -} -``` - -| Key | Values | Default | -|-----|--------|---------| -| `stt_engine` | `whisper` / `vosk` | `whisper` | -| `stt_model` | `tiny` / `base` / `small` / `medium` / `large-v3` | `base` | -| `stt_language` | `auto` or ISO code (`tr`, `en`, `de` …) | `auto` | -| `llm_url` | Ollama API base URL | `http://localhost:11434` | -| `llm_model` | Any model pulled in Ollama | `qwen2.5:7b` | -| `tts_engine` | `edgetts` / `kokoro` / `elevenlabs` | `edgetts` | -| `tts_voice` | Voice name / ID depending on engine | `en-US-GuyNeural` | - ---- - -## Built-in Tools - -| Tool | Description | -|------|-------------| -| `open_app` | Opens any application or website | -| `web_search` | Web search and compare mode | -| `weather_report` | Current weather for any city | -| `send_message` | WhatsApp / Telegram messaging | -| `reminder` | Timed reminders via Task Scheduler | -| `youtube_video` | Play, summarize, trending videos | -| `screen_process` | Screen capture + vision model analysis | -| `computer_settings` | Volume, brightness, window management, shortcuts | -| `browser_control` | Full Playwright browser automation | -| `file_controller` | File/folder CRUD, search, disk usage | -| `desktop_control` | Wallpaper, organize, clean desktop | -| `code_helper` | Write, edit, explain, run code | -| `dev_agent` | Build complete multi-file projects | -| `agent_task` | Multi-step autonomous task execution | -| `computer_control` | Direct mouse/keyboard control | -| `game_updater` | Steam / Epic Games install & update | -| `flight_finder` | Google Flights search | -| `file_processor` | Process images, PDFs, CSV, audio, video | - ---- - -## Keyboard Shortcuts - -| Key | Action | -|-----|--------| -| `F4` | Mute / unmute microphone | -| `F11` | Toggle fullscreen | - ---- - -## TTS Engine Comparison - -| Engine | Internet | Quality | Cost | -|--------|----------|---------|------| -| EdgeTTS | Required | Good | Free | -| Kokoro | No | Excellent | Free (local model ~100 MB) | -| ElevenLabs | Required | Best | Paid API | - ---- - -## License - -MIT — FatihMakes Industries +# 🤖 MARK XLVI (46) +### The Ultimate Cross-Platform Personal AI Assistant — By FatihMakes + +> 📺 **[Watch the full setup video on YouTube](https://www.youtube.com/watch?v=BhOsnGC_sAA)** + +A real-time voice AI that can hear, see, understand, and control your computer — on any OS. Supporting Windows, macOS, and Linux. Built with Gemini integration for maximum stability and performance, delivering zero subscriptions and total digital autonomy. + +--- + +## ✨ Overview + +MARK XLVI represents a massive milestone in the Jarvis series, evolving into a fully connected, highly persistent, and remote-accessible system. It completely bridges the gap between your mobile device, desktop OS, and human intent. Through real-time Gemini reasoning, Mark 46 allows you to control your PC from your phone, share large files securely, and maintain deep contextual conversations across sessions. + +It's not just an assistant — it's an extension of your digital life. + +--- + +## 🚀 Capabilities + +### Core Features +| Feature | Description | +|---|---| +| 🎙️ Real-time Voice | Ultra-low latency conversation in any language | +| 🖥️ System Control | Launch apps, manage files, execute terminal commands | +| 🧩 Autonomous Tasks | High-level planning for complex, multi-step goals | +| 👁️ Visual Awareness | Real-time screen processing and webcam vision | +| 🧠 Persistent Memory | Deeply remembers your projects, preferences, and personal context | +| ⌨️ Hybrid Input | Seamlessly switch between keyboard typing and voice commands | + +--- + +## 🆕 What's New in XLVI + +- 📱 **Full Remote Phone Control** — Take command of your entire desktop operating system directly from your smartphone, anywhere, anytime. +- 🧠 **Advanced Long-Term Memory** — Upgraded memory architecture allows Jarvis to contextually remember past interactions, preferences, and complex workflows across reboots. +- 🚀 **Powered by Gemini Integration** — Re-engineered from the ground up to utilize the full speed and precision of the Google Gemini API for ultimate reasoning and stability. +- ⚡ **Next-Gen Performance & Stability** — Comprehensive system-wide optimizations delivering faster response times and rock-solid execution on Windows, Mac, and Linux. +- 📂 **Advanced File Handling & Hybrid Input** — Fluidly switch between voice or keyboard input, and drag-and-drop code, PDFs, or images for instant analysis and automation. +- 🔒 **Secure Mobile File Sharing** — Wirelessly and securely share files or entire folders up to 500 MB from your phone directly to your computer with complete privacy. + +--- + +## ⚡ Quick Start + +```bash +git clone [https://github.com/FatihMakes/Mark-XLVI.git](https://github.com/FatihMakes/Mark-XLVI.git) +cd Mark-XLVI +pip install -r requirements.txt +playwright install +python main.py + +``` + +> ⚠️ **Installation Note:** To keep the repository lightweight, some OS-specific dependencies are not bundled in `requirements.txt`. If you run into a `ModuleNotFoundError`, simply install the missing package via `pip install ` for your specific system. + +--- + +## 📋 Requirements + +| Requirement | Details | +| --- | --- | +| **OS** | Windows 10/11, macOS, or Linux | +| **Python** | 3.11 or 3.12 | +| **Microphone** | Required for voice interaction | +| **API Key** | Free Gemini API key | + +--- + +## ⚠️ License + +Personal and non-commercial use only. +Licensed under **[Creative Commons BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/)**. + +--- + +## 👤 Connect with the Creator + +Engineered by a developer building a real-world JARVIS-style assistant. +⭐ **Star the repository to support the journey to Mark 100.** + +| Platform | Link | +| --- | --- | +| YouTube | [@FatihMakes](https://www.youtube.com/@FatihMakes) | +| Instagram | [@fatihmakes](https://www.instagram.com/fatihmakes) | \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f60c114..e527d72 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,54 +1,27 @@ -# MARK XL — Python dependencies -# -# Normal users: just run python main.py -# All packages are installed automatically on first launch. -# -# Developers / manual setup: -# pip install -r requirements.txt -# Platform-specific packages install only on the matching OS. - -# ── Core UI & system ────────────────────────────────────────────────────── -PyQt6 -psutil -pillow sounddevice -numpy -soundfile +google-genai +google-generativeai +pillow requests - -# ── Web automation & search ─────────────────────────────────────────────── -playwright beautifulsoup4 duckduckgo-search - -# ── OS control (cross-platform) ─────────────────────────────────────────── +playwright pyautogui pyperclip pygetwindow -send2trash -mss opencv-python - -# ── OS control (Windows only) ───────────────────────────────────────────── -pywinauto ; sys_platform == "win32" -comtypes ; sys_platform == "win32" -pycaw ; sys_platform == "win32" -win10toast; sys_platform == "win32" - -# ── Media & file processing ─────────────────────────────────────────────── +numpy +mss +psutil +send2trash youtube-transcript-api python-pptx -miniaudio - -# ── STT — choose one (auto-installed by main.py based on your config) ───── -faster-whisper # Whisper (default, recommended) -# vosk # Vosk (alternative — uncomment if preferred) - -# ── TTS — choose one (auto-installed by main.py based on your config) ───── -edge-tts # EdgeTTS (default, free, requires internet) -# kokoro # Kokoro (fully offline — uncomment if preferred) -# elevenlabs # ElevenLabs (cloud API — uncomment if preferred) - -# ── Local LLM ───────────────────────────────────────────────────────────── -# Ollama must be installed separately: https://ollama.com -# Then pull a model: ollama pull qwen2.5:7b +fastapi +uvicorn[standard] +cryptography +python-multipart +comtypes; sys_platform == "win32" +pycaw; sys_platform == "win32" +win10toast; sys_platform == "win32" +pywinauto; sys_platform == "win32" +qrcode[pil] diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..5eb9976 --- /dev/null +++ b/setup.py @@ -0,0 +1,11 @@ +import subprocess +import sys + +print("Installing requirements...") +subprocess.run([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"], check=True) + +print("Installing Playwright browsers...") +subprocess.run([sys.executable, "-m", "playwright", "install"], check=True) + +print("\n✅ Setup complete! Run 'python main.py' to start MARK XXV.") + diff --git a/ui.py b/ui.py index 068fe10..792f23f 100644 --- a/ui.py +++ b/ui.py @@ -23,7 +23,7 @@ from PyQt6.QtGui import ( QRadialGradient, QShortcut, ) from PyQt6.QtWidgets import ( - QApplication, QComboBox, QFileDialog, QFrame, QHBoxLayout, QLabel, QLineEdit, + QApplication, QFileDialog, QFrame, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QPushButton, QScrollArea, QSizePolicy, QTextEdit, QVBoxLayout, QWidget, QProgressBar, ) @@ -856,86 +856,111 @@ class _DropCanvas(QWidget): class SetupOverlay(QWidget): - # Emits a JSON string containing the full config dict - done = pyqtSignal(str) + done = pyqtSignal(str, str) - # ------------------------------------------------------------------ # - _INPUT_STYLE = "" # filled in __init__ after C is available - - def __init__(self, parent=None, initial: dict | None = None, mode: str = "init"): + def __init__(self, parent=None): super().__init__(parent) - self._mode = mode - _init = initial or {} self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) self.setStyleSheet(f""" SetupOverlay {{ - background: rgba(0, 6, 10, 248); + background: rgba(0, 6, 10, 245); border: 1px solid {C.BORDER_B}; border-radius: 6px; }} """) - _INPUT = f""" - QLineEdit {{ - background: #000d12; color: {C.TEXT}; - border: 1px solid {C.BORDER}; border-radius: 3px; padding: 3px 7px; - font-family: 'Courier New'; font-size: 9pt; - }} - QLineEdit:focus {{ border: 1px solid {C.PRI}; }} - """ - - self._sel_stt = _init.get("stt_engine", "whisper") - self._sel_tts = _init.get("tts_engine", "edgetts") - self._sel_llm_provider = _init.get("llm_provider", "ollama") + detected = {"darwin": "mac", "windows": "windows"}.get( + _OS.lower(), "linux" + ) + self._sel_os = detected layout = QVBoxLayout(self) - layout.setContentsMargins(22, 16, 22, 16) - layout.setSpacing(6) + layout.setContentsMargins(30, 22, 30, 22) + layout.setSpacing(8) - def _lbl(txt, sz=9, bold=False, col=C.PRI, align=Qt.AlignmentFlag.AlignCenter): - w = QLabel(txt); w.setAlignment(align) - w.setFont(QFont("Courier New", sz, + def _lbl(txt, font_size=9, bold=False, color=C.PRI, + align=Qt.AlignmentFlag.AlignCenter): + w = QLabel(txt) + w.setAlignment(align) + w.setFont(QFont("Courier New", font_size, QFont.Weight.Bold if bold else QFont.Weight.Normal)) - w.setStyleSheet(f"color: {col}; background: transparent;") + w.setStyleSheet(f"color: {color}; background: transparent;") return w - def _sep(): - s = QFrame(); s.setFrameShape(QFrame.Shape.HLine) - s.setStyleSheet(f"color: {C.BORDER}; margin: 2px 0;") - return s + layout.addWidget(_lbl("◈ INITIALISATION REQUIRED", 13, True)) + layout.addWidget(_lbl("Configure J.A.R.V.I.S. before first boot.", 9, color=C.PRI_DIM)) + layout.addSpacing(6) - def _input(placeholder="", pw=False, fixed_h=28): - w = QLineEdit() - w.setPlaceholderText(placeholder) - w.setFixedHeight(fixed_h) - if pw: - w.setEchoMode(QLineEdit.EchoMode.Password) - w.setStyleSheet(_INPUT) - return w + sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine) + sep.setStyleSheet(f"color: {C.BORDER};"); layout.addWidget(sep) + layout.addSpacing(4) - def _toggle_row(keys_labels: list, getter, setter): - row = QHBoxLayout(); row.setSpacing(5) - btns: dict[str, QPushButton] = {} - def _click(k): - setter(k) - for bk, b in btns.items(): - _style_btn(b, bk == k) - for k, lbl in keys_labels: - b = QPushButton(lbl) - b.setFixedHeight(26) - b.setFont(QFont("Courier New", 8, QFont.Weight.Bold)) - b.setCursor(Qt.CursorShape.PointingHandCursor) - b.clicked.connect(lambda _, kk=k: _click(kk)) - row.addWidget(b) - btns[k] = b - _click(getter()) - return row, btns + layout.addWidget(_lbl("GEMINI API KEY", 8, color=C.TEXT_DIM, + align=Qt.AlignmentFlag.AlignLeft)) + self._key_input = QLineEdit() + self._key_input.setEchoMode(QLineEdit.EchoMode.Password) + self._key_input.setPlaceholderText("AIza…") + self._key_input.setFont(QFont("Courier New", 10)) + self._key_input.setFixedHeight(32) + self._key_input.setStyleSheet(f""" + QLineEdit {{ + background: #000d12; color: {C.TEXT}; + border: 1px solid {C.BORDER}; border-radius: 3px; padding: 4px 8px; + }} + QLineEdit:focus {{ border: 1px solid {C.PRI}; }} + """) + layout.addWidget(self._key_input) + layout.addSpacing(12) - def _style_btn(btn: QPushButton, active: bool): - if active: + sep2 = QFrame(); sep2.setFrameShape(QFrame.Shape.HLine) + sep2.setStyleSheet(f"color: {C.BORDER};"); layout.addWidget(sep2) + layout.addSpacing(4) + + layout.addWidget(_lbl("OPERATING SYSTEM", 8, color=C.TEXT_DIM, + align=Qt.AlignmentFlag.AlignLeft)) + det_name = {"windows": "Windows", "mac": "macOS", "linux": "Linux"}[detected] + layout.addWidget(_lbl(f"Auto-detected: {det_name}", 8, color=C.ACC2, + align=Qt.AlignmentFlag.AlignLeft)) + + os_row = QHBoxLayout(); os_row.setSpacing(6) + self._os_btns: dict[str, QPushButton] = {} + for key, label in [("windows","⊞ Windows"),("mac"," macOS"),("linux","🐧 Linux")]: + btn = QPushButton(label) + btn.setFont(QFont("Courier New", 9, QFont.Weight.Bold)) + btn.setFixedHeight(32) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.clicked.connect(lambda _, k=key: self._sel(k)) + os_row.addWidget(btn) + self._os_btns[key] = btn + layout.addLayout(os_row) + self._sel(detected) + layout.addSpacing(12) + + init_btn = QPushButton("▸ INITIALISE SYSTEMS") + init_btn.setFont(QFont("Courier New", 10, QFont.Weight.Bold)) + init_btn.setFixedHeight(36) + init_btn.setCursor(Qt.CursorShape.PointingHandCursor) + init_btn.setStyleSheet(f""" + QPushButton {{ + background: transparent; color: {C.PRI}; + border: 1px solid {C.PRI_DIM}; border-radius: 3px; + }} + QPushButton:hover {{ + background: {C.PRI_GHO}; border: 1px solid {C.PRI}; + }} + """) + init_btn.clicked.connect(self._submit) + layout.addWidget(init_btn) + + def _sel(self, key: str): + self._sel_os = key + pal = {"windows":(C.PRI,"#001a22"),"mac":(C.ACC2,"#1a1400"),"linux":(C.GREEN,"#001a0d")} + for k, btn in self._os_btns.items(): + if k == key: + fg, bg = pal[k] btn.setStyleSheet(f""" QPushButton {{ - background: {C.PRI}; color: #001a22; + background: {fg}; color: {bg}; border: none; border-radius: 3px; font-weight: bold; }} """) @@ -948,529 +973,252 @@ class SetupOverlay(QWidget): QPushButton:hover {{ color: {C.TEXT}; border: 1px solid {C.BORDER_B}; }} """) - # ── Header ──────────────────────────────────────────────────── # - if mode == "config": - layout.addWidget(_lbl("◈ CONFIGURATION", 12, True)) - layout.addWidget(_lbl("Update J.A.R.V.I.S. settings and click Apply.", 8, col=C.PRI_DIM)) - else: - layout.addWidget(_lbl("◈ INITIALISATION REQUIRED", 12, True)) - layout.addWidget(_lbl("Configure J.A.R.V.I.S. before first boot.", 8, col=C.PRI_DIM)) - layout.addWidget(_sep()) - - # ── STT ──────────────────────────────────────────────────────── # - layout.addWidget(_lbl("SPEECH-TO-TEXT ENGINE", 7, col=C.TEXT_DIM, - align=Qt.AlignmentFlag.AlignLeft)) - stt_row, self._stt_btns = _toggle_row( - [("whisper","🎙 Whisper"), ("vosk","🔊 Vosk")], - lambda: self._sel_stt, - self._set_stt, - ) - layout.addLayout(stt_row) - - _COMBO_STYLE = f""" - QComboBox {{ - background: #000d12; color: {C.TEXT}; - border: 1px solid {C.BORDER}; border-radius: 3px; padding: 3px 7px; - font-family: 'Courier New'; font-size: 9pt; - }} - QComboBox:focus {{ border: 1px solid {C.PRI}; }} - QComboBox::drop-down {{ border: none; width: 18px; }} - QComboBox QAbstractItemView {{ - background: #000d12; color: {C.TEXT}; - border: 1px solid {C.BORDER}; - selection-background-color: {C.PRI_GHO}; - font-family: 'Courier New'; font-size: 9pt; - }} - """ - - stt_detail = QHBoxLayout(); stt_detail.setSpacing(5) - stt_detail.addWidget(_lbl("Model:", 7, col=C.TEXT_MED, - align=Qt.AlignmentFlag.AlignRight)) - - # Whisper: dropdown with predefined sizes - self._whisper_combo = QComboBox() - self._whisper_combo.setFixedHeight(28) - self._whisper_combo.setStyleSheet(_COMBO_STYLE) - for m in ["tiny", "base", "small", "medium", "large-v3"]: - self._whisper_combo.addItem(m) - _cur_model = _init.get("stt_model", "base") - _idx = self._whisper_combo.findText(_cur_model) - self._whisper_combo.setCurrentIndex(_idx if _idx >= 0 else 1) - stt_detail.addWidget(self._whisper_combo) - - # Vosk: free-text path input - self._vosk_model_input = _input("model dir path (leave empty for auto-download)") - self._vosk_model_input.setText(_init.get("vosk_model_path", "")) - stt_detail.addWidget(self._vosk_model_input) - - layout.addLayout(stt_detail) - - # Initial visibility - self._whisper_combo.setVisible(self._sel_stt == "whisper") - self._vosk_model_input.setVisible(self._sel_stt == "vosk") - - stt_lang_row = QHBoxLayout(); stt_lang_row.setSpacing(5) - stt_lang_row.addWidget(_lbl("Language:", 7, col=C.TEXT_MED, - align=Qt.AlignmentFlag.AlignRight)) - self._stt_lang_input = _input("auto (or: tr / en / de / fr / es / zh …)") - self._stt_lang_input.setText(_init.get("stt_language", "auto")) - stt_lang_row.addWidget(self._stt_lang_input) - layout.addLayout(stt_lang_row) - layout.addWidget(_sep()) - - # ── LLM ──────────────────────────────────────────────────────── # - layout.addWidget(_lbl("LOCAL LLM", 7, col=C.TEXT_DIM, - align=Qt.AlignmentFlag.AlignLeft)) - - # Provider toggle: Ollama vs LM Studio / OpenAI-compatible - llm_prov_row, self._llm_prov_btns = _toggle_row( - [ - ("ollama", "🦙 Ollama"), - ("openai", "🔌 LM Studio / OpenAI"), - ], - lambda: self._sel_llm_provider, - self._set_llm_provider, - ) - layout.addLayout(llm_prov_row) - - # Hint label — changes based on provider - _ollama_hint = "ollama.com · run: ollama pull qwen2.5:3b" - _openai_hint = "lmstudio.ai · start Local Server first, then pick model" - self._llm_hint_lbl = _lbl( - _openai_hint if self._sel_llm_provider == "openai" else _ollama_hint, - 7, col=C.TEXT_DIM, align=Qt.AlignmentFlag.AlignLeft - ) - layout.addWidget(self._llm_hint_lbl) - - llm_row = QHBoxLayout(); llm_row.setSpacing(5) - llm_row.addWidget(_lbl("URL:", 7, col=C.TEXT_MED, - align=Qt.AlignmentFlag.AlignRight)) - _default_url = _init.get("llm_url", - "http://localhost:1234" if self._sel_llm_provider == "openai" - else "http://localhost:11434") - self._llm_url_input = _input( - "http://localhost:1234" if self._sel_llm_provider == "openai" - else "http://localhost:11434" - ) - self._llm_url_input.setText(_default_url) - llm_row.addWidget(self._llm_url_input, stretch=2) - llm_row.addWidget(_lbl("Model:", 7, col=C.TEXT_MED, - align=Qt.AlignmentFlag.AlignRight)) - self._llm_model_input = _input("e.g. qwen2.5:3b / llama3.2 / mistral") - self._llm_model_input.setText(_init.get("llm_model", "")) - llm_row.addWidget(self._llm_model_input, stretch=2) - layout.addLayout(llm_row) - layout.addWidget(_sep()) - - # ── TTS ──────────────────────────────────────────────────────── # - layout.addWidget(_lbl("TEXT-TO-SPEECH ENGINE", 7, col=C.TEXT_DIM, - align=Qt.AlignmentFlag.AlignLeft)) - tts_row, self._tts_btns = _toggle_row( - [("edgetts","🔈 EdgeTTS"), ("kokoro","🤖 Kokoro"), ("elevenlabs","⚡ ElevenLabs")], - lambda: self._sel_tts, - self._set_tts, - ) - layout.addLayout(tts_row) - - voice_row = QHBoxLayout(); voice_row.setSpacing(5) - self._voice_lbl = QLabel("Voice:") - self._voice_lbl.setFont(QFont("Courier New", 7)) - self._voice_lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self._voice_lbl.setStyleSheet(f"color: {C.TEXT_MED}; background: transparent;") - voice_row.addWidget(self._voice_lbl) - - self._tts_voice_input = _input("en-US-GuyNeural") - self._tts_voice_input.setText(_init.get("tts_voice", "en-US-GuyNeural")) - voice_row.addWidget(self._tts_voice_input) - - # Kokoro: dropdown with predefined voices (hidden unless Kokoro selected) - self._kokoro_combo = QComboBox() - self._kokoro_combo.setFixedHeight(28) - self._kokoro_combo.setStyleSheet(_COMBO_STYLE) - _KOKORO_VOICES = [ - ("af_heart", "af_heart — EN-F warm (recommended)"), - ("af_sky", "af_sky — EN-F clear"), - ("af_bella", "af_bella — EN-F bella"), - ("af_sarah", "af_sarah — EN-F sarah"), - ("am_adam", "am_adam — EN-M adam"), - ("am_michael", "am_michael — EN-M michael"), - ("bf_emma", "bf_emma — UK-F emma"), - ("bf_isabella", "bf_isabella — UK-F isabella"), - ("bm_george", "bm_george — UK-M george"), - ("bm_lewis", "bm_lewis — UK-M lewis"), - ("jf_alpha", "jf_alpha — Japanese Female"), - ("jm_kumo", "jm_kumo — Japanese Male"), - ] - for val, display in _KOKORO_VOICES: - self._kokoro_combo.addItem(display, userData=val) - _cur_voice = _init.get("tts_voice", "af_heart") - for i in range(self._kokoro_combo.count()): - if self._kokoro_combo.itemData(i) == _cur_voice: - self._kokoro_combo.setCurrentIndex(i) - break - self._kokoro_combo.setVisible(False) # shown only when Kokoro active - voice_row.addWidget(self._kokoro_combo) - - layout.addLayout(voice_row) - - # Kokoro speed — only visible when Kokoro is selected - self._kokoro_speed_widget = QWidget() - self._kokoro_speed_widget.setStyleSheet("background: transparent;") - ks_row = QHBoxLayout(self._kokoro_speed_widget) - ks_row.setContentsMargins(0, 0, 0, 0) - ks_row.setSpacing(5) - ks_row.addWidget(_lbl("Speed:", 7, col=C.TEXT_MED, - align=Qt.AlignmentFlag.AlignRight)) - self._kokoro_speed_combo = QComboBox() - self._kokoro_speed_combo.setFixedHeight(28) - self._kokoro_speed_combo.setStyleSheet(_COMBO_STYLE) - for val, label in [ - ("0.8", "0.8× — Slow"), - ("1.0", "1.0× — Normal"), - ("1.1", "1.1× — Slightly fast"), - ("1.2", "1.2× — Fast (recommended)"), - ("1.3", "1.3× — Faster"), - ("1.5", "1.5× — Very fast"), - ]: - self._kokoro_speed_combo.addItem(label, userData=val) - _cur_speed = str(_init.get("tts_speed", "1.2")) - for i in range(self._kokoro_speed_combo.count()): - if self._kokoro_speed_combo.itemData(i) == _cur_speed: - self._kokoro_speed_combo.setCurrentIndex(i) - break - ks_row.addWidget(self._kokoro_speed_combo) - layout.addWidget(self._kokoro_speed_widget) - - # ElevenLabs key — only visible when ElevenLabs is selected - self._el_key_widget = QWidget() - self._el_key_widget.setStyleSheet("background: transparent;") - el_row = QHBoxLayout(self._el_key_widget) - el_row.setContentsMargins(0, 0, 0, 0) - el_row.setSpacing(5) - el_row.addWidget(_lbl("API Key:", 7, col=C.TEXT_MED, - align=Qt.AlignmentFlag.AlignRight)) - self._el_key_input = _input("ElevenLabs API key", pw=True) - self._el_key_input.setText(_init.get("elevenlabs_api_key", "")) - el_row.addWidget(self._el_key_input) - layout.addWidget(self._el_key_widget) - - layout.addWidget(_sep()) - - # Set correct initial state for TTS UI - self._update_tts_ui(self._sel_tts) - - # ── Action buttons ─────────────────────────────────────────────── # - btn_row = QHBoxLayout(); btn_row.setSpacing(8) - - if mode == "config": - cancel_btn = QPushButton("✕ CANCEL") - cancel_btn.setFont(QFont("Courier New", 9, QFont.Weight.Bold)) - cancel_btn.setFixedHeight(34) - cancel_btn.setCursor(Qt.CursorShape.PointingHandCursor) - cancel_btn.setStyleSheet(f""" - QPushButton {{ - background: transparent; color: {C.TEXT_DIM}; - border: 1px solid {C.BORDER}; border-radius: 3px; - }} - QPushButton:hover {{ - color: {C.RED}; border: 1px solid {C.RED}; - }} - """) - cancel_btn.clicked.connect(self.hide) - btn_row.addWidget(cancel_btn) - - btn_label = "▸ APPLY CHANGES" if mode == "config" else "▸ INITIALISE SYSTEMS" - init_btn = QPushButton(btn_label) - init_btn.setFont(QFont("Courier New", 10, QFont.Weight.Bold)) - init_btn.setFixedHeight(34) - init_btn.setCursor(Qt.CursorShape.PointingHandCursor) - init_btn.setStyleSheet(f""" - QPushButton {{ - background: transparent; color: {C.PRI}; - border: 1px solid {C.PRI_DIM}; border-radius: 3px; - }} - QPushButton:hover {{ - background: {C.PRI_GHO}; border: 1px solid {C.PRI}; - }} - """) - init_btn.clicked.connect(self._submit) - btn_row.addWidget(init_btn) - layout.addLayout(btn_row) - - # ------------------------------------------------------------------ # - def _update_tts_ui(self, key: str) -> None: - """Show/hide and relabel TTS fields based on selected engine.""" - if not hasattr(self, "_voice_lbl"): - return - - is_kokoro = (key == "kokoro") - - # Kokoro uses a dropdown; other engines use a text input - if hasattr(self, "_tts_voice_input"): - self._tts_voice_input.setVisible(not is_kokoro) - if hasattr(self, "_kokoro_combo"): - self._kokoro_combo.setVisible(is_kokoro) - - if key == "elevenlabs": - self._voice_lbl.setText("Voice ID:") - if hasattr(self, "_tts_voice_input"): - self._tts_voice_input.setPlaceholderText("ElevenLabs voice ID") - elif key == "kokoro": - self._voice_lbl.setText("Voice:") - else: # edgetts - self._voice_lbl.setText("Voice:") - if hasattr(self, "_tts_voice_input"): - self._tts_voice_input.setPlaceholderText( - "en-US-GuyNeural / en-GB-RyanNeural / tr-TR-AhmetNeural …" - ) - - if hasattr(self, "_kokoro_speed_widget"): - self._kokoro_speed_widget.setVisible(is_kokoro) - if hasattr(self, "_el_key_widget"): - self._el_key_widget.setVisible(key == "elevenlabs") - - def _set_llm_provider(self, key: str): - self._sel_llm_provider = key - if not hasattr(self, "_llm_prov_btns"): - return - for k, btn in self._llm_prov_btns.items(): - active = (k == key) - btn.setStyleSheet(f""" - QPushButton {{ - background: {'#00d4ff' if active else '#000d12'}; - color: {'#001a22' if active else C.TEXT_DIM}; - border: {'none' if active else f'1px solid {C.BORDER}'}; - border-radius: 3px; font-weight: {'bold' if active else 'normal'}; - }} - QPushButton:hover {{ color: {C.TEXT}; border: 1px solid {C.BORDER_B}; }} - """) - # Update URL placeholder and hint to match selected provider - if hasattr(self, "_llm_url_input"): - if key == "openai": - self._llm_url_input.setPlaceholderText("http://localhost:1234") - # Only override if it still looks like an Ollama default URL - cur = self._llm_url_input.text().strip() - if not cur or cur == "http://localhost:11434": - self._llm_url_input.setText("http://localhost:1234") - else: - self._llm_url_input.setPlaceholderText("http://localhost:11434") - cur = self._llm_url_input.text().strip() - if not cur or cur == "http://localhost:1234": - self._llm_url_input.setText("http://localhost:11434") - if hasattr(self, "_llm_hint_lbl"): - if key == "openai": - self._llm_hint_lbl.setText("lmstudio.ai · start Local Server first, then pick model") - else: - self._llm_hint_lbl.setText("ollama.com · run: ollama pull qwen2.5:3b") - - def _set_stt(self, key: str): - self._sel_stt = key - if not hasattr(self, "_stt_btns"): - return - for k, btn in self._stt_btns.items(): - active = (k == key) - btn.setStyleSheet(f""" - QPushButton {{ - background: {'#00d4ff' if active else '#000d12'}; - color: {'#001a22' if active else C.TEXT_DIM}; - border: {'none' if active else f'1px solid {C.BORDER}'}; - border-radius: 3px; font-weight: {'bold' if active else 'normal'}; - }} - QPushButton:hover {{ color: {C.TEXT}; border: 1px solid {C.BORDER_B}; }} - """) - # Toggle model selector widgets - if hasattr(self, "_whisper_combo"): - self._whisper_combo.setVisible(key == "whisper") - if hasattr(self, "_vosk_model_input"): - self._vosk_model_input.setVisible(key == "vosk") - - def _set_tts(self, key: str): - self._sel_tts = key - if not hasattr(self, "_tts_btns"): - return - for k, btn in self._tts_btns.items(): - active = (k == key) - btn.setStyleSheet(f""" - QPushButton {{ - background: {'#00d4ff' if active else '#000d12'}; - color: {'#001a22' if active else C.TEXT_DIM}; - border: {'none' if active else f'1px solid {C.BORDER}'}; - border-radius: 3px; font-weight: {'bold' if active else 'normal'}; - }} - QPushButton:hover {{ color: {C.TEXT}; border: 1px solid {C.BORDER_B}; }} - """) - self._update_tts_ui(key) - def _submit(self): - llm_model = self._llm_model_input.text().strip() - if not llm_model: - self._llm_model_input.setStyleSheet( - self._llm_model_input.styleSheet() + + key = self._key_input.text().strip() + if not key: + self._key_input.setStyleSheet( + self._key_input.styleSheet() + f" QLineEdit {{ border: 1px solid {C.RED}; }}" ) return - - # STT model: combo for Whisper, text input for Vosk - if self._sel_stt == "whisper": - stt_model = self._whisper_combo.currentText() - else: - stt_model = self._vosk_model_input.text().strip() - - # Voice: Kokoro uses dropdown, others use text input - if self._sel_tts == "kokoro": - tts_voice = self._kokoro_combo.currentData() or "af_heart" - tts_speed = self._kokoro_speed_combo.currentData() or "1.2" - else: - tts_voice = self._tts_voice_input.text().strip() or "en-US-GuyNeural" - tts_speed = "1.0" - - _provider = getattr(self, "_sel_llm_provider", "ollama") - _default_url = "http://localhost:1234" if _provider == "openai" else "http://localhost:11434" - cfg = { - "stt_engine": self._sel_stt, - "stt_model": stt_model, - "stt_language": self._stt_lang_input.text().strip() or "auto", - "llm_provider": _provider, - "llm_url": self._llm_url_input.text().strip() or _default_url, - "llm_model": llm_model, - "tts_engine": self._sel_tts, - "tts_voice": tts_voice, - "tts_speed": tts_speed, - "elevenlabs_api_key": self._el_key_input.text().strip(), - } - if self._sel_stt == "vosk" and stt_model: - cfg["vosk_model_path"] = stt_model - self.done.emit(json.dumps(cfg)) + self.done.emit(key, self._sel_os) -class StartupPanel(QWidget): - """Animated startup progress overlay — shown while components initialize.""" +class RemoteKeyOverlay(QWidget): + """Floating overlay — QR code for instant phone pairing + manual key fallback.""" - def __init__(self, parent=None): + closed = pyqtSignal() + + _OW, _OH = 400, 465 + + def __init__(self, url: str, key: str, auto_login_url: str = "", + manual_url: str = "", expiry_secs: int = 600, parent=None): super().__init__(parent) self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) self.setStyleSheet(f""" - StartupPanel {{ - background: rgba(0, 6, 10, 235); + RemoteKeyOverlay {{ + background: rgba(0, 4, 12, 0.95); border: 1px solid {C.BORDER_B}; - border-radius: 8px; + border-radius: 14px; }} """) + self._expiry = time.time() + expiry_secs + self._on_new_key = None + self._auto_login_url = auto_login_url + self._manual_url = manual_url or url lay = QVBoxLayout(self) - lay.setContentsMargins(28, 20, 28, 20) - lay.setSpacing(10) + lay.setContentsMargins(24, 16, 24, 16) + lay.setSpacing(5) - # ── Title ────────────────────────────────────────────────────── - title = QLabel("◈ SYSTEMS INITIALISING") - title.setFont(QFont("Courier New", 11, QFont.Weight.Bold)) - title.setAlignment(Qt.AlignmentFlag.AlignCenter) - title.setStyleSheet(f"color: {C.PRI}; background: transparent;") - lay.addWidget(title) + def _lbl(txt, fs=9, bold=False, color=C.PRI, + align=Qt.AlignmentFlag.AlignCenter): + w = QLabel(txt) + w.setAlignment(align) + w.setFont(QFont("Courier New", fs, + QFont.Weight.Bold if bold else QFont.Weight.Normal)) + w.setStyleSheet(f"color: {color}; background: transparent;") + w.setWordWrap(True) + return w - lay.addSpacing(2) + lay.addWidget(_lbl("◈ REMOTE ACCESS", 12, True)) + sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine) + sep.setStyleSheet(f"color: {C.BORDER}; margin: 1px 0;") + lay.addWidget(sep) - # ── Component rows ────────────────────────────────────────────── - self._rows: dict[str, dict] = {} - _COMPS = [ - ("stt", "SPEECH RECOGNITION (STT)", C.GREEN), - ("llm", "LANGUAGE MODEL (LLM)", C.ACC2), - ("tts", "VOICE SYNTHESIS (TTS)", C.PRI), - ] - for key, label, color in _COMPS: - box = QWidget() - box.setStyleSheet( - f"background: {C.PANEL2}; border: 1px solid {C.BORDER}; border-radius: 4px;" - ) - box_lay = QVBoxLayout(box) - box_lay.setContentsMargins(10, 6, 10, 6) - box_lay.setSpacing(4) + # ── QR code ─────────────────────────────────────────────────────────── + self._qr_label = QLabel() + self._qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._qr_label.setFixedSize(176, 176) + self._qr_label.setStyleSheet( + "background: white; border-radius: 10px; padding: 4px;" + ) + qr_row = QHBoxLayout() + qr_row.addStretch() + qr_row.addWidget(self._qr_label) + qr_row.addStretch() + lay.addLayout(qr_row) - top = QHBoxLayout() - nm = QLabel(label) - nm.setFont(QFont("Courier New", 8, QFont.Weight.Bold)) - nm.setStyleSheet(f"color: {C.TEXT_MED}; background: transparent; border: none;") - top.addWidget(nm) - top.addStretch() + self._update_qr(auto_login_url) - st = QLabel("LOADING…") - st.setFont(QFont("Courier New", 8, QFont.Weight.Bold)) - st.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent; border: none;") - top.addWidget(st) - box_lay.addLayout(top) + lay.addWidget(_lbl("Scan with phone camera to connect instantly", 8, color=C.TEXT_DIM)) - bar = QProgressBar() - bar.setFixedHeight(4) - bar.setRange(0, 0) # indeterminate marquee - bar.setTextVisible(False) - bar.setStyleSheet(f""" - QProgressBar {{ - background: {C.BAR_BG}; border: none; border-radius: 2px; - }} - QProgressBar::chunk {{ - background: qlineargradient(x1:0,y1:0,x2:1,y2:0, - stop:0 {C.BORDER}, stop:1 {color}); - border-radius: 2px; width: 60px; margin: 0px; - }} - """) - box_lay.addWidget(bar) - lay.addWidget(box) - self._rows[key] = {"bar": bar, "status": st, "color": color} + sep2 = QFrame(); sep2.setFrameShape(QFrame.Shape.HLine) + sep2.setStyleSheet(f"color: {C.BORDER}; margin: 1px 0;") + lay.addWidget(sep2) - lay.addSpacing(4) + lay.addWidget(_lbl("Or enter manually:", 7, color=C.TEXT_DIM, + align=Qt.AlignmentFlag.AlignLeft)) - # ── Bottom status ─────────────────────────────────────────────── - self._status_lbl = QLabel("Initialising components…") - self._status_lbl.setFont(QFont("Courier New", 8)) - self._status_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._status_lbl.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent;") - self._status_lbl.setWordWrap(True) - lay.addWidget(self._status_lbl) + self._url_lbl = QLabel(self._manual_url) + self._url_lbl.setFont(QFont("Courier New", 8)) + self._url_lbl.setStyleSheet(f"color: {C.PRI_DIM}; background: transparent;") + self._url_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._url_lbl.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse) + lay.addWidget(self._url_lbl) - tip = QLabel("All AI models run 100% locally · No data leaves your device") - tip.setFont(QFont("Courier New", 7)) - tip.setAlignment(Qt.AlignmentFlag.AlignCenter) - tip.setStyleSheet(f"color: {C.BORDER}; background: transparent;") - lay.addWidget(tip) - - # Called only from the main thread (via MainWindow._startup_sig) - def update_component(self, key: str, status: str) -> None: - if key not in self._rows: - return - row = self._rows[key] - ok = status == "ready" - color = row["color"] if ok else C.RED - label = "READY ✓" if ok else "ERROR ✗" - - bar = row["bar"] - bar.setRange(0, 100) - bar.setValue(100) - bar.setStyleSheet(f""" - QProgressBar {{ - background: {C.BAR_BG}; border: none; border-radius: 2px; - }} - QProgressBar::chunk {{ - background: {color}; border-radius: 2px; - }} + self._key_lbl = QLabel(key) + self._key_lbl.setFont(QFont("Courier New", 28, QFont.Weight.Bold)) + self._key_lbl.setStyleSheet(f""" + color: {C.ACC}; + background: {C.PANEL2}; + border: 1px solid {C.BORDER_B}; + border-radius: 8px; + padding: 6px 4px; + letter-spacing: 10px; """) - st = row["status"] - st.setText(label) - st.setStyleSheet(f"color: {color}; background: transparent; border: none;") + self._key_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + lay.addWidget(self._key_lbl) - def set_status(self, text: str) -> None: - self._status_lbl.setText(text) - col = C.GREEN if "online" in text.lower() else C.TEXT_DIM - self._status_lbl.setStyleSheet(f"color: {col}; background: transparent;") + self._timer_lbl = QLabel() + self._timer_lbl.setFont(QFont("Courier New", 8)) + self._timer_lbl.setStyleSheet(f"color: {C.TEXT_MED}; background: transparent;") + self._timer_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + lay.addWidget(self._timer_lbl) + + btn_row = QHBoxLayout(); btn_row.setSpacing(8) + new_btn = QPushButton("NEW KEY") + new_btn.setFixedHeight(32) + new_btn.setFont(QFont("Courier New", 8, QFont.Weight.Bold)) + new_btn.setCursor(Qt.CursorShape.PointingHandCursor) + new_btn.setStyleSheet(f""" + QPushButton {{ + background: {C.PANEL}; color: {C.PRI}; + border: 1px solid {C.PRI_DIM}; border-radius: 5px; + }} + QPushButton:hover {{ background: {C.PRI_GHO}; border: 1px solid {C.PRI}; }} + """) + new_btn.clicked.connect(self._refresh_key) + btn_row.addWidget(new_btn) + + close_btn = QPushButton("DISMISS") + close_btn.setFixedHeight(32) + close_btn.setFont(QFont("Courier New", 8, QFont.Weight.Bold)) + close_btn.setCursor(Qt.CursorShape.PointingHandCursor) + close_btn.setStyleSheet(f""" + QPushButton {{ + background: transparent; color: {C.TEXT_MED}; + border: 1px solid {C.BORDER}; border-radius: 5px; + }} + QPushButton:hover {{ color: {C.TEXT}; border: 1px solid {C.BORDER_B}; }} + """) + close_btn.clicked.connect(self._do_close) + btn_row.addWidget(close_btn) + lay.addLayout(btn_row) + + self._ctimer = QTimer(self) + self._ctimer.timeout.connect(self._tick) + self._ctimer.start(1000) + self._tick() + + def set_new_key_callback(self, fn) -> None: + self._on_new_key = fn + + def _update_qr(self, url: str) -> None: + if not url: + self._qr_label.setText("—") + return + try: + import qrcode as _qrmod + from io import BytesIO + qr = _qrmod.QRCode( + box_size=5, border=2, + error_correction=_qrmod.constants.ERROR_CORRECT_M, + ) + qr.add_data(url) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + buf = BytesIO() + img.save(buf, format="PNG") + px = QPixmap() + px.loadFromData(buf.getvalue()) + self._qr_label.setPixmap( + px.scaled(170, 170, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation) + ) + except ImportError: + self._qr_label.setText("pip install\nqrcode[pil]") + self._qr_label.setFont(QFont("Courier New", 8)) + self._qr_label.setStyleSheet( + "color: #888; background: white; border-radius: 10px; padding: 4px;" + ) + except Exception: + self._qr_label.setText(url[:28]) + self._qr_label.setFont(QFont("Courier New", 7)) + self._qr_label.setStyleSheet( + f"color: {C.PRI}; background: white; border-radius: 10px; padding: 4px;" + ) + + def _tick(self): + remaining = max(0, int(self._expiry - time.time())) + m, s = divmod(remaining, 60) + self._timer_lbl.setText(f"Key expires in {m:02d}:{s:02d}") + if remaining == 0: + self._do_close() + + def mark_connected(self) -> None: + """Call from any thread when a phone successfully connects.""" + self._ctimer.stop() + self._key_lbl.setText("CONNECTED") + self._key_lbl.setStyleSheet(f""" + color: {C.GREEN}; + background: rgba(34,197,94,0.08); + border: 2px solid rgba(34,197,94,0.4); + border-radius: 8px; + padding: 6px 4px; + letter-spacing: 4px; + """) + self._qr_label.setText("✓") + self._qr_label.setFont(QFont("Courier New", 54, QFont.Weight.Bold)) + self._qr_label.setStyleSheet( + "color: #00ff88; background: #001a0d; border-radius: 10px;" + ) + self._timer_lbl.setText("Phone connected — JARVIS ready") + self._timer_lbl.setStyleSheet(f"color: {C.GREEN}; background: transparent;") + + def _refresh_key(self): + if self._on_new_key: + result = self._on_new_key() + if result: + url = result[0] + key = result[1] + auto = result[2] if len(result) >= 3 else "" + manual = result[3] if len(result) >= 4 else url + self._manual_url = manual or url + self._url_lbl.setText(self._manual_url) + self._key_lbl.setText(key) + self._auto_login_url = auto + self._update_qr(auto or url) + self._expiry = time.time() + 600 + self._key_lbl.setStyleSheet(f""" + color: {C.ACC}; + background: {C.PANEL2}; + border: 1px solid {C.BORDER_B}; + border-radius: 8px; + padding: 6px 4px; + letter-spacing: 10px; + """) + self._timer_lbl.setStyleSheet( + f"color: {C.TEXT_MED}; background: transparent;" + ) + self._ctimer.start(1000) + self._tick() + + def _do_close(self): + self._ctimer.stop() + self.hide() + self.closed.emit() class MainWindow(QMainWindow): - _log_sig = pyqtSignal(str) - _state_sig = pyqtSignal(str) - _startup_sig = pyqtSignal(str, str) # action, data — thread-safe startup panel control + _log_sig = pyqtSignal(str) + _state_sig = pyqtSignal(str) def __init__(self, face_path: str): super().__init__() - self.setWindowTitle("J.A.R.V.I.S — MARK XL") + self.setWindowTitle("J.A.R.V.I.S — MARK XLVI") self.setMinimumSize(_MIN_W, _MIN_H) self.resize(_DEFAULT_W, _DEFAULT_H) @@ -1481,8 +1229,10 @@ class MainWindow(QMainWindow): ) self.on_text_command = None + self.on_remote_clicked = None # callable: () -> (url, key) | None self._muted = False self._current_file: str | None = None + self._remote_overlay: RemoteKeyOverlay | None = None central = QWidget() central.setStyleSheet(f"background: {C.BG};") @@ -1523,11 +1273,8 @@ class MainWindow(QMainWindow): self._log_sig.connect(self._log.append_log) self._state_sig.connect(self._apply_state) - self._startup_sig.connect(self._on_startup_sig) self._overlay: SetupOverlay | None = None - self._startup_panel: StartupPanel | None = None - self._on_reconfigure_cb = None self._ready = self._check_config() if not self._ready: self._show_setup() @@ -1547,57 +1294,20 @@ class MainWindow(QMainWindow): super().resizeEvent(event) cw = self.centralWidget() if self._overlay and self._overlay.isVisible(): - ow, oh = 520, 580 + ow, oh = 460, 390 self._overlay.setGeometry( (cw.width() - ow) // 2, (cw.height() - oh) // 2, ow, oh, ) - if self._startup_panel and self._startup_panel.isVisible(): - pw, ph = 400, 310 - self._startup_panel.setGeometry( - (cw.width() - pw) // 2, - (cw.height() - ph) // 2, - pw, ph, + if self._remote_overlay and self._remote_overlay.isVisible(): + ow, oh = RemoteKeyOverlay._OW, RemoteKeyOverlay._OH + self._remote_overlay.setGeometry( + (cw.width() - ow) // 2, + (cw.height() - oh) // 2, + ow, oh, ) - # ── Startup panel (thread-safe via _startup_sig) ──────────────────── - def _on_startup_sig(self, action: str, data: str) -> None: - """Runs on main thread — handles all startup panel state changes.""" - if action == "show": - self._create_startup_panel() - elif action in ("ready", "error"): - if self._startup_panel: - self._startup_panel.update_component(data, action) - elif action == "status": - if self._startup_panel: - self._startup_panel.set_status(data) - elif action == "hide": - if self._startup_panel: - # Fade out after a short pause so "READY ✓" is visible - QTimer.singleShot(1200, self._destroy_startup_panel) - - def _create_startup_panel(self) -> None: - if self._startup_panel and self._startup_panel.isVisible(): - return - cw = self.centralWidget() - pw, ph = 400, 310 - panel = StartupPanel(cw) - panel.setGeometry( - (cw.width() - pw) // 2, - (cw.height() - ph) // 2, - pw, ph, - ) - panel.show() - panel.raise_() - self._startup_panel = panel - - def _destroy_startup_panel(self) -> None: - if self._startup_panel: - self._startup_panel.hide() - self._startup_panel.deleteLater() - self._startup_panel = None - def _update_metrics(self): snap = _metrics.snapshot() @@ -1662,7 +1372,7 @@ class MainWindow(QMainWindow): l.setStyleSheet(f"color: {color}; background: transparent;") return l - lay.addWidget(_badge("MARK XL", C.PRI_DIM)) + lay.addWidget(_badge("MARK XLVI", C.PRI_DIM)) lay.addStretch() mid = QVBoxLayout(); mid.setSpacing(1) @@ -1754,7 +1464,7 @@ class MainWindow(QMainWindow): for txt, col in [ ("AI CORE\nACTIVE", C.GREEN), ("SEC\nCLEARED", C.PRI), - ("PROTOCOL\nXL", C.TEXT_DIM), + ("PROTOCOL\nXXXVIII", C.TEXT_DIM), ]: lbl = QLabel(txt) lbl.setFont(QFont("Courier New", 7, QFont.Weight.Bold)) @@ -1814,6 +1524,22 @@ class MainWindow(QMainWindow): self._style_mute_btn() lay.addWidget(self._mute_btn) + remote_btn = QPushButton("◉ REMOTE CONTROL") + remote_btn.setFixedHeight(30) + remote_btn.setFont(QFont("Courier New", 8, QFont.Weight.Bold)) + remote_btn.setCursor(Qt.CursorShape.PointingHandCursor) + remote_btn.setStyleSheet(f""" + QPushButton {{ + background: #00091a; color: {C.PRI}; + border: 1px solid {C.PRI_DIM}; border-radius: 3px; + }} + QPushButton:hover {{ + background: {C.PRI_GHO}; border: 1px solid {C.PRI}; + }} + """) + remote_btn.clicked.connect(self._open_remote) + lay.addWidget(remote_btn) + fs_btn = QPushButton("⛶ FULLSCREEN [F11]") fs_btn.setFixedHeight(26) fs_btn.setFont(QFont("Courier New", 7)) @@ -1830,22 +1556,6 @@ class MainWindow(QMainWindow): fs_btn.clicked.connect(self._toggle_fullscreen) lay.addWidget(fs_btn) - cfg_btn = QPushButton("⚙ CONFIGURE") - cfg_btn.setFixedHeight(26) - cfg_btn.setFont(QFont("Courier New", 7)) - cfg_btn.setCursor(Qt.CursorShape.PointingHandCursor) - cfg_btn.setStyleSheet(f""" - QPushButton {{ - background: transparent; color: {C.TEXT_MED}; - border: 1px solid {C.BORDER}; border-radius: 3px; - }} - QPushButton:hover {{ - color: {C.ACC2}; border: 1px solid {C.ACC2}; - }} - """) - cfg_btn.clicked.connect(self._show_config) - lay.addWidget(cfg_btn) - return w def _build_input_row(self) -> QHBoxLayout: @@ -1892,9 +1602,9 @@ class MainWindow(QMainWindow): lay.addWidget(_fl("[F4] Mute · [F11] Fullscreen")) lay.addStretch() - lay.addWidget(_fl("FatihMakes Industries · MARK XL · CLASSIFIED")) + lay.addWidget(_fl("FatihMakes Industries · MARK XLVI · CLASSIFIED")) lay.addStretch() - lay.addWidget(_fl("© FATIHMAKES", C.PRI_DIM)) + lay.addWidget(_fl("© STARK INDUSTRIES", C.PRI_DIM)) return w def _on_file_selected(self, path: str): @@ -1914,6 +1624,39 @@ class MainWindow(QMainWindow): ) threading.Thread(target=self.on_text_command, args=(msg,), daemon=True).start() + def notify_phone_connected(self) -> None: + if self._remote_overlay and self._remote_overlay.isVisible(): + self._remote_overlay.mark_connected() + + def _open_remote(self): + if not self.on_remote_clicked: + self._log.append_log("SYS: Dashboard not running — remote unavailable.") + return + result = self.on_remote_clicked() + if not result: + self._log.append_log("SYS: Could not generate remote key.") + return + url = result[0] + key = result[1] + auto = result[2] if len(result) >= 3 else "" + manual = result[3] if len(result) >= 4 else url + if self._remote_overlay: + self._remote_overlay._do_close() + cw = self.centralWidget() + ow, oh = RemoteKeyOverlay._OW, RemoteKeyOverlay._OH + ov = RemoteKeyOverlay(url, key, auto_login_url=auto, manual_url=manual, + expiry_secs=600, parent=cw) + ov.set_new_key_callback(self.on_remote_clicked) + ov.setGeometry( + (cw.width() - ow) // 2, + (cw.height() - oh) // 2, + ow, oh, + ) + ov.closed.connect(lambda: setattr(self, '_remote_overlay', None)) + ov.show() + self._remote_overlay = ov + self._log.append_log(f"SYS: Remote key generated — manual: {manual or url}") + def _toggle_mute(self): self._muted = not self._muted self.hud.muted = self._muted @@ -1948,6 +1691,7 @@ class MainWindow(QMainWindow): txt = self._input.text().strip() if not txt: return self._input.clear() + self._log.append_log(f"You: {txt}") if self.on_text_command: threading.Thread(target=self.on_text_command, args=(txt,), daemon=True).start() @@ -1959,18 +1703,14 @@ class MainWindow(QMainWindow): if not API_FILE.exists(): return False try: d = json.loads(API_FILE.read_text(encoding="utf-8")) - return ( - bool(d.get("llm_model")) and - bool(d.get("stt_engine")) and - bool(d.get("tts_engine")) - ) + return bool(d.get("gemini_api_key")) and bool(d.get("os_system")) except Exception: return False def _show_setup(self): ov = SetupOverlay(self.centralWidget()) cw = self.centralWidget() - ow, oh = 520, 580 + ow, oh = 460, 390 ov.setGeometry( (cw.width() - ow) // 2, (cw.height() - oh) // 2, @@ -1980,14 +1720,10 @@ class MainWindow(QMainWindow): ov.show() self._overlay = ov - def _on_setup_done(self, config_json: str): - try: - cfg = json.loads(config_json) - except Exception: - cfg = {} + def _on_setup_done(self, key: str, os_name: str): os.makedirs(CONFIG_DIR, exist_ok=True) API_FILE.write_text( - json.dumps(cfg, indent=4), + json.dumps({"gemini_api_key": key, "os_system": os_name}, indent=4), encoding="utf-8", ) self._ready = True @@ -1995,50 +1731,7 @@ class MainWindow(QMainWindow): self._overlay.hide() self._overlay = None self._apply_state("LISTENING") - llm = cfg.get("llm_model", "") - stt = cfg.get("stt_engine", "") - tts = cfg.get("tts_engine", "") - self._log.append_log( - f"SYS: Initialised. LLM={llm} | STT={stt} | TTS={tts}" - ) - - def _show_config(self): - if self._overlay and self._overlay.isVisible(): - return - current: dict = {} - try: - current = json.loads(API_FILE.read_text(encoding="utf-8")) - except Exception: - pass - ov = SetupOverlay(self.centralWidget(), initial=current, mode="config") - cw = self.centralWidget() - ow, oh = 520, 600 - ov.setGeometry( - (cw.width() - ow) // 2, - (cw.height() - oh) // 2, - ow, oh, - ) - ov.done.connect(self._on_config_done) - ov.show() - self._overlay = ov - - def _on_config_done(self, config_json: str): - try: - cfg = json.loads(config_json) - except Exception: - cfg = {} - os.makedirs(CONFIG_DIR, exist_ok=True) - API_FILE.write_text(json.dumps(cfg, indent=4), encoding="utf-8") - if self._overlay: - self._overlay.hide() - self._overlay = None - llm = cfg.get("llm_model", "") - stt = cfg.get("stt_engine", "") - tts = cfg.get("tts_engine", "") - self._log.append_log(f"SYS: Config updated. LLM={llm} | STT={stt} | TTS={tts}") - if self._on_reconfigure_cb: - self._on_reconfigure_cb(cfg) - + self._log.append_log(f"SYS: Initialised. OS={os_name.upper()}. JARVIS online.") class _RootShim: def __init__(self, app: QApplication): @@ -2079,12 +1772,15 @@ class JarvisUI: self._win.on_text_command = cb @property - def on_reconfigure(self): - return self._win._on_reconfigure_cb + def on_remote_clicked(self): + return self._win.on_remote_clicked - @on_reconfigure.setter - def on_reconfigure(self, cb): - self._win._on_reconfigure_cb = cb + @on_remote_clicked.setter + def on_remote_clicked(self, cb): + self._win.on_remote_clicked = cb + + def notify_phone_connected(self) -> None: + self._win.notify_phone_connected() def set_state(self, state: str): self._win._state_sig.emit(state) @@ -2092,19 +1788,6 @@ class JarvisUI: def write_log(self, text: str): self._win._log_sig.emit(text) - # ── Startup panel (all thread-safe) ────────────────────────────────── - def show_startup_panel(self) -> None: - self._win._startup_sig.emit("show", "") - - def mark_startup_ready(self, key: str, error: bool = False) -> None: - self._win._startup_sig.emit("error" if error else "ready", key) - - def set_startup_status(self, text: str) -> None: - self._win._startup_sig.emit("status", text) - - def hide_startup_panel(self) -> None: - self._win._startup_sig.emit("hide", "") - def wait_for_api_key(self): while not self._win._ready: time.sleep(0.1) @@ -2114,4 +1797,4 @@ class JarvisUI: def stop_speaking(self): if not self.muted: - self.set_state("LISTENING") + self.set_state("LISTENING") \ No newline at end of file