Add files via upload
This commit is contained in:
1 parent
fb46b5e6a1
commit
067f5289b2
26 files changed
+4189
-3033
No files matched your search
@@ -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
|
||||
|
||||
+130
-76
@@ -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:
|
||||
|
||||
+33
-32
@@ -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
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
+39
-15
@@ -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()
|
||||
|
||||
+135
-51
@@ -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
|
||||
|
||||
+12
-10
@@ -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:
|
||||
|
||||
+36
-19
@@ -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(
|
||||
|
||||
+442
-272
@@ -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.")
|
||||
+141
-117
@@ -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}"
|
||||
+35
-12
@@ -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:
|
||||
|
||||
+9
-9
@@ -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"
|
||||
|
||||
@@ -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-----
|
||||
@@ -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-----
|
||||
+10
-37
@@ -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.
|
||||
Whitespace-only changes.
@@ -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("""<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width">
|
||||
<style>
|
||||
body{background:#07090f;color:#dde3ed;font-family:sans-serif;
|
||||
display:flex;align-items:center;justify-content:center;height:100vh;margin:0;text-align:center}
|
||||
h2{color:#f87171;margin-bottom:12px}p{color:#5e6a7e;font-size:14px}
|
||||
</style></head>
|
||||
<body><div><h2>Link Expired</h2>
|
||||
<p>Press <strong style="color:#dde3ed">Remote Control</strong> in JARVIS to get a new QR code.</p>
|
||||
</div></body></html>""")
|
||||
|
||||
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"""<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width">
|
||||
<style>
|
||||
body{{background:#07090f;color:#dde3ed;font-family:sans-serif;
|
||||
display:flex;align-items:center;justify-content:center;height:100vh;margin:0;text-align:center}}
|
||||
p{{color:#5e6a7e;font-size:14px}}
|
||||
</style></head>
|
||||
<body>
|
||||
<script>
|
||||
sessionStorage.setItem('jarvis_token','{tok}');
|
||||
sessionStorage.setItem('jarvis_key','{key}');
|
||||
localStorage.setItem('jarvis_device_token','{dev_tok}');
|
||||
setTimeout(function(){{location.replace('/')}},400);
|
||||
</script>
|
||||
<p>Connecting to JARVIS…</p>
|
||||
</body></html>""")
|
||||
|
||||
@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 <a download> 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()
|
||||
@@ -0,0 +1,559 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>JARVIS Dashboard</title>
|
||||
<script src="/static/crypto.js"></script>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #07090f;
|
||||
--surface: rgba(255,255,255,0.04);
|
||||
--border: rgba(255,255,255,0.07);
|
||||
--accent: #6366f1;
|
||||
--adim: rgba(99,102,241,0.14);
|
||||
--text: #dde3ed;
|
||||
--muted: #5e6a7e;
|
||||
--green: #22c55e;
|
||||
--gdim: rgba(34,197,94,0.14);
|
||||
}
|
||||
html, body {
|
||||
height: 100%; background: var(--bg); color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── HEADER ── */
|
||||
header {
|
||||
flex-shrink: 0; display: flex; align-items: center; gap: 10px;
|
||||
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
||||
background: rgba(7,9,15,0.95);
|
||||
}
|
||||
.logo { font-size: 15px; font-weight: 700; letter-spacing: 6px; }
|
||||
.logo em { color: var(--accent); font-style: normal; }
|
||||
.pill {
|
||||
display: flex; align-items: center; gap: 6px; padding: 4px 11px;
|
||||
border-radius: 99px; background: var(--surface); border: 1px solid var(--border);
|
||||
font-size: 10px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase;
|
||||
color: var(--muted); transition: all 0.3s;
|
||||
}
|
||||
.pill.on { background: var(--gdim); border-color: rgba(34,197,94,0.3); color: var(--green); }
|
||||
.dot { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); transition: background 0.3s; }
|
||||
.pill.on .dot { background: var(--green); animation: blink 2s infinite; }
|
||||
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.4} }
|
||||
.spacer { flex: 1; }
|
||||
.enc-badge {
|
||||
font-size: 9px; font-weight: 700; letter-spacing: 1.5px; padding: 3px 8px;
|
||||
border-radius: 4px; display: none;
|
||||
}
|
||||
.enc-badge.on { display: block; background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.25); color: var(--green); }
|
||||
.enc-badge.off { display: block; background: rgba(255,100,100,0.08); border: 1px solid rgba(255,100,100,0.2); color: #f87171; }
|
||||
.url { font-size: 10px; font-family: monospace; color: var(--muted); padding: 4px 9px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; }
|
||||
|
||||
/* ── FEED ── */
|
||||
main { flex: 1; overflow-y: auto; padding: 14px 16px 8px; display: flex; flex-direction: column; gap: 10px; }
|
||||
main::-webkit-scrollbar { width: 3px; }
|
||||
main::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
.msg { max-width: 82%; padding: 10px 14px; border-radius: 14px; font-size: 14px; line-height: 1.55; animation: pop 0.18s ease; }
|
||||
@keyframes pop { from { opacity:0; transform:translateY(4px); } }
|
||||
.msg-j { align-self:flex-start; background:var(--adim); border:1px solid rgba(99,102,241,0.18); border-bottom-left-radius:4px; }
|
||||
.msg-u { align-self:flex-end; background:var(--surface); border:1px solid var(--border); border-bottom-right-radius:4px; }
|
||||
.msg-sys { align-self:center; font-size:11px; color:var(--muted); padding:2px 0; background:transparent; border:none; letter-spacing:0.3px; }
|
||||
.lbl { font-size:9px; font-weight:700; letter-spacing:2px; text-transform:uppercase; opacity:0.45; margin-bottom:4px; }
|
||||
.msg-j .lbl { color:var(--accent); }
|
||||
.msg-u .lbl { color:var(--muted); text-align:right; }
|
||||
|
||||
/* ── FILE CARD ── */
|
||||
.msg-file {
|
||||
align-self: flex-end;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 14px; border-bottom-right-radius: 4px;
|
||||
padding: 11px 14px; display: flex; align-items: center; gap: 12px;
|
||||
max-width: 82%; animation: pop 0.18s ease;
|
||||
}
|
||||
.fc-icon { font-size: 26px; flex-shrink: 0; }
|
||||
.fc-meta { flex: 1; min-width: 0; }
|
||||
.fc-lbl { font-size:9px; font-weight:700; letter-spacing:2px; text-transform:uppercase; opacity:0.45; color:var(--muted); margin-bottom:4px; }
|
||||
.fc-name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.fc-size { font-size: 11px; color: var(--muted); margin-top: 2px; }
|
||||
.fc-bar-wrap { height: 3px; background: var(--border); border-radius: 2px; margin-top: 7px; overflow: hidden; }
|
||||
.fc-bar { height: 100%; width: 0%; background: var(--accent); border-radius: 2px; transition: width 0.12s linear; }
|
||||
.fc-status { font-size: 10px; font-weight: 700; letter-spacing: 1px; flex-shrink: 0; color: var(--muted); }
|
||||
.fc-status.done { color: var(--green); }
|
||||
.fc-status.err { color: #f87171; }
|
||||
.fc-dl { display:block; margin-top:6px; font-size:11px; color:var(--accent); text-decoration:none; font-weight:600; }
|
||||
.fc-dl:hover { opacity: 0.8; }
|
||||
|
||||
/* ── FOOTER ── */
|
||||
footer { flex-shrink:0; padding:10px 14px; border-top:1px solid var(--border); background:rgba(7,9,15,0.95); display:flex; gap:8px; align-items:center; }
|
||||
#inp { flex:1; padding:11px 14px; background:var(--surface); border:1.5px solid var(--border); border-radius:12px; color:var(--text); font-size:14px; outline:none; transition:border-color 0.15s; }
|
||||
#inp:focus { border-color: var(--accent); }
|
||||
#inp::placeholder { color: var(--muted); }
|
||||
.btn { padding:11px 15px; border:none; border-radius:12px; font-size:11px; font-weight:700; letter-spacing:1.5px; cursor:pointer; transition:opacity 0.15s,transform 0.1s; }
|
||||
.btn:active { transform: scale(0.96); }
|
||||
.btn:hover { opacity: 0.82; }
|
||||
.btn-send { background: var(--accent); color: #fff; }
|
||||
.btn-wake { background:var(--surface); border:1px solid var(--border); color:var(--muted); display:none; }
|
||||
.btn-wake.show { display:block; color:var(--green); border-color:rgba(34,197,94,0.35); }
|
||||
.btn-attach {
|
||||
background: var(--surface); border: 1px solid var(--border); color: var(--muted);
|
||||
padding: 11px 13px; font-size: 16px; line-height: 1; border-radius: 12px;
|
||||
cursor: pointer; transition: opacity 0.15s, transform 0.1s; display: flex;
|
||||
align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.btn-attach:hover { opacity: 0.82; border-color: var(--accent); color: var(--accent); }
|
||||
.btn-attach:active { transform: scale(0.96); }
|
||||
|
||||
/* ── MIC BUTTON ── */
|
||||
#mic-btn {
|
||||
background: var(--surface); border: 1px solid var(--border); color: var(--muted);
|
||||
padding: 11px 13px; font-size: 16px; line-height: 1; border-radius: 12px;
|
||||
cursor: pointer; transition: opacity 0.15s, transform 0.1s, background 0.2s, border-color 0.2s;
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
#mic-btn:hover { opacity: 0.82; border-color: var(--accent); color: var(--accent); }
|
||||
#mic-btn:active { transform: scale(0.96); }
|
||||
#mic-btn.recording {
|
||||
background: rgba(239,68,68,0.15); border-color: rgba(239,68,68,0.45); color: #ef4444;
|
||||
animation: mic-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
#mic-btn.unsupported { opacity: 0.3; cursor: not-allowed; }
|
||||
@keyframes mic-pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:0.65;transform:scale(0.97)} }
|
||||
|
||||
/* ── TOAST ── */
|
||||
#toast {
|
||||
position:fixed; top:66px; left:50%; transform:translateX(-50%) translateY(-8px);
|
||||
background:rgba(34,197,94,0.16); border:1px solid rgba(34,197,94,0.4);
|
||||
color:var(--green); font-size:12px; font-weight:700; letter-spacing:1px;
|
||||
padding:7px 18px; border-radius:99px;
|
||||
opacity:0; transition:opacity 0.22s,transform 0.22s;
|
||||
pointer-events:none; white-space:nowrap;
|
||||
}
|
||||
#toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="toast"></div>
|
||||
<header>
|
||||
<div class="logo"><em>J</em>ARVIS</div>
|
||||
<div class="pill" id="pill"><span class="dot"></span><span id="st">Connecting</span></div>
|
||||
<div class="enc-badge" id="enc">AES-256</div>
|
||||
<div class="spacer"></div>
|
||||
<div class="url">__IP__:__PORT__</div>
|
||||
</header>
|
||||
<main id="feed"></main>
|
||||
<footer>
|
||||
<input id="inp" placeholder="Send a command to JARVIS…" autocomplete="off">
|
||||
<label class="btn-attach" for="file-inp" title="Send file">📎</label>
|
||||
<input type="file" id="file-inp" multiple accept="*/*" style="display:none">
|
||||
<button id="mic-btn" onclick="doMic()" title="Voice command">🎤</button>
|
||||
<button class="btn btn-wake" id="wake" onclick="doWake()">WAKE</button>
|
||||
<button class="btn btn-send" onclick="doSend()">SEND</button>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// ── Auth — bearer token ───────────────────────────────────────────────────
|
||||
const _authToken = sessionStorage.getItem('jarvis_token');
|
||||
const _sessionKey = sessionStorage.getItem('jarvis_key');
|
||||
|
||||
if (!_authToken) {
|
||||
location.replace('/login');
|
||||
}
|
||||
|
||||
function _authHeader() { return { 'Authorization': `Bearer ${_authToken}` }; }
|
||||
function _authFetch(url, opts = {}) {
|
||||
opts.headers = Object.assign({}, opts.headers, _authHeader());
|
||||
return fetch(url, opts);
|
||||
}
|
||||
|
||||
// ── AES-256-CBC encryption (CryptoJS) ────────────────────────────────────
|
||||
const _AES_SALT = 'JARVIS-DASHBOARD-v1';
|
||||
let _aesKey = null;
|
||||
|
||||
function _initCrypto(key) {
|
||||
if (typeof CryptoJS === 'undefined') return false;
|
||||
_aesKey = CryptoJS.SHA256(key + _AES_SALT);
|
||||
return true;
|
||||
}
|
||||
|
||||
function _encrypt(plaintext) {
|
||||
if (!_aesKey || typeof CryptoJS === 'undefined') return null;
|
||||
try {
|
||||
const iv = CryptoJS.lib.WordArray.random(16);
|
||||
const enc = CryptoJS.AES.encrypt(
|
||||
CryptoJS.enc.Utf8.parse(plaintext), _aesKey,
|
||||
{ iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }
|
||||
);
|
||||
const ivHex = iv.toString(CryptoJS.enc.Hex);
|
||||
const ctHex = enc.ciphertext.toString(CryptoJS.enc.Hex);
|
||||
const hex = ivHex + ctHex;
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < bytes.length; i++)
|
||||
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
const _encReady = _sessionKey ? _initCrypto(_sessionKey) : false;
|
||||
const encBadge = document.getElementById('enc');
|
||||
if (_encReady) { encBadge.className = 'enc-badge on'; }
|
||||
else { encBadge.className = 'enc-badge off'; encBadge.textContent = 'NO ENC'; }
|
||||
|
||||
// ── WebSocket ─────────────────────────────────────────────────────────────
|
||||
const feed = document.getElementById('feed');
|
||||
const pill = document.getElementById('pill');
|
||||
const stTxt = document.getElementById('st');
|
||||
const wake = document.getElementById('wake');
|
||||
|
||||
const _wsProto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const _wsUrl = `${_wsProto}://${location.host}/ws?token=${encodeURIComponent(_authToken)}`;
|
||||
const ws = new WebSocket(_wsUrl);
|
||||
|
||||
ws.onopen = () => { sys('Remote session active.'); toast('Connected to JARVIS'); };
|
||||
ws.onclose = () => sys('Connection lost — refresh to reconnect.');
|
||||
ws.onerror = () => sys('Connection error.');
|
||||
ws.onmessage = (e) => {
|
||||
const m = JSON.parse(e.data);
|
||||
if (m.type === 'log') append(m.speaker, m.text);
|
||||
if (m.type === 'status') setStatus(m.state);
|
||||
if (m.type === 'wake') sys('Wake word detected — connecting…');
|
||||
if (m.type === 'sys') sys(m.text);
|
||||
if (m.type === 'file_received') _onFileReceived(m);
|
||||
};
|
||||
|
||||
function setStatus(s) {
|
||||
if (s === 'active') {
|
||||
pill.className = 'pill on'; stTxt.textContent = 'Active'; wake.classList.remove('show');
|
||||
} else {
|
||||
pill.className = 'pill'; stTxt.textContent = 'Sleeping'; wake.classList.add('show');
|
||||
}
|
||||
}
|
||||
|
||||
function append(speaker, text) {
|
||||
const d = document.createElement('div');
|
||||
if (speaker === 'jarvis') {
|
||||
d.className = 'msg msg-j';
|
||||
d.innerHTML = `<div class="lbl">Jarvis</div>${esc(text)}`;
|
||||
} else {
|
||||
d.className = 'msg msg-u';
|
||||
d.innerHTML = `<div class="lbl">You</div>${esc(text)}`;
|
||||
}
|
||||
feed.appendChild(d);
|
||||
feed.scrollTop = feed.scrollHeight;
|
||||
}
|
||||
|
||||
function sys(text) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'msg msg-sys'; d.textContent = text;
|
||||
feed.appendChild(d); feed.scrollTop = feed.scrollHeight;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
// ── File helpers ──────────────────────────────────────────────────────────
|
||||
function _fileIcon(name) {
|
||||
const ext = (name.split('.').pop() || '').toLowerCase();
|
||||
if (['jpg','jpeg','png','gif','webp','heic','heif','avif','bmp','svg'].includes(ext)) return '🖼️';
|
||||
if (['mp4','mov','avi','mkv','webm','m4v','3gp'].includes(ext)) return '🎬';
|
||||
if (['mp3','m4a','wav','flac','aac','ogg','opus'].includes(ext)) return '🎵';
|
||||
if (['pdf'].includes(ext)) return '📋';
|
||||
if (['doc','docx','txt','md','rtf','odt'].includes(ext)) return '📝';
|
||||
if (['xls','xlsx','csv','ods'].includes(ext)) return '📊';
|
||||
if (['ppt','pptx','odp'].includes(ext)) return '📑';
|
||||
if (['zip','rar','7z','tar','gz','bz2','xz'].includes(ext)) return '📦';
|
||||
if (['apk','exe','dmg','deb','rpm','msi'].includes(ext)) return '⚙️';
|
||||
return '📎';
|
||||
}
|
||||
|
||||
function _fmtSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1024 ** 3) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||
return (bytes / 1024 ** 3).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
// ── File upload (phone → computer) ────────────────────────────────────────
|
||||
function _uploadFile(file) {
|
||||
const id = 'fc_' + Date.now() + '_' + Math.random().toString(36).slice(2);
|
||||
|
||||
// Create file card in feed
|
||||
const card = document.createElement('div');
|
||||
card.className = 'msg-file';
|
||||
card.id = id;
|
||||
card.innerHTML = `
|
||||
<div class="fc-icon">${_fileIcon(file.name)}</div>
|
||||
<div class="fc-meta">
|
||||
<div class="fc-lbl">Sending</div>
|
||||
<div class="fc-name">${esc(file.name)}</div>
|
||||
<div class="fc-size">${_fmtSize(file.size)}</div>
|
||||
<div class="fc-bar-wrap"><div class="fc-bar" id="${id}_bar"></div></div>
|
||||
</div>
|
||||
<div class="fc-status" id="${id}_st">0%</div>
|
||||
`;
|
||||
feed.appendChild(card);
|
||||
feed.scrollTop = feed.scrollHeight;
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/upload');
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${_authToken}`);
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (!e.lengthComputable) return;
|
||||
const pct = Math.round(e.loaded / e.total * 100);
|
||||
const bar = document.getElementById(`${id}_bar`);
|
||||
const st = document.getElementById(`${id}_st`);
|
||||
if (bar) bar.style.width = pct + '%';
|
||||
if (st) st.textContent = pct + '%';
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
const bar = document.getElementById(`${id}_bar`);
|
||||
const st = document.getElementById(`${id}_st`);
|
||||
const meta = card.querySelector('.fc-meta');
|
||||
const lbl = card.querySelector('.fc-lbl');
|
||||
if (xhr.status === 200) {
|
||||
let savedName = file.name;
|
||||
try { savedName = JSON.parse(xhr.responseText).name || file.name; } catch {}
|
||||
const dlUrl = `/uploads/${encodeURIComponent(savedName)}?token=${encodeURIComponent(_authToken)}`;
|
||||
if (bar) { bar.style.width = '100%'; bar.style.background = 'var(--green)'; }
|
||||
if (st) { st.textContent = '✓'; st.className = 'fc-status done'; }
|
||||
if (lbl) lbl.textContent = 'Sent';
|
||||
if (meta) meta.insertAdjacentHTML('beforeend',
|
||||
`<a class="fc-dl" href="${dlUrl}" download="${esc(savedName)}">⬇ Download back</a>`);
|
||||
toast('File sent!');
|
||||
} else {
|
||||
let errMsg = 'Upload failed';
|
||||
try { errMsg = JSON.parse(xhr.responseText).error || errMsg; } catch {}
|
||||
if (bar) bar.style.background = '#f87171';
|
||||
if (st) { st.textContent = 'ERR'; st.className = 'fc-status err'; }
|
||||
if (lbl) lbl.textContent = errMsg;
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
const st = document.getElementById(`${id}_st`);
|
||||
const lbl = card.querySelector('.fc-lbl');
|
||||
if (st) { st.textContent = 'ERR'; st.className = 'fc-status err'; }
|
||||
if (lbl) lbl.textContent = 'Connection error';
|
||||
};
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
xhr.send(fd);
|
||||
}
|
||||
|
||||
// File received by another WebSocket client (e.g., second open tab)
|
||||
function _onFileReceived(m) {
|
||||
const dlUrl = `/uploads/${encodeURIComponent(m.name)}?token=${encodeURIComponent(_authToken)}`;
|
||||
const card = document.createElement('div');
|
||||
card.className = 'msg-file';
|
||||
card.innerHTML = `
|
||||
<div class="fc-icon">${_fileIcon(m.name)}</div>
|
||||
<div class="fc-meta">
|
||||
<div class="fc-lbl">Received</div>
|
||||
<div class="fc-name">${esc(m.name)}</div>
|
||||
<div class="fc-size">${_fmtSize(m.size)}</div>
|
||||
<a class="fc-dl" href="${dlUrl}" download="${esc(m.name)}">⬇ Download</a>
|
||||
</div>
|
||||
<div class="fc-status done">✓</div>
|
||||
`;
|
||||
feed.appendChild(card);
|
||||
feed.scrollTop = feed.scrollHeight;
|
||||
toast('File received on computer!');
|
||||
}
|
||||
|
||||
// File input change handler
|
||||
document.getElementById('file-inp').addEventListener('change', function () {
|
||||
Array.from(this.files).forEach(_uploadFile);
|
||||
this.value = ''; // reset so the same file can be re-sent
|
||||
});
|
||||
|
||||
// ── Commands ──────────────────────────────────────────────────────────────
|
||||
function doSend() {
|
||||
const inp = document.getElementById('inp');
|
||||
const txt = inp.value.trim();
|
||||
if (!txt) return;
|
||||
inp.value = '';
|
||||
append('user', txt);
|
||||
const enc = _encrypt(txt);
|
||||
const body = enc ? { enc } : { text: txt };
|
||||
_authFetch('/api/command', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
function doWake() {
|
||||
sys('Sending wake signal…');
|
||||
_authFetch('/api/wake', { method: 'POST' });
|
||||
}
|
||||
|
||||
document.getElementById('inp').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); }
|
||||
});
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────────────────────
|
||||
let _toastTimer;
|
||||
function toast(msg) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg; t.classList.add('show');
|
||||
clearTimeout(_toastTimer);
|
||||
_toastTimer = setTimeout(() => t.classList.remove('show'), 2800);
|
||||
}
|
||||
|
||||
// ── Real-time Voice — PCM16 WebSocket → Gemini Live ──────────────────────
|
||||
const micBtn = document.getElementById('mic-btn');
|
||||
let _voiceWs = null;
|
||||
let _audioCtx = null;
|
||||
let _micStm = null;
|
||||
let _audioNd = null;
|
||||
|
||||
function _micIdle() {
|
||||
micBtn.innerHTML = '🎤';
|
||||
micBtn.title = 'Voice — tap to speak';
|
||||
micBtn.classList.remove('recording');
|
||||
}
|
||||
|
||||
// Float32 PCM → resample to 16 kHz + convert to Int16
|
||||
function _f32toPcm16(f32, srcRate) {
|
||||
let s = f32;
|
||||
if (srcRate !== 16000) {
|
||||
const ratio = srcRate / 16000;
|
||||
const len = Math.round(f32.length / ratio);
|
||||
s = new Float32Array(len);
|
||||
for (let i = 0; i < len; i++)
|
||||
s[i] = f32[Math.min(Math.round(i * ratio), f32.length - 1)];
|
||||
}
|
||||
const out = new Int16Array(s.length);
|
||||
for (let i = 0; i < s.length; i++)
|
||||
out[i] = Math.max(-32768, Math.min(32767, Math.round(s[i] * 32768)));
|
||||
return out.buffer;
|
||||
}
|
||||
|
||||
function _showVoiceSetup() {
|
||||
if (document.getElementById('voice-setup')) return; // show once
|
||||
const origin = location.origin;
|
||||
const card = document.createElement('div');
|
||||
card.id = 'voice-setup';
|
||||
card.style.cssText = [
|
||||
'align-self:stretch', 'background:rgba(99,102,241,0.07)',
|
||||
'border:1px solid rgba(99,102,241,0.22)', 'border-radius:14px',
|
||||
'padding:16px 18px', 'font-size:13px', 'line-height:1.8',
|
||||
'animation:pop 0.18s ease'
|
||||
].join(';');
|
||||
card.innerHTML =
|
||||
'<div style="font-weight:700;color:#818cf8;letter-spacing:.5px;margin-bottom:10px">🎤 One-time voice setup</div>' +
|
||||
'<ol style="padding-left:18px;color:#8899aa;margin:0;display:flex;flex-direction:column;gap:4px">' +
|
||||
'<li>Open a new tab → go to <span style="background:rgba(255,255,255,0.07);padding:1px 6px;border-radius:4px;font-family:monospace;font-size:12px">chrome://flags</span></li>' +
|
||||
'<li>Search: <strong style="color:#dde3ed">Insecure origins treated as secure</strong></li>' +
|
||||
'<li>Paste this URL into the box:<br><span id="flags-url" style="background:rgba(255,255,255,0.07);padding:2px 8px;border-radius:4px;font-family:monospace;font-size:11px;user-select:all;cursor:pointer">' + esc(origin) + '</span></li>' +
|
||||
'<li>Set to <strong style="color:#22c55e">Enabled</strong> → tap <strong style="color:#dde3ed">Relaunch</strong></li>' +
|
||||
'<li>Return here and tap 🎤</li>' +
|
||||
'</ol>';
|
||||
feed.appendChild(card);
|
||||
feed.scrollTop = feed.scrollHeight;
|
||||
// tap to copy
|
||||
card.querySelector('#flags-url').addEventListener('click', () => {
|
||||
navigator.clipboard?.writeText(origin).then(() => toast('URL copied!')).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
async function doMic() {
|
||||
if (_voiceWs) { _stopVoice(); return; }
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
_showVoiceSetup();
|
||||
return;
|
||||
}
|
||||
let stream;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
|
||||
});
|
||||
} catch (e) {
|
||||
sys(e.name === 'NotAllowedError'
|
||||
? 'Microphone denied — tap the address bar lock and allow microphone.'
|
||||
: 'Mic error: ' + e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 16 kHz context (fall back to native rate — _f32toPcm16 resamples)
|
||||
let ctx;
|
||||
try { ctx = new AudioContext({ sampleRate: 16000 }); }
|
||||
catch (_) { ctx = new AudioContext(); }
|
||||
if (ctx.state === 'suspended') await ctx.resume();
|
||||
|
||||
const wsProto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(
|
||||
`${wsProto}://${location.host}/ws/phone-audio?token=${encodeURIComponent(_authToken)}`
|
||||
);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = async () => {
|
||||
const rate = ctx.sampleRate;
|
||||
const src = ctx.createMediaStreamSource(stream);
|
||||
|
||||
// AudioWorklet (modern) — runs in dedicated audio thread
|
||||
const wCode = `class J extends AudioWorkletProcessor{process(i){const c=i[0]?.[0];if(c)this.port.postMessage(c.slice());return true;}}registerProcessor('j',J);`;
|
||||
try {
|
||||
const burl = URL.createObjectURL(new Blob([wCode], { type: 'application/javascript' }));
|
||||
await ctx.audioWorklet.addModule(burl);
|
||||
URL.revokeObjectURL(burl);
|
||||
const nd = new AudioWorkletNode(ctx, 'j');
|
||||
// Buffer to 1024 samples (64 ms at 16 kHz) before sending —
|
||||
// matches PC mic chunk size, prevents queue flooding on the server.
|
||||
let _pbuf = [], _plen = 0;
|
||||
nd.port.onmessage = e => {
|
||||
const chunk = new Int16Array(_f32toPcm16(e.data, rate));
|
||||
_pbuf.push(chunk); _plen += chunk.length;
|
||||
if (_plen >= 1024) {
|
||||
const out = new Int16Array(_plen); let off = 0;
|
||||
for (const c of _pbuf) { out.set(c, off); off += c.length; }
|
||||
if (ws.readyState === 1) ws.send(out.buffer);
|
||||
_pbuf = []; _plen = 0;
|
||||
}
|
||||
};
|
||||
src.connect(nd);
|
||||
_audioNd = nd;
|
||||
} catch (_) {
|
||||
// ScriptProcessor fallback (deprecated, still works everywhere)
|
||||
const sp = ctx.createScriptProcessor(4096, 1, 1);
|
||||
sp.onaudioprocess = e => {
|
||||
if (ws.readyState === 1) ws.send(_f32toPcm16(e.inputBuffer.getChannelData(0), rate));
|
||||
};
|
||||
src.connect(sp); sp.connect(ctx.destination);
|
||||
_audioNd = sp;
|
||||
}
|
||||
|
||||
micBtn.innerHTML = '⏹';
|
||||
micBtn.title = 'Tap to stop';
|
||||
micBtn.classList.add('recording');
|
||||
sys('Voice live — speak now');
|
||||
toast('🎤 Live');
|
||||
};
|
||||
|
||||
ws.onclose = () => { _stopVoice(); };
|
||||
ws.onerror = () => { sys('Voice connection failed.'); _stopVoice(); };
|
||||
|
||||
_voiceWs = ws;
|
||||
_audioCtx = ctx;
|
||||
_micStm = stream;
|
||||
}
|
||||
|
||||
function _stopVoice() {
|
||||
if (_audioNd) { try { _audioNd.disconnect(); } catch (_) {}; _audioNd = null; }
|
||||
if (_audioCtx) { try { _audioCtx.close(); } catch (_) {}; _audioCtx = null; }
|
||||
if (_micStm) { _micStm.getTracks().forEach(t => t.stop()); _micStm = null; }
|
||||
if (_voiceWs) {
|
||||
const w = _voiceWs; _voiceWs = null;
|
||||
if (w.readyState < 2) w.close();
|
||||
}
|
||||
_micIdle();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+1
@@ -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<o;n++){var s=r[n>>>2]>>>24-n%4*8&255;e[i+n>>>2]|=s<<24-(i+n)%4*8}else for(var c=0;c<o;c+=4)e[i+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<t;r+=4)e.push(function(){if(i){if("function"==typeof i.getRandomValues)try{return i.getRandomValues(new Uint32Array(1))[0]}catch(t){}if("function"==typeof i.randomBytes)try{return i.randomBytes(4).readInt32LE()}catch(t){}}throw new Error("Native crypto module could not be used to get secure random number.")}());return new l.init(e,t)}}),s=t.enc={},c=s.Hex={stringify:function(t){for(var e=t.words,r=t.sigBytes,i=[],o=0;o<r;o++){var n=e[o>>>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<e;i+=2)r[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<r;o++){var n=e[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<e;i++)r[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<c;a+=n)this._doProcessBlock(i,a);e=i.splice(0,c),r.sigBytes-=t}return new l.init(e,t)},clone:function(){var t=n.clone.call(this);return t._data=this._data.clone(),t},_minBufferSize:0}),u=(o.Hasher=d.extend({cfg:n.extend(),init:function(t){this.cfg=this.cfg.extend(t),this.reset()},reset:function(){d.reset.call(this),this._doReset()},update:function(t){return this._append(t),this._process(),this},finalize:function(t){return t&&this._append(t),this._doFinalize()},blockSize:16,_createHelper:function(r){return function(t,e){return new r.init(e).finalize(t)}},_createHmacHelper:function(r){return function(t,e){return new u.HMAC.init(r,e).finalize(t)}}}),t.algo={});return t}(Math),u=(u=(p=i).lib,W=u.Base,O=u.WordArray,(u=p.x64={}).Word=W.extend({init:function(t,e){this.high=t,this.low=e}}),u.WordArray=W.extend({init:function(t,e){t=this.words=t||[],this.sigBytes=null!=e?e:8*t.length},toX32:function(){for(var t=this.words,e=t.length,r=[],i=0;i<e;i++){var o=t[i];r.push(o.high),r.push(o.low)}return O.create(r,this.sigBytes)},clone:function(){for(var t=W.clone.call(this),e=t.words=this.words.slice(0),r=e.length,i=0;i<r;i++)e[i]=e[i].clone();return t}}),"function"==typeof ArrayBuffer&&(p=i.lib.WordArray,I=p.init,(p.init=function(t){if((t=(t=t instanceof ArrayBuffer?new Uint8Array(t):t)instanceof Int8Array||"undefined"!=typeof Uint8ClampedArray&&t instanceof Uint8ClampedArray||t instanceof Int16Array||t instanceof Uint16Array||t instanceof Int32Array||t instanceof Uint32Array||t instanceof Float32Array||t instanceof Float64Array?new Uint8Array(t.buffer,t.byteOffset,t.byLine truncated
|
||||
@@ -0,0 +1,224 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>JARVIS</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #07090f;
|
||||
--card: rgba(255,255,255,0.04);
|
||||
--border: rgba(255,255,255,0.08);
|
||||
--accent: #6366f1;
|
||||
--adim: rgba(99,102,241,0.15);
|
||||
--text: #dde3ed;
|
||||
--muted: #5e6a7e;
|
||||
}
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.card {
|
||||
width: 340px;
|
||||
padding: 44px 36px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 22px;
|
||||
text-align: center;
|
||||
}
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.logo em { color: var(--accent); font-style: normal; }
|
||||
.sub {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 3px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.key-wrap {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.key-input {
|
||||
width: 100%;
|
||||
padding: 18px 14px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 14px;
|
||||
color: var(--text);
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
text-align: center;
|
||||
letter-spacing: 10px;
|
||||
text-transform: uppercase;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
||||
caret-color: var(--accent);
|
||||
}
|
||||
.key-input::placeholder {
|
||||
color: rgba(94,106,126,0.5);
|
||||
letter-spacing: 6px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.key-input:focus {
|
||||
border-color: var(--accent);
|
||||
background: var(--adim);
|
||||
box-shadow: 0 0 0 3px rgba(99,102,241,0.12);
|
||||
}
|
||||
.err {
|
||||
font-size: 12px;
|
||||
color: #f87171;
|
||||
min-height: 18px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 3px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
.btn:hover { opacity: 0.88; }
|
||||
.btn:active { transform: scale(0.98); }
|
||||
@keyframes shake {
|
||||
0%,100% { transform: translateX(0); }
|
||||
20%,60% { transform: translateX(-7px); }
|
||||
40%,80% { transform: translateX(7px); }
|
||||
}
|
||||
.shake { animation: shake 0.35s ease; }
|
||||
.voice-note {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
margin-top: 20px;
|
||||
line-height: 1.6;
|
||||
text-align: left;
|
||||
}
|
||||
.voice-note code {
|
||||
background: rgba(255,255,255,0.07);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 9px;
|
||||
}
|
||||
#reconnecting {
|
||||
display: none;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card" id="card">
|
||||
<div class="logo"><em>J</em>ARVIS</div>
|
||||
<div class="sub">Remote Access</div>
|
||||
<p id="reconnecting">Reconnecting…</p>
|
||||
<p class="hint">Press <strong style="color:var(--text)">Remote Control</strong> in the JARVIS desktop app to get a QR code or session key.</p>
|
||||
<div class="key-wrap">
|
||||
<input class="key-input" id="key" type="text" maxlength="6"
|
||||
autocomplete="off" autocorrect="off" spellcheck="false"
|
||||
placeholder="· · · · · ·">
|
||||
</div>
|
||||
<div class="err" id="err"></div>
|
||||
<button class="btn" onclick="doLogin()">CONNECT</button>
|
||||
<p class="voice-note">
|
||||
🎤 <strong style="color:var(--text)">Voice commands:</strong> open
|
||||
<code>chrome://flags</code> on your phone, search
|
||||
<code>Insecure origins treated as secure</code>, add your JARVIS URL, tap Relaunch.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Try auto-reconnect with stored device token (set on first QR login)
|
||||
(async function() {
|
||||
const devTok = localStorage.getItem('jarvis_device_token');
|
||||
if (!devTok) return;
|
||||
document.getElementById('reconnecting').style.display = 'block';
|
||||
try {
|
||||
const r = await fetch('/api/device-login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ device_token: devTok }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.ok && d.token) {
|
||||
sessionStorage.setItem('jarvis_key', d.key);
|
||||
sessionStorage.setItem('jarvis_token', d.token);
|
||||
location.replace('/');
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
// Device token rejected (server restarted) — clear it and show normal form
|
||||
localStorage.removeItem('jarvis_device_token');
|
||||
document.getElementById('reconnecting').style.display = 'none';
|
||||
})();
|
||||
|
||||
const inp = document.getElementById('key');
|
||||
inp.focus();
|
||||
|
||||
inp.addEventListener('input', () => {
|
||||
inp.value = inp.value.toUpperCase().replace(/[^A-Z2-9]/g, '');
|
||||
if (inp.value.length === 6) doLogin();
|
||||
});
|
||||
inp.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
|
||||
|
||||
async function doLogin() {
|
||||
const key = inp.value.trim();
|
||||
if (key.length < 6) { shake(); return; }
|
||||
let data;
|
||||
try {
|
||||
const r = await fetch('/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pin: key })
|
||||
});
|
||||
data = await r.json();
|
||||
} catch (e) {
|
||||
document.getElementById('err').textContent = 'Connection error';
|
||||
shake(); return;
|
||||
}
|
||||
if (data.ok && data.token) {
|
||||
sessionStorage.setItem('jarvis_key', key);
|
||||
sessionStorage.setItem('jarvis_token', data.token);
|
||||
location.href = '/';
|
||||
} else {
|
||||
inp.value = '';
|
||||
inp.focus();
|
||||
document.getElementById('err').textContent = 'Invalid or expired key';
|
||||
shake();
|
||||
}
|
||||
}
|
||||
|
||||
function shake() {
|
||||
const c = document.getElementById('card');
|
||||
c.classList.remove('shake');
|
||||
void c.offsetWidth;
|
||||
c.classList.add('shake');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+51
-54
@@ -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)
|
||||
@@ -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 <module_name>` 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) |
|
||||
+17
-44
@@ -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]
|
||||
@@ -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.")
|
||||
|
||||
Reference in new issue
Block a user