افزودن ارائه‌دهندهٔ agy (Google Antigravity) با لاگین headless و پول چرخشی اکانت

- Adapter اکانت‌محور با چرخش round-robin و بنچ‌شدن خودکار روی ۴۲۹/۴۰۱
- ورود ادمین از طریق URL بازگشتی گوگل (PKCE) و ذخیرهٔ رمزنگاری‌شدهٔ refresh token
- صفحهٔ مدیریت اکانت‌های agy در پنل ادمین به همراه تست و فعال/غیرفعال‌سازی
- مهاجرت Alembic b1c2d3e4f5a6 برای ستون‌های اکانت agy
- seed همیشه کاتالوگ agy را می‌سازد و فقط با AI_DEFAULT_PROVIDER=agy مسیریابی می‌شود
This commit is contained in:
mjalmoudy94
2026-10-13 08:20:27 +03:30
parent 54074f10c3
commit 3d1e98f4fb
25 changed files with 3073 additions and 10 deletions
@@ -0,0 +1,393 @@
"use client";
import * as React from "react";
import type { AgyAccount } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
ConfirmDialog,
EmptyState,
ErrorState,
Field,
LoadingState,
Modal,
ToastStack,
type ToastMessage,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
/**
* agy account management (platform owner only).
*
* agy has no API keys — an account is a Google identity. The login flow is the
* headless paste-URL shape of Google OAuth:
*
* 1. start login → the API returns an auth URL;
* 2. the owner opens it, signs in, and Google redirects to a loopback URL
* that deliberately never finishes loading — the code and state are
* already in the address bar;
* 3. the owner pastes that whole URL back and the API redeems it.
*
* The rotation pool spreads calls across the accounts listed here and benches
* a rate-limited one until its quota window resets, so adding more accounts is
* the sanctioned way to raise agy throughput.
*/
const STATUS_LABEL: Record<AgyAccount["status"], string> = {
active: "فعال",
cooldown: "استراحت (سهمیه)",
error: "خطا — نیاز به لاگین مجدد",
};
export default function AdminAgyAccountsPage() {
const [accounts, setAccounts] = React.useState<AgyAccount[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [loginOpen, setLoginOpen] = React.useState(false);
const [toDelete, setToDelete] = React.useState<AgyAccount | null>(null);
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
const pushToast = React.useCallback(
(title: string, description?: string) => {
const id = Math.random().toString(36).slice(2);
setToasts((current) => [...current, { id, title, description }]);
window.setTimeout(
() => setToasts((current) => current.filter((t) => t.id !== id)),
6000,
);
},
[],
);
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
setAccounts(await api.agyAccounts());
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت اکانت‌ها ناموفق بود.");
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
void load();
}, [load]);
const onAccountAdded = (account: AgyAccount) => {
setAccounts((current) => {
const exists = current.some((a) => a.id === account.id);
return exists
? current.map((a) => (a.id === account.id ? account : a))
: [account, ...current];
});
setLoginOpen(false);
pushToast("اکانت agy اضافه شد", account.email || undefined);
};
const onSetActive = async (account: AgyAccount, active: boolean) => {
try {
const updated = await api.agySetAccountActive(account.id, active);
setAccounts((current) => current.map((a) => (a.id === updated.id ? updated : a)));
} catch (err) {
pushToast(
"تغییر وضعیت ناموفق",
err instanceof ApiError ? err.message : undefined,
);
}
};
const onDelete = async () => {
if (!toDelete) return;
try {
await api.agyDeleteAccount(toDelete.id);
setAccounts((current) => current.filter((a) => a.id !== toDelete.id));
pushToast("اکانت حذف شد", toDelete.email || undefined);
} catch (err) {
pushToast("حذف ناموفق", err instanceof ApiError ? err.message : undefined);
} finally {
setToDelete(null);
}
};
return (
<div className="flex flex-col gap-5">
<header className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">اکانت‌های agy</h1>
<p className="max-w-2xl text-sm text-slate-400">
agy کلید API ندارد: هر اکانت یک هویت Google است که از طریق OAuth وارد
می‌شود. هر تعداد اکانت اضافه کنید؛ پول چرخشی بار را بین آن‌ها پخش
می‌کند و اکانت محدودشده را تا پایان پنجرهٔ سهمیه کنار می‌گذارد.
</p>
</div>
<Button onClick={() => setLoginOpen(true)}>افزودن اکانت agy</Button>
</header>
{loading ? (
<LoadingState label="در حال دریافت اکانت‌ها…" />
) : error ? (
<ErrorState message={error} onRetry={load} />
) : accounts.length === 0 ? (
<EmptyState
title="هنوز اکانتی ثبت نشده است"
description="با دکمهٔ «افزودن اکانت agy» اولین اکانت Google را وارد کنید."
/>
) : (
<div className="flex flex-col gap-3">
{accounts.map((account) => (
<AgyAccountCard
key={account.id}
account={account}
onSetActive={onSetActive}
onDelete={() => setToDelete(account)}
/>
))}
</div>
)}
{loginOpen && (
<LoginModal
onClose={() => setLoginOpen(false)}
onDone={onAccountAdded}
onError={(message) => pushToast("لاگین ناموفق", message)}
/>
)}
<ConfirmDialog
open={toDelete !== null}
title="حذف اکانت agy"
description={
toDelete
? `اکانت «${toDelete.email || toDelete.name}» حذف شود؟ این عملیات قابل بازگشت نیست.`
: ""
}
confirmLabel="حذف"
onConfirm={onDelete}
onCancel={() => setToDelete(null)}
/>
<ToastStack toasts={toasts} onDismiss={(id) =>
setToasts((current) => current.filter((t) => t.id !== id))
} />
</div>
);
}
function AgyAccountCard({
account,
onSetActive,
onDelete,
}: {
account: AgyAccount;
onSetActive: (account: AgyAccount, active: boolean) => void;
onDelete: () => void;
}) {
const [testing, setTesting] = React.useState(false);
const [testResult, setTestResult] = React.useState<string | null>(null);
const onTest = async () => {
setTesting(true);
setTestResult(null);
try {
const result = await api.agyTestAccount(account.id);
setTestResult(
result.ok ? `پاسخ دریافت شد: ${result.detail}` : `تست ناموفق: ${result.detail}`,
);
} catch (err) {
setTestResult(err instanceof ApiError ? err.message : "تست ناموفق بود.");
} finally {
setTesting(false);
}
};
const cooldownRemaining = React.useMemo(() => {
if (account.status !== "cooldown" || !account.cooldown_until) return null;
const ms = new Date(account.cooldown_until).getTime() - Date.now();
if (ms <= 0) return null;
return Math.ceil(ms / 60000);
}, [account.status, account.cooldown_until]);
return (
<Card>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<p className="ltr-inline text-sm font-medium text-slate-100">
{account.email || account.name}
</p>
<Badge tone={account.is_active ? "success" : "neutral"}>
{account.is_active ? "فعال" : "غیرفعال"}
</Badge>
<Badge
tone={
account.status === "active"
? "info"
: account.status === "cooldown"
? "warning"
: "danger"
}
>
{STATUS_LABEL[account.status]}
{cooldownRemaining ? ` · ${cooldownRemaining} دقیقه` : ""}
</Badge>
</div>
<p className="text-xs text-slate-500">
{account.last_used_at
? `آخرین استفاده: ${new Date(account.last_used_at).toLocaleString("fa-IR")}`
: "هنوز استفاده نشده"}
{account.last_error ? ` · ${account.last_error}` : ""}
</p>
{testResult && <p className="text-xs text-slate-400">{testResult}</p>}
</div>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" size="sm" loading={testing} onClick={onTest}>
تست
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onSetActive(account, !account.is_active)}
>
{account.is_active ? "غیرفعال‌سازی" : "فعال‌سازی"}
</Button>
<Button variant="danger" size="sm" onClick={onDelete}>
حذف
</Button>
</div>
</div>
</Card>
);
}
function LoginModal({
onClose,
onDone,
onError,
}: {
onClose: () => void;
onDone: (account: AgyAccount) => void;
onError: (message: string) => void;
}) {
const [step, setStep] = React.useState<"start" | "redeem">("start");
const [authUrl, setAuthUrl] = React.useState("");
const [loginId, setLoginId] = React.useState("");
const [callbackUrl, setCallbackUrl] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [formError, setFormError] = React.useState<string | null>(null);
const onStart = async () => {
setBusy(true);
setFormError(null);
try {
const started = await api.agyStartLogin();
setLoginId(started.login_id);
setAuthUrl(started.auth_url);
setStep("redeem");
} catch (err) {
setFormError(err instanceof ApiError ? err.message : "شروع لاگین ناموفق بود.");
} finally {
setBusy(false);
}
};
const onComplete = async (event: React.FormEvent) => {
event.preventDefault();
setBusy(true);
setFormError(null);
try {
const account = await api.agyCompleteLogin({
login_id: loginId,
callback_url: callbackUrl.trim(),
});
onDone(account);
} catch (err) {
const message = err instanceof ApiError ? err.message : "تکمیل لاگین ناموفق بود.";
setFormError(message);
onError(message);
} finally {
setBusy(false);
}
};
return (
<Modal
open
title="افزودن اکانت agy"
description="ورود headless با OAuth گوگل — مراحل سه‌گانه"
onClose={onClose}
footer={
step === "redeem" ? (
<>
<Button variant="ghost" onClick={onClose}>
انصراف
</Button>
<Button form="agy-login-form" type="submit" loading={busy}>
تکمیل ورود
</Button>
</>
) : (
<Button onClick={onStart} loading={busy}>
دریافت لینک لاگین
</Button>
)
}
>
{step === "start" ? (
<div className="flex flex-col gap-3 text-sm text-slate-300">
<p>
۱. روی «دریافت لینک لاگین» بزنید. یک آدرس احراز هویت گوگل ساخته
می‌شود که فقط برای این درخواست معتبر است (۱۰ دقیقه).
</p>
<p>
۲. آن آدرس را در مرورگر باز کنید و با اکانت Google مورد نظر وارد
شوید.
</p>
<p>
۳. بعد از تأیید، مرورگر به یک آدرس محلی منتقل می‌شود که عمداً
بارگذاری نمی‌شود. نوار آدرس را همان‌جا کپی کنید (آدرس کامل با
<span className="ltr-inline mx-1 font-mono text-xs">?code=…&state=…</span>)
و در گام بعدی Paste کنید.
</p>
</div>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<p className="text-sm text-slate-300">
این آدرس را در مرورگر باز کنید و با Google وارد شوید:
</p>
<a
href={authUrl}
target="_blank"
rel="noopener noreferrer"
className="ltr-inline break-all rounded-lg border border-surface-border bg-surface-overlay px-3 py-2 font-mono text-xs text-accent hover:underline"
>
{authUrl}
</a>
<p className="text-xs text-slate-500">
بعد از تأیید در گوگل، آدرس کامل نوار آدرس مرورگر (که با
<span className="ltr-inline mx-1">oauth-callback?…</span> شروع
می‌شود) را در کادر زیر بچسبانید.
</p>
</div>
<form id="agy-login-form" onSubmit={onComplete} className="flex flex-col gap-3">
<Field
label="URL بازگشت از مرورگر"
required
value={callbackUrl}
onChange={(e) => setCallbackUrl(e.target.value)}
placeholder="http://localhost:51121/oauth-callback?code=…&state=…"
className="ltr-inline"
autoComplete="off"
/>
{formError && <ErrorState title="تکمیل لاگین ناموفق" message={formError} />}
</form>
</div>
)}
</Modal>
);
}
+1
View File
@@ -16,6 +16,7 @@ import { useSession } from "@/lib/session";
const SECTIONS = [
{ href: "/admin", label: "نمای کلی" },
{ href: "/admin/agy-accounts", label: "اکانت‌های agy" },
{ href: "/admin/providers", label: "ارائه‌دهنده‌ها" },
{ href: "/admin/models", label: "مدل‌ها" },
{ href: "/admin/routing", label: "مسیریابی" },
@@ -22,6 +22,7 @@ const PROVIDER_TYPES: { value: ProviderType; label: string }[] = [
{ value: "anthropic", label: "Anthropic" },
{ value: "gemini", label: "Gemini" },
{ value: "openrouter", label: "OpenRouter" },
{ value: "agy", label: "agy (Google Antigravity)" },
];
export default function AdminProvidersPage() {