zerochat 7.3.0__py3-none-any.whl
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.
- zerochat-7.3.0.dist-info/METADATA +70 -0
- zerochat-7.3.0.dist-info/RECORD +130 -0
- zerochat-7.3.0.dist-info/WHEEL +5 -0
- zerochat-7.3.0.dist-info/entry_points.txt +2 -0
- zerochat-7.3.0.dist-info/licenses/LICENSE +34 -0
- zerochat-7.3.0.dist-info/top_level.txt +2 -0
- zerochat.py +2004 -0
- zerochat_runtime/__init__.py +1 -0
- zerochat_runtime/assets/css/base.css +199 -0
- zerochat_runtime/assets/css/components/composer.css +1258 -0
- zerochat_runtime/assets/css/components/debug.css +777 -0
- zerochat_runtime/assets/css/components/header.css +97 -0
- zerochat_runtime/assets/css/components/icons.css +39 -0
- zerochat_runtime/assets/css/components/markdown.css +685 -0
- zerochat_runtime/assets/css/components/messages.css +473 -0
- zerochat_runtime/assets/css/components/modals.css +1319 -0
- zerochat_runtime/assets/css/components/sidebar.css +622 -0
- zerochat_runtime/assets/css/components/tools.css +1223 -0
- zerochat_runtime/assets/css/layout.css +171 -0
- zerochat_runtime/assets/css/print.css +120 -0
- zerochat_runtime/assets/css/styles.css +18 -0
- zerochat_runtime/assets/css/theme-overrides.css +555 -0
- zerochat_runtime/assets/css/tokens.css +165 -0
- zerochat_runtime/assets/help/architecture.html +247 -0
- zerochat_runtime/assets/help/composer.html +134 -0
- zerochat_runtime/assets/help/conversations.html +105 -0
- zerochat_runtime/assets/help/debug.html +108 -0
- zerochat_runtime/assets/help/en/architecture.html +247 -0
- zerochat_runtime/assets/help/en/composer.html +134 -0
- zerochat_runtime/assets/help/en/conversations.html +104 -0
- zerochat_runtime/assets/help/en/debug.html +105 -0
- zerochat_runtime/assets/help/en/gemini-free.html +39 -0
- zerochat_runtime/assets/help/en/index.html +191 -0
- zerochat_runtime/assets/help/en/learning.html +71 -0
- zerochat_runtime/assets/help/en/mcp.html +143 -0
- zerochat_runtime/assets/help/en/openrouter-free.html +39 -0
- zerochat_runtime/assets/help/en/profiles.html +173 -0
- zerochat_runtime/assets/help/en/rag.html +130 -0
- zerochat_runtime/assets/help/en/reasoning-telemetry.html +108 -0
- zerochat_runtime/assets/help/en/tools-agent.html +133 -0
- zerochat_runtime/assets/help/en/webllm.html +355 -0
- zerochat_runtime/assets/help/gemini-free.html +39 -0
- zerochat_runtime/assets/help/help.css +604 -0
- zerochat_runtime/assets/help/help.js +51 -0
- zerochat_runtime/assets/help/index.html +194 -0
- zerochat_runtime/assets/help/learning.html +71 -0
- zerochat_runtime/assets/help/mcp.html +143 -0
- zerochat_runtime/assets/help/openrouter-free.html +39 -0
- zerochat_runtime/assets/help/profiles.html +174 -0
- zerochat_runtime/assets/help/rag.html +130 -0
- zerochat_runtime/assets/help/reasoning-telemetry.html +111 -0
- zerochat_runtime/assets/help/tools-agent.html +133 -0
- zerochat_runtime/assets/help/webllm.html +359 -0
- zerochat_runtime/assets/js/agent-core.js +1553 -0
- zerochat_runtime/assets/js/api.js +844 -0
- zerochat_runtime/assets/js/app.js +2558 -0
- zerochat_runtime/assets/js/attachments.js +220 -0
- zerochat_runtime/assets/js/charts.js +338 -0
- zerochat_runtime/assets/js/chat-engine.js +566 -0
- zerochat_runtime/assets/js/config-store.js +207 -0
- zerochat_runtime/assets/js/context-manager.js +584 -0
- zerochat_runtime/assets/js/conversation-service.js +484 -0
- zerochat_runtime/assets/js/cookies.js +688 -0
- zerochat_runtime/assets/js/data-reset-service.js +136 -0
- zerochat_runtime/assets/js/debug.js +508 -0
- zerochat_runtime/assets/js/defaults.js +16 -0
- zerochat_runtime/assets/js/export.js +179 -0
- zerochat_runtime/assets/js/file-parser.js +2088 -0
- zerochat_runtime/assets/js/generation-controller.js +417 -0
- zerochat_runtime/assets/js/i18n.js +1483 -0
- zerochat_runtime/assets/js/icons.js +156 -0
- zerochat_runtime/assets/js/ingestionEngine.js +436 -0
- zerochat_runtime/assets/js/markdown.js +588 -0
- zerochat_runtime/assets/js/mcp.js +1271 -0
- zerochat_runtime/assets/js/message-turns.js +62 -0
- zerochat_runtime/assets/js/profile-backup.js +118 -0
- zerochat_runtime/assets/js/profile-export-bundle.js +481 -0
- zerochat_runtime/assets/js/profile-repository.js +213 -0
- zerochat_runtime/assets/js/providers-webllm.js +486 -0
- zerochat_runtime/assets/js/providers.js +1560 -0
- zerochat_runtime/assets/js/rag-index.js +295 -0
- zerochat_runtime/assets/js/rag-service.js +530 -0
- zerochat_runtime/assets/js/rag-ui.js +997 -0
- zerochat_runtime/assets/js/ragStorage.js +676 -0
- zerochat_runtime/assets/js/sandbox.js +449 -0
- zerochat_runtime/assets/js/state.js +704 -0
- zerochat_runtime/assets/js/storage-db.js +144 -0
- zerochat_runtime/assets/js/tool-cards.js +330 -0
- zerochat_runtime/assets/js/tool-security.js +653 -0
- zerochat_runtime/assets/js/tools/README.md +50 -0
- zerochat_runtime/assets/js/tools/builtin/agent-checkpoint.tool.js +212 -0
- zerochat_runtime/assets/js/tools/builtin/download-pdf.tool.js +111 -0
- zerochat_runtime/assets/js/tools/builtin/execute-javascript.tool.js +219 -0
- zerochat_runtime/assets/js/tools/builtin/fetch-web-page.tool.js +112 -0
- zerochat_runtime/assets/js/tools/builtin/list-documents.tool.js +117 -0
- zerochat_runtime/assets/js/tools/builtin/read-knowledge-chunk.tool.js +129 -0
- zerochat_runtime/assets/js/tools/builtin/read-knowledge-image.tool.js +95 -0
- zerochat_runtime/assets/js/tools/builtin/render-chart.tool.js +90 -0
- zerochat_runtime/assets/js/tools/builtin/search-knowledge-base.tool.js +124 -0
- zerochat_runtime/assets/js/tools/builtin/search-web.tool.js +125 -0
- zerochat_runtime/assets/js/tools/tool-manifest.js +53 -0
- zerochat_runtime/assets/js/tools/tool-runtime.js +77 -0
- zerochat_runtime/assets/js/ui-composer.js +319 -0
- zerochat_runtime/assets/js/ui-conversation.js +655 -0
- zerochat_runtime/assets/js/ui-dialogs.js +132 -0
- zerochat_runtime/assets/js/ui-generation-status.js +119 -0
- zerochat_runtime/assets/js/ui-inspector.js +804 -0
- zerochat_runtime/assets/js/ui-mcp.js +604 -0
- zerochat_runtime/assets/js/ui-profiles.js +824 -0
- zerochat_runtime/assets/js/ui-reasoning.js +146 -0
- zerochat_runtime/assets/js/ui-settings.js +825 -0
- zerochat_runtime/assets/js/ui-shell.js +215 -0
- zerochat_runtime/assets/js/ui-sidebar.js +429 -0
- zerochat_runtime/assets/js/ui-telemetry.js +404 -0
- zerochat_runtime/assets/js/ui-transfer.js +262 -0
- zerochat_runtime/assets/js/utils.js +216 -0
- zerochat_runtime/assets/js/vendor/orama.browser.js +11 -0
- zerochat_runtime/assets/js/web-browser.js +569 -0
- zerochat_runtime/assets/js/web-search.js +519 -0
- zerochat_runtime/assets/manifest.webmanifest +20 -0
- zerochat_runtime/assets/services/dummy_mcp/dummy_mcp_server.py +47 -0
- zerochat_runtime/assets/services/dummy_mcp/service.json +22 -0
- zerochat_runtime/assets/services/lsp/installer.json +9 -0
- zerochat_runtime/assets/services/lsp/service.json +22 -0
- zerochat_runtime/assets/services/memory/installer.json +9 -0
- zerochat_runtime/assets/services/memory/service.json +24 -0
- zerochat_runtime/assets/services/playwright/installer.json +10 -0
- zerochat_runtime/assets/services/playwright/service.json +39 -0
- zerochat_runtime/assets/sw.js +164 -0
- zerochat_runtime/assets/zerochat.html +671 -0
zerochat.py
ADDED
|
@@ -0,0 +1,2004 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ZeroChat - Backend Local Unificado y Gestor de Entorno
|
|
4
|
+
|
|
5
|
+
Proporciona:
|
|
6
|
+
1. Auto-creación, actualización y ejecución en el entorno virtual local `./zerochat`.
|
|
7
|
+
2. Servidor local HTTP y Server-Sent Events (SSE) con autenticación estricta por token efímero.
|
|
8
|
+
3. Herramientas locales seguras: read_file, edit_file, list_directory, execute_command.
|
|
9
|
+
4. Apertura automática del navegador apuntando a zerochat.html con token en el fragmento hash.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import atexit
|
|
16
|
+
import datetime
|
|
17
|
+
import hmac
|
|
18
|
+
import importlib.metadata
|
|
19
|
+
import importlib.resources
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import platform
|
|
23
|
+
import queue
|
|
24
|
+
import re
|
|
25
|
+
import secrets
|
|
26
|
+
import shutil
|
|
27
|
+
import shlex
|
|
28
|
+
import signal
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
import traceback
|
|
34
|
+
import urllib.request
|
|
35
|
+
from urllib.parse import urlencode
|
|
36
|
+
import venv
|
|
37
|
+
import webbrowser
|
|
38
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
def _read_source_version(filename: str) -> str | None:
|
|
42
|
+
"""Lee la versión de un archivo del repositorio cuando se ejecuta desde fuentes."""
|
|
43
|
+
try:
|
|
44
|
+
version_file = Path(__file__).resolve().parent / filename
|
|
45
|
+
if version_file.is_file():
|
|
46
|
+
content = version_file.read_text(encoding="utf-8")
|
|
47
|
+
match = re.search(r'version\s*=\s*"([0-9]+\.[0-9]+\.[0-9]+)"', content)
|
|
48
|
+
if match:
|
|
49
|
+
return match.group(1)
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _read_backend_version() -> str:
|
|
56
|
+
"""Devuelve la versión publicada del backend, sin depender de package.json instalado."""
|
|
57
|
+
try:
|
|
58
|
+
return importlib.metadata.version("zerochat")
|
|
59
|
+
except importlib.metadata.PackageNotFoundError:
|
|
60
|
+
return _read_source_version("pyproject.toml") or "7.3.0"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _read_ui_version() -> str:
|
|
64
|
+
"""Devuelve la versión de la interfaz cuando se ejecuta desde el repositorio."""
|
|
65
|
+
try:
|
|
66
|
+
pkg_path = Path(__file__).resolve().parent / "package.json"
|
|
67
|
+
if pkg_path.is_file():
|
|
68
|
+
data = json.loads(pkg_path.read_text(encoding="utf-8"))
|
|
69
|
+
if isinstance(data.get("version"), str):
|
|
70
|
+
return data["version"].strip()
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
return BACKEND_PACKAGE_VERSION
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def compatibility_version(version: str) -> str:
|
|
77
|
+
"""La interfaz y el backend son compatibles si comparten major.minor."""
|
|
78
|
+
parts = version.split(".")
|
|
79
|
+
return ".".join(parts[:2]) if len(parts) >= 2 else version
|
|
80
|
+
|
|
81
|
+
BACKEND_PACKAGE_VERSION = _read_backend_version()
|
|
82
|
+
VERSION = compatibility_version(BACKEND_PACKAGE_VERSION)
|
|
83
|
+
UI_VERSION = _read_ui_version()
|
|
84
|
+
DEFAULT_PORT = 6388
|
|
85
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
86
|
+
DEFAULT_UI_URL = "https://albalday.github.io/zerochat/zerochat.html"
|
|
87
|
+
REMOTE_VERSION_URL = "https://raw.githubusercontent.com/albalday/zerochat/master/package.json"
|
|
88
|
+
REMOTE_SCRIPT_URL = "https://raw.githubusercontent.com/albalday/zerochat/master/zerochat.py"
|
|
89
|
+
CONSOLE_STATUS_IDLE_SECONDS = 8.0
|
|
90
|
+
CONSOLE_CONTROL = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def format_uptime(seconds: float) -> str:
|
|
94
|
+
"""Devuelve una duración breve y estable para la línea de estado de consola."""
|
|
95
|
+
total = max(0, int(seconds))
|
|
96
|
+
hours, remainder = divmod(total, 3600)
|
|
97
|
+
minutes, secs = divmod(remainder, 60)
|
|
98
|
+
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ConsoleControl:
|
|
102
|
+
"""Atajos de consola y línea de estado, solo para terminales interactivos."""
|
|
103
|
+
def __init__(self, server: ThreadingHTTPServer, parser: argparse.ArgumentParser):
|
|
104
|
+
self.server = server
|
|
105
|
+
self.parser = parser
|
|
106
|
+
self.started_at = time.monotonic()
|
|
107
|
+
self.last_activity = self.started_at
|
|
108
|
+
self.stop_event = threading.Event()
|
|
109
|
+
self.lock = threading.Lock()
|
|
110
|
+
self.status_visible = False
|
|
111
|
+
self.enabled = bool(getattr(sys.stdin, "isatty", lambda: False)() and getattr(sys.stdout, "isatty", lambda: False)())
|
|
112
|
+
self._threads: list[threading.Thread] = []
|
|
113
|
+
self._terminal_fd: int | None = None
|
|
114
|
+
self._terminal_state = None
|
|
115
|
+
self._closed = False
|
|
116
|
+
|
|
117
|
+
def start(self):
|
|
118
|
+
if not self.enabled:
|
|
119
|
+
return
|
|
120
|
+
if os.name != "nt":
|
|
121
|
+
try:
|
|
122
|
+
import termios
|
|
123
|
+
import tty
|
|
124
|
+
self._terminal_fd = sys.stdin.fileno()
|
|
125
|
+
self._terminal_state = termios.tcgetattr(self._terminal_fd)
|
|
126
|
+
tty.setcbreak(self._terminal_fd)
|
|
127
|
+
except (OSError, ValueError):
|
|
128
|
+
self._restore_terminal()
|
|
129
|
+
self.enabled = False
|
|
130
|
+
return
|
|
131
|
+
self._threads = [
|
|
132
|
+
threading.Thread(target=self._status_loop, name="zerochat-console-status", daemon=True),
|
|
133
|
+
threading.Thread(target=self._keyboard_loop, name="zerochat-console-input", daemon=True),
|
|
134
|
+
]
|
|
135
|
+
for thread in self._threads:
|
|
136
|
+
thread.start()
|
|
137
|
+
|
|
138
|
+
def close(self):
|
|
139
|
+
if self._closed:
|
|
140
|
+
return
|
|
141
|
+
self._closed = True
|
|
142
|
+
self.stop_event.set()
|
|
143
|
+
for thread in self._threads:
|
|
144
|
+
if thread is not threading.current_thread():
|
|
145
|
+
thread.join(timeout=1.0)
|
|
146
|
+
self._restore_terminal()
|
|
147
|
+
self.clear_status(final=True)
|
|
148
|
+
|
|
149
|
+
def _restore_terminal(self):
|
|
150
|
+
if self._terminal_fd is None or self._terminal_state is None:
|
|
151
|
+
return
|
|
152
|
+
try:
|
|
153
|
+
import termios
|
|
154
|
+
termios.tcsetattr(self._terminal_fd, termios.TCSADRAIN, self._terminal_state)
|
|
155
|
+
except OSError:
|
|
156
|
+
pass
|
|
157
|
+
finally:
|
|
158
|
+
self._terminal_fd = None
|
|
159
|
+
self._terminal_state = None
|
|
160
|
+
|
|
161
|
+
def clear_status(self, *, final: bool = False):
|
|
162
|
+
if not self.enabled:
|
|
163
|
+
return
|
|
164
|
+
with self.lock:
|
|
165
|
+
if self.status_visible:
|
|
166
|
+
sys.stdout.write("\r\033[2K")
|
|
167
|
+
self.status_visible = False
|
|
168
|
+
if final:
|
|
169
|
+
sys.stdout.write("\r\n")
|
|
170
|
+
sys.stdout.flush()
|
|
171
|
+
|
|
172
|
+
def log(self, message: str, *, flush: bool = True):
|
|
173
|
+
with self.lock:
|
|
174
|
+
if self.enabled and self.status_visible:
|
|
175
|
+
sys.stdout.write("\r\033[2K")
|
|
176
|
+
self.status_visible = False
|
|
177
|
+
print(message, flush=flush)
|
|
178
|
+
self.last_activity = time.monotonic()
|
|
179
|
+
|
|
180
|
+
def show_help(self):
|
|
181
|
+
with self.lock:
|
|
182
|
+
if self.status_visible:
|
|
183
|
+
sys.stdout.write("\r\033[2K")
|
|
184
|
+
self.status_visible = False
|
|
185
|
+
print("\nComandos de consola: [h] ayuda · [x] salir ordenadamente\n", flush=True)
|
|
186
|
+
print(self.parser.format_help().rstrip(), flush=True)
|
|
187
|
+
self.last_activity = time.monotonic()
|
|
188
|
+
|
|
189
|
+
def _render_status(self):
|
|
190
|
+
if not self.enabled:
|
|
191
|
+
return
|
|
192
|
+
uptime = format_uptime(time.monotonic() - self.started_at)
|
|
193
|
+
with self.lock:
|
|
194
|
+
if time.monotonic() - self.last_activity < CONSOLE_STATUS_IDLE_SECONDS:
|
|
195
|
+
return
|
|
196
|
+
sys.stdout.write(f"\r\033[2KZeroChat activo {uptime} · [h] ayuda · [x] salir")
|
|
197
|
+
sys.stdout.flush()
|
|
198
|
+
self.status_visible = True
|
|
199
|
+
|
|
200
|
+
def _status_loop(self):
|
|
201
|
+
while not self.stop_event.wait(1.0):
|
|
202
|
+
self._render_status()
|
|
203
|
+
|
|
204
|
+
def _handle_key(self, key: str):
|
|
205
|
+
if key.lower() == "h":
|
|
206
|
+
self.show_help()
|
|
207
|
+
elif key.lower() == "x":
|
|
208
|
+
self.log(f"[{time.strftime('%H:%M:%S')}] Deteniendo servidor ZeroChat...")
|
|
209
|
+
stop_zerochat_server(self.server)
|
|
210
|
+
|
|
211
|
+
def _keyboard_loop(self):
|
|
212
|
+
if os.name == "nt":
|
|
213
|
+
import msvcrt
|
|
214
|
+
while not self.stop_event.wait(0.05):
|
|
215
|
+
if msvcrt.kbhit():
|
|
216
|
+
self._handle_key(msvcrt.getwch())
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
import select
|
|
220
|
+
while not self.stop_event.is_set():
|
|
221
|
+
ready, _, _ = select.select([sys.stdin], [], [], 0.1)
|
|
222
|
+
if ready:
|
|
223
|
+
self._handle_key(sys.stdin.read(1))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def console_log(message: str, *, flush: bool = True):
|
|
227
|
+
if CONSOLE_CONTROL:
|
|
228
|
+
CONSOLE_CONTROL.log(message, flush=flush)
|
|
229
|
+
else:
|
|
230
|
+
print(message, flush=flush)
|
|
231
|
+
|
|
232
|
+
def get_packaged_assets_root() -> Path | None:
|
|
233
|
+
"""Devuelve los recursos incluidos en la distribución PyPI, si existen."""
|
|
234
|
+
try:
|
|
235
|
+
root = importlib.resources.files("zerochat_runtime").joinpath("assets")
|
|
236
|
+
path = Path(str(root))
|
|
237
|
+
if (path / "zerochat.html").is_file():
|
|
238
|
+
return path
|
|
239
|
+
except (ModuleNotFoundError, TypeError):
|
|
240
|
+
pass
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def get_dev_root() -> Path | None:
|
|
245
|
+
"""
|
|
246
|
+
Detecta si zerochat.py se está ejecutando en el directorio de desarrollo del repositorio.
|
|
247
|
+
Comprueba si existen zerochat.html, js/ y css/ en el directorio del script o en cwd.
|
|
248
|
+
"""
|
|
249
|
+
script_dir = Path(__file__).resolve().parent
|
|
250
|
+
cwd = Path.cwd().resolve()
|
|
251
|
+
for candidate in (script_dir, cwd):
|
|
252
|
+
if (candidate / "zerochat.html").is_file() and (candidate / "js").is_dir() and (candidate / "css").is_dir():
|
|
253
|
+
return candidate
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def get_static_root() -> Path | None:
|
|
258
|
+
"""Prioriza el árbol de desarrollo y usa los recursos empaquetados fuera de él."""
|
|
259
|
+
return get_dev_root() or get_packaged_assets_root()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def is_packaged_runtime() -> bool:
|
|
263
|
+
return get_dev_root() is None and get_packaged_assets_root() is not None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def get_venv_dir() -> Path:
|
|
267
|
+
"""Devuelve el estado persistente; PyPI nunca crea un venv en el proyecto del usuario."""
|
|
268
|
+
if get_dev_root() is not None:
|
|
269
|
+
return (Path.cwd() / "zerochat").resolve()
|
|
270
|
+
|
|
271
|
+
configured = os.environ.get("ZEROCHAT_DATA_DIR", "").strip()
|
|
272
|
+
if configured:
|
|
273
|
+
return Path(configured).expanduser().resolve()
|
|
274
|
+
if sys.platform.startswith("win"):
|
|
275
|
+
base = Path(os.environ.get("APPDATA", Path.home()))
|
|
276
|
+
elif sys.platform == "darwin":
|
|
277
|
+
base = Path.home() / "Library" / "Application Support"
|
|
278
|
+
else:
|
|
279
|
+
base = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state"))
|
|
280
|
+
return (base / "zerochat").resolve()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def get_daily_token() -> str:
|
|
284
|
+
"""Devuelve un token de sesión diario persistido en ./zerochat/config/token.json."""
|
|
285
|
+
config_dir = get_venv_dir() / "config"
|
|
286
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
287
|
+
token_file = config_dir / "token.json"
|
|
288
|
+
today = datetime.date.today().isoformat()
|
|
289
|
+
if token_file.exists():
|
|
290
|
+
try:
|
|
291
|
+
data = json.loads(token_file.read_text(encoding="utf-8"))
|
|
292
|
+
if data.get("date") == today and data.get("token") and isinstance(data["token"], str):
|
|
293
|
+
return data["token"]
|
|
294
|
+
except Exception:
|
|
295
|
+
pass
|
|
296
|
+
token = secrets.token_urlsafe(32)
|
|
297
|
+
try:
|
|
298
|
+
tmp_file = token_file.with_suffix(".tmp")
|
|
299
|
+
tmp_file.write_text(json.dumps({"token": token, "date": today}, indent=2), encoding="utf-8")
|
|
300
|
+
tmp_file.replace(token_file)
|
|
301
|
+
except Exception:
|
|
302
|
+
pass
|
|
303
|
+
return token
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# Estado de sesión en memoria (generación diaria por defecto)
|
|
307
|
+
SESSION_TOKEN = get_daily_token()
|
|
308
|
+
ACTIVE_PORT = DEFAULT_PORT
|
|
309
|
+
ACTIVE_HOST = DEFAULT_HOST
|
|
310
|
+
|
|
311
|
+
DETECTED_OS = "windows" if sys.platform.startswith("win") else ("android" if "ANDROID_ROOT" in os.environ else "linux")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def get_venv_python(venv_dir: Path) -> Path:
|
|
315
|
+
"""Devuelve el ejecutable de Python del entorno virtual según el SO."""
|
|
316
|
+
if sys.platform.startswith("win"):
|
|
317
|
+
return venv_dir / "Scripts" / "python.exe"
|
|
318
|
+
return venv_dir / "bin" / "python"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def ensure_virtual_environment():
|
|
322
|
+
"""
|
|
323
|
+
Comprueba si existe el entorno virtual en ./zerochat. Si no existe, lo crea.
|
|
324
|
+
Si el proceso actual no se está ejecutando bajo dicho entorno, se re-ejecuta.
|
|
325
|
+
"""
|
|
326
|
+
if is_packaged_runtime():
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
venv_dir = get_venv_dir()
|
|
330
|
+
venv_py = get_venv_python(venv_dir)
|
|
331
|
+
|
|
332
|
+
# 1. Crear el venv si no existe
|
|
333
|
+
if not venv_py.exists():
|
|
334
|
+
console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] Inicializando entorno virtual en {venv_dir}...", flush=True)
|
|
335
|
+
try:
|
|
336
|
+
venv.create(venv_dir, with_pip=True, clear=False)
|
|
337
|
+
console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] Entorno virtual preparado con éxito.", flush=True)
|
|
338
|
+
except Exception as err:
|
|
339
|
+
console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] Advertencia al crear venv: {err}. Continuando con intérprete actual.", flush=True)
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
# 2. Comprobar si ya estamos ejecutándonos dentro del venv
|
|
343
|
+
try:
|
|
344
|
+
current_py = Path(sys.executable).resolve()
|
|
345
|
+
target_py = venv_py.resolve()
|
|
346
|
+
if current_py != target_py and target_py.exists():
|
|
347
|
+
console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] Re-ejecutando bajo {venv_py}...", flush=True)
|
|
348
|
+
# Re-ejecutar con los mismos argumentos
|
|
349
|
+
args = [str(target_py), str(Path(__file__).resolve())] + sys.argv[1:]
|
|
350
|
+
os.execv(str(target_py), args)
|
|
351
|
+
except Exception as err:
|
|
352
|
+
console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] Error en re-ejecución: {err}. Continuando.", flush=True)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def parse_version(ver: str) -> tuple[int, ...]:
|
|
356
|
+
"""Convierte una cadena de versión semántica en tupla de enteros para comparación."""
|
|
357
|
+
parts = []
|
|
358
|
+
for piece in ver.split("."):
|
|
359
|
+
clean = "".join(filter(str.isdigit, piece))
|
|
360
|
+
if clean:
|
|
361
|
+
parts.append(int(clean))
|
|
362
|
+
return tuple(parts)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def check_version():
|
|
366
|
+
"""Comprueba si hay una nueva versión de zerochat.py en el repositorio remoto (solo en modo producción/standalone)."""
|
|
367
|
+
if get_dev_root() is not None or is_packaged_runtime():
|
|
368
|
+
return
|
|
369
|
+
try:
|
|
370
|
+
req = urllib.request.Request(REMOTE_VERSION_URL, headers={"User-Agent": f"ZeroChat/{VERSION}"})
|
|
371
|
+
with urllib.request.urlopen(req, timeout=3) as resp:
|
|
372
|
+
content = resp.read(2048).decode("utf-8", errors="ignore")
|
|
373
|
+
remote_ver = None
|
|
374
|
+
try:
|
|
375
|
+
data = json.loads(content)
|
|
376
|
+
if isinstance(data, dict) and "version" in data and isinstance(data["version"], str):
|
|
377
|
+
remote_ver = data["version"].strip()
|
|
378
|
+
except Exception:
|
|
379
|
+
pass
|
|
380
|
+
if not remote_ver:
|
|
381
|
+
match = re.search(r'["\']?version["\']?\s*[:=]\s*["\'](\d+\.\d+\.\d+)["\']', content)
|
|
382
|
+
if match:
|
|
383
|
+
remote_ver = match.group(1)
|
|
384
|
+
if remote_ver and re.match(r"^\d+(\.\d+)+", remote_ver):
|
|
385
|
+
if parse_version(remote_ver) > parse_version(VERSION):
|
|
386
|
+
console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] ¡Nueva versión disponible! (Local: {VERSION}, Remota: {remote_ver})", flush=True)
|
|
387
|
+
console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] Actualiza con: curl -sSL {REMOTE_SCRIPT_URL} -o zerochat.py", flush=True)
|
|
388
|
+
except Exception:
|
|
389
|
+
# Modo offline o timeout ignorado de forma segura
|
|
390
|
+
pass
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# ==============================================================================
|
|
394
|
+
# Herramientas Locales Core
|
|
395
|
+
# ==============================================================================
|
|
396
|
+
|
|
397
|
+
def list_directory(path: str = ".", max_depth: int = 1) -> str:
|
|
398
|
+
"""Recorre un directorio local y devuelve la lista de archivos y carpetas."""
|
|
399
|
+
try:
|
|
400
|
+
target = Path(path).expanduser().resolve()
|
|
401
|
+
if not target.exists():
|
|
402
|
+
return json.dumps({"success": False, "error": f"La ruta '{path}' no existe."}, ensure_ascii=False)
|
|
403
|
+
if not target.is_dir():
|
|
404
|
+
return json.dumps({"success": False, "error": f"La ruta '{path}' no es un directorio."}, ensure_ascii=False)
|
|
405
|
+
|
|
406
|
+
entries = []
|
|
407
|
+
for entry in os.scandir(target):
|
|
408
|
+
try:
|
|
409
|
+
stat = entry.stat(follow_symlinks=False)
|
|
410
|
+
is_dir = entry.is_dir(follow_symlinks=False)
|
|
411
|
+
entries.append({
|
|
412
|
+
"name": entry.name,
|
|
413
|
+
"path": str(Path(entry.path).resolve()),
|
|
414
|
+
"type": "directory" if is_dir else "file",
|
|
415
|
+
"size_bytes": None if is_dir else stat.st_size,
|
|
416
|
+
"is_symlink": entry.is_symlink()
|
|
417
|
+
})
|
|
418
|
+
except (PermissionError, FileNotFoundError):
|
|
419
|
+
continue
|
|
420
|
+
|
|
421
|
+
entries.sort(key=lambda e: (e["type"] != "directory", e["name"].lower()))
|
|
422
|
+
return json.dumps({
|
|
423
|
+
"success": True,
|
|
424
|
+
"path": str(target),
|
|
425
|
+
"total_items": len(entries),
|
|
426
|
+
"entries": entries
|
|
427
|
+
}, ensure_ascii=False, indent=2)
|
|
428
|
+
except Exception as e:
|
|
429
|
+
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def read_file(path: str, start_line: int = 1, max_lines: int = 500, max_bytes: int = 100000) -> str:
|
|
433
|
+
"""Lee el contenido de texto de un archivo local con rangos y límites seguros."""
|
|
434
|
+
try:
|
|
435
|
+
target = Path(path).expanduser().resolve()
|
|
436
|
+
if not target.exists():
|
|
437
|
+
return json.dumps({"success": False, "error": f"El archivo '{path}' no existe."}, ensure_ascii=False)
|
|
438
|
+
if not target.is_file():
|
|
439
|
+
return json.dumps({"success": False, "error": f"La ruta '{path}' no es un archivo regular."}, ensure_ascii=False)
|
|
440
|
+
|
|
441
|
+
file_size = target.stat().st_size
|
|
442
|
+
safe_max_bytes = max(1024, min(int(max_bytes), 2000000))
|
|
443
|
+
safe_start_line = max(1, int(start_line))
|
|
444
|
+
safe_max_lines = max(1, min(int(max_lines), 2000))
|
|
445
|
+
|
|
446
|
+
with open(target, "r", encoding="utf-8", errors="replace") as f:
|
|
447
|
+
lines = f.readlines()
|
|
448
|
+
|
|
449
|
+
total_lines = len(lines)
|
|
450
|
+
start_idx = safe_start_line - 1
|
|
451
|
+
end_idx = min(start_idx + safe_max_lines, total_lines)
|
|
452
|
+
|
|
453
|
+
selected_lines = lines[start_idx:end_idx] if start_idx < total_lines else []
|
|
454
|
+
content = "".join(selected_lines)
|
|
455
|
+
|
|
456
|
+
truncated_bytes = False
|
|
457
|
+
if len(content.encode("utf-8")) > safe_max_bytes:
|
|
458
|
+
content = content[:safe_max_bytes]
|
|
459
|
+
truncated_bytes = True
|
|
460
|
+
|
|
461
|
+
return json.dumps({
|
|
462
|
+
"success": True,
|
|
463
|
+
"path": str(target),
|
|
464
|
+
"size_bytes": file_size,
|
|
465
|
+
"total_lines": total_lines,
|
|
466
|
+
"start_line": safe_start_line,
|
|
467
|
+
"lines_returned": len(selected_lines),
|
|
468
|
+
"truncated": truncated_bytes or end_idx < total_lines,
|
|
469
|
+
"content": content
|
|
470
|
+
}, ensure_ascii=False, indent=2)
|
|
471
|
+
except Exception as e:
|
|
472
|
+
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def edit_file(path: str, content: str, mode: str = "write", target_content: str = None) -> str:
|
|
476
|
+
"""Crea, sobrescribe o edita un archivo de forma atómica."""
|
|
477
|
+
try:
|
|
478
|
+
target = Path(path).expanduser().resolve()
|
|
479
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
480
|
+
|
|
481
|
+
if mode == "append":
|
|
482
|
+
with open(target, "a", encoding="utf-8") as f:
|
|
483
|
+
f.write(content)
|
|
484
|
+
bytes_written = len(content.encode("utf-8"))
|
|
485
|
+
elif mode == "replace_chunk":
|
|
486
|
+
if not target.exists():
|
|
487
|
+
return json.dumps({"success": False, "error": f"El archivo '{path}' no existe para replace_chunk."}, ensure_ascii=False)
|
|
488
|
+
if not target_content:
|
|
489
|
+
return json.dumps({"success": False, "error": "target_content es obligatorio en modo replace_chunk."}, ensure_ascii=False)
|
|
490
|
+
|
|
491
|
+
with open(target, "r", encoding="utf-8", errors="replace") as f:
|
|
492
|
+
existing = f.read()
|
|
493
|
+
|
|
494
|
+
if target_content not in existing:
|
|
495
|
+
return json.dumps({"success": False, "error": "target_content no fue encontrado en el archivo."}, ensure_ascii=False)
|
|
496
|
+
|
|
497
|
+
new_text = existing.replace(target_content, content, 1)
|
|
498
|
+
temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}")
|
|
499
|
+
with open(temp_path, "w", encoding="utf-8") as f:
|
|
500
|
+
f.write(new_text)
|
|
501
|
+
temp_path.replace(target)
|
|
502
|
+
bytes_written = len(new_text.encode("utf-8"))
|
|
503
|
+
else: # write
|
|
504
|
+
temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}")
|
|
505
|
+
with open(temp_path, "w", encoding="utf-8") as f:
|
|
506
|
+
f.write(content)
|
|
507
|
+
temp_path.replace(target)
|
|
508
|
+
bytes_written = len(content.encode("utf-8"))
|
|
509
|
+
|
|
510
|
+
return json.dumps({
|
|
511
|
+
"success": True,
|
|
512
|
+
"path": str(target),
|
|
513
|
+
"mode": mode,
|
|
514
|
+
"bytes_written": bytes_written
|
|
515
|
+
}, ensure_ascii=False, indent=2)
|
|
516
|
+
except Exception as e:
|
|
517
|
+
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def execute_command(command: str, cwd: str = ".", timeout_seconds: int = 60) -> str:
|
|
521
|
+
"""Ejecuta un comando en la shell del sistema y devuelve stdout y stderr."""
|
|
522
|
+
try:
|
|
523
|
+
target_cwd = Path(cwd).expanduser().resolve()
|
|
524
|
+
if not target_cwd.exists() or not target_cwd.is_dir():
|
|
525
|
+
target_cwd = Path.cwd()
|
|
526
|
+
|
|
527
|
+
proc = subprocess.run(
|
|
528
|
+
command,
|
|
529
|
+
cwd=str(target_cwd),
|
|
530
|
+
shell=True,
|
|
531
|
+
capture_output=True,
|
|
532
|
+
text=True,
|
|
533
|
+
timeout=max(1, min(int(timeout_seconds), 300)),
|
|
534
|
+
encoding="utf-8",
|
|
535
|
+
errors="replace"
|
|
536
|
+
)
|
|
537
|
+
return json.dumps({
|
|
538
|
+
"success": proc.returncode == 0,
|
|
539
|
+
"returncode": proc.returncode,
|
|
540
|
+
"stdout": proc.stdout,
|
|
541
|
+
"stderr": proc.stderr,
|
|
542
|
+
"cwd": str(target_cwd)
|
|
543
|
+
}, ensure_ascii=False, indent=2)
|
|
544
|
+
except subprocess.TimeoutExpired:
|
|
545
|
+
return json.dumps({"success": False, "error": f"Comando excedió el tiempo límite de {timeout_seconds} segundos."}, ensure_ascii=False)
|
|
546
|
+
except Exception as e:
|
|
547
|
+
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
LOCAL_TOOLS_DEFINITIONS = [
|
|
551
|
+
{
|
|
552
|
+
"name": "list_directory",
|
|
553
|
+
"description": "Lista archivos y carpetas en un directorio local.",
|
|
554
|
+
"inputSchema": {
|
|
555
|
+
"type": "object",
|
|
556
|
+
"properties": {
|
|
557
|
+
"path": {"type": "string", "description": "Ruta relativa o absoluta (por defecto '.')"},
|
|
558
|
+
"max_depth": {"type": "integer", "description": "Profundidad máxima", "default": 1}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
"name": "read_file",
|
|
564
|
+
"description": "Lee el contenido de texto de un archivo local.",
|
|
565
|
+
"inputSchema": {
|
|
566
|
+
"type": "object",
|
|
567
|
+
"properties": {
|
|
568
|
+
"path": {"type": "string", "description": "Ruta del archivo"},
|
|
569
|
+
"start_line": {"type": "integer", "description": "Línea inicial (1-indexed)", "default": 1},
|
|
570
|
+
"max_lines": {"type": "integer", "description": "Número máximo de líneas a leer", "default": 500},
|
|
571
|
+
"max_bytes": {"type": "integer", "description": "Límite máximo de bytes", "default": 100000}
|
|
572
|
+
},
|
|
573
|
+
"required": ["path"]
|
|
574
|
+
}
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
"name": "edit_file",
|
|
578
|
+
"description": "Crea, sobrescribe o modifica un archivo local de forma atómica.",
|
|
579
|
+
"inputSchema": {
|
|
580
|
+
"type": "object",
|
|
581
|
+
"properties": {
|
|
582
|
+
"path": {"type": "string", "description": "Ruta del archivo a editar"},
|
|
583
|
+
"content": {"type": "string", "description": "Contenido a escribir o reemplazar"},
|
|
584
|
+
"mode": {"type": "string", "enum": ["write", "append", "replace_chunk"], "default": "write"},
|
|
585
|
+
"target_content": {"type": "string", "description": "Texto exacto a reemplazar cuando mode='replace_chunk'"}
|
|
586
|
+
},
|
|
587
|
+
"required": ["path", "content"]
|
|
588
|
+
}
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
"name": "execute_command",
|
|
592
|
+
"description": "Ejecuta un comando en la shell del sistema y captura la salida.",
|
|
593
|
+
"inputSchema": {
|
|
594
|
+
"type": "object",
|
|
595
|
+
"properties": {
|
|
596
|
+
"command": {"type": "string", "description": "Comando a ejecutar"},
|
|
597
|
+
"cwd": {"type": "string", "description": "Directorio de trabajo (por defecto '.')"},
|
|
598
|
+
"timeout_seconds": {"type": "integer", "description": "Tiempo límite en segundos", "default": 60}
|
|
599
|
+
},
|
|
600
|
+
"required": ["command"]
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
]
|
|
604
|
+
|
|
605
|
+
LOCAL_TOOL_HANDLERS = {
|
|
606
|
+
"list_directory": list_directory,
|
|
607
|
+
"read_file": read_file,
|
|
608
|
+
"edit_file": edit_file,
|
|
609
|
+
"execute_command": execute_command
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
# ==============================================================================
|
|
613
|
+
# Servidor HTTP JSON-RPC 2.0 y SSE con Autenticación por Token
|
|
614
|
+
# ==============================================================================
|
|
615
|
+
|
|
616
|
+
def sanitize_log_path(raw_path: str) -> str:
|
|
617
|
+
"""Oculta tokens de sesión o parámetros sensibles en la query string para logs seguros."""
|
|
618
|
+
if not raw_path or "?" not in raw_path:
|
|
619
|
+
return raw_path or "/"
|
|
620
|
+
path, query = raw_path.split("?", 1)
|
|
621
|
+
safe_query = re.sub(r'(token=)[^&]+', r'\1***', query, flags=re.IGNORECASE)
|
|
622
|
+
return f"{path}?{safe_query}"
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def format_log_error(msg: str, max_len: int = 160) -> str:
|
|
626
|
+
"""Limpia y trunca mensajes de error para mantener el log en una sola línea legible."""
|
|
627
|
+
if not msg:
|
|
628
|
+
return ""
|
|
629
|
+
cleaned = " ".join(str(msg).strip().splitlines())
|
|
630
|
+
if len(cleaned) > max_len:
|
|
631
|
+
return cleaned[:max_len - 3] + "..."
|
|
632
|
+
return cleaned
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def is_allowed_origin(origin: str | None) -> bool:
|
|
636
|
+
"""Verifica si el origen CORS está autorizado."""
|
|
637
|
+
if origin is None or origin == "null":
|
|
638
|
+
return True
|
|
639
|
+
origin_lower = origin.lower()
|
|
640
|
+
if origin_lower == "https://albalday.github.io" or origin_lower.startswith("https://albalday.github.io/"):
|
|
641
|
+
return True
|
|
642
|
+
if origin_lower == "http://127.0.0.1" or origin_lower.startswith("http://127.0.0.1:"):
|
|
643
|
+
return True
|
|
644
|
+
if origin_lower == "http://localhost" or origin_lower.startswith("http://localhost:"):
|
|
645
|
+
return True
|
|
646
|
+
return False
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def public_tool_name(server_id: str, original: str) -> str:
|
|
650
|
+
"""Codificación inyectiva de nombres de herramientas MCP idéntica a publicToolName en js/mcp.js."""
|
|
651
|
+
def encode(value: str, tool: bool = False) -> str:
|
|
652
|
+
if not isinstance(value, str) or not value or len(value) > 256:
|
|
653
|
+
raise ValueError("Componente de nombre MCP no válido")
|
|
654
|
+
return "".join(ch if ("a" <= ch <= "y" or "0" <= ch <= "9" or (tool and ch == "_"))
|
|
655
|
+
else f"z{ord(ch):x}z" for ch in value)
|
|
656
|
+
name = f"mcp_{encode(server_id)}_{encode(original, True)}"
|
|
657
|
+
if len(name) > 64:
|
|
658
|
+
raise ValueError(f"El nombre público de la herramienta MCP excede 64 caracteres: {name}")
|
|
659
|
+
return name
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
class StdioMcpClient:
|
|
663
|
+
def __init__(self, command: str, args: list[str], cwd: str, env: dict[str, str]):
|
|
664
|
+
self.command = command
|
|
665
|
+
self.args = args
|
|
666
|
+
self.cwd = cwd
|
|
667
|
+
self.env = env
|
|
668
|
+
self.process: subprocess.Popen | None = None
|
|
669
|
+
self._pending: dict[int, queue.Queue] = {}
|
|
670
|
+
self._next = 0
|
|
671
|
+
self._lock = threading.Lock()
|
|
672
|
+
self._alive = False
|
|
673
|
+
self.tools: list[dict] = []
|
|
674
|
+
|
|
675
|
+
def running(self) -> bool:
|
|
676
|
+
return self._alive and self.process is not None and self.process.poll() is None
|
|
677
|
+
|
|
678
|
+
def start(self, handshake_timeout: int = 30):
|
|
679
|
+
cmd = [self.command] + self.args
|
|
680
|
+
self.process = subprocess.Popen(
|
|
681
|
+
cmd,
|
|
682
|
+
stdin=subprocess.PIPE,
|
|
683
|
+
stdout=subprocess.PIPE,
|
|
684
|
+
stderr=subprocess.PIPE,
|
|
685
|
+
cwd=self.cwd,
|
|
686
|
+
env=self.env,
|
|
687
|
+
text=True,
|
|
688
|
+
encoding="utf-8",
|
|
689
|
+
errors="replace",
|
|
690
|
+
bufsize=1
|
|
691
|
+
)
|
|
692
|
+
self._alive = True
|
|
693
|
+
threading.Thread(target=self._drain_stderr, daemon=True).start()
|
|
694
|
+
threading.Thread(target=self._read_stdout, daemon=True).start()
|
|
695
|
+
|
|
696
|
+
self.request("initialize", {
|
|
697
|
+
"protocolVersion": "2024-11-05",
|
|
698
|
+
"capabilities": {},
|
|
699
|
+
"clientInfo": {"name": "zerochat", "version": VERSION}
|
|
700
|
+
}, timeout=handshake_timeout)
|
|
701
|
+
self.notify("notifications/initialized")
|
|
702
|
+
tools_resp = self.request("tools/list", {}, timeout=10)
|
|
703
|
+
self.tools = tools_resp.get("tools", [])
|
|
704
|
+
|
|
705
|
+
def _drain_stderr(self):
|
|
706
|
+
if self.process and self.process.stderr:
|
|
707
|
+
for _ in self.process.stderr:
|
|
708
|
+
pass
|
|
709
|
+
|
|
710
|
+
def _read_stdout(self):
|
|
711
|
+
try:
|
|
712
|
+
if not self.process or not self.process.stdout:
|
|
713
|
+
return
|
|
714
|
+
for line in self.process.stdout:
|
|
715
|
+
line = line.strip()
|
|
716
|
+
if not line:
|
|
717
|
+
continue
|
|
718
|
+
try:
|
|
719
|
+
msg = json.loads(line)
|
|
720
|
+
except Exception:
|
|
721
|
+
continue
|
|
722
|
+
req_id = msg.get("id")
|
|
723
|
+
if req_id is not None:
|
|
724
|
+
with self._lock:
|
|
725
|
+
waiter = self._pending.pop(req_id, None)
|
|
726
|
+
if waiter:
|
|
727
|
+
waiter.put(msg)
|
|
728
|
+
finally:
|
|
729
|
+
self._alive = False
|
|
730
|
+
with self._lock:
|
|
731
|
+
pending = list(self._pending.values())
|
|
732
|
+
self._pending.clear()
|
|
733
|
+
for waiter in pending:
|
|
734
|
+
waiter.put({"error": {"message": "MCP process ended unexpectedly"}})
|
|
735
|
+
|
|
736
|
+
def request(self, method: str, params: dict, timeout: int = 30) -> dict:
|
|
737
|
+
if not self.running():
|
|
738
|
+
raise RuntimeError("MCP process is not running")
|
|
739
|
+
with self._lock:
|
|
740
|
+
self._next += 1
|
|
741
|
+
req_id = self._next
|
|
742
|
+
waiter = queue.Queue(maxsize=1)
|
|
743
|
+
self._pending[req_id] = waiter
|
|
744
|
+
payload = json.dumps({"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}) + "\n"
|
|
745
|
+
try:
|
|
746
|
+
self.process.stdin.write(payload)
|
|
747
|
+
self.process.stdin.flush()
|
|
748
|
+
except Exception as exc:
|
|
749
|
+
self._alive = False
|
|
750
|
+
self._pending.pop(req_id, None)
|
|
751
|
+
raise RuntimeError(f"Failed writing to MCP process: {exc}") from exc
|
|
752
|
+
try:
|
|
753
|
+
response = waiter.get(timeout=timeout)
|
|
754
|
+
except queue.Empty as exc:
|
|
755
|
+
with self._lock:
|
|
756
|
+
self._pending.pop(req_id, None)
|
|
757
|
+
raise TimeoutError(f"MCP request timed out: {method}") from exc
|
|
758
|
+
if response.get("error"):
|
|
759
|
+
raise RuntimeError(str(response["error"].get("message", "MCP request failed")))
|
|
760
|
+
return response.get("result", {})
|
|
761
|
+
|
|
762
|
+
def notify(self, method: str):
|
|
763
|
+
if self.running():
|
|
764
|
+
with self._lock:
|
|
765
|
+
try:
|
|
766
|
+
self.process.stdin.write(json.dumps({"jsonrpc": "2.0", "method": method}) + "\n")
|
|
767
|
+
self.process.stdin.flush()
|
|
768
|
+
except Exception:
|
|
769
|
+
self._alive = False
|
|
770
|
+
|
|
771
|
+
def stop(self):
|
|
772
|
+
self._alive = False
|
|
773
|
+
if not self.process:
|
|
774
|
+
return
|
|
775
|
+
try:
|
|
776
|
+
self.process.terminate()
|
|
777
|
+
self.process.wait(timeout=2)
|
|
778
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
779
|
+
try:
|
|
780
|
+
self.process.kill()
|
|
781
|
+
except OSError:
|
|
782
|
+
pass
|
|
783
|
+
self.process = None
|
|
784
|
+
self.tools = []
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
class McpServiceManager:
|
|
788
|
+
def __init__(self, services_root: Path | None = None):
|
|
789
|
+
if services_root:
|
|
790
|
+
self.services_root = Path(services_root)
|
|
791
|
+
else:
|
|
792
|
+
candidates = [
|
|
793
|
+
Path.cwd() / "services",
|
|
794
|
+
get_venv_dir() / "services"
|
|
795
|
+
]
|
|
796
|
+
self.services_root = candidates[0] if get_dev_root() is not None and candidates[0].is_dir() else candidates[1]
|
|
797
|
+
self.services_root.mkdir(parents=True, exist_ok=True)
|
|
798
|
+
self.config_file = get_venv_dir() / "config" / "services.json"
|
|
799
|
+
self.config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
800
|
+
self.clients: dict[str, StdioMcpClient] = {}
|
|
801
|
+
self.states: dict[str, str] = {}
|
|
802
|
+
self.errors: dict[str, str] = {}
|
|
803
|
+
self._lock = threading.Lock()
|
|
804
|
+
self._ensure_default_services()
|
|
805
|
+
self.services = self._load_services()
|
|
806
|
+
self.preferences = self._load_preferences()
|
|
807
|
+
|
|
808
|
+
def _copy_packaged_service_templates(self):
|
|
809
|
+
"""Inicializa descriptores MCP editables sin copiar dependencias instaladas."""
|
|
810
|
+
assets_root = get_packaged_assets_root()
|
|
811
|
+
if not assets_root:
|
|
812
|
+
return
|
|
813
|
+
templates_root = assets_root / "services"
|
|
814
|
+
if not templates_root.is_dir():
|
|
815
|
+
return
|
|
816
|
+
for source in templates_root.rglob("*"):
|
|
817
|
+
if not source.is_file():
|
|
818
|
+
continue
|
|
819
|
+
relative = source.relative_to(templates_root)
|
|
820
|
+
if any(part in {"node_modules", ".playwright-mcp"} or part.startswith(".") for part in relative.parts):
|
|
821
|
+
continue
|
|
822
|
+
target = self.services_root / relative
|
|
823
|
+
if target.exists():
|
|
824
|
+
continue
|
|
825
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
826
|
+
shutil.copy2(source, target)
|
|
827
|
+
|
|
828
|
+
def _ensure_default_services(self):
|
|
829
|
+
self._copy_packaged_service_templates()
|
|
830
|
+
dummy_dir = self.services_root / "dummy_mcp"
|
|
831
|
+
dummy_dir.mkdir(parents=True, exist_ok=True)
|
|
832
|
+
service_json_file = dummy_dir / "service.json"
|
|
833
|
+
dummy_server_file = dummy_dir / "dummy_mcp_server.py"
|
|
834
|
+
|
|
835
|
+
if not service_json_file.exists():
|
|
836
|
+
service_json_file.write_text(json.dumps({
|
|
837
|
+
"schemaVersion": 1,
|
|
838
|
+
"id": "dummy_mcp",
|
|
839
|
+
"displayName": {
|
|
840
|
+
"es": "MCP de prueba",
|
|
841
|
+
"en": "Test MCP"
|
|
842
|
+
},
|
|
843
|
+
"description": {
|
|
844
|
+
"es": "Servicio MCP mínimo para comprobar la infraestructura externa.",
|
|
845
|
+
"en": "Minimal MCP service for verifying the external infrastructure."
|
|
846
|
+
},
|
|
847
|
+
"enabledByDefault": False,
|
|
848
|
+
"transport": "stdio",
|
|
849
|
+
"launch": {
|
|
850
|
+
"executable": "${pythonExecutable}",
|
|
851
|
+
"args": ["${serviceDir}/dummy_mcp_server.py"],
|
|
852
|
+
"cwd": "${serviceDir}",
|
|
853
|
+
"env": {},
|
|
854
|
+
"handshakeTimeoutSeconds": 10
|
|
855
|
+
}
|
|
856
|
+
}, indent=2), encoding="utf-8")
|
|
857
|
+
|
|
858
|
+
if not dummy_server_file.exists():
|
|
859
|
+
dummy_server_file.write_text('''#!/usr/bin/env python3
|
|
860
|
+
import json, sys
|
|
861
|
+
|
|
862
|
+
def reply(req_id, result=None, error=None):
|
|
863
|
+
resp = {"jsonrpc": "2.0", "id": req_id}
|
|
864
|
+
if error: resp["error"] = error
|
|
865
|
+
else: resp["result"] = result
|
|
866
|
+
sys.stdout.write(json.dumps(resp) + "\\n")
|
|
867
|
+
sys.stdout.flush()
|
|
868
|
+
|
|
869
|
+
for raw in sys.stdin:
|
|
870
|
+
try: req = json.loads(raw)
|
|
871
|
+
except: continue
|
|
872
|
+
req_id, method, params = req.get("id"), req.get("method"), req.get("params", {})
|
|
873
|
+
if method == "initialize":
|
|
874
|
+
reply(req_id, {
|
|
875
|
+
"protocolVersion": "2024-11-05",
|
|
876
|
+
"serverInfo": {"name": "ZeroChat Dummy MCP", "version": "1.0.0"},
|
|
877
|
+
"capabilities": {"tools": {}}
|
|
878
|
+
})
|
|
879
|
+
elif method == "tools/list":
|
|
880
|
+
reply(req_id, {"tools": [{
|
|
881
|
+
"name": "echo",
|
|
882
|
+
"description": "Echo back a message for testing.",
|
|
883
|
+
"inputSchema": {
|
|
884
|
+
"type": "object",
|
|
885
|
+
"properties": {"message": {"type": "string", "description": "Message to echo."}},
|
|
886
|
+
"required": ["message"]
|
|
887
|
+
}
|
|
888
|
+
}]})
|
|
889
|
+
elif method == "tools/call":
|
|
890
|
+
if params.get("name") != "echo":
|
|
891
|
+
reply(req_id, error={"code": -32601, "message": "Tool not found"})
|
|
892
|
+
continue
|
|
893
|
+
msg = params.get("arguments", {}).get("message", "")
|
|
894
|
+
reply(req_id, {
|
|
895
|
+
"content": [{"type": "text", "text": f"echo: {msg}"}],
|
|
896
|
+
"isError": False
|
|
897
|
+
})
|
|
898
|
+
''', encoding="utf-8")
|
|
899
|
+
|
|
900
|
+
# 2. playwright
|
|
901
|
+
playwright_dir = self.services_root / "playwright"
|
|
902
|
+
playwright_dir.mkdir(parents=True, exist_ok=True)
|
|
903
|
+
pw_service = playwright_dir / "service.json"
|
|
904
|
+
pw_installer = playwright_dir / "installer.json"
|
|
905
|
+
if not pw_service.exists():
|
|
906
|
+
pw_service.write_text(json.dumps({
|
|
907
|
+
"schemaVersion": 1,
|
|
908
|
+
"id": "playwright",
|
|
909
|
+
"displayName": {
|
|
910
|
+
"es": "Playwright MCP",
|
|
911
|
+
"en": "Playwright MCP"
|
|
912
|
+
},
|
|
913
|
+
"description": {
|
|
914
|
+
"es": "Automatización de navegador mediante el servidor MCP oficial de Playwright.",
|
|
915
|
+
"en": "Browser automation through the official Playwright MCP server."
|
|
916
|
+
},
|
|
917
|
+
"enabledByDefault": False,
|
|
918
|
+
"transport": "stdio",
|
|
919
|
+
"launch": {
|
|
920
|
+
"executable": "${nodeExecutable}",
|
|
921
|
+
"args": ["${serviceDir}/node_modules/@playwright/mcp/cli.js", "--browser=chromium"],
|
|
922
|
+
"cwd": "${serviceDir}",
|
|
923
|
+
"env": {},
|
|
924
|
+
"handshakeTimeoutSeconds": 30
|
|
925
|
+
},
|
|
926
|
+
"options": [
|
|
927
|
+
{
|
|
928
|
+
"id": "headless",
|
|
929
|
+
"type": "boolean",
|
|
930
|
+
"label": {
|
|
931
|
+
"es": "Navegación en segundo plano (Headless)",
|
|
932
|
+
"en": "Headless background mode"
|
|
933
|
+
},
|
|
934
|
+
"description": {
|
|
935
|
+
"es": "Desactívalo para ver la ventana del navegador durante la automatización",
|
|
936
|
+
"en": "Disable to display the browser window during automation"
|
|
937
|
+
},
|
|
938
|
+
"default": True,
|
|
939
|
+
"argsWhenTrue": ["--headless"],
|
|
940
|
+
"argsWhenFalse": []
|
|
941
|
+
}
|
|
942
|
+
]
|
|
943
|
+
}, indent=2), encoding="utf-8")
|
|
944
|
+
if not pw_installer.exists():
|
|
945
|
+
pw_installer.write_text(json.dumps({
|
|
946
|
+
"schemaVersion": 1,
|
|
947
|
+
"type": "npm",
|
|
948
|
+
"product": {
|
|
949
|
+
"package": "@playwright/mcp",
|
|
950
|
+
"version": "0.0.81",
|
|
951
|
+
"browser": "chromium"
|
|
952
|
+
}
|
|
953
|
+
}, indent=2), encoding="utf-8")
|
|
954
|
+
|
|
955
|
+
# 3. memory
|
|
956
|
+
memory_dir = self.services_root / "memory"
|
|
957
|
+
memory_dir.mkdir(parents=True, exist_ok=True)
|
|
958
|
+
mem_service = memory_dir / "service.json"
|
|
959
|
+
mem_installer = memory_dir / "installer.json"
|
|
960
|
+
if not mem_service.exists():
|
|
961
|
+
mem_service.write_text(json.dumps({
|
|
962
|
+
"schemaVersion": 1,
|
|
963
|
+
"id": "memory",
|
|
964
|
+
"displayName": {
|
|
965
|
+
"es": "Memoria y Grafos (Knowledge Graph)",
|
|
966
|
+
"en": "Memory & Knowledge Graph"
|
|
967
|
+
},
|
|
968
|
+
"description": {
|
|
969
|
+
"es": "Almacenamiento persistente de entidades, preferencias y contexto histórico estructurado en un grafo de conocimiento.",
|
|
970
|
+
"en": "Persistent storage of entities, preferences, and historical context structured as a knowledge graph."
|
|
971
|
+
},
|
|
972
|
+
"enabledByDefault": False,
|
|
973
|
+
"transport": "stdio",
|
|
974
|
+
"launch": {
|
|
975
|
+
"executable": "${nodeExecutable}",
|
|
976
|
+
"args": ["${serviceDir}/node_modules/@modelcontextprotocol/server-memory/dist/index.js"],
|
|
977
|
+
"cwd": "${serviceDir}",
|
|
978
|
+
"env": {
|
|
979
|
+
"MEMORY_FILE_PATH": "${serviceDir}/memory.jsonl"
|
|
980
|
+
},
|
|
981
|
+
"handshakeTimeoutSeconds": 30
|
|
982
|
+
}
|
|
983
|
+
}, indent=2), encoding="utf-8")
|
|
984
|
+
if not mem_installer.exists():
|
|
985
|
+
mem_installer.write_text(json.dumps({
|
|
986
|
+
"schemaVersion": 1,
|
|
987
|
+
"type": "npm",
|
|
988
|
+
"product": {
|
|
989
|
+
"package": "@modelcontextprotocol/server-memory",
|
|
990
|
+
"version": "2026.8.31"
|
|
991
|
+
}
|
|
992
|
+
}, indent=2), encoding="utf-8")
|
|
993
|
+
|
|
994
|
+
# 4. lsp
|
|
995
|
+
lsp_dir = self.services_root / "lsp"
|
|
996
|
+
lsp_dir.mkdir(parents=True, exist_ok=True)
|
|
997
|
+
lsp_service = lsp_dir / "service.json"
|
|
998
|
+
lsp_installer = lsp_dir / "installer.json"
|
|
999
|
+
if not lsp_service.exists():
|
|
1000
|
+
lsp_service.write_text(json.dumps({
|
|
1001
|
+
"schemaVersion": 1,
|
|
1002
|
+
"id": "lsp",
|
|
1003
|
+
"displayName": {
|
|
1004
|
+
"es": "LSP y Navegación de Código",
|
|
1005
|
+
"en": "LSP & Code Intelligence"
|
|
1006
|
+
},
|
|
1007
|
+
"description": {
|
|
1008
|
+
"es": "Servidor de protocolos de lenguaje (LSP): salto a definiciones, búsqueda de símbolos, referencias e inspección de tipos sin sobrecargar el contexto.",
|
|
1009
|
+
"en": "Language Server Protocol (LSP) server: jump to definitions, symbol search, references, and type inspection without context overload."
|
|
1010
|
+
},
|
|
1011
|
+
"enabledByDefault": False,
|
|
1012
|
+
"transport": "stdio",
|
|
1013
|
+
"launch": {
|
|
1014
|
+
"executable": "${nodeExecutable}",
|
|
1015
|
+
"args": ["${serviceDir}/node_modules/@axivo/mcp-lsp/dist/index.js"],
|
|
1016
|
+
"cwd": "${serviceDir}",
|
|
1017
|
+
"env": {},
|
|
1018
|
+
"handshakeTimeoutSeconds": 30
|
|
1019
|
+
}
|
|
1020
|
+
}, indent=2), encoding="utf-8")
|
|
1021
|
+
if not lsp_installer.exists():
|
|
1022
|
+
lsp_installer.write_text(json.dumps({
|
|
1023
|
+
"schemaVersion": 1,
|
|
1024
|
+
"type": "npm",
|
|
1025
|
+
"product": {
|
|
1026
|
+
"package": "@axivo/mcp-lsp",
|
|
1027
|
+
"version": "1.0.5"
|
|
1028
|
+
}
|
|
1029
|
+
}, indent=2), encoding="utf-8")
|
|
1030
|
+
|
|
1031
|
+
def _load_services(self) -> dict[str, dict]:
|
|
1032
|
+
servers = {}
|
|
1033
|
+
for directory in sorted(self.services_root.iterdir()):
|
|
1034
|
+
if not directory.is_dir():
|
|
1035
|
+
continue
|
|
1036
|
+
service_file = directory / "service.json"
|
|
1037
|
+
if not service_file.exists():
|
|
1038
|
+
continue
|
|
1039
|
+
try:
|
|
1040
|
+
server = json.loads(service_file.read_text(encoding="utf-8"))
|
|
1041
|
+
server_id = server.get("id") or directory.name.replace(".mcp", "")
|
|
1042
|
+
server["id"] = server_id
|
|
1043
|
+
server["_directory"] = directory
|
|
1044
|
+
servers[server_id] = server
|
|
1045
|
+
except Exception:
|
|
1046
|
+
continue
|
|
1047
|
+
return servers
|
|
1048
|
+
|
|
1049
|
+
def _load_preferences(self) -> dict:
|
|
1050
|
+
if self.config_file.exists():
|
|
1051
|
+
try:
|
|
1052
|
+
return json.loads(self.config_file.read_text(encoding="utf-8"))
|
|
1053
|
+
except Exception:
|
|
1054
|
+
return {}
|
|
1055
|
+
return {}
|
|
1056
|
+
|
|
1057
|
+
def _save_preferences(self):
|
|
1058
|
+
try:
|
|
1059
|
+
tmp = self.config_file.with_suffix(".tmp")
|
|
1060
|
+
tmp.write_text(json.dumps(self.preferences, indent=2), encoding="utf-8")
|
|
1061
|
+
tmp.replace(self.config_file)
|
|
1062
|
+
except Exception:
|
|
1063
|
+
pass
|
|
1064
|
+
|
|
1065
|
+
def list_servers(self) -> list[dict]:
|
|
1066
|
+
self.services = self._load_services()
|
|
1067
|
+
result = []
|
|
1068
|
+
for server_id, server in self.services.items():
|
|
1069
|
+
client = self.clients.get(server_id)
|
|
1070
|
+
running = bool(client and client.running())
|
|
1071
|
+
pref = self.preferences.get(server_id, {})
|
|
1072
|
+
result.append({
|
|
1073
|
+
"id": server_id,
|
|
1074
|
+
"displayName": server.get("displayName", {}),
|
|
1075
|
+
"description": server.get("description", {}),
|
|
1076
|
+
"enabled": pref.get("enabled", server.get("enabledByDefault", False)),
|
|
1077
|
+
"status": "running" if running else self.states.get(server_id, "stopped"),
|
|
1078
|
+
"toolCount": len(client.tools) if running else 0,
|
|
1079
|
+
"error": self.errors.get(server_id),
|
|
1080
|
+
"options": server.get("options", []),
|
|
1081
|
+
"userOptions": pref.get("options", {})
|
|
1082
|
+
})
|
|
1083
|
+
return result
|
|
1084
|
+
|
|
1085
|
+
def _expand(self, value: str, values: dict[str, str]) -> str:
|
|
1086
|
+
if not isinstance(value, str):
|
|
1087
|
+
raise ValueError("Invalid process argument")
|
|
1088
|
+
for key, replacement in values.items():
|
|
1089
|
+
value = value.replace("${" + key + "}", str(replacement))
|
|
1090
|
+
return value
|
|
1091
|
+
|
|
1092
|
+
def _prepare_service(self, server_id: str, server: dict) -> dict[str, str]:
|
|
1093
|
+
service_dir = server["_directory"]
|
|
1094
|
+
installer_file = service_dir / "installer.json"
|
|
1095
|
+
node = shutil.which("node") or "node"
|
|
1096
|
+
npm = shutil.which("npm") or "npm"
|
|
1097
|
+
|
|
1098
|
+
if installer_file.exists():
|
|
1099
|
+
marker = service_dir / ".installed.json"
|
|
1100
|
+
try:
|
|
1101
|
+
installer = json.loads(installer_file.read_text(encoding="utf-8"))
|
|
1102
|
+
except Exception as e:
|
|
1103
|
+
raise RuntimeError(f"Error leyendo installer.json: {e}")
|
|
1104
|
+
kind = installer.get("type", "npm")
|
|
1105
|
+
if kind == "npm":
|
|
1106
|
+
product = installer.get("product", {})
|
|
1107
|
+
package = product.get("package")
|
|
1108
|
+
version = product.get("version")
|
|
1109
|
+
if not package or not version:
|
|
1110
|
+
raise RuntimeError("El instalador npm debe definir package y version")
|
|
1111
|
+
if not shutil.which("node") or not shutil.which("npm"):
|
|
1112
|
+
raise RuntimeError("Node.js 18+ y npm son necesarios para instalar este servicio MCP")
|
|
1113
|
+
|
|
1114
|
+
needs_install = not marker.exists()
|
|
1115
|
+
if not needs_install:
|
|
1116
|
+
try:
|
|
1117
|
+
installation = json.loads(marker.read_text(encoding="utf-8"))
|
|
1118
|
+
if installation.get("package") != package or installation.get("version") != version:
|
|
1119
|
+
needs_install = True
|
|
1120
|
+
except Exception:
|
|
1121
|
+
needs_install = True
|
|
1122
|
+
|
|
1123
|
+
if needs_install:
|
|
1124
|
+
self.states[server_id] = "installing"
|
|
1125
|
+
manifest = service_dir / "package.json"
|
|
1126
|
+
manifest.write_text(json.dumps({"private": True, "dependencies": {package: version}}, indent=2), encoding="utf-8")
|
|
1127
|
+
res = subprocess.run([npm, "install", "--ignore-scripts", "--no-audit", "--no-fund"], cwd=service_dir, capture_output=True, text=True, timeout=600)
|
|
1128
|
+
if res.returncode != 0:
|
|
1129
|
+
raise RuntimeError(f"Fallo instalando dependencias npm: {res.stderr or res.stdout}")
|
|
1130
|
+
installation = {"type": "npm", "package": package, "version": version, "nodeExecutable": node}
|
|
1131
|
+
browser = product.get("browser")
|
|
1132
|
+
if browser:
|
|
1133
|
+
playwright_cli = service_dir / "node_modules" / "playwright" / "cli.js"
|
|
1134
|
+
if playwright_cli.is_file():
|
|
1135
|
+
subprocess.run([node, str(playwright_cli), "install", browser], cwd=service_dir, capture_output=True, text=True, timeout=600)
|
|
1136
|
+
installation["browser"] = browser
|
|
1137
|
+
marker.write_text(json.dumps(installation, indent=2), encoding="utf-8")
|
|
1138
|
+
|
|
1139
|
+
return {
|
|
1140
|
+
"serviceDir": str(service_dir),
|
|
1141
|
+
"pythonExecutable": sys.executable,
|
|
1142
|
+
"nodeExecutable": node
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
def start(self, server_id: str) -> list[dict]:
|
|
1146
|
+
with self._lock:
|
|
1147
|
+
self.services = self._load_services()
|
|
1148
|
+
server = self.services.get(server_id)
|
|
1149
|
+
if not server:
|
|
1150
|
+
raise KeyError(f"Servidor MCP desconocido: {server_id}")
|
|
1151
|
+
current = self.clients.get(server_id)
|
|
1152
|
+
if current and current.running():
|
|
1153
|
+
return self.list_servers()
|
|
1154
|
+
self.states[server_id] = "starting"
|
|
1155
|
+
try:
|
|
1156
|
+
service_dir = server["_directory"]
|
|
1157
|
+
values = self._prepare_service(server_id, server)
|
|
1158
|
+
pref = self.preferences.get(server_id, {})
|
|
1159
|
+
user_opts = pref.get("options", {})
|
|
1160
|
+
for opt in server.get("options", []):
|
|
1161
|
+
opt_id = opt.get("id")
|
|
1162
|
+
if opt_id:
|
|
1163
|
+
val = user_opts.get(opt_id, opt.get("default"))
|
|
1164
|
+
values[f"option:{opt_id}"] = str(val)
|
|
1165
|
+
|
|
1166
|
+
launch = server.get("launch", {})
|
|
1167
|
+
command = self._expand(launch.get("executable", sys.executable), values)
|
|
1168
|
+
args = [self._expand(arg, values) for arg in launch.get("args", [])]
|
|
1169
|
+
for opt in server.get("options", []):
|
|
1170
|
+
opt_id = opt.get("id")
|
|
1171
|
+
if not opt_id:
|
|
1172
|
+
continue
|
|
1173
|
+
val = user_opts.get(opt_id, opt.get("default"))
|
|
1174
|
+
if opt.get("type") == "boolean":
|
|
1175
|
+
extra = opt.get("argsWhenTrue", []) if val else opt.get("argsWhenFalse", [])
|
|
1176
|
+
args.extend([self._expand(a, values) for a in extra])
|
|
1177
|
+
|
|
1178
|
+
env = os.environ.copy()
|
|
1179
|
+
for k, v in launch.get("env", {}).items():
|
|
1180
|
+
env[k] = self._expand(v, values)
|
|
1181
|
+
|
|
1182
|
+
client = StdioMcpClient(command, args, str(service_dir), env)
|
|
1183
|
+
client.start(int(launch.get("handshakeTimeoutSeconds", 15)))
|
|
1184
|
+
self.clients[server_id] = client
|
|
1185
|
+
self.states[server_id] = "running"
|
|
1186
|
+
self.errors.pop(server_id, None)
|
|
1187
|
+
entry = self.preferences.setdefault(server_id, {})
|
|
1188
|
+
entry["enabled"] = True
|
|
1189
|
+
self._save_preferences()
|
|
1190
|
+
except Exception as exc:
|
|
1191
|
+
self.states[server_id] = "error"
|
|
1192
|
+
self.errors[server_id] = str(exc)
|
|
1193
|
+
if server_id in self.clients:
|
|
1194
|
+
self.clients.pop(server_id).stop()
|
|
1195
|
+
return self.list_servers()
|
|
1196
|
+
|
|
1197
|
+
def stop(self, server_id: str) -> list[dict]:
|
|
1198
|
+
with self._lock:
|
|
1199
|
+
client = self.clients.pop(server_id, None)
|
|
1200
|
+
if client:
|
|
1201
|
+
client.stop()
|
|
1202
|
+
self.states[server_id] = "stopped"
|
|
1203
|
+
self.errors.pop(server_id, None)
|
|
1204
|
+
entry = self.preferences.setdefault(server_id, {})
|
|
1205
|
+
entry["enabled"] = False
|
|
1206
|
+
self._save_preferences()
|
|
1207
|
+
return self.list_servers()
|
|
1208
|
+
|
|
1209
|
+
def configure(self, server_id: str, options: dict | None = None) -> list[dict]:
|
|
1210
|
+
with self._lock:
|
|
1211
|
+
self.services = self._load_services()
|
|
1212
|
+
if server_id not in self.services:
|
|
1213
|
+
raise KeyError(f"Servidor MCP desconocido: {server_id}")
|
|
1214
|
+
entry = self.preferences.setdefault(server_id, {})
|
|
1215
|
+
if options is not None:
|
|
1216
|
+
opts = entry.setdefault("options", {})
|
|
1217
|
+
opts.update(options)
|
|
1218
|
+
self._save_preferences()
|
|
1219
|
+
return self.list_servers()
|
|
1220
|
+
|
|
1221
|
+
def tools(self) -> list[dict]:
|
|
1222
|
+
aggregated = []
|
|
1223
|
+
with self._lock:
|
|
1224
|
+
for server_id, client in self.clients.items():
|
|
1225
|
+
if not client.running():
|
|
1226
|
+
continue
|
|
1227
|
+
for t in client.tools:
|
|
1228
|
+
try:
|
|
1229
|
+
pname = public_tool_name(server_id, t["name"])
|
|
1230
|
+
tcopy = dict(t)
|
|
1231
|
+
tcopy["name"] = pname
|
|
1232
|
+
tcopy["metadata"] = {
|
|
1233
|
+
"mcpServerId": server_id,
|
|
1234
|
+
"originalName": t["name"]
|
|
1235
|
+
}
|
|
1236
|
+
aggregated.append(tcopy)
|
|
1237
|
+
except Exception:
|
|
1238
|
+
continue
|
|
1239
|
+
return aggregated
|
|
1240
|
+
|
|
1241
|
+
def call(self, public_name: str, arguments: dict) -> dict:
|
|
1242
|
+
with self._lock:
|
|
1243
|
+
for server_id, client in self.clients.items():
|
|
1244
|
+
if not client.running():
|
|
1245
|
+
continue
|
|
1246
|
+
for t in client.tools:
|
|
1247
|
+
if public_tool_name(server_id, t["name"]) == public_name:
|
|
1248
|
+
return client.request("tools/call", {"name": t["name"], "arguments": arguments})
|
|
1249
|
+
raise ValueError(f"Herramienta externa '{public_name}' no disponible o servidor detenido.")
|
|
1250
|
+
|
|
1251
|
+
def close(self):
|
|
1252
|
+
with self._lock:
|
|
1253
|
+
for client in list(self.clients.values()):
|
|
1254
|
+
client.stop()
|
|
1255
|
+
self.clients.clear()
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
GLOBAL_MCP_MANAGER = McpServiceManager()
|
|
1259
|
+
|
|
1260
|
+
DEFAULT_HEARTBEAT_TIMEOUT = float(os.environ.get("ZEROCHAT_HEARTBEAT_TIMEOUT", "60.0"))
|
|
1261
|
+
DEFAULT_HEARTBEAT_GRACE = float(os.environ.get("ZEROCHAT_HEARTBEAT_GRACE", "45.0"))
|
|
1262
|
+
DEFAULT_HEARTBEAT_POLL = float(os.environ.get("ZEROCHAT_HEARTBEAT_POLL", "5.0"))
|
|
1263
|
+
HEARTBEAT_LAST_SEEN = 0.0
|
|
1264
|
+
HEARTBEAT_INITIALIZED = False
|
|
1265
|
+
HEARTBEAT_WATCHDOG_STOP = threading.Event()
|
|
1266
|
+
_SERVER_SHUTTING_DOWN = threading.Event()
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def mark_browser_active():
|
|
1270
|
+
"""Registra la presencia activa del navegador ante cualquier petición válida."""
|
|
1271
|
+
global HEARTBEAT_LAST_SEEN, HEARTBEAT_INITIALIZED
|
|
1272
|
+
HEARTBEAT_LAST_SEEN = time.monotonic()
|
|
1273
|
+
HEARTBEAT_INITIALIZED = True
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def stop_zerochat_server(server: ThreadingHTTPServer):
|
|
1277
|
+
"""Detiene limpiamente el servidor y todos los subsistemas evitando reentradas."""
|
|
1278
|
+
if _SERVER_SHUTTING_DOWN.is_set():
|
|
1279
|
+
return
|
|
1280
|
+
_SERVER_SHUTTING_DOWN.set()
|
|
1281
|
+
HEARTBEAT_WATCHDOG_STOP.set()
|
|
1282
|
+
GLOBAL_MCP_MANAGER.close()
|
|
1283
|
+
threading.Thread(target=server.shutdown, daemon=True).start()
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
def heartbeat_watchdog(server: ThreadingHTTPServer, initial_grace_seconds: float = DEFAULT_HEARTBEAT_GRACE, inactivity_timeout_seconds: float = DEFAULT_HEARTBEAT_TIMEOUT, require_initial_connection: bool = True):
|
|
1287
|
+
"""
|
|
1288
|
+
Supervisa la presencia de la pestaña del navegador mediante latidos HTTP.
|
|
1289
|
+
Si el navegador se cierra o deja de emitir latidos, detiene el servidor automáticamente.
|
|
1290
|
+
"""
|
|
1291
|
+
start_time = time.monotonic()
|
|
1292
|
+
poll_interval = min(DEFAULT_HEARTBEAT_POLL, max(0.02, inactivity_timeout_seconds / 2))
|
|
1293
|
+
while not HEARTBEAT_WATCHDOG_STOP.is_set():
|
|
1294
|
+
if HEARTBEAT_WATCHDOG_STOP.wait(timeout=poll_interval):
|
|
1295
|
+
break
|
|
1296
|
+
|
|
1297
|
+
now = time.monotonic()
|
|
1298
|
+
|
|
1299
|
+
# 1. Periodo de gracia inicial (solo si zerochat abrió el navegador)
|
|
1300
|
+
if not HEARTBEAT_INITIALIZED:
|
|
1301
|
+
if require_initial_connection and (now - start_time > initial_grace_seconds):
|
|
1302
|
+
console_log(f"[{time.strftime('%H:%M:%S')}] Tiempo de espera del navegador agotado ({initial_grace_seconds:.0f}s). Deteniendo servidor ZeroChat...", flush=True)
|
|
1303
|
+
stop_zerochat_server(server)
|
|
1304
|
+
break
|
|
1305
|
+
continue
|
|
1306
|
+
|
|
1307
|
+
# 2. Inactividad tras haber recibido latidos
|
|
1308
|
+
if now - HEARTBEAT_LAST_SEEN > inactivity_timeout_seconds:
|
|
1309
|
+
console_log(f"[{time.strftime('%H:%M:%S')}] Navegador desconectado (cierre detectado). Deteniendo servidor ZeroChat...", flush=True)
|
|
1310
|
+
stop_zerochat_server(server)
|
|
1311
|
+
break
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
DEV_CONTENT_TYPES = {
|
|
1315
|
+
".html": "text/html; charset=utf-8",
|
|
1316
|
+
".js": "application/javascript; charset=utf-8",
|
|
1317
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
1318
|
+
".css": "text/css; charset=utf-8",
|
|
1319
|
+
".json": "application/json; charset=utf-8",
|
|
1320
|
+
".webmanifest": "application/manifest+json; charset=utf-8",
|
|
1321
|
+
".svg": "image/svg+xml",
|
|
1322
|
+
".png": "image/png",
|
|
1323
|
+
".jpg": "image/jpeg",
|
|
1324
|
+
".jpeg": "image/jpeg",
|
|
1325
|
+
".ico": "image/x-icon",
|
|
1326
|
+
".txt": "text/plain; charset=utf-8",
|
|
1327
|
+
".map": "application/json",
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
1332
|
+
server_version = f"ZeroChatServer/{VERSION}"
|
|
1333
|
+
|
|
1334
|
+
def _log_req(self, method: str, detail: str):
|
|
1335
|
+
now = time.strftime("%H:%M:%S")
|
|
1336
|
+
console_log(f"[{now}] --> {method} {detail}", flush=True)
|
|
1337
|
+
|
|
1338
|
+
def _log_res(self, status: int, detail: str, duration_ms: float, error_info: str = ""):
|
|
1339
|
+
now = time.strftime("%H:%M:%S")
|
|
1340
|
+
status_text = {
|
|
1341
|
+
200: "200 OK",
|
|
1342
|
+
204: "204 No Content",
|
|
1343
|
+
400: "400 Bad Request",
|
|
1344
|
+
401: "401 Unauthorized",
|
|
1345
|
+
403: "403 Forbidden",
|
|
1346
|
+
404: "404 Not Found",
|
|
1347
|
+
500: "500 Internal Server Error",
|
|
1348
|
+
}.get(status, str(status))
|
|
1349
|
+
err_suffix = f" - ERROR: {format_log_error(error_info)}" if error_info else ""
|
|
1350
|
+
console_log(f"[{now}] <-- {status_text} {detail}{err_suffix} ({duration_ms:.1f}ms)", flush=True)
|
|
1351
|
+
|
|
1352
|
+
def serve_static_file(self, rel_path: str) -> bool:
|
|
1353
|
+
"""Sirve los recursos web del repositorio o de la distribución instalada."""
|
|
1354
|
+
static_root = get_static_root()
|
|
1355
|
+
if not static_root:
|
|
1356
|
+
return False
|
|
1357
|
+
|
|
1358
|
+
clean_rel = rel_path.split("?", 1)[0].lstrip("/")
|
|
1359
|
+
if not clean_rel:
|
|
1360
|
+
return False
|
|
1361
|
+
|
|
1362
|
+
target_path = (static_root / clean_rel).resolve()
|
|
1363
|
+
try:
|
|
1364
|
+
rel_parts = target_path.relative_to(static_root.resolve()).parts
|
|
1365
|
+
except ValueError:
|
|
1366
|
+
return False
|
|
1367
|
+
|
|
1368
|
+
# Whitelist de archivos y carpetas autorizados para servir la interfaz web
|
|
1369
|
+
is_allowed_static = (
|
|
1370
|
+
clean_rel == "zerochat.html" or
|
|
1371
|
+
clean_rel in ("manifest.webmanifest", "sw.js", "favicon.ico") or
|
|
1372
|
+
clean_rel.startswith("js/") or
|
|
1373
|
+
clean_rel.startswith("css/") or
|
|
1374
|
+
clean_rel.startswith("help/")
|
|
1375
|
+
)
|
|
1376
|
+
if not is_allowed_static:
|
|
1377
|
+
return False
|
|
1378
|
+
|
|
1379
|
+
# Protección: bloquear archivos ocultos, datos y dependencias locales.
|
|
1380
|
+
for part in rel_parts:
|
|
1381
|
+
if part.startswith(".") and part != ".":
|
|
1382
|
+
return False
|
|
1383
|
+
if part in ("zerochat", "node_modules", ".git", "tests"):
|
|
1384
|
+
return False
|
|
1385
|
+
|
|
1386
|
+
if not target_path.is_file():
|
|
1387
|
+
return False
|
|
1388
|
+
|
|
1389
|
+
ext = target_path.suffix.lower()
|
|
1390
|
+
content_type = DEV_CONTENT_TYPES.get(ext, "application/octet-stream")
|
|
1391
|
+
try:
|
|
1392
|
+
data = target_path.read_bytes()
|
|
1393
|
+
except Exception:
|
|
1394
|
+
return False
|
|
1395
|
+
|
|
1396
|
+
mark_browser_active()
|
|
1397
|
+
self.send_response(200)
|
|
1398
|
+
self.send_header("Content-Type", content_type)
|
|
1399
|
+
self.send_header("Content-Length", str(len(data)))
|
|
1400
|
+
if clean_rel == "sw.js":
|
|
1401
|
+
self.send_header("Service-Worker-Allowed", "/")
|
|
1402
|
+
self.send_cors_headers()
|
|
1403
|
+
self.end_headers()
|
|
1404
|
+
self.wfile.write(data)
|
|
1405
|
+
return True
|
|
1406
|
+
|
|
1407
|
+
def send_cors_headers(self):
|
|
1408
|
+
origin = self.headers.get("Origin")
|
|
1409
|
+
if not is_allowed_origin(origin):
|
|
1410
|
+
return
|
|
1411
|
+
self.send_header("Access-Control-Allow-Origin", origin if origin else "*")
|
|
1412
|
+
self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
|
|
1413
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type, Accept, Authorization, X-ZeroChat-Token, X-ZeroChat-Client")
|
|
1414
|
+
self.send_header("Access-Control-Allow-Private-Network", "true")
|
|
1415
|
+
|
|
1416
|
+
def verify_token(self) -> bool:
|
|
1417
|
+
"""Comprueba el token efímero de sesión en cabeceras o query string."""
|
|
1418
|
+
auth_header = self.headers.get("Authorization", "")
|
|
1419
|
+
token_candidate = None
|
|
1420
|
+
if auth_header.startswith("Bearer "):
|
|
1421
|
+
token_candidate = auth_header[7:].strip()
|
|
1422
|
+
elif "X-ZeroChat-Token" in self.headers:
|
|
1423
|
+
token_candidate = self.headers.get("X-ZeroChat-Token", "").strip()
|
|
1424
|
+
elif "?" in self.path:
|
|
1425
|
+
query = self.path.split("?", 1)[1]
|
|
1426
|
+
for part in query.split("&"):
|
|
1427
|
+
if part.startswith("token="):
|
|
1428
|
+
token_candidate = part.split("=", 1)[1].strip()
|
|
1429
|
+
break
|
|
1430
|
+
|
|
1431
|
+
if not token_candidate:
|
|
1432
|
+
return False
|
|
1433
|
+
is_valid = hmac.compare_digest(token_candidate, SESSION_TOKEN)
|
|
1434
|
+
if is_valid:
|
|
1435
|
+
mark_browser_active()
|
|
1436
|
+
return is_valid
|
|
1437
|
+
|
|
1438
|
+
def do_OPTIONS(self):
|
|
1439
|
+
t0 = time.monotonic()
|
|
1440
|
+
safe_path = sanitize_log_path(self.path)
|
|
1441
|
+
path_clean = self.path.split("?", 1)[0].rstrip("/")
|
|
1442
|
+
is_heartbeat = path_clean == "/zerochat/heartbeat"
|
|
1443
|
+
if not is_heartbeat:
|
|
1444
|
+
self._log_req("OPTIONS", safe_path)
|
|
1445
|
+
origin = self.headers.get("Origin")
|
|
1446
|
+
if not is_allowed_origin(origin):
|
|
1447
|
+
self.send_response(403)
|
|
1448
|
+
self.end_headers()
|
|
1449
|
+
self._log_res(403, safe_path, (time.monotonic() - t0) * 1000, f"Origen no permitido: '{origin}'")
|
|
1450
|
+
return
|
|
1451
|
+
self.send_response(204)
|
|
1452
|
+
self.send_cors_headers()
|
|
1453
|
+
self.end_headers()
|
|
1454
|
+
if not is_heartbeat:
|
|
1455
|
+
self._log_res(204, safe_path, (time.monotonic() - t0) * 1000)
|
|
1456
|
+
|
|
1457
|
+
def _send_json_response(self, status: int, data: dict):
|
|
1458
|
+
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|
1459
|
+
self.send_response(status)
|
|
1460
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
1461
|
+
self.send_header("Content-Length", str(len(body)))
|
|
1462
|
+
self.send_cors_headers()
|
|
1463
|
+
self.end_headers()
|
|
1464
|
+
self.wfile.write(body)
|
|
1465
|
+
|
|
1466
|
+
def do_GET(self):
|
|
1467
|
+
t0 = time.monotonic()
|
|
1468
|
+
safe_path = sanitize_log_path(self.path)
|
|
1469
|
+
path_clean = self.path.split("?", 1)[0].rstrip("/")
|
|
1470
|
+
is_heartbeat = path_clean == "/zerochat/heartbeat"
|
|
1471
|
+
|
|
1472
|
+
origin = self.headers.get("Origin")
|
|
1473
|
+
if not is_allowed_origin(origin):
|
|
1474
|
+
if not is_heartbeat:
|
|
1475
|
+
self._log_req("GET", safe_path)
|
|
1476
|
+
self.send_response(403)
|
|
1477
|
+
self.end_headers()
|
|
1478
|
+
self._log_res(403, safe_path, (time.monotonic() - t0) * 1000, f"Origen no permitido: '{origin}'")
|
|
1479
|
+
return
|
|
1480
|
+
|
|
1481
|
+
# Servir archivos estáticos del repositorio si estamos en entorno de desarrollo
|
|
1482
|
+
if path_clean and self.serve_static_file(path_clean):
|
|
1483
|
+
self._log_req("GET", safe_path)
|
|
1484
|
+
self._log_res(200, safe_path, (time.monotonic() - t0) * 1000)
|
|
1485
|
+
return
|
|
1486
|
+
|
|
1487
|
+
if not is_heartbeat:
|
|
1488
|
+
self._log_req("GET", safe_path)
|
|
1489
|
+
|
|
1490
|
+
if not self.verify_token():
|
|
1491
|
+
err_msg = json.dumps({"error": "Unauthorized: invalid or missing session token"}).encode("utf-8")
|
|
1492
|
+
self.send_response(401)
|
|
1493
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
1494
|
+
self.send_header("Content-Length", str(len(err_msg)))
|
|
1495
|
+
self.send_cors_headers()
|
|
1496
|
+
self.end_headers()
|
|
1497
|
+
self.wfile.write(err_msg)
|
|
1498
|
+
self._log_res(401, safe_path, (time.monotonic() - t0) * 1000, "Token de sesión ausente o inválido")
|
|
1499
|
+
return
|
|
1500
|
+
|
|
1501
|
+
if is_heartbeat:
|
|
1502
|
+
mark_browser_active()
|
|
1503
|
+
self._send_json_response(200, {"ok": True})
|
|
1504
|
+
return
|
|
1505
|
+
|
|
1506
|
+
accept = self.headers.get("Accept", "")
|
|
1507
|
+
if "/sse" in self.path or "text/event-stream" in accept:
|
|
1508
|
+
self.send_response(200)
|
|
1509
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
1510
|
+
self.send_header("Cache-Control", "no-cache")
|
|
1511
|
+
self.send_header("Connection", "keep-alive")
|
|
1512
|
+
self.send_cors_headers()
|
|
1513
|
+
self.end_headers()
|
|
1514
|
+
endpoint_data = b"/mcp/external" if "/mcp/external" in self.path else b"/"
|
|
1515
|
+
self.wfile.write(b"event: endpoint\r\ndata: " + endpoint_data + b"\r\n\r\n")
|
|
1516
|
+
self.wfile.flush()
|
|
1517
|
+
self._log_res(200, f"{safe_path} [SSE canal activo]", (time.monotonic() - t0) * 1000)
|
|
1518
|
+
return
|
|
1519
|
+
|
|
1520
|
+
# Status general
|
|
1521
|
+
res_data = json.dumps({
|
|
1522
|
+
"status": "active",
|
|
1523
|
+
"server": "ZeroChat Local Server",
|
|
1524
|
+
"version": VERSION,
|
|
1525
|
+
"tools_count": len(LOCAL_TOOLS_DEFINITIONS),
|
|
1526
|
+
"os": DETECTED_OS
|
|
1527
|
+
}, ensure_ascii=False, indent=2).encode("utf-8")
|
|
1528
|
+
|
|
1529
|
+
self.send_response(200)
|
|
1530
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
1531
|
+
self.send_header("Content-Length", str(len(res_data)))
|
|
1532
|
+
self.send_cors_headers()
|
|
1533
|
+
self.end_headers()
|
|
1534
|
+
self.wfile.write(res_data)
|
|
1535
|
+
self._log_res(200, safe_path, (time.monotonic() - t0) * 1000)
|
|
1536
|
+
|
|
1537
|
+
def do_POST(self):
|
|
1538
|
+
t0 = time.monotonic()
|
|
1539
|
+
safe_path = sanitize_log_path(self.path)
|
|
1540
|
+
origin = self.headers.get("Origin")
|
|
1541
|
+
if not is_allowed_origin(origin):
|
|
1542
|
+
self._log_req("POST", safe_path)
|
|
1543
|
+
self.send_response(403)
|
|
1544
|
+
self.end_headers()
|
|
1545
|
+
self._log_res(403, safe_path, (time.monotonic() - t0) * 1000, f"Origen no permitido: '{origin}'")
|
|
1546
|
+
return
|
|
1547
|
+
|
|
1548
|
+
if not self.verify_token():
|
|
1549
|
+
self._log_req("POST", safe_path)
|
|
1550
|
+
err_msg = json.dumps({
|
|
1551
|
+
"jsonrpc": "2.0",
|
|
1552
|
+
"id": None,
|
|
1553
|
+
"error": {"code": -32000, "message": "Unauthorized: invalid or missing session token"}
|
|
1554
|
+
}).encode("utf-8")
|
|
1555
|
+
self.send_response(401)
|
|
1556
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
1557
|
+
self.send_header("Content-Length", str(len(err_msg)))
|
|
1558
|
+
self.send_cors_headers()
|
|
1559
|
+
self.end_headers()
|
|
1560
|
+
self.wfile.write(err_msg)
|
|
1561
|
+
self._log_res(401, safe_path, (time.monotonic() - t0) * 1000, "Token de sesión ausente o inválido")
|
|
1562
|
+
return
|
|
1563
|
+
|
|
1564
|
+
content_len = int(self.headers.get("Content-Length", 0))
|
|
1565
|
+
post_data = self.rfile.read(content_len) if content_len > 0 else b"{}"
|
|
1566
|
+
|
|
1567
|
+
try:
|
|
1568
|
+
req = json.loads(post_data.decode("utf-8"))
|
|
1569
|
+
except Exception as err:
|
|
1570
|
+
self._log_req("POST", safe_path)
|
|
1571
|
+
err_resp = json.dumps({
|
|
1572
|
+
"jsonrpc": "2.0",
|
|
1573
|
+
"id": None,
|
|
1574
|
+
"error": {"code": -32700, "message": f"Parse error: {str(err)}"}
|
|
1575
|
+
}).encode("utf-8")
|
|
1576
|
+
self.send_response(400)
|
|
1577
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
1578
|
+
self.send_cors_headers()
|
|
1579
|
+
self.end_headers()
|
|
1580
|
+
self.wfile.write(err_resp)
|
|
1581
|
+
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, f"Error parseando JSON: {err}")
|
|
1582
|
+
return
|
|
1583
|
+
|
|
1584
|
+
req_path = self.path.split("?", 1)[0].rstrip("/")
|
|
1585
|
+
req_id = req.get("id")
|
|
1586
|
+
method = req.get("method")
|
|
1587
|
+
params = req.get("params", {})
|
|
1588
|
+
|
|
1589
|
+
# Determinar etiqueta de seguimiento para la petición y respuesta (sin datos sensibles)
|
|
1590
|
+
tool_name = params.get("name", "") if isinstance(params, dict) else ""
|
|
1591
|
+
if method == "tools/call" and tool_name:
|
|
1592
|
+
action_tag = f"[tools/call: {tool_name}]"
|
|
1593
|
+
elif method:
|
|
1594
|
+
action_tag = f"[rpc: {method}]"
|
|
1595
|
+
elif req_path.startswith("/zerochat/external/servers/"):
|
|
1596
|
+
action_tag = f"[REST: {req_path}]"
|
|
1597
|
+
else:
|
|
1598
|
+
action_tag = f"[{safe_path}]"
|
|
1599
|
+
|
|
1600
|
+
self._log_req("POST", f"{safe_path} {action_tag}")
|
|
1601
|
+
|
|
1602
|
+
if req_id is None and (method or "").startswith("notifications/"):
|
|
1603
|
+
self.send_response(204)
|
|
1604
|
+
self.send_cors_headers()
|
|
1605
|
+
self.end_headers()
|
|
1606
|
+
self._log_res(204, f"{safe_path} {action_tag}", (time.monotonic() - t0) * 1000)
|
|
1607
|
+
return
|
|
1608
|
+
|
|
1609
|
+
result = None
|
|
1610
|
+
error = None
|
|
1611
|
+
tool_error_info = ""
|
|
1612
|
+
is_external_endpoint = "/mcp/external" in req_path
|
|
1613
|
+
|
|
1614
|
+
if is_external_endpoint:
|
|
1615
|
+
if method == "initialize":
|
|
1616
|
+
result = {
|
|
1617
|
+
"protocolVersion": "2024-11-05",
|
|
1618
|
+
"serverInfo": {
|
|
1619
|
+
"name": "ZeroChat External MCP Host",
|
|
1620
|
+
"version": VERSION
|
|
1621
|
+
},
|
|
1622
|
+
"capabilities": {
|
|
1623
|
+
"tools": {"listChanged": True}
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
elif method == "tools/list":
|
|
1627
|
+
result = {"tools": GLOBAL_MCP_MANAGER.tools()}
|
|
1628
|
+
elif method == "tools/call":
|
|
1629
|
+
tool_name = params.get("name", "") if isinstance(params, dict) else ""
|
|
1630
|
+
tool_args = params.get("arguments", {}) if isinstance(params, dict) else {}
|
|
1631
|
+
try:
|
|
1632
|
+
result = GLOBAL_MCP_MANAGER.call(tool_name, tool_args)
|
|
1633
|
+
if isinstance(result, dict) and result.get("isError"):
|
|
1634
|
+
c_list = result.get("content", [])
|
|
1635
|
+
if c_list and isinstance(c_list, list) and isinstance(c_list[0], dict):
|
|
1636
|
+
tool_error_info = c_list[0].get("text", "Error en herramienta MCP externa")
|
|
1637
|
+
else:
|
|
1638
|
+
tool_error_info = "Error en herramienta MCP externa"
|
|
1639
|
+
except Exception as ex:
|
|
1640
|
+
tool_error_info = str(ex)
|
|
1641
|
+
result = {
|
|
1642
|
+
"content": [{"type": "text", "text": json.dumps({"success": False, "error": str(ex)}, ensure_ascii=False)}],
|
|
1643
|
+
"isError": True
|
|
1644
|
+
}
|
|
1645
|
+
else:
|
|
1646
|
+
error = {"code": -32601, "message": f"Método '{method}' no soportado en /mcp/external."}
|
|
1647
|
+
else:
|
|
1648
|
+
if method == "initialize":
|
|
1649
|
+
result = {
|
|
1650
|
+
"protocolVersion": "2024-11-05",
|
|
1651
|
+
"serverInfo": {
|
|
1652
|
+
"name": "ZeroChat Local Server",
|
|
1653
|
+
"version": VERSION
|
|
1654
|
+
},
|
|
1655
|
+
"capabilities": {
|
|
1656
|
+
"tools": {"listChanged": True}
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
elif method == "tools/list":
|
|
1660
|
+
result = {"tools": list(LOCAL_TOOLS_DEFINITIONS)}
|
|
1661
|
+
elif method == "tools/call":
|
|
1662
|
+
tool_name = params.get("name", "") if isinstance(params, dict) else ""
|
|
1663
|
+
tool_args = params.get("arguments", {}) if isinstance(params, dict) else {}
|
|
1664
|
+
|
|
1665
|
+
if tool_name in LOCAL_TOOL_HANDLERS:
|
|
1666
|
+
handler = LOCAL_TOOL_HANDLERS[tool_name]
|
|
1667
|
+
try:
|
|
1668
|
+
tool_output_json = handler(**tool_args)
|
|
1669
|
+
is_tool_err = False
|
|
1670
|
+
try:
|
|
1671
|
+
parsed_out = json.loads(tool_output_json)
|
|
1672
|
+
if isinstance(parsed_out, dict) and parsed_out.get("success") is False:
|
|
1673
|
+
is_tool_err = True
|
|
1674
|
+
tool_error_info = str(parsed_out.get("error", "Error en herramienta local"))
|
|
1675
|
+
except Exception:
|
|
1676
|
+
pass
|
|
1677
|
+
|
|
1678
|
+
result = {
|
|
1679
|
+
"content": [{"type": "text", "text": tool_output_json}],
|
|
1680
|
+
"isError": is_tool_err
|
|
1681
|
+
}
|
|
1682
|
+
except Exception as ex:
|
|
1683
|
+
tool_error_info = str(ex)
|
|
1684
|
+
result = {
|
|
1685
|
+
"content": [{"type": "text", "text": json.dumps({"success": False, "error": str(ex)}, ensure_ascii=False)}],
|
|
1686
|
+
"isError": True
|
|
1687
|
+
}
|
|
1688
|
+
elif tool_name.startswith("mcp_"):
|
|
1689
|
+
try:
|
|
1690
|
+
result = GLOBAL_MCP_MANAGER.call(tool_name, tool_args)
|
|
1691
|
+
if isinstance(result, dict) and result.get("isError"):
|
|
1692
|
+
c_list = result.get("content", [])
|
|
1693
|
+
if c_list and isinstance(c_list, list) and isinstance(c_list[0], dict):
|
|
1694
|
+
tool_error_info = c_list[0].get("text", "Error en herramienta MCP")
|
|
1695
|
+
else:
|
|
1696
|
+
tool_error_info = "Error en herramienta MCP"
|
|
1697
|
+
except Exception as ex:
|
|
1698
|
+
tool_error_info = str(ex)
|
|
1699
|
+
result = {
|
|
1700
|
+
"content": [{"type": "text", "text": json.dumps({"success": False, "error": str(ex)}, ensure_ascii=False)}],
|
|
1701
|
+
"isError": True
|
|
1702
|
+
}
|
|
1703
|
+
else:
|
|
1704
|
+
error = {"code": -32601, "message": f"Herramienta local '{tool_name}' no encontrada."}
|
|
1705
|
+
elif method == "zerochat/external/status":
|
|
1706
|
+
result = {
|
|
1707
|
+
"host": "running",
|
|
1708
|
+
"version": VERSION,
|
|
1709
|
+
"servers": GLOBAL_MCP_MANAGER.list_servers()
|
|
1710
|
+
}
|
|
1711
|
+
elif method == "zerochat/external/servers/start":
|
|
1712
|
+
server_id = params.get("serverId") or req.get("serverId")
|
|
1713
|
+
servers = GLOBAL_MCP_MANAGER.start(server_id)
|
|
1714
|
+
result = {"servers": servers}
|
|
1715
|
+
elif method == "zerochat/external/servers/stop":
|
|
1716
|
+
server_id = params.get("serverId") or req.get("serverId")
|
|
1717
|
+
servers = GLOBAL_MCP_MANAGER.stop(server_id)
|
|
1718
|
+
result = {"servers": servers}
|
|
1719
|
+
elif method == "zerochat/external/servers/configure":
|
|
1720
|
+
server_id = params.get("serverId") or req.get("serverId")
|
|
1721
|
+
opts = params.get("options") or req.get("options", {})
|
|
1722
|
+
servers = GLOBAL_MCP_MANAGER.configure(server_id, opts)
|
|
1723
|
+
result = {"servers": servers}
|
|
1724
|
+
else:
|
|
1725
|
+
error = {"code": -32601, "message": f"Método '{method}' no soportado."}
|
|
1726
|
+
|
|
1727
|
+
response_payload = {"jsonrpc": "2.0", "id": req_id}
|
|
1728
|
+
error_info = ""
|
|
1729
|
+
if error:
|
|
1730
|
+
response_payload["error"] = error
|
|
1731
|
+
error_info = f"[{error.get('code')}] {error.get('message')}"
|
|
1732
|
+
else:
|
|
1733
|
+
response_payload["result"] = result
|
|
1734
|
+
if tool_error_info:
|
|
1735
|
+
error_info = tool_error_info
|
|
1736
|
+
|
|
1737
|
+
resp_bytes = json.dumps(response_payload, ensure_ascii=False).encode("utf-8")
|
|
1738
|
+
|
|
1739
|
+
self.send_response(200)
|
|
1740
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
1741
|
+
self.send_header("Content-Length", str(len(resp_bytes)))
|
|
1742
|
+
self.send_cors_headers()
|
|
1743
|
+
self.end_headers()
|
|
1744
|
+
self.wfile.write(resp_bytes)
|
|
1745
|
+
self._log_res(200, f"{safe_path} {action_tag}", (time.monotonic() - t0) * 1000, error_info=error_info)
|
|
1746
|
+
|
|
1747
|
+
def log_message(self, format, *args):
|
|
1748
|
+
# Silenciar logs ruidosos por defecto
|
|
1749
|
+
pass
|
|
1750
|
+
|
|
1751
|
+
def is_termux_environment() -> bool:
|
|
1752
|
+
"""Devuelve si el proceso se ejecuta dentro de la instalación de Termux."""
|
|
1753
|
+
termux_prefix = "/data/data/com.termux/files/usr"
|
|
1754
|
+
return bool(
|
|
1755
|
+
os.environ.get("TERMUX_VERSION")
|
|
1756
|
+
or os.environ.get("PREFIX", "").startswith(termux_prefix)
|
|
1757
|
+
or sys.prefix.startswith(termux_prefix)
|
|
1758
|
+
)
|
|
1759
|
+
|
|
1760
|
+
|
|
1761
|
+
def get_manual_browser_command(url: str) -> str | None:
|
|
1762
|
+
"""Devuelve un comando pegable para abrir la sesión cuando Termux no pudo hacerlo."""
|
|
1763
|
+
if is_termux_environment():
|
|
1764
|
+
return shlex.join(["termux-open-url", url])
|
|
1765
|
+
return None
|
|
1766
|
+
|
|
1767
|
+
|
|
1768
|
+
def get_termux_open_url_executable() -> str | None:
|
|
1769
|
+
"""Localiza termux-open-url incluso si el venv recibió un PATH incompleto."""
|
|
1770
|
+
if shutil.which("termux-open-url"):
|
|
1771
|
+
return "termux-open-url"
|
|
1772
|
+
|
|
1773
|
+
prefix = os.environ.get("PREFIX", "/data/data/com.termux/files/usr")
|
|
1774
|
+
executable = Path(prefix) / "bin" / "termux-open-url"
|
|
1775
|
+
if executable.is_file():
|
|
1776
|
+
return str(executable)
|
|
1777
|
+
return None
|
|
1778
|
+
|
|
1779
|
+
|
|
1780
|
+
def browser_launch_diagnostics(attempts: list[str]) -> str:
|
|
1781
|
+
"""Resume el entorno y los lanzadores comprobados sin exponer la URL de sesión."""
|
|
1782
|
+
termux_prefix = os.environ.get("PREFIX", "(no definido)")
|
|
1783
|
+
configured_path = os.environ.get("PATH", "(no definido)")
|
|
1784
|
+
termux_binary = Path(termux_prefix) / "bin" / "termux-open-url" if termux_prefix != "(no definido)" else None
|
|
1785
|
+
lines = [
|
|
1786
|
+
f"plataforma={sys.platform}",
|
|
1787
|
+
f"termux_detectado={is_termux_environment()}",
|
|
1788
|
+
f"TERMUX_VERSION={'definido' if os.environ.get('TERMUX_VERSION') else 'no definido'}",
|
|
1789
|
+
f"PREFIX={termux_prefix}",
|
|
1790
|
+
f"PATH={configured_path}",
|
|
1791
|
+
f"termux-open-url_en_PATH={shutil.which('termux-open-url') or 'no encontrado'}",
|
|
1792
|
+
f"termux-open-url_en_PREFIX={str(termux_binary) if termux_binary and termux_binary.is_file() else 'no encontrado'}",
|
|
1793
|
+
f"xdg-open={shutil.which('xdg-open') or 'no encontrado'}",
|
|
1794
|
+
f"gio={shutil.which('gio') or 'no encontrado'}",
|
|
1795
|
+
"intentos=" + ("; ".join(attempts) if attempts else "ninguno"),
|
|
1796
|
+
]
|
|
1797
|
+
return "\n ".join(lines)
|
|
1798
|
+
|
|
1799
|
+
|
|
1800
|
+
def open_browser(url: str) -> bool:
|
|
1801
|
+
"""
|
|
1802
|
+
Abre la URL en el navegador predeterminado del usuario respetando el entorno del sistema.
|
|
1803
|
+
En Termux usa termux-open-url para delegar la apertura en Android. En el resto de
|
|
1804
|
+
Linux prioriza xdg-open o gio para respetar el gestor de ventanas y mimeapps.list,
|
|
1805
|
+
desacoplando el proceso hijo para evitar ruidos en la terminal.
|
|
1806
|
+
"""
|
|
1807
|
+
attempts = []
|
|
1808
|
+
if is_termux_environment():
|
|
1809
|
+
termux_open_url = get_termux_open_url_executable()
|
|
1810
|
+
if not termux_open_url:
|
|
1811
|
+
raise FileNotFoundError(
|
|
1812
|
+
"Termux fue detectado, pero no se encontró termux-open-url.\n "
|
|
1813
|
+
+ browser_launch_diagnostics(["termux-open-url: no localizado"])
|
|
1814
|
+
)
|
|
1815
|
+
try:
|
|
1816
|
+
subprocess.Popen(
|
|
1817
|
+
[termux_open_url, url],
|
|
1818
|
+
stdout=subprocess.DEVNULL,
|
|
1819
|
+
stderr=subprocess.DEVNULL,
|
|
1820
|
+
start_new_session=True,
|
|
1821
|
+
)
|
|
1822
|
+
return True
|
|
1823
|
+
except OSError as error:
|
|
1824
|
+
raise RuntimeError(
|
|
1825
|
+
f"termux-open-url no pudo iniciarse: {type(error).__name__}: {error}\n "
|
|
1826
|
+
+ browser_launch_diagnostics(["termux-open-url: error al iniciar"])
|
|
1827
|
+
) from error
|
|
1828
|
+
elif sys.platform.startswith("linux"):
|
|
1829
|
+
for cmd in ("xdg-open", "gio"):
|
|
1830
|
+
if shutil.which(cmd):
|
|
1831
|
+
try:
|
|
1832
|
+
args = ["gio", "open", url] if cmd == "gio" else ["xdg-open", url]
|
|
1833
|
+
subprocess.Popen(
|
|
1834
|
+
args,
|
|
1835
|
+
stdout=subprocess.DEVNULL,
|
|
1836
|
+
stderr=subprocess.DEVNULL,
|
|
1837
|
+
start_new_session=True,
|
|
1838
|
+
)
|
|
1839
|
+
return True
|
|
1840
|
+
except OSError as error:
|
|
1841
|
+
attempts.append(f"{cmd}: {type(error).__name__}: {error}")
|
|
1842
|
+
else:
|
|
1843
|
+
attempts.append(f"{cmd}: no encontrado")
|
|
1844
|
+
elif sys.platform == "darwin":
|
|
1845
|
+
if shutil.which("open"):
|
|
1846
|
+
try:
|
|
1847
|
+
subprocess.Popen(
|
|
1848
|
+
["open", url],
|
|
1849
|
+
stdout=subprocess.DEVNULL,
|
|
1850
|
+
stderr=subprocess.DEVNULL,
|
|
1851
|
+
start_new_session=True,
|
|
1852
|
+
)
|
|
1853
|
+
return True
|
|
1854
|
+
except OSError as error:
|
|
1855
|
+
attempts.append(f"open: {type(error).__name__}: {error}")
|
|
1856
|
+
elif sys.platform == "win32":
|
|
1857
|
+
try:
|
|
1858
|
+
os.startfile(url)
|
|
1859
|
+
return True
|
|
1860
|
+
except OSError as error:
|
|
1861
|
+
attempts.append(f"os.startfile: {type(error).__name__}: {error}")
|
|
1862
|
+
|
|
1863
|
+
try:
|
|
1864
|
+
if webbrowser.open(url):
|
|
1865
|
+
return True
|
|
1866
|
+
except Exception as error:
|
|
1867
|
+
attempts.append(f"webbrowser: {type(error).__name__}: {error}")
|
|
1868
|
+
raise RuntimeError(
|
|
1869
|
+
"El navegador predeterminado rechazó la URL de ZeroChat.\n "
|
|
1870
|
+
+ browser_launch_diagnostics(attempts)
|
|
1871
|
+
) from error
|
|
1872
|
+
attempts.append("webbrowser: devolvió False")
|
|
1873
|
+
raise RuntimeError(
|
|
1874
|
+
"Ningún lanzador de navegador aceptó la URL de ZeroChat.\n "
|
|
1875
|
+
+ browser_launch_diagnostics(attempts)
|
|
1876
|
+
)
|
|
1877
|
+
|
|
1878
|
+
|
|
1879
|
+
# ==============================================================================
|
|
1880
|
+
# Punto de Entrada Principal (CLI)
|
|
1881
|
+
# ==============================================================================
|
|
1882
|
+
|
|
1883
|
+
def main():
|
|
1884
|
+
global ACTIVE_PORT, ACTIVE_HOST, SESSION_TOKEN, CONSOLE_CONTROL
|
|
1885
|
+
|
|
1886
|
+
parser = argparse.ArgumentParser(description=f"ZeroChat Local Server v{VERSION}")
|
|
1887
|
+
parser.add_argument("--port", type=int, default=int(os.environ.get("ZEROCHAT_PORT", DEFAULT_PORT)), help=f"Puerto de escucha (default: {DEFAULT_PORT})")
|
|
1888
|
+
parser.add_argument("--host", default=os.environ.get("ZEROCHAT_HOST", DEFAULT_HOST), help=f"Host de escucha (default: {DEFAULT_HOST})")
|
|
1889
|
+
parser.add_argument("--token", default=None, help="Fijar un token de sesión específico (opcional)")
|
|
1890
|
+
parser.add_argument("--ui-url", default=None, help="URL de la interfaz web a abrir (por defecto: interfaz local en desarrollo o GitHub Pages)")
|
|
1891
|
+
parser.add_argument("--no-browser", action="store_true", help="No abrir automáticamente el navegador")
|
|
1892
|
+
parser.add_argument("--no-exit-on-close", action="store_true", help="No detener el servidor automáticamente al cerrar el navegador")
|
|
1893
|
+
parser.add_argument("--no-venv", action="store_true", help="Omitir la comprobación/creación del venv ./zerochat")
|
|
1894
|
+
parser.add_argument("--test", action="store_true", help="Ejecutar autocomprobación interna de herramientas")
|
|
1895
|
+
parser.add_argument("--version", action="version", version=f"ZeroChat {VERSION}")
|
|
1896
|
+
args = parser.parse_args()
|
|
1897
|
+
|
|
1898
|
+
if args.test:
|
|
1899
|
+
print(f"[{time.strftime('%H:%M:%S')}] TEST list_directory {'ok' if json.loads(list_directory('.'))['success'] else 'error'}")
|
|
1900
|
+
print(f"[{time.strftime('%H:%M:%S')}] TEST read_file {'ok' if json.loads(read_file('package.json', max_lines=5))['success'] else 'error'}")
|
|
1901
|
+
print(f"[{time.strftime('%H:%M:%S')}] TEST execute_command {'ok' if json.loads(execute_command('echo hello'))['success'] else 'error'}")
|
|
1902
|
+
print(f"[{time.strftime('%H:%M:%S')}] TEST all local tools ready.")
|
|
1903
|
+
return
|
|
1904
|
+
|
|
1905
|
+
# 1. Asegurar entorno virtual ./zerochat solo cuando se ejecuta desde fuentes.
|
|
1906
|
+
if not args.no_venv:
|
|
1907
|
+
ensure_virtual_environment()
|
|
1908
|
+
|
|
1909
|
+
# 2. Detectar entorno de desarrollo y resolver URL de destino
|
|
1910
|
+
dev_root = get_dev_root()
|
|
1911
|
+
static_root = get_static_root()
|
|
1912
|
+
is_dev = dev_root is not None
|
|
1913
|
+
is_packaged = is_packaged_runtime()
|
|
1914
|
+
|
|
1915
|
+
# 3. Comprobar versión remota en segundo plano (solo fuera del entorno de desarrollo local)
|
|
1916
|
+
if not is_dev:
|
|
1917
|
+
threading.Thread(target=check_version, daemon=True).start()
|
|
1918
|
+
|
|
1919
|
+
ACTIVE_PORT = args.port
|
|
1920
|
+
ACTIVE_HOST = args.host
|
|
1921
|
+
if args.token:
|
|
1922
|
+
SESSION_TOKEN = args.token
|
|
1923
|
+
else:
|
|
1924
|
+
SESSION_TOKEN = get_daily_token()
|
|
1925
|
+
|
|
1926
|
+
server = ThreadingHTTPServer((ACTIVE_HOST, ACTIVE_PORT), ZeroChatServerHandler)
|
|
1927
|
+
|
|
1928
|
+
if args.ui_url:
|
|
1929
|
+
ui_url = args.ui_url
|
|
1930
|
+
elif static_root:
|
|
1931
|
+
ui_url = f"http://{ACTIVE_HOST}:{ACTIVE_PORT}/zerochat.html"
|
|
1932
|
+
else:
|
|
1933
|
+
ui_url = DEFAULT_UI_URL
|
|
1934
|
+
|
|
1935
|
+
browser_host = ACTIVE_HOST if ACTIVE_HOST in {"127.0.0.1", "localhost"} else "127.0.0.1"
|
|
1936
|
+
target_url = f"{ui_url}#{urlencode({'token': SESSION_TOKEN, 'host': browser_host, 'port': ACTIVE_PORT})}"
|
|
1937
|
+
exit_on_close = not args.no_exit_on_close
|
|
1938
|
+
|
|
1939
|
+
print("=" * 64)
|
|
1940
|
+
print(f" ZeroChat Local Server v{VERSION} (UI {UI_VERSION})")
|
|
1941
|
+
print(f" Directorio de trabajo : {Path.cwd()}")
|
|
1942
|
+
print(f" Entorno virtual : {get_venv_dir()}")
|
|
1943
|
+
if is_dev:
|
|
1944
|
+
print(f" Modo de ejecución : Desarrollo local ({dev_root})")
|
|
1945
|
+
elif is_packaged:
|
|
1946
|
+
print(" Modo de ejecución : Paquete PyPI (interfaz local)")
|
|
1947
|
+
else:
|
|
1948
|
+
print(f" Modo de ejecución : Producción (Web universal)")
|
|
1949
|
+
print(f" Servidor HTTP/SSE : http://{ACTIVE_HOST}:{ACTIVE_PORT}")
|
|
1950
|
+
print(" Token de sesión (diario): configurado")
|
|
1951
|
+
print(f" Destino Web : {ui_url}")
|
|
1952
|
+
if exit_on_close:
|
|
1953
|
+
print(f" Auto-cierre : Activado (al cerrar navegador)")
|
|
1954
|
+
else:
|
|
1955
|
+
print(f" Auto-cierre : Desactivado")
|
|
1956
|
+
print("=" * 64, flush=True)
|
|
1957
|
+
|
|
1958
|
+
CONSOLE_CONTROL = ConsoleControl(server, parser)
|
|
1959
|
+
CONSOLE_CONTROL.start()
|
|
1960
|
+
|
|
1961
|
+
if not args.no_browser:
|
|
1962
|
+
termux_detected = is_termux_environment()
|
|
1963
|
+
if termux_detected:
|
|
1964
|
+
console_log(f"[{time.strftime('%H:%M:%S')}] Termux detectado; se abrirá mediante termux-open-url.", flush=True)
|
|
1965
|
+
console_log(f"[{time.strftime('%H:%M:%S')}] Abriendo navegador en la interfaz configurada...", flush=True)
|
|
1966
|
+
try:
|
|
1967
|
+
if not open_browser(target_url):
|
|
1968
|
+
raise RuntimeError("El lanzador de navegador devolvió un resultado sin éxito.")
|
|
1969
|
+
except Exception as e:
|
|
1970
|
+
console_log(f"[{time.strftime('%H:%M:%S')}] No se pudo abrir el navegador automáticamente: {e}", flush=True)
|
|
1971
|
+
console_log(" Traza de diagnóstico:", flush=True)
|
|
1972
|
+
traceback.print_exc()
|
|
1973
|
+
manual_command = get_manual_browser_command(target_url)
|
|
1974
|
+
if manual_command:
|
|
1975
|
+
console_log(" Termux detectado. Prueba este comando exacto:", flush=True)
|
|
1976
|
+
console_log(f" {manual_command}", flush=True)
|
|
1977
|
+
|
|
1978
|
+
if exit_on_close:
|
|
1979
|
+
require_initial = not args.no_browser
|
|
1980
|
+
threading.Thread(
|
|
1981
|
+
target=heartbeat_watchdog,
|
|
1982
|
+
args=(server, DEFAULT_HEARTBEAT_GRACE, DEFAULT_HEARTBEAT_TIMEOUT, require_initial),
|
|
1983
|
+
daemon=True
|
|
1984
|
+
).start()
|
|
1985
|
+
|
|
1986
|
+
def shutdown(*_):
|
|
1987
|
+
console_log(f"\n[{time.strftime('%H:%M:%S')}] Deteniendo servidor ZeroChat...")
|
|
1988
|
+
stop_zerochat_server(server)
|
|
1989
|
+
|
|
1990
|
+
signal.signal(signal.SIGINT, shutdown)
|
|
1991
|
+
signal.signal(signal.SIGTERM, shutdown)
|
|
1992
|
+
|
|
1993
|
+
try:
|
|
1994
|
+
server.serve_forever()
|
|
1995
|
+
finally:
|
|
1996
|
+
stop_zerochat_server(server)
|
|
1997
|
+
server.server_close()
|
|
1998
|
+
if CONSOLE_CONTROL:
|
|
1999
|
+
CONSOLE_CONTROL.close()
|
|
2000
|
+
CONSOLE_CONTROL = None
|
|
2001
|
+
|
|
2002
|
+
|
|
2003
|
+
if __name__ == "__main__":
|
|
2004
|
+
main()
|