fix(a55): account connection fixes - antigravity windows/linux, ollama server discovery, version display, full settings
This commit is contained in:
parent
26f7d2ce73
commit
3e660c39bb
14 changed files with 566 additions and 83 deletions
|
|
@ -124,17 +124,57 @@ def discover_models(profile_id: str | None = None) -> dict[str, str]:
|
||||||
# отвечала «Please sign in to view available models» — при шести рабочих
|
# отвечала «Please sign in to view available models» — при шести рабочих
|
||||||
# OAuth-профилях. Список моделей поэтому был пуст всегда.
|
# OAuth-профилях. Список моделей поэтому был пуст всегда.
|
||||||
target_profile_id = profile_id
|
target_profile_id = profile_id
|
||||||
if not target_profile_id:
|
if target_profile_id:
|
||||||
try:
|
try:
|
||||||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||||
|
st = ProfileAuthManager.get_profile_status("antigravity", target_profile_id)
|
||||||
|
if not st.get("authenticated") and not ProfileAuthManager.load_profile_auth("antigravity", target_profile_id):
|
||||||
|
target_profile_id = None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not target_profile_id:
|
||||||
|
try:
|
||||||
|
from antigravity_provider.paths import get_hermes_home
|
||||||
|
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||||
|
from antigravity_provider.router.router_config import load_router_config
|
||||||
|
|
||||||
|
candidate_pids: list[str] = []
|
||||||
|
try:
|
||||||
|
cfg = load_router_config()
|
||||||
|
for pid, pcfg in cfg.profiles.items():
|
||||||
|
if pcfg.provider.lower() in ("antigravity", "google-antigravity") and pid not in candidate_pids:
|
||||||
|
candidate_pids.append(pid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
main_p = ProfileAuthManager.get_main_profile("antigravity")
|
main_p = ProfileAuthManager.get_main_profile("antigravity")
|
||||||
if main_p and ProfileAuthManager.load_profile_auth("antigravity", main_p):
|
if main_p and main_p not in candidate_pids:
|
||||||
target_profile_id = main_p
|
candidate_pids.append(main_p)
|
||||||
else:
|
|
||||||
for candidate in ["ag-orch-primary", "ag-w1", "ag-w2", "ag-w3", "ag-w4", "ag-w5"]:
|
standard_slots = (
|
||||||
if ProfileAuthManager.load_profile_auth("antigravity", candidate):
|
["ag-orch-primary", "ag-orch-fallback"]
|
||||||
target_profile_id = candidate
|
+ [f"ag-{i}" for i in range(1, 21)]
|
||||||
break
|
+ [f"ag-w{i}" for i in range(1, 11)]
|
||||||
|
)
|
||||||
|
for s in standard_slots:
|
||||||
|
if s not in candidate_pids:
|
||||||
|
candidate_pids.append(s)
|
||||||
|
|
||||||
|
try:
|
||||||
|
agy_dir = get_hermes_home() / "agy_profiles"
|
||||||
|
if agy_dir.is_dir():
|
||||||
|
for sub in sorted(agy_dir.iterdir()):
|
||||||
|
if sub.is_dir() and sub.name not in candidate_pids:
|
||||||
|
candidate_pids.append(sub.name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for candidate in candidate_pids:
|
||||||
|
st = ProfileAuthManager.get_profile_status("antigravity", candidate)
|
||||||
|
if st.get("authenticated") or ProfileAuthManager.load_profile_auth("antigravity", candidate):
|
||||||
|
target_profile_id = candidate
|
||||||
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
target_profile_id = None
|
target_profile_id = None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,12 +51,12 @@ def do_test_profile(provider: str, profile_id: str, timeout: float = 10.0, disco
|
||||||
return {'success': False, 'error': f"Профиль '{profile_id}' не найден"}
|
return {'success': False, 'error': f"Профиль '{profile_id}' не найден"}
|
||||||
|
|
||||||
status = ProfileAuthManager.get_profile_status(pcfg.provider, profile_id)
|
status = ProfileAuthManager.get_profile_status(pcfg.provider, profile_id)
|
||||||
if not status.get('authenticated'):
|
|
||||||
return {'success': False, 'error': 'Аккаунт не добавлен. Сначала выполните подключение.'}
|
|
||||||
|
|
||||||
if status.get('is_expired') or status.get('expired') or status.get('status') == 'EXPIRED':
|
if status.get('is_expired') or status.get('expired') or status.get('status') == 'EXPIRED':
|
||||||
return {'success': False, 'error': 'Авторизация истекла, требуется повторный вход.'}
|
return {'success': False, 'error': 'Авторизация истекла, требуется повторный вход.'}
|
||||||
|
|
||||||
|
if not status.get('authenticated'):
|
||||||
|
return {'success': False, 'error': 'Аккаунт не добавлен. Сначала выполните подключение.'}
|
||||||
|
|
||||||
candidates = discovered_models if discovered_models is not None else pcfg.preferred_models
|
candidates = discovered_models if discovered_models is not None else pcfg.preferred_models
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return {'success': False, 'error': 'Сервер отвечает, но доступных моделей для тестового запроса нет' if discovered_models == [] else 'Каталог моделей не получен; сначала запросите список моделей'}
|
return {'success': False, 'error': 'Сервер отвечает, но доступных моделей для тестового запроса нет' if discovered_models == [] else 'Каталог моделей не получен; сначала запросите список моделей'}
|
||||||
|
|
@ -753,6 +753,8 @@ class ActionExecutor:
|
||||||
return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'}
|
return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'}
|
||||||
existing_status = ProfileAuthManager.get_profile_status(prov_norm, slot) if slot else {}
|
existing_status = ProfileAuthManager.get_profile_status(prov_norm, slot) if slot else {}
|
||||||
if not token and prov_norm not in ('local', 'vllm', 'ollama') and not existing_status.get('authenticated'):
|
if not token and prov_norm not in ('local', 'vllm', 'ollama') and not existing_status.get('authenticated'):
|
||||||
|
if prov_norm in ('antigravity', 'google-antigravity', 'claude', 'anthropic'):
|
||||||
|
return {'ok': False, 'message': 'Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата.'}
|
||||||
return {'ok': False, 'message': 'Не указан API-ключ или не завершена авторизация'}
|
return {'ok': False, 'message': 'Не указан API-ключ или не завершена авторизация'}
|
||||||
validation = None
|
validation = None
|
||||||
if token or prov_norm in ('local', 'vllm', 'ollama'):
|
if token or prov_norm in ('local', 'vllm', 'ollama'):
|
||||||
|
|
@ -790,7 +792,7 @@ class ActionExecutor:
|
||||||
return {'ok': False, 'message': 'Не указан API-ключ для NVIDIA NIM'}
|
return {'ok': False, 'message': 'Не указан API-ключ для NVIDIA NIM'}
|
||||||
elif prov_norm in ('claude', 'anthropic'):
|
elif prov_norm in ('claude', 'anthropic'):
|
||||||
if not token:
|
if not token:
|
||||||
return {'ok': False, 'message': 'Не указан API-ключ для Claude'}
|
return {'ok': False, 'message': 'Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата.'}
|
||||||
elif prov_norm in ('opencode-go', 'opencode'):
|
elif prov_norm in ('opencode-go', 'opencode'):
|
||||||
if not token:
|
if not token:
|
||||||
return {'ok': False, 'message': 'Не указан API-ключ для OpenCode Go'}
|
return {'ok': False, 'message': 'Не указан API-ключ для OpenCode Go'}
|
||||||
|
|
@ -802,7 +804,7 @@ class ActionExecutor:
|
||||||
return {'ok': False, 'message': f'Не указан API-ключ для {prov_norm}'}
|
return {'ok': False, 'message': f'Не указан API-ключ для {prov_norm}'}
|
||||||
elif prov_norm in ('antigravity', 'google-antigravity'):
|
elif prov_norm in ('antigravity', 'google-antigravity'):
|
||||||
if not token and not is_authenticated:
|
if not token and not is_authenticated:
|
||||||
return {'ok': False, 'message': 'Не выполнена авторизация для Antigravity'}
|
return {'ok': False, 'message': 'Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата.'}
|
||||||
else:
|
else:
|
||||||
return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'}
|
return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from .local_adapter import LocalLLMAdapter
|
||||||
|
|
||||||
logger = logging.getLogger("hermes.router.adapter.ollama")
|
logger = logging.getLogger("hermes.router.adapter.ollama")
|
||||||
|
|
||||||
DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434/v1"
|
DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434"
|
||||||
DEFAULT_OLLAMA_MODELS = ["llama3:latest"]
|
DEFAULT_OLLAMA_MODELS = ["llama3:latest"]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -54,21 +54,24 @@ class OllamaAdapter(LocalLLMAdapter):
|
||||||
|
|
||||||
def _get_native_host(self, base_url: str) -> str:
|
def _get_native_host(self, base_url: str) -> str:
|
||||||
"""Strip trailing /v1 from base_url to get native Ollama host."""
|
"""Strip trailing /v1 from base_url to get native Ollama host."""
|
||||||
if base_url.endswith("/v1"):
|
b = base_url.rstrip("/")
|
||||||
return base_url[:-3]
|
if b.endswith("/v1"):
|
||||||
return base_url
|
return b[:-3].rstrip("/")
|
||||||
|
return b
|
||||||
|
|
||||||
def _get_chat_url(self, base_url: str) -> str:
|
def _get_chat_url(self, base_url: str) -> str:
|
||||||
"""Get standard chat completions endpoint URL."""
|
"""Get standard chat completions endpoint URL."""
|
||||||
if base_url.endswith("/v1"):
|
b = base_url.rstrip("/")
|
||||||
return f"{base_url}/chat/completions"
|
if b.endswith("/v1"):
|
||||||
return f"{base_url}/v1/chat/completions"
|
return f"{b}/chat/completions"
|
||||||
|
return f"{b}/v1/chat/completions"
|
||||||
|
|
||||||
def _get_models_url(self, base_url: str) -> str:
|
def _get_models_url(self, base_url: str) -> str:
|
||||||
"""Get OpenAI-compatible models endpoint URL."""
|
"""Get OpenAI-compatible models endpoint URL."""
|
||||||
if base_url.endswith("/v1"):
|
b = base_url.rstrip("/")
|
||||||
return f"{base_url}/models"
|
if b.endswith("/v1"):
|
||||||
return f"{base_url}/v1/models"
|
return f"{b}/models"
|
||||||
|
return f"{b}/v1/models"
|
||||||
|
|
||||||
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
|
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
base_url = self._resolve_base_url(profile)
|
base_url = self._resolve_base_url(profile)
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,14 @@ def validate_connection(provider, token="", base_url="", preferred_model=""):
|
||||||
message = f"Подключено и проверено. Получено моделей: {len(models)}" if models else "Сервер отвечает; моделей пока нет"
|
message = f"Подключено и проверено. Получено моделей: {len(models)}" if models else "Сервер отвечает; моделей пока нет"
|
||||||
return {"ok": True, "message": message, "data": {"models": models, "base_url": base_url}}
|
return {"ok": True, "message": message, "data": {"models": models, "base_url": base_url}}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
exc_str = str(exc)
|
||||||
|
exc_lower = exc_str.lower()
|
||||||
|
if provider == "ollama" and any(k in exc_lower for k in ("connection refused", "winerror 10061", "errno 111", "111", "refused", "failed to connect", "target machine actively refused")):
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"message": f"Не удалось подключиться к {base_url} (Connection refused). Если Ollama работает на сервере или другой машине, укажите её сетевой адрес (например, http://192.168.1.81:11434).",
|
||||||
|
"data": {"models": []},
|
||||||
|
}
|
||||||
if isinstance(exc, urllib.error.HTTPError):
|
if isinstance(exc, urllib.error.HTTPError):
|
||||||
reason = exc.reason or 'провайдер отклонил запрос'
|
reason = exc.reason or 'провайдер отклонил запрос'
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -369,9 +369,56 @@ class ModelDiscoveryService:
|
||||||
|
|
||||||
if prov in ("antigravity", "google-antigravity"):
|
if prov in ("antigravity", "google-antigravity"):
|
||||||
from antigravity_provider.agy_subprocess import discover_models
|
from antigravity_provider.agy_subprocess import discover_models
|
||||||
main_p = getattr(self._probe_context, "profile_id", None) or ProfileAuthManager.get_main_profile("antigravity") or "ag-orch-fallback"
|
from antigravity_provider.paths import get_hermes_home
|
||||||
|
from antigravity_provider.router.router_config import load_router_config
|
||||||
|
|
||||||
|
candidate_pids: List[str] = []
|
||||||
|
req_p = getattr(self._probe_context, "profile_id", None)
|
||||||
|
if req_p:
|
||||||
|
candidate_pids.append(req_p)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
res = discover_models(profile_id=main_p)
|
cfg = load_router_config()
|
||||||
|
for pid, pcfg in cfg.profiles.items():
|
||||||
|
if pcfg.provider.lower() in ("antigravity", "google-antigravity") and pid not in candidate_pids:
|
||||||
|
candidate_pids.append(pid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
main_p = ProfileAuthManager.get_main_profile("antigravity")
|
||||||
|
if main_p and main_p not in candidate_pids:
|
||||||
|
candidate_pids.append(main_p)
|
||||||
|
|
||||||
|
standard_slots = (
|
||||||
|
["ag-orch-primary", "ag-orch-fallback"]
|
||||||
|
+ [f"ag-{i}" for i in range(1, 21)]
|
||||||
|
+ [f"ag-w{i}" for i in range(1, 11)]
|
||||||
|
)
|
||||||
|
for s in standard_slots:
|
||||||
|
if s not in candidate_pids:
|
||||||
|
candidate_pids.append(s)
|
||||||
|
|
||||||
|
try:
|
||||||
|
agy_dir = get_hermes_home() / "agy_profiles"
|
||||||
|
if agy_dir.is_dir():
|
||||||
|
for sub in sorted(agy_dir.iterdir()):
|
||||||
|
if sub.is_dir() and sub.name not in candidate_pids:
|
||||||
|
candidate_pids.append(sub.name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
target_pid = None
|
||||||
|
for cand in candidate_pids:
|
||||||
|
st = ProfileAuthManager.get_profile_status("antigravity", cand)
|
||||||
|
if st.get("authenticated") or ProfileAuthManager.load_profile_auth("antigravity", cand):
|
||||||
|
target_pid = cand
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target_pid:
|
||||||
|
target_pid = candidate_pids[0] if candidate_pids else "ag-orch-fallback"
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = discover_models(profile_id=target_pid)
|
||||||
if res:
|
if res:
|
||||||
return sorted(list(set(res.values()))), None
|
return sorted(list(set(res.values()))), None
|
||||||
return None, "Модели Google Antigravity не обнаружены"
|
return None, "Модели Google Antigravity не обнаружены"
|
||||||
|
|
|
||||||
|
|
@ -423,6 +423,17 @@ class ProfileAuthManager:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Error reading %s: %s", auth_file, e)
|
logger.warning("Error reading %s: %s", auth_file, e)
|
||||||
|
|
||||||
|
if not auth_file.is_file() and provider in ("antigravity", "google-antigravity"):
|
||||||
|
pdir = get_profile_dir(profile_id, provider)
|
||||||
|
gemini_creds = pdir / ".gemini" / "oauth_creds.json"
|
||||||
|
if gemini_creds.is_file():
|
||||||
|
try:
|
||||||
|
data = json.loads(gemini_creds.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return {"provider": provider, "profile_id": profile_id, "token": data}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error reading %s: %s", gemini_creds, e)
|
||||||
|
|
||||||
# Fallbacks for specific providers
|
# Fallbacks for specific providers
|
||||||
if provider == "openai-codex":
|
if provider == "openai-codex":
|
||||||
env_var = f"CODEX_TOKEN_{profile_id.upper().replace('-', '_')}"
|
env_var = f"CODEX_TOKEN_{profile_id.upper().replace('-', '_')}"
|
||||||
|
|
@ -655,11 +666,24 @@ class ProfileAuthManager:
|
||||||
}
|
}
|
||||||
|
|
||||||
if provider in ("antigravity", "google-antigravity"):
|
if provider in ("antigravity", "google-antigravity"):
|
||||||
tokens = auth_data.get("token") or auth_data.get("tokens", {})
|
tokens = auth_data.get("token") or auth_data.get("tokens")
|
||||||
acc_token = tokens.get("access_token") if isinstance(tokens, dict) else (auth_data.get("access_token") or "")
|
if not isinstance(tokens, dict):
|
||||||
id_token = tokens.get("id_token") if isinstance(tokens, dict) else (auth_data.get("id_token") or "")
|
tokens = {}
|
||||||
refresh_tok = tokens.get("refresh_token") if isinstance(tokens, dict) else (auth_data.get("refresh_token") or "")
|
acc_token = tokens.get("access_token") or auth_data.get("access_token") or ""
|
||||||
email = auth_data.get("email")
|
id_token = tokens.get("id_token") or auth_data.get("id_token") or ""
|
||||||
|
refresh_tok = tokens.get("refresh_token") or auth_data.get("refresh_token") or ""
|
||||||
|
key = auth_data.get("api_key", "")
|
||||||
|
email = auth_data.get("email") or auth_data.get("user_email")
|
||||||
|
is_auth = bool(acc_token or refresh_tok or key or (email and auth_data.get("auth_method") == "oauth"))
|
||||||
|
if not is_auth:
|
||||||
|
return {
|
||||||
|
"authenticated": False,
|
||||||
|
"provider": provider,
|
||||||
|
"profile_id": profile_id,
|
||||||
|
"status": "NOT_CONFIGURED",
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
acc_id = None
|
acc_id = None
|
||||||
if id_token:
|
if id_token:
|
||||||
email_from_jwt, acc_id = cls.extract_jwt_identity(id_token)
|
email_from_jwt, acc_id = cls.extract_jwt_identity(id_token)
|
||||||
|
|
@ -668,11 +692,14 @@ class ProfileAuthManager:
|
||||||
email_from_jwt, acc_id = cls.extract_jwt_identity(acc_token)
|
email_from_jwt, acc_id = cls.extract_jwt_identity(acc_token)
|
||||||
email = email or email_from_jwt
|
email = email or email_from_jwt
|
||||||
|
|
||||||
expiry = tokens.get("expiry_date") if isinstance(tokens, dict) else auth_data.get("expiry_date")
|
expiry = (
|
||||||
if not expiry and isinstance(tokens, dict):
|
tokens.get("expiry_date")
|
||||||
expiry = tokens.get("expires_at")
|
or auth_data.get("expiry_date")
|
||||||
|
or tokens.get("expires_at")
|
||||||
|
or auth_data.get("expires_at")
|
||||||
|
)
|
||||||
if not expiry:
|
if not expiry:
|
||||||
expiry_str = tokens.get("expiry") if isinstance(tokens, dict) else auth_data.get("expiry")
|
expiry_str = tokens.get("expiry") or auth_data.get("expiry")
|
||||||
if expiry_str:
|
if expiry_str:
|
||||||
try:
|
try:
|
||||||
dt = datetime.fromisoformat(str(expiry_str).replace("Z", "+00:00"))
|
dt = datetime.fromisoformat(str(expiry_str).replace("Z", "+00:00"))
|
||||||
|
|
@ -688,7 +715,7 @@ class ProfileAuthManager:
|
||||||
is_expired = not bool(refresh_tok)
|
is_expired = not bool(refresh_tok)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"authenticated": True,
|
"authenticated": not is_expired,
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"profile_id": profile_id,
|
"profile_id": profile_id,
|
||||||
"email_masked": mask_email(email) if email else None,
|
"email_masked": mask_email(email) if email else None,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import time
|
||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, field, replace
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from antigravity_provider.version import __version__
|
||||||
|
|
||||||
from antigravity_provider.router.event_bus import (
|
from antigravity_provider.router.event_bus import (
|
||||||
EventBus,
|
EventBus,
|
||||||
EVENT_ACCOUNT_UPDATED,
|
EVENT_ACCOUNT_UPDATED,
|
||||||
|
|
@ -213,7 +215,9 @@ class HubStateStore:
|
||||||
hermes_cfg = {"exists": False, "model": None, "provider": None}
|
hermes_cfg = {"exists": False, "model": None, "provider": None}
|
||||||
default_role = "manager"
|
default_role = "manager"
|
||||||
|
|
||||||
|
from antigravity_provider.version import __version__
|
||||||
metrics = {
|
metrics = {
|
||||||
|
"version": __version__,
|
||||||
"generation": gen,
|
"generation": gen,
|
||||||
"seq": request_seq,
|
"seq": request_seq,
|
||||||
"duration_ms": round((time.time() - t0) * 1000, 2),
|
"duration_ms": round((time.time() - t0) * 1000, 2),
|
||||||
|
|
@ -311,6 +315,7 @@ class HubStateStore:
|
||||||
routing={},
|
routing={},
|
||||||
quotas={},
|
quotas={},
|
||||||
metrics={
|
metrics={
|
||||||
|
"version": __version__,
|
||||||
"generation": 0,
|
"generation": 0,
|
||||||
"seq": 0,
|
"seq": 0,
|
||||||
"telemetry": telemetry_data,
|
"telemetry": telemetry_data,
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,13 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)):
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
snap_dict["version"] = __version__
|
||||||
|
snap_dict["commit"] = get_installed_commit()
|
||||||
|
snap_dict["system_paths"] = {
|
||||||
|
"hermes_home": str(paths.get_hermes_home()),
|
||||||
|
"config_dir": str(paths.get_config_dir()),
|
||||||
|
"log_file": str(paths.get_log_file()),
|
||||||
|
}
|
||||||
return JSONResponse(content=jsonable_encoder(snap_dict))
|
return JSONResponse(content=jsonable_encoder(snap_dict))
|
||||||
|
|
||||||
@app.post("/api/action")
|
@app.post("/api/action")
|
||||||
|
|
@ -387,15 +394,21 @@ def get_settings(authorized: bool = Depends(get_auth_token)):
|
||||||
last_check = UpdateManager.get_last_check_result()
|
last_check = UpdateManager.get_last_check_result()
|
||||||
server_host = raw.get("web_api_host", "127.0.0.1")
|
server_host = raw.get("web_api_host", "127.0.0.1")
|
||||||
is_external = (server_host != "127.0.0.1" and server_host != "localhost")
|
is_external = (server_host != "127.0.0.1" and server_host != "localhost")
|
||||||
|
system_paths = {
|
||||||
|
"hermes_home": str(paths.get_hermes_home()),
|
||||||
|
"config_dir": str(paths.get_config_dir()),
|
||||||
|
"log_file": str(paths.get_log_file()),
|
||||||
|
}
|
||||||
settings_out: Dict[str, Any] = {
|
settings_out: Dict[str, Any] = {
|
||||||
|
"system_paths": system_paths,
|
||||||
"web_api_host": server_host,
|
"web_api_host": server_host,
|
||||||
"web_api_port": raw.get("web_api_port", 5800),
|
"web_api_port": raw.get("web_api_port", 5800),
|
||||||
"web_api_token_configured": has_token,
|
"web_api_token_configured": has_token,
|
||||||
"theme": raw.get("theme", "system"),
|
"theme": raw.get("theme", "system"),
|
||||||
"quota_refresh_interval_sec": raw.get("quota_refresh_interval_sec", 300),
|
"quota_refresh_interval_sec": raw.get("quota_refresh_interval_sec", raw.get("account_check_interval_seconds", 300)),
|
||||||
"hermes_home": str(paths.get_hermes_home()),
|
"hermes_home": system_paths["hermes_home"],
|
||||||
"config_dir": str(paths.get_config_dir()),
|
"config_dir": system_paths["config_dir"],
|
||||||
"log_file": str(paths.get_log_file()),
|
"log_file": system_paths["log_file"],
|
||||||
"installed_commit": get_installed_commit(),
|
"installed_commit": get_installed_commit(),
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"last_update_check": last_check.to_dict() if last_check else None,
|
"last_update_check": last_check.to_dict() if last_check else None,
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
initNavigation();
|
initNavigation();
|
||||||
initEventListeners();
|
initEventListeners();
|
||||||
initSettings();
|
initSettings();
|
||||||
|
fetchSettings();
|
||||||
fetchSnapshot();
|
fetchSnapshot();
|
||||||
startPolling();
|
startPolling();
|
||||||
checkUpdates(true);
|
checkUpdates(true);
|
||||||
|
|
@ -114,6 +115,10 @@ function switchView(viewName) {
|
||||||
fetchSkills();
|
fetchSkills();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (viewName === 'settings') {
|
||||||
|
fetchSettings();
|
||||||
|
}
|
||||||
|
|
||||||
if (currentSnapshot) {
|
if (currentSnapshot) {
|
||||||
renderCurrentView();
|
renderCurrentView();
|
||||||
}
|
}
|
||||||
|
|
@ -620,6 +625,12 @@ function updateGlobalHeader() {
|
||||||
if (kpiReadyRoles) kpiReadyRoles.textContent = `${readyRoles}/${totalRoles}`;
|
if (kpiReadyRoles) kpiReadyRoles.textContent = `${readyRoles}/${totalRoles}`;
|
||||||
if (kpiRolesSub) kpiRolesSub.textContent = `${readyRoles} из ${totalRoles} ролей маршрутизации активны`;
|
if (kpiRolesSub) kpiRolesSub.textContent = `${readyRoles} из ${totalRoles} ролей маршрутизации активны`;
|
||||||
if (kpiProvidersCount) kpiProvidersCount.textContent = (currentSnapshot.providers || []).length;
|
if (kpiProvidersCount) kpiProvidersCount.textContent = (currentSnapshot.providers || []).length;
|
||||||
|
|
||||||
|
const curVer = (currentSnapshot && (currentSnapshot.version || (currentSnapshot.metrics || {}).version)) || (currentSettings && currentSettings.version) || '';
|
||||||
|
const versionTag = document.getElementById('version-tag');
|
||||||
|
if (versionTag) {
|
||||||
|
versionTag.textContent = curVer ? `Hermes Hub Web v${curVer}` : 'Hermes Hub Web — Н/Д: версия не передана сервером';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── VIEW ROUTER ──
|
// ── VIEW ROUTER ──
|
||||||
|
|
@ -1616,86 +1627,166 @@ function renderLogsList(events) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SETTINGS MANAGEMENT ──
|
// ── SETTINGS MANAGEMENT ──
|
||||||
|
async function fetchSettings() {
|
||||||
|
try {
|
||||||
|
const headers = authToken ? { 'X-Hub-Token': authToken } : {};
|
||||||
|
const res = await fetch('/api/settings', { headers });
|
||||||
|
if (res.ok) {
|
||||||
|
currentSettings = await res.json();
|
||||||
|
if (activeView === 'settings') {
|
||||||
|
renderSettingsView();
|
||||||
|
}
|
||||||
|
return currentSettings;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch settings:', err);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function renderSettingsView() {
|
function renderSettingsView() {
|
||||||
if (!currentSnapshot) return;
|
const s = currentSettings || {};
|
||||||
const paths = {}; // HubSnapshot does not expose filesystem paths.
|
const sysPaths = s.system_paths || (currentSnapshot && currentSnapshot.system_paths) || {};
|
||||||
const s = currentSettings;
|
|
||||||
|
|
||||||
const elHome = document.getElementById('path-hermes-home');
|
const elHome = document.getElementById('path-hermes-home');
|
||||||
const elConfig = document.getElementById('path-config-dir');
|
const elConfig = document.getElementById('path-config-dir');
|
||||||
const elLog = document.getElementById('path-log-file');
|
const elLog = document.getElementById('path-log-file');
|
||||||
|
|
||||||
if (elHome) elHome.textContent = paths.hermes_home || 'Н/Д: API не передаёт путь';
|
const homePath = sysPaths.hermes_home || s.hermes_home;
|
||||||
if (elConfig) elConfig.textContent = paths.config_dir || 'Н/Д: API не передаёт путь';
|
const configPath = sysPaths.config_dir || s.config_dir;
|
||||||
if (elLog) elLog.textContent = paths.log_file || 'Н/Д: API не передаёт путь';
|
const logPath = sysPaths.log_file || s.log_file;
|
||||||
|
|
||||||
const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent');
|
if (elHome) elHome.textContent = homePath || 'Н/Д: путь не передан сервером';
|
||||||
const quotaActionSel = document.getElementById('setting-quota-threshold-action');
|
if (elConfig) elConfig.textContent = configPath || 'Н/Д: путь не передан сервером';
|
||||||
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
|
if (elLog) elLog.textContent = logPath || 'Н/Д: путь не передан сервером';
|
||||||
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
|
|
||||||
|
// Server Host & Port
|
||||||
|
const hostInput = document.getElementById('setting-server-host');
|
||||||
|
const portInput = document.getElementById('setting-server-port');
|
||||||
|
if (hostInput && s.web_api_host !== undefined) {
|
||||||
|
hostInput.value = s.web_api_host;
|
||||||
|
}
|
||||||
|
if (portInput && s.web_api_port !== undefined) {
|
||||||
|
portInput.value = s.web_api_port;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token Badge
|
||||||
|
const tokenBadge = document.getElementById('setting-token-status-badge');
|
||||||
|
if (tokenBadge) {
|
||||||
|
const isTokenSet = Boolean(s.web_api_token_configured || s.web_api_token);
|
||||||
|
if (isTokenSet) {
|
||||||
|
tokenBadge.textContent = '✓ Задан';
|
||||||
|
tokenBadge.className = 'badge healthy';
|
||||||
|
} else {
|
||||||
|
tokenBadge.textContent = 'Не задан';
|
||||||
|
tokenBadge.className = 'badge';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account Check Interval
|
||||||
const accountIntervalInput = document.getElementById('setting-account-check-interval');
|
const accountIntervalInput = document.getElementById('setting-account-check-interval');
|
||||||
const defaultRoleSel = document.getElementById('setting-default-role');
|
if (accountIntervalInput) {
|
||||||
|
const accVal = s.account_check_interval_seconds ?? s.account_interval;
|
||||||
|
if (accVal !== undefined && Number.isFinite(Number(accVal))) {
|
||||||
|
accountIntervalInput.value = accVal;
|
||||||
|
accountIntervalInput.disabled = false;
|
||||||
|
accountIntervalInput.placeholder = '300';
|
||||||
|
} else {
|
||||||
|
accountIntervalInput.placeholder = 'Н/Д: не передан сервером';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quota Interval
|
||||||
|
const quotaIntervalSel = document.getElementById('setting-quota-interval');
|
||||||
|
if (quotaIntervalSel) {
|
||||||
|
const qVal = s.quota_refresh_interval_sec ?? s.quota_interval ?? s.account_check_interval_seconds;
|
||||||
|
if (qVal !== undefined) {
|
||||||
|
quotaIntervalSel.value = String(qVal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quota Threshold Percent
|
||||||
|
const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent');
|
||||||
if (quotaThresholdSel && s.quota_threshold_percent !== undefined) {
|
if (quotaThresholdSel && s.quota_threshold_percent !== undefined) {
|
||||||
quotaThresholdSel.value = String(Math.round(s.quota_threshold_percent));
|
quotaThresholdSel.value = String(Math.round(s.quota_threshold_percent));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quota Threshold Action
|
||||||
|
const quotaActionSel = document.getElementById('setting-quota-threshold-action');
|
||||||
if (quotaActionSel && s.quota_threshold_action) {
|
if (quotaActionSel && s.quota_threshold_action) {
|
||||||
quotaActionSel.value = s.quota_threshold_action;
|
quotaActionSel.value = s.quota_threshold_action;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Email Masking Mode
|
||||||
|
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
|
||||||
if (emailMaskingSel && s.email_masking_mode) {
|
if (emailMaskingSel && s.email_masking_mode) {
|
||||||
emailMaskingSel.value = s.email_masking_mode;
|
emailMaskingSel.value = s.email_masking_mode;
|
||||||
}
|
}
|
||||||
if (accountIntervalInput && !accountIntervalInput.dataset.loaded) {
|
|
||||||
accountIntervalInput.dataset.loaded = 'loading';
|
// Default Role
|
||||||
fetch('/api/settings', {headers: authToken ? {'X-Hub-Token': authToken} : {}})
|
const defaultRoleSel = document.getElementById('setting-default-role');
|
||||||
.then(response => { if (!response.ok) throw new Error('Настройки недоступны'); return response.json(); })
|
if (defaultRoleSel) {
|
||||||
.then(settings => {
|
const currentDef = s.default_role || (currentSnapshot && currentSnapshot.metrics && currentSnapshot.metrics.default_role) || 'manager';
|
||||||
if (!Number.isFinite(Number(settings.account_check_interval_seconds))) throw new Error('Период не передан сервером');
|
defaultRoleSel.value = currentDef;
|
||||||
accountIntervalInput.value = settings.account_check_interval_seconds;
|
|
||||||
accountIntervalInput.disabled = false;
|
|
||||||
accountIntervalInput.dataset.loaded = 'yes';
|
|
||||||
}).catch(error => { accountIntervalInput.placeholder = 'Н/Д: ' + error.message; accountIntervalInput.dataset.loaded = ''; });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
const themeSel = document.getElementById('setting-theme');
|
||||||
|
if (themeSel && s.theme) {
|
||||||
|
themeSel.value = s.theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor Interval
|
||||||
|
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
|
||||||
if (monitorIntervalInput && s.monitoring_interval_seconds !== undefined) {
|
if (monitorIntervalInput && s.monitoring_interval_seconds !== undefined) {
|
||||||
monitorIntervalInput.value = s.monitoring_interval_seconds;
|
monitorIntervalInput.value = s.monitoring_interval_seconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Obsidian Vault Path
|
||||||
const vaultPathInput = document.getElementById('setting-obsidian-vault-path');
|
const vaultPathInput = document.getElementById('setting-obsidian-vault-path');
|
||||||
if (vaultPathInput) {
|
if (vaultPathInput) {
|
||||||
vaultPathInput.value = s.obsidian_vault_path || '/srv/projects/AI-Memory';
|
vaultPathInput.value = s.obsidian_vault_path || '/srv/projects/AI-Memory';
|
||||||
}
|
}
|
||||||
if (defaultRoleSel) {
|
|
||||||
const currentDef = s.default_role || currentSnapshot.metrics?.default_role || 'manager';
|
|
||||||
defaultRoleSel.value = currentDef;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveHubServerSettings() {
|
async function saveHubServerSettings() {
|
||||||
|
const hostInput = document.getElementById('setting-server-host');
|
||||||
|
const portInput = document.getElementById('setting-server-port');
|
||||||
|
const tokenInput = document.getElementById('setting-server-token-input');
|
||||||
const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent');
|
const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent');
|
||||||
const quotaActionSel = document.getElementById('setting-quota-threshold-action');
|
const quotaActionSel = document.getElementById('setting-quota-threshold-action');
|
||||||
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
|
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
|
||||||
|
const quotaIntervalSel = document.getElementById('setting-quota-interval');
|
||||||
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
|
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
|
||||||
const vaultPathInput = document.getElementById('setting-obsidian-vault-path');
|
const vaultPathInput = document.getElementById('setting-obsidian-vault-path');
|
||||||
const accountIntervalInput = document.getElementById('setting-account-check-interval');
|
const accountIntervalInput = document.getElementById('setting-account-check-interval');
|
||||||
const defaultRoleSel = document.getElementById('setting-default-role');
|
const defaultRoleSel = document.getElementById('setting-default-role');
|
||||||
|
const themeSel = document.getElementById('setting-theme');
|
||||||
|
|
||||||
const newSettings = {};
|
const newSettings = {};
|
||||||
if (accountIntervalInput?.value) newSettings.account_check_interval_seconds = Math.max(60, Number(accountIntervalInput.value));
|
if (hostInput && hostInput.value.trim()) newSettings.web_api_host = hostInput.value.trim();
|
||||||
if (quotaThresholdSel?.value) newSettings.quota_threshold_percent = Number(quotaThresholdSel.value);
|
if (portInput && portInput.value) newSettings.web_api_port = Number(portInput.value);
|
||||||
if (quotaActionSel?.value) newSettings.quota_threshold_action = quotaActionSel.value;
|
if (tokenInput && tokenInput.value.trim()) newSettings.web_api_token = tokenInput.value.trim();
|
||||||
if (emailMaskingSel?.value) newSettings.email_masking_mode = emailMaskingSel.value;
|
if (accountIntervalInput && accountIntervalInput.value) newSettings.account_check_interval_seconds = Math.max(60, Number(accountIntervalInput.value));
|
||||||
if (monitorIntervalInput?.value) newSettings.monitoring_interval_seconds = Number(monitorIntervalInput.value);
|
if (quotaIntervalSel && quotaIntervalSel.value) newSettings.quota_refresh_interval_sec = Number(quotaIntervalSel.value);
|
||||||
if (vaultPathInput?.value) newSettings.obsidian_vault_path = vaultPathInput.value.trim();
|
if (quotaThresholdSel && quotaThresholdSel.value) newSettings.quota_threshold_percent = Number(quotaThresholdSel.value);
|
||||||
if (defaultRoleSel?.value) newSettings.default_role = defaultRoleSel.value;
|
if (quotaActionSel && quotaActionSel.value) newSettings.quota_threshold_action = quotaActionSel.value;
|
||||||
|
if (emailMaskingSel && emailMaskingSel.value) newSettings.email_masking_mode = emailMaskingSel.value;
|
||||||
|
if (monitorIntervalInput && monitorIntervalInput.value) newSettings.monitoring_interval_seconds = Number(monitorIntervalInput.value);
|
||||||
|
if (vaultPathInput && vaultPathInput.value.trim()) newSettings.obsidian_vault_path = vaultPathInput.value.trim();
|
||||||
|
if (defaultRoleSel && defaultRoleSel.value) newSettings.default_role = defaultRoleSel.value;
|
||||||
|
if (themeSel && themeSel.value) newSettings.theme = themeSel.value;
|
||||||
|
|
||||||
if (!Object.keys(newSettings).length) { showToast('Нет выбранных изменений', 'info'); return; }
|
if (!Object.keys(newSettings).length) { showToast('Нет выбранных изменений', 'info'); return; }
|
||||||
|
|
||||||
showToast('Сохранение настроек сервера...', 'info');
|
showToast('Сохранение настроек сервера...', 'info');
|
||||||
const res = await executeAction('save_settings', newSettings);
|
const res = await executeAction('save_settings', newSettings);
|
||||||
if (res.ok) {
|
if (res && res.ok) {
|
||||||
showToast('Настройки сервера успешно сохранены', 'success');
|
showToast('Настройки сервера успешно сохранены', 'success');
|
||||||
|
await fetchSettings();
|
||||||
fetchSnapshot();
|
fetchSnapshot();
|
||||||
} else {
|
} else {
|
||||||
showToast(res.message || 'Ошибка сохранения настроек сервера', 'error');
|
showToast((res && res.message) || 'Ошибка сохранения настроек сервера', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2826,7 +2917,7 @@ function showWizardStep2(providerId) {
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom:10px;">
|
<div style="margin-bottom:10px;">
|
||||||
<label style="display:block; font-weight:600; margin-bottom:4px;">Слот, в который войти:</label>
|
<label style="display:block; font-weight:600; margin-bottom:4px;">Слот, в который войти:</label>
|
||||||
<select class="input-text" style="width:100%;" id="wiz-redirect-slot">${buildSlotOptions(providerId)}</select>
|
<select class="input-text" style="width:100%;" id="wiz-redirect-slot" onchange="startRedirectAuth('${escapeHtml(providerId)}')">${buildSlotOptions(providerId)}</select>
|
||||||
<div style="font-size:12px; color:var(--text-muted); margin-top:4px;">
|
<div style="font-size:12px; color:var(--text-muted); margin-top:4px;">
|
||||||
Вход в занятый слот заменит учётные данные, которые в нём сейчас.
|
Вход в занятый слот заменит учётные данные, которые в нём сейчас.
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2835,7 +2926,18 @@ function showWizardStep2(providerId) {
|
||||||
<button class="btn btn-primary btn-sm" onclick="startRedirectAuth('${escapeHtml(providerId)}')">Получить ссылку</button>
|
<button class="btn btn-primary btn-sm" onclick="startRedirectAuth('${escapeHtml(providerId)}')">Получить ссылку</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="redirect-auth-box" style="background:var(--surface-muted); padding:14px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle);">
|
<div id="redirect-auth-box" style="background:var(--surface-muted); padding:14px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle);">
|
||||||
<div style="color:var(--text-secondary);">Выберите слот и нажмите «Получить ссылку».</div>
|
<div style="font-weight:700; margin-bottom:6px;">1. Откройте ссылку:</div>
|
||||||
|
<div style="display:flex; gap:8px; margin-bottom:12px;">
|
||||||
|
<input type="text" class="input-text" style="flex:1;" id="wiz-redirect-url" placeholder="Получение ссылки авторизации…" readonly>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="window.open(document.getElementById('wiz-redirect-url').value, '_blank')">Открыть</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(document.getElementById('wiz-redirect-url').value, 'Ссылка скопирована')">Копировать</button>
|
||||||
|
</div>
|
||||||
|
<div style="font-weight:700; margin-bottom:6px;">2. Вставьте адрес из браузера или код:</div>
|
||||||
|
<div style="display:flex; gap:8px; margin-bottom:6px;">
|
||||||
|
<input type="text" class="input-text" style="flex:1;" id="wiz-redirect-paste" placeholder="http://127.0.0.1:…/oauth-callback?code=…">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="submitRedirectCallback()">Завершить вход</button>
|
||||||
|
</div>
|
||||||
|
<div id="redirect-auth-status" style="font-size:12px; color:var(--text-muted); margin-top:10px;"></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
footerHtml = `
|
footerHtml = `
|
||||||
|
|
@ -2850,10 +2952,14 @@ function showWizardStep2(providerId) {
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom:12px;">
|
<div style="margin-bottom:12px;">
|
||||||
<label style="display:block; font-weight:600; margin-bottom:4px;">URL сервера (Base URL):</label>
|
<label style="display:block; font-weight:600; margin-bottom:4px;">URL сервера (Base URL):</label>
|
||||||
<input type="text" class="input-text" style="width:100%;" id="wiz-base-url-input" placeholder="http://127.0.0.1:11434/v1" value="http://127.0.0.1:11434/v1">
|
<div style="display:flex; gap:8px;">
|
||||||
|
<input type="text" class="input-text" style="flex:1;" id="wiz-base-url-input" placeholder="http://127.0.0.1:11434" value="http://127.0.0.1:11434">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="wiz-test-ollama-btn" onclick="testOllamaConnection()">Проверить адрес</button>
|
||||||
|
</div>
|
||||||
|
<div id="wiz-ollama-check-result" style="margin-top:6px; font-size:12px;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom:10px; font-size:12px; color:var(--text-muted);">
|
<div style="margin-bottom:10px; font-size:12px; color:var(--text-muted);">
|
||||||
Поиск серверов выполняется на машине, где запущен Hub (не в браузере).
|
По умолчанию указан адрес на машине с Hub (127.0.0.1:11434). Если Ollama работает на сервере или другом компьютере (например, http://192.168.1.81:11434), укажите его сетевой адрес.
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom:12px;">
|
<div style="margin-bottom:12px;">
|
||||||
<button class="btn btn-secondary" style="width:100%;" id="wiz-discover-btn" onclick="discoverLocalServers('discover_local_models')" data-action="discover_local_models">🔍 Найти на этом компьютере</button>
|
<button class="btn btn-secondary" style="width:100%;" id="wiz-discover-btn" onclick="discoverLocalServers('discover_local_models')" data-action="discover_local_models">🔍 Найти на этом компьютере</button>
|
||||||
|
|
@ -2948,6 +3054,42 @@ function showWizardStep2(providerId) {
|
||||||
${bodyHtml}
|
${bodyHtml}
|
||||||
`;
|
`;
|
||||||
elements.modalFooter.innerHTML = footerHtml;
|
elements.modalFooter.innerHTML = footerHtml;
|
||||||
|
if (providerId === 'antigravity' || providerId === 'claude') {
|
||||||
|
startRedirectAuth(providerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testOllamaConnection() {
|
||||||
|
const urlInput = document.getElementById('wiz-base-url-input');
|
||||||
|
const tokenInput = document.getElementById('wiz-token-input');
|
||||||
|
const resultEl = document.getElementById('wiz-ollama-check-result');
|
||||||
|
const btn = document.getElementById('wiz-test-ollama-btn');
|
||||||
|
if (!urlInput || !resultEl) return;
|
||||||
|
const baseUrl = (urlInput.value || '').trim() || 'http://127.0.0.1:11434';
|
||||||
|
const token = tokenInput ? tokenInput.value.trim() : '';
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = '⏳ Проверка…'; }
|
||||||
|
resultEl.innerHTML = '<span style="color:var(--text-secondary);">Проверяем подключение…</span>';
|
||||||
|
try {
|
||||||
|
const res = await executeAction('validate_connection', {
|
||||||
|
provider: 'ollama',
|
||||||
|
base_url: baseUrl,
|
||||||
|
token: token,
|
||||||
|
});
|
||||||
|
if (res && res.ok) {
|
||||||
|
const models = (res.data && res.data.models) || [];
|
||||||
|
window._wiz_models = models;
|
||||||
|
window._wiz_base_url = baseUrl;
|
||||||
|
resultEl.innerHTML = `<span style="color:var(--status-healthy); font-weight:600;">✓ Подключение успешно. Моделей: ${models.length}</span>`;
|
||||||
|
showToast('Ollama подключена успешно', 'success');
|
||||||
|
} else {
|
||||||
|
const errMsg = (res && res.message) || 'Не удалось подключиться к Ollama';
|
||||||
|
resultEl.innerHTML = `<span style="color:var(--status-error);">${escapeHtml(errMsg)}</span>`;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
resultEl.innerHTML = `<span style="color:var(--status-error);">Ошибка: ${escapeHtml(String(err))}</span>`;
|
||||||
|
} finally {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Проверить адрес'; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function proceedToWizardStep3(providerId) {
|
async function proceedToWizardStep3(providerId) {
|
||||||
|
|
@ -2968,11 +3110,14 @@ async function proceedToWizardStep3(providerId) {
|
||||||
// Only read slot elements for providers that have them (grok/openai-codex have wiz-device-slot,
|
// Only read slot elements for providers that have them (grok/openai-codex have wiz-device-slot,
|
||||||
// antigravity/claude/openrouter/nvidia have wiz-redirect-slot). Local providers have no slot elements.
|
// antigravity/claude/openrouter/nvidia have wiz-redirect-slot). Local providers have no slot elements.
|
||||||
const isDeviceAuthFlow = providerId === 'grok' || providerId === 'openai-codex';
|
const isDeviceAuthFlow = providerId === 'grok' || providerId === 'openai-codex';
|
||||||
const isRedirectAuthFlow = providerId === 'antigravity' || providerId === 'claude' || providerId === 'openrouter' || providerId === 'nvidia';
|
const isRedirectAuthFlow = providerId === 'antigravity' || providerId === 'claude';
|
||||||
if (isDeviceAuthFlow) {
|
if (isDeviceAuthFlow) {
|
||||||
const deviceSlot = document.getElementById('wiz-device-slot');
|
const deviceSlot = document.getElementById('wiz-device-slot');
|
||||||
window._wiz_device_profile = deviceSlot?.value || '';
|
window._wiz_device_profile = window._wiz_device_profile || deviceSlot?.value || '';
|
||||||
} else if (isRedirectAuthFlow) {
|
} else if (isRedirectAuthFlow) {
|
||||||
|
const redirectSlot = document.getElementById('wiz-redirect-slot');
|
||||||
|
window._wiz_device_profile = window._wiz_redirect_slot_id || window._wiz_device_profile || redirectSlot?.value || '';
|
||||||
|
} else if (providerId === 'openrouter' || providerId === 'nvidia') {
|
||||||
const redirectSlot = document.getElementById('wiz-redirect-slot');
|
const redirectSlot = document.getElementById('wiz-redirect-slot');
|
||||||
window._wiz_device_profile = redirectSlot?.value || '';
|
window._wiz_device_profile = redirectSlot?.value || '';
|
||||||
}
|
}
|
||||||
|
|
@ -3055,6 +3200,8 @@ async function finishAddAccount(providerId) {
|
||||||
let selectedProfileId;
|
let selectedProfileId;
|
||||||
if (isLocalProvider) {
|
if (isLocalProvider) {
|
||||||
selectedProfileId = '';
|
selectedProfileId = '';
|
||||||
|
} else if (providerId === 'antigravity' || providerId === 'claude') {
|
||||||
|
selectedProfileId = window._wiz_redirect_slot_id || window._wiz_device_profile || document.getElementById('wiz-redirect-slot')?.value || '';
|
||||||
} else {
|
} else {
|
||||||
const deviceSlot = document.getElementById('wiz-device-slot');
|
const deviceSlot = document.getElementById('wiz-device-slot');
|
||||||
const redirectSlot = document.getElementById('wiz-redirect-slot');
|
const redirectSlot = document.getElementById('wiz-redirect-slot');
|
||||||
|
|
@ -3278,6 +3425,7 @@ async function startRedirectAuth(providerId) {
|
||||||
window._wiz_redirect_session = d.session_id;
|
window._wiz_redirect_session = d.session_id;
|
||||||
window._wiz_redirect_provider = providerId;
|
window._wiz_redirect_provider = providerId;
|
||||||
window._wiz_redirect_slot_id = d.profile_id;
|
window._wiz_redirect_slot_id = d.profile_id;
|
||||||
|
window._wiz_device_profile = d.profile_id;
|
||||||
|
|
||||||
const pastesUrl = d.paste_kind !== 'code';
|
const pastesUrl = d.paste_kind !== 'code';
|
||||||
const label = pastesUrl
|
const label = pastesUrl
|
||||||
|
|
@ -3363,6 +3511,7 @@ async function submitRedirectCallback() {
|
||||||
|
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
stopRedirectAuthPolling();
|
stopRedirectAuthPolling();
|
||||||
|
window._wiz_device_profile = window._wiz_redirect_slot_id;
|
||||||
status.innerHTML = '<span style="color:var(--status-healthy); font-weight:600;">Аккаунт подключён</span>' + redirectSlotRoleNote();
|
status.innerHTML = '<span style="color:var(--status-healthy); font-weight:600;">Аккаунт подключён</span>' + redirectSlotRoleNote();
|
||||||
showToast('Аккаунт подключён', 'success');
|
showToast('Аккаунт подключён', 'success');
|
||||||
fetchSnapshot();
|
fetchSnapshot();
|
||||||
|
|
@ -3405,6 +3554,7 @@ async function pollRedirectAuth() {
|
||||||
|
|
||||||
if (res.ok && (res.data || {}).status === 'completed') {
|
if (res.ok && (res.data || {}).status === 'completed') {
|
||||||
stopRedirectAuthPolling();
|
stopRedirectAuthPolling();
|
||||||
|
window._wiz_device_profile = window._wiz_redirect_slot_id;
|
||||||
status.innerHTML = '<span style="color:var(--status-healthy); font-weight:600;">Аккаунт подключён</span>';
|
status.innerHTML = '<span style="color:var(--status-healthy); font-weight:600;">Аккаунт подключён</span>';
|
||||||
showToast('Аккаунт подключён', 'success');
|
showToast('Аккаунт подключён', 'success');
|
||||||
fetchSnapshot();
|
fetchSnapshot();
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.1.2"
|
__version__ = "0.1.2"
|
||||||
VERSION_INFO = (0, 1, 1)
|
VERSION_INFO = (0, 1, 2)
|
||||||
CHANNEL = "stable"
|
CHANNEL = "stable"
|
||||||
MINIMUM_HERMES_VERSION = "0.20.0"
|
MINIMUM_HERMES_VERSION = "0.20.0"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,7 @@ def test_p0_1_add_account_honest_rejections():
|
||||||
# Missing API key for claude
|
# Missing API key for claude
|
||||||
res_cl = ActionExecutor.execute("add_account", {"provider": "claude", "token": ""})
|
res_cl = ActionExecutor.execute("add_account", {"provider": "claude", "token": ""})
|
||||||
assert res_cl["ok"] is False
|
assert res_cl["ok"] is False
|
||||||
assert "API-ключ" in res_cl["message"]
|
assert "Авторизация через браузер не завершена" in res_cl["message"] or "API-ключ" in res_cl["message"]
|
||||||
|
|
||||||
# Unsupported provider
|
# Unsupported provider
|
||||||
res_unsupp = ActionExecutor.execute("add_account", {"provider": "unknown_provider_xyz"})
|
res_unsupp = ActionExecutor.execute("add_account", {"provider": "unknown_provider_xyz"})
|
||||||
|
|
|
||||||
188
tests/test_a55_account_connection.py
Normal file
188
tests/test_a55_account_connection.py
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
"""Tests for Task A55: Account Connection Defect Fixes (P0-1 through P0-5)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from antigravity_provider.version import __version__, VERSION_INFO
|
||||||
|
from antigravity_provider.router.action_handler import ActionExecutor
|
||||||
|
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||||
|
from antigravity_provider.router.adapters.ollama_adapter import OllamaAdapter, DEFAULT_OLLAMA_BASE_URL
|
||||||
|
from antigravity_provider.router.connection_preflight import validate_connection
|
||||||
|
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
|
||||||
|
from antigravity_provider.router.web.server import app
|
||||||
|
|
||||||
|
|
||||||
|
# ── P0-1: Antigravity & Browser Auth Tests ──
|
||||||
|
|
||||||
|
def test_p0_1_add_account_unauthenticated_browser_auth_error_message():
|
||||||
|
"""add_account should return Russian browser auth message for antigravity and claude."""
|
||||||
|
with patch.object(ProfileAuthManager, "get_profile_status", return_value={"authenticated": False}):
|
||||||
|
# Antigravity without token/completed auth
|
||||||
|
res_ag = ActionExecutor.execute("add_account", {
|
||||||
|
"provider": "antigravity",
|
||||||
|
"profile_id": "ag-1",
|
||||||
|
"token": "",
|
||||||
|
})
|
||||||
|
assert not res_ag["ok"]
|
||||||
|
assert "Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата." in res_ag["message"]
|
||||||
|
|
||||||
|
# Claude without token/completed auth
|
||||||
|
res_cl = ActionExecutor.execute("add_account", {
|
||||||
|
"provider": "claude",
|
||||||
|
"profile_id": "claude-1",
|
||||||
|
"token": "",
|
||||||
|
})
|
||||||
|
assert not res_cl["ok"]
|
||||||
|
assert "Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата." in res_cl["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_p0_1_profile_auth_manager_get_profile_status_antigravity():
|
||||||
|
"""get_profile_status should return authenticated: False when auth_data is empty or invalid."""
|
||||||
|
# Unauthenticated/empty auth_data
|
||||||
|
with patch.object(ProfileAuthManager, "load_profile_auth", return_value={}):
|
||||||
|
st = ProfileAuthManager.get_profile_status("antigravity", "ag-1")
|
||||||
|
assert st["authenticated"] is False
|
||||||
|
assert st["status"] == "NOT_CONFIGURED"
|
||||||
|
|
||||||
|
# Authenticated with access_token
|
||||||
|
with patch.object(ProfileAuthManager, "load_profile_auth", return_value={"token": {"access_token": "ya29.valid-token"}}):
|
||||||
|
st = ProfileAuthManager.get_profile_status("antigravity", "ag-1")
|
||||||
|
assert st["authenticated"] is True
|
||||||
|
assert st["status"] == "AUTHENTICATED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_p0_1_load_profile_auth_gemini_oauth_creds_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
"""load_profile_auth should check .gemini/oauth_creds.json if auth.json is missing."""
|
||||||
|
monkeypatch.setattr("antigravity_provider.paths.get_hermes_home", lambda: tmp_path)
|
||||||
|
pdir = tmp_path / "agy_profiles" / "ag-5"
|
||||||
|
gemini_dir = pdir / ".gemini"
|
||||||
|
gemini_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
creds_file = gemini_dir / "oauth_creds.json"
|
||||||
|
creds_file.write_text(json.dumps({"access_token": "token-123", "refresh_token": "refresh-456"}), encoding="utf-8")
|
||||||
|
|
||||||
|
auth = ProfileAuthManager.load_profile_auth("antigravity", "ag-5")
|
||||||
|
assert auth is not None
|
||||||
|
assert auth.get("provider") == "antigravity"
|
||||||
|
assert auth.get("token", {}).get("access_token") == "token-123"
|
||||||
|
|
||||||
|
|
||||||
|
# ── P0-2: Ollama Base URL & Connection Error Tests ──
|
||||||
|
|
||||||
|
def test_p0_2_ollama_default_base_url():
|
||||||
|
"""DEFAULT_OLLAMA_BASE_URL must be http://127.0.0.1:11434 (without trailing /v1)."""
|
||||||
|
assert DEFAULT_OLLAMA_BASE_URL == "http://127.0.0.1:11434"
|
||||||
|
|
||||||
|
|
||||||
|
def test_p0_2_ollama_adapter_url_builders():
|
||||||
|
"""OllamaAdapter URL methods should handle base_urls with or without /v1 cleanly."""
|
||||||
|
adapter = OllamaAdapter()
|
||||||
|
|
||||||
|
# Standard base url without /v1
|
||||||
|
assert adapter._get_native_host("http://127.0.0.1:11434") == "http://127.0.0.1:11434"
|
||||||
|
assert adapter._get_chat_url("http://127.0.0.1:11434") == "http://127.0.0.1:11434/v1/chat/completions"
|
||||||
|
assert adapter._get_models_url("http://127.0.0.1:11434") == "http://127.0.0.1:11434/v1/models"
|
||||||
|
|
||||||
|
# Base url with /v1
|
||||||
|
assert adapter._get_native_host("http://192.168.1.81:11434/v1") == "http://192.168.1.81:11434"
|
||||||
|
assert adapter._get_chat_url("http://192.168.1.81:11434/v1") == "http://192.168.1.81:11434/v1/chat/completions"
|
||||||
|
assert adapter._get_models_url("http://192.168.1.81:11434/v1") == "http://192.168.1.81:11434/v1/models"
|
||||||
|
|
||||||
|
# Base url with trailing slash
|
||||||
|
assert adapter._get_native_host("http://192.168.1.81:11434/") == "http://192.168.1.81:11434"
|
||||||
|
assert adapter._get_chat_url("http://192.168.1.81:11434/") == "http://192.168.1.81:11434/v1/chat/completions"
|
||||||
|
|
||||||
|
|
||||||
|
def test_p0_2_ollama_connection_refused_helpful_message():
|
||||||
|
"""Ollama connection refused error should provide network IP guidance."""
|
||||||
|
# Test connection preflight when endpoint is down
|
||||||
|
res = validate_connection("ollama", token="", base_url="http://127.0.0.1:11434")
|
||||||
|
assert not res["ok"]
|
||||||
|
assert "Не удалось подключиться к http://127.0.0.1:11434 (Connection refused)" in res["message"]
|
||||||
|
assert "http://192.168.1.81:11434" in res["message"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── P0-3: Antigravity Dynamic Model Discovery Tests ──
|
||||||
|
|
||||||
|
def test_p0_3_antigravity_dynamic_model_discovery():
|
||||||
|
"""ModelDiscoveryService probe should find and query any authenticated Antigravity profile."""
|
||||||
|
discovery = ModelDiscoveryService.get()
|
||||||
|
|
||||||
|
with patch("antigravity_provider.router.profile_manager.ProfileAuthManager.get_profile_status") as mock_st, \
|
||||||
|
patch("antigravity_provider.agy_subprocess.discover_models", return_value={"gemini-2.5-pro": "gemini-2.5-pro"}) as mock_disc:
|
||||||
|
|
||||||
|
# Simulate ag-3 being authenticated
|
||||||
|
def status_side_effect(prov, pid):
|
||||||
|
if pid == "ag-3":
|
||||||
|
return {"authenticated": True, "provider": prov, "profile_id": pid}
|
||||||
|
return {"authenticated": False, "provider": prov, "profile_id": pid}
|
||||||
|
|
||||||
|
mock_st.side_effect = status_side_effect
|
||||||
|
|
||||||
|
models, err = discovery._probe_provider("antigravity")
|
||||||
|
assert err is None
|
||||||
|
assert models == ["gemini-2.5-pro"]
|
||||||
|
mock_disc.assert_called_with(profile_id="ag-3")
|
||||||
|
|
||||||
|
|
||||||
|
# ── P0-4: Version Info & API Propagation Tests ──
|
||||||
|
|
||||||
|
def test_p0_4_version_single_source_of_truth():
|
||||||
|
"""Version must be 0.1.2 and VERSION_INFO must be (0, 1, 2)."""
|
||||||
|
assert __version__ == "0.1.2"
|
||||||
|
assert VERSION_INFO == (0, 1, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_p0_4_version_in_api_endpoints():
|
||||||
|
"""API endpoints /api/snapshot, /api/health, and /api/settings must return version 0.1.2."""
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
# /api/health
|
||||||
|
res_health = client.get("/api/health")
|
||||||
|
assert res_health.status_code == 200
|
||||||
|
assert res_health.json().get("version") == "0.1.2"
|
||||||
|
|
||||||
|
# /api/settings
|
||||||
|
res_settings = client.get("/api/settings")
|
||||||
|
assert res_settings.status_code == 200
|
||||||
|
assert res_settings.json().get("version") == "0.1.2"
|
||||||
|
|
||||||
|
# /api/snapshot
|
||||||
|
res_snap = client.get("/api/snapshot")
|
||||||
|
assert res_snap.status_code == 200
|
||||||
|
snap = res_snap.json()
|
||||||
|
assert snap.get("version") == "0.1.2"
|
||||||
|
assert (snap.get("metrics") or {}).get("version") == "0.1.2"
|
||||||
|
|
||||||
|
|
||||||
|
# ── P0-5: Settings View & System Paths Tests ──
|
||||||
|
|
||||||
|
def test_p0_5_settings_api_returns_system_paths_and_hub_settings():
|
||||||
|
"""GET /api/settings must return system_paths, server host/port, token status, and hub settings."""
|
||||||
|
client = TestClient(app)
|
||||||
|
res = client.get("/api/settings")
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
|
||||||
|
# System paths dict
|
||||||
|
assert "system_paths" in data
|
||||||
|
sys_paths = data["system_paths"]
|
||||||
|
assert "hermes_home" in sys_paths
|
||||||
|
assert "config_dir" in sys_paths
|
||||||
|
assert "log_file" in sys_paths
|
||||||
|
|
||||||
|
# Server settings & tokens
|
||||||
|
assert "web_api_host" in data
|
||||||
|
assert "web_api_port" in data
|
||||||
|
assert "web_api_token_configured" in data
|
||||||
|
|
||||||
|
# Snapshot includes system_paths
|
||||||
|
res_snap = client.get("/api/snapshot")
|
||||||
|
assert res_snap.status_code == 200
|
||||||
|
snap = res_snap.json()
|
||||||
|
assert "system_paths" in snap
|
||||||
|
assert snap["system_paths"]["hermes_home"] == sys_paths["hermes_home"]
|
||||||
|
|
@ -86,7 +86,7 @@ def test_ollama_invoke_default_base_url():
|
||||||
|
|
||||||
assert resp["choices"][0]["message"]["content"] == "pong"
|
assert resp["choices"][0]["message"]["content"] == "pong"
|
||||||
req_arg = mock_urlopen.call_args[0][0]
|
req_arg = mock_urlopen.call_args[0][0]
|
||||||
assert req_arg.get_full_url() == f"{DEFAULT_OLLAMA_BASE_URL}/chat/completions"
|
assert req_arg.get_full_url() == f"{DEFAULT_OLLAMA_BASE_URL}/v1/chat/completions"
|
||||||
assert req_arg.get_method() == "POST"
|
assert req_arg.get_method() == "POST"
|
||||||
assert "Authorization" not in req_arg.headers
|
assert "Authorization" not in req_arg.headers
|
||||||
|
|
||||||
|
|
|
||||||
2
uv.lock
2
uv.lock
|
|
@ -297,7 +297,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hermes-hub"
|
name = "hermes-hub"
|
||||||
version = "0.1.1"
|
version = "0.1.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue