zerochat 10.0.0__tar.gz → 10.1.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: 10.0.0
3
+ Version: 10.1.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 = "10.0.0"
7
+ version = "10.1.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: 10.0.0
3
+ Version: 10.1.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
@@ -53,7 +53,7 @@ import zlib
53
53
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
54
54
  from pathlib import Path
55
55
 
56
- SOURCE_BACKEND_VERSION = "10.0.0"
56
+ SOURCE_BACKEND_VERSION = "10.1.0"
57
57
 
58
58
  def _read_source_version(filename: str) -> str | None:
59
59
  """Lee la versión de un archivo del repositorio cuando se ejecuta desde fuentes."""
@@ -3433,6 +3433,8 @@ MAX_PROFILE_NAME_LENGTH = 128
3433
3433
  MAX_PROFILE_DESCRIPTION_LENGTH = 2048
3434
3434
  MAX_CONVERSATION_ID_LENGTH = 128
3435
3435
  MAX_CONVERSATION_TITLE_LENGTH = 256
3436
+ MAX_BLOB_SIZE = 50 * 1024 * 1024
3437
+ MAX_BLOB_HTTP_BYTES = 72 * 1024 * 1024
3436
3438
 
3437
3439
  STORAGE_MIGRATIONS = [
3438
3440
  (1, "001_create_profiles_table", """
@@ -3469,6 +3471,16 @@ STORAGE_MIGRATIONS = [
3469
3471
  FOREIGN KEY(conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
3470
3472
  PRIMARY KEY (conversation_id, position)
3471
3473
  );
3474
+ """),
3475
+ (3, "003_create_blobs_table", """
3476
+ CREATE TABLE IF NOT EXISTS blobs (
3477
+ id TEXT PRIMARY KEY,
3478
+ mime_type TEXT NOT NULL,
3479
+ size INTEGER NOT NULL,
3480
+ data BLOB NOT NULL,
3481
+ created_at INTEGER NOT NULL
3482
+ );
3483
+ CREATE INDEX IF NOT EXISTS idx_blobs_created_at ON blobs(created_at);
3472
3484
  """)
3473
3485
  ]
3474
3486
 
@@ -3831,6 +3843,82 @@ def delete_all_conversations() -> int:
3831
3843
  cursor = conn.execute("DELETE FROM conversations;")
3832
3844
  conn.commit()
3833
3845
  return cursor.rowcount
3846
+
3847
+
3848
+ def _validate_blob_id(blob_id: str) -> str:
3849
+ """Valida que el identificador del blob sea un hash SHA-256 en hexadecimal (64 caracteres)."""
3850
+ if not isinstance(blob_id, str) or not re.match(r"^[0-9a-f]{64}$", blob_id.strip().lower()):
3851
+ raise ValueError(f"ID de blob inválido (debe ser un hash SHA-256 hexadecimal de 64 caracteres): {blob_id}")
3852
+ return blob_id.strip().lower()
3853
+
3854
+
3855
+ def put_blob(data_bytes: bytes, mime_type: str = "application/octet-stream", blob_id: str | None = None) -> dict:
3856
+ """Almacena un blob binario indexado por su hash SHA-256."""
3857
+ if not isinstance(data_bytes, (bytes, bytearray)):
3858
+ raise ValueError("Los datos del blob deben ser bytes")
3859
+ if len(data_bytes) == 0:
3860
+ raise ValueError("El blob no puede estar vacío")
3861
+ if len(data_bytes) > MAX_BLOB_SIZE:
3862
+ raise ValueError(f"El tamaño del blob ({len(data_bytes)} bytes) excede el máximo permitido ({MAX_BLOB_SIZE} bytes)")
3863
+
3864
+ calc_id = hashlib.sha256(data_bytes).hexdigest()
3865
+ if blob_id is not None:
3866
+ clean_id = _validate_blob_id(blob_id)
3867
+ if clean_id != calc_id:
3868
+ raise ValueError(f"El ID del blob ({clean_id}) no coincide con el hash SHA-256 de los datos ({calc_id})")
3869
+ else:
3870
+ clean_id = calc_id
3871
+
3872
+ mime = (mime_type or "application/octet-stream").strip()
3873
+ now = int(time.time() * 1000)
3874
+ with open_storage() as conn:
3875
+ conn.execute("""
3876
+ INSERT INTO blobs (id, mime_type, size, data, created_at)
3877
+ VALUES (?, ?, ?, ?, ?)
3878
+ ON CONFLICT(id) DO UPDATE SET
3879
+ mime_type = excluded.mime_type,
3880
+ size = excluded.size,
3881
+ data = excluded.data;
3882
+ """, (clean_id, mime, len(data_bytes), sqlite3.Binary(data_bytes), now))
3883
+ conn.commit()
3884
+ return {"id": clean_id, "mime_type": mime, "size": len(data_bytes), "created_at": now}
3885
+
3886
+
3887
+ def get_blob(blob_id: str) -> dict | None:
3888
+ """Recupera un blob por su hash SHA-256."""
3889
+ clean_id = _validate_blob_id(blob_id)
3890
+ with open_storage() as conn:
3891
+ row = conn.execute("SELECT id, mime_type, size, data, created_at FROM blobs WHERE id = ?;", (clean_id,)).fetchone()
3892
+ if not row:
3893
+ return None
3894
+ return {
3895
+ "id": row["id"],
3896
+ "mime_type": row["mime_type"],
3897
+ "size": row["size"],
3898
+ "data": bytes(row["data"]),
3899
+ "created_at": row["created_at"],
3900
+ }
3901
+
3902
+
3903
+ def has_blobs(blob_ids: list[str]) -> dict:
3904
+ """Comprueba la existencia de una lista de IDs de blobs, devolviendo los existentes y los faltantes."""
3905
+ if not isinstance(blob_ids, list):
3906
+ raise ValueError("La lista de IDs de blobs debe ser una lista")
3907
+ if len(blob_ids) > 1000:
3908
+ raise ValueError("No se pueden consultar más de 1000 IDs de blobs a la vez")
3909
+ clean_ids = [_validate_blob_id(bid) for bid in blob_ids]
3910
+ if not clean_ids:
3911
+ return {"existing": [], "missing": []}
3912
+ unique_ids = list(dict.fromkeys(clean_ids))
3913
+ with open_storage() as conn:
3914
+ placeholders = ",".join("?" for _ in unique_ids)
3915
+ rows = conn.execute(f"SELECT id FROM blobs WHERE id IN ({placeholders});", unique_ids).fetchall()
3916
+ existing_set = {row["id"] for row in rows}
3917
+ return {
3918
+ "existing": [bid for bid in unique_ids if bid in existing_set],
3919
+ "missing": [bid for bid in unique_ids if bid not in existing_set],
3920
+ }
3921
+
3834
3922
  DEFAULT_HEARTBEAT_TIMEOUT = float(os.environ.get("ZEROCHAT_HEARTBEAT_TIMEOUT", "60.0"))
3835
3923
  DEFAULT_HEARTBEAT_GRACE = float(os.environ.get("ZEROCHAT_HEARTBEAT_GRACE", "45.0"))
3836
3924
  DEFAULT_HEARTBEAT_POLL = float(os.environ.get("ZEROCHAT_HEARTBEAT_POLL", "5.0"))
@@ -4162,7 +4250,7 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
4162
4250
  return
4163
4251
  self.send_header("Access-Control-Allow-Origin", origin if origin else "*")
4164
4252
  self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
4165
- self.send_header("Access-Control-Allow-Headers", "Content-Type, Accept, Authorization, X-ZeroChat-Token, X-ZeroChat-Client, X-ZeroChat-Proxy, X-ZeroChat-Tool-Session, X-ZeroChat-Tool-Expires, X-ZeroChat-Tool-Nonce, X-ZeroChat-Tool-Signature")
4253
+ self.send_header("Access-Control-Allow-Headers", "Content-Type, Accept, Authorization, X-ZeroChat-Token, X-ZeroChat-Client, X-ZeroChat-Proxy, X-ZeroChat-Tool-Session, X-ZeroChat-Tool-Expires, X-ZeroChat-Tool-Nonce, X-ZeroChat-Tool-Signature, X-ZeroChat-Blob")
4166
4254
  self.send_header("Access-Control-Allow-Private-Network", "true")
4167
4255
 
4168
4256
  def verify_token(self) -> bool:
@@ -4287,7 +4375,11 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
4287
4375
  self._log_req("POST", safe_path)
4288
4376
  self._reject(400, {"error": "Invalid Content-Length"}, safe_path, t0, "Content-Length negativo")
4289
4377
  return None
4290
- body_limit = http_body_limit()
4378
+ is_blob_upload = (
4379
+ self.headers.get("X-ZeroChat-Blob") == "1"
4380
+ or safe_path in ("/zerochat/blobs", "/zerochat/blobs/put")
4381
+ )
4382
+ body_limit = MAX_BLOB_HTTP_BYTES if is_blob_upload else http_body_limit()
4291
4383
  if content_len > body_limit:
4292
4384
  self._log_req("POST", safe_path)
4293
4385
  if content_len <= body_limit * 2:
@@ -4307,8 +4399,10 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
4307
4399
  self._reject(400, {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": f"Parse error: {err}"}},
4308
4400
  safe_path, t0, f"Error parseando JSON: {err}")
4309
4401
  return None
4310
- # El tope ampliado de http_body_limit es solo para la exportación de --prompt --export.
4311
- if content_len > MAX_HTTP_BODY_BYTES and not (isinstance(req, dict) and req.get("method") == "zerochat/run/export"):
4402
+ # El tope ampliado de http_body_limit es solo para la exportación de --prompt --export y la subida de blobs.
4403
+ if content_len > MAX_HTTP_BODY_BYTES and not (
4404
+ isinstance(req, dict) and req.get("method") in ("zerochat/run/export", "zerochat/blobs/put")
4405
+ ):
4312
4406
  self._log_req("POST", safe_path)
4313
4407
  self._reject(413, {"error": "Request body too large"}, safe_path, t0, "Cuerpo HTTP excede el límite")
4314
4408
  return None
@@ -4467,6 +4561,8 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
4467
4561
  return self._dispatch_profiles(method, params)
4468
4562
  if method.startswith("zerochat/conversations/"):
4469
4563
  return self._dispatch_conversations(method, params)
4564
+ if method.startswith("zerochat/blobs/"):
4565
+ return self._dispatch_blobs(method, params)
4470
4566
  return None, {"code": -32601, "message": f"Método '{method}' no soportado."}, ""
4471
4567
 
4472
4568
  @staticmethod
@@ -4525,6 +4621,51 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
4525
4621
  return None, {"code": -32603, "message": f"Error en gestión de conversaciones: {exc}"}, str(exc)
4526
4622
  return None, {"code": -32601, "message": f"Método '{method}' no soportado."}, ""
4527
4623
 
4624
+ @staticmethod
4625
+ def _dispatch_blobs(method: str, params: dict) -> tuple[dict | None, dict | None, str]:
4626
+ """Gestión de blobs desacoplados de mensajes (imágenes y adjuntos) en SQLite."""
4627
+ params = params if isinstance(params, dict) else {}
4628
+ try:
4629
+ if method == "zerochat/blobs/has":
4630
+ ids = params.get("ids")
4631
+ if ids is None:
4632
+ ids = []
4633
+ return has_blobs(ids), None, ""
4634
+ if method == "zerochat/blobs/put":
4635
+ raw_data = params.get("data")
4636
+ if not raw_data or not isinstance(raw_data, str):
4637
+ raise ValueError("El parámetro 'data' debe ser una cadena en base64 no vacía")
4638
+ mime_type = params.get("mimeType") or "application/octet-stream"
4639
+ if raw_data.startswith("data:") and ";base64," in raw_data:
4640
+ header, b64_part = raw_data.split(";base64,", 1)
4641
+ if not params.get("mimeType"):
4642
+ mime_type = header[5:]
4643
+ raw_data = b64_part
4644
+ try:
4645
+ data_bytes = base64.b64decode(raw_data)
4646
+ except Exception as decode_err:
4647
+ raise ValueError(f"Error decodificando base64: {decode_err}")
4648
+ saved = put_blob(data_bytes, mime_type=mime_type, blob_id=params.get("id"))
4649
+ return {"id": saved["id"], "mimeType": saved["mime_type"], "size": saved["size"]}, None, ""
4650
+ if method == "zerochat/blobs/get":
4651
+ blob = get_blob(params.get("id"))
4652
+ if not blob:
4653
+ return {"blob": None}, None, ""
4654
+ return {
4655
+ "blob": {
4656
+ "id": blob["id"],
4657
+ "mimeType": blob["mime_type"],
4658
+ "size": blob["size"],
4659
+ "data": base64.b64encode(blob["data"]).decode("ascii"),
4660
+ "createdAt": blob["created_at"],
4661
+ }
4662
+ }, None, ""
4663
+ except ValueError as exc:
4664
+ return None, {"code": -32602, "message": str(exc)}, ""
4665
+ except Exception as exc:
4666
+ return None, {"code": -32603, "message": f"Error en gestión de blobs: {exc}"}, str(exc)
4667
+ return None, {"code": -32601, "message": f"Método '{method}' no soportado."}, ""
4668
+
4528
4669
  @staticmethod
4529
4670
  def _login(params: dict) -> tuple[dict | None, dict | None, str]:
4530
4671
  try:
@@ -4913,7 +5054,12 @@ def launch_headless_browser(url: str) -> subprocess.Popen | None:
4913
5054
  log_event(f"Abriendo {Path(executable).name} sin ventana en la interfaz configurada...")
4914
5055
  try:
4915
5056
  return subprocess.Popen(
4916
- [executable, "--headless", url],
5057
+ [
5058
+ executable,
5059
+ "--headless",
5060
+ "--disable-features=LocalNetworkAccessChecks,BlockInsecurePrivateNetworkRequests",
5061
+ url,
5062
+ ],
4917
5063
  stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
4918
5064
  start_new_session=True,
4919
5065
  )
@@ -4965,7 +5111,12 @@ def launch_playwright_chromium(url: str) -> subprocess.Popen | None:
4965
5111
  log_event(f"Abriendo {Path(executable).name} de Playwright sin ventana en la interfaz configurada...")
4966
5112
  try:
4967
5113
  return subprocess.Popen(
4968
- [executable, "--headless", url],
5114
+ [
5115
+ executable,
5116
+ "--headless",
5117
+ "--disable-features=LocalNetworkAccessChecks,BlockInsecurePrivateNetworkRequests",
5118
+ url,
5119
+ ],
4969
5120
  stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
4970
5121
  start_new_session=True,
4971
5122
  )
File without changes
File without changes
File without changes