mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 19:52:23 +02:00
[eric] fix 9Router CLI auth for subscription users, add analytics, update models
This commit is contained in:
@@ -539,6 +539,12 @@ class AgentManager:
|
||||
session.pending_approvals.append(approval_req)
|
||||
session.status = "waiting_approval"
|
||||
|
||||
_analytics("approval.requested", {
|
||||
"tool_name": tool_name,
|
||||
"is_first_approval_in_session": len(session.pending_approvals) == 1,
|
||||
"model": session.model,
|
||||
}, session_id=session_id, dashboard_id=session.dashboard_id)
|
||||
|
||||
await ws_manager.send_to_session(session_id, "agent:status", {
|
||||
"session_id": session_id,
|
||||
"status": "waiting_approval",
|
||||
@@ -548,6 +554,15 @@ class AgentManager:
|
||||
session_id, request_id, tool_name, safe_input
|
||||
)
|
||||
|
||||
approval_latency_ms = int((datetime.now() - approval_req.created_at).total_seconds() * 1000)
|
||||
_analytics("approval.resolved", {
|
||||
"tool_name": tool_name,
|
||||
"decision": decision.get("behavior", "unknown"),
|
||||
"latency_ms": approval_latency_ms,
|
||||
"input_was_modified": decision.get("updated_input") is not None,
|
||||
"model": session.model,
|
||||
}, session_id=session_id, dashboard_id=session.dashboard_id)
|
||||
|
||||
session.pending_approvals = [
|
||||
a for a in session.pending_approvals if a.id != request_id
|
||||
]
|
||||
@@ -625,6 +640,18 @@ class AgentManager:
|
||||
|
||||
raw_response = input_data.get("tool_response", "")
|
||||
|
||||
# Track individual tool execution
|
||||
hook_tool_name_early = input_data.get("tool_name", "")
|
||||
if hook_tool_name_early:
|
||||
_analytics("tool.executed", {
|
||||
"tool_name": hook_tool_name_early,
|
||||
"tool_type": "mcp" if "__" in hook_tool_name_early else "builtin",
|
||||
"duration_ms": elapsed_ms,
|
||||
"success": not (isinstance(raw_response, str) and raw_response.startswith("Error")),
|
||||
"model": session.model,
|
||||
"provider": session.provider,
|
||||
}, session_id=session_id, dashboard_id=session.dashboard_id)
|
||||
|
||||
if isinstance(raw_response, list) and raw_response:
|
||||
text_parts = [
|
||||
block.get("text", "")
|
||||
@@ -839,8 +866,11 @@ class AgentManager:
|
||||
from backend.apps.nine_router import is_running as _9r_running
|
||||
if _9r_running():
|
||||
options_kwargs["env"] = {
|
||||
"ANTHROPIC_API_KEY": "9router",
|
||||
"ANTHROPIC_BASE_URL": "http://localhost:20128",
|
||||
}
|
||||
# --bare skips CLI's own OAuth/keychain auth, uses only ANTHROPIC_API_KEY
|
||||
options_kwargs["extra_args"] = {"bare": None}
|
||||
elif global_settings.anthropic_api_key:
|
||||
options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key}
|
||||
else:
|
||||
@@ -871,6 +901,7 @@ class AgentManager:
|
||||
stream_text_msg_id = None
|
||||
stream_tool_msg_ids_ordered = []
|
||||
stream_block_index_map = {}
|
||||
_turn_number = 0
|
||||
|
||||
async for message in query(
|
||||
prompt=prompt_stream(),
|
||||
@@ -976,6 +1007,13 @@ class AgentManager:
|
||||
"message": tool_msg.model_dump(mode="json"),
|
||||
})
|
||||
|
||||
_turn_number += 1
|
||||
_analytics("turn.completed", {
|
||||
"turn_number": _turn_number,
|
||||
"tool_calls_in_turn": len(tool_uses),
|
||||
"model": session.model,
|
||||
}, session_id=session_id, dashboard_id=session.dashboard_id)
|
||||
|
||||
stream_text_msg_id = None
|
||||
stream_tool_msg_ids_ordered = []
|
||||
stream_block_index_map = {}
|
||||
@@ -996,6 +1034,13 @@ class AgentManager:
|
||||
except Exception as e:
|
||||
logger.exception(f"Agent {session_id} error: {e}")
|
||||
session.status = "error"
|
||||
_analytics("session.error", {
|
||||
"error_type": type(e).__name__,
|
||||
"error_message": str(e)[:500],
|
||||
"model": session.model,
|
||||
"provider": session.provider,
|
||||
"mode": session.mode,
|
||||
}, session_id=session_id, dashboard_id=session.dashboard_id)
|
||||
error_msg = Message(role="system", content=f"Error: {str(e)}", branch_id=session.active_branch_id)
|
||||
session.messages.append(error_msg)
|
||||
await ws_manager.send_to_session(session_id, "agent:message", {
|
||||
@@ -1026,6 +1071,12 @@ class AgentManager:
|
||||
"tools_list": list(set(tool_names)),
|
||||
"session_title": session.name,
|
||||
"first_user_message": user_messages[0] if user_messages else "",
|
||||
"input_tokens": session.tokens.get("input", 0),
|
||||
"output_tokens": session.tokens.get("output", 0),
|
||||
"is_sub_agent": session.parent_session_id is not None,
|
||||
"parent_session_id": session.parent_session_id,
|
||||
"sub_agent_count": len([s for s in self.sessions.values() if s.parent_session_id == session_id]),
|
||||
"branch_count": len(session.branches),
|
||||
}, session_id=session_id, dashboard_id=session.dashboard_id)
|
||||
|
||||
await ws_manager.send_to_session(session_id, "agent:status", {
|
||||
@@ -1193,9 +1244,22 @@ class AgentManager:
|
||||
|
||||
session_changed = False
|
||||
if model and model != session.model:
|
||||
_analytics("model.switched", {
|
||||
"from_model": session.model,
|
||||
"to_model": model,
|
||||
"from_provider": session.provider,
|
||||
"to_provider": provider or session.provider,
|
||||
"message_number": len([m for m in session.messages if m.role == "user"]),
|
||||
"cost_so_far": session.cost_usd,
|
||||
}, session_id=session_id, dashboard_id=session.dashboard_id)
|
||||
session.model = model
|
||||
session_changed = True
|
||||
if mode and mode != session.mode:
|
||||
_analytics("feature.used", {
|
||||
"feature": "mode.switched",
|
||||
"from_mode": session.mode,
|
||||
"to_mode": mode,
|
||||
}, session_id=session_id, dashboard_id=session.dashboard_id)
|
||||
session.mode = mode
|
||||
mode_tools, _, _ = self._resolve_mode(mode)
|
||||
session.allowed_tools = mode_tools
|
||||
@@ -1225,6 +1289,34 @@ class AgentManager:
|
||||
"message": user_msg.model_dump(mode="json"),
|
||||
})
|
||||
|
||||
# Track context attachment patterns
|
||||
if context_paths or attached_skills or images or forced_tools:
|
||||
_analytics("context.attached", {
|
||||
"file_count": len([c for c in (context_paths or []) if c.get("type") == "file"]),
|
||||
"directory_count": len([c for c in (context_paths or []) if c.get("type") == "directory"]),
|
||||
"skill_count": len(attached_skills or []),
|
||||
"image_count": len(images or []),
|
||||
"has_forced_tools": bool(forced_tools),
|
||||
}, session_id=session_id, dashboard_id=session.dashboard_id)
|
||||
|
||||
# Track skill usage
|
||||
for skill in (attached_skills or []):
|
||||
_analytics("feature.used", {
|
||||
"feature": "skill.used",
|
||||
"skill_name": skill.get("name", ""),
|
||||
}, session_id=session_id, dashboard_id=session.dashboard_id)
|
||||
|
||||
# Track first message sophistication
|
||||
is_first_message = sum(1 for m in session.messages if m.role == "user") == 1
|
||||
if is_first_message:
|
||||
_analytics("session.first_message", {
|
||||
"message_length": len(prompt),
|
||||
"has_code_block": "```" in prompt,
|
||||
"has_url": "http://" in prompt or "https://" in prompt,
|
||||
"model": session.model,
|
||||
"mode": session.mode,
|
||||
}, session_id=session_id, dashboard_id=session.dashboard_id)
|
||||
|
||||
session.status = "running"
|
||||
await ws_manager.send_to_session(session_id, "agent:status", {
|
||||
"session_id": session_id,
|
||||
@@ -1317,6 +1409,13 @@ class AgentManager:
|
||||
session.branches[new_branch_id] = new_branch
|
||||
session.active_branch_id = new_branch_id
|
||||
|
||||
_analytics("feature.used", {
|
||||
"feature": "message.branched",
|
||||
"branch_depth": len([b for b in session.branches.values() if b.parent_branch_id]),
|
||||
"total_branches_in_session": len(session.branches),
|
||||
"messages_before_fork": len([m for m in session.messages if m.branch_id == fork_parent_branch]),
|
||||
}, session_id=session_id, dashboard_id=session.dashboard_id)
|
||||
|
||||
edited_msg = Message(
|
||||
role="user",
|
||||
content=new_content,
|
||||
@@ -1592,6 +1691,20 @@ class AgentManager:
|
||||
|
||||
session = AgentSession(**data)
|
||||
|
||||
hours_since_closed = 0
|
||||
if data.get("closed_at"):
|
||||
try:
|
||||
closed = datetime.fromisoformat(data["closed_at"][:19])
|
||||
hours_since_closed = round((datetime.now() - closed).total_seconds() / 3600, 1)
|
||||
except Exception:
|
||||
pass
|
||||
_analytics("session.resumed", {
|
||||
"hours_since_closed": hours_since_closed,
|
||||
"original_message_count": len(data.get("messages", [])),
|
||||
"original_cost_usd": data.get("cost_usd", 0),
|
||||
"model": session.model,
|
||||
}, session_id=session_id, dashboard_id=session.dashboard_id)
|
||||
|
||||
session.closed_at = None
|
||||
self.sessions[session_id] = session
|
||||
|
||||
|
||||
@@ -239,6 +239,9 @@ async def subscriptions_poll(body: dict):
|
||||
code_verifier=body.get("code_verifier"),
|
||||
extra_data=body.get("extra_data"),
|
||||
)
|
||||
if result.get("success"):
|
||||
from backend.apps.analytics.collector import record as _analytics
|
||||
_analytics("subscription.connected", {"provider": provider})
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -259,6 +262,9 @@ async def subscriptions_exchange(body: dict):
|
||||
|
||||
try:
|
||||
result = await exchange_oauth(provider, code, redirect_uri, code_verifier, state)
|
||||
if result.get("success"):
|
||||
from backend.apps.analytics.collector import record as _analytics
|
||||
_analytics("subscription.connected", {"provider": provider})
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -290,6 +296,8 @@ async def subscriptions_disconnect(body: dict):
|
||||
if conn and conn.get("id"):
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await client.delete(f"{NINE_ROUTER_API}/providers/{conn['id']}")
|
||||
from backend.apps.analytics.collector import record as _analytics
|
||||
_analytics("subscription.disconnected", {"provider": provider})
|
||||
return {"ok": True}
|
||||
return {"ok": False, "error": "Connection not found"}
|
||||
except Exception as e:
|
||||
|
||||
@@ -592,6 +592,13 @@ async def run_browser_agents(
|
||||
Each task dict has: { browser_id (optional), task, url (optional) }
|
||||
Returns a list of result dicts, one per task.
|
||||
"""
|
||||
from backend.apps.analytics.collector import record as _analytics
|
||||
_analytics("feature.used", {
|
||||
"feature": "browser_agent.launched",
|
||||
"task_count": len(tasks),
|
||||
"model": model,
|
||||
}, dashboard_id=dashboard_id)
|
||||
|
||||
pre_selected = set(pre_selected_browser_ids or [])
|
||||
|
||||
async def _run_one(task_def: dict) -> dict:
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Analytics SubApp: PostHog for product analytics + local usage summary from session data."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
from collections import Counter
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
|
||||
from backend.config.Apps import SubApp
|
||||
from backend.config.paths import SESSIONS_DIR
|
||||
@@ -13,16 +15,49 @@ from backend.apps.analytics.collector import init as init_collector, shutdown as
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_VERSION = "1.0.15"
|
||||
|
||||
_heartbeat_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
async def _heartbeat_loop():
|
||||
"""Send a heartbeat event every 60 seconds for usage-time tracking."""
|
||||
while True:
|
||||
await asyncio.sleep(60)
|
||||
try:
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
record("app.heartbeat", {
|
||||
"active_session_count": len(agent_manager.sessions),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def analytics_lifespan():
|
||||
global _heartbeat_task
|
||||
|
||||
init_collector()
|
||||
logger.info("PostHog analytics initialised")
|
||||
|
||||
try:
|
||||
from backend.apps.settings.settings import load_settings
|
||||
from backend.apps.settings.settings import load_settings, _save_settings
|
||||
settings = load_settings()
|
||||
|
||||
# Track first open
|
||||
is_first_open = settings.first_opened_at is None
|
||||
if is_first_open:
|
||||
settings.first_opened_at = datetime.now().isoformat()
|
||||
_save_settings(settings)
|
||||
|
||||
days_since_install = 0
|
||||
if settings.first_opened_at:
|
||||
try:
|
||||
first = datetime.fromisoformat(settings.first_opened_at[:19])
|
||||
days_since_install = (datetime.now() - first).days
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
providers = []
|
||||
if getattr(settings, "anthropic_api_key", None):
|
||||
providers.append("anthropic")
|
||||
@@ -40,11 +75,15 @@ async def analytics_lifespan():
|
||||
"platform": platform.platform(),
|
||||
"provider_count": len(providers),
|
||||
"providers": providers,
|
||||
"is_first_open": is_first_open,
|
||||
"days_since_install": days_since_install,
|
||||
"app_version": APP_VERSION,
|
||||
})
|
||||
|
||||
identify({
|
||||
"providers_configured": providers,
|
||||
"provider_count": len(providers),
|
||||
"app_version": APP_VERSION,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Analytics startup event failed (non-critical): {e}")
|
||||
@@ -56,8 +95,20 @@ async def analytics_lifespan():
|
||||
except Exception as e:
|
||||
logger.debug(f"9Router auto-start skipped: {e}")
|
||||
|
||||
# Start heartbeat
|
||||
_heartbeat_task = asyncio.create_task(_heartbeat_loop())
|
||||
|
||||
yield
|
||||
|
||||
# Stop heartbeat
|
||||
if _heartbeat_task:
|
||||
_heartbeat_task.cancel()
|
||||
try:
|
||||
await _heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_heartbeat_task = None
|
||||
|
||||
# Stop 9Router
|
||||
try:
|
||||
from backend.apps.nine_router import stop as stop_9router
|
||||
@@ -123,7 +174,6 @@ async def usage_summary():
|
||||
closed = s.get("closed_at")
|
||||
if created and closed:
|
||||
try:
|
||||
from datetime import datetime
|
||||
c_str = created[:19]
|
||||
cl_str = closed[:19]
|
||||
dur = (datetime.fromisoformat(cl_str) - datetime.fromisoformat(c_str)).total_seconds()
|
||||
@@ -230,3 +280,15 @@ async def cost_breakdown(period: str = "7d"):
|
||||
@analytics.router.get("/status")
|
||||
async def analytics_status():
|
||||
return {"status": "posthog", "enabled": True}
|
||||
|
||||
|
||||
@analytics.router.post("/event")
|
||||
async def record_event(body: dict):
|
||||
"""Accept analytics events from the frontend (e.g. feature.time_spent)."""
|
||||
event_type = body.get("event_type", "")
|
||||
properties = body.get("properties", {})
|
||||
if event_type:
|
||||
record(event_type, properties,
|
||||
session_id=body.get("session_id"),
|
||||
dashboard_id=body.get("dashboard_id"))
|
||||
return {"ok": True}
|
||||
|
||||
@@ -305,6 +305,8 @@ async def create_output(body: OutputCreate):
|
||||
updated_at=now,
|
||||
)
|
||||
_save(output)
|
||||
from backend.apps.analytics.collector import record as _analytics
|
||||
_analytics("feature.used", {"feature": "view.created"})
|
||||
return {"ok": True, "output": output.model_dump()}
|
||||
|
||||
|
||||
@@ -349,6 +351,8 @@ Return ONLY valid JSON with these keys. No markdown fences, no extra text.\
|
||||
@outputs.router.post("/vibe-code")
|
||||
async def vibe_code(body: VibeCodeRequest):
|
||||
"""Use an LLM to generate or iterate on Output code from a natural language prompt."""
|
||||
from backend.apps.analytics.collector import record as _analytics
|
||||
_analytics("feature.used", {"feature": "vibe_code.used"})
|
||||
try:
|
||||
import anthropic
|
||||
except ImportError:
|
||||
|
||||
@@ -52,6 +52,7 @@ class AppSettings(BaseModel):
|
||||
# Analytics: opted in by default, user can toggle off
|
||||
analytics_opt_in: bool = True
|
||||
installation_id: Optional[str] = None
|
||||
first_opened_at: Optional[str] = None # ISO timestamp of first app open
|
||||
|
||||
|
||||
class CustomProvider(BaseModel):
|
||||
|
||||
@@ -52,6 +52,39 @@ async def get_settings():
|
||||
|
||||
@settings.router.put("")
|
||||
async def update_settings(body: AppSettings):
|
||||
from backend.apps.analytics.collector import record as _analytics
|
||||
|
||||
old = load_settings()
|
||||
|
||||
# Track provider key changes
|
||||
provider_keys = {
|
||||
"anthropic_api_key": "anthropic",
|
||||
"openai_api_key": "openai",
|
||||
"google_api_key": "gemini",
|
||||
"openrouter_api_key": "openrouter",
|
||||
}
|
||||
for key, provider_name in provider_keys.items():
|
||||
old_val = bool(getattr(old, key, None))
|
||||
new_val = bool(getattr(body, key, None))
|
||||
if old_val != new_val:
|
||||
_analytics("provider.configured", {
|
||||
"provider": provider_name,
|
||||
"action": "added" if new_val else "removed",
|
||||
})
|
||||
|
||||
# Track settings changes (key names only, not values)
|
||||
old_dict = old.model_dump()
|
||||
new_dict = body.model_dump()
|
||||
secret_keys = {"anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key",
|
||||
"claude_subscription_token", "openai_subscription_token", "gemini_subscription_token",
|
||||
"copilot_github_token", "copilot_token", "installation_id"}
|
||||
safe_changed = [
|
||||
k for k in new_dict
|
||||
if k in old_dict and new_dict[k] != old_dict[k] and k not in secret_keys
|
||||
]
|
||||
if safe_changed:
|
||||
_analytics("settings.changed", {"changed_keys": safe_changed})
|
||||
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
with open(SETTINGS_FILE, "w") as f:
|
||||
json.dump(body.model_dump(), f, indent=2)
|
||||
|
||||
@@ -163,6 +163,8 @@ async def create_skill(body: SkillCreate):
|
||||
file_path=fpath,
|
||||
command=body.command or slug,
|
||||
)
|
||||
from backend.apps.analytics.collector import record as _analytics
|
||||
_analytics("feature.used", {"feature": "skill.created"})
|
||||
return {"ok": True, "skill": skill.model_dump()}
|
||||
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ async def create_template(body: PromptTemplateCreate):
|
||||
tags=body.tags,
|
||||
)
|
||||
_save(template)
|
||||
from backend.apps.analytics.collector import record as _analytics
|
||||
_analytics("feature.used", {"feature": "template.created"})
|
||||
return {"ok": True, "template": template.model_dump()}
|
||||
|
||||
@templates.router.put("/{template_id}")
|
||||
@@ -88,4 +90,6 @@ async def render_template(body: dict):
|
||||
placeholder = "{{" + field.name + "}}"
|
||||
value = values.get(field.name, field.default or "")
|
||||
rendered = rendered.replace(placeholder, str(value))
|
||||
from backend.apps.analytics.collector import record as _analytics
|
||||
_analytics("feature.used", {"feature": "template.used"})
|
||||
return {"rendered": rendered}
|
||||
|
||||
@@ -21,8 +21,8 @@ logger = logging.getLogger(__name__)
|
||||
# Default Google OAuth credentials for the OpenSwarm project.
|
||||
# These are public credentials for a desktop/web OAuth client (safe to embed per Google's docs).
|
||||
# Users can override via GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET env vars.
|
||||
_DEFAULT_GOOGLE_CLIENT_ID = "514323102245-ivhcnod6q9jj16ck1hbaiaelivtgtnd1.apps.googleusercontent.com"
|
||||
_DEFAULT_GOOGLE_CLIENT_SECRET = "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"
|
||||
_DEFAULT_GOOGLE_CLIENT_ID = "6741219524-8vpt07arcc5rvkdb4j1b6v9g53469ugq.apps.googleusercontent.com"
|
||||
_DEFAULT_GOOGLE_CLIENT_SECRET = "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"
|
||||
os.environ.setdefault("GOOGLE_OAUTH_CLIENT_ID", _DEFAULT_GOOGLE_CLIENT_ID)
|
||||
os.environ.setdefault("GOOGLE_OAUTH_CLIENT_SECRET", _DEFAULT_GOOGLE_CLIENT_SECRET)
|
||||
|
||||
@@ -410,6 +410,19 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
|
||||
env = config.setdefault("env", {})
|
||||
env.setdefault("PATH", _augmented_path())
|
||||
env.setdefault("PYTHONPATH", "")
|
||||
# Point uv/uvx at our bundled Python — avoids macOS CLT popup on fresh Macs
|
||||
# and avoids downloading Python at runtime
|
||||
_is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1"
|
||||
if _is_packaged:
|
||||
_resources = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
_bundled_python = os.path.join(_resources, "python-env", "bin", "python3")
|
||||
if os.path.exists(_bundled_python):
|
||||
env.setdefault("UV_PYTHON", _bundled_python)
|
||||
else:
|
||||
_backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
_venv_python = os.path.join(_backend, ".venv", "bin", "python3")
|
||||
if os.path.exists(_venv_python):
|
||||
env.setdefault("UV_PYTHON", _venv_python)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openswarm",
|
||||
"version": "1.0.14",
|
||||
"version": "1.0.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openswarm",
|
||||
"version": "1.0.14",
|
||||
"version": "1.0.15",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.3.0",
|
||||
|
||||
@@ -29,13 +29,14 @@ const OnboardingModal: React.FC = () => {
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.running) {
|
||||
setNineRouterReady(true);
|
||||
// Check if already has subscription
|
||||
const connections = data.providers?.connections || [];
|
||||
if (connections.some((p: any) => p.isActive)) {
|
||||
// Already connected — don't show onboarding
|
||||
return;
|
||||
}
|
||||
// Delay before marking ready — 9Router's OAuth needs time to warm up
|
||||
setTimeout(() => setNineRouterReady(true), 3000);
|
||||
} else {
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) {
|
||||
@@ -79,9 +80,16 @@ const OnboardingModal: React.FC = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
// Exact same connect logic as Settings/SubscriptionCards
|
||||
// Same connect logic as Settings/SubscriptionCards
|
||||
const handleConnect = async (providerId: string) => {
|
||||
// Cancel any previous attempt
|
||||
if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
|
||||
if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; }
|
||||
setConnecting(providerId);
|
||||
|
||||
// Delay before calling connect — avoids Claude OAuth rate limit on retries
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/agents/subscriptions/connect`, {
|
||||
method: 'POST',
|
||||
@@ -118,7 +126,7 @@ const OnboardingModal: React.FC = () => {
|
||||
} catch {}
|
||||
}, 5000);
|
||||
pollTimerRef.current = timer;
|
||||
setTimeout(() => { clearInterval(timer); pollTimerRef.current = null; setConnecting(null); }, 300000);
|
||||
setTimeout(() => { clearInterval(timer); pollTimerRef.current = null; setConnecting(null); }, 30000);
|
||||
|
||||
} else if (data.flow === 'authorization_code') {
|
||||
const popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700');
|
||||
@@ -174,7 +182,7 @@ const OnboardingModal: React.FC = () => {
|
||||
if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
|
||||
if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; }
|
||||
setConnecting(null);
|
||||
}, 300000);
|
||||
}, 30000);
|
||||
|
||||
} else {
|
||||
setConnecting(null);
|
||||
|
||||
@@ -210,6 +210,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
}) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const hasApiKey = !!useAppSelector((s) => s.settings.data.anthropic_api_key);
|
||||
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected);
|
||||
|
||||
const cardBoxRef = useRef<HTMLDivElement>(null);
|
||||
@@ -783,7 +784,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
|
||||
{formatDuration(session.created_at, (session as any).closed_at, session.status)}
|
||||
</Typography>
|
||||
{session.cost_usd > 0 && (
|
||||
{session.cost_usd > 0 && hasApiKey && (
|
||||
<Typography variant="caption" sx={{ color: c.accent.primary }}>
|
||||
${session.cost_usd.toFixed(4)}
|
||||
</Typography>
|
||||
|
||||
@@ -223,17 +223,23 @@ const SubscriptionCards: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleConnect = async (providerId: string) => {
|
||||
// Cancel any previous attempt first
|
||||
if (pollTimer) { clearInterval(pollTimer); setPollTimer(null); }
|
||||
setConnecting(providerId);
|
||||
setUserCode('');
|
||||
|
||||
// Small delay if retrying — avoids hitting Claude's rate limit
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/agents/subscriptions/connect`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: providerId }),
|
||||
});
|
||||
if (!r.ok) { setConnecting(null); return; }
|
||||
const data = await r.json();
|
||||
|
||||
if (data.flow === 'device_code') {
|
||||
// Device code flow (GitHub, Qwen, etc.) — show code, poll
|
||||
const code = data.user_code || '';
|
||||
setUserCode(code);
|
||||
if (data.verification_uri) window.open(data.verification_uri, '_blank');
|
||||
@@ -247,6 +253,7 @@ const SubscriptionCards: React.FC = () => {
|
||||
const pd = await pr.json();
|
||||
if (pd.success) {
|
||||
clearInterval(timer);
|
||||
setPollTimer(null);
|
||||
setConnecting(null);
|
||||
setUserCode('');
|
||||
fetchStatus();
|
||||
@@ -254,18 +261,36 @@ const SubscriptionCards: React.FC = () => {
|
||||
} catch {}
|
||||
}, 5000);
|
||||
setPollTimer(timer);
|
||||
setTimeout(() => { clearInterval(timer); setConnecting(null); setUserCode(''); }, 300000);
|
||||
setTimeout(() => { clearInterval(timer); setPollTimer(null); setConnecting(null); setUserCode(''); }, 300000);
|
||||
|
||||
} else if (data.flow === 'authorization_code') {
|
||||
// Open auth URL as popup — window.opener lets callback page postMessage back
|
||||
const popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700');
|
||||
|
||||
// Status polling as primary detection
|
||||
const statusPoller = setInterval(async () => {
|
||||
try {
|
||||
const sr = await fetch(`${API_BASE}/agents/subscriptions/status`);
|
||||
const sd = await sr.json();
|
||||
const connections = sd.providers?.connections || [];
|
||||
if (connections.some((p: any) => p.provider === providerId && p.isActive)) {
|
||||
clearInterval(statusPoller);
|
||||
setPollTimer(null);
|
||||
window.removeEventListener('message', msgHandler);
|
||||
setConnecting(null);
|
||||
fetchStatus();
|
||||
}
|
||||
} catch {}
|
||||
}, 2000);
|
||||
setPollTimer(statusPoller);
|
||||
|
||||
// postMessage listener as secondary (faster when it works)
|
||||
const msgHandler = async (event: MessageEvent) => {
|
||||
const d = event.data;
|
||||
const callbackData = d?.type === 'oauth_callback' ? d.data : d;
|
||||
if (callbackData?.code) {
|
||||
window.removeEventListener('message', msgHandler);
|
||||
clearInterval(statusPoller);
|
||||
setPollTimer(null);
|
||||
if (popup && !popup.closed) popup.close();
|
||||
try {
|
||||
await fetch(`${API_BASE}/agents/subscriptions/exchange`, {
|
||||
@@ -283,21 +308,13 @@ const SubscriptionCards: React.FC = () => {
|
||||
};
|
||||
window.addEventListener('message', msgHandler);
|
||||
|
||||
const statusPoller = setInterval(async () => {
|
||||
try {
|
||||
const sr = await fetch(`${API_BASE}/agents/subscriptions/status`);
|
||||
const sd = await sr.json();
|
||||
const connections = sd.providers?.connections || [];
|
||||
if (connections.some((p: any) => p.provider === providerId && p.isActive)) {
|
||||
clearInterval(statusPoller);
|
||||
window.removeEventListener('message', msgHandler);
|
||||
setConnecting(null);
|
||||
fetchStatus();
|
||||
}
|
||||
} catch {}
|
||||
}, 2000);
|
||||
setPollTimer(statusPoller);
|
||||
setTimeout(() => { clearInterval(statusPoller); window.removeEventListener('message', msgHandler); setConnecting(null); }, 300000);
|
||||
// Timeout: reset after 30s so user can try again (not 5min)
|
||||
setTimeout(() => {
|
||||
clearInterval(statusPoller);
|
||||
setPollTimer(null);
|
||||
window.removeEventListener('message', msgHandler);
|
||||
setConnecting(null);
|
||||
}, 30000);
|
||||
|
||||
} else {
|
||||
setConnecting(null);
|
||||
@@ -435,7 +452,8 @@ const UsageStats: React.FC = () => {
|
||||
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
|
||||
return `${(n / 1_000_000).toFixed(2)}M`;
|
||||
};
|
||||
const costSourceLabel = stats.cost_source === '9router' ? 'equivalent API cost' : stats.cost_source === 'sdk' ? 'via API' : '';
|
||||
const isSubscription = stats.cost_source === '9router';
|
||||
const costSourceLabel = isSubscription ? 'saved with your subscription' : stats.cost_source === 'sdk' ? 'via API' : '';
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
@@ -449,10 +467,12 @@ const UsageStats: React.FC = () => {
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>{stats.cost_source === '9router' ? 'Est. API Cost' : 'Total Cost'}</Typography>
|
||||
<Typography sx={labelSx}>{isSubscription ? 'You Saved' : 'Total Cost'}</Typography>
|
||||
<Typography sx={valueSx}>{formatCost(stats.total_cost_usd)}</Typography>
|
||||
<Typography sx={subSx}>
|
||||
{costSourceLabel ? `${formatCost(stats.avg_cost_per_session)} avg · ${costSourceLabel}` : 'no cost data'}
|
||||
{isSubscription
|
||||
? `${formatCost(stats.avg_cost_per_session)} avg · saved with your subscription`
|
||||
: costSourceLabel ? `${formatCost(stats.avg_cost_per_session)} avg · ${costSourceLabel}` : 'no cost data'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
|
||||
Reference in New Issue
Block a user