zerochat 9.7.0__tar.gz → 9.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of zerochat might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zerochat
3
- Version: 9.7.0
3
+ Version: 9.9.0
4
4
  Summary: Private local web client for AI models, tools and MCP services
5
5
  Author: Alberto Alday
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "zerochat"
7
- version = "9.7.0"
7
+ version = "9.9.0"
8
8
  description = "Private local web client for AI models, tools and MCP services"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zerochat
3
- Version: 9.7.0
3
+ Version: 9.9.0
4
4
  Summary: Private local web client for AI models, tools and MCP services
5
5
  Author: Alberto Alday
6
6
  License-Expression: MIT
@@ -46,7 +46,7 @@ import zlib
46
46
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
47
47
  from pathlib import Path
48
48
 
49
- SOURCE_BACKEND_VERSION = "9.7.0"
49
+ SOURCE_BACKEND_VERSION = "9.9.0"
50
50
 
51
51
  def _read_source_version(filename: str) -> str | None:
52
52
  """Lee la versión de un archivo del repositorio cuando se ejecuta desde fuentes."""
@@ -1344,6 +1344,92 @@ def set_project_directory(path) -> dict:
1344
1344
  return {"cwd": str(target), "previous_cwd": previous}
1345
1345
 
1346
1346
 
1347
+ # Visores gráficos que `git difftool` ya sabe lanzar, en orden de preferencia, con el ejecutable que los delata.
1348
+ GUI_DIFF_TOOLS = {"vscode": "code", "meld": "meld", "kdiff3": "kdiff3", "kompare": "kompare", "gvimdiff": "gvim"}
1349
+ # Respaldo cuando no hay ninguno: visor web que se instala en el entorno aislado de ZeroChat.
1350
+ WEBDIFF_REQUIREMENT = "webdiff==1.4.0"
1351
+ WEBDIFF_INSTALL_TIMEOUT_SECONDS = 120
1352
+
1353
+
1354
+ def _venv_webdiff() -> Path:
1355
+ """Ruta de webdiff dentro de ~/zerochat/.venv, junto al intérprete del entorno."""
1356
+ venv_python = get_venv_python(get_venv_dir())
1357
+ return venv_python.with_name("webdiff" + venv_python.suffix)
1358
+
1359
+
1360
+ def _find_webdiff() -> str:
1361
+ """webdiff del entorno de ZeroChat o, si no, del PATH; '' si no hay ninguno."""
1362
+ venv_webdiff = _venv_webdiff()
1363
+ if venv_webdiff.exists():
1364
+ return str(venv_webdiff)
1365
+ return shutil.which("webdiff") or ""
1366
+
1367
+
1368
+ def _diff_tool_settings(tool: str, webdiff_bin: str) -> list:
1369
+ """Pares (clave, valor) de git config que deja `tool` como visor gráfico; webdiff no es un visor integrado de git."""
1370
+ settings = [("diff.guitool", tool)]
1371
+ if tool == "webdiff":
1372
+ settings.insert(0, ("difftool.webdiff.cmd", f'"{webdiff_bin}" "$LOCAL" "$REMOTE"'))
1373
+ return settings
1374
+
1375
+
1376
+ def detect_gui_diff_tool() -> dict:
1377
+ """Visor que proponer cuando Git no tiene ninguno: el primero instalado o, si no hay, webdiff.
1378
+
1379
+ Incluye las órdenes exactas que aplicará configure_gui_diff_tool, para mostrarlas antes de confirmar.
1380
+ """
1381
+ tool = next((name for name, binary in GUI_DIFF_TOOLS.items() if shutil.which(binary)), "webdiff")
1382
+ webdiff_bin = _find_webdiff()
1383
+ return {
1384
+ "tool": tool,
1385
+ "install": tool == "webdiff" and not webdiff_bin,
1386
+ "commands": [
1387
+ shlex.join(["git", "config", "--global", key, value])
1388
+ for key, value in _diff_tool_settings(tool, webdiff_bin or str(_venv_webdiff()))
1389
+ ],
1390
+ }
1391
+
1392
+
1393
+ def _install_webdiff() -> str:
1394
+ """Instala webdiff en ~/zerochat/.venv y devuelve la ruta del ejecutable."""
1395
+ ensure_virtual_environment()
1396
+ venv_python = get_venv_python(get_venv_dir())
1397
+ if not venv_python.exists():
1398
+ raise RuntimeError(f"No se pudo preparar el entorno virtual de ZeroChat: falta {venv_python}")
1399
+ res = subprocess.run(
1400
+ [str(venv_python), "-m", "pip", "install", WEBDIFF_REQUIREMENT],
1401
+ capture_output=True,
1402
+ text=True,
1403
+ timeout=WEBDIFF_INSTALL_TIMEOUT_SECONDS,
1404
+ check=False
1405
+ )
1406
+ if res.returncode != 0:
1407
+ raise RuntimeError(f"Error instalando webdiff: {(res.stderr or res.stdout).strip()[-500:]}")
1408
+ webdiff = _venv_webdiff()
1409
+ if not webdiff.exists():
1410
+ raise RuntimeError(f"webdiff se instaló pero no aparece en {webdiff}")
1411
+ return str(webdiff)
1412
+
1413
+
1414
+ def configure_gui_diff_tool(tool) -> dict:
1415
+ """Deja `tool` como visor gráfico global de Git, instalando antes webdiff si es el elegido y falta."""
1416
+ if not isinstance(tool, str) or (tool != "webdiff" and tool not in GUI_DIFF_TOOLS):
1417
+ raise ValueError(f"Visor de diffs '{tool}' no reconocido.")
1418
+ git_bin = shutil.which("git")
1419
+ if not git_bin:
1420
+ raise RuntimeError("Git no está instalado o no se encuentra en el PATH del sistema.")
1421
+
1422
+ webdiff_bin = ""
1423
+ if tool == "webdiff":
1424
+ webdiff_bin = _find_webdiff() or _install_webdiff()
1425
+ elif not shutil.which(GUI_DIFF_TOOLS[tool]):
1426
+ raise ValueError(f"'{GUI_DIFF_TOOLS[tool]}' no está en el PATH del sistema.")
1427
+
1428
+ for key, value in _diff_tool_settings(tool, webdiff_bin):
1429
+ subprocess.run([git_bin, "config", "--global", key, value], capture_output=True, timeout=5, check=True)
1430
+ return {"tool": tool}
1431
+
1432
+
1347
1433
  def _configured_gui_diff_tool(git_bin: str, repo_root: Path) -> str:
1348
1434
  """Herramienta gráfica que usaría `git difftool --gui`, o '' si no hay ninguna configurada.
1349
1435
 
@@ -1365,7 +1451,10 @@ def _configured_gui_diff_tool(git_bin: str, repo_root: Path) -> str:
1365
1451
 
1366
1452
 
1367
1453
  def open_file_diff(path: str, commit_fallback: bool = False) -> dict:
1368
- """Abre un visor externo de diffs (git difftool) para comparar el fichero actual frente a Git HEAD, su último commit o fichero vacío."""
1454
+ """Abre un visor externo de diffs (git difftool) para comparar el fichero actual frente a Git HEAD, su último commit o fichero vacío.
1455
+
1456
+ Sin visor gráfico configurado no lanza nada y devuelve en `setup` el que propone configurar.
1457
+ """
1369
1458
  if not isinstance(path, str) or not path.strip():
1370
1459
  raise ValueError("La ruta del archivo está vacía.")
1371
1460
  if len(path) > MAX_PATH_LENGTH:
@@ -1398,10 +1487,7 @@ def open_file_diff(path: str, commit_fallback: bool = False) -> dict:
1398
1487
 
1399
1488
  repo_root = Path(res.stdout.strip()).resolve()
1400
1489
  if not _configured_gui_diff_tool(git_bin, repo_root):
1401
- raise ValueError(
1402
- "No hay un visor gráfico de diffs configurado en Git. Configura uno, por ejemplo: "
1403
- "git config --global diff.guitool meld"
1404
- )
1490
+ return {"success": False, "setup": detect_gui_diff_tool()}
1405
1491
 
1406
1492
  has_head = subprocess.run(
1407
1493
  [git_bin, "rev-parse", "--verify", "HEAD"],
@@ -1727,17 +1813,13 @@ class PersistentBrowserSession:
1727
1813
  const readline = require('readline');
1728
1814
  let playwright;
1729
1815
  try {
1730
- playwright = require('playwright');
1731
- } catch (e1) {
1732
- try {
1733
- playwright = require(process.env.ZEROCHAT_PLAYWRIGHT_MODULE);
1734
- } catch (e2) {
1735
- console.log(JSON.stringify({
1736
- success: false,
1737
- error: "Playwright is not available. Install it with 'npm install playwright' or enable the Playwright MCP service."
1738
- }));
1739
- process.exit(1);
1740
- }
1816
+ playwright = require(process.env.ZEROCHAT_PLAYWRIGHT_MODULE);
1817
+ } catch (e) {
1818
+ console.log(JSON.stringify({
1819
+ success: false,
1820
+ error: "Playwright is not available. Start the Playwright service once in Settings > MCP to install it."
1821
+ }));
1822
+ process.exit(1);
1741
1823
  }
1742
1824
 
1743
1825
  (async () => {
@@ -1903,8 +1985,8 @@ def browser_action_availability() -> dict:
1903
1985
  """Comprueba requisitos locales sin lanzar Node.js ni Chromium."""
1904
1986
  if not shutil.which("node"):
1905
1987
  return {"available": False, "error": "Node.js is not installed or not found in system PATH."}
1906
- playwright_locations = (Path.cwd() / "node_modules" / "playwright", playwright_service_module())
1907
- if not any(location.is_dir() for location in playwright_locations):
1988
+ # Solo el Playwright del servicio, no el node_modules de la carpeta desde la que arranca el servidor.
1989
+ if not playwright_service_cli().is_file():
1908
1990
  return {"available": False, "error": "Playwright is not available."}
1909
1991
  # El runner prueba las entradas de Chromium de Playwright y los canales chrome y msedge.
1910
1992
  browsers = system_browsers()
@@ -1913,6 +1995,11 @@ def browser_action_availability() -> dict:
1913
1995
  return {"available": False, "error": "Playwright Chromium is not installed."}
1914
1996
 
1915
1997
 
1998
+ def playwright_status() -> dict:
1999
+ """Playwright del servicio y browser_action tal como están ahora, para el latido del cliente."""
2000
+ return {"installed": playwright_service_cli().is_file(), "browserAction": browser_action_availability()}
2001
+
2002
+
1916
2003
  def browser_action(action: str, url: str | None = None, selector: str | None = None, value: str | None = None) -> str:
1917
2004
  """Control a headless browser for UI testing and visual inspection."""
1918
2005
  try:
@@ -2217,18 +2304,22 @@ def playwright_launch_env(config: dict) -> dict[str, str]:
2217
2304
 
2218
2305
 
2219
2306
  def validate_profile_browser(raw) -> dict:
2220
- """Navegador que exige un perfil: el propio con un motor o el del usuario con uno de sus navegadores."""
2221
- if isinstance(raw, dict) and raw.get("mode") == "own" and set(raw) == {"mode", "engine"} and raw["engine"] in OWN_BROWSER_ENGINES:
2222
- return {"mode": "own", "engine": raw["engine"]}
2307
+ """Navegador que exige un perfil: el propio con un motor, y headless False si exige la ventana
2308
+ visible, o el del usuario con uno de sus navegadores."""
2309
+ if isinstance(raw, dict) and raw.get("mode") == "own" and raw.get("engine") in OWN_BROWSER_ENGINES:
2310
+ if set(raw) == {"mode", "engine"}:
2311
+ return {"mode": "own", "engine": raw["engine"]}
2312
+ if set(raw) == {"mode", "engine", "headless"} and raw["headless"] is False:
2313
+ return {"mode": "own", "engine": raw["engine"], "headless": False}
2223
2314
  if isinstance(raw, dict) and raw.get("mode") == "user" and set(raw) == {"mode", "userBrowser"} and raw["userBrowser"] in USER_BROWSERS:
2224
2315
  return {"mode": "user", "userBrowser": raw["userBrowser"]}
2225
- raise ValueError("browser debe ser {mode: own, engine} o {mode: user, userBrowser} con un motor o navegador admitido")
2316
+ raise ValueError("browser debe ser {mode: own, engine[, headless: false]} o {mode: user, userBrowser} con un motor o navegador admitido")
2226
2317
 
2227
2318
 
2228
2319
  def browser_choice(config: dict) -> dict:
2229
2320
  """Modo y navegador con los que arranca Playwright MCP, en la forma de validate_profile_browser."""
2230
2321
  if config["mode"] == "own":
2231
- return {"mode": "own", "engine": config["engine"]}
2322
+ return {"mode": "own", "engine": config["engine"], **({} if config["headless"] else {"headless": False})}
2232
2323
  return {"mode": "user", "userBrowser": config["userBrowser"]}
2233
2324
 
2234
2325
 
@@ -2358,22 +2449,19 @@ class BrowserEngines:
2358
2449
  return {name: (title, Path(location.strip())) for title, name, location in _DRY_RUN_ENTRY.findall(result.stdout)}
2359
2450
 
2360
2451
  def list(self) -> dict:
2452
+ """Navegadores del sistema y motores de Playwright; sin Playwright del servicio no hay motores."""
2361
2453
  cli = playwright_service_cli()
2362
2454
  result = {"systemBrowsers": system_browsers(), "extensions": user_browser_extensions(), "engines": []}
2363
2455
  if not cli.is_file():
2364
- return {**result, "available": False, "error": "Playwright MCP is not installed."}
2365
- try:
2366
- version = json.loads((cli.parent / "package.json").read_text(encoding="utf-8")).get("version")
2367
- locations = self._locations(cli)
2368
- except (OSError, json.JSONDecodeError, RuntimeError, subprocess.SubprocessError) as err:
2369
- return {**result, "available": False, "error": str(err)}
2456
+ return result
2457
+ locations = self._locations(cli)
2370
2458
  with self._lock:
2371
2459
  jobs = {engine: dict(job) for engine, job in self._jobs.items()}
2372
2460
  for engine, entries in ENGINE_CACHE_ENTRIES.items():
2373
2461
  title = locations.get(entries[0], (engine,))[0]
2374
2462
  installed = all(name in locations and (locations[name][1] / "INSTALLATION_COMPLETE").is_file() for name in entries)
2375
2463
  result["engines"].append({"id": engine, "title": title, "installed": installed, **jobs.get(engine, {})})
2376
- return {**result, "available": True, "playwrightVersion": version}
2464
+ return result
2377
2465
 
2378
2466
  def _check_engine(self, engine) -> Path:
2379
2467
  if engine not in ENGINE_CACHE_ENTRIES:
@@ -2864,6 +2952,12 @@ class McpServiceManager:
2864
2952
  client = self.clients.get(server_id)
2865
2953
  state = self.states.get(server_id, "stopped")
2866
2954
  running = bool(client and client.running() and state == "running")
2955
+ error = self.errors.get(server_id)
2956
+ # El proceso de un servicio en marcha puede morir sin que nadie lo detenga.
2957
+ if state == "running" and client and not running:
2958
+ code = client.process.poll() if client.process else None
2959
+ state = "error"
2960
+ error = f"MCP process exited with code {code}" if code is not None else "MCP process is not responding"
2867
2961
  pref = self.preferences.get(server_id, {})
2868
2962
  result.append({
2869
2963
  "id": server_id,
@@ -2874,7 +2968,7 @@ class McpServiceManager:
2874
2968
  "enabled": pref.get("enabled", server.get("enabledByDefault", False)),
2875
2969
  "status": "running" if running else state,
2876
2970
  "toolCount": len(client.tools) if running else 0,
2877
- "error": self.errors.get(server_id),
2971
+ "error": error,
2878
2972
  "options": server.get("options", []),
2879
2973
  "userOptions": pref.get("options", {}),
2880
2974
  "oauthUrl": client.oauth_url if client else None,
@@ -3568,12 +3662,16 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
3568
3662
  if is_heartbeat:
3569
3663
  mark_browser_active()
3570
3664
  # cwd permite a cada pestaña notar que otra ha cambiado el directorio del proyecto;
3571
- # jobs, mostrar los comandos que siguen en marcha y avisar antes de cerrar si se detendrían.
3665
+ # jobs, mostrar los comandos que siguen en marcha y avisar antes de cerrar si se detendrían;
3666
+ # playwright, si se puede navegar aunque se haya instalado o quitado durante la sesión;
3667
+ # servers, los servicios MCP externos aunque otra pestaña los cambie o su proceso muera.
3572
3668
  self._send_json_response(200, {
3573
3669
  "ok": True,
3574
3670
  "cwd": str(Path.cwd().resolve()),
3575
3671
  "jobs": [job.summary() for job in JOBS.running()],
3576
- "exit_on_close": EXIT_ON_CLOSE
3672
+ "exit_on_close": EXIT_ON_CLOSE,
3673
+ "playwright": playwright_status(),
3674
+ "servers": GLOBAL_MCP_MANAGER.list_servers()
3577
3675
  })
3578
3676
  return
3579
3677
 
@@ -3597,7 +3695,6 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
3597
3695
  "version": VERSION,
3598
3696
  "cwd": str(Path.cwd().resolve()),
3599
3697
  "tools_count": len(LOCAL_TOOLS_DEFINITIONS),
3600
- "browser_action": browser_action_availability(),
3601
3698
  "os": DETECTED_OS
3602
3699
  })
3603
3700
  self._log_res(200, safe_path, (time.monotonic() - t0) * 1000)
@@ -3731,18 +3828,7 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
3731
3828
  }
3732
3829
  return self._initialize_result(server_info), None, ""
3733
3830
  if method == "tools/list":
3734
- browser_availability = browser_action_availability()
3735
- tools = []
3736
- for definition in LOCAL_TOOLS_DEFINITIONS:
3737
- item = dict(definition)
3738
- if item.get("name") == "browser_action":
3739
- item["availability"] = browser_availability
3740
- tools.append(item)
3741
- return {"tools": tools}, None, ""
3742
- if method == "tools/availability":
3743
- if params.get("name", "") != "browser_action":
3744
- return None, {"code": -32602, "message": "Herramienta no compatible con comprobación de disponibilidad."}, ""
3745
- return browser_action_availability(), None, ""
3831
+ return {"tools": [dict(definition) for definition in LOCAL_TOOLS_DEFINITIONS]}, None, ""
3746
3832
  if method == "tools/call":
3747
3833
  tool_name = params["name"]
3748
3834
  handler = LOCAL_TOOL_HANDLERS.get(tool_name)
@@ -3778,6 +3864,13 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
3778
3864
  return None, {"code": -32602, "message": str(exc)}, ""
3779
3865
  except Exception as exc:
3780
3866
  return None, {"code": -32603, "message": f"Error al abrir el visor de diffs: {exc}"}, str(exc)
3867
+ if method == "zerochat/diff/configure":
3868
+ try:
3869
+ return configure_gui_diff_tool(params.get("tool")), None, ""
3870
+ except (ValueError, RuntimeError) as exc:
3871
+ return None, {"code": -32602, "message": str(exc)}, ""
3872
+ except Exception as exc:
3873
+ return None, {"code": -32603, "message": f"Error configurando el visor de diffs: {exc}"}, str(exc)
3781
3874
  if method == "zerochat/web/fetch":
3782
3875
  try:
3783
3876
  return fetch_web_resource(params), None, ""
@@ -3789,8 +3882,6 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
3789
3882
  return self._dispatch_jobs(method, params)
3790
3883
  if method.startswith("zerochat/run/"):
3791
3884
  return dispatch_run(method, params)
3792
- if method == "zerochat/external/status":
3793
- return {"host": "running", "version": VERSION, "servers": GLOBAL_MCP_MANAGER.list_servers()}, None, ""
3794
3885
  if method in ("zerochat/external/servers/start", "zerochat/external/servers/stop", "zerochat/external/servers/configure"):
3795
3886
  return self._dispatch_server_control(method, params)
3796
3887
  if method.startswith("zerochat/browser/"):
File without changes
File without changes
File without changes