agy تنها ارائهدهندهٔ مدل؛ حذف بخش ارائهدهندهها و افزودن زنجیرهٔ انتخاب مدل
- حذف کامل providerهای mock و openai_compatible: دامنه، آداپوترها، رجیستری، کانفیگ، seed و رابطٔ مدیریت - بخش «ارائهدهندهها» از کنسول حذف شد؛ ورود اکانتها فقط از صفحهٔ اکانتهای agy - صفحهٔ مدلها بازطراحی شد: ارائهدهنده → اکانت → مدل (کاتالوگ لحظهای و زندهٔ همان اکانت از Google) → افورت استدلال؛ بدون هیچ ورود دستی نام مدل - مدلها ستون reasoning_effort گرفتند؛ آداپتور آن را به thinkingConfig (بودجهٔ ۱۰۲۴ تا ۳۲۷۶۸) ترجمه میکند و فقط به مدلهای استدلالی میفرستد - مهاجرت c2d3e4f5b6a7: افزودن reasoning_effort و حذف ردیفهای providerهای قدیمی - تستها: ۲۴۶ تست بکاند و ۵۱ تست فرانت سبز؛ ruff و mypy و tsc و eslint تمیز
This commit is contained in:
@@ -17,7 +17,6 @@ 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: "مسیریابی" },
|
||||
{ href: "/admin/rate-cards", label: "تعرفهها" },
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { AdminModel, AdminProvider, Capability } from "@agy/contracts";
|
||||
import {
|
||||
effortsForModel,
|
||||
effortLabel,
|
||||
type AdminModel,
|
||||
type AgyAccount,
|
||||
type Capability,
|
||||
type ReasoningEffort,
|
||||
} from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -17,17 +24,34 @@ import {
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* Model catalogue (platform owner only).
|
||||
*
|
||||
* agy is the only provider, and nothing about a model is typed by hand: the
|
||||
* console asks the selected *account* for its live catalogue, the owner picks a
|
||||
* model and a reasoning effort, and the rest is metadata. The model id itself
|
||||
* never leaves the console — a customer only ever sees routing choices.
|
||||
*/
|
||||
|
||||
const CAPABILITIES: Capability[] = ["text", "vision", "tools", "reasoning", "long_context", "code"];
|
||||
|
||||
/** The single provider the platform talks to. */
|
||||
const PROVIDER_OPTIONS = [{ value: "agy", label: "agy (Google Antigravity)" }];
|
||||
|
||||
export default function AdminModelsPage() {
|
||||
const [models, setModels] = React.useState<AdminModel[]>([]);
|
||||
const [providers, setProviders] = React.useState<AdminProvider[]>([]);
|
||||
const [accounts, setAccounts] = React.useState<AgyAccount[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [providerId, setProviderId] = React.useState("");
|
||||
const [internalName, setInternalName] = React.useState("");
|
||||
const [displayName, setDisplayName] = React.useState("");
|
||||
// The add-model cascade: provider -> account -> model -> effort.
|
||||
const [providerId, setProviderId] = React.useState("agy");
|
||||
const [accountId, setAccountId] = React.useState("");
|
||||
const [catalog, setCatalog] = React.useState<string[] | null>(null);
|
||||
const [catalogLoading, setCatalogLoading] = React.useState(false);
|
||||
const [catalogError, setCatalogError] = React.useState<string | null>(null);
|
||||
const [modelId, setModelId] = React.useState("");
|
||||
const [effort, setEffort] = React.useState("");
|
||||
const [caps, setCaps] = React.useState<Capability[]>(["text"]);
|
||||
const [inputPrice, setInputPrice] = React.useState("");
|
||||
const [outputPrice, setOutputPrice] = React.useState("");
|
||||
@@ -38,13 +62,12 @@ export default function AdminModelsPage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nextModels, nextProviders] = await Promise.all([
|
||||
const [nextModels, nextAccounts] = await Promise.all([
|
||||
api.adminModels(),
|
||||
api.adminProviders(),
|
||||
api.agyAccounts(),
|
||||
]);
|
||||
setModels(nextModels);
|
||||
setProviders(nextProviders);
|
||||
setProviderId((current) => current || nextProviders[0]?.id || "");
|
||||
setAccounts(nextAccounts);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت مدلها ناموفق بود.");
|
||||
} finally {
|
||||
@@ -56,6 +79,72 @@ export default function AdminModelsPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
/** Reload only the accounts — the provider step of the cascade. */
|
||||
const loadAccounts = React.useCallback(async () => {
|
||||
try {
|
||||
setAccounts(await api.agyAccounts());
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت اکانتها ناموفق بود.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Live catalogue of the selected account — fetched from Google on every
|
||||
* account change (and on explicit refresh), never from a cached list.
|
||||
*/
|
||||
const loadCatalog = React.useCallback(async (id: string) => {
|
||||
if (!id) {
|
||||
setCatalog(null);
|
||||
setCatalogError(null);
|
||||
return;
|
||||
}
|
||||
setCatalogLoading(true);
|
||||
setCatalogError(null);
|
||||
try {
|
||||
setCatalog(await api.agyAccountModels(id));
|
||||
} catch (err) {
|
||||
setCatalog(null);
|
||||
setCatalogError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: "دریافت کاتالوگ مدلها از agy ناموفق بود.",
|
||||
);
|
||||
} finally {
|
||||
setCatalogLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onProviderChange = (value: string) => {
|
||||
setProviderId(value);
|
||||
// One provider exists, but the flow is wired for real: picking it reloads
|
||||
// the account list, which is what the next step depends on.
|
||||
setAccountId("");
|
||||
setModelId("");
|
||||
setEffort("");
|
||||
setCatalog(null);
|
||||
setCatalogError(null);
|
||||
void loadAccounts();
|
||||
};
|
||||
|
||||
const onAccountChange = (value: string) => {
|
||||
setAccountId(value);
|
||||
setModelId("");
|
||||
setEffort("");
|
||||
void loadCatalog(value);
|
||||
};
|
||||
|
||||
const onModelChange = (value: string) => {
|
||||
setModelId(value);
|
||||
setEffort("");
|
||||
// Sensible defaults the owner can still adjust: everything answers text,
|
||||
// and a thinking-capable model is a reasoning model.
|
||||
setCaps(
|
||||
effortsForModel(value).length > 0
|
||||
? ["text", "reasoning"]
|
||||
: ["text"],
|
||||
);
|
||||
};
|
||||
|
||||
const toggleCap = (cap: Capability) => {
|
||||
setCaps((current) =>
|
||||
current.includes(cap) ? current.filter((c) => c !== cap) : [...current, cap],
|
||||
@@ -65,8 +154,8 @@ export default function AdminModelsPage() {
|
||||
const onCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFormError(null);
|
||||
if (!providerId) {
|
||||
setFormError("ابتدا یک ارائهدهنده بسازید.");
|
||||
if (!modelId) {
|
||||
setFormError("ابتدا یک مدل از کاتالوگ اکانت انتخاب کنید.");
|
||||
return;
|
||||
}
|
||||
if (caps.length === 0) {
|
||||
@@ -76,16 +165,15 @@ export default function AdminModelsPage() {
|
||||
setCreating(true);
|
||||
try {
|
||||
const created = await api.adminCreateModel({
|
||||
provider_id: providerId,
|
||||
internal_name: internalName.trim(),
|
||||
display_name_for_admin: displayName.trim() || internalName.trim(),
|
||||
internal_name: modelId,
|
||||
capabilities: caps,
|
||||
input_price_per_1m: Number(inputPrice) || 0,
|
||||
output_price_per_1m: Number(outputPrice) || 0,
|
||||
reasoning_effort: effort || undefined,
|
||||
});
|
||||
setModels((current) => [created, ...current]);
|
||||
setInternalName("");
|
||||
setDisplayName("");
|
||||
setModelId("");
|
||||
setEffort("");
|
||||
setInputPrice("");
|
||||
setOutputPrice("");
|
||||
setCaps(["text"]);
|
||||
@@ -96,53 +184,93 @@ export default function AdminModelsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const providerName = (id: string) => providers.find((p) => p.id === id)?.name ?? id;
|
||||
const efforts: ReasoningEffort[] = modelId ? effortsForModel(modelId) : [];
|
||||
const accountOptions = accounts.map((a) => ({
|
||||
value: a.id,
|
||||
label: a.email || a.name || a.id,
|
||||
}));
|
||||
const modelOptions = (catalog ?? []).map((id) => ({ value: id, label: id }));
|
||||
const effortOptions = efforts.map((e) => ({ value: e, label: effortLabel(e) }));
|
||||
|
||||
return (
|
||||
<div className="anim-stagger flex flex-col gap-5">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-medium text-slate-100">مدلها</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
نام داخلی مدلها فقط در این کنسول دیده میشود. مشتری هرگز نام مدل یا ارائهدهنده را
|
||||
نمیبیند.
|
||||
agy تنها ارائهدهندهٔ سامانه است. برای افزودن مدل، اکانت را انتخاب کنید؛
|
||||
کاتالوگ مدلها لحظهای و مستقیم از همان اکانت خوانده میشود. نام مدل فقط
|
||||
در این کنسول دیده میشود و مشتری هرگز آن را نمیبیند.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Card>
|
||||
<CardTitle>افزودن مدل</CardTitle>
|
||||
<CardHint>
|
||||
قیمتها به «واحد پول به ازای هر یکمیلیون توکن» وارد میشوند و مبنای محاسبهٔ هزینهٔ
|
||||
داخلی هستند.
|
||||
قیمتها به «واحد پول به ازای هر یکمیلیون توکن» وارد میشوند و مبنای
|
||||
محاسبهٔ هزینهٔ داخلی هستند.
|
||||
</CardHint>
|
||||
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Select
|
||||
label="ارائهدهنده"
|
||||
required
|
||||
value={providerId}
|
||||
onChange={(e) => setProviderId(e.target.value)}
|
||||
options={
|
||||
providers.length === 0
|
||||
? [{ value: "", label: "ارائهدهندهای وجود ندارد" }]
|
||||
: providers.map((p) => ({ value: p.id, label: p.name }))
|
||||
}
|
||||
onChange={(e) => onProviderChange(e.target.value)}
|
||||
options={PROVIDER_OPTIONS}
|
||||
/>
|
||||
<Field
|
||||
label="نام داخلی"
|
||||
<Select
|
||||
label="اکانت agy"
|
||||
required
|
||||
value={internalName}
|
||||
onChange={(e) => setInternalName(e.target.value)}
|
||||
placeholder="gpt-4o-mini"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
<Field
|
||||
label="نام نمایشی برای مالک"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="مدل سریع و ارزان"
|
||||
value={accountId}
|
||||
onChange={(e) => onAccountChange(e.target.value)}
|
||||
options={
|
||||
accountOptions.length === 0
|
||||
? [{ value: "", label: "اکانتی ثبت نشده — ابتدا اکانت agy بسازید" }]
|
||||
: accountOptions
|
||||
}
|
||||
hint="کاتالوگ مدلها از همین اکانت خوانده میشود."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Select
|
||||
label="مدل"
|
||||
required
|
||||
value={modelId}
|
||||
onChange={(e) => onModelChange(e.target.value)}
|
||||
options={
|
||||
!accountId
|
||||
? [{ value: "", label: "ابتدا یک اکانت انتخاب کنید" }]
|
||||
: modelOptions.length === 0
|
||||
? [{ value: "", label: "کاتالوگی برای این اکانت نیست" }]
|
||||
: modelOptions
|
||||
}
|
||||
disabled={!accountId || catalogLoading || modelOptions.length === 0}
|
||||
/>
|
||||
<Select
|
||||
label="افورت استدلال"
|
||||
value={effort}
|
||||
onChange={(e) => setEffort(e.target.value)}
|
||||
options={effortOptions}
|
||||
disabled={efforts.length === 0}
|
||||
hint={
|
||||
efforts.length === 0
|
||||
? "این مدل افورت استدلال ندارد."
|
||||
: "بودجهٔ تفکر مدل؛ روی مدلهای غیراستدلالی نادیده گرفته میشود."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(accountId && catalogLoading) || (accountId && catalog === null && !catalogError) ? (
|
||||
<LoadingState label="در حال دریافت کاتالوگ مدلها از اکانت…" />
|
||||
) : catalogError ? (
|
||||
<ErrorState
|
||||
title="دریافت کاتالوگ ناموفق"
|
||||
message={catalogError}
|
||||
onRetry={() => void loadCatalog(accountId)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field
|
||||
label="قیمت ورودی (به ازای ۱M توکن)"
|
||||
@@ -178,7 +306,7 @@ export default function AdminModelsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" loading={creating}>
|
||||
<Button type="submit" loading={creating} disabled={!modelId}>
|
||||
افزودن مدل
|
||||
</Button>
|
||||
</div>
|
||||
@@ -207,7 +335,7 @@ export default function AdminModelsPage() {
|
||||
<thead>
|
||||
<tr className="border-b border-surface-border text-xs text-slate-400">
|
||||
<th className="px-2 py-2 text-right font-medium">نام داخلی</th>
|
||||
<th className="px-2 py-2 text-right font-medium">ارائهدهنده</th>
|
||||
<th className="px-2 py-2 text-right font-medium">افورت</th>
|
||||
<th className="px-2 py-2 text-right font-medium">تواناییها</th>
|
||||
<th className="px-2 py-2 text-right font-medium">ورودی</th>
|
||||
<th className="px-2 py-2 text-right font-medium">خروجی</th>
|
||||
@@ -219,10 +347,16 @@ export default function AdminModelsPage() {
|
||||
<tr key={model.id} className="border-b border-surface-border/60">
|
||||
<td className="px-2 py-2">
|
||||
<span className="ltr-inline text-slate-200">{model.internal_name}</span>
|
||||
<p className="text-xs text-slate-500">{model.display_name_for_admin}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{model.display_name_for_admin}
|
||||
</p>
|
||||
</td>
|
||||
<td className="ltr-inline px-2 py-2 text-xs text-slate-400">
|
||||
{providerName(model.provider_id)}
|
||||
<td className="px-2 py-2">
|
||||
{model.reasoning_effort ? (
|
||||
<Badge tone="info">{effortLabel(model.reasoning_effort)}</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500">پیشفرض</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function AdminOverviewPage() {
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-medium text-slate-100">کنسول مالک سامانه</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
مدیریت ارائهدهندهها، مدلها، سیاست مسیریابی و تعرفهها.
|
||||
مدیریت اکانتهای agy، مدلها، سیاست مسیریابی و تعرفهها.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -69,8 +69,8 @@ export default function AdminOverviewPage() {
|
||||
<Card>
|
||||
<CardTitle>آداپتورهای فعال</CardTitle>
|
||||
<CardHint>
|
||||
این مقادیر از محیط خوانده میشوند. هر مقدار «mock» یعنی آن سرویس بیرونی هنوز
|
||||
متصل نشده است.
|
||||
وضعیت سرویسهای بیرونی. هر مقدار «mock» یعنی آن سرویس هنوز متصل نشده
|
||||
است؛ مدلها فقط از agy (Google Antigravity) سرویس میشوند.
|
||||
</CardHint>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{Object.entries(readiness?.checks.adapters ?? {}).map(([key, value]) => (
|
||||
@@ -84,13 +84,13 @@ export default function AdminOverviewPage() {
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{[
|
||||
{
|
||||
title: "ارائهدهندهها و کلیدها",
|
||||
hint: "ساخت ارائهدهنده و ذخیرهٔ کلید رمزنگاریشده. کلید خام هرگز نمایش داده نمیشود.",
|
||||
path: "/admin/providers",
|
||||
title: "اکانتهای agy",
|
||||
hint: "ورود با Google و پول چرخشی اکانتها. agy تنها ارائهدهندهٔ سامانه است.",
|
||||
path: "/admin/agy-accounts",
|
||||
},
|
||||
{
|
||||
title: "مدلها",
|
||||
hint: "نام داخلی، تواناییها و قیمت ورودی/خروجی — فقط برای مالک سامانه.",
|
||||
hint: "انتخاب مدل از کاتالوگ زندهٔ اکانت، افورت استدلال و قیمت — فقط برای مالک سامانه.",
|
||||
path: "/admin/models",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { AdminCredential, AdminProvider, ProviderType } from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Select,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
|
||||
const PROVIDER_TYPES: { value: ProviderType; label: string }[] = [
|
||||
{ value: "mock", label: "آزمایشی (mock)" },
|
||||
{ value: "openai_compatible", label: "سازگار با OpenAI" },
|
||||
{ value: "anthropic", label: "Anthropic" },
|
||||
{ value: "gemini", label: "Gemini" },
|
||||
{ value: "openrouter", label: "OpenRouter" },
|
||||
{ value: "agy", label: "agy (Google Antigravity)" },
|
||||
];
|
||||
|
||||
export default function AdminProvidersPage() {
|
||||
const [providers, setProviders] = React.useState<AdminProvider[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [name, setName] = React.useState("");
|
||||
const [type, setType] = React.useState<ProviderType>("openai_compatible");
|
||||
const [baseUrl, setBaseUrl] = React.useState("");
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const [formError, setFormError] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setProviders(await api.adminProviders());
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت ارائهدهندهها ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const onCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFormError(null);
|
||||
setCreating(true);
|
||||
try {
|
||||
const created = await api.adminCreateProvider({
|
||||
name: name.trim(),
|
||||
type,
|
||||
base_url: baseUrl.trim(),
|
||||
});
|
||||
setProviders((current) => [created, ...current]);
|
||||
setName("");
|
||||
setBaseUrl("");
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : "ساخت ارائهدهنده ناموفق بود.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="anim-stagger flex flex-col gap-5">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-medium text-slate-100">ارائهدهندهها</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
هر ارائهدهنده میتواند چند کلید داشته باشد. کلیدها رمزنگاریشده ذخیره میشوند و فقط
|
||||
بهصورت ماسکشده برگردانده میشوند.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Card>
|
||||
<CardTitle>افزودن ارائهدهنده</CardTitle>
|
||||
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field
|
||||
label="نام"
|
||||
required
|
||||
minLength={2}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="مثلاً openai-main"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
<Select
|
||||
label="نوع"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as ProviderType)}
|
||||
options={PROVIDER_TYPES}
|
||||
/>
|
||||
<Field
|
||||
label="آدرس پایه"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" loading={creating}>
|
||||
افزودن
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{formError && (
|
||||
<div className="mt-3">
|
||||
<ErrorState title="ساخت ارائهدهنده ناموفق" message={formError} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<LoadingState label="در حال دریافت ارائهدهندهها…" />
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={load} />
|
||||
) : providers.length === 0 ? (
|
||||
<EmptyState
|
||||
title="ارائهدهندهای ثبت نشده است"
|
||||
description="با فرم بالا اولین ارائهدهنده را اضافه کنید."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{providers.map((provider) => (
|
||||
<ProviderCard key={provider.id} provider={provider} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderCard({ provider }: { provider: AdminProvider }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [credentials, setCredentials] = React.useState<AdminCredential[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [credName, setCredName] = React.useState("");
|
||||
const [apiKey, setApiKey] = React.useState("");
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [formError, setFormError] = React.useState<string | null>(null);
|
||||
|
||||
const loadCredentials = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setCredentials(await api.adminCredentials(provider.id));
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت کلیدها ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [provider.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) void loadCredentials();
|
||||
}, [open, loadCredentials]);
|
||||
|
||||
const onAddCredential = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFormError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
const created = await api.adminCreateCredential(provider.id, {
|
||||
name: credName.trim(),
|
||||
api_key: apiKey.trim(),
|
||||
});
|
||||
// The response carries the masked form only; the plaintext is gone.
|
||||
setCredentials((current) => [...current, created]);
|
||||
setCredName("");
|
||||
setApiKey("");
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : "ذخیرهٔ کلید ناموفق بود.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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">{provider.name}</p>
|
||||
<Badge tone={provider.is_active ? "success" : "neutral"}>
|
||||
{provider.is_active ? "فعال" : "غیرفعال"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="ltr-inline text-xs text-slate-500">
|
||||
{provider.type}
|
||||
{provider.base_url ? ` · ${provider.base_url}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => setOpen((v) => !v)}>
|
||||
{open ? "بستن کلیدها" : "مدیریت کلیدها"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="mt-4 flex flex-col gap-4 border-t border-surface-border pt-4">
|
||||
{loading ? (
|
||||
<LoadingState label="در حال دریافت کلیدها…" />
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={loadCredentials} />
|
||||
) : credentials.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">کلیدی ثبت نشده است.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{credentials.map((credential) => (
|
||||
<li
|
||||
key={credential.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2"
|
||||
>
|
||||
<span className="text-xs text-slate-300">{credential.name}</span>
|
||||
<span className="ltr-inline font-mono text-xs text-slate-500">
|
||||
{credential.masked_key}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<form onSubmit={onAddCredential} className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<Field
|
||||
label="نام کلید"
|
||||
required
|
||||
value={credName}
|
||||
onChange={(e) => setCredName(e.target.value)}
|
||||
placeholder="production"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Field
|
||||
label="کلید API"
|
||||
required
|
||||
type="password"
|
||||
minLength={4}
|
||||
autoComplete="off"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
hint="پس از ذخیره دیگر قابل بازیابی نیست."
|
||||
className="ltr-inline"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" loading={saving}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</form>
|
||||
{formError && <ErrorState title="ذخیرهٔ کلید ناموفق" message={formError} />}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user