From 8d1d00d6b861a5f6de5f0e33dc9e2142815069d9 Mon Sep 17 00:00:00 2001 From: Chao Li Date: Mon, 3 Aug 2026 14:25:38 +0800 Subject: [PATCH 1/5] feat: add native internal quota setup --- README.md | 4 +- agentbar/__init__.py | 2 +- agentbar/chrome_login.py | 273 +++++++++++++++++++++++++ agentbar/config.py | 7 + agentbar/menu_spec.py | 32 +++ agentbar/menubar.py | 15 ++ agentbar/panel_window.py | 17 ++ agentbar/provider_window.py | 373 ++++++++++++++++++++++++++++++++++ agentbar/scheduler.py | 13 +- agentbar/server.py | 16 +- agentbar/web/index.html | 2 +- pyproject.toml | 4 +- tests/test_chrome_login.py | 46 +++++ tests/test_menu_spec.py | 21 +- tests/test_menubar.py | 12 ++ tests/test_provider_window.py | 63 ++++++ tests/test_scheduler.py | 16 ++ tests/test_server.py | 4 + uv.lock | 13 +- 19 files changed, 917 insertions(+), 16 deletions(-) create mode 100644 agentbar/chrome_login.py create mode 100644 agentbar/provider_window.py create mode 100644 tests/test_chrome_login.py create mode 100644 tests/test_provider_window.py diff --git a/README.md b/README.md index dc74b77..8c94893 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ macOS 状态栏(Menu Bar)AI Agent 调度器 —— 让 Claude Code、Codex | ---- | ---- | | 常驻 Menu Bar / 开机自启 | 原生 AppKit(NSStatusItem/NSMenu)+ `scripts/install-launch-agent.sh` | | 可操作的 Menu Bar | 每一行都可点(概览/任务/额度行点击即打开面板);菜单只在展开时刷新(menuWillOpen);所有动作毫秒级返回,绝不阻塞主线程;实时菜单状态导出 `menu-debug.json` 可核查 | -| 原生任务面板窗口 | 菜单点击直接弹出 AppKit 窗口(accessory 进程激活自身窗口不受 macOS 26 协作激活限制):添加任务(Prompt/工具/模型/强度/权限/目录选择器)、队列排优先级(⇧置顶/↑/↓)、取消/重试/暂停派发,进程内直连调度器不经浏览器;web 面板降为次要入口(`🌐 在浏览器中打开面板` / 手机远程) | +| 原生任务面板窗口 | 菜单点击直接弹出 AppKit 窗口(accessory 进程激活自身窗口不受 macOS 26 协作激活限制):添加任务(Prompt/工具/模型/强度/权限/目录选择器)、队列排优先级(⇧置顶/↑/↓)、取消/重试/暂停派发;底部「额度设置」可原生配置 MyToken / Tokenverse,不再依赖 Web 面板 | | 添加任务(Prompt+工具+目录) | 菜单栏快捷添加 / Web 面板 / `agentbar add` CLI | | 支持 Claude Code、Codex,可扩展 | Adapter 插件制,新 CLI ≈ 60 行代码 | | 串行 / 有限并行 | `max_parallel`(默认 1 串行)+ `per_tool_limit` | @@ -180,7 +180,7 @@ agentbar.log # 调度器日志 | **MyToken** | `mytoken.corp.kuaishou.com` — `/api/auth/sso/user` → `/api/v1/billing/account` | corp SSO cookie + `kwaipilot-username` 头(自动带) | | **Tokenverse** | `tokenverse.corp.kuaishou.com` — `/api/coding-plan/status` + `/api/coding-plan/usage/summary` | corp SSO cookie | -启用方式:打开 AgentBar 面板 → **内部额度配置**,可直接点“从浏览器导入 Cookie”读取本机 Chrome / Edge / Brave 登录态,也可手动粘贴浏览器 DevTools 里的整行 `Cookie`。保存后会立即重建 provider 并刷新额度,不需要手写 JSON 或重启。每个 provider 可独立配置展示单位(`credits`/`percent`/`token`)与刷新间隔。诚实原则不变:cookie 缺失或接口失败时如实显示错误,绝不编造额度。接口契约参考 [ylab/aiusagebar](https://git.corp.kuaishou.com/ylab/aiusagebar)。 +启用方式:点击菜单栏 AgentBar 图标 → **内部额度设置…**(或原生任务面板底部「额度设置」)。MyToken / Tokenverse 即使尚未配置也会以“未配置”状态出现在菜单中。点「浏览器登录」后 AgentBar 会像 AIUsageBar 一样启动一个临时 Chrome 会话;在其中完成企业 SSO 后,通过 Chrome 调试协议捕获 Cookie,并用真实额度接口校验,成功后自动保存、启用和刷新。原有「读取已有登录」与手动粘贴整行 `Cookie` 仍作为兜底。每个 provider 可独立配置展示单位(`credits`/`percent`/`token`)与刷新间隔;Web 面板中的 **内部额度配置** 仍作为备用入口。诚实原则不变:cookie 缺失或接口失败时如实显示错误,绝不编造额度。交互和接口契约参考 [ylab/aiusagebar](https://git.corp.kuaishou.com/ylab/aiusagebar)。 ## 扩展新的 AI CLI diff --git a/agentbar/__init__.py b/agentbar/__init__.py index 106642a..ccbe259 100644 --- a/agentbar/__init__.py +++ b/agentbar/__init__.py @@ -1,3 +1,3 @@ """AgentBar — macOS menu bar scheduler for AI CLI agents.""" -__version__ = "0.10.2" +__version__ = "0.10.3" diff --git a/agentbar/chrome_login.py b/agentbar/chrome_login.py new file mode 100644 index 0000000..80e4dd4 --- /dev/null +++ b/agentbar/chrome_login.py @@ -0,0 +1,273 @@ +"""Interactive corp SSO login through an isolated Chrome CDP session. + +This mirrors the reliable part of AIUsageBar's setup flow: launch a real Chrome +window with a temporary profile, let Kit/0Pass complete SSO, read the target +host's cookies through Chrome DevTools Protocol, then validate them against the +real provider API. No cookie is persisted until validation succeeds. +""" + +from __future__ import annotations + +import json +import shutil +import socket +import subprocess +import tempfile +import threading +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Callable + +import websocket + +from .browser_cookies import ImportedCookie +from .config import PROVIDER_HOSTS, PROVIDER_LOGIN_URLS +from .usage import MyTokenUsageFetcher, TokenverseUsageFetcher + + +class ChromeLoginError(RuntimeError): + pass + + +_READY_COOKIE_NAMES = { + "mytoken": {"accessproxy_session", "JSESSIONID", "KP_SSO_SID", "ktrace-context"}, + "tokenverse": {"accessproxy_session"}, +} + + +def _chrome_binary() -> Path | None: + candidates = ( + Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), + Path.home() / "Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + Path("/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"), + ) + return next((path for path in candidates if path.is_file()), None) + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _domain_matches(cookie_host: str, target_host: str) -> bool: + cookie_host = (cookie_host or "").lstrip(".").lower() + target_host = target_host.lower() + return target_host == cookie_host or target_host.endswith("." + cookie_host) + + +def _cookie_header(raw_cookies: list[dict], target_host: str) -> str: + """Build a deterministic Cookie header for target_host without logging values.""" + now = time.time() + candidates = [] + for entry in raw_cookies: + name = str(entry.get("name") or "").strip() + value = str(entry.get("value") or "") + domain = str(entry.get("domain") or "") + expires = entry.get("expires") + if not name or not value or not _domain_matches(domain, target_host): + continue + if isinstance(expires, (int, float)) and expires > 0 and expires <= now: + continue + path = str(entry.get("path") or "/") + exact = int(domain.lstrip(".").lower() == target_host.lower()) + candidates.append((exact, len(domain.lstrip(".")), len(path), name, value)) + candidates.sort(reverse=True) + seen = set() + parts = [] + for _, _, _, name, value in candidates: + if name in seen: + continue + seen.add(name) + parts.append(f"{name}={value}") + return "; ".join(parts) + + +def _validate_cookie(provider: str, header: str) -> bool: + if provider == "mytoken": + snap = MyTokenUsageFetcher(cookie=header).fetch() + elif provider == "tokenverse": + snap = TokenverseUsageFetcher(cookie=header).fetch() + else: + return False + return bool(snap and not snap.error and snap.windows) + + +class ChromeCDPLogin: + def __init__(self, provider: str): + if provider not in PROVIDER_HOSTS: + raise ChromeLoginError(f"未知 provider: {provider}") + self.provider = provider + self.host = PROVIDER_HOSTS[provider] + self.login_url = PROVIDER_LOGIN_URLS[provider] + self.port = _free_port() + self._cancel = threading.Event() + self._process: subprocess.Popen | None = None + self._profile_dir: Path | None = None + + def cancel(self) -> None: + self._cancel.set() + # UI 主线程只发终止信号,不在这里 wait/rmtree;run() 的后台线程 + # 会在 finally 里完成回收,避免“取消登录”按钮卡住菜单栏。 + process = self._process + if process and process.poll() is None: + process.terminate() + + def close(self) -> None: + process = self._process + self._process = None + if process and process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=3) + profile_dir = self._profile_dir + self._profile_dir = None + if profile_dir: + shutil.rmtree(profile_dir, ignore_errors=True) + + def run( + self, + on_status: Callable[[str], None] | None = None, + timeout: float = 300, + ) -> ImportedCookie: + chrome = _chrome_binary() + if chrome is None: + raise ChromeLoginError("未检测到 Google Chrome / Chrome Beta") + status = on_status or (lambda _message: None) + self._profile_dir = Path(tempfile.mkdtemp(prefix="agentbar-chrome-login-")) + args = [ + str(chrome), + f"--remote-debugging-port={self.port}", + "--remote-allow-origins=*", + f"--user-data-dir={self._profile_dir}", + "--no-first-run", + "--no-default-browser-check", + "--new-window", + self.login_url, + ] + status("正在启动独立 Chrome 登录窗口…") + try: + self._process = subprocess.Popen( + args, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + self._wait_debugger(timeout=20) + status("请在弹出的 Chrome 中完成企业 SSO 登录…") + deadline = time.time() + timeout + last_count = -1 + while time.time() < deadline: + self._check_cancelled() + raw = self._fetch_all_cookies() + header = _cookie_header(raw, self.host) + fields = { + part.split("=", 1)[0].strip() + for part in header.split(";") + if "=" in part + } + count = len(fields) + if count != last_count: + last_count = count + status( + f"已捕获 {count} 个 Cookie,等待 SSO 完成…" + if count + else "等待企业登录 Cookie…" + ) + ready = _READY_COOKIE_NAMES[self.provider].issubset(fields) + if ready: + status("已捕获完整 Cookie,正在用真实额度接口验证…") + if _validate_cookie(self.provider, header): + return ImportedCookie( + header=header, + source="Chrome 企业 SSO 登录", + count=count, + ) + status("Cookie 已捕获,但额度接口尚未通过,继续等待登录完成…") + self._cancel.wait(1.5) + raise ChromeLoginError("浏览器登录超时(5 分钟)") + except OSError as exc: + raise ChromeLoginError(f"启动 Chrome 失败:{exc}") from exc + finally: + self.close() + + def _check_cancelled(self) -> None: + if self._cancel.is_set(): + raise ChromeLoginError("已取消浏览器登录") + if self._process and self._process.poll() is not None: + raise ChromeLoginError("Chrome 登录窗口已关闭") + + def _json_get(self, path: str, timeout: float = 5) -> object: + request = urllib.request.Request( + f"http://127.0.0.1:{self.port}{path}", + headers={"Accept": "application/json"}, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + def _wait_debugger(self, timeout: float) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + self._check_cancelled() + try: + if isinstance(self._json_get("/json/version", timeout=1), dict): + return + except (OSError, urllib.error.URLError, json.JSONDecodeError): + pass + self._cancel.wait(0.5) + raise ChromeLoginError("Chrome 调试端口启动超时") + + def _fetch_all_cookies(self) -> list[dict]: + try: + targets = self._json_get("/json/list") + except (OSError, urllib.error.URLError, json.JSONDecodeError): + return [] + if not isinstance(targets, list): + return [] + page = next( + ( + target + for target in targets + if target.get("type") == "page" and target.get("webSocketDebuggerUrl") + and self.host in str(target.get("url") or "") + ), + None, + ) + if page is None: + page = next( + ( + target + for target in targets + if target.get("type") == "page" and target.get("webSocketDebuggerUrl") + ), + None, + ) + if not page: + return [] + try: + connection = websocket.create_connection( + page["webSocketDebuggerUrl"], + timeout=6, + origin=f"http://127.0.0.1:{self.port}", + ) + except (OSError, ValueError, websocket.WebSocketException): + return [] + try: + connection.send(json.dumps({"id": 1, "method": "Network.enable"})) + connection.send(json.dumps({"id": 2, "method": "Network.getAllCookies"})) + deadline = time.time() + 6 + while time.time() < deadline: + message = json.loads(connection.recv()) + if message.get("id") == 2: + cookies = (message.get("result") or {}).get("cookies") or [] + return cookies if isinstance(cookies, list) else [] + except (OSError, ValueError, websocket.WebSocketException): + return [] + finally: + connection.close() + return [] diff --git a/agentbar/config.py b/agentbar/config.py index 4760b42..75ec449 100644 --- a/agentbar/config.py +++ b/agentbar/config.py @@ -20,6 +20,13 @@ "mytoken": {"enabled": False, "cookie": "", "unit": "credits", "refresh_seconds": 300}, "tokenverse": {"enabled": False, "cookie": "", "unit": "credits", "refresh_seconds": 300}, } +PROVIDER_HOSTS = { + "mytoken": "mytoken.corp.kuaishou.com", + "tokenverse": "tokenverse.corp.kuaishou.com", +} +PROVIDER_LOGIN_URLS = { + name: f"https://{host}/usage" for name, host in PROVIDER_HOSTS.items() +} PROVIDER_UNITS = ("credits", "percent", "token") diff --git a/agentbar/menu_spec.py b/agentbar/menu_spec.py index 6c4006a..0718f48 100644 --- a/agentbar/menu_spec.py +++ b/agentbar/menu_spec.py @@ -161,11 +161,34 @@ def _quota_submenu(tool: str, qi: dict) -> dict: children.append(_action(f"⚠ {qi['error'][:70]}")) children.append(_sep()) children.append(_action("↻ 立即刷新额度", "refresh_quota")) + if tool in ("mytoken", "tokenverse"): + children.append(_action(f"⚙ 配置 {_provider_name(tool)}…", "provider_settings")) if tool == "claude" and "Keychain" in (qi.get("error") or ""): children.append(_action("🔑 授权读取 Claude Keychain…", "authorize_keychain")) return _submenu(f"{dot} {_provider_name(tool)} · {_quota_compact(qi)}", children) +def _provider_setup_submenu(tool: str, cfg: dict) -> dict: + enabled = bool(cfg.get("enabled")) + cookie_set = bool(cfg.get("cookie_set")) + if enabled and cookie_set: + state = "等待刷新" + detail = "已启用且 Cookie 已配置,正在等待额度接口返回。" + elif enabled: + state = "缺少 Cookie" + detail = "已启用,但尚未配置企业 SSO Cookie。" + elif cookie_set: + state = "未启用" + detail = "Cookie 已保存;启用后才会请求并显示额度。" + else: + state = "未配置" + detail = "点击配置,从本机浏览器导入企业 SSO 登录态。" + return _submenu( + f"⚪ {_provider_name(tool)} · {state}", + [_info(detail), _action(f"⚙ 配置 {_provider_name(tool)}…", "provider_settings")], + ) + + def _mobile_submenu(t: dict) -> dict: """手机访问子菜单:局域网扫码 + 公网隧道(按隧道状态变形)。""" children = [_action("📶 局域网扫码(同一 Wi-Fi)…", "mobile_qr")] @@ -225,11 +248,20 @@ def build_menu_spec(snapshot: dict) -> list[dict]: if qi: rows.append(_quota_submenu(tool, qi)) shown = True + # 未配置的内部 provider 也必须出现在真实菜单中,否则用户无从发现入口。 + # 老测试/第三方调用未提供 provider_config 时保持历史输出不变。 + provider_config = snapshot.get("provider_config") + if isinstance(provider_config, dict): + for tool in ("mytoken", "tokenverse"): + if tool not in quota: + rows.append(_provider_setup_submenu(tool, provider_config.get(tool) or {})) + shown = True if shown: rows.append(_sep()) rows.append(_action("↗ 打开任务面板", "open_panel")) rows.append(_action("+ 快速添加任务…", "quick_add")) + rows.append(_action("⚙ 内部额度设置…", "provider_settings")) rows.append(_mobile_submenu(snapshot.get("tunnel") or {})) rows.append(_sep()) if snapshot.get("paused"): diff --git a/agentbar/menubar.py b/agentbar/menubar.py index 9d946c6..2b8346e 100644 --- a/agentbar/menubar.py +++ b/agentbar/menubar.py @@ -158,6 +158,7 @@ def __init__(self, core: Scheduler, settings: Settings, server: ApiServer): self._menu = None self._timer = None self._panel = None # 原生任务面板窗口(懒加载) + self._provider_panel = None # MyToken / Tokenverse 原生设置窗口(懒加载) self._ring_key = None # 双环图标缓存键(进度没变不重画,避免 2s 一次的无谓刷新) self._qr_window = None # 手机访问二维码窗口(懒加载) self._qr_webview = None # 二维码窗口里的 WKWebView(内容可切换 LAN/公网) @@ -325,6 +326,8 @@ def _dispatch(self, action: str) -> None: self._show_panel(False) elif action == "quick_add": self._show_panel(True) + elif action == "provider_settings": + self._show_provider_settings() elif action == "refresh_quota": self.core.quota.refresh_now() # 异步:只置事件 elif action == "authorize_keychain": @@ -357,6 +360,18 @@ def _show_panel(self, focus_prompt: bool) -> None: ) self._panel.show_(focus_prompt) + def _show_provider_settings(self) -> None: + """原生内部额度设置:菜单里始终可发现,不再藏在 Web 面板。""" + if self._provider_panel is None: + from .provider_window import ProviderSettingsWindowController + + self._provider_panel = ( + ProviderSettingsWindowController.alloc().initWithCore_settings_( + self.core, self.settings + ) + ) + self._provider_panel.show_(None) + def _show_mobile_qr(self) -> None: """局域网扫码:http://:/m?token=…(手机与 Mac 同一 Wi-Fi)。""" url = self.server.mobile_url() diff --git a/agentbar/panel_window.py b/agentbar/panel_window.py index f5f4eb9..c10b3bd 100644 --- a/agentbar/panel_window.py +++ b/agentbar/panel_window.py @@ -95,6 +95,7 @@ def initWithCore_settings_server_(self, core, settings, server): self._current_efforts = [] self._transcript_windows = {} self._transcript_meta = {} + self._provider_settings_panel = None self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( 2.0, self, "onTick:", None, True ) @@ -258,6 +259,11 @@ def _build(self): self.pause_btn.setAutoresizingMask_(NSViewMaxYMargin) v.addSubview_(self.pause_btn) + provider_btn = _button("⚙ 额度设置", W - PAD - 234, PAD + 26, 118, + self, "onProviderSettings:") + provider_btn.setAutoresizingMask_(NSViewMaxYMargin | 1) + v.addSubview_(provider_btn) + self.transcript_btn = _button("📄 查看对话", W - PAD - 108, PAD + 26, 108, self, "onShowTranscript:") self.transcript_btn.setAutoresizingMask_(NSViewMaxYMargin | 1) @@ -479,6 +485,17 @@ def onShowTranscript_(self, _sender): return self._open_transcript_window(t) + def onProviderSettings_(self, _sender): + if self._provider_settings_panel is None: + from .provider_window import ProviderSettingsWindowController + + self._provider_settings_panel = ( + ProviderSettingsWindowController.alloc().initWithCore_settings_( + self.core, self.settings + ) + ) + self._provider_settings_panel.show_(None) + @objc.python_method def _open_transcript_window(self, task_dict: dict): tid = task_dict["id"] diff --git a/agentbar/provider_window.py b/agentbar/provider_window.py new file mode 100644 index 0000000..f80fd17 --- /dev/null +++ b/agentbar/provider_window.py @@ -0,0 +1,373 @@ +"""Native MyToken / Tokenverse settings window. + +The Web panel still exposes the same settings, but this window is the primary +macOS entry point so users do not need to discover a hidden browser-only page. +Cookie access only happens after an explicit button click. +""" + +from __future__ import annotations + +import logging +import threading + +import objc +from AppKit import ( + NSAlert, + NSAlertFirstButtonReturn, + NSApp, + NSBackingStoreBuffered, + NSButton, + NSFont, + NSMakeRect, + NSPopUpButton, + NSSecureTextField, + NSTextField, + NSWindow, + NSWindowStyleMaskClosable, + NSWindowStyleMaskMiniaturizable, + NSWindowStyleMaskTitled, +) +from PyObjCTools import AppHelper +from Foundation import NSObject + +from .browser_cookies import CookieImportError, import_cookie_header +from .chrome_login import ChromeCDPLogin, ChromeLoginError +from .config import ( + DEFAULT_PROVIDERS, + PROVIDER_HOSTS, + PROVIDER_UNITS, + save_settings, +) + +log = logging.getLogger("agentbar.provider_window") + +W, H = 720, 500 +PAD = 18 +_PROVIDERS = ("mytoken", "tokenverse") +_NAMES = {"mytoken": "MyToken", "tokenverse": "Tokenverse"} +_TITLE_OPTIONS = ( + ("Claude", "claude"), + ("Codex", "codex"), + ("MyToken", "mytoken"), + ("Tokenverse", "tokenverse"), +) + + +def _label(text, x, y, w, h=18, *, bold=False, dim=False): + label = NSTextField.labelWithString_(text) + label.setFrame_(NSMakeRect(x, y, w, h)) + label.setFont_( + NSFont.boldSystemFontOfSize_(12) + if bold + else NSFont.systemFontOfSize_(11 if dim else 12) + ) + if dim: + label.setTextColor_(label.textColor().colorWithAlphaComponent_(0.58)) + return label + + +def _button(title, x, y, w, target, selector, h=27): + button = NSButton.alloc().initWithFrame_(NSMakeRect(x, y, w, h)) + button.setTitle_(title) + button.setBezelStyle_(1) + button.setTarget_(target) + button.setAction_(selector) + return button + + +class ProviderSettingsWindowController(NSObject): + + def initWithCore_settings_(self, core, settings): + self = objc.super(ProviderSettingsWindowController, self).init() + if self is None: + return None + self.core = core + self.settings = settings + self.window = None + self._enabled = {} + self._unit = {} + self._refresh = {} + self._cookie = {} + self._status = {} + self._import_buttons = {} + self._chrome_buttons = {} + self._chrome_logins = {} + return self + + def show_(self, _sender): + if self.window is None: + self._build() + self._reload_controls() + try: + NSApp.activateIgnoringOtherApps_(True) + except Exception: + pass + self.window.makeKeyAndOrderFront_(None) + + def _build(self): + mask = ( + NSWindowStyleMaskTitled + | NSWindowStyleMaskClosable + | NSWindowStyleMaskMiniaturizable + ) + self.window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_( + NSMakeRect(0, 0, W, H), mask, NSBackingStoreBuffered, False + ) + self.window.setTitle_("AgentBar 内部额度设置") + self.window.setReleasedWhenClosed_(False) + self.window.center() + view = self.window.contentView() + + view.addSubview_(_label("内部额度设置", PAD, H - 42, 220, 22, bold=True)) + view.addSubview_(_label( + "参考 AIUsageBar:点“浏览器登录”,在独立 Chrome 完成 SSO;验证通过后自动保存、刷新并出现在菜单。", + PAD, H - 65, W - 2 * PAD, 18, dim=True, + )) + + view.addSubview_(_label("菜单栏数字", PAD, H - 103, 90)) + self.title_popup = NSPopUpButton.alloc().initWithFrame_pullsDown_( + NSMakeRect(PAD + 92, H - 109, 180, 27), False + ) + self.title_popup.addItemsWithTitles_([f"标题:{name}" for name, _ in _TITLE_OPTIONS]) + view.addSubview_(self.title_popup) + + self._build_provider_row(view, "mytoken", H - 250) + self._build_provider_row(view, "tokenverse", H - 395) + + self._message = _label("", PAD, 23, W - 200, 20, dim=True) + view.addSubview_(self._message) + save = _button("保存并刷新", W - PAD - 142, 18, 142, self, "onSave:", 30) + save.setKeyEquivalent_("\r") + view.addSubview_(save) + + def _build_provider_row(self, view, provider: str, y: int): + name = _NAMES[provider] + enabled = NSButton.alloc().initWithFrame_(NSMakeRect(PAD, y + 92, 150, 24)) + enabled.setButtonType_(3) # NSSwitchButton + enabled.setTitle_(name) + enabled.setFont_(NSFont.boldSystemFontOfSize_(13)) + view.addSubview_(enabled) + self._enabled[provider] = enabled + + status = _label("", PAD + 158, y + 94, W - PAD * 2 - 158, 20, dim=True) + view.addSubview_(status) + self._status[provider] = status + + view.addSubview_(_label("展示单位", PAD, y + 57, 70)) + unit = NSPopUpButton.alloc().initWithFrame_pullsDown_( + NSMakeRect(PAD + 72, y + 52, 110, 27), False + ) + unit.addItemsWithTitles_(list(PROVIDER_UNITS)) + view.addSubview_(unit) + self._unit[provider] = unit + + view.addSubview_(_label("刷新秒数", PAD + 198, y + 57, 70)) + refresh = NSTextField.alloc().initWithFrame_(NSMakeRect(PAD + 270, y + 53, 76, 24)) + refresh.setPlaceholderString_("300") + view.addSubview_(refresh) + self._refresh[provider] = refresh + + login = _button("🌐 浏览器登录", PAD + 365, y + 51, 122, self, "onChromeLogin:") + login.setRepresentedObject_(provider) + view.addSubview_(login) + self._chrome_buttons[provider] = login + + import_btn = _button("读取已有登录", PAD + 495, y + 51, 122, self, "onImport:") + import_btn.setRepresentedObject_(provider) + view.addSubview_(import_btn) + self._import_buttons[provider] = import_btn + + view.addSubview_(_label("Cookie", PAD, y + 20, 55)) + cookie = NSSecureTextField.alloc().initWithFrame_( + NSMakeRect(PAD + 55, y + 16, W - 2 * PAD - 55 - 90, 24) + ) + cookie.setPlaceholderString_("可选:手动粘贴 Request Headers 的 Cookie;留空不修改") + view.addSubview_(cookie) + self._cookie[provider] = cookie + + clear = _button("清空", W - PAD - 82, y + 15, 82, self, "onClear:", 26) + clear.setRepresentedObject_(provider) + view.addSubview_(clear) + + @objc.python_method + def _reload_controls(self): + title_values = [value for _, value in _TITLE_OPTIONS] + try: + self.title_popup.selectItemAtIndex_(title_values.index(self.settings.title_provider)) + except ValueError: + self.title_popup.selectItemAtIndex_(1) + + for provider in _PROVIDERS: + defaults = DEFAULT_PROVIDERS[provider] + cfg = (self.settings.providers or {}).get(provider) or defaults + self._enabled[provider].setState_(1 if cfg.get("enabled") else 0) + unit = cfg.get("unit") if cfg.get("unit") in PROVIDER_UNITS else defaults["unit"] + self._unit[provider].selectItemWithTitle_(unit) + self._refresh[provider].setStringValue_( + str(int(cfg.get("refresh_seconds") or defaults["refresh_seconds"])) + ) + self._cookie[provider].setStringValue_("") + cookie = str(cfg.get("cookie") or "") + if cookie: + names = [part.strip().split("=", 1)[0] for part in cookie.split(";") if "=" in part] + preview = ", ".join(names[:4]) + (" …" if len(names) > 4 else "") + state = f"Cookie 已配置:{preview}" + elif cfg.get("enabled"): + state = "已启用,但缺少 Cookie" + else: + state = "未配置 Cookie" + self._status[provider].setStringValue_(state) + + def onChromeLogin_(self, sender): + provider = str(sender.representedObject() or "") + active = self._chrome_logins.get(provider) + if active is not None: + active.cancel() + self._status[provider].setStringValue_("正在取消浏览器登录…") + return + try: + login = ChromeCDPLogin(provider) + except ChromeLoginError as exc: + self._alert("无法启动浏览器登录", str(exc)) + return + self._chrome_logins[provider] = login + sender.setTitle_("取消登录") + self._status[provider].setStringValue_("正在启动独立 Chrome 登录窗口…") + + def update_status(message): + AppHelper.callAfter(self._set_provider_status, provider, message) + + def work(): + try: + imported = login.run(on_status=update_status) + error = "" + except ChromeLoginError as exc: + imported = None + error = str(exc) + except Exception as exc: + log.exception("Chrome CDP login failed") + imported = None + error = str(exc) + AppHelper.callAfter(self._finish_chrome_login, provider, imported, error) + + threading.Thread( + target=work, + name=f"agentbar-chrome-login-{provider}", + daemon=True, + ).start() + + @objc.python_method + def _set_provider_status(self, provider, message): + self._status[provider].setStringValue_(message) + + @objc.python_method + def _finish_chrome_login(self, provider, imported, error): + self._chrome_logins.pop(provider, None) + self._chrome_buttons[provider].setTitle_("🌐 浏览器登录") + if error or imported is None: + message = error or "未捕获到 Cookie" + self._status[provider].setStringValue_(f"浏览器登录失败:{message}") + if "已取消" not in message: + self._alert("浏览器登录失败", message) + return + self._save_imported_cookie(provider, imported) + + def onImport_(self, sender): + provider = str(sender.representedObject() or "") + host = PROVIDER_HOSTS.get(provider) + if not host: + return + sender.setEnabled_(False) + self._status[provider].setStringValue_("正在读取 Chrome / Edge / Brave 已有登录态…") + + def work(): + try: + imported = import_cookie_header(host) + error = "" + except CookieImportError as exc: + imported = None + error = str(exc) + except Exception as exc: # Keychain/SQLite 失败不能带崩 AppKit 主线程 + log.exception("provider cookie import failed") + imported = None + error = str(exc) + AppHelper.callAfter(self._finish_import, provider, imported, error) + + threading.Thread( + target=work, + name=f"agentbar-cookie-{provider}", + daemon=True, + ).start() + + @objc.python_method + def _finish_import(self, provider, imported, error): + self._import_buttons[provider].setEnabled_(True) + if error or imported is None: + message = error or "未导入到 Cookie" + self._status[provider].setStringValue_(f"导入失败:{message}") + self._alert("Cookie 导入失败", message) + return + self._save_imported_cookie(provider, imported) + + @objc.python_method + def _save_imported_cookie(self, provider, imported): + cfg = self.settings.providers.setdefault(provider, dict(DEFAULT_PROVIDERS[provider])) + cfg["enabled"] = True + cfg["cookie"] = imported.header + save_settings(self.settings) + self.core.quota.reload_fetchers() + self._reload_controls() + self._message.setStringValue_( + f"{_NAMES[provider]}:已从 {imported.source} 导入 {imported.count} 个 Cookie,并触发刷新" + ) + + def onSave_(self, _sender): + for provider in _PROVIDERS: + defaults = DEFAULT_PROVIDERS[provider] + cfg = self.settings.providers.setdefault(provider, dict(defaults)) + cfg["enabled"] = bool(self._enabled[provider].state()) + unit = str(self._unit[provider].titleOfSelectedItem() or defaults["unit"]) + cfg["unit"] = unit if unit in PROVIDER_UNITS else defaults["unit"] + try: + cfg["refresh_seconds"] = max( + 60, int(str(self._refresh[provider].stringValue()).strip() or 300) + ) + except ValueError: + cfg["refresh_seconds"] = defaults["refresh_seconds"] + cookie = str(self._cookie[provider].stringValue()).strip() + if cookie: + cfg["cookie"] = cookie + + title_index = self.title_popup.indexOfSelectedItem() + if 0 <= title_index < len(_TITLE_OPTIONS): + self.settings.title_provider = _TITLE_OPTIONS[title_index][1] + save_settings(self.settings) + self.core.quota.reload_fetchers() + self._reload_controls() + self._message.setStringValue_("配置已保存;额度刷新已触发,稍后可在菜单中查看。") + + def onClear_(self, sender): + provider = str(sender.representedObject() or "") + if provider not in DEFAULT_PROVIDERS: + return + alert = NSAlert.alloc().init() + alert.setMessageText_(f"清空 {_NAMES[provider]} 登录态?") + alert.setInformativeText_("将删除 AgentBar 保存的 Cookie 并停用该额度来源。") + alert.addButtonWithTitle_("清空") + alert.addButtonWithTitle_("取消") + if alert.runModal() != NSAlertFirstButtonReturn: + return + cfg = self.settings.providers.setdefault(provider, dict(DEFAULT_PROVIDERS[provider])) + cfg["cookie"] = "" + cfg["enabled"] = False + save_settings(self.settings) + self.core.quota.reload_fetchers() + self._reload_controls() + self._message.setStringValue_(f"{_NAMES[provider]} Cookie 已清空并停用。") + + @objc.python_method + def _alert(self, title, text): + alert = NSAlert.alloc().init() + alert.setMessageText_(title) + alert.setInformativeText_(text) + alert.runModal() diff --git a/agentbar/scheduler.py b/agentbar/scheduler.py index e0f51cc..378eebd 100644 --- a/agentbar/scheduler.py +++ b/agentbar/scheduler.py @@ -17,7 +17,7 @@ from . import __version__ from .adapters.base import Outcome, get_registry -from .config import Settings +from .config import DEFAULT_PROVIDERS, Settings from .models import ( FINISHED_STATES, PROFILES, @@ -381,6 +381,14 @@ def snapshot(self) -> dict: status = "waiting" else: status = "idle" + provider_config = {} + for name in DEFAULT_PROVIDERS: + cfg = (self.settings.providers or {}).get(name) or {} + provider_config[name] = { + "enabled": bool(cfg.get("enabled")), + "cookie_set": bool(str(cfg.get("cookie") or "").strip()), + "unit": cfg.get("unit") or DEFAULT_PROVIDERS[name]["unit"], + } return { "version": __version__, "status": status, @@ -397,6 +405,9 @@ def snapshot(self) -> dict: ) }, "title_provider": self.settings.title_provider, + # 菜单只需要无敏感信息的配置摘要,用于展示“未配置/待刷新”入口。 + # Cookie 永远不进入 snapshot、日志或前端状态转储。 + "provider_config": provider_config, "cli_processes": self._cli_processes_snapshot(), "settings": { "max_parallel": self.settings.max_parallel, diff --git a/agentbar/server.py b/agentbar/server.py index 2c24ef7..fd9c476 100644 --- a/agentbar/server.py +++ b/agentbar/server.py @@ -20,19 +20,19 @@ from . import __version__ from .browser_cookies import CookieImportError, import_cookie_header -from .config import DEFAULT_PROVIDERS, PROVIDER_UNITS, Settings, save_settings +from .config import ( + DEFAULT_PROVIDERS, + PROVIDER_HOSTS, + PROVIDER_UNITS, + Settings, + save_settings, +) from .scheduler import Scheduler log = logging.getLogger("agentbar.server") MAX_BODY = 200_000 ALLOWED_HOSTS = {"127.0.0.1", "localhost"} -PROVIDER_HOSTS = { - "mytoken": "mytoken.corp.kuaishou.com", - "tokenverse": "tokenverse.corp.kuaishou.com", -} - - def _host_of(header: str) -> str: """Extract host part from a Host header ([::1]:8737 / 10.1.2.3:8737 / localhost).""" header = header or "" @@ -412,7 +412,7 @@ def do_POST(self): self._json(404, {"ok": False, "error": "menu bar 未运行(headless 无此通道)"}) return - if action not in {"open_panel", "quick_add", "refresh_quota", + if action not in {"open_panel", "quick_add", "provider_settings", "refresh_quota", "tunnel_start", "tunnel_stop"}: self._json(400, {"ok": False, "error": f"action 不在白名单: {action!r}"}) return diff --git a/agentbar/web/index.html b/agentbar/web/index.html index a1d9735..6d9d97c 100644 --- a/agentbar/web/index.html +++ b/agentbar/web/index.html @@ -126,7 +126,7 @@

添加任务

-
+

内部额度配置

MyToken / Tokenverse 使用本机浏览器登录态;Cookie 只保存在 ~/.agentbar/config.json。
周期自动刷新 - +
+
订阅来源 · 保存时仅刷新有变更且凭据就绪的来源;关闭周期刷新后不再自动轮询。
+
+
内部来源 · MyToken / Tokenverse 使用本机浏览器登录态;Cookie 只保存在 ~/.agentbar/config.json。
@@ -171,21 +175,31 @@

本机正在运行的 AI CLI

正常文字") assert "