fix: repair account setup and routing actions

This commit is contained in:
Hermes Team 2026-08-22 09:40:14 +07:00
parent 4c45a1c73c
commit f0461bc539
5 changed files with 184 additions and 29 deletions

View file

@ -109,7 +109,7 @@ def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]:
def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]: def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
"""Strictly tests stored credentials WITHOUT triggering OAuth or opening browsers.""" """Check local profile readiness without inference, OAuth, or a browser."""
config = load_router_config() config = load_router_config()
pcfg = config.get_profile(profile_id) pcfg = config.get_profile(profile_id)
if not pcfg: if not pcfg:
@ -119,22 +119,30 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
if not status.get("authenticated"): if not status.get("authenticated"):
return {"success": False, "error": "Аккаунт не добавлен. Сначала выполните подключение."} return {"success": False, "error": "Аккаунт не добавлен. Сначала выполните подключение."}
adapter = get_adapter(pcfg.provider)
model = pcfg.preferred_models[0] if pcfg.preferred_models else "default" model = pcfg.preferred_models[0] if pcfg.preferred_models else "default"
t0 = time.time() t0 = time.time()
try: try:
resp = adapter.invoke( auth_data = ProfileAuthManager.load_profile_auth(pcfg.provider, profile_id)
pcfg, if not auth_data:
{ return {"success": False, "error": "Сохранённые данные авторизации не найдены"}
"model": model, adapter = get_adapter(pcfg.provider)
"messages": [{"role": "user", "content": f"Respond strictly with: TEST_OK_FOR_{profile_id}"}], runtime_ready = adapter.health_check(pcfg)
"temperature": 0.1,
},
)
el = round(time.time() - t0, 2) el = round(time.time() - t0, 2)
content = resp.get("choices", [{}])[0].get("message", {}).get("content", "").strip() if not runtime_ready:
EventLogService.get().log("system", f"Тест {profile_id} ({model}) успешно пройден за {el}s.", level="success") return {
return {"success": True, "model": model, "duration_sec": el, "response": content[:120]} "success": False,
"duration_sec": el,
"error": "Локальный runtime провайдера недоступен; повторная авторизация не запускалась",
}
EventLogService.get().log(
"system", f"Локальная проверка профиля {profile_id} ({model}) пройдена за {el}s.", level="success"
)
return {
"success": True,
"model": model,
"duration_sec": el,
"response": "Авторизация сохранена; runtime провайдера доступен",
}
except Exception as e: except Exception as e:
EventLogService.get().log("system", f"Ошибка теста {profile_id} ({model}): {e}", level="error") EventLogService.get().log("system", f"Ошибка теста {profile_id} ({model}): {e}", level="error")
return {"success": False, "model": model, "duration_sec": round(time.time() - t0, 2), "error": str(e)} return {"success": False, "model": model, "duration_sec": round(time.time() - t0, 2), "error": str(e)}
@ -630,7 +638,12 @@ class HermesHubApp(ctk.CTk):
on_complete=lambda: self.after(0, self._refresh_data), on_complete=lambda: self.after(0, self._refresh_data),
) )
elif action == "edit_route": elif action == "edit_route":
self._show_toast("Редактор цепочки использует кнопки и селекторы; drag-and-drop отключён.") role_id = data.get("role_id", "")
self._show_view("team")
team = self._views.get("team")
if team and hasattr(team, "focus_role"):
team.focus_role(role_id)
self._show_toast(f"Настройка цепочки роли: {role_id}")
elif action == "open_routing": elif action == "open_routing":
self._show_view("routing") self._show_view("routing")
routing = self._views.get("routing") routing = self._views.get("routing")
@ -672,14 +685,19 @@ class HermesHubApp(ctk.CTk):
justify="left", justify="left",
).pack(anchor="w", pady=(0, 12)) ).pack(anchor="w", pady=(0, 12))
role_var = ctk.StringVar(value="orchestrator") config = load_router_config()
current_role = next(
(role_id for role_id, policy in config.roles.items() if profile_id in policy.preferred_chain),
"orchestrator",
)
role_var = ctk.StringVar(value=current_role)
roles = [ roles = [
("orchestrator", "👑 Главный оркестратор"), ("orchestrator", "👑 Главный оркестратор"),
("coder", "💻 Кодер (Code Generation)"), ("coder-primary", "💻 Основной кодер"),
("coder-secondary", "💻 Резервный кодер"),
("reviewer", "🔍 Ревьюер (Code Review)"), ("reviewer", "🔍 Ревьюер (Code Review)"),
("researcher", "🌐 Исследователь (Search / Docs)"), ("research", "🌐 Исследователь (Search / Docs)"),
("tester", "🧪 Тестировщик (Deterministic Tests)"), ("fast", "⚡ Быстрый агент"),
("general", "⚡ Агент общего назначения (Subagent)"),
("spare", "🛡️ Резерв (Spare)"), ("spare", "🛡️ Резерв (Spare)"),
] ]
@ -695,9 +713,24 @@ class HermesHubApp(ctk.CTk):
hover_color=Theme.ACCENT_HOVER, hover_color=Theme.ACCENT_HOVER,
).pack(anchor="w", padx=8, pady=3) ).pack(anchor="w", padx=8, pady=3)
primary_var = ctk.BooleanVar(value=True)
ctk.CTkCheckBox(
modal.body,
text="Сделать основным в выбранной цепочке",
variable=primary_var,
font=Theme.font_body(),
text_color=Theme.TEXT_PRIMARY,
fg_color=Theme.ACCENT,
hover_color=Theme.ACCENT_HOVER,
).pack(anchor="w", padx=8, pady=(12, 3))
def _save(): def _save():
chosen = role_var.get() chosen = role_var.get()
ok, msg = AutoAssigner.assign_profile_to_role(profile_id, chosen, is_primary=(chosen != "spare")) ok, msg = AutoAssigner.assign_profile_to_role(
profile_id,
chosen,
is_primary=primary_var.get() and chosen != "spare",
)
modal.destroy() modal.destroy()
self._show_toast(f"{msg}" if ok else f"{msg}") self._show_toast(f"{msg}" if ok else f"{msg}")
self._refresh_data() self._refresh_data()
@ -714,7 +747,7 @@ class HermesHubApp(ctk.CTk):
def _show_test_result(self, result: Dict[str, Any]): def _show_test_result(self, result: Dict[str, Any]):
if result.get("success"): if result.get("success"):
msg = f"Тест успешен | Модель: {result.get('model')} | Время: {result.get('duration_sec')}s" msg = f"Профиль готов | Модель: {result.get('model')} | Время: {result.get('duration_sec')}s"
else: else:
msg = f"✕ Ошибка теста: {result.get('error', 'Неизвестная ошибка')}" msg = f"✕ Ошибка теста: {result.get('error', 'Неизвестная ошибка')}"
self._show_toast(msg) self._show_toast(msg)

View file

@ -482,6 +482,18 @@ class AddAccountWizard(HubModal):
auth_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED) auth_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED)
auth_card.pack(fill="x", pady=(0, 8)) auth_card.pack(fill="x", pady=(0, 8))
ctk.CTkLabel(
auth_card,
text=(
"1. Нажмите «Открыть в браузере». 2. Вставьте показанный ниже код на странице OpenAI. "
"3. Вернитесь в Hub — поле для этого кода в приложении не требуется."
),
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
wraplength=520,
justify="left",
).pack(anchor="w", padx=10, pady=(7, 2))
ctk.CTkLabel( ctk.CTkLabel(
auth_card, auth_card,
text="Ссылка для входа в OpenAI (ChatGPT):", text="Ссылка для входа в OpenAI (ChatGPT):",
@ -655,6 +667,14 @@ class AddAccountWizard(HubModal):
def _open_codex_browser(self): def _open_codex_browser(self):
if self.codex_url: if self.codex_url:
self._copy_codex_code()
self.codex_status_lbl.configure(
text=(
"Код скопирован. Вставьте его на открывшейся странице OpenAI; "
"затем вернитесь сюда — Hub продолжит автоматически."
),
text_color=Theme.TEXT_SECONDARY,
)
webbrowser.open(self.codex_url) webbrowser.open(self.codex_url)
def _handle_codex_manual_submit(self): def _handle_codex_manual_submit(self):
@ -918,6 +938,18 @@ class AddAccountWizard(HubModal):
auth_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED) auth_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED)
auth_card.pack(fill="x", pady=(0, 8)) auth_card.pack(fill="x", pady=(0, 8))
ctk.CTkLabel(
auth_card,
text=(
"1. Нажмите «Открыть в браузере». 2. Вставьте показанный ниже код на странице xAI. "
"3. Вернитесь в Hub — поле для этого кода в приложении не требуется."
),
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
wraplength=520,
justify="left",
).pack(anchor="w", padx=10, pady=(7, 2))
ctk.CTkLabel( ctk.CTkLabel(
auth_card, auth_card,
text="Ссылка для входа в xAI Grok:", text="Ссылка для входа в xAI Grok:",
@ -1091,6 +1123,14 @@ class AddAccountWizard(HubModal):
def _open_grok_browser(self): def _open_grok_browser(self):
if self.grok_url: if self.grok_url:
self._copy_grok_code()
self.grok_status_lbl.configure(
text=(
"Код скопирован. Вставьте его на открывшейся странице xAI; "
"затем вернитесь сюда — Hub продолжит автоматически."
),
text_color=Theme.TEXT_SECONDARY,
)
webbrowser.open(self.grok_url) webbrowser.open(self.grok_url)
def _handle_grok_manual_submit(self): def _handle_grok_manual_submit(self):
@ -1455,6 +1495,15 @@ class AddAccountWizard(HubModal):
anchor="w", anchor="w",
).pack(fill="x", pady=8) ).pack(fill="x", pady=8)
self.finish_status_lbl = ctk.CTkLabel(
self.body,
text="Нажмите «Завершить подключение», чтобы сохранить роль и обновить Hub.",
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
anchor="w",
)
self.finish_status_lbl.pack(fill="x", pady=(0, 4))
HubButton( HubButton(
self.footer, self.footer,
text="✓ Завершить подключение", text="✓ Завершить подключение",
@ -1466,13 +1515,19 @@ class AddAccountWizard(HubModal):
# Most built-in slots already occur in a default chain. Custom or # Most built-in slots already occur in a default chain. Custom or
# repaired configs may not, so completion makes that invariant explicit # repaired configs may not, so completion makes that invariant explicit
# without reordering a slot that is already assigned. # without reordering a slot that is already assigned.
ensure_profile_in_routing(self.target_slot) ok, message = ensure_profile_in_routing(self.target_slot)
EventLogService.get().log_event( if not ok:
event_type="ACCOUNT_CONNECTED", self.finish_status_lbl.configure(text=f"{message}", text_color=Theme.STATUS_ERROR)
title=f"Подключен аккаунт {self.selected_provider}", return
detail=f"Слот: {self.target_slot} ({self.discovered_identity})", # A reused slot can carry cooldown from an older account. Fresh OAuth
provider=self.selected_provider, # credentials must start with fresh health state.
profile_id=self.target_slot, from antigravity_provider.router.router_engine import get_router_engine
get_router_engine().health.clear_cooldown(self.target_slot)
EventLogService.get().log(
"account",
f"Подключен аккаунт {self.selected_provider}; слот {self.target_slot}; роль сохранена.",
level="success",
) )
if self.on_complete: if self.on_complete:
self.on_complete( self.on_complete(

View file

@ -481,7 +481,8 @@ class HubModal(ctk.CTkToplevel):
self, self,
corner_radius=Theme.RADIUS_LG, corner_radius=Theme.RADIUS_LG,
border_color=Theme.BORDER_ACCENT, border_color=Theme.BORDER_ACCENT,
fg_color=Theme.DARK, # Contrast-safe in dark, hybrid and light palettes.
fg_color=Theme.SURFACE,
) )
self.container.pack(fill="both", expand=True, padx=Theme.SPACE_LG, pady=Theme.SPACE_LG) self.container.pack(fill="both", expand=True, padx=Theme.SPACE_LG, pady=Theme.SPACE_LG)

View file

@ -712,6 +712,18 @@ class TeamView(ctk.CTkFrame):
break break
return "break" return "break"
def focus_role(self, role_id: str) -> None:
"""Select a role when Routing delegates editing to this single editor."""
if not any(node.role_id == role_id for node in self.controller.graph.nodes):
self.state_label.configure(text=f"Роль {role_id} не найдена", text_color=Theme.STATUS_WARNING)
return
self.selected_role = role_id
self.selected_edge = ""
target = next((node.role_id for node in self.controller.graph.nodes if node.role_id != role_id), role_id)
self.edge_target.set(target)
self._draw_graph(rebuild=True)
self.state_label.configure(text=f"Редактируется цепочка: {role_id}", text_color=Theme.TEXT_ACCENT)
def _connect_selected(self) -> None: def _connect_selected(self) -> None:
if len(self.controller.graph.nodes) < 2: if len(self.controller.graph.nodes) < 2:
return return

View file

@ -8,6 +8,7 @@ pytest.importorskip("customtkinter")
from antigravity_provider.router.ui import routing_graph as graph_module from antigravity_provider.router.ui import routing_graph as graph_module
from antigravity_provider.router.ui import add_account_wizard as wizard_module from antigravity_provider.router.ui import add_account_wizard as wizard_module
from antigravity_provider.router import hermes_hub_app as app_module
from antigravity_provider.router.ui.routing_graph import ( from antigravity_provider.router.ui.routing_graph import (
GraphEdge, GraphEdge,
GraphNode, GraphNode,
@ -128,3 +129,56 @@ def test_wizard_keeps_existing_chain_rank_and_assigns_missing_slot(monkeypatch):
) )
assert wizard_module.ensure_profile_in_routing("new-slot")[0] assert wizard_module.ensure_profile_in_routing("new-slot")[0]
assert calls == [("new-slot", "coder", False)] assert calls == [("new-slot", "coder", False)]
def test_profile_test_does_not_invoke_model_or_oauth(monkeypatch):
profile = SimpleNamespace(provider="antigravity", preferred_models=["gemini"], profile_id="connected")
config = SimpleNamespace(get_profile=lambda _profile_id: profile)
class Adapter:
@staticmethod
def health_check(_profile):
return True
@staticmethod
def invoke(*_args, **_kwargs):
raise AssertionError("profile test must never invoke inference")
monkeypatch.setattr(app_module, "load_router_config", lambda: config)
monkeypatch.setattr(app_module.ProfileAuthManager, "get_profile_status", lambda *_args: {"authenticated": True})
monkeypatch.setattr(app_module.ProfileAuthManager, "load_profile_auth", lambda *_args: {"token": "present"})
monkeypatch.setattr(app_module, "get_adapter", lambda _provider: Adapter())
monkeypatch.setattr(app_module.EventLogService, "get", lambda: SimpleNamespace(log=lambda *_args, **_kwargs: None))
result = app_module.do_test_profile("antigravity", "connected")
assert result["success"] is True
assert "runtime" in result["response"]
def test_wizard_finish_closes_logs_and_clears_reused_slot(monkeypatch):
calls = []
monkeypatch.setattr(wizard_module, "ensure_profile_in_routing", lambda _profile: (True, "ok"))
monkeypatch.setattr(
wizard_module.EventLogService,
"get",
lambda: SimpleNamespace(log=lambda *args, **kwargs: calls.append(("log", args, kwargs))),
)
from antigravity_provider.router import router_engine
monkeypatch.setattr(
router_engine,
"get_router_engine",
lambda: SimpleNamespace(health=SimpleNamespace(clear_cooldown=lambda profile: calls.append(("clear", profile)))),
)
fake = SimpleNamespace(
target_slot="ag-orch-fallback",
selected_provider="antigravity",
discovered_identity="account",
finish_status_lbl=SimpleNamespace(configure=lambda **_kwargs: None),
on_complete=lambda payload: calls.append(("complete", payload)),
destroy=lambda: calls.append(("destroy",)),
)
wizard_module.AddAccountWizard._finish(fake)
assert ("clear", "ag-orch-fallback") in calls
assert any(item[0] == "log" for item in calls)
assert calls[-1] == ("destroy",)