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.
- {zerochat-10.0.0/zerochat.egg-info → zerochat-10.1.0}/PKG-INFO +1 -1
- {zerochat-10.0.0 → zerochat-10.1.0}/pyproject.toml +1 -1
- {zerochat-10.0.0 → zerochat-10.1.0/zerochat.egg-info}/PKG-INFO +1 -1
- {zerochat-10.0.0 → zerochat-10.1.0}/zerochat.py +158 -7
- {zerochat-10.0.0 → zerochat-10.1.0}/LICENSE +0 -0
- {zerochat-10.0.0 → zerochat-10.1.0}/README.md +0 -0
- {zerochat-10.0.0 → zerochat-10.1.0}/setup.cfg +0 -0
- {zerochat-10.0.0 → zerochat-10.1.0}/zerochat.egg-info/SOURCES.txt +0 -0
- {zerochat-10.0.0 → zerochat-10.1.0}/zerochat.egg-info/dependency_links.txt +0 -0
- {zerochat-10.0.0 → zerochat-10.1.0}/zerochat.egg-info/entry_points.txt +0 -0
- {zerochat-10.0.0 → zerochat-10.1.0}/zerochat.egg-info/top_level.txt +0 -0
|
@@ -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.
|
|
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
|
-
|
|
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 (
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|