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]:
"""Strictly tests stored credentials WITHOUT triggering OAuth or opening browsers."""
"""Check local profile readiness without inference, OAuth, or a browser."""
config = load_router_config()
pcfg = config.get_profile(profile_id)
if not pcfg:
@ -119,22 +119,30 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
if not status.get("authenticated"):
return {"success": False, "error": "Аккаунт не добавлен. Сначала выполните подключение."}
adapter = get_adapter(pcfg.provider)
model = pcfg.preferred_models[0] if pcfg.preferred_models else "default"
t0 = time.time()
try:
resp = adapter.invoke(
pcfg,
{
"model": model,
"messages": [{"role": "user", "content": f"Respond strictly with: TEST_OK_FOR_{profile_id}"}],
"temperature": 0.1,
},
)
auth_data = ProfileAuthManager.load_profile_auth(pcfg.provider, profile_id)
if not auth_data:
return {"success": False, "error": "Сохранённые данные авторизации не найдены"}
adapter = get_adapter(pcfg.provider)
runtime_ready = adapter.health_check(pcfg)
el = round(time.time() - t0, 2)
content = resp.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
EventLogService.get().log("system", f"Тест {profile_id} ({model}) успешно пройден за {el}s.", level="success")
return {"success": True, "model": model, "duration_sec": el, "response": content[:120]}
if not runtime_ready:
return {
"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:
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)}
@ -630,7 +638,12 @@ class HermesHubApp(ctk.CTk):
on_complete=lambda: self.after(0, self._refresh_data),
)
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":
self._show_view("routing")
routing = self._views.get("routing")
@ -672,14 +685,19 @@ class HermesHubApp(ctk.CTk):
justify="left",
).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 = [
("orchestrator", "👑 Главный оркестратор"),
("coder", "💻 Кодер (Code Generation)"),
("coder-primary", "💻 Основной кодер"),
("coder-secondary", "💻 Резервный кодер"),
("reviewer", "🔍 Ревьюер (Code Review)"),
("researcher", "🌐 Исследователь (Search / Docs)"),
("tester", "🧪 Тестировщик (Deterministic Tests)"),
("general", "⚡ Агент общего назначения (Subagent)"),
("research", "🌐 Исследователь (Search / Docs)"),
("fast", "⚡ Быстрый агент"),
("spare", "🛡️ Резерв (Spare)"),
]
@ -695,9 +713,24 @@ class HermesHubApp(ctk.CTk):
hover_color=Theme.ACCENT_HOVER,
).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():
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()
self._show_toast(f"{msg}" if ok else f"{msg}")
self._refresh_data()
@ -714,7 +747,7 @@ class HermesHubApp(ctk.CTk):
def _show_test_result(self, result: Dict[str, Any]):
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:
msg = f"✕ Ошибка теста: {result.get('error', 'Неизвестная ошибка')}"
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.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(
auth_card,
text="Ссылка для входа в OpenAI (ChatGPT):",
@ -655,6 +667,14 @@ class AddAccountWizard(HubModal):
def _open_codex_browser(self):
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)
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.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(
auth_card,
text="Ссылка для входа в xAI Grok:",
@ -1091,6 +1123,14 @@ class AddAccountWizard(HubModal):
def _open_grok_browser(self):
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)
def _handle_grok_manual_submit(self):
@ -1455,6 +1495,15 @@ class AddAccountWizard(HubModal):
anchor="w",
).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(
self.footer,
text="✓ Завершить подключение",
@ -1466,13 +1515,19 @@ class AddAccountWizard(HubModal):
# Most built-in slots already occur in a default chain. Custom or
# repaired configs may not, so completion makes that invariant explicit
# without reordering a slot that is already assigned.
ensure_profile_in_routing(self.target_slot)
EventLogService.get().log_event(
event_type="ACCOUNT_CONNECTED",
title=f"Подключен аккаунт {self.selected_provider}",
detail=f"Слот: {self.target_slot} ({self.discovered_identity})",
provider=self.selected_provider,
profile_id=self.target_slot,
ok, message = ensure_profile_in_routing(self.target_slot)
if not ok:
self.finish_status_lbl.configure(text=f"{message}", text_color=Theme.STATUS_ERROR)
return
# A reused slot can carry cooldown from an older account. Fresh OAuth
# credentials must start with fresh health state.
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:
self.on_complete(

View file

@ -481,7 +481,8 @@ class HubModal(ctk.CTkToplevel):
self,
corner_radius=Theme.RADIUS_LG,
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)

View file

@ -712,6 +712,18 @@ class TeamView(ctk.CTkFrame):
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:
if len(self.controller.graph.nodes) < 2:
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 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 (
GraphEdge,
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 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",)