From 2c40eb55edcd9049aed2e9d48df3ca71a1dcec73 Mon Sep 17 00:00:00 2001 From: FatihMakes Date: Wed, 15 Jul 2026 18:47:21 +0300 Subject: [PATCH] Add files via upload --- actions/browser_control.py | 225 +++++++- actions/code_helper.py | 76 +-- actions/computer_settings.py | 17 +- actions/open_app.py | 18 +- actions/reminder.py | 13 +- main.py | 152 +++-- memory/config_manager.py | 42 +- readme.md | 76 ++- requirements.txt | 2 + setup.py | 15 + ui.py | 1048 +++++++++++++++++++++++++++++++--- 11 files changed, 1447 insertions(+), 237 deletions(-) diff --git a/actions/browser_control.py b/actions/browser_control.py index 8f18767..10cb685 100644 --- a/actions/browser_control.py +++ b/actions/browser_control.py @@ -8,6 +8,7 @@ import platform import shutil import subprocess import threading +import webbrowser from pathlib import Path from typing import Optional @@ -344,6 +345,102 @@ def _detect_default_browser() -> str: return "chrome" +_SEARCH_ENGINES: dict[str, str] = { + "google": "https://www.google.com/search?q=", + "bing": "https://www.bing.com/search?q=", + "duckduckgo": "https://duckduckgo.com/?q=", + "yandex": "https://yandex.com/search/?text=", +} + +_MAC_APP_NAMES: dict[str, str] = { + "chrome": "Google Chrome", + "edge": "Microsoft Edge", + "firefox": "Firefox", + "opera": "Opera", + "operagx": "Opera GX", + "brave": "Brave Browser", + "vivaldi": "Vivaldi", + "safari": "Safari", +} + +# Windows registry lookup names for browsers whose spec has no explicit binary +_WIN_EXE_HINTS: dict[str, str] = {"chrome": "chrome", "edge": "msedge"} + + +def _open_native(url: str, browser_name: Optional[str]) -> str: + """ + Kullanıcının GERÇEK tarayıcısını normal şekilde açar — kendi profili, + giriş yapılmış hesapları ve eklentileriyle. Otomasyon bağlanmaz, bu yüzden + about:blank sekmesi veya boş profil ASLA görünmez. + url boş ise tarayıcı URL'siz başlatılır (kendi açılış sayfası / + oturum geri yükleme ile) — tıpkı kullanıcının kendisi açmış gibi. + Windows / macOS / Linux üçünde de çalışır. + """ + url = _normalize_url(url) if url and url.strip() else "" + if url == "about:blank": + url = "" + + name = None + if browser_name: + name = _ALIASES.get(browser_name.lower().strip(), browser_name.lower().strip()) + elif not url: + # URL yok → sadece pencere açılacak; varsayılan tarayıcının exe'si gerekir + name = _detect_default_browser() + + # Specific browser → launch its own executable, exactly like the user would. + if name: + if _OS == "Darwin": + app = _MAC_APP_NAMES.get(name) + if app: + cmd = ["open", "-a", app] + ([url] if url else []) + try: + subprocess.run(cmd, check=True, timeout=10) + return f"Opened in {name}: {url}" if url else f"Opened {name}." + except Exception as e: + print(f"[Browser] 'open -a {app}' failed ({e}), trying binary…") + + spec = _resolve_browser(name) + exe = spec.get("exe") if spec else None + if not exe and _OS == "Windows": + if name in ("opera", "operagx"): + exe = _find_opera_windows() + else: + exe = _find_exe_windows(_WIN_EXE_HINTS.get(name, name)) + if exe: + try: + subprocess.Popen( + [exe, url] if url else [exe], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + return f"Opened in {name}: {url}" if url else f"Opened {name}." + except Exception as e: + print(f"[Browser] Native launch failed for {name}: {e}") + print(f"[Browser] '{name}' not found — falling back to default browser.") + + if not url: + return "Could not find a browser to open." + + # Default browser via the OS — exactly like the user clicking a link. + try: + if _OS == "Windows": + os.startfile(url) # ShellExecute → default browser + elif _OS == "Darwin": + subprocess.run(["open", url], check=True, timeout=10) + else: + subprocess.Popen( + ["xdg-open", url], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + return f"Opened in your default browser: {url}" + except Exception: + try: + if webbrowser.open(url): + return f"Opened in your default browser: {url}" + except Exception: + pass + return f"Could not open a browser for: {url}" + + class _BrowserSession: """ Bir tarayıcı örneği için tam oturum. @@ -406,6 +503,16 @@ class _BrowserSession: pass self._context = self._page = None + async def _adopt_page(self) -> Page: + """ + launch_persistent_context zaten bir başlangıç sekmesi açar. + Yeni bir boş sekme (about:blank) açmak yerine o sekmeyi devralır — + böylece kullanıcı fazladan boş sekme görmez. + """ + await asyncio.sleep(0.3) + pages = self._context.pages + return pages[0] if pages else await self._context.new_page() + async def _launch(self): """ Tarayıcıyı gerçek kullanıcı profiliyle başlatır. @@ -433,6 +540,7 @@ class _BrowserSession: "slow_mo": 0, "viewport": None, "no_viewport": True, + "timeout": 25_000, } if exe: kwargs["executable_path"] = exe @@ -444,8 +552,7 @@ class _BrowserSession: Path(jarvis).mkdir(parents=True, exist_ok=True) self._context = await engine_obj.launch_persistent_context(jarvis, **kwargs) - await asyncio.sleep(0.5) - self._page = await self._context.new_page() + self._page = await self._adopt_page() print(f"[Browser] ✅ Firefox launched") return @@ -457,10 +564,10 @@ class _BrowserSession: "slow_mo": 0, "viewport": None, "no_viewport": True, + "timeout": 25_000, } self._context = await engine_obj.launch_persistent_context(safari_profile, **kwargs) - await asyncio.sleep(0.5) - self._page = await self._context.new_page() + self._page = await self._adopt_page() print(f"[Browser] ✅ Safari launched") return @@ -471,6 +578,7 @@ class _BrowserSession: "slow_mo": 0, "viewport": None, "no_viewport": True, + "timeout": 25_000, "args": [ "--start-maximized", "--disable-blink-features=AutomationControlled", @@ -493,22 +601,25 @@ class _BrowserSession: try: self._context = await engine_obj.launch_persistent_context(profile, **kwargs) - await asyncio.sleep(0.5) - self._page = await self._context.new_page() + self._page = await self._adopt_page() print(f"[Browser] ✅ Launched [{label}] profile={profile}") return except Exception as e: print(f"[Browser] ⚠️ Real profile failed for {label}: {e}") + # Gerçek profil açılamadı (tarayıcı zaten açık / kilitli profil / yeni + # Chrome sürümleri otomasyonla gerçek profili engelliyor). Kalıcı + # JARVIS otomasyon profiline geçilir — buraya bir kez giriş yapılan + # hesaplar sonraki oturumlarda da açık kalır. jarvis_profile = str(Path.home() / ".jarvis_profiles" / self.browser_name) Path(jarvis_profile).mkdir(parents=True, exist_ok=True) print(f"[Browser] Retrying with JARVIS profile: {jarvis_profile}") try: self._context = await engine_obj.launch_persistent_context(jarvis_profile, **kwargs) - await asyncio.sleep(0.5) - self._page = await self._context.new_page() - print(f"[Browser] ✅ Launched [{label}] with JARVIS profile") + self._page = await self._adopt_page() + print(f"[Browser] ✅ Launched [{label}] with JARVIS profile " + f"(sign-ins persist across sessions)") except Exception as e2: raise RuntimeError(f"Could not launch {self.browser_name}: {e2}") from e2 @@ -554,13 +665,7 @@ class _BrowserSession: return f"Could not open: {url}" async def search(self, query: str, engine: str = "google") -> str: - _engines = { - "google": "https://www.google.com/search?q=", - "bing": "https://www.bing.com/search?q=", - "duckduckgo": "https://duckduckgo.com/?q=", - "yandex": "https://yandex.com/search/?text=", - } - base = _engines.get(engine.lower(), _engines["google"]) + base = _SEARCH_ENGINES.get(engine.lower(), _SEARCH_ENGINES["google"]) return await self.go_to(base + query.replace(" ", "+")) async def click(self, selector: str = None, text: str = None) -> str: @@ -738,9 +843,26 @@ class _SessionRegistry: """Tüm aktif tarayıcı oturumlarını yönetir.""" def __init__(self): - self._sessions: dict[str, _BrowserSession] = {} - self._active_browser: str = "" - self._lock = threading.Lock() + self._sessions: dict[str, _BrowserSession] = {} + self._active_browser: str = "" + self._lock = threading.Lock() + self._last_native_url: str = "" + + def has(self, browser_name: str | None = None) -> bool: + """Bu tarayıcı için (veya hiç) aktif bir otomasyon oturumu var mı?""" + with self._lock: + if not browser_name: + return bool(self._sessions) + name = _ALIASES.get(browser_name.lower().strip(), browser_name.lower().strip()) + return name in self._sessions + + def note_native_url(self, url: str) -> None: + self._last_native_url = url + + def pop_native_url(self) -> str: + """Son native açılan URL'yi bir kez döndürür (tekrarı önlemek için tüketilir).""" + url, self._last_native_url = self._last_native_url, "" + return url def _get_or_create(self, browser_name: str) -> _BrowserSession: with self._lock: @@ -828,6 +950,53 @@ def browser_control( _log(player, result) return result + if action == "close": + target = browser or _registry._active_browser + result = _registry.close_one(target) if target else "No browser specified." + _log(player, result) + return result + + # ── Gezinme HER ZAMAN native ───────────────────────────────────────────── + # go_to / search / new_tab siteyi kullanıcının kendi tarayıcısında açar — + # kendi profili, giriş yapılmış hesapları ve açılış sayfasıyla; tıpkı + # kullanıcının kendisi açmış gibi. about:blank'li kontrollü pencere burada + # asla açılmaz. Tek istisna: hâlihazırda süren bir otomasyon akışı varsa + # gezinme o pencerede devam eder (çok adımlı görevler bölünmesin diye). + if action in ("go_to", "search", "new_tab"): + if _registry.has(browser): + sess = _registry.get(browser) + try: + if action == "search": + result = sess.run(sess.search(params.get("query", ""), + params.get("engine", "google"))) + elif action == "new_tab": + result = sess.run(sess.new_tab(params.get("url", ""))) + else: + result = sess.run(sess.go_to(params.get("url", ""))) + except concurrent.futures.TimeoutError: + result = f"Browser action '{action}' timed out (60s)." + except Exception as e: + result = f"Browser error ({action}): {e}" + _log(player, result) + return result + + if action == "search": + base = _SEARCH_ENGINES.get(params.get("engine", "google").lower(), + _SEARCH_ENGINES["google"]) + nav_url = base + params.get("query", "").replace(" ", "+") + else: + nav_url = params.get("url", "").strip() + + result = _open_native(nav_url, browser) + if result.startswith("Opened") and nav_url: + _registry.note_native_url(_normalize_url(nav_url)) + _log(player, result) + return result + + # ── Etkileşimli aksiyonlar (tıklama/yazma/okuma…) ──────────────────────── + # Bunlar fiziksel olarak kontrol edilebilir bir tarayıcı gerektirir; + # yalnızca burada otomasyon penceresi açılır ve açılır açılmaz kullanıcının + # son gezindiği sayfaya gider — boş sayfada beklemez. try: sess = _registry.get(browser) except Exception as e: @@ -836,11 +1005,14 @@ def browser_control( return result try: - if action == "go_to": - result = sess.run(sess.go_to(params.get("url", ""))) - elif action == "search": - result = sess.run(sess.search(params.get("query", ""), params.get("engine", "google"))) - elif action == "click": + last = _registry.pop_native_url() + if last: + try: + sess.run(sess.go_to(last)) + except Exception as e: + print(f"[Browser] Could not resume last page ({last}): {e}") + + if action == "click": result = sess.run(sess.click(params.get("selector"), params.get("text"))) elif action == "type": result = sess.run(sess.type_text( @@ -859,8 +1031,6 @@ def browser_control( result = sess.run(sess.get_url()) elif action == "press": result = sess.run(sess.press(params.get("key", "Enter"))) - elif action == "new_tab": - result = sess.run(sess.new_tab(params.get("url", ""))) elif action == "close_tab": result = sess.run(sess.close_tab()) elif action == "screenshot": @@ -871,9 +1041,6 @@ def browser_control( result = sess.run(sess.forward()) elif action == "reload": result = sess.run(sess.reload()) - elif action == "close": - target = browser or _registry._active_browser - result = _registry.close_one(target) if target else "No browser specified." else: result = f"Unknown browser action: '{action}'" diff --git a/actions/code_helper.py b/actions/code_helper.py index 38e6357..1010658 100644 --- a/actions/code_helper.py +++ b/actions/code_helper.py @@ -110,43 +110,53 @@ def _image_to_base64(path: Path) -> str: return base64.b64encode(path.read_bytes()).decode("utf-8") +_VALID_INTENTS = {"write", "edit", "explain", "run", "build", "screen_debug", "optimize"} + + def _detect_intent(description: str, file_path: str, code: str) -> str: - desc = (description or "").lower() + """ + Dil bağımsız niyet tespiti — sabit anahtar kelime listesi YOK. + Kullanıcı hangi dilde konuşursa konuşsun, açıklama Gemini'ye + sınıflandırtılır. API'ye ulaşılamazsa dile bakmayan yapısal + ipuçlarına (dosya diskte var mı, kod verilmiş mi) düşülür. + """ + desc = (description or "").strip() + file_exists = bool(file_path) and Path(file_path).exists() - screen_kw = ["ekrandaki", "screen", "ekranda", "bu hatayı", "why am i getting", - "neden hata", "what's wrong", "ne yanlış", "screenshot", "görüntü"] - if any(k in desc for k in screen_kw): - return "screen_debug" + if desc: + try: + ctx = [] + if file_path: + ctx.append(f"a file path is provided (exists on disk: {file_exists})") + if code: + ctx.append("an inline code snippet is provided") + prompt = ( + "Classify a coding assistant request into exactly ONE intent word.\n" + "The request may be written in ANY language.\n\n" + f"Request: {desc}\n" + + (f"Context: {'; '.join(ctx)}\n" if ctx else "") + + "\nIntents:\n" + " write = create new code from scratch\n" + " edit = modify an existing file\n" + " explain = describe what given code/file does\n" + " run = execute an existing file\n" + " build = write code, run it, and iterate until it works\n" + " screen_debug = analyze an error currently visible on the user's screen\n" + " optimize = refactor / clean up / speed up existing code\n\n" + "Reply with ONLY the intent word, nothing else." + ) + ans = _get_gemini().generate_content(prompt).text.strip().lower() + ans = ans.strip("`'\". \n") + if ans in _VALID_INTENTS: + return ans + except Exception as e: + print(f"[Code] Intent classification failed ({e}) — structural fallback") - optimize_kw = ["optimize", "refactor", "clean up", "improve", "temizle", - "iyileştir", "daha iyi", "make it better", "hızlandır"] - if any(k in desc for k in optimize_kw) and (code or file_path): - return "optimize" - - if file_path: - p = Path(file_path) - edit_kw = ["edit", "update", "modify", "change", "add", "remove", - "refactor", "fix", "rename", "replace", "düzenle", "değiştir"] - run_kw = ["run", "execute", "launch", "start", "çalıştır"] - build_kw = ["build", "make it work", "try", "attempt"] - - if p.exists() and any(k in desc for k in edit_kw): - return "edit" - if p.exists() and any(k in desc for k in run_kw): - return "run" - if any(k in desc for k in build_kw): - return "build" - if p.exists(): - return "explain" - - explain_kw = ["explain", "what does", "describe", "analyze", "açıkla", "ne yapıyor"] - if any(k in desc for k in explain_kw) and (code or file_path): + # Yapısal geri dönüş — hiçbir dile bağlı değil + if file_exists: + return "edit" if desc else "explain" + if code: return "explain" - - build_kw = ["build", "make it work", "try and", "attempt"] - if any(k in desc for k in build_kw): - return "build" - return "write" def _write(description: str, language: str, output_path: str, player=None) -> tuple[str, Path]: diff --git a/actions/computer_settings.py b/actions/computer_settings.py index 3b887e5..5dd2285 100644 --- a/actions/computer_settings.py +++ b/actions/computer_settings.py @@ -206,7 +206,14 @@ def maximize_window(): def snap_left(): if _OS == "Windows": pyautogui.hotkey("win", "left") - elif _OS == "Linux": + elif _OS == "Darwin": + # macOS has no built-in snap; try Rectangle app shortcut if installed + try: + subprocess.run(["open", "-a", "Rectangle"], capture_output=True, timeout=1) + except Exception: + pass + pyautogui.hotkey("ctrl", "option", "left") + else: # Linux try: subprocess.run(["wmctrl", "-r", ":ACTIVE:", "-e", "0,0,0,960,1080"], capture_output=True) @@ -216,7 +223,13 @@ def snap_left(): def snap_right(): if _OS == "Windows": pyautogui.hotkey("win", "right") - elif _OS == "Linux": + elif _OS == "Darwin": + try: + subprocess.run(["open", "-a", "Rectangle"], capture_output=True, timeout=1) + except Exception: + pass + pyautogui.hotkey("ctrl", "option", "right") + else: # Linux try: subprocess.run(["wmctrl", "-r", ":ACTIVE:", "-e", "0,960,0,960,1080"], capture_output=True) diff --git a/actions/open_app.py b/actions/open_app.py index 54ade6d..5e49f82 100644 --- a/actions/open_app.py +++ b/actions/open_app.py @@ -34,7 +34,7 @@ _APP_ALIASES: dict[str, dict[str, str]] = { "vscode": {"Windows": "code", "Darwin": "Visual Studio Code", "Linux": "code"}, "visual studio code": {"Windows": "code", "Darwin": "Visual Studio Code", "Linux": "code"}, "code": {"Windows": "code", "Darwin": "Visual Studio Code", "Linux": "code"}, - "terminal": {"Windows": "wt", "Darwin": "Terminal", "Linux": "gnome-terminal"}, + "terminal": {"Windows": "wt", "Darwin": "Terminal", "Linux": "x-terminal-emulator"}, "cmd": {"Windows": "cmd.exe", "Darwin": "Terminal", "Linux": "bash"}, "powershell": {"Windows": "powershell.exe", "Darwin": "Terminal", "Linux": "bash"}, "postman": {"Windows": "Postman", "Darwin": "Postman", "Linux": "postman"}, @@ -168,8 +168,24 @@ def _launch_macos(app_name: str) -> bool: return False +_LINUX_TERMINAL_FALLBACKS = [ + "x-terminal-emulator", "gnome-terminal", "konsole", "xfce4-terminal", + "xterm", "lxterminal", "mate-terminal", "tilix", "alacritty", "kitty", +] + def _launch_linux(app_name: str) -> bool: + # terminal emulators: try common ones in order + if app_name in ("x-terminal-emulator", "gnome-terminal", "terminal"): + for term in _LINUX_TERMINAL_FALLBACKS: + if shutil.which(term): + try: + subprocess.Popen([term], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + time.sleep(1.0) + return True + except Exception: + continue + binary = ( shutil.which(app_name) or shutil.which(app_name.lower()) or diff --git a/actions/reminder.py b/actions/reminder.py index 90540e0..6bc7dd3 100644 --- a/actions/reminder.py +++ b/actions/reminder.py @@ -19,13 +19,12 @@ def _base_dir() -> Path: def _get_os() -> str: - try: - cfg = json.loads( - (_base_dir() / "config" / "api_keys.json").read_text(encoding="utf-8") - ) - return cfg.get("os_system", "windows").lower() - except Exception: - return "windows" + _sys = platform.system() + if _sys == "Darwin": + return "mac" + if _sys == "Linux": + return "linux" + return "windows" def _scripts_dir() -> Path: diff --git a/main.py b/main.py index a6cac01..0eb38ee 100644 --- a/main.py +++ b/main.py @@ -52,6 +52,8 @@ from actions.computer_control import computer_control from actions.game_updater import game_updater from actions.system_monitor import SystemMonitor, get_system_status from actions.proactive import ProactiveEngine +from actions.web_search import _news as _fetch_news_sync +from memory.config_manager import get_brief_enabled def get_base_dir(): @@ -248,6 +250,9 @@ TOOL_DECLARATIONS = [ "description": ( "Controls any web browser. Use for: opening websites, searching the web, " "clicking elements, filling forms, scrolling, screenshots, navigation, any web-based task. " + "Simple open/search requests launch the user's own browser normally (their real profile " + "and logged-in accounts); interactive actions (click, type, fill_form...) attach an " + "automation browser. " "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." ), @@ -520,6 +525,7 @@ class JarvisLive: def __init__(self, ui: JarvisUI): self.ui = ui + self._asst_name = "JARVIS" # updated each session from config self.session = None self.audio_in_queue = None self.out_queue = None @@ -613,6 +619,15 @@ class JarvisLive: def _build_config(self) -> types.LiveConnectConfig: from datetime import datetime + # Load customization from config + try: + _cfg = json.loads(open(API_CONFIG_PATH, encoding="utf-8").read()) + self._asst_name = (_cfg.get("assistant_name") or "JARVIS").strip() + _user_name = (_cfg.get("user_name") or "").strip() + except Exception: + self._asst_name = "JARVIS" + _user_name = "" + memory = load_memory() mem_str = format_memory_for_prompt(memory) sys_prompt = _load_system_prompt() @@ -625,7 +640,19 @@ class JarvisLive: f"Use this to calculate exact times for reminders.\n\n" ) - parts = [time_ctx] + # Identity injection — overrides any hardcoded name in prompt.txt + _addr = (f"ADDRESS: Always call the user '{_user_name}'." + if _user_name + else "ADDRESS: When speaking Turkish → always say \"efendim\". " + "When speaking English → say \"sir\". Never mix languages.") + identity_ctx = ( + f"[IDENTITY]\n" + f"Your name is {self._asst_name}. " + f"Always refer to yourself as {self._asst_name}.\n" + f"{_addr}\n\n" + ) + + parts = [time_ctx, identity_ctx] if mem_str: parts.append(mem_str) parts.append(sys_prompt) @@ -725,9 +752,8 @@ class JarvisLive: self._pending_vision = (img_b, mime_t, user_text, angle) result = ( f"[VISION_ACTIVE] {_stall.capitalize()} captured. " - f"Immediately say ONE natural sentence in the user's language " - f"(e.g. 'Looking at your {_stall} now, sir' / " - f"'{'Kameraya' if _stall == 'camera' else 'Ekrana'} bakıyorum efendim'). " + f"Immediately say ONE short natural sentence in the user's own language, " + f"telling them you are looking at their {_stall} right now. " f"Do NOT describe or guess content — the actual image arrives in the NEXT message." ) @@ -905,7 +931,7 @@ class JarvisLive: full_out = " ".join(out_buf).strip() if full_out: - self.ui.write_log(f"Jarvis: {full_out}") + self.ui.write_log(f"{self._asst_name}: {full_out}") if self._dashboard: asyncio.create_task(self._dashboard.broadcast({ "type": "log", "speaker": "jarvis", @@ -1003,15 +1029,13 @@ class JarvisLive: async def _send_startup_briefing(self) -> None: """ - Two-phase briefing for instant perceived response: - Phase 1 — immediate greeting (no tools, no fetch) → Jarvis speaks in <2s - Phase 2 — news fetched in background, injected after greeting finishes + Two-phase briefing optimized for speed: + Phase 1 — instant greeting (no tools) → speech starts in <1s + Phase 2 — news pre-fetched in a background thread while Phase 1 plays, + delivered as ready text (no Gemini tool-call round-trip) and + shown on the UI content panel. Waits for turn_complete event + instead of a fixed sleep so there is no unnecessary gap. """ - await asyncio.sleep(0.3) - if not self.session: - return - - # ── memory ─────────────────────────────────────────────────────────── memory = load_memory() identity = memory.get("identity", {}) @@ -1021,59 +1045,87 @@ class JarvisLive: lang = _val("language") name = _val("name") - - from datetime import datetime time_str = datetime.now().strftime("%H:%M") - # ── Phase 1: instant greeting — one simple sentence ────────────────── + # Start fetching news immediately — runs in parallel while phase 1 plays + loop = asyncio.get_event_loop() + news_future = loop.run_in_executor(None, _fetch_news_sync, "top world news today") + + await asyncio.sleep(0.3) + if not self.session: + return + + # ── Phase 1: instant greeting ───────────────────────────────────────── lang_clause = f" Respond in {lang}." if lang else "" name_clause = f" Address the user as {name}." if name else "" p1 = ( - f"Greet the user, mention it is {time_str}, and say you are fetching today's news headlines now. " + f"Greet the user, mention it is {time_str}, and say you are fetching today's news now. " f"One short sentence only. Do not call any tools.{lang_clause}{name_clause}" ) + # Clear the turn-done event so we can wait for Phase 1 to finish + if self._turn_done_event: + self._turn_done_event.clear() + await self.session.send_client_content( turns={"parts": [{"text": p1}]}, turn_complete=True, ) self.ui.write_log("SYS: Briefing phase 1 (greeting) sent.") - # ── Phase 2: fetch news in background, deliver after greeting plays ─── - async def _guarded_news(): + # ── Phase 2: fire as soon as Phase 1 audio is done ─────────────────── + async def _deliver_news(): try: - await self._briefing_news_phase(lang) + lang_str = f" Respond in {lang}." if lang else "" + + # Wait for news fetch (already running) and Phase 1 turn-complete + # in parallel — whichever takes longer determines the wait time + news_done = asyncio.wrap_future(news_future) + turn_waited = False + if self._turn_done_event: + try: + await asyncio.wait_for(self._turn_done_event.wait(), timeout=6.0) + turn_waited = True + except asyncio.TimeoutError: + pass + + # If turn_complete didn't fire (timeout), give a small buffer + if not turn_waited: + await asyncio.sleep(1.0) + + try: + news_text = await asyncio.wait_for(news_done, timeout=4.0) + except Exception: + news_text = "" + + if not self.session: + return + + if news_text and len(news_text) > 60: + # Show on UI content panel immediately + self.ui.show_content("NEWS — top world news today", news_text) + + p2 = ( + f"[BRIEFING] Here are today's top news headlines:\n{news_text}\n\n" + "Pick ONE headline, summarise it in one sentence, then say the full list " + f"is displayed on screen. Do not call any tools.{lang_str}" + ) + else: + p2 = ( + "News headlines could not be fetched right now. " + f"Let the user know briefly.{lang_str}" + ) + + await self.session.send_client_content( + turns={"parts": [{"text": p2}]}, + turn_complete=True, + ) + self.ui.write_log("SYS: Briefing phase 2 (news) sent.") except Exception as e: print(f"[Briefing] Phase 2 error: {e}") - self.ui.write_log(f"SYS: Briefing news phase failed: {e}") - asyncio.create_task(_guarded_news()) + self.ui.write_log(f"SYS: Briefing phase 2 failed: {e}") - async def _briefing_news_phase(self, lang: str) -> None: - """ - Sends phase-2 (news) to Gemini ~1.5 s after phase-1 is dispatched so - Gemini starts working on it while phase-1 audio is still playing. - """ - lang_str = f" Respond in {lang}." if lang else "" - - # 1.5 s is enough for Gemini to finish generating phase-1 audio on its - # side (turn_complete) while the greeting is still being played locally. - await asyncio.sleep(1.5) - - if not self.session: - return - - p2 = ( - "[BRIEFING] Call web_search with mode='news' and query='top world news today' " - "to find actual recent news articles with real event headlines (not just website names). " - "After the search, say ONE specific news event from the results in one sentence, " - f"then say the full list is displayed on screen.{lang_str}" - ) - - await self.session.send_client_content( - turns={"parts": [{"text": p2}]}, - turn_complete=True, - ) - self.ui.write_log("SYS: Briefing phase 2 (news) sent.") + asyncio.create_task(_deliver_news()) # ── System monitor ────────────────────────────────────────────────────────── @@ -1242,8 +1294,8 @@ class JarvisLive: if self._dashboard: tg.create_task(self._relay_phone_audio()) - # Morning briefing — fires once per process launch - if not self._briefing_sent: + # Morning briefing — fires once per process launch (if enabled) + if not self._briefing_sent and get_brief_enabled(): self._briefing_sent = True tg.create_task(self._send_startup_briefing()) diff --git a/memory/config_manager.py b/memory/config_manager.py index 1a324c4..21a06e3 100644 --- a/memory/config_manager.py +++ b/memory/config_manager.py @@ -48,4 +48,44 @@ def get_gemini_key() -> str | None: def is_configured() -> bool: key = get_gemini_key() - return bool(key and len(key) > 15) \ No newline at end of file + return bool(key and len(key) > 15) + + +def get_assistant_name() -> str: + """Return the configured assistant name, or 'JARVIS' if not set.""" + return load_api_keys().get("assistant_name", "JARVIS") or "JARVIS" + + +def get_user_name() -> str: + """Return the configured user name for addressing.""" + return load_api_keys().get("user_name", "") + + +def save_assistant_config(assistant_name: str, user_name: str) -> None: + """Persist assistant name and user name to config.""" + ensure_config_dir() + data: dict = {} + if CONFIG_FILE.exists(): + try: + data = json.loads(CONFIG_FILE.read_text(encoding="utf-8")) + except Exception: + data = {} + data["assistant_name"] = assistant_name.strip() or "JARVIS" + data["user_name"] = user_name.strip() + CONFIG_FILE.write_text(json.dumps(data, indent=4), encoding="utf-8") + + +def get_brief_enabled() -> bool: + return load_api_keys().get("morning_brief_enabled", True) + + +def save_brief_enabled(enabled: bool) -> 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["morning_brief_enabled"] = enabled + CONFIG_FILE.write_text(json.dumps(data, indent=4), encoding="utf-8") \ No newline at end of file diff --git a/readme.md b/readme.md index 6fa6670..c66e838 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,4 @@ -# ⚙️ MARK XLVIII (48) +# ⚙️ MARK XLIX ### The Ultimate Cross-Platform Personal AI Assistant — By FatihMakes > 📺 **[Watch the full setup video on YouTube](https://www.youtube.com/@FatihMakes)** @@ -9,9 +9,7 @@ A real-time voice AI that can hear, see, understand, and control your computer ## ✨ Overview -MARK XLVIII is a direct evolution of Mark XLVII — sharper, faster, and more natural to talk to. This build focused entirely on removing every friction point: the silence while JARVIS "thinks," the awkward double-responses, the interrupts that took four seconds to land, the news results that linked to homepages instead of articles. Everything that made the experience feel like software instead of an assistant has been addressed. - -It's not just an assistant — it's an extension of your digital life. +MARK XLIX deepens the personal assistant foundation. Rather than adding more tools, this build focused on making the assistant truly *yours*: it starts with your computer, learns your name, and pays attention to what you're doing. The goal before the plugin era begins is a core that feels alive — not just reactive. --- @@ -27,63 +25,61 @@ It's not just an assistant — it's an extension of your digital life. | 🧠 Persistent Memory | Deeply remembers projects, preferences, and personal context across sessions | | ⌨️ Hybrid Input | Seamlessly switch between keyboard typing and voice commands | | 🌅 Morning Briefing | On first boot: greets you, reads the time, fetches live news headlines, and checks weather | -| 🔔 Proactive Check-ins | After 15 minutes of silence JARVIS checks context and offers something genuinely useful — no hardcoded rules, Gemini decides | -| 📊 Hardware Monitoring | Continuous CPU, RAM, GPU and temperature telemetry with localized voice alerts when thresholds are breached | +| 🔔 Proactive Check-ins | After 15 minutes of silence, checks context and offers something genuinely useful | +| 📊 Hardware Monitoring | Continuous CPU, RAM, GPU and temperature telemetry with localized voice alerts | | 🌤️ Weather Report | Live weather data for your city, personalized from memory | -| 🗺️ Dynamic Content Panel | Scrollable display layer beneath the HUD that renders web results, news, and search data with timestamps | +| 🗺️ Dynamic Content Panel | Scrollable display layer beneath the HUD that renders web results, news, and search data | | 🔍 Multi-Mode Web Search | `news` / `research` / `price` / `compare` / `search` — Gemini Grounded first, DDG fallback | | ⏰ Smart Reminders | OS-native scheduled notifications (Windows Task Scheduler / macOS LaunchAgent / Linux systemd) | | ✈️ Flight Finder | Live flight price and availability lookup | -| 🎮 Game Updater | Checks and triggers game updates on demand | +| 🎮 Game Updater | Checks and triggers game updates on Steam and Epic Games on demand | | 📂 File Processor | Read, summarize, and answer questions about local files | | 💻 Code Helper | Inline code review, debugging, and generation | | 🌐 Browser Control | Open URLs, navigate tabs, and interact with the browser by voice | -| 📨 Send Message | Compose and send messages through integrated messaging apps | +| 📨 Send Message | Compose and send messages through WhatsApp, Telegram, and more | | 🎬 YouTube Control | Search, play, and control YouTube playback by voice | | 🖱️ Desktop Control | Taskbar, window management, and desktop-level operations | -| 🧑‍💻 Silent Language Memory | Detects spoken language on first use and saves it silently — all future sessions and briefings adapt automatically | +| 🧑‍💻 Silent Language Memory | Detects spoken language on first use and saves it — all future sessions adapt automatically | +| 📱 Remote Dashboard | Control the assistant from your phone via QR code pairing | --- -## 🆕 What's New in XLVIII +## 🆕 What's New in XLIX -### ✋ Instant Interrupt — ESC or Button -Press **Escape** or click the `INTERRUPT` button to cut JARVIS off mid-sentence and return to listening. Previously, cancelling a response required waiting for the full audio buffer to drain — sometimes four seconds. In Mark XLVIII, audio is split into ~50 ms chunks (2400 bytes each) so the interrupt fires within one slice. The interrupt drains the audio queue, sets a flag, and clears the turn — listening resumes in under 100 ms. +### ⚡ Auto-Start on Boot +The assistant now registers itself with the operating system's startup system. One click in the UI toggles it on or off. On Windows, it writes to the registry using `pythonw.exe` so no console window ever appears. On macOS it installs a LaunchAgent plist; on Linux a `.desktop` autostart entry. The button reflects the current state every time the app launches. -### 👁️ Immediate Vision Acknowledgment -When you ask JARVIS to look at your screen or camera, it no longer goes silent while processing. The tool now instructs Gemini to immediately say a natural sentence ("Looking at your screen now, sir" / "Ekrana bakıyorum efendim") while the image capture runs. The actual analysis follows as a second response. No more awkward silence. +### 🎨 Assistant Customization +The assistant is no longer locked to the name "JARVIS". Click `⚙ CUSTOMISE ASSISTANT` in the right panel to change: +- **Assistant name** — displayed everywhere in the UI (title bar, header, HUD, log, footer) and injected into the Gemini system prompt so the AI knows its own name +- **Your name** — how the assistant addresses you. Leave blank for the default language-aware addressing (`sir` / `efendim`), or set your actual name for a more personal feel -### 📰 Parallel News Search — First Result Wins -News queries now run **Gemini Grounded Search and DuckDuckGo news simultaneously** in two daemon threads. Whichever delivers a valid result first wins; the other is silently discarded. Previously, a Gemini 503 error would stall the search for several seconds before falling back to DDG. Now the fallback happens in parallel — total news fetch time drops to whichever backend is fastest at that moment. +Changes take effect immediately without restarting. -### 🗞️ Real News Articles (Not Homepages) -DDG news search was previously using `ddgs.text()`, which returned website homepage URLs for news queries. Mark XLVIII switches to `ddgs.news()`, which returns actual article URLs, titles, snippets, and source names — exactly what you want in a news briefing. +### 📋 Clipboard Intelligence +Copy any text of 10 or more characters and a floating panel appears at the bottom of the window. Four quick actions — **TRANSLATE**, **SUMMARISE**, **EXPLAIN**, **FIX** — send the copied content directly to the assistant with one click. The panel auto-dismisses after 8 seconds. This turns the clipboard into a silent command channel for anything on your screen. -### 🌅 Two-Phase Startup Briefing — Runs Concurrently -The startup briefing now sends Phase 2 (news fetch) while Phase 1 audio (the greeting) is still playing. Previously, Phase 2 waited for Phase 1 to fully complete. The 1.5-second overlap means the news headline is ready by the time JARVIS finishes saying "Good morning." +### ☀ Morning Brief Toggle + Speed Optimization +The morning briefing can now be turned on or off with one click from the settings drawer (`⚙` → `☀ MORNING BRIEF: ON/OFF`). Users who don't want a startup briefing can disable it permanently; the setting survives restarts. The briefing itself was also re-engineered: news is now pre-fetched in a background thread the moment the session starts, running in parallel while the greeting plays. By the time the greeting finishes, the results are already ready — no extra Gemini tool-call round-trip needed. Briefing delivery is noticeably faster as a result. -### 🔁 Smarter Reconnection — Exponential Backoff -Network timeouts now use exponential backoff: 3s → 6s → 12s → 60s (capped). Each retry shows a Turkish-language status message in the UI ("Bağlantı kurulamadı — Xs sonra tekrar deneniyor"). Previously, a dropped connection would loop tightly and show no useful information. +--- -### 🛡️ Vision Cooldown & Echo Guard -Screen capture is guarded against echo loops. If JARVIS speaks about the screen and the microphone picks up its own voice, a secondary `screen_process` call can be triggered. Mark XLVIII blocks duplicate calls with a 4-second cooldown and a `_vision_busy` flag. Both are fully reset when a new session connects — fixing a bug where the cooldown would persist across reconnects and block legitimate requests. +## 🗺️ Mark Roadmap -### 🌐 Language-Aware Address — Never Mixed -The system prompt now enforces: **Turkish → efendim**, **English → sir**, never mixed. Mark XLVII's prompt said "Always call sir to user" which caused JARVIS to say "sir" mid-Turkish sentence. Fixed. - -### 🪟 Zero Terminal Windows -A subprocess monkey-patch at startup sets `CREATE_NO_WINDOW` on every child process launched by the app. No PowerShell, no CMD, no terminal flash — ever. Applies to all actions including reminders, system commands, and scheduler calls. - -### 🔄 Session State Isolation -All transient vision and interrupt flags (`_pending_vision`, `_vision_busy`, `_vision_cam_active`, `_vision_close_pending`, `_interrupted`) are fully reset whenever a new Gemini session connects. Previously, state from a crashed session could carry over and leave JARVIS in a broken state until restart. +| Mark | Focus | +|---|---| +| **XLVIII** | Instant interrupt · parallel news · two-phase briefing · exponential backoff · vision cooldown | +| **XLIX** | Auto-start · clipboard intelligence · assistant customization | +| **L** | Wake word · proactive system 2.0 · session memory / daily continuity | +| **LI+** | Plugin system · email · quiz mode · calorie counter · and more | --- ## ⚡ Quick Start ```bash -git clone https://github.com/FatihMakes/Mark-XLVIII.git -cd Mark-XLVIII +git clone https://github.com/FatihMakes/Mark-XLIX.git +cd Mark-XLIX pip install -r requirements.txt python main.py ``` @@ -106,7 +102,7 @@ python main.py ## 🗂️ Project Structure ``` -Mark XLVIII/ +Mark XLIX/ ├── main.py # Core loop — Gemini Live session, audio I/O, tool dispatch ├── ui.py # PyQt6 HUD — waveform, log panel, interrupt button, camera feed ├── setup.py # First-run configuration wizard @@ -125,16 +121,16 @@ Mark XLVIII/ │ ├── weather_report.py # Live weather data │ ├── flight_finder.py # Flight search │ ├── youtube_video.py # YouTube playback control -│ ├── game_updater.py # Game update management +│ ├── game_updater.py # Game update management (Steam / Epic) │ ├── code_helper.py # Code review and generation │ ├── dev_agent.py # Developer task agent │ ├── desktop.py # Desktop and taskbar control -│ └── proactive.py # Proactive silence-break suggestions +│ └── proactive.py # Proactive silence-break suggestions ├── memory/ # Persistent key-value memory store ├── core/ -│ └── prompt.txt # JARVIS personality and tool-routing rules +│ └── prompt.txt # Assistant personality and tool-routing rules └── config/ - └── api_keys.json # API key and system configuration + └── api_keys.json # API key, OS setting, assistant name, user name ``` --- diff --git a/requirements.txt b/requirements.txt index e527d72..a937c30 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +PyQt6 sounddevice google-genai google-generativeai @@ -24,4 +25,5 @@ comtypes; sys_platform == "win32" pycaw; sys_platform == "win32" win10toast; sys_platform == "win32" pywinauto; sys_platform == "win32" +pywin32; sys_platform == "win32" qrcode[pil] diff --git a/setup.py b/setup.py index 5eb9976..6287892 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,7 @@ import subprocess import sys +import platform +from pathlib import Path print("Installing requirements...") subprocess.run([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"], check=True) @@ -7,5 +9,18 @@ subprocess.run([sys.executable, "-m", "pip", "install", "-r", "requirements.txt" print("Installing Playwright browsers...") subprocess.run([sys.executable, "-m", "playwright", "install"], check=True) +if platform.system() == "Windows": + try: + import win32com.client # noqa: F401 + except ImportError: + postinstall = Path(sys.executable).parent / "Scripts" / "pywin32_postinstall.py" + print( + "\n⚠️ pywin32 did not install correctly — desktop shortcut creation " + "will fall back to a slower method that may not work on this machine.\n" + " Try fixing it manually with:\n" + f' "{sys.executable}" -m pip install --force-reinstall pywin32\n' + f' "{sys.executable}" "{postinstall}" -install\n' + ) + print("\n✅ Setup complete! Run 'python main.py' to start MARK XXV.") diff --git a/ui.py b/ui.py index d92bae3..882de6a 100644 --- a/ui.py +++ b/ui.py @@ -23,9 +23,9 @@ from PyQt6.QtCore import ( QTimer, QUrl, pyqtSignal, ) from PyQt6.QtGui import ( - QBrush, QColor, QDragEnterEvent, QDropEvent, QFont, QFontDatabase, - QKeySequence, QLinearGradient, QPainter, QPainterPath, QPen, QPixmap, - QRadialGradient, QShortcut, + QBrush, QColor, QConicalGradient, QDragEnterEvent, QDropEvent, QFont, + QFontDatabase, QKeySequence, QLinearGradient, QPainter, QPainterPath, + QPen, QPixmap, QRadialGradient, QShortcut, ) from PyQt6.QtWidgets import ( QApplication, QFileDialog, QFrame, QHBoxLayout, QLabel, QLineEdit, @@ -42,6 +42,15 @@ BASE_DIR = _base_dir() CONFIG_DIR = BASE_DIR / "config" API_FILE = CONFIG_DIR / "api_keys.json" + +def _read_full_config() -> dict: + """Read api_keys.json config dict. Returns {} on any error.""" + try: + return json.loads(API_FILE.read_text(encoding="utf-8")) + except Exception: + return {} + + _DEFAULT_W, _DEFAULT_H = 980, 700 _MIN_W, _MIN_H = 820, 580 _LEFT_W = 148 @@ -74,6 +83,89 @@ class C: BAR_BG = "#011520" +# Ana renge (accent) bağlı anahtarlar — durum renkleri (ACC, GREEN, RED…) sabit kalır +_HUE_LINKED = ( + "BG", "PANEL", "PANEL2", "BORDER", "BORDER_B", "BORDER_A", + "PRI", "PRI_DIM", "PRI_GHO", "TEXT", "TEXT_DIM", "TEXT_MED", + "WHITE", "DARK", "BAR_BG", +) +_PALETTE_DEFAULTS: dict[str, str] = {k: getattr(C, k) for k in _HUE_LINKED} + +DEFAULT_UI_COLOR = _PALETTE_DEFAULTS["PRI"] + + +def apply_ui_accent(accent_hex: str) -> bool: + """ + Seçilen accent rengine göre tüm turkuaz-ailesi paleti yeniden türetir + (hue kaydırma — parlaklık/doygunluk oranları korunur, tasarım bozulmaz). + Boyanan öğeler (HUD, dalga formu, metrikler) bir sonraki karede yeni + rengi alır; stylesheet tabanlı paneller yeniden kurulduklarında alır. + """ + import colorsys + + accent_hex = (accent_hex or "").strip().lower() + if not (accent_hex.startswith("#") and len(accent_hex) == 7): + return False + try: + int(accent_hex[1:], 16) + except ValueError: + return False + + def _hsv(h: str) -> tuple[float, float, float]: + r = int(h[1:3], 16) / 255 + g = int(h[3:5], 16) / 255 + b = int(h[5:7], 16) / 255 + return colorsys.rgb_to_hsv(r, g, b) + + base_h = _hsv(_PALETTE_DEFAULTS["PRI"])[0] + acc_h, acc_s, _av = _hsv(accent_hex) + dh = acc_h - base_h + grey = acc_s < 0.08 # griye yakın accent → tüm tema desaturize edilir + + for key, hex0 in _PALETTE_DEFAULTS.items(): + h, s, v = _hsv(hex0) + if grey: + s *= 0.15 + r, g, b = colorsys.hsv_to_rgb((h + dh) % 1.0, s, v) + setattr(C, key, "#{:02x}{:02x}{:02x}".format( + int(r * 255 + 0.5), int(g * 255 + 0.5), int(b * 255 + 0.5))) + return True + + +def current_palette() -> dict[str, str]: + """C sınıfındaki accent'e bağlı renklerin anlık kopyası.""" + return {k: getattr(C, k) for k in _HUE_LINKED} + + +def retheme_all_widgets(old: dict[str, str], new: dict[str, str]) -> None: + """ + CANLI tam tema değişimi. Uygulamadaki HER widget'ın stylesheet'inde eski + palet renklerini yenileriyle değiştirir ve yeniden çizdirir. Böylece renk + değişimi yalnızca boyanan öğelerde değil, panel/buton/kenarlık dahil tüm + arayüzde ANINDA uygulanır — yeniden başlatma gerekmez. + """ + mapping = {old[k].lower(): new[k].lower() + for k in old if old[k].lower() != new.get(k, old[k]).lower()} + if not mapping: + return + app = QApplication.instance() + if app is None: + return + for w in app.allWidgets(): + try: + ss = w.styleSheet() + if ss: + s2 = ss + for o, n in mapping.items(): + if o in s2: + s2 = s2.replace(o, n) + if s2 != ss: + w.setStyleSheet(s2) + w.update() + except Exception: + pass + + def qcol(h: str, a: int = 255) -> QColor: c = QColor(h); c.setAlpha(a); return c @@ -246,7 +338,7 @@ class _SysMetrics: _metrics = _SysMetrics() class HudCanvas(QWidget): - def __init__(self, face_path: str, parent=None): + def __init__(self, face_path: str, assistant_name: str = "J.A.R.V.I.S", parent=None): super().__init__(parent) self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent) self.setMinimumSize(300, 300) @@ -255,6 +347,7 @@ class HudCanvas(QWidget): self.muted = False self.speaking = False self.state = "INITIALISING" + self._assistant_name = assistant_name self._tick = 0 self._scale = 1.0 @@ -456,7 +549,7 @@ class HudCanvas(QWidget): p.setPen(QPen(qcol(C.PRI, min(255, int(self._halo * 2))), 1)) p.setFont(QFont("Courier New", 13, QFont.Weight.Bold)) p.drawText(QRectF(cx - 80, cy - 14, 160, 28), - Qt.AlignmentFlag.AlignCenter, "J.A.R.V.I.S") + Qt.AlignmentFlag.AlignCenter, self._assistant_name) # particles for pt in self._particles: @@ -589,6 +682,7 @@ class LogWidget(QTextEdit): self._text = "" self._pos = 0 self._tag = "sys" + self._ai_name_lc = "jarvis" # updated when assistant name changes self._tmr = QTimer(self) self._tmr.timeout.connect(self._step) self._sig.connect(self._enqueue) @@ -609,11 +703,12 @@ class LogWidget(QTextEdit): self._text = self._queue.pop(0) self._pos = 0 tl = self._text.lower() - if tl.startswith("you:"): self._tag = "you" - elif tl.startswith("jarvis:"): self._tag = "ai" - elif tl.startswith("file:"): self._tag = "file" - elif "err" in tl: self._tag = "err" - else: self._tag = "sys" + _ai_pfx = f"{self._ai_name_lc}:" + if tl.startswith("you:"): self._tag = "you" + elif tl.startswith(_ai_pfx) or tl.startswith("jarvis:"): self._tag = "ai" + elif tl.startswith("file:"): self._tag = "file" + elif "err" in tl: self._tag = "err" + else: self._tag = "sys" self._tmr.start(6) def _step(self): @@ -1054,6 +1149,355 @@ class SetupOverlay(QWidget): self.done.emit(key, self._sel_os) +class HueWheel(QWidget): + """ + Dairesel renk seçici. Kullanıcı tutamacı (küçük beyaz daire) çarkın + çevresinde sürükleyerek TÜM renk tonları arasından seçim yapar. + Merkezdeki dolu daire seçilen rengin canlı önizlemesidir. + """ + + hue_picked = pyqtSignal(str) # sürükleme sırasında (canlı) + hue_committed = pyqtSignal(str) # tutamaç bırakıldığında + + _RING = 16 # halka kalınlığı (px) + + def __init__(self, initial_hex: str = DEFAULT_UI_COLOR, parent=None): + super().__init__(parent) + self.setFixedSize(148, 148) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self._hue = 0.53 + self._drag = False + self.set_color(initial_hex) + + # ── API ────────────────────────────────────────────────────────────────── + def color(self) -> str: + return QColor.fromHsvF(self._hue, 1.0, 1.0).name() + + def set_color(self, hex_str: str): + c = QColor((hex_str or "").strip()) + if c.isValid() and c.hsvHueF() >= 0: + self._hue = c.hsvHueF() + self.update() + + # ── geometri yardımcıları ──────────────────────────────────────────────── + def _ring_rect(self) -> QRectF: + m = self._RING / 2 + 3 + return QRectF(self.rect()).adjusted(m, m, -m, -m) + + def _hue_from_pos(self, pos: QPointF) -> float: + c = QRectF(self.rect()).center() + dx = pos.x() - c.x() + dy = c.y() - pos.y() # ekran y'si aşağı — matematiksel eksene çevir + ang = math.atan2(dy, dx) # [-π, π], saat yönünün tersi + return (ang / (2 * math.pi)) % 1.0 + + # ── çizim ──────────────────────────────────────────────────────────────── + def paintEvent(self, _): + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = self._ring_rect() + center = rect.center() + + grad = QConicalGradient(center, 0) + for i in range(0, 361, 20): + grad.setColorAt(i / 360.0, QColor.fromHsvF((i % 360) / 360.0, 1.0, 1.0)) + p.setPen(QPen(QBrush(grad), self._RING)) + p.setBrush(Qt.BrushStyle.NoBrush) + p.drawEllipse(rect) + + # merkez önizleme dairesi + preview = QColor.fromHsvF(self._hue, 1.0, 1.0) + inner = rect.adjusted(30, 30, -30, -30) + p.setPen(QPen(qcol(C.BORDER_B), 1)) + p.setBrush(QBrush(preview)) + p.drawEllipse(inner) + + # sürüklenen tutamaç + r = rect.width() / 2 + ang = self._hue * 2 * math.pi + hx = center.x() + r * math.cos(ang) + hy = center.y() - r * math.sin(ang) + p.setPen(QPen(QColor("#00060a"), 2)) + p.setBrush(QBrush(QColor("#ffffff"))) + p.drawEllipse(QPointF(hx, hy), 7.5, 7.5) + + # ── fare ───────────────────────────────────────────────────────────────── + def mousePressEvent(self, e): + self._drag = True + self._hue = self._hue_from_pos(e.position()) + self.update() + self.hue_picked.emit(self.color()) + + def mouseMoveEvent(self, e): + if self._drag: + self._hue = self._hue_from_pos(e.position()) + self.update() + self.hue_picked.emit(self.color()) + + def mouseReleaseEvent(self, e): + if self._drag: + self._drag = False + self.hue_committed.emit(self.color()) + + +class CustomizeOverlay(QWidget): + """Floating overlay — change assistant name, user name and UI colour.""" + + saved = pyqtSignal(str, str, str) # assistant_name, user_name, ui_color + _OW, _OH = 400, 500 + + def __init__(self, assistant_name="JARVIS", user_name="", + ui_color=DEFAULT_UI_COLOR, parent=None): + super().__init__(parent) + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self.setStyleSheet(f""" + CustomizeOverlay {{ + background: rgba(0, 6, 10, 245); + border: 1px solid {C.BORDER_B}; + border-radius: 6px; + }} + """) + lay = QVBoxLayout(self) + lay.setContentsMargins(24, 18, 24, 18) + lay.setSpacing(8) + + 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;") + return w + + _fs = (f"QLineEdit {{ background: #000d12; color: {C.TEXT}; " + f"border: 1px solid {C.BORDER}; border-radius: 3px; padding: 4px 8px; }}" + f"QLineEdit:focus {{ border: 1px solid {C.PRI}; }}") + + lay.addWidget(_lbl("⚙ CUSTOMISE ASSISTANT", 12, True)) + sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine) + sep.setStyleSheet(f"color: {C.BORDER}; margin: 2px 0;") + lay.addWidget(sep) + + lay.addWidget(_lbl("ASSISTANT NAME", 8, color=C.TEXT_DIM, + align=Qt.AlignmentFlag.AlignLeft)) + self._name_input = QLineEdit(assistant_name) + self._name_input.setFont(QFont("Courier New", 10)) + self._name_input.setFixedHeight(32) + self._name_input.setStyleSheet(_fs) + lay.addWidget(self._name_input) + + lay.addSpacing(4) + lay.addWidget(_lbl("YOUR NAME (leave blank for default sir / efendim)", 8, + color=C.TEXT_DIM, align=Qt.AlignmentFlag.AlignLeft)) + self._user_input = QLineEdit(user_name) + self._user_input.setPlaceholderText("e.g. Tony (leave blank for auto)") + self._user_input.setFont(QFont("Courier New", 10)) + self._user_input.setFixedHeight(32) + self._user_input.setStyleSheet(_fs) + lay.addWidget(self._user_input) + + # ── UI colour — renk çarkı ─────────────────────────────────────────── + lay.addSpacing(4) + clr_hdr = QHBoxLayout() + clr_hdr.addWidget(_lbl("UI COLOUR — drag the handle", 8, + color=C.TEXT_DIM, align=Qt.AlignmentFlag.AlignLeft)) + clr_hdr.addStretch() + df_btn = QPushButton("DEFAULT") + df_btn.setFixedSize(64, 20) + df_btn.setFont(QFont("Courier New", 7, QFont.Weight.Bold)) + df_btn.setCursor(Qt.CursorShape.PointingHandCursor) + df_btn.setStyleSheet(f""" + QPushButton {{ + background: transparent; color: {C.TEXT_MED}; + border: 1px solid {C.BORDER}; border-radius: 3px; + }} + QPushButton:hover {{ color: {C.TEXT}; border-color: {C.BORDER_B}; }} + """) + df_btn.clicked.connect(lambda: self._set_color(DEFAULT_UI_COLOR)) + clr_hdr.addWidget(df_btn) + lay.addLayout(clr_hdr) + + self._initial_color = (ui_color or DEFAULT_UI_COLOR).strip().lower() + self._sel_color = self._initial_color + self.on_preview = None # callable(hex) — canlı önizleme; MainWindow bağlar + + self._wheel = HueWheel(self._sel_color) + wheel_row = QHBoxLayout() + wheel_row.addStretch(); wheel_row.addWidget(self._wheel); wheel_row.addStretch() + lay.addLayout(wheel_row) + self._wheel.hue_picked.connect(self._on_wheel_pick) + self._wheel.hue_committed.connect(self._on_wheel_commit) + + self._hex_input = QLineEdit(self._sel_color) + self._hex_input.setPlaceholderText("#00d4ff (custom hex colour)") + self._hex_input.setFont(QFont("Courier New", 10)) + self._hex_input.setFixedHeight(28) + self._hex_input.setStyleSheet(_fs) + self._hex_input.textEdited.connect(self._on_hex_edited) + lay.addWidget(self._hex_input) + + lay.addSpacing(6) + btn_row = QHBoxLayout(); btn_row.setSpacing(8) + + save_btn = QPushButton("▸ APPLY CHANGES") + save_btn.setFixedHeight(34) + save_btn.setFont(QFont("Courier New", 9, QFont.Weight.Bold)) + save_btn.setCursor(Qt.CursorShape.PointingHandCursor) + save_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}; }} + """) + save_btn.clicked.connect(self._save) + btn_row.addWidget(save_btn) + + cancel_btn = QPushButton("CANCEL") + cancel_btn.setFixedHeight(34) + cancel_btn.setFont(QFont("Courier New", 9)) + cancel_btn.setCursor(Qt.CursorShape.PointingHandCursor) + cancel_btn.setStyleSheet(f""" + QPushButton {{ + background: transparent; color: {C.TEXT_MED}; + border: 1px solid {C.BORDER}; border-radius: 3px; + }} + QPushButton:hover {{ color: {C.TEXT}; border-color: {C.BORDER_B}; }} + """) + cancel_btn.clicked.connect(self._cancel) + btn_row.addWidget(cancel_btn) + lay.addLayout(btn_row) + + # ── renk akışı ─────────────────────────────────────────────────────────── + def _set_color(self, hx: str, update_wheel: bool = True, preview: bool = True): + """Seçili rengi günceller; hex kutusu + çark senkron kalır, tema canlı önizlenir.""" + self._sel_color = hx.strip().lower() + self._hex_input.blockSignals(True) + self._hex_input.setText(self._sel_color) + self._hex_input.blockSignals(False) + if update_wheel: + self._wheel.set_color(self._sel_color) + if preview and self.on_preview: + self.on_preview(self._sel_color) + + def _on_wheel_pick(self, hx: str): + # Sürükleme sırasında: hex kutusunu güncelle, temayı henüz uygulama + self._sel_color = hx + self._hex_input.blockSignals(True) + self._hex_input.setText(hx) + self._hex_input.blockSignals(False) + + def _on_wheel_commit(self, hx: str): + # Tutamaç bırakıldı → tüm arayüzü canlı önizle + self._set_color(hx, update_wheel=False) + + def _on_hex_edited(self, text: str): + t = text.strip().lower() + if t.startswith("#") and len(t) == 7: + try: + int(t[1:], 16) + except ValueError: + return + self._set_color(t, update_wheel=True, preview=True) + + def _cancel(self): + # Önizleme uygulandıysa açılıştaki renge geri dön + if self.on_preview and self._sel_color != self._initial_color: + self.on_preview(self._initial_color) + self.hide() + + def _save(self): + name = self._name_input.text().strip() or "JARVIS" + user = self._user_input.text().strip() + self.saved.emit(name, user, self._sel_color or DEFAULT_UI_COLOR) + self.hide() + + +class ClipboardPanel(QWidget): + """Floating panel shown when text is copied — offers quick Jarvis actions.""" + + action_requested = pyqtSignal(str) + _W, _H = 326, 112 + + def __init__(self, parent=None): + super().__init__(parent) + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self.setStyleSheet(f""" + ClipboardPanel {{ + background: rgba(0, 8, 14, 248); + border: 1px solid {C.BORDER_B}; + border-radius: 6px; + }} + """) + self.setFixedWidth(self._W) + self._clip_text = "" + + lay = QVBoxLayout(self) + lay.setContentsMargins(8, 6, 8, 7) + lay.setSpacing(4) + + hdr = QHBoxLayout(); hdr.setSpacing(4) + icon_lbl = QLabel("◈ CLIPBOARD DETECTED") + icon_lbl.setFont(QFont("Courier New", 7, QFont.Weight.Bold)) + icon_lbl.setStyleSheet(f"color: {C.ACC2}; background: transparent;") + hdr.addWidget(icon_lbl); hdr.addStretch() + x_btn = QPushButton("✕") + x_btn.setFixedSize(16, 16) + x_btn.setFont(QFont("Courier New", 8)) + x_btn.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent; border: none;") + x_btn.setCursor(Qt.CursorShape.PointingHandCursor) + x_btn.clicked.connect(self.hide) + hdr.addWidget(x_btn) + lay.addLayout(hdr) + + self._preview = QLabel() + self._preview.setFont(QFont("Courier New", 8)) + self._preview.setStyleSheet(f""" + color: {C.TEXT}; background: {C.PANEL2}; + border: 1px solid {C.BORDER}; border-radius: 3px; padding: 4px 6px; + """) + self._preview.setWordWrap(False) + self._preview.setFixedHeight(28) + lay.addWidget(self._preview) + + btn_row = QHBoxLayout(); btn_row.setSpacing(4) + _bs = (f"QPushButton {{ background: {C.PANEL2}; color: {C.TEXT_MED}; " + f"border: 1px solid {C.BORDER}; border-radius: 2px; }}" + f"QPushButton:hover {{ color: {C.PRI}; border-color: {C.BORDER_B}; }}") + for label, cmd_fmt in [ + ("TRANSLATE", "Translate this text to English: {text}"), + ("SUMMARISE", "Summarise this: {text}"), + ("EXPLAIN", "Explain this: {text}"), + ("FIX", "Fix grammar and spelling: {text}"), + ]: + b = QPushButton(label) + b.setFixedHeight(22) + b.setFont(QFont("Courier New", 7, QFont.Weight.Bold)) + b.setCursor(Qt.CursorShape.PointingHandCursor) + b.setStyleSheet(_bs) + b.clicked.connect(lambda _, c=cmd_fmt: self._trigger(c)) + btn_row.addWidget(b) + lay.addLayout(btn_row) + + self._dismiss_timer = QTimer(self) + self._dismiss_timer.setSingleShot(True) + self._dismiss_timer.timeout.connect(self.hide) + self.hide() + + def _trigger(self, cmd_fmt: str): + if self._clip_text: + self.action_requested.emit(cmd_fmt.format(text=self._clip_text[:800])) + self.hide() + + def show_clipboard(self, text: str): + self._clip_text = text + preview = text[:58].replace('\n', ' ') + if len(text) > 58: + preview += "…" + self._preview.setText(f'"{preview}"') + self.show(); self.raise_() + self._dismiss_timer.start(8000) + + class RemoteKeyOverlay(QWidget): """Floating overlay — QR code for instant phone pairing + manual key fallback.""" @@ -1283,18 +1727,30 @@ class RemoteKeyOverlay(QWidget): class MainWindow(QMainWindow): - _log_sig = pyqtSignal(str) - _state_sig = pyqtSignal(str) - _content_sig = pyqtSignal(str, str) # (title, text) — thread-safe content display - _reconfig_sig = pyqtSignal() # trigger setup overlay from any thread - _camera_sig = pyqtSignal(bytes) # show camera frame preview (small overlay) - _cam_stream_sig = pyqtSignal(bool) # True=start live stream, False=stop - _cam_frame_sig = pyqtSignal(bytes) # live camera frame → HUD area + _log_sig = pyqtSignal(str) + _state_sig = pyqtSignal(str) + _content_sig = pyqtSignal(str, str) # (title, text) — thread-safe content display + _reconfig_sig = pyqtSignal() # trigger setup overlay from any thread + _camera_sig = pyqtSignal(bytes) # show camera frame preview (small overlay) + _cam_stream_sig = pyqtSignal(bool) # True=start live stream, False=stop + _cam_frame_sig = pyqtSignal(bytes) # live camera frame → HUD area + _clipboard_sig = pyqtSignal(str) # clipboard text changed (thread-safe) def __init__(self, face_path: str): super().__init__() self._face_path = face_path - self.setWindowTitle("J.A.R.V.I.S — MARK XLVIII") + + # Load customization from config + _cfg = _read_full_config() + self._assistant_name: str = (_cfg.get("assistant_name") or "JARVIS").strip() + _display = self._assistant_name.upper() + + # Kayıtlı UI rengini panel/stylesheet'ler kurulmadan ÖNCE uygula + _ui_color = (_cfg.get("ui_color") or "").strip() + if _ui_color and _ui_color.lower() != DEFAULT_UI_COLOR: + apply_ui_accent(_ui_color) + + self.setWindowTitle(f"{_display} — MARK XLIX") self.setMinimumSize(_MIN_W, _MIN_H) self.resize(_DEFAULT_W, _DEFAULT_H) @@ -1310,6 +1766,7 @@ class MainWindow(QMainWindow): self._muted = False self._current_file: str | None = None self._remote_overlay: RemoteKeyOverlay | None = None + self._customize_overlay: CustomizeOverlay | None = None central = QWidget() central.setStyleSheet(f"background: {C.BG};") @@ -1328,7 +1785,7 @@ class MainWindow(QMainWindow): body.addWidget(self._left_panel, stretch=0) # Center column: HUD + resizable content panel via QSplitter - self.hud = HudCanvas(face_path) + self.hud = HudCanvas(face_path, _display) self.hud.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self._content_panel = self._build_content_panel() @@ -1394,6 +1851,12 @@ class MainWindow(QMainWindow): root.addLayout(body, stretch=1) root.addWidget(self._build_footer()) + # Quick-access drawer (floating overlay, built after central widget layout is done) + self._quick_drawer = self._build_quick_drawer() + self._update_autostart_btn(self._check_autostart()) + from memory.config_manager import get_brief_enabled as _gbe + self._update_brief_btn(_gbe()) + self._clock_tmr = QTimer(self) self._clock_tmr.timeout.connect(self._tick_clock) self._clock_tmr.start(1000) @@ -1412,11 +1875,17 @@ class MainWindow(QMainWindow): self._camera_sig.connect(self._show_camera_frame) self._cam_stream_sig.connect(self._on_cam_stream) self._cam_frame_sig.connect(self._on_cam_frame) + self._clipboard_sig.connect(self._show_clipboard_panel) self._cam_stop = threading.Event() # Camera preview overlay (child of central widget, positioned in resizeEvent) self._cam_preview = _CameraPreview(self.centralWidget()) + # Clipboard panel (child of central widget, bottom-center) + self._clipboard_panel = ClipboardPanel(self.centralWidget()) + self._clipboard_panel.action_requested.connect(self._on_clipboard_action) + QApplication.clipboard().dataChanged.connect(self._on_clipboard_changed) + self._overlay: SetupOverlay | None = None self._ready = self._check_config() if not self._ready: @@ -1607,6 +2076,87 @@ class MainWindow(QMainWindow): print(f"[Shortcut] ⚠️ Icon generation failed: {e}") return False + @staticmethod + def _get_desktop_dir() -> Path: + """ + Resolve the user's REAL desktop directory instead of assuming + ~/Desktop, which breaks when: + • OneDrive "Known Folder Move" relocates the desktop + (C:/Users/x/OneDrive/Desktop) — very common on Win 10/11; + • the XDG desktop is localized on Linux (~/Masaüstü, + ~/Schreibtisch, ~/Bureau, …). + Falls back to ~/Desktop only as a last resort. + """ + home = Path.home() + _os = platform.system() + + if _os == "Windows": + # ── 1) SHGetKnownFolderPath(FOLDERID_Desktop) — the canonical + # answer; follows OneDrive redirection. No dependencies. ── + try: + import ctypes + from ctypes import wintypes + + class _GUID(ctypes.Structure): + _fields_ = [("Data1", wintypes.DWORD), + ("Data2", wintypes.WORD), + ("Data3", wintypes.WORD), + ("Data4", ctypes.c_ubyte * 8)] + + # FOLDERID_Desktop {B4BFCC3A-DB2C-424C-B029-7FE99A87C641} + fid = _GUID(0xB4BFCC3A, 0xDB2C, 0x424C, + (ctypes.c_ubyte * 8)(0xB0, 0x29, 0x7F, 0xE9, + 0x9A, 0x87, 0xC6, 0x41)) + buf = ctypes.c_wchar_p() + if ctypes.windll.shell32.SHGetKnownFolderPath( + ctypes.byref(fid), 0, None, ctypes.byref(buf)) == 0: + p = Path(buf.value) + ctypes.windll.ole32.CoTaskMemFree(buf) + if p.is_dir(): + return p + except Exception: + pass + + # ── 2) Registry: User Shell Folders (may contain %VARS%) ────── + try: + import winreg + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion" + r"\Explorer\User Shell Folders") as key: + val, _t = winreg.QueryValueEx(key, "Desktop") + p = Path(os.path.expandvars(val)) + if p.is_dir(): + return p + except Exception: + pass + + elif _os == "Linux": + # ── xdg-user-dir honours localized names (~/Masaüstü, …) ────── + try: + out = subprocess.run(["xdg-user-dir", "DESKTOP"], + capture_output=True, text=True, timeout=5) + p = Path(out.stdout.strip()) + if out.stdout.strip() and p != home and p.is_dir(): + return p + except Exception: + pass + try: + cfg = home / ".config" / "user-dirs.dirs" + for line in cfg.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line.startswith("XDG_DESKTOP_DIR"): + val = line.split("=", 1)[1].strip().strip('"') + p = Path(val.replace("$HOME", str(home))) + if p != home and p.is_dir(): + return p + except Exception: + pass + + # macOS: ~/Desktop is always the real path (localization is + # display-only). Everything else lands here as a last resort. + return home / "Desktop" + @staticmethod def _create_lnk_windows(lnk: str, target: str, args: str, work_dir: str, icon_loc: str) -> None: @@ -1614,8 +2164,10 @@ class MainWindow(QMainWindow): Create a Windows .lnk shortcut WITHOUT launching PowerShell or cmd. Tries win32com (pywin32) first; falls back to wscript.exe + VBScript. wscript.exe is a GUI-mode host — it never opens a console window. + Raises on failure so the caller can log a useful error. """ # ── Option 1: pywin32 (pure Python COM, zero subprocess) ────────── + com_err: Exception | None = None try: from win32com.client import Dispatch # type: ignore sh = Dispatch("WScript.Shell") @@ -1629,18 +2181,25 @@ class MainWindow(QMainWindow): return except ImportError: pass + except Exception as e: # COM error — still try VBScript + com_err = e # ── Option 2: wscript.exe + VBScript (always available on Windows, # GUI-mode executable — never opens a console window) ──────────── + def q(s: str) -> str: # escape for a VBScript string literal + return s.replace('"', '""') + vbs = "\n".join([ + 'On Error Resume Next', 'Set ws = CreateObject("WScript.Shell")', - f'Set sc = ws.CreateShortcut("{lnk}")', - f'sc.TargetPath = "{target}"', - f'sc.Arguments = Chr(34) & "{args}" & Chr(34)', - f'sc.WorkingDirectory = "{work_dir}"', + f'Set sc = ws.CreateShortcut("{q(lnk)}")', + f'sc.TargetPath = "{q(target)}"', + f'sc.Arguments = Chr(34) & "{q(args)}" & Chr(34)', + f'sc.WorkingDirectory = "{q(work_dir)}"', 'sc.Description = "J.A.R.V.I.S AI Assistant"', - f'sc.IconLocation = "{icon_loc}"', + f'sc.IconLocation = "{q(icon_loc)}"', 'sc.Save', + 'If Err.Number <> 0 Then WScript.Quit 1', ]) import tempfile fd, tmp = tempfile.mkstemp(suffix=".vbs") @@ -1658,6 +2217,12 @@ class MainWindow(QMainWindow): except Exception: pass + if not Path(lnk).exists(): + raise RuntimeError( + f"could not create '{lnk}'" + + (f" ({com_err})" if com_err else "") + ) + def _create_desktop_shortcut(self): """ Create a desktop shortcut on Windows / macOS / Linux. @@ -1666,7 +2231,7 @@ class MainWindow(QMainWindow): import stat as _stat script = Path(__file__).resolve().parent / "main.py" python = Path(sys.executable) - desktop = Path.home() / "Desktop" + desktop = self._get_desktop_dir() # Arc-reactor icon (.ico — also exported as .png for Linux/macOS) ico_path = Path(__file__).resolve().parent / "config" / "jarvis.ico" @@ -1675,6 +2240,7 @@ class MainWindow(QMainWindow): try: _os = platform.system() + desktop.mkdir(parents=True, exist_ok=True) # ── Windows ─────────────────────────────────────────────────────── if _os == "Windows": @@ -1755,7 +2321,7 @@ class MainWindow(QMainWindow): desk.write_text( "[Desktop Entry]\n" "Name=J.A.R.V.I.S\n" - f"Exec={python} {script}\n" + f'Exec="{python}" "{script}"\n' f"Path={script.parent}\n" "Type=Application\n" "Terminal=false\n" @@ -1763,10 +2329,22 @@ class MainWindow(QMainWindow): + icon_line ) desk.chmod(desk.stat().st_mode | 0o755) + # GNOME refuses to launch desktop files until they are + # marked trusted ("Allow Launching") — do it automatically. + try: + subprocess.run( + ["gio", "set", str(desk), + "metadata::trusted", "true"], + capture_output=True, timeout=5, + ) + except Exception: + pass # non-GNOME desktops don't need (or have) gio - self._log.append_log("SYS: Desktop shortcut created.") + self._log.append_log(f"SYS: Desktop shortcut created in '{desktop}'.") except Exception as e: - self._log.append_log(f"ERR: Shortcut failed — {e}") + self._log.append_log( + f"ERR: Shortcut failed — {e} (desktop dir: '{desktop}')" + ) def _toggle_fullscreen(self): if self.isFullScreen(): @@ -1791,6 +2369,13 @@ class MainWindow(QMainWindow): (cw.height() - oh) // 2, ow, oh, ) + if self._customize_overlay and self._customize_overlay.isVisible(): + ow, oh = CustomizeOverlay._OW, CustomizeOverlay._OH + self._customize_overlay.setGeometry( + (cw.width() - ow) // 2, + (cw.height() - oh) // 2, + ow, oh, + ) # Camera preview — bottom-right corner of the center/HUD area pw = _CameraPreview._W ph = self._cam_preview.height() or _CameraPreview._H @@ -1799,6 +2384,12 @@ class MainWindow(QMainWindow): cw.height() - ph - 28, pw, ph, ) + # Clipboard panel — bottom-center + if hasattr(self, '_clipboard_panel') and self._clipboard_panel.isVisible(): + self._position_clipboard_panel() + # Quick drawer — reposition if open + if hasattr(self, '_quick_drawer') and self._quick_drawer.isVisible(): + self._position_quick_drawer() def _update_metrics(self): snap = _metrics.snapshot() @@ -1864,20 +2455,41 @@ class MainWindow(QMainWindow): l.setStyleSheet(f"color: {color}; background: transparent;") return l - lay.addWidget(_badge("MARK XLVIII", C.PRI_DIM)) + lay.addWidget(_badge("MARK XLIX", C.PRI_DIM)) + lay.addSpacing(8) + self._drawer_btn = QPushButton("⚙") + self._drawer_btn.setFixedSize(26, 26) + self._drawer_btn.setFont(QFont("Courier New", 11)) + self._drawer_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self._drawer_btn.setToolTip("Settings & Controls") + self._drawer_btn.setStyleSheet(f""" + QPushButton {{ + background: transparent; color: {C.TEXT_DIM}; + border: 1px solid {C.BORDER}; border-radius: 4px; + }} + QPushButton:hover {{ color: {C.PRI}; border-color: {C.PRI_DIM}; }} + QPushButton:checked {{ color: {C.PRI}; border-color: {C.PRI}; background: {C.PRI_GHO}; }} + """) + self._drawer_btn.setCheckable(True) + self._drawer_btn.clicked.connect(self._toggle_drawer) + lay.addWidget(self._drawer_btn) lay.addStretch() mid = QVBoxLayout(); mid.setSpacing(1) - title = QLabel("J.A.R.V.I.S") - title.setAlignment(Qt.AlignmentFlag.AlignCenter) - title.setFont(QFont("Courier New", 17, QFont.Weight.Bold)) - title.setStyleSheet(f"color: {C.PRI}; background: transparent;") - mid.addWidget(title) - sub = QLabel("Just A Rather Very Intelligent System") - sub.setAlignment(Qt.AlignmentFlag.AlignCenter) - sub.setFont(QFont("Courier New", 7)) - sub.setStyleSheet(f"color: {C.PRI_DIM}; background: transparent;") - mid.addWidget(sub) + _disp = self._assistant_name.upper() + self._title_lbl = QLabel(_disp) + self._title_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._title_lbl.setFont(QFont("Courier New", 17, QFont.Weight.Bold)) + self._title_lbl.setStyleSheet(f"color: {C.PRI}; background: transparent;") + mid.addWidget(self._title_lbl) + _sub_text = ("Just A Rather Very Intelligent System" + if _disp in ("JARVIS", "J.A.R.V.I.S") + else "Personal AI Assistant") + self._sub_lbl = QLabel(_sub_text) + self._sub_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._sub_lbl.setFont(QFont("Courier New", 7)) + self._sub_lbl.setStyleSheet(f"color: {C.PRI_DIM}; background: transparent;") + mid.addWidget(self._sub_lbl) lay.addLayout(mid) lay.addStretch() @@ -1956,9 +2568,9 @@ class MainWindow(QMainWindow): lay.addStretch() for txt, col in [ - ("AI CORE\nACTIVE", C.GREEN), - ("SEC\nCLEARED", C.PRI), - ("PROTOCOL\nXLVIII", C.TEXT_DIM), + ("AI CORE\nACTIVE", C.GREEN), + ("SEC\nCLEARED", C.PRI), + ("PROTOCOL\nXLIX", C.TEXT_DIM), ]: lbl = QLabel(txt) lbl.setFont(QFont("Courier New", 7, QFont.Weight.Bold)) @@ -2037,19 +2649,54 @@ class MainWindow(QMainWindow): self._style_mute_btn() lay.addWidget(self._mute_btn) + return w + + def _build_quick_drawer(self) -> QWidget: + """Floating overlay panel shown when the ⚙ header button is toggled.""" + _BTN_STYLE_PRI = f""" + QPushButton {{ + background: #00091a; color: {C.PRI}; + border: 1px solid {C.PRI_DIM}; border-radius: 3px; + text-align: left; padding: 0 8px; + }} + QPushButton:hover {{ background: {C.PRI_GHO}; border-color: {C.PRI}; }} + """ + _BTN_STYLE_DIM = f""" + QPushButton {{ + background: transparent; color: {C.TEXT_MED}; + border: 1px solid {C.BORDER}; border-radius: 3px; + text-align: left; padding: 0 8px; + }} + QPushButton:hover {{ color: {C.PRI}; border-color: {C.BORDER_B}; }} + """ + + w = QWidget(self.centralWidget()) + w.setObjectName("QuickDrawer") + w.setStyleSheet(f""" + QWidget#QuickDrawer {{ + background: {C.DARK}; + border: 1px solid {C.BORDER_B}; + border-top: none; + border-radius: 0 0 6px 6px; + }} + """) + w.hide() + + lay = QVBoxLayout(w) + lay.setContentsMargins(10, 8, 10, 10) + lay.setSpacing(5) + + hdr = QLabel("◈ CONTROLS") + hdr.setFont(QFont("Courier New", 7, QFont.Weight.Bold)) + hdr.setStyleSheet(f"color: {C.PRI_DIM}; background: transparent; " + f"border-bottom: 1px solid {C.BORDER}; padding-bottom: 4px;") + lay.addWidget(hdr) + 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.setStyleSheet(_BTN_STYLE_PRI) remote_btn.clicked.connect(self._open_remote) lay.addWidget(remote_btn) @@ -2057,15 +2704,7 @@ class MainWindow(QMainWindow): fs_btn.setFixedHeight(26) fs_btn.setFont(QFont("Courier New", 7)) fs_btn.setCursor(Qt.CursorShape.PointingHandCursor) - fs_btn.setStyleSheet(f""" - QPushButton {{ - background: transparent; color: {C.TEXT_MED}; - border: 1px solid {C.BORDER}; border-radius: 3px; - }} - QPushButton:hover {{ - color: {C.PRI}; border: 1px solid {C.BORDER_B}; - }} - """) + fs_btn.setStyleSheet(_BTN_STYLE_DIM) fs_btn.clicked.connect(self._toggle_fullscreen) lay.addWidget(fs_btn) @@ -2073,20 +2712,51 @@ class MainWindow(QMainWindow): sc_btn.setFixedHeight(26) sc_btn.setFont(QFont("Courier New", 7)) sc_btn.setCursor(Qt.CursorShape.PointingHandCursor) - sc_btn.setStyleSheet(f""" - QPushButton {{ - background: transparent; color: {C.TEXT_DIM}; - border: 1px solid {C.BORDER}; border-radius: 3px; - }} - QPushButton:hover {{ - color: {C.ACC2}; border: 1px solid {C.BORDER_B}; - }} - """) + sc_btn.setStyleSheet(_BTN_STYLE_DIM) sc_btn.clicked.connect(self._create_desktop_shortcut) lay.addWidget(sc_btn) + self._autostart_btn = QPushButton("◉ AUTO-START: OFF") + self._autostart_btn.setFixedHeight(26) + self._autostart_btn.setFont(QFont("Courier New", 7)) + self._autostart_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self._autostart_btn.clicked.connect(self._toggle_autostart) + lay.addWidget(self._autostart_btn) + + cust_btn = QPushButton("⚙ CUSTOMISE ASSISTANT") + cust_btn.setFixedHeight(26) + cust_btn.setFont(QFont("Courier New", 7)) + cust_btn.setCursor(Qt.CursorShape.PointingHandCursor) + cust_btn.setStyleSheet(_BTN_STYLE_DIM) + cust_btn.clicked.connect(self._open_customize) + lay.addWidget(cust_btn) + + self._brief_btn = QPushButton() + self._brief_btn.setFixedHeight(26) + self._brief_btn.setFont(QFont("Courier New", 7)) + self._brief_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self._brief_btn.clicked.connect(self._toggle_brief) + lay.addWidget(self._brief_btn) + + w.adjustSize() return w + def _toggle_drawer(self, checked: bool): + if checked: + self._position_quick_drawer() + self._quick_drawer.show() + self._quick_drawer.raise_() + else: + self._quick_drawer.hide() + + def _position_quick_drawer(self): + if not hasattr(self, '_quick_drawer'): + return + _W = 220 + self._quick_drawer.setFixedWidth(_W) + self._quick_drawer.adjustSize() + self._quick_drawer.setGeometry(12, 54, _W, self._quick_drawer.sizeHint().height()) + def _build_input_row(self) -> QHBoxLayout: row = QHBoxLayout(); row.setSpacing(5) self._input = QLineEdit() @@ -2236,9 +2906,7 @@ class MainWindow(QMainWindow): lay.addWidget(_fl("[F4] Mute · [F11] Fullscreen")) lay.addStretch() - lay.addWidget(_fl("FatihMakes Industries · MARK XLVIII · CLASSIFIED")) - lay.addStretch() - lay.addWidget(_fl("© STARK INDUSTRIES", C.PRI_DIM)) + lay.addWidget(_fl("By FatihMakes", C.PRI_DIM)) return w def _on_file_selected(self, path: str): @@ -2247,7 +2915,7 @@ class MainWindow(QMainWindow): cat = _file_category(p) icon, _ = _FILE_ICONS.get(cat, _FILE_ICONS["unknown"]) size = _fmt_size(p.stat().st_size) - self._file_hint.setText(f"{icon} {p.name} · {size} · Tell JARVIS what to do with it") + self._file_hint.setText(f"{icon} {p.name} · {size} · Tell {self._assistant_name} what to do with it") self._log.append_log(f"FILE: {p.name} ({size}) loaded") if self.on_text_command: msg = ( @@ -2291,6 +2959,233 @@ class MainWindow(QMainWindow): self._remote_overlay = ov self._log.append_log(f"SYS: Remote key generated — manual: {manual or url}") + # ── Auto-start ────────────────────────────────────────────────────────────── + + def _check_autostart(self) -> bool: + """Returns True if auto-start is currently registered on this OS.""" + try: + if _OS == "Windows": + import winreg + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Run", 0, winreg.KEY_READ) + try: + winreg.QueryValueEx(key, "JARVIS_AI") + return True + except FileNotFoundError: + return False + finally: + winreg.CloseKey(key) + elif _OS == "Darwin": + return (Path.home() / "Library" / "LaunchAgents" + / "com.jarvis.assistant.plist").exists() + else: + return (Path.home() / ".config" / "autostart" / "jarvis.desktop").exists() + except Exception: + return False + + def _toggle_autostart(self): + currently_on = self._check_autostart() + try: + script = str(Path(__file__).resolve().parent / "main.py") + if _OS == "Windows": + import winreg + reg = winreg.OpenKey(winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Run", 0, winreg.KEY_ALL_ACCESS) + if currently_on: + winreg.DeleteValue(reg, "JARVIS_AI") + else: + pythonw = Path(sys.executable).parent / "pythonw.exe" + exe = str(pythonw if pythonw.exists() else sys.executable) + winreg.SetValueEx(reg, "JARVIS_AI", 0, winreg.REG_SZ, + f'"{exe}" "{script}"') + winreg.CloseKey(reg) + elif _OS == "Darwin": + plist_dir = Path.home() / "Library" / "LaunchAgents" + plist_dir.mkdir(parents=True, exist_ok=True) + plist = plist_dir / "com.jarvis.assistant.plist" + if currently_on: + plist.unlink(missing_ok=True) + else: + plist.write_text( + '\n' + '\n' + '\n' + ' Labelcom.jarvis.assistant\n' + ' ProgramArguments\n' + f' {sys.executable}\n' + f' {script}\n' + ' \n' + ' RunAtLoad\n' + '\n' + ) + else: + desk_dir = Path.home() / ".config" / "autostart" + desk_dir.mkdir(parents=True, exist_ok=True) + desk = desk_dir / "jarvis.desktop" + if currently_on: + desk.unlink(missing_ok=True) + else: + desk.write_text( + "[Desktop Entry]\n" + f"Name={self._assistant_name}\n" + f"Exec={sys.executable} {script}\n" + "Type=Application\nTerminal=false\n" + "X-GNOME-Autostart-enabled=true\n" + ) + enabled = not currently_on + self._update_autostart_btn(enabled) + self._log.append_log( + f"SYS: Auto-start {'enabled' if enabled else 'disabled'}.") + except Exception as e: + self._log.append_log(f"ERR: Auto-start failed — {e}") + + def _update_autostart_btn(self, enabled: bool): + if not hasattr(self, '_autostart_btn'): + return + if enabled: + self._autostart_btn.setText("◉ AUTO-START: ON") + self._autostart_btn.setStyleSheet(f""" + QPushButton {{ + background: #001a08; color: {C.GREEN}; + border: 1px solid {C.GREEN_D}; border-radius: 3px; + }} + QPushButton:hover {{ background: #002010; }} + """) + else: + self._autostart_btn.setText("◉ AUTO-START: OFF") + self._autostart_btn.setStyleSheet(f""" + QPushButton {{ + background: transparent; color: {C.TEXT_DIM}; + border: 1px solid {C.BORDER}; border-radius: 3px; + }} + QPushButton:hover {{ color: {C.TEXT}; border: 1px solid {C.BORDER_B}; }} + """) + + def _toggle_brief(self): + from memory.config_manager import get_brief_enabled, save_brief_enabled + new_val = not get_brief_enabled() + save_brief_enabled(new_val) + self._update_brief_btn(new_val) + + def _update_brief_btn(self, enabled: bool): + if not hasattr(self, '_brief_btn'): + return + if enabled: + self._brief_btn.setText("☀ MORNING BRIEF: ON") + self._brief_btn.setStyleSheet(f""" + QPushButton {{ + background: #001a08; color: {C.GREEN}; + border: 1px solid {C.GREEN_D}; border-radius: 3px; + text-align: left; padding: 0 8px; + }} + QPushButton:hover {{ background: #002010; }} + """) + else: + self._brief_btn.setText("☀ MORNING BRIEF: OFF") + self._brief_btn.setStyleSheet(f""" + QPushButton {{ + background: transparent; color: {C.TEXT_DIM}; + border: 1px solid {C.BORDER}; border-radius: 3px; + text-align: left; padding: 0 8px; + }} + QPushButton:hover {{ color: {C.TEXT}; border: 1px solid {C.BORDER_B}; }} + """) + + # ── Customization ──────────────────────────────────────────────────────────── + + def _open_customize(self): + cfg = _read_full_config() + if self._customize_overlay: + self._customize_overlay.hide() + cw = self.centralWidget() + ov = CustomizeOverlay( + cfg.get("assistant_name", "JARVIS") or "JARVIS", + cfg.get("user_name", ""), + cfg.get("ui_color", "") or DEFAULT_UI_COLOR, + parent=cw, + ) + ow, oh = CustomizeOverlay._OW, CustomizeOverlay._OH + oh = min(oh, cw.height() - 16) + ov.setGeometry( + (cw.width() - ow) // 2, + (cw.height() - oh) // 2, + ow, oh, + ) + ov.on_preview = self._preview_ui_color + ov.saved.connect(self._apply_name_update) + ov.show() + self._customize_overlay = ov + + def _preview_ui_color(self, hex_color: str): + """Canlı önizleme — tüm arayüzü yeni renge boyar (config'e YAZMAZ).""" + old = current_palette() + if apply_ui_accent(hex_color): + retheme_all_widgets(old, current_palette()) + + def _apply_name_update(self, name: str, user_name: str, ui_color: str = ""): + """Update all name/theme-dependent UI elements and persist to config.""" + self._assistant_name = name.strip() or "JARVIS" + display = self._assistant_name.upper() + self.setWindowTitle(f"{display} — MARK XLIX") + self._title_lbl.setText(display) + if display in ("JARVIS", "J.A.R.V.I.S"): + self._sub_lbl.setText("Just A Rather Very Intelligent System") + else: + self._sub_lbl.setText("Personal AI Assistant") + self._log._ai_name_lc = self._assistant_name.lower() + self.hud._assistant_name = display + + color_changed = False + if ui_color: + old = current_palette() + if apply_ui_accent(ui_color): + # Tüm arayüzü (paneller, butonlar, kenarlıklar, HUD) canlı boya + retheme_all_widgets(old, current_palette()) + color_changed = old["PRI"] != C.PRI + + try: + data = _read_full_config() + data["assistant_name"] = self._assistant_name + data["user_name"] = user_name.strip() + if ui_color: + data["ui_color"] = ui_color.strip().lower() + API_FILE.write_text(json.dumps(data, indent=4), encoding="utf-8") + self._log.append_log(f"SYS: Identity updated — {display}") + if color_changed: + self._log.append_log(f"SYS: UI colour applied — {ui_color}") + except Exception as e: + self._log.append_log(f"ERR: Config save failed — {e}") + + # ── Clipboard intelligence ─────────────────────────────────────────────────── + + def _on_clipboard_changed(self): + try: + text = QApplication.clipboard().text().strip() + if len(text) >= 10: + self._clipboard_sig.emit(text) + except Exception: + pass + + def _show_clipboard_panel(self, text: str): + self._clipboard_panel.show_clipboard(text) + self._position_clipboard_panel() + + def _position_clipboard_panel(self): + cw = self.centralWidget() + pw = ClipboardPanel._W + ph = self._clipboard_panel.sizeHint().height() or ClipboardPanel._H + x = (cw.width() - pw) // 2 + y = cw.height() - ph - 6 + self._clipboard_panel.setGeometry(x, y, pw, ph) + self._clipboard_panel.raise_() + + def _on_clipboard_action(self, cmd: str): + if self.on_text_command: + threading.Thread(target=self.on_text_command, args=(cmd,), daemon=True).start() + + # ──────────────────────────────────────────────────────────────────────────── + def _do_interrupt(self): if self.on_interrupt: self.on_interrupt() @@ -2369,7 +3264,8 @@ class MainWindow(QMainWindow): self._overlay.hide() self._overlay = None self._apply_state("LISTENING") - self._log.append_log(f"SYS: Initialised. OS={os_name.upper()}. JARVIS online.") + self._assistant_name = _read_full_config().get("assistant_name", "JARVIS") or "JARVIS" + self._log.append_log(f"SYS: Initialised. OS={os_name.upper()}. {self._assistant_name} online.") class _RootShim: def __init__(self, app: QApplication): @@ -2459,6 +3355,10 @@ class JarvisUI: """Thread-safe: stop the live camera feed.""" self._win.stop_camera_stream() + @property + def assistant_name(self) -> str: + return self._win._assistant_name + def start_speaking(self): self.set_state("SPEAKING")