نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی

مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت.
تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
mjalmoudy94
2026-10-13 00:39:28 +03:30
commit 717e53a7ce
185 changed files with 39910 additions and 0 deletions
@@ -0,0 +1,557 @@
"use client";
import * as React from "react";
import type {
ErrorEvent,
ErrorGroup,
ErrorSeverity,
ErrorSource,
ErrorStatusFilter,
ErrorSummary,
} from "@agy/contracts";
import {
Badge,
Button,
Card,
Drawer,
EmptyState,
ErrorState,
Field,
LoadingState,
Select,
Stat,
Table,
Tabs,
} from "@agy/ui";
import type { TableColumn } from "@agy/ui";
import { ApiError, api } from "@/lib/api";
/**
* Platform-owner error console.
*
* Two views over the same table. **Groups** answers "what is broken, and how
* often?" — one row per distinct bug, however many times it fired. **Occurrences**
* answers "what exactly happened on this request?" — the full record, including
* the traceback and the request id.
*
* The split matters: a bug that fired four thousand times must not push every
* other bug off the screen, and a bug that fired once must still be diagnosable
* without reproducing it.
*/
const SEVERITY_TONE: Record<ErrorSeverity, "neutral" | "warning" | "danger" | "info"> = {
warning: "warning",
error: "danger",
critical: "danger",
};
const SEVERITY_LABEL: Record<string, string> = {
warning: "هشدار",
error: "خطا",
critical: "بحرانی",
};
const SOURCE_LABEL: Record<string, string> = {
http: "درخواست",
worker: "کارگر پس‌زمینه",
startup: "راه‌اندازی",
};
const STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "open", label: "باز" },
{ value: "resolved", label: "بسته‌شده" },
{ value: "", label: "همه" },
];
const SEVERITY_OPTIONS: { value: string; label: string }[] = [
{ value: "", label: "همه" },
{ value: "critical", label: "بحرانی" },
{ value: "error", label: "خطا" },
{ value: "warning", label: "هشدار" },
];
const SOURCE_OPTIONS: { value: string; label: string }[] = [
{ value: "", label: "همه" },
{ value: "http", label: "درخواست" },
{ value: "worker", label: "کارگر پس‌زمینه" },
{ value: "startup", label: "راه‌اندازی" },
];
function formatTime(value: string | null): string {
if (!value) return "—";
return new Date(value).toLocaleString("fa-IR");
}
export default function AdminErrorsPage() {
const [tab, setTab] = React.useState<"groups" | "occurrences">("groups");
const [status, setStatus] = React.useState<ErrorStatusFilter | "">("open");
const [severity, setSeverity] = React.useState<ErrorSeverity | "">("");
const [source, setSource] = React.useState<ErrorSource | "">("");
const [search, setSearch] = React.useState("");
const [fingerprint, setFingerprint] = React.useState<string | null>(null);
const [groups, setGroups] = React.useState<ErrorGroup[]>([]);
const [events, setEvents] = React.useState<ErrorEvent[]>([]);
const [total, setTotal] = React.useState(0);
const [summary, setSummary] = React.useState<ErrorSummary | null>(null);
const [selected, setSelected] = React.useState<ErrorEvent | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [busy, setBusy] = React.useState<string | null>(null);
const [notice, setNotice] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const query = {
status: status || undefined,
severity: severity || undefined,
source: source || undefined,
search: search || undefined,
fingerprint: fingerprint || undefined,
};
const [nextGroups, nextEvents, nextSummary] = await Promise.all([
api.adminErrorGroups(query),
api.adminErrors({ ...query, limit: 100 }),
api.adminErrorSummary(),
]);
setGroups(nextGroups);
setEvents(nextEvents.items);
setTotal(nextEvents.total);
setSummary(nextSummary);
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت خطاها ناموفق بود.");
} finally {
setLoading(false);
}
}, [status, severity, source, search, fingerprint]);
React.useEffect(() => {
void load();
}, [load]);
const openGroup = React.useCallback((group: ErrorGroup) => {
setFingerprint(group.fingerprint);
setTab("occurrences");
}, []);
const clearGroup = React.useCallback(() => {
setFingerprint(null);
setTab("groups");
}, []);
const resolveGroup = React.useCallback(
async (group: ErrorGroup) => {
setBusy(group.fingerprint);
setNotice(null);
try {
const result = await api.adminResolveErrorGroup(
group.fingerprint,
"بسته‌شده از کنسول خطا",
);
setNotice(`${result.resolved} رخداد بسته شد.`);
await load();
} catch (err) {
setNotice(err instanceof ApiError ? err.message : "بستن خطا ناموفق بود.");
} finally {
setBusy(null);
}
},
[load],
);
const reopenGroup = React.useCallback(
async (group: ErrorGroup) => {
setBusy(group.fingerprint);
setNotice(null);
try {
const result = await api.adminReopenErrorGroup(group.fingerprint);
setNotice(`${result.resolved} رخداد بازگشایی شد.`);
await load();
} catch (err) {
setNotice(err instanceof ApiError ? err.message : "بازگشایی خطا ناموفق بود.");
} finally {
setBusy(null);
}
},
[load],
);
const groupColumns: Array<TableColumn<ErrorGroup>> = [
{
key: "severity",
header: "شدت",
render: (group) => (
<Badge tone={SEVERITY_TONE[group.severity] ?? "neutral"}>
{SEVERITY_LABEL[group.severity] ?? group.severity}
</Badge>
),
},
{
key: "what",
header: "خطا",
render: (group) => (
<div className="flex flex-col gap-1">
<span className="text-slate-200">{group.sample_message || group.kind}</span>
<span className="text-xs text-slate-500">
<span className="ltr-inline">{group.kind}</span>
{group.error_type ? ` · ${group.error_type}` : ""}
{group.path ? (
<>
{" · "}
<span className="ltr-inline">{group.path}</span>
</>
) : null}
</span>
</div>
),
},
{
key: "source",
header: "منبع",
render: (group) => (
<Badge tone="neutral">{SOURCE_LABEL[group.source] ?? group.source}</Badge>
),
},
{
key: "count",
header: "رخداد",
align: "center",
render: (group) => (
<div className="flex items-center justify-center gap-2">
<Badge tone="info">{group.occurrences}</Badge>
{group.open_count > 0 ? (
<Badge tone="danger">{group.open_count} باز</Badge>
) : (
<Badge tone="success">بسته</Badge>
)}
</div>
),
},
{
key: "last_seen",
header: "آخرین بار",
render: (group) => (
<span className="text-xs text-slate-400">{formatTime(group.last_seen)}</span>
),
},
{
key: "actions",
header: "عملیات",
align: "end",
render: (group) => (
<div className="flex justify-end gap-2">
<Button size="sm" variant="ghost" onClick={() => openGroup(group)}>
رخدادها
</Button>
{group.open_count > 0 ? (
<Button
size="sm"
variant="secondary"
loading={busy === group.fingerprint}
onClick={() => void resolveGroup(group)}
>
بستن
</Button>
) : (
<Button
size="sm"
variant="ghost"
loading={busy === group.fingerprint}
onClick={() => void reopenGroup(group)}
>
بازگشایی
</Button>
)}
</div>
),
},
];
const eventColumns: Array<TableColumn<ErrorEvent>> = [
{
key: "severity",
header: "شدت",
render: (event) => (
<Badge tone={SEVERITY_TONE[event.severity] ?? "neutral"}>
{SEVERITY_LABEL[event.severity] ?? event.severity}
</Badge>
),
},
{
key: "when",
header: "زمان",
render: (event) => (
<span className="text-xs text-slate-400">{formatTime(event.created_at)}</span>
),
},
{
key: "request",
header: "درخواست",
render: (event) => (
<div className="flex flex-col gap-0.5 text-xs">
{event.method || event.path ? (
<span className="ltr-inline text-slate-300">
{event.method} {event.path}
</span>
) : (
<span className="text-slate-500">
{SOURCE_LABEL[event.source] ?? event.source}
</span>
)}
<span className="ltr-inline text-slate-500">
{event.error_type || event.kind}
{event.status_code ? ` · ${event.status_code}` : ""}
</span>
</div>
),
},
{
key: "message",
header: "پیام",
render: (event) => (
<span className="line-clamp-2 text-slate-200">{event.message}</span>
),
},
{
key: "resolved",
header: "وضعیت",
align: "center",
render: (event) =>
event.resolved_at ? (
<Badge tone="success">بسته</Badge>
) : (
<Badge tone="danger">باز</Badge>
),
},
{
key: "actions",
header: "",
align: "end",
render: (event) => (
<Button size="sm" variant="ghost" onClick={() => setSelected(event)}>
جزئیات
</Button>
),
},
];
const tiles = summary?.open_by_severity ?? {};
return (
<div className="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>
<div className="grid gap-3 sm:grid-cols-4">
<Stat
label="خطاهای باز"
value={summary?.total_open ?? 0}
tone={summary && summary.total_open > 0 ? "danger" : "neutral"}
/>
<Stat label="بحرانی" value={tiles.critical ?? 0} tone={(tiles.critical ?? 0) > 0 ? "danger" : "neutral"} />
<Stat label="خطا" value={tiles.error ?? 0} tone={(tiles.error ?? 0) > 0 ? "danger" : "neutral"} />
<Stat label="هشدار" value={tiles.warning ?? 0} />
</div>
{notice && (
<Card className="border-accent/40 bg-accent-soft">
<p className="text-sm text-slate-200">{notice}</p>
</Card>
)}
{fingerprint && (
<Card className="border-accent/40 bg-accent-soft">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-slate-200">
فقط رخدادهای این خطا نمایش داده می‌شود:{" "}
<span className="ltr-inline text-xs text-slate-400">{fingerprint}</span>
</p>
<Button size="sm" variant="ghost" onClick={clearGroup}>
حذف فیلتر
</Button>
</div>
</Card>
)}
<Card>
<div className="flex flex-wrap items-end gap-3">
<Select
label="وضعیت"
options={STATUS_OPTIONS}
value={status}
onChange={(event) => setStatus(event.target.value as ErrorStatusFilter | "")}
className="w-32"
/>
<Select
label="شدت"
options={SEVERITY_OPTIONS}
value={severity}
onChange={(event) => setSeverity(event.target.value as ErrorSeverity | "")}
className="w-32"
/>
<Select
label="منبع"
options={SOURCE_OPTIONS}
value={source}
onChange={(event) => setSource(event.target.value as ErrorSource | "")}
className="w-40"
/>
<Field
label="جست‌وجو در پیام"
placeholder="مثلاً timeout"
value={search}
onChange={(event) => setSearch(event.target.value)}
className="w-56"
/>
<Button variant="secondary" onClick={() => void load()} loading={loading}>
به‌روزرسانی
</Button>
</div>
</Card>
<Tabs
items={[
{ key: "groups", label: "گروه‌ها", badge: <Badge tone="neutral">{groups.length}</Badge> },
{ key: "occurrences", label: "رخدادها", badge: <Badge tone="neutral">{total}</Badge> },
]}
active={tab}
onChange={(key) => setTab(key as "groups" | "occurrences")}
/>
{loading ? (
<LoadingState label="در حال دریافت خطاها…" />
) : error ? (
<ErrorState message={error} onRetry={load} />
) : tab === "groups" ? (
<Table
columns={groupColumns}
rows={groups}
rowKey={(group) => group.fingerprint}
empty={
<EmptyState
title="خطایی ثبت نشده است"
description="با این فیلترها هیچ خطایی پیدا نشد. این خبر خوبی است."
/>
}
caption="گروه‌های خطا"
/>
) : (
<Table
columns={eventColumns}
rows={events}
rowKey={(event) => event.id}
empty={
<EmptyState
title="رخدادی ثبت نشده است"
description="با این فیلترها هیچ رخدادی پیدا نشد."
/>
}
caption="رخدادهای خطا"
/>
)}
<Drawer
open={selected !== null}
title="جزئیات خطا"
onClose={() => setSelected(null)}
>
{selected && <ErrorDetail event={selected} />}
</Drawer>
</div>
);
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex flex-col gap-0.5 border-b border-surface-border/60 py-2">
<span className="text-xs text-slate-500">{label}</span>
<span className="break-words text-sm text-slate-200">{value || "—"}</span>
</div>
);
}
function ErrorDetail({ event }: { event: ErrorEvent }) {
return (
<div className="flex flex-col gap-1">
<div className="mb-2 flex flex-wrap gap-2">
<Badge tone={SEVERITY_TONE[event.severity] ?? "neutral"}>
{SEVERITY_LABEL[event.severity] ?? event.severity}
</Badge>
<Badge tone="neutral">{SOURCE_LABEL[event.source] ?? event.source}</Badge>
{event.resolved_at ? (
<Badge tone="success">بسته‌شده</Badge>
) : (
<Badge tone="danger">باز</Badge>
)}
</div>
<DetailRow label="نوع" value={event.kind} />
<DetailRow label="کلاس خطا" value={event.error_type} />
<DetailRow label="پیام" value={event.message} />
<DetailRow
label="درخواست"
value={
<span className="ltr-inline">
{event.method} {event.path}
{event.status_code ? ` → ${event.status_code}` : ""}
</span>
}
/>
<DetailRow
label="شناسهٔ درخواست"
value={<span className="ltr-inline text-xs">{event.request_id}</span>}
/>
<DetailRow
label="اثر انگشت"
value={<span className="ltr-inline text-xs">{event.fingerprint}</span>}
/>
<DetailRow label="زمان" value={formatTime(event.created_at)} />
<DetailRow label="سازمان" value={<span className="ltr-inline text-xs">{event.organization_id}</span>} />
<DetailRow label="کاربر" value={<span className="ltr-inline text-xs">{event.user_id}</span>} />
<DetailRow label="اجرا" value={<span className="ltr-inline text-xs">{event.run_id}</span>} />
<DetailRow label="نشانی" value={<span className="ltr-inline text-xs">{event.ip}</span>} />
<DetailRow
label="مرورگر"
value={<span className="ltr-inline text-xs">{event.user_agent}</span>}
/>
{event.resolved_at && (
<DetailRow
label="بسته‌شده در"
value={`${formatTime(event.resolved_at)} — ${event.resolved_note || "بدون توضیح"}`}
/>
)}
{Object.keys(event.metadata_json ?? {}).length > 0 && (
<div className="mt-3 flex flex-col gap-1">
<span className="text-xs text-slate-500">فراداده</span>
<pre className="ltr-inline max-h-56 overflow-auto rounded-lg border border-surface-border bg-surface-overlay p-3 text-xs text-slate-300">
{JSON.stringify(event.metadata_json, null, 2)}
</pre>
</div>
)}
<div className="mt-3 flex flex-col gap-1">
<span className="text-xs text-slate-500">ردِّ خطا</span>
{event.stack ? (
<pre className="ltr-inline max-h-96 overflow-auto rounded-lg border border-surface-border bg-surface-overlay p-3 text-xs text-slate-300">
{event.stack}
</pre>
) : (
<p className="text-xs text-slate-500">
برای این خطا ردِّ خطا ثبت نشده است.
</p>
)}
</div>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import * as React from "react";
import { EmptyState, LoadingState } from "@agy/ui";
import { useSession } from "@/lib/session";
/**
* Guard + chrome for the platform-owner console.
*
* The API enforces ownership on every endpoint; this layout only avoids
* rendering a console that would 401 on every request. It is a convenience,
* never the security boundary.
*/
const SECTIONS = [
{ href: "/admin", label: "نمای کلی" },
{ href: "/admin/providers", label: "ارائه‌دهنده‌ها" },
{ href: "/admin/models", label: "مدل‌ها" },
{ href: "/admin/routing", label: "مسیریابی" },
{ href: "/admin/rate-cards", label: "تعرفه‌ها" },
{ href: "/admin/organizations", label: "سازمان‌ها" },
{ href: "/admin/errors", label: "کنسول خطا" },
] as const;
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const { user, ready } = useSession();
const pathname = usePathname();
if (!ready) return <LoadingState label="در حال بررسی دسترسی…" />;
if (!user?.is_platform_owner) {
return (
<EmptyState
title="دسترسی محدود"
description="این بخش فقط برای مالک سامانه قابل دسترسی است."
/>
);
}
return (
<div className="flex flex-col gap-5">
<nav className="flex flex-wrap gap-1 border-b border-surface-border">
{SECTIONS.map((section) => {
const active =
section.href === "/admin"
? pathname === "/admin"
: pathname.startsWith(section.href);
return (
<Link
key={section.href}
href={section.href}
className={
"border-b-2 px-3 py-2 text-sm transition-colors " +
(active
? "border-accent text-slate-100"
: "border-transparent text-slate-400 hover:text-slate-200")
}
>
{section.label}
</Link>
);
})}
</nav>
{children}
</div>
);
}
@@ -0,0 +1,256 @@
"use client";
import * as React from "react";
import type { AdminModel, AdminProvider, Capability } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
Checkbox,
EmptyState,
ErrorState,
Field,
LoadingState,
Select,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
const CAPABILITIES: Capability[] = ["text", "vision", "tools", "reasoning", "long_context", "code"];
export default function AdminModelsPage() {
const [models, setModels] = React.useState<AdminModel[]>([]);
const [providers, setProviders] = React.useState<AdminProvider[]>([]);
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("");
const [caps, setCaps] = React.useState<Capability[]>(["text"]);
const [inputPrice, setInputPrice] = React.useState("");
const [outputPrice, setOutputPrice] = 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 {
const [nextModels, nextProviders] = await Promise.all([
api.adminModels(),
api.adminProviders(),
]);
setModels(nextModels);
setProviders(nextProviders);
setProviderId((current) => current || nextProviders[0]?.id || "");
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت مدل‌ها ناموفق بود.");
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
void load();
}, [load]);
const toggleCap = (cap: Capability) => {
setCaps((current) =>
current.includes(cap) ? current.filter((c) => c !== cap) : [...current, cap],
);
};
const onCreate = async (event: React.FormEvent) => {
event.preventDefault();
setFormError(null);
if (!providerId) {
setFormError("ابتدا یک ارائه‌دهنده بسازید.");
return;
}
if (caps.length === 0) {
setFormError("حداقل یک توانایی انتخاب کنید.");
return;
}
setCreating(true);
try {
const created = await api.adminCreateModel({
provider_id: providerId,
internal_name: internalName.trim(),
display_name_for_admin: displayName.trim() || internalName.trim(),
capabilities: caps,
input_price_per_1m: Number(inputPrice) || 0,
output_price_per_1m: Number(outputPrice) || 0,
});
setModels((current) => [created, ...current]);
setInternalName("");
setDisplayName("");
setInputPrice("");
setOutputPrice("");
setCaps(["text"]);
} catch (err) {
setFormError(err instanceof ApiError ? err.message : "ساخت مدل ناموفق بود.");
} finally {
setCreating(false);
}
};
const providerName = (id: string) => providers.find((p) => p.id === id)?.name ?? id;
return (
<div className="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>
<CardHint>
قیمت‌ها به «واحد پول به ازای هر یک‌میلیون توکن» وارد می‌شوند و مبنای محاسبهٔ هزینهٔ
داخلی هستند.
</CardHint>
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-3">
<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 }))
}
/>
<Field
label="نام داخلی"
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="مدل سریع و ارزان"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field
label="قیمت ورودی (به ازای ۱M توکن)"
inputMode="decimal"
value={inputPrice}
onChange={(e) => setInputPrice(e.target.value)}
placeholder="0.15"
className="ltr-inline"
/>
<Field
label="قیمت خروجی (به ازای ۱M توکن)"
inputMode="decimal"
value={outputPrice}
onChange={(e) => setOutputPrice(e.target.value)}
placeholder="0.60"
className="ltr-inline"
/>
</div>
<div className="flex flex-col gap-2">
<p className="text-xs font-medium text-slate-300">توانایی‌ها</p>
<div className="flex flex-wrap gap-4">
{CAPABILITIES.map((cap) => (
<Checkbox
key={cap}
label={cap}
checked={caps.includes(cap)}
onChange={() => toggleCap(cap)}
className="ltr-inline"
/>
))}
</div>
</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} />
) : models.length === 0 ? (
<EmptyState
title="مدلی ثبت نشده است"
description="بدون مدل فعال، هیچ حالت مسیریابی قابل اجرا نیست."
/>
) : (
<Card>
<CardTitle>فهرست مدل‌ها</CardTitle>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<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>
</tr>
</thead>
<tbody>
{models.map((model) => (
<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>
</td>
<td className="ltr-inline px-2 py-2 text-xs text-slate-400">
{providerName(model.provider_id)}
</td>
<td className="px-2 py-2">
<div className="flex flex-wrap gap-1">
{model.capabilities.map((cap) => (
<Badge key={cap} tone="info">
<span className="ltr-inline">{cap}</span>
</Badge>
))}
</div>
</td>
<td className="ltr-inline px-2 py-2 text-xs text-slate-300">
{model.input_price_per_1m}
</td>
<td className="ltr-inline px-2 py-2 text-xs text-slate-300">
{model.output_price_per_1m}
</td>
<td className="px-2 py-2">
<Badge tone={model.is_active ? "success" : "neutral"}>
{model.is_active ? "فعال" : "غیرفعال"}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
);
}
@@ -0,0 +1,100 @@
"use client";
import * as React from "react";
import type { AdminOrganization, AdminUsageRow } from "@agy/contracts";
import { Badge, Card, CardTitle, EmptyState, ErrorState, LoadingState, Stat } from "@agy/ui";
import { ApiError, api } from "@/lib/api";
export default function AdminOrganizationsPage() {
const [organizations, setOrganizations] = React.useState<AdminOrganization[]>([]);
const [usage, setUsage] = React.useState<AdminUsageRow[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const [nextOrganizations, nextUsage] = await Promise.all([
api.adminOrganizations(),
api.adminUsage(),
]);
setOrganizations(nextOrganizations);
setUsage(nextUsage);
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت سازمان‌ها ناموفق بود.");
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
void load();
}, [load]);
if (loading) return <LoadingState label="در حال دریافت سازمان‌ها…" />;
if (error) return <ErrorState message={error} onRetry={load} />;
const usageFor = (orgId: string) => usage.find((row) => row.organization_id === orgId);
const totalBilled = usage.reduce((sum, row) => sum + row.total_price, 0);
return (
<div className="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>
<div className="grid gap-3 sm:grid-cols-3">
<Stat label="تعداد سازمان‌ها" value={organizations.length} />
<Stat
label="مجموع اعضا"
value={organizations.reduce((sum, org) => sum + org.member_count, 0)}
/>
<Stat label="مجموع مصرف" value={totalBilled.toFixed(2)} />
</div>
{organizations.length === 0 ? (
<EmptyState
title="سازمانی وجود ندارد"
description="هنوز هیچ مستأجری ثبت نشده است."
/>
) : (
<Card>
<CardTitle>فهرست سازمان‌ها</CardTitle>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<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>
</tr>
</thead>
<tbody>
{organizations.map((org) => {
const row = usageFor(org.id);
return (
<tr key={org.id} className="border-b border-surface-border/60">
<td className="px-2 py-2 text-slate-200">{org.name}</td>
<td className="ltr-inline px-2 py-2 text-xs text-slate-500">{org.slug}</td>
<td className="px-2 py-2">
<Badge tone="neutral">{org.member_count}</Badge>
</td>
<td className="ltr-inline px-2 py-2 text-xs text-slate-300">
{row ? row.total_price.toFixed(2) : "0.00"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
)}
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import Link from "next/link";
import * as React from "react";
import type { AdminOrganization, AdminUsageRow, ReadinessResponse } from "@agy/contracts";
import { Badge, Card, CardHint, CardTitle, ErrorState, LoadingState, Stat } from "@agy/ui";
import { ApiError, api } from "@/lib/api";
/**
* Platform owner overview.
*
* Everything on this page is invisible to normal customers by design — the whole
* point of the AI Control Plane is that model identity never reaches a tenant.
*/
export default function AdminOverviewPage() {
const [readiness, setReadiness] = React.useState<ReadinessResponse | null>(null);
const [organizations, setOrganizations] = React.useState<AdminOrganization[]>([]);
const [usage, setUsage] = React.useState<AdminUsageRow[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const [nextReadiness, nextOrganizations, nextUsage] = await Promise.all([
api.readiness(),
api.adminOrganizations(),
api.adminUsage(),
]);
setReadiness(nextReadiness);
setOrganizations(nextOrganizations);
setUsage(nextUsage);
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت وضعیت ناموفق بود.");
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
void load();
}, [load]);
if (loading) return <LoadingState label="در حال دریافت وضعیت سامانه…" />;
if (error) return <ErrorState message={error} onRetry={load} />;
const totalBilled = usage.reduce((sum, row) => sum + row.total_price, 0);
return (
<div className="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>
<div className="grid gap-3 sm:grid-cols-3">
<Stat label="سازمان‌ها" value={organizations.length} />
<Stat label="مجموع اعضای سازمان‌ها" value={organizations.reduce((s, o) => s + o.member_count, 0)} />
<Stat
label="مجموع مصرف ثبت‌شده"
value={totalBilled.toFixed(2)}
hint="بر پایهٔ دفتر مصرف، به واحد پول داخلی سامانه."
/>
</div>
<Card>
<CardTitle>آداپتورهای فعال</CardTitle>
<CardHint>
این مقادیر از محیط خوانده می‌شوند. هر مقدار «mock» یعنی آن سرویس بیرونی هنوز
متصل نشده است.
</CardHint>
<div className="mt-3 flex flex-wrap gap-2">
{Object.entries(readiness?.checks.adapters ?? {}).map(([key, value]) => (
<Badge key={key} tone={value === "mock" ? "warning" : "success"}>
{key}: {value}
</Badge>
))}
</div>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
{[
{
title: "ارائه‌دهنده‌ها و کلیدها",
hint: "ساخت ارائه‌دهنده و ذخیرهٔ کلید رمزنگاری‌شده. کلید خام هرگز نمایش داده نمی‌شود.",
path: "/admin/providers",
},
{
title: "مدل‌ها",
hint: "نام داخلی، توانایی‌ها و قیمت ورودی/خروجی — فقط برای مالک سامانه.",
path: "/admin/models",
},
{
title: "سیاست مسیریابی",
hint: "حالت‌های fast / cheap / balanced / reasoning / vision / coding و اجرای آزمایشی.",
path: "/admin/routing",
},
{
title: "تعرفه‌ها",
hint: "قیمت مشتری و برآورد هزینهٔ داخلی به تفکیک واحد مصرف.",
path: "/admin/rate-cards",
},
{
title: "سازمان‌ها",
hint: "فهرست همهٔ سازمان‌ها، تعداد اعضا و مصرف تجمیعی هرکدام.",
path: "/admin/organizations",
},
].map((item) => (
<Link key={item.path} href={item.path}>
<Card className="h-full transition-colors hover:border-accent/60">
<CardTitle>{item.title}</CardTitle>
<CardHint>{item.hint}</CardHint>
</Card>
</Link>
))}
</div>
</div>
);
}
@@ -0,0 +1,268 @@
"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" },
];
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="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>
);
}
@@ -0,0 +1,228 @@
"use client";
import * as React from "react";
import type { AdminRateCard, RateUnit } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
Checkbox,
EmptyState,
ErrorState,
Field,
LoadingState,
Select,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
const UNITS: { value: RateUnit; label: string }[] = [
{ value: "model_input_tokens", label: "توکن ورودی مدل" },
{ value: "model_output_tokens", label: "توکن خروجی مدل" },
{ value: "sandbox_cpu_second", label: "ثانیهٔ CPU سندباکس" },
{ value: "sandbox_memory_gb_second", label: "گیگابایت‌ثانیهٔ حافظه" },
{ value: "storage_gb_day", label: "گیگابایت‌روز ذخیره‌سازی" },
{ value: "tool_call", label: "فراخوانی ابزار" },
{ value: "premium_employee_run", label: "اجرای کارمند حرفه‌ای" },
];
const unitLabel = (unit: RateUnit) => UNITS.find((u) => u.value === unit)?.label ?? unit;
export default function AdminRateCardsPage() {
const [cards, setCards] = React.useState<AdminRateCard[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [unit, setUnit] = React.useState<RateUnit>("model_input_tokens");
const [name, setName] = React.useState("");
const [customerPrice, setCustomerPrice] = React.useState("");
const [internalCost, setInternalCost] = React.useState("");
const [isActive, setIsActive] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [formError, setFormError] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
setCards(await api.adminRateCards());
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت تعرفه‌ها ناموفق بود.");
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
void load();
}, [load]);
// Prefill the form when the operator picks a unit that already has a card —
// the endpoint is an upsert, so showing current values avoids blind overwrite.
React.useEffect(() => {
const existing = cards.find((c) => c.unit === unit);
if (existing) {
setName(existing.name);
setCustomerPrice(String(existing.customer_price));
setInternalCost(String(existing.internal_cost_estimate));
setIsActive(existing.is_active);
} else {
setName("");
setCustomerPrice("");
setInternalCost("");
setIsActive(true);
}
}, [unit, cards]);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setFormError(null);
const price = Number(customerPrice);
if (!Number.isFinite(price) || price < 0) {
setFormError("قیمت مشتری باید عددی نامنفی باشد.");
return;
}
setSaving(true);
try {
const saved = await api.adminUpsertRateCard({
unit,
name: name.trim(),
customer_price: price,
internal_cost_estimate: Number(internalCost) || 0,
is_active: isActive,
});
setCards((current) => {
const others = current.filter((c) => c.unit !== saved.unit);
return [...others, saved].sort((a, b) => a.unit.localeCompare(b.unit));
});
} catch (err) {
setFormError(err instanceof ApiError ? err.message : "ذخیرهٔ تعرفه ناموفق بود.");
} finally {
setSaving(false);
}
};
return (
<div className="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>
<CardHint>
این عملیات «به‌روزرسانی یا ایجاد» است: برای هر واحد مصرف فقط یک تعرفه وجود دارد و
ذخیرهٔ دوباره آن را جایگزین می‌کند.
</CardHint>
<form onSubmit={onSubmit} className="mt-4 flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Select
label="واحد مصرف"
value={unit}
onChange={(e) => setUnit(e.target.value as RateUnit)}
options={UNITS}
/>
<Field
label="نام (اختیاری)"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Field
label="قیمت مشتری"
required
inputMode="decimal"
value={customerPrice}
onChange={(e) => setCustomerPrice(e.target.value)}
placeholder="0.0004"
className="ltr-inline"
/>
<Field
label="برآورد هزینهٔ داخلی"
inputMode="decimal"
value={internalCost}
onChange={(e) => setInternalCost(e.target.value)}
placeholder="0.0002"
className="ltr-inline"
/>
</div>
<Checkbox
label="این تعرفه فعال باشد"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
/>
<div className="flex justify-end">
<Button type="submit" loading={saving}>
ذخیرهٔ تعرفه
</Button>
</div>
</form>
{formError && (
<div className="mt-3">
<ErrorState title="ذخیرهٔ تعرفه ناموفق" message={formError} />
</div>
)}
</Card>
{loading ? (
<LoadingState label="در حال دریافت تعرفه‌ها…" />
) : error ? (
<ErrorState message={error} onRetry={load} />
) : cards.length === 0 ? (
<EmptyState
title="تعرفه‌ای ثبت نشده است"
description="بدون تعرفه، مصرف سازمان‌ها قابل محاسبه نیست."
/>
) : (
<Card>
<CardTitle>تعرفه‌های جاری</CardTitle>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<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>
</tr>
</thead>
<tbody>
{cards.map((card) => {
const margin = card.customer_price - card.internal_cost_estimate;
const marginPct =
card.customer_price > 0 ? (margin / card.customer_price) * 100 : 0;
return (
<tr key={card.id} className="border-b border-surface-border/60">
<td className="px-2 py-2 text-slate-200">{unitLabel(card.unit)}</td>
<td className="ltr-inline px-2 py-2 text-xs text-slate-300">
{card.customer_price}
</td>
<td className="ltr-inline px-2 py-2 text-xs text-slate-500">
{card.internal_cost_estimate}
</td>
<td className="px-2 py-2">
<Badge tone={margin >= 0 ? "success" : "danger"}>
{marginPct.toFixed(1)}%
</Badge>
</td>
<td className="px-2 py-2">
<Badge tone={card.is_active ? "success" : "neutral"}>
{card.is_active ? "فعال" : "غیرفعال"}
</Badge>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
)}
</div>
);
}
@@ -0,0 +1,222 @@
"use client";
import * as React from "react";
import type { AdminRoutingPolicy, ModelChoice, RoutingMode } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
Checkbox,
EmptyState,
ErrorState,
Field,
LoadingState,
Select,
Stat,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
const MODES: { value: RoutingMode; label: string }[] = [
{ value: "fast", label: "سریع (fast)" },
{ value: "cheap", label: "ارزان (cheap)" },
{ value: "balanced", label: "متعادل (balanced)" },
{ value: "reasoning", label: "استدلالی (reasoning)" },
{ value: "vision", label: "تصویری (vision)" },
{ value: "coding", label: "کدنویسی (coding)" },
];
export default function AdminRoutingPage() {
const [policies, setPolicies] = React.useState<AdminRoutingPolicy[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [name, setName] = React.useState("");
const [mode, setMode] = React.useState<RoutingMode>("balanced");
const [description, setDescription] = React.useState("");
const [isDefault, setIsDefault] = React.useState(false);
const [creating, setCreating] = React.useState(false);
const [formError, setFormError] = React.useState<string | null>(null);
const [probeMode, setProbeMode] = React.useState<RoutingMode>("coding");
const [choice, setChoice] = React.useState<ModelChoice | null>(null);
const [probing, setProbing] = React.useState(false);
const [probeError, setProbeError] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
setPolicies(await api.adminRoutingPolicies());
} 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.adminCreateRoutingPolicy({
name: name.trim(),
mode,
description: description.trim(),
is_default: isDefault,
});
// A new default demotes the previous one server-side; mirror that locally.
setPolicies((current) => [
created,
...current.map((p) => (isDefault ? { ...p, is_default: false } : p)),
]);
setName("");
setDescription("");
setIsDefault(false);
} catch (err) {
setFormError(err instanceof ApiError ? err.message : "ساخت سیاست ناموفق بود.");
} finally {
setCreating(false);
}
};
const onProbe = async () => {
setProbing(true);
setProbeError(null);
try {
setChoice(await api.adminValidateModelChoice(probeMode));
} catch (err) {
setChoice(null);
setProbeError(err instanceof ApiError ? err.message : "اجرای آزمایشی ناموفق بود.");
} finally {
setProbing(false);
}
};
return (
<div className="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>
<CardHint>
بررسی کنید در هر حالت کدام مدل انتخاب می‌شود و هزینهٔ برآوردی آن چقدر است. هیچ فراخوانی
واقعی به مدل انجام نمی‌شود.
</CardHint>
<div className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="flex-1">
<Select
label="حالت"
value={probeMode}
onChange={(e) => setProbeMode(e.target.value as RoutingMode)}
options={MODES}
/>
</div>
<Button onClick={onProbe} loading={probing} variant="secondary">
اجرای آزمایشی
</Button>
</div>
{probeError && (
<div className="mt-3">
<ErrorState title="اجرای آزمایشی ناموفق" message={probeError} />
</div>
)}
{choice && (
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat label="حالت" value={<span className="ltr-inline">{choice.mode}</span>} />
<Stat label="مدل انتخاب‌شده" value={<span className="ltr-inline">{choice.internal_name}</span>} />
<Stat label="قیمت ورودی" value={choice.estimated_input_price_per_1m} hint="به ازای ۱M توکن" />
<Stat label="قیمت خروجی" value={choice.estimated_output_price_per_1m} hint="به ازای ۱M توکن" />
</div>
)}
</Card>
<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="مثلاً coding-preferred"
className="ltr-inline"
/>
<Select
label="حالت"
value={mode}
onChange={(e) => setMode(e.target.value as RoutingMode)}
options={MODES}
/>
<Field
label="توضیح"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<Checkbox
label="این سیاست پیش‌فرض سامانه باشد"
checked={isDefault}
onChange={(e) => setIsDefault(e.target.checked)}
/>
<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} />
) : policies.length === 0 ? (
<EmptyState
title="سیاستی ثبت نشده است"
description="بدون سیاست پیش‌فرض، سامانه به حالت متعادل برمی‌گردد."
/>
) : (
<div className="grid gap-3 md:grid-cols-2">
{policies.map((policy) => (
<Card key={policy.id} className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<p className="ltr-inline text-sm font-medium text-slate-100">{policy.name}</p>
<div className="flex shrink-0 gap-1.5">
{policy.is_default && <Badge tone="info">پیش‌فرض</Badge>}
<Badge tone={policy.is_active ? "success" : "neutral"}>
{policy.is_active ? "فعال" : "غیرفعال"}
</Badge>
</div>
</div>
<Badge tone="neutral" className="w-fit">
<span className="ltr-inline">{policy.mode}</span>
</Badge>
{policy.description && (
<p className="text-xs text-slate-400">{policy.description}</p>
)}
</Card>
))}
</div>
)}
</div>
);
}
+391
View File
@@ -0,0 +1,391 @@
"use client";
import * as React from "react";
import type {
BillingSummary,
Employee,
Invoice,
Payment,
UsageEvent,
} from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
EmptyState,
ErrorState,
Field,
LoadingState,
Stat,
UsageBar,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { CAN_MANAGE_BILLING, roleHasCapability } from "@/lib/permissions";
import { useSession } from "@/lib/session";
const UNIT_LABELS: Record<string, string> = {
model_input_tokens: "توکن ورودی",
model_output_tokens: "توکن خروجی",
sandbox_cpu_second: "ثانیهٔ CPU",
sandbox_memory_gb_second: "گیگابایت‌ثانیهٔ حافظه",
storage_gb_day: "گیگابایت‌روز",
tool_call: "فراخوانی ابزار",
premium_employee_run: "اجرای کارمند حرفه‌ای",
};
const INVOICE_TONES: Record<string, "neutral" | "info" | "success" | "warning" | "danger"> = {
draft: "neutral",
open: "info",
paid: "success",
void: "neutral",
uncollectible: "danger",
};
/** Format a money amount. The platform currency is a single global unit. */
function money(value: number, currency = ""): string {
const formatted = value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 6,
});
return currency ? `${formatted} ${currency}` : formatted;
}
export default function BillingPage() {
const { activeOrganization } = useSession();
const orgId = activeOrganization?.id ?? null;
const [summary, setSummary] = React.useState<BillingSummary | null>(null);
const [events, setEvents] = React.useState<UsageEvent[]>([]);
const [invoices, setInvoices] = React.useState<Invoice[]>([]);
const [payments, setPayments] = React.useState<Payment[]>([]);
const [employees, setEmployees] = React.useState<Employee[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [topUp, setTopUp] = React.useState("");
const [adding, setAdding] = React.useState(false);
const [actionError, setActionError] = React.useState<string | null>(null);
const [checkoutUrl, setCheckoutUrl] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const [nextSummary, nextEvents, nextInvoices, nextPayments, nextEmployees] =
await Promise.all([
api.billingSummary(orgId),
api.billingUsageEvents(orgId, 50),
api.billingInvoices(orgId),
api.billingPayments(orgId),
api.listEmployees(orgId),
]);
setSummary(nextSummary);
setEvents(nextEvents);
setInvoices(nextInvoices);
setPayments(nextPayments);
setEmployees(nextEmployees);
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت اطلاعات مالی ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId]);
React.useEffect(() => {
void load();
}, [load]);
const onAddCredit = async (event: React.FormEvent) => {
event.preventDefault();
if (!orgId) return;
setActionError(null);
const amount = Number(topUp);
if (!Number.isFinite(amount) || amount <= 0) {
setActionError("مبلغ باید عددی بزرگ‌تر از صفر باشد.");
return;
}
setAdding(true);
try {
const wallet = await api.billingAddCredit(orgId, amount);
setSummary((current) =>
current ? { ...current, balance: wallet.balance, low_balance: wallet.low_balance } : current,
);
setTopUp("");
} catch (err) {
setActionError(err instanceof ApiError ? err.message : "افزایش اعتبار ناموفق بود.");
} finally {
setAdding(false);
}
};
const onCheckout = async () => {
if (!orgId) return;
setActionError(null);
const amount = Number(topUp) || 10;
try {
const result = await api.billingCheckout(orgId, amount);
setCheckoutUrl(result.checkout_url);
await load();
} catch (err) {
setActionError(err instanceof ApiError ? err.message : "ایجاد پرداخت ناموفق بود.");
}
};
if (!activeOrganization) {
return (
<EmptyState
title="سازمانی انتخاب نشده است"
description="برای دیدن اطلاعات مالی ابتدا یک سازمان بسازید."
/>
);
}
if (loading) return <LoadingState label="در حال دریافت اطلاعات مالی…" />;
if (error) return <ErrorState message={error} onRetry={load} />;
const currency = summary?.currency ?? "";
const canManage = roleHasCapability(activeOrganization.role, CAN_MANAGE_BILLING);
return (
<div className="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>
{summary?.low_balance && (
<Card className="border-warning/50">
<div className="flex items-center gap-2">
<Badge tone="warning">اعتبار کم</Badge>
<p className="text-xs text-slate-300">
موجودی اعتبار به آستانهٔ هشدار رسیده است. اجراهای جدید ممکن است متوقف شوند.
</p>
</div>
</Card>
)}
<div className="grid gap-3 sm:grid-cols-3">
<Stat
label="اعتبار باقی‌مانده"
value={money(summary?.balance ?? 0, currency)}
tone={summary?.low_balance ? "warning" : "neutral"}
hint={`آستانهٔ هشدار: ${money(summary?.low_balance_threshold ?? 0)}`}
/>
<Stat label="مجموع مصرف" value={money(summary?.total_spend ?? 0, currency)} />
<Stat label="تعداد ردیف مصرف" value={events.length} hint="۵۰ ردیف آخر" />
</div>
{canManage && (
<Card>
<CardTitle>افزایش اعتبار</CardTitle>
<CardHint>
افزایش اعتبار دستی توسط مدیر سازمان. اگر درگاه پرداخت متصل باشد، می‌توانید از مسیر
پرداخت آنلاین هم استفاده کنید.
</CardHint>
<form onSubmit={onAddCredit} className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="flex-1">
<Field
label="مبلغ"
required
inputMode="decimal"
value={topUp}
onChange={(e) => setTopUp(e.target.value)}
placeholder="مثلاً 25"
className="ltr-inline"
/>
</div>
<Button type="submit" loading={adding}>
افزودن اعتبار
</Button>
<Button type="button" variant="secondary" onClick={onCheckout}>
پرداخت آنلاین
</Button>
</form>
{actionError && (
<div className="mt-3">
<ErrorState title="عملیات مالی ناموفق" message={actionError} />
</div>
)}
{checkoutUrl && (
<p className="mt-3 text-xs text-slate-400">
لینک پرداخت:{" "}
<a
href={checkoutUrl}
target="_blank"
rel="noreferrer noopener"
className="ltr-inline text-accent hover:underline"
>
{checkoutUrl}
</a>
</p>
)}
</Card>
)}
<Card>
<CardTitle>تفکیک بر اساس واحد مصرف</CardTitle>
{!summary || summary.by_unit.length === 0 ? (
<p className="mt-2 text-xs text-slate-500">هنوز مصرفی ثبت نشده است.</p>
) : (
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<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>
</tr>
</thead>
<tbody>
{summary.by_unit.map((row) => (
<tr key={row.unit} className="border-b border-surface-border/60">
<td className="px-2 py-2 text-slate-200">
{UNIT_LABELS[row.unit] ?? row.unit}
</td>
<td className="ltr-inline px-2 py-2 text-xs text-slate-400">
{row.quantity.toLocaleString("en-US", { maximumFractionDigits: 2 })}
</td>
<td className="ltr-inline px-2 py-2 text-xs text-slate-300">
{money(row.total_price, currency)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardTitle>مصرف به تفکیک کارمند</CardTitle>
{!summary || Object.keys(summary.by_employee).length === 0 ? (
<p className="mt-2 text-xs text-slate-500">مصرفی به کارمندی نسبت داده نشده است.</p>
) : (
<ul className="mt-3 flex flex-col gap-2">
{Object.entries(summary.by_employee).map(([employeeId, total]) => {
const employee = employees.find((e) => e.id === employeeId);
const share =
(summary.total_spend > 0 ? total / summary.total_spend : 0) * 100;
return (
<li key={employeeId} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-slate-300">{employee?.name ?? employeeId.slice(0, 8)}</span>
<span className="ltr-inline text-slate-400">{money(total, currency)}</span>
</div>
<UsageBar used={share} total={100} />
</li>
);
})}
</ul>
)}
</Card>
<Card>
<CardTitle>صورت‌حساب‌ها</CardTitle>
{invoices.length === 0 ? (
<p className="mt-2 text-xs text-slate-500">صورت‌حسابی صادر نشده است.</p>
) : (
<ul className="mt-3 flex flex-col gap-2">
{invoices.map((invoice) => (
<li
key={invoice.id}
className="flex items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2"
>
<span className="ltr-inline text-xs text-slate-300">
{money(invoice.amount, invoice.currency)}
</span>
<Badge tone={INVOICE_TONES[invoice.status] ?? "neutral"}>
{invoice.status}
</Badge>
</li>
))}
</ul>
)}
</Card>
</div>
<Card>
<CardTitle>آخرین ردیف‌های مصرف</CardTitle>
<CardHint>هر ردیف یک واحد قابل صورتحساب است و حذف نمی‌شود.</CardHint>
{events.length === 0 ? (
<p className="mt-2 text-xs text-slate-500">مصرفی ثبت نشده است.</p>
) : (
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<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>
</tr>
</thead>
<tbody>
{events.map((event) => (
<tr key={event.id} className="border-b border-surface-border/60">
<td className="px-2 py-2 text-slate-200">
{UNIT_LABELS[event.unit] ?? event.unit}
</td>
<td className="ltr-inline px-2 py-2 text-xs text-slate-400">
{event.quantity.toLocaleString("en-US", { maximumFractionDigits: 2 })}
</td>
<td className="ltr-inline px-2 py-2 text-xs text-slate-300">
{money(event.total_price, currency)}
</td>
<td className="px-2 py-2 text-xs text-slate-500">
{new Date(event.created_at).toLocaleString("fa-IR")}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
{payments.length > 0 && (
<Card>
<CardTitle>پرداخت‌ها</CardTitle>
<ul className="mt-3 flex flex-col gap-2">
{payments.map((payment) => (
<li
key={payment.id}
className="flex items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2"
>
<span className="ltr-inline text-xs text-slate-300">
{money(payment.amount, payment.currency)}
</span>
<div className="flex gap-2">
<Badge tone="neutral">
<span className="ltr-inline">{payment.provider}</span>
</Badge>
<Badge
tone={
payment.status === "succeeded"
? "success"
: payment.status === "failed"
? "danger"
: "warning"
}
>
{payment.status}
</Badge>
</div>
</li>
))}
</ul>
</Card>
)}
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
"use client";
import * as React from "react";
import type { AuditEvent, ReadinessResponse } from "@agy/contracts";
import {
Badge,
Card,
CardHint,
CardTitle,
EmptyState,
ErrorState,
LoadingState,
Stat,
Timeline,
UsageBar,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { useSession } from "@/lib/session";
export default function DashboardPage() {
const { activeOrganization, user } = useSession();
const [readiness, setReadiness] = React.useState<ReadinessResponse | null>(null);
const [audit, setAudit] = React.useState<AuditEvent[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const health = await api.readiness();
setReadiness(health);
if (activeOrganization) {
setAudit(await api.listAudit(activeOrganization.id).catch(() => []));
}
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت وضعیت ناموفق بود.");
} finally {
setLoading(false);
}
}, [activeOrganization]);
React.useEffect(() => {
void load();
}, [load]);
if (loading) return <LoadingState label="در حال دریافت وضعیت سازمان…" />;
if (error) return <ErrorState message={error} onRetry={load} />;
if (!activeOrganization) {
return (
<EmptyState
title="هنوز سازمانی انتخاب نشده است"
description="برای دیدن داشبورد، ابتدا یک سازمان بسازید یا از منوی بالا یکی را انتخاب کنید."
action={
<a
href="/onboarding"
className="rounded-lg bg-accent px-4 py-2 text-sm text-white hover:bg-accent-strong"
>
ساخت سازمان
</a>
}
/>
);
}
const adapters = readiness?.checks.adapters ?? {};
const databaseOk = readiness?.checks.database === "ok";
return (
<div className="flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">
{activeOrganization.name}
</h1>
<p className="text-sm text-slate-400">
خوش آمدید{user?.name ? `، ${user.name}` : ""}. وضعیت کلی سازمان در یک نگاه.
</p>
</header>
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Stat label="اعتبار" value="—" hint="پس از فاز billing" />
<Stat label="وظایف فعال" value="0" hint="پس از فاز Task engine" />
<Stat label="کارمندهای فعال" value="0" hint="پس از فاز Workforce" />
<Stat
label="وضعیت سرویس"
value={databaseOk ? "سالم" : "ناپایدار"}
tone={databaseOk ? "neutral" : "danger"}
hint={`محیط: ${readiness?.checks.adapters ? "dev" : "—"}`}
/>
</section>
<section className="grid gap-4 lg:grid-cols-2">
<Card>
<CardTitle>مصرف این ماه</CardTitle>
<CardHint>سقف مصرف بر اساس پلن سازمان محاسبه می‌شود.</CardHint>
<div className="mt-4">
<UsageBar used={0} total={100} label="مصرف از سقف" />
</div>
</Card>
<Card>
<CardTitle>وضعیت اتصال‌ها</CardTitle>
<CardHint>
مدل و provider از دید شما پنهان است؛ این‌ها فقط وضعیت فنی سرویس‌اند.
</CardHint>
<div className="mt-3 flex flex-wrap gap-2">
<Badge tone={databaseOk ? "success" : "danger"}>
پایگاه داده: {databaseOk ? "متصل" : "خطا"}
</Badge>
{Object.entries(adapters).map(([key, value]) => (
<Badge key={key} tone={value === "mock" ? "warning" : "info"}>
{key}: {value}
</Badge>
))}
</div>
</Card>
</section>
<section className="grid gap-4 lg:grid-cols-2">
<Card>
<CardTitle>رویدادهای اخیر سازمان</CardTitle>
<CardHint>هر عملیات حساس در audit log ثبت می‌شود.</CardHint>
<div className="mt-4">
{audit.length === 0 ? (
<p className="text-xs text-slate-500">رویدادی ثبت نشده است.</p>
) : (
<Timeline
items={audit.slice(0, 8).map((event) => ({
id: event.id,
title: event.action,
meta: new Date(event.created_at).toLocaleString("fa-IR"),
}))}
/>
)}
</div>
</Card>
<Card>
<CardTitle>دامنه‌های پیش‌نمایش</CardTitle>
<CardHint>پس از فاز Domain Routing، دامنه‌های تست پروژه‌ها اینجا دیده می‌شوند.</CardHint>
<p className="mt-4 text-xs text-slate-500">دامنه‌ای ثبت نشده است.</p>
</Card>
</section>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { PlaceholderPage } from "@/components/PlaceholderPage";
export default function KnowledgePage() {
return (
<PlaceholderPage
title="دانش سازمانی"
description="منابع دانش سازمان که کارمندهای هوش مصنوعی از آن‌ها استفاده می‌کنند."
planned={[
"افزودن فایل، متن، URL و مخزن به‌عنوان منبع دانش",
"استخراج متن، تکه‌بندی (chunk) و ایندکس برداری",
"جست‌وجوی سادهٔ کلیدواژه‌ای در MVP",
"اتصال هر منبع دانش به کارمندهای مشخص",
"جداسازی کامل دانش بین سازمان‌ها",
]}
/>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { useRouter } from "next/navigation";
import * as React from "react";
import { LoadingState } from "@agy/ui";
import { AppShell } from "@/components/AppShell";
import { useSession } from "@/lib/session";
export default function AppLayout({ children }: { children: React.ReactNode }) {
const { ready, user } = useSession();
const router = useRouter();
React.useEffect(() => {
if (ready && !user) router.replace("/login");
}, [ready, user, router]);
if (!ready) {
return (
<div className="flex min-h-screen items-center justify-center p-8">
<LoadingState label="در حال آماده‌سازی نشست…" />
</div>
);
}
if (!user) return null;
return <AppShell>{children}</AppShell>;
}
+321
View File
@@ -0,0 +1,321 @@
"use client";
import Link from "next/link";
import * as React from "react";
import type { Employee, Message, Task, Thread } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
EmptyState,
ErrorState,
Field,
LoadingState,
Select,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions";
import { useSession } from "@/lib/session";
/**
* Manager Chat — three columns, matching the plan: conversations, the
* conversation itself, and the work it produced.
*/
export default function ManagerPage() {
const { activeOrganization } = useSession();
const orgId = activeOrganization?.id ?? null;
const [threads, setThreads] = React.useState<Thread[]>([]);
const [employees, setEmployees] = React.useState<Employee[]>([]);
const [activeThreadId, setActiveThreadId] = React.useState<string | null>(null);
const [messages, setMessages] = React.useState<Message[]>([]);
const [tasks, setTasks] = React.useState<Task[]>([]);
const [loading, setLoading] = React.useState(true);
const [loadingThread, setLoadingThread] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [draft, setDraft] = React.useState("");
const [sending, setSending] = React.useState(false);
const [sendError, setSendError] = React.useState<string | null>(null);
const [creatingTask, setCreatingTask] = React.useState(false);
const [taskDraft, setTaskDraft] = React.useState("");
const [taskEmployee, setTaskEmployee] = React.useState("");
const [taskError, setTaskError] = React.useState<string | null>(null);
const bottomRef = React.useRef<HTMLDivElement | null>(null);
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const [nextThreads, nextEmployees, nextTasks] = await Promise.all([
api.listThreads(orgId),
api.listEmployees(orgId),
api.listTasks(orgId),
]);
setThreads(nextThreads);
setEmployees(nextEmployees);
setTasks(nextTasks);
setActiveThreadId((current) => current ?? nextThreads[0]?.id ?? null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت گفتگوها ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId]);
React.useEffect(() => {
void load();
}, [load]);
const loadThread = React.useCallback(
async (threadId: string) => {
if (!orgId) return;
setLoadingThread(true);
setSendError(null);
try {
const detail = await api.getThread(orgId, threadId);
setMessages(detail.messages);
} catch (err) {
setSendError(err instanceof ApiError ? err.message : "دریافت پیام‌ها ناموفق بود.");
} finally {
setLoadingThread(false);
}
},
[orgId],
);
React.useEffect(() => {
if (activeThreadId) void loadThread(activeThreadId);
else setMessages([]);
}, [activeThreadId, loadThread]);
React.useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const onCreateThread = async () => {
if (!orgId) return;
try {
const thread = await api.createThread(orgId, { title: "گفتگوی جدید" });
setThreads((current) => [thread, ...current]);
setActiveThreadId(thread.id);
setMessages([]);
} catch (err) {
setError(err instanceof ApiError ? err.message : "ساخت گفتگو ناموفق بود.");
}
};
const onSend = async (event: React.FormEvent) => {
event.preventDefault();
if (!orgId || !activeThreadId || !draft.trim()) return;
setSendError(null);
setSending(true);
const content = draft.trim();
setDraft("");
try {
const result = await api.postMessage(orgId, activeThreadId, { content });
setMessages((current) => [...current, result.user_message, result.assistant_message]);
} catch (err) {
setSendError(err instanceof ApiError ? err.message : "ارسال پیام ناموفق بود.");
setDraft(content);
} finally {
setSending(false);
}
};
const onCreateTask = async (event: React.FormEvent) => {
event.preventDefault();
if (!orgId || !activeThreadId) return;
setTaskError(null);
setCreatingTask(true);
try {
const task = await api.createTask(orgId, {
title: taskDraft.trim(),
description: `از گفتگو: ${messages.filter((m) => m.role === "user").at(-1)?.content.slice(0, 400) ?? ""}`,
thread_id: activeThreadId,
assigned_employee_id: taskEmployee || null,
});
setTasks((current) => [task, ...current]);
setTaskDraft("");
} catch (err) {
setTaskError(err instanceof ApiError ? err.message : "ساخت کار ناموفق بود.");
} finally {
setCreatingTask(false);
}
};
if (!activeOrganization) {
return (
<EmptyState
title="سازمانی انتخاب نشده است"
description="برای گفتگو با مدیر هوش مصنوعی ابتدا یک سازمان بسازید."
/>
);
}
const canRun = roleHasCapability(activeOrganization.role, CAN_RUN_TASKS);
const threadTasks = tasks.filter((t) => t.thread_id === activeThreadId);
if (loading) return <LoadingState label="در حال دریافت گفتگوها…" />;
if (error) return <ErrorState message={error} onRetry={load} />;
return (
<div className="flex h-[calc(100vh-8rem)] flex-col gap-4">
<header className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">گفتگو با مدیر</h1>
<p className="text-sm text-slate-400">
درخواست خود را بنویسید؛ مدیر آن را می‌فهمد و در صورت نیاز به یک کار تبدیل می‌کند.
</p>
</div>
<Button variant="secondary" onClick={onCreateThread}>
گفتگوی جدید
</Button>
</header>
<div className="grid min-h-0 flex-1 gap-4 lg:grid-cols-[16rem_1fr_20rem]">
{/* Column 1 — conversations */}
<Card className="flex min-h-0 flex-col gap-2 overflow-y-auto">
<CardTitle>گفتگوها</CardTitle>
{threads.length === 0 ? (
<p className="text-xs text-slate-500">گفتگویی وجود ندارد.</p>
) : (
<ul className="flex flex-col gap-1">
{threads.map((thread) => (
<li key={thread.id}>
<button
type="button"
onClick={() => setActiveThreadId(thread.id)}
className={
"w-full rounded-lg px-2 py-1.5 text-right text-xs transition-colors " +
(thread.id === activeThreadId
? "bg-accent-soft text-slate-100"
: "text-slate-400 hover:bg-surface-overlay")
}
>
{thread.title || "بدون عنوان"}
</button>
</li>
))}
</ul>
)}
</Card>
{/* Column 2 — the conversation */}
<Card className="flex min-h-0 flex-col">
<CardTitle>پیام‌ها</CardTitle>
<div className="mt-3 flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto pl-1">
{loadingThread ? (
<LoadingState label="در حال دریافت پیام‌ها…" />
) : messages.length === 0 ? (
<p className="text-xs text-slate-500">
هنوز پیامی رد و بدل نشده است. اولین پیام را بنویسید.
</p>
) : (
messages.map((message) => (
<div
key={message.id}
className={
"max-w-[85%] rounded-xl border px-3 py-2 text-sm " +
(message.role === "user"
? "self-end border-accent/40 bg-accent-soft text-slate-100"
: "self-start border-surface-border bg-surface-overlay text-slate-300")
}
>
{message.content}
</div>
))
)}
<div ref={bottomRef} />
</div>
{sendError && (
<div className="mt-3">
<ErrorState title="ارسال ناموفق" message={sendError} />
</div>
)}
<form onSubmit={onSend} className="mt-3 flex items-end gap-2 border-t border-surface-border pt-3">
<div className="flex-1">
<Field
label="پیام شما"
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="مثلاً یک صفحهٔ گزارش فروش طراحی کن…"
disabled={!activeThreadId || sending}
/>
</div>
<Button type="submit" loading={sending} disabled={!activeThreadId || !draft.trim()}>
ارسال
</Button>
</form>
</Card>
{/* Column 3 — the work this conversation produced */}
<Card className="flex min-h-0 flex-col gap-3 overflow-y-auto">
<CardTitle>کارهای این گفتگو</CardTitle>
<CardHint>از گفتگو یک کار بسازید تا کارمند هوش مصنوعی آن را اجرا کند.</CardHint>
{threadTasks.length === 0 ? (
<p className="text-xs text-slate-500">کاری ساخته نشده است.</p>
) : (
<ul className="flex flex-col gap-2">
{threadTasks.map((task) => (
<li key={task.id}>
<Link
href={`/tasks/${task.id}`}
className="flex items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2 text-xs text-slate-300 hover:border-accent/50"
>
<span className="truncate">{task.title}</span>
<Badge tone={task.status === "done" ? "success" : "neutral"}>
{task.status}
</Badge>
</Link>
</li>
))}
</ul>
)}
{canRun && (
<form onSubmit={onCreateTask} className="flex flex-col gap-3 border-t border-surface-border pt-3">
<Field
label="عنوان کار جدید"
required
minLength={2}
value={taskDraft}
onChange={(e) => setTaskDraft(e.target.value)}
placeholder="عنوان کار…"
/>
<Select
label="کارمند مسئول"
value={taskEmployee}
onChange={(e) => setTaskEmployee(e.target.value)}
options={[
{ value: "", label: "سپردن خودکار به مدیر" },
...employees.map((e) => ({ value: e.id, label: e.name })),
]}
/>
<Button
type="submit"
loading={creatingTask}
disabled={!activeThreadId || !taskDraft.trim()}
>
ساخت کار
</Button>
</form>
)}
{taskError && <ErrorState title="ساخت کار ناموفق" message={taskError} />}
</Card>
</div>
</div>
);
}
@@ -0,0 +1,464 @@
"use client";
import Link from "next/link";
import * as React from "react";
import type { PreviewDomain, Project, RepoStatus, Repository } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
ConfirmDialog,
EmptyState,
ErrorState,
Field,
LoadingState,
Stat,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { useSession } from "@/lib/session";
import { SandboxPanel } from "@/components/sandbox-panel";
type Tab = "repo" | "preview" | "sandbox";
const DOMAIN_TONES = {
active: "success",
disabled: "neutral",
failed: "danger",
} as const;
export default function ProjectDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id: projectId } = React.use(params);
const { activeOrganization } = useSession();
const [tab, setTab] = React.useState<Tab>("repo");
const [project, setProject] = React.useState<Project | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const orgId = activeOrganization?.id ?? null;
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
setProject(await api.getProject(orgId, projectId));
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت پروژه ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId, projectId]);
React.useEffect(() => {
void load();
}, [load]);
if (!activeOrganization) {
return (
<EmptyState
title="سازمانی انتخاب نشده است"
description="برای دیدن جزئیات پروژه ابتدا یک سازمان بسازید."
/>
);
}
if (loading) return <LoadingState label="در حال دریافت پروژه…" />;
if (error) return <ErrorState message={error} onRetry={load} />;
if (!project) {
return (
<EmptyState
title="پروژه پیدا نشد"
description="این پروژه حذف شده است یا به سازمان دیگری تعلق دارد."
/>
);
}
return (
<div className="flex flex-col gap-5">
<nav className="text-xs text-slate-500">
<Link href="/projects" className="hover:text-slate-300">
پروژه‌ها
</Link>
<span className="mx-1.5">/</span>
<span className="text-slate-400">{project.name}</span>
</nav>
<header className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<h1 className="text-lg font-medium text-slate-100">{project.name}</h1>
<Badge tone="neutral">
<span className="ltr-inline">{project.slug}</span>
</Badge>
</div>
{project.description ? (
<p className="text-sm text-slate-400">{project.description}</p>
) : (
<p className="text-sm text-slate-500">توضیحی ثبت نشده است.</p>
)}
</header>
<div className="flex gap-1 border-b border-surface-border">
{(
[
["repo", "مخزن کد"],
["preview", "دامنهٔ تست"],
["sandbox", "محیط ایزوله"],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => setTab(value)}
className={
"border-b-2 px-3 py-2 text-sm transition-colors " +
(tab === value
? "border-accent text-slate-100"
: "border-transparent text-slate-400 hover:text-slate-200")
}
>
{label}
</button>
))}
</div>
{tab === "repo" ? (
<RepositoryPanel orgId={activeOrganization.id} projectId={project.id} />
) : tab === "preview" ? (
<PreviewDomainPanel orgId={activeOrganization.id} projectId={project.id} />
) : (
<SandboxPanel orgId={activeOrganization.id} projectId={project.id} />
)}
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Repository */
/* -------------------------------------------------------------------------- */
function RepositoryPanel({ orgId, projectId }: { orgId: string; projectId: string }) {
const [status, setStatus] = React.useState<RepoStatus | null>(null);
const [repo, setRepo] = React.useState<Repository | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [creating, setCreating] = React.useState(false);
const [copied, setCopied] = React.useState(false);
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const nextStatus = await api.getRepoStatus(orgId, projectId);
setStatus(nextStatus);
if (nextStatus.exists) {
// The status probe only reports shape; the detail call carries the URLs.
setRepo(await api.getRepo(orgId, projectId));
} else {
setRepo(null);
}
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت وضعیت مخزن ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId, projectId]);
React.useEffect(() => {
void load();
}, [load]);
const onEnsure = async () => {
setCreating(true);
setError(null);
try {
setRepo(await api.ensureRepo(orgId, projectId));
setStatus(await api.getRepoStatus(orgId, projectId));
} catch (err) {
setError(err instanceof ApiError ? err.message : "ساخت مخزن ناموفق بود.");
} finally {
setCreating(false);
}
};
const onCopy = async () => {
if (!repo?.clone_url) return;
try {
await navigator.clipboard.writeText(repo.clone_url);
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
} catch {
/* clipboard blocked — the URL is visible and selectable anyway */
}
};
if (loading) return <LoadingState label="در حال بررسی مخزن…" />;
return (
<div className="flex flex-col gap-4">
{error && <ErrorState message={error} onRetry={load} />}
{!status?.exists ? (
<Card>
<CardTitle>مخزنی وجود ندارد</CardTitle>
<CardHint>
یک مخزن خصوصی Git برای این پروژه ساخته می‌شود و اعتبارنامهٔ آن به‌صورت رمزنگاری‌شده
ذخیره می‌گردد. کلید خام هرگز از طریق API برگردانده نمی‌شود.
</CardHint>
<div className="mt-4">
<Button onClick={onEnsure} loading={creating}>
ساخت مخزن خصوصی
</Button>
</div>
</Card>
) : (
<>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat
label="وضعیت"
value={<Badge tone="success">فعال</Badge>}
hint={status.private ? "مخزن خصوصی" : "مخزن عمومی"}
/>
<Stat label="شاخهٔ پیش‌فرض" value={<span className="ltr-inline">{status.default_branch}</span>} />
<Stat label="تعداد شاخه‌ها" value={status.branches.length} />
<Stat label="درخواست‌های ادغام باز" value={status.open_pull_requests} />
</div>
<Card>
<CardTitle>دسترسی</CardTitle>
<CardHint>از این آدرس برای clone استفاده کنید؛ اعتبارنامه به‌صورت خودکار تزریق می‌شود.</CardHint>
<div className="mt-3 flex flex-col gap-2">
<div className="flex items-center gap-2 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2">
<code className="ltr-inline flex-1 truncate text-xs text-slate-300">
{repo?.clone_url ?? "—"}
</code>
<Button variant="ghost" size="sm" onClick={onCopy} disabled={!repo?.clone_url}>
{copied ? "کپی شد" : "کپی"}
</Button>
</div>
{repo?.web_url && (
<a
href={repo.web_url}
target="_blank"
rel="noreferrer noopener"
className="ltr-inline w-fit text-xs text-accent hover:underline"
>
{repo.web_url}
</a>
)}
<p className="text-xs text-slate-500">
نام مخزن: <span className="ltr-inline">{repo?.repo_name ?? "—"}</span> · مالک:{" "}
<span className="ltr-inline">{repo?.owner ?? "—"}</span> · ارائه‌دهنده:{" "}
<span className="ltr-inline">{repo?.provider ?? "—"}</span>
</p>
</div>
</Card>
<Card>
<CardTitle>شاخه‌ها</CardTitle>
{status.branches.length === 0 ? (
<p className="mt-2 text-xs text-slate-500">شاخه‌ای ثبت نشده است.</p>
) : (
<ul className="mt-3 flex flex-wrap gap-2">
{status.branches.map((branch) => (
<li key={branch}>
<Badge tone={branch === status.default_branch ? "info" : "neutral"}>
<span className="ltr-inline">{branch}</span>
</Badge>
</li>
))}
</ul>
)}
</Card>
</>
)}
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Preview domains */
/* -------------------------------------------------------------------------- */
function PreviewDomainPanel({ orgId, projectId }: { orgId: string; projectId: string }) {
const [domains, setDomains] = React.useState<PreviewDomain[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [subdomain, setSubdomain] = React.useState("");
const [port, setPort] = React.useState("");
const [creating, setCreating] = React.useState(false);
const [formError, setFormError] = React.useState<string | null>(null);
const [pendingDelete, setPendingDelete] = React.useState<PreviewDomain | null>(null);
const [deleting, setDeleting] = React.useState(false);
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
setDomains(await api.listPreviewDomains(orgId, projectId));
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت دامنه‌ها ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId, projectId]);
React.useEffect(() => {
void load();
}, [load]);
const onCreate = async (event: React.FormEvent) => {
event.preventDefault();
setFormError(null);
const parsedPort = port.trim() === "" ? undefined : Number(port);
if (parsedPort !== undefined && (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535)) {
setFormError("شمارهٔ پورت باید عددی بین ۱ تا ۶۵۵۳۵ باشد.");
return;
}
setCreating(true);
try {
const created = await api.createPreviewDomain(orgId, projectId, {
subdomain: subdomain.trim(),
port: parsedPort,
});
setDomains((current) => [...current, created]);
setSubdomain("");
setPort("");
} catch (err) {
setFormError(err instanceof ApiError ? err.message : "ساخت دامنه ناموفق بود.");
} finally {
setCreating(false);
}
};
const onConfirmDelete = async () => {
if (!pendingDelete) return;
setDeleting(true);
try {
await api.deletePreviewDomain(orgId, projectId, pendingDelete.id);
setDomains((current) => current.filter((d) => d.id !== pendingDelete.id));
setPendingDelete(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "حذف دامنه ناموفق بود.");
setPendingDelete(null);
} finally {
setDeleting(false);
}
};
return (
<div className="flex flex-col gap-4">
<Card>
<CardTitle>ساخت دامنهٔ تست</CardTitle>
<CardHint>
برچسب زیردامنه باید بین ۳ تا ۴۰ نویسه و فقط شامل حروف کوچک انگلیسی، رقم و خط تیره باشد.
اگر پورت را خالی بگذارید، سامانه خودش یک پورت آزاد تخصیص می‌دهد.
</CardHint>
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="flex-1">
<Field
label="زیردامنه"
required
minLength={3}
maxLength={40}
pattern="[a-z0-9-]+"
value={subdomain}
onChange={(e) => setSubdomain(e.target.value)}
placeholder="مثلاً demo-shop"
className="ltr-inline"
/>
</div>
<div className="w-full sm:w-40">
<Field
label="پورت (اختیاری)"
inputMode="numeric"
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder="۸۰۸۰"
className="ltr-inline"
/>
</div>
<Button type="submit" loading={creating}>
ساخت دامنه
</Button>
</form>
{formError && (
<div className="mt-3">
<ErrorState title="ساخت دامنه ناموفق" message={formError} />
</div>
)}
</Card>
{error && <ErrorState message={error} onRetry={load} />}
{loading ? (
<LoadingState label="در حال دریافت دامنه‌ها…" />
) : domains.length === 0 ? (
<EmptyState
title="دامنه‌ای ثبت نشده است"
description="برای انتشار موقت پروژه، یک زیردامنه بسازید."
/>
) : (
<div className="grid gap-3 md:grid-cols-2">
{domains.map((domain) => (
<Card key={domain.id} className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-col gap-1">
<p className="ltr-inline truncate text-sm font-medium text-slate-100">
{domain.host}
</p>
<p className="ltr-inline text-xs text-slate-500">پورت {domain.port}</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Badge tone={DOMAIN_TONES[domain.status] ?? "neutral"}>{domain.status}</Badge>
{domain.protected && <Badge tone="warning">محافظت‌شده</Badge>}
</div>
</div>
<a
href={domain.url}
target="_blank"
rel="noreferrer noopener"
className="ltr-inline w-fit truncate text-xs text-accent hover:underline"
>
{domain.url}
</a>
<div className="mt-auto flex justify-end">
<Button
variant="danger"
size="sm"
onClick={() => setPendingDelete(domain)}
disabled={domain.protected}
title={domain.protected ? "دامنه‌های محافظت‌شده حذف نمی‌شوند." : undefined}
>
حذف
</Button>
</div>
</Card>
))}
</div>
)}
<ConfirmDialog
open={pendingDelete !== null}
title="حذف دامنهٔ تست"
description={
pendingDelete
? `دامنهٔ «${pendingDelete.host}» حذف می‌شود و از این پس در دسترس نخواهد بود.`
: undefined
}
confirmLabel={deleting ? "در حال حذف…" : "حذف کن"}
onConfirm={onConfirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
"use client";
import Link from "next/link";
import * as React from "react";
import type { Project } from "@agy/contracts";
import {
Button,
Card,
CardHint,
CardTitle,
EmptyState,
ErrorState,
Field,
LoadingState,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { useSession } from "@/lib/session";
export default function ProjectsPage() {
const { activeOrganization } = useSession();
const [projects, setProjects] = React.useState<Project[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [name, setName] = React.useState("");
const [description, setDescription] = React.useState("");
const [creating, setCreating] = React.useState(false);
const [formError, setFormError] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
if (!activeOrganization) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
setProjects(await api.listProjects(activeOrganization.id));
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت پروژه‌ها ناموفق بود.");
} finally {
setLoading(false);
}
}, [activeOrganization]);
React.useEffect(() => {
void load();
}, [load]);
const onCreate = async (event: React.FormEvent) => {
event.preventDefault();
if (!activeOrganization) return;
setFormError(null);
setCreating(true);
try {
const created = await api.createProject(activeOrganization.id, {
name: name.trim(),
description: description.trim(),
});
setProjects((current) => [created, ...current]);
setName("");
setDescription("");
} catch (err) {
setFormError(err instanceof ApiError ? err.message : "ساخت پروژه ناموفق بود.");
} finally {
setCreating(false);
}
};
if (!activeOrganization) {
return (
<EmptyState
title="سازمانی انتخاب نشده است"
description="برای دیدن پروژه‌ها ابتدا یک سازمان بسازید."
/>
);
}
return (
<div className="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>
<CardHint>نام پروژه به یک slug یکتا در سازمان تبدیل می‌شود.</CardHint>
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="flex-1">
<Field
label="نام پروژه"
required
minLength={2}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="مثلاً فروشگاه آنلاین"
/>
</div>
<div className="flex-1">
<Field
label="توضیح (اختیاری)"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<Button type="submit" loading={creating}>
ساخت پروژه
</Button>
</form>
{formError && (
<div className="mt-3">
<ErrorState title="ساخت پروژه ناموفق" message={formError} />
</div>
)}
</Card>
{loading ? (
<LoadingState label="در حال دریافت پروژه‌ها…" />
) : error ? (
<ErrorState message={error} onRetry={load} />
) : projects.length === 0 ? (
<EmptyState
title="هنوز پروژه‌ای نساخته‌اید"
description="اولین پروژه را با فرم بالا بسازید؛ سپس می‌توانید مخزن Git و دامنهٔ تست بسازید."
/>
) : (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{projects.map((project) => (
<Card key={project.id} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-slate-100">{project.name}</p>
<p className="text-xs text-slate-500 ltr-inline">{project.slug}</p>
</div>
{project.description && (
<p className="text-xs text-slate-400">{project.description}</p>
)}
<Link href={`/projects/${project.id}`} className="mt-auto">
<Button variant="secondary" size="sm" className="w-full">
جزئیات پروژه
</Button>
</Link>
</Card>
))}
</div>
)}
</div>
);
}
+379
View File
@@ -0,0 +1,379 @@
"use client";
import * as React from "react";
import type { AuditEvent, Invite, Member, Role } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
ConfirmDialog,
EmptyState,
ErrorState,
Field,
LoadingState,
Select,
Timeline,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import {
CAN_MANAGE_MEMBERS,
CAN_VIEW_AUDIT,
roleHasCapability,
} from "@/lib/permissions";
import { useSession } from "@/lib/session";
const ROLE_LABELS: Record<Role, string> = {
owner: "مالک",
admin: "مدیر",
manager: "سرپرست",
member: "عضو",
viewer: "بیننده",
};
const AUDIT_LABELS: Record<string, string> = {
"auth.login": "ورود به حساب",
"auth.logout": "خروج از حساب",
"auth.signup": "ثبت‌نام",
"organization.create": "ساخت سازمان",
"organization.delete": "حذف سازمان",
"member.invite": "دعوت عضو",
"member.role_change": "تغییر نقش",
"member.remove": "حذف عضو",
"employee.create": "ساخت کارمند",
"employee.update": "ویرایش کارمند",
"repo.create": "ساخت مخزن",
"repo.delete": "حذف مخزن",
"domain.create": "ساخت دامنه",
"domain.delete": "حذف دامنه",
"sandbox.command": "اجرای دستور سندباکس",
"sandbox.destroy": "نابودی سندباکس",
"approval.decision": "تصمیم تأیید",
"billing.credit": "تغییر اعتبار",
"billing.payment": "پرداخت",
"provider.change": "تغییر ارائه‌دهنده",
"model.change": "تغییر مدل",
"routing.change": "تغییر مسیریابی",
"project.create": "ساخت پروژه",
"project.delete": "حذف پروژه",
"task.create": "ساخت کار",
"run.cancel": "لغو اجرا",
};
type Tab = "members" | "audit";
export default function SettingsPage() {
const { activeOrganization } = useSession();
const orgId = activeOrganization?.id ?? null;
const [tab, setTab] = React.useState<Tab>("members");
const [members, setMembers] = React.useState<Member[]>([]);
const [audit, setAudit] = React.useState<AuditEvent[]>([]);
const [loading, setLoading] = React.useState(true);
const [auditLoading, setAuditLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [email, setEmail] = React.useState("");
const [inviteRole, setInviteRole] = React.useState<Role>("member");
const [inviting, setInviting] = React.useState(false);
const [inviteError, setInviteError] = React.useState<string | null>(null);
const [lastInvite, setLastInvite] = React.useState<Invite | null>(null);
const [busyMemberId, setBusyMemberId] = React.useState<string | null>(null);
const [pendingRemove, setPendingRemove] = React.useState<Member | null>(null);
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
setMembers(await api.listMembers(orgId));
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت اعضا ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId]);
const loadAudit = React.useCallback(async () => {
if (!orgId) return;
setAuditLoading(true);
try {
setAudit(await api.listAudit(orgId));
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت رویدادهای حسابرسی ناموفق بود.");
} finally {
setAuditLoading(false);
}
}, [orgId]);
React.useEffect(() => {
void load();
}, [load]);
React.useEffect(() => {
if (tab === "audit") void loadAudit();
}, [tab, loadAudit]);
const onInvite = async (event: React.FormEvent) => {
event.preventDefault();
if (!orgId) return;
setInviteError(null);
setLastInvite(null);
setInviting(true);
try {
const invite = await api.createInvite(orgId, { email: email.trim(), role: inviteRole });
setLastInvite(invite);
setEmail("");
} catch (err) {
setInviteError(err instanceof ApiError ? err.message : "ارسال دعوت ناموفق بود.");
} finally {
setInviting(false);
}
};
const onChangeRole = async (member: Member, role: Role) => {
if (!orgId) return;
setBusyMemberId(member.id);
setError(null);
try {
const updated = await api.updateMember(orgId, member.id, { role });
setMembers((current) => current.map((m) => (m.id === updated.id ? updated : m)));
} catch (err) {
setError(err instanceof ApiError ? err.message : "تغییر نقش ناموفق بود.");
} finally {
setBusyMemberId(null);
}
};
const onConfirmRemove = async () => {
if (!orgId || !pendingRemove) return;
setBusyMemberId(pendingRemove.id);
try {
await api.removeMember(orgId, pendingRemove.id);
setMembers((current) => current.filter((m) => m.id !== pendingRemove.id));
setPendingRemove(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "حذف عضو ناموفق بود.");
setPendingRemove(null);
} finally {
setBusyMemberId(null);
}
};
if (!activeOrganization) {
return (
<EmptyState
title="سازمانی انتخاب نشده است"
description="برای مشاهدهٔ تنظیمات، ابتدا یک سازمان بسازید."
/>
);
}
const canManageMembers = roleHasCapability(activeOrganization.role, CAN_MANAGE_MEMBERS);
const canViewAudit = roleHasCapability(activeOrganization.role, CAN_VIEW_AUDIT);
return (
<div className="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">{activeOrganization.name}</p>
</header>
<div className="flex gap-1 border-b border-surface-border">
{(
[
["members", "اعضا و دعوت"],
["audit", "رویدادهای حسابرسی"],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => setTab(value)}
className={
"border-b-2 px-3 py-2 text-sm transition-colors " +
(tab === value
? "border-accent text-slate-100"
: "border-transparent text-slate-400 hover:text-slate-200")
}
>
{label}
</button>
))}
</div>
{error && <ErrorState message={error} onRetry={load} />}
{tab === "members" ? (
<>
<Card>
<CardTitle>اعضای سازمان</CardTitle>
<CardHint>
نقش‌ها به ترتیب قدرت: مالک، مدیر، سرپرست، عضو، بیننده. آخرین مالک قابل حذف نیست.
</CardHint>
<div className="mt-4">
{loading ? (
<LoadingState label="در حال دریافت اعضا…" />
) : members.length === 0 ? (
<EmptyState title="عضوی یافت نشد" />
) : (
<ul className="flex flex-col divide-y divide-surface-border">
{members.map((member) => (
<li key={member.id} className="flex flex-wrap items-center justify-between gap-3 py-3">
<div className="flex flex-col">
<span className="text-sm text-slate-200">
{member.name || member.email}
</span>
<span className="ltr-inline text-xs text-slate-500">{member.email}</span>
</div>
<div className="flex items-center gap-2">
<Badge tone={member.status === "active" ? "success" : "warning"}>
{member.status}
</Badge>
{canManageMembers ? (
<>
<div className="w-36">
<Select
label=""
value={member.role}
disabled={busyMemberId === member.id}
onChange={(e) => void onChangeRole(member, e.target.value as Role)}
options={Object.entries(ROLE_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</div>
<Button
size="sm"
variant="danger"
disabled={member.role === "owner"}
title={
member.role === "owner"
? "مالک سازمان حذف نمی‌شود."
: undefined
}
onClick={() => setPendingRemove(member)}
>
حذف
</Button>
</>
) : (
<Badge tone="neutral">{ROLE_LABELS[member.role] ?? member.role}</Badge>
)}
</div>
</li>
))}
</ul>
)}
</div>
</Card>
{canManageMembers && (
<Card>
<CardTitle>دعوت عضو جدید</CardTitle>
<CardHint>
یک لینک دعوت یک‌بارمصرف ساخته می‌شود. لینک را خودتان برای فرد بفرستید — سامانه
ایمیل ارسال نمی‌کند.
</CardHint>
<form onSubmit={onInvite} className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="flex-1">
<Field
label="ایمیل"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
className="ltr-inline"
/>
</div>
<div className="w-full sm:w-40">
<Select
label="نقش"
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as Role)}
options={Object.entries(ROLE_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</div>
<Button type="submit" loading={inviting}>
ساخت دعوت
</Button>
</form>
{inviteError && (
<div className="mt-3">
<ErrorState title="دعوت ناموفق" message={inviteError} />
</div>
)}
{lastInvite && (
<div className="mt-3 rounded-lg border border-surface-border bg-surface-overlay p-3">
<p className="text-xs text-slate-400">
دعوت برای <span className="ltr-inline">{lastInvite.email}</span> ساخته شد.
</p>
<p className="ltr-inline mt-1 break-all text-xs text-accent">
{lastInvite.invite_url}
</p>
</div>
)}
</Card>
)}
</>
) : (
<Card>
<CardTitle>رویدادهای حسابرسی</CardTitle>
<CardHint>
این فهرست فقط-افزودنی است. هر عملیات حساس — ورود، تغییر نقش، ساخت کارمند، تغییر
اعتبار و تصمیم تأیید — اینجا ثبت می‌شود.
</CardHint>
<div className="mt-4">
{!canViewAudit ? (
<p className="text-xs text-slate-500">
برای دیدن رویدادهای حسابرسی به سطح دسترسی مدیر یا بالاتر نیاز دارید.
</p>
) : auditLoading ? (
<LoadingState label="در حال دریافت رویدادها…" />
) : audit.length === 0 ? (
<p className="text-xs text-slate-500">رویدادی ثبت نشده است.</p>
) : (
<Timeline
items={audit.slice(0, 60).map((event) => ({
id: event.id,
title: AUDIT_LABELS[event.action] ?? event.action,
meta: `${event.target_type || "—"} · ${new Date(event.created_at).toLocaleString("fa-IR")}`,
tone:
event.action.includes("delete") || event.action.includes("remove")
? ("danger" as const)
: event.action.includes("create") || event.action.includes("payment")
? ("success" as const)
: ("info" as const),
}))}
/>
)}
</div>
</Card>
)}
<ConfirmDialog
open={pendingRemove !== null}
title="حذف عضو"
description={
pendingRemove
? `دسترسی «${pendingRemove.name || pendingRemove.email}» به این سازمان قطع می‌شود.`
: undefined
}
confirmLabel={busyMemberId ? "در حال حذف…" : "حذف کن"}
onConfirm={onConfirmRemove}
onCancel={() => setPendingRemove(null)}
/>
</div>
);
}
+424
View File
@@ -0,0 +1,424 @@
"use client";
import Link from "next/link";
import * as React from "react";
import type { Employee, RunDetail, TaskDetail, TaskStatus, ToolCall } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
EmptyState,
ErrorState,
LoadingState,
Stat,
Timeline,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions";
import { useSession } from "@/lib/session";
const STATUS_LABELS: Record<TaskStatus, string> = {
new: "جدید",
triaged: "دسته‌بندی‌شده",
waiting_approval: "در انتظار تأیید",
running: "در حال اجرا",
done: "انجام‌شده",
failed: "ناموفق",
cancelled: "لغوشده",
};
const RUN_TONES: Record<string, "neutral" | "info" | "success" | "warning" | "danger"> = {
queued: "neutral",
planning: "info",
running: "info",
tool_calling: "info",
sandbox_running: "info",
waiting_approval: "warning",
completed: "success",
failed: "danger",
cancelled: "neutral",
timed_out: "danger",
};
const TERMINAL = new Set(["completed", "failed", "cancelled", "timed_out"]);
export default function TaskDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id: taskId } = React.use(params);
const { activeOrganization } = useSession();
const orgId = activeOrganization?.id ?? null;
const [task, setTask] = React.useState<TaskDetail | null>(null);
const [employees, setEmployees] = React.useState<Employee[]>([]);
const [selectedRun, setSelectedRun] = React.useState<RunDetail | null>(null);
const [approvals, setApprovals] = React.useState<ToolCall[]>([]);
const [decidingId, setDecidingId] = React.useState<string | null>(null);
const [approvalNote, setApprovalNote] = React.useState<Record<string, string>>({});
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [running, setRunning] = React.useState(false);
const [actionError, setActionError] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const [nextTask, nextEmployees, nextApprovals] = await Promise.all([
api.getTask(orgId, taskId),
api.listEmployees(orgId),
// Deny-by-default: a dangerous tool stays blocked until a human decides,
// so this list is the only thing standing between a run and the action.
api.listPendingApprovals(orgId),
]);
setTask(nextTask);
setEmployees(nextEmployees);
setApprovals(nextApprovals);
// Hydrate the newest run so the timeline is visible without a click.
const newest = nextTask.runs[0];
setSelectedRun(newest ? await api.getRun(orgId, newest.id) : null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت کار ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId, taskId]);
React.useEffect(() => {
void load();
}, [load]);
const onDecideApproval = async (toolCallId: string, approve: boolean) => {
if (!orgId) return;
setActionError(null);
setDecidingId(toolCallId);
try {
await api.decideApproval(orgId, toolCallId, approve, approvalNote[toolCallId] ?? "");
// Re-read rather than filter locally: the server owns the final status and
// may resolve more than one call.
setApprovals(await api.listPendingApprovals(orgId));
setApprovalNote((current) => {
const next = { ...current };
delete next[toolCallId];
return next;
});
await load();
} catch (err) {
setActionError(err instanceof ApiError ? err.message : "ثبت تصمیم ناموفق بود.");
} finally {
setDecidingId(null);
}
};
const onStartRun = async () => {
if (!orgId) return;
setActionError(null);
setRunning(true);
try {
const detail = await api.startRun(orgId, taskId, task?.assigned_employee_id ?? null);
setSelectedRun(detail);
await load();
} catch (err) {
setActionError(err instanceof ApiError ? err.message : "اجرای کار ناموفق بود.");
} finally {
setRunning(false);
}
};
const onCancelRun = async () => {
if (!orgId || !selectedRun) return;
setActionError(null);
setRunning(true);
try {
setSelectedRun(await api.cancelRun(orgId, selectedRun.id));
await load();
} catch (err) {
setActionError(err instanceof ApiError ? err.message : "لغو اجرا ناموفق بود.");
} finally {
setRunning(false);
}
};
const onSelectRun = async (runId: string) => {
if (!orgId) return;
try {
setSelectedRun(await api.getRun(orgId, runId));
} catch (err) {
setActionError(err instanceof ApiError ? err.message : "دریافت اجرا ناموفق بود.");
}
};
if (!activeOrganization) {
return (
<EmptyState
title="سازمانی انتخاب نشده است"
description="برای دیدن جزئیات کار ابتدا یک سازمان بسازید."
/>
);
}
if (loading) return <LoadingState label="در حال دریافت کار…" />;
if (error) return <ErrorState message={error} onRetry={load} />;
if (!task) {
return (
<EmptyState
title="کار پیدا نشد"
description="این کار حذف شده است یا به سازمان دیگری تعلق دارد."
/>
);
}
const canRun = roleHasCapability(activeOrganization.role, CAN_RUN_TASKS);
const assignee = employees.find((e) => e.id === task.assigned_employee_id);
const runIsLive = selectedRun ? !TERMINAL.has(selectedRun.status) : false;
return (
<div className="flex flex-col gap-5">
<nav className="text-xs text-slate-500">
<Link href="/tasks" className="hover:text-slate-300">
کارها
</Link>
<span className="mx-1.5">/</span>
<span className="text-slate-400">{task.title}</span>
</nav>
<header className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<h1 className="text-lg font-medium text-slate-100">{task.title}</h1>
<Badge tone={task.status === "done" ? "success" : "info"}>
{STATUS_LABELS[task.status] ?? task.status}
</Badge>
</div>
{task.description && <p className="text-sm text-slate-400">{task.description}</p>}
<p className="text-xs text-slate-500">
{assignee ? `کارمند مسئول: ${assignee.name}` : "بدون کارمند مسئول — مدیر سازمان اجرا می‌کند."}
</p>
</div>
{canRun && (
<Button onClick={onStartRun} loading={running}>
اجرای کار
</Button>
)}
</header>
{actionError && <ErrorState title="عملیات ناموفق" message={actionError} />}
{approvals.length > 0 && (
<Card className="border-warning/40">
<CardTitle>در انتظار تأیید شما ({approvals.length})</CardTitle>
<CardHint>
ابزارهای خطرناک به‌صورت پیش‌فرض مجاز نیستند. تا زمانی که تصمیم نگیرید، اجرا متوقف
می‌ماند و هیچ اقدامی انجام نمی‌شود.
</CardHint>
<div className="mt-4 flex flex-col gap-3">
{approvals.map((call) => (
<div
key={call.id}
className="flex flex-col gap-3 rounded-lg border border-surface-border bg-surface-overlay p-3"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="ltr-inline text-sm text-slate-200">{call.tool_key}</span>
<Badge tone="warning">نیازمند تأیید</Badge>
</div>
<div>
<p className="mb-1 text-xs text-slate-500">آرگومان‌ها</p>
<pre className="ltr-inline max-h-40 overflow-auto whitespace-pre-wrap break-all rounded border border-surface-border bg-surface-base p-2 text-xs text-slate-300">
{JSON.stringify(call.arguments_json ?? {}, null, 2)}
</pre>
</div>
{canRun && (
<>
<input
value={approvalNote[call.id] ?? ""}
onChange={(e) =>
setApprovalNote((current) => ({ ...current, [call.id]: e.target.value }))
}
placeholder="یادداشت (اختیاری)"
className="h-9 w-full rounded-lg border border-surface-border bg-surface-base px-3 text-xs text-slate-200 outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
/>
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="danger"
onClick={() => void onDecideApproval(call.id, false)}
loading={decidingId === call.id}
>
رد کن
</Button>
<Button
size="sm"
onClick={() => void onDecideApproval(call.id, true)}
loading={decidingId === call.id}
>
تأیید کن
</Button>
</div>
</>
)}
</div>
))}
</div>
</Card>
)}
<div className="grid gap-3 sm:grid-cols-3">
<Stat label="تعداد اجراها" value={task.runs.length} />
<Stat
label="آخرین وضعیت اجرا"
value={
selectedRun ? (
<Badge tone={RUN_TONES[selectedRun.status] ?? "neutral"}>{selectedRun.status}</Badge>
) : (
"—"
)
}
/>
<Stat
label="خروجی‌ها"
value={selectedRun ? selectedRun.artifacts.length : 0}
/>
</div>
{task.runs.length > 0 && (
<Card>
<CardTitle>تاریخچهٔ اجراها</CardTitle>
<div className="mt-3 flex flex-wrap gap-2">
{task.runs.map((run) => (
<Button
key={run.id}
size="sm"
variant={selectedRun?.id === run.id ? "primary" : "secondary"}
onClick={() => void onSelectRun(run.id)}
>
<Badge tone={RUN_TONES[run.status] ?? "neutral"}>{run.status}</Badge>
<span className="ltr-inline text-xs">{run.id.slice(0, 6)}</span>
</Button>
))}
</div>
</Card>
)}
{!selectedRun ? (
<EmptyState
title="هنوز اجرایی ثبت نشده است"
description="برای دیدن مراحل اجرا، دکمهٔ «اجرای کار» را بزنید."
/>
) : (
<>
<Card>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>خط زمانی اجرا</CardTitle>
<CardHint>
هر گذار وضعیت به‌صورت یک گام ثبت می‌شود؛ این خط زمانی منبع حقیقت ردیابی است.
</CardHint>
</div>
{canRun && runIsLive && (
<Button variant="danger" size="sm" onClick={onCancelRun} loading={running}>
لغو اجرا
</Button>
)}
</div>
<div className="mt-4">
{selectedRun.steps.length === 0 ? (
<p className="text-xs text-slate-500">گامی ثبت نشده است.</p>
) : (
<Timeline
items={selectedRun.steps.map((step) => ({
id: step.id,
title: step.title || step.kind,
meta: new Date(step.created_at).toLocaleString("fa-IR"),
tone:
step.kind === "error"
? ("danger" as const)
: step.kind === "artifact"
? ("success" as const)
: ("info" as const),
}))}
/>
)}
</div>
{selectedRun.error && (
<div className="mt-3">
<ErrorState title="خطای اجرا" message={selectedRun.error} />
</div>
)}
</Card>
{selectedRun.tool_calls.length > 0 && (
<Card>
<CardTitle>فراخوانی ابزارها</CardTitle>
<CardHint>
ابزارهای خطرناک تا زمانی که تأیید نشوند اجرا نمی‌شوند.
</CardHint>
<div className="mt-3 flex flex-col gap-2">
{selectedRun.tool_calls.map((call) => (
<div
key={call.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2"
>
<span className="ltr-inline text-xs text-slate-300">{call.tool_key}</span>
<div className="flex gap-2">
{call.requires_approval && <Badge tone="warning">نیازمند تأیید</Badge>}
<Badge
tone={
call.status === "succeeded"
? "success"
: call.status === "failed" || call.status === "rejected"
? "danger"
: "neutral"
}
>
{call.status}
</Badge>
</div>
</div>
))}
</div>
</Card>
)}
<Card>
<CardTitle>خروجی‌ها</CardTitle>
{selectedRun.artifacts.length === 0 ? (
<p className="mt-2 text-xs text-slate-500">خروجی‌ای ثبت نشده است.</p>
) : (
<div className="mt-3 flex flex-col gap-3">
{selectedRun.artifacts.map((artifact) => (
<div
key={artifact.id}
className="flex flex-col gap-2 rounded-lg border border-surface-border bg-surface-overlay p-3"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm text-slate-200">{artifact.name}</span>
<div className="flex gap-2">
<Badge tone="neutral">
<span className="ltr-inline">{artifact.kind}</span>
</Badge>
<Badge tone="neutral">{artifact.size_bytes} بایت</Badge>
</div>
</div>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words text-xs text-slate-400">
{artifact.content_ref}
</pre>
</div>
))}
</div>
)}
</Card>
</>
)}
</div>
);
}
+238
View File
@@ -0,0 +1,238 @@
"use client";
import Link from "next/link";
import * as React from "react";
import type { Employee, Task, TaskStatus } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
EmptyState,
ErrorState,
Field,
LoadingState,
Select,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions";
import { useSession } from "@/lib/session";
const STATUS_LABELS: Record<TaskStatus, string> = {
new: "جدید",
triaged: "دسته‌بندی‌شده",
waiting_approval: "در انتظار تأیید",
running: "در حال اجرا",
done: "انجام‌شده",
failed: "ناموفق",
cancelled: "لغوشده",
};
const STATUS_TONES: Record<TaskStatus, "neutral" | "info" | "success" | "warning" | "danger"> = {
new: "neutral",
triaged: "info",
waiting_approval: "warning",
running: "info",
done: "success",
failed: "danger",
cancelled: "neutral",
};
export default function TasksPage() {
const { activeOrganization } = useSession();
const orgId = activeOrganization?.id ?? null;
const [tasks, setTasks] = React.useState<Task[]>([]);
const [employees, setEmployees] = React.useState<Employee[]>([]);
const [filter, setFilter] = React.useState<TaskStatus | "">("");
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [showForm, setShowForm] = React.useState(false);
const [title, setTitle] = React.useState("");
const [description, setDescription] = React.useState("");
const [assignedTo, setAssignedTo] = React.useState("");
const [creating, setCreating] = React.useState(false);
const [formError, setFormError] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const [nextTasks, nextEmployees] = await Promise.all([
api.listTasks(orgId, filter || undefined),
api.listEmployees(orgId),
]);
setTasks(nextTasks);
setEmployees(nextEmployees);
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت کارها ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId, filter]);
React.useEffect(() => {
void load();
}, [load]);
const onCreate = async (event: React.FormEvent) => {
event.preventDefault();
if (!orgId) return;
setFormError(null);
setCreating(true);
try {
const created = await api.createTask(orgId, {
title: title.trim(),
description: description.trim(),
assigned_employee_id: assignedTo || null,
});
setTasks((current) => [created, ...current]);
setTitle("");
setDescription("");
setAssignedTo("");
setShowForm(false);
} catch (err) {
setFormError(err instanceof ApiError ? err.message : "ساخت کار ناموفق بود.");
} finally {
setCreating(false);
}
};
if (!activeOrganization) {
return (
<EmptyState
title="سازمانی انتخاب نشده است"
description="برای دیدن کارها ابتدا یک سازمان بسازید."
/>
);
}
const canRun = roleHasCapability(activeOrganization.role, CAN_RUN_TASKS);
return (
<div className="flex flex-col gap-5">
<header className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">کارها</h1>
<p className="text-sm text-slate-400">
هر کار می‌تواند به یک کارمند سپرده شود و در چند اجرا پیش برود.
</p>
</div>
{canRun && (
<Button onClick={() => setShowForm((v) => !v)} variant={showForm ? "ghost" : "primary"}>
{showForm ? "بستن" : "کار جدید"}
</Button>
)}
</header>
{showForm && (
<Card>
<CardTitle>ساخت کار</CardTitle>
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field
label="عنوان"
required
minLength={2}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="مثلاً افزودن صفحهٔ تسویه‌حساب"
/>
<Select
label="کارمند مسئول (اختیاری)"
value={assignedTo}
onChange={(e) => setAssignedTo(e.target.value)}
options={[
{ value: "", label: "سپردن خودکار به مدیر" },
...employees.map((e) => ({ value: e.id, label: e.name })),
]}
/>
</div>
<Field
label="توضیحات"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="جزئیات و معیار پذیرش…"
/>
<div className="flex justify-end">
<Button type="submit" loading={creating}>
ساخت کار
</Button>
</div>
</form>
{formError && (
<div className="mt-3">
<ErrorState title="ساخت کار ناموفق" message={formError} />
</div>
)}
</Card>
)}
<div className="flex items-center gap-3">
<div className="w-56">
<Select
label="فیلتر وضعیت"
value={filter}
onChange={(e) => setFilter(e.target.value as TaskStatus | "")}
options={[
{ value: "", label: "همه" },
...Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label })),
]}
/>
</div>
</div>
{loading ? (
<LoadingState label="در حال دریافت کارها…" />
) : error ? (
<ErrorState message={error} onRetry={load} />
) : tasks.length === 0 ? (
<EmptyState
title="کاری یافت نشد"
description="یک کار جدید بسازید تا کارمند هوش مصنوعی آن را اجرا کند."
/>
) : (
<div className="flex flex-col gap-3">
{tasks.map((task) => {
const employee = employees.find((e) => e.id === task.assigned_employee_id);
return (
<Card key={task.id} className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1">
<p className="truncate text-sm font-medium text-slate-100">{task.title}</p>
{task.description && (
<p className="line-clamp-1 text-xs text-slate-500">{task.description}</p>
)}
<p className="text-xs text-slate-500">
{employee ? `مسئول: ${employee.name}` : "بدون مسئول"}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge tone={STATUS_TONES[task.status] ?? "neutral"}>
{STATUS_LABELS[task.status] ?? task.status}
</Badge>
<Link href={`/tasks/${task.id}`}>
<Button variant="secondary" size="sm">
جزئیات
</Button>
</Link>
</div>
</Card>
);
})}
</div>
)}
<Card>
<CardHint>
کارهای در انتظار تأیید ابزارهای خطرناک در صفحهٔ جزئیات همان کار نمایش داده می‌شوند.
</CardHint>
</Card>
</div>
);
}
+371
View File
@@ -0,0 +1,371 @@
"use client";
/**
* Cross-cutting trace view.
*
* One place to reconstruct what the platform did: every run across the
* organization, its step timeline, the tools it called, and the audit stream
* beside it.
*
* Two deliberate constraints:
*
* - The task list is capped before fanning out. There is no "list all runs for
* an organization" endpoint, so we read runs through their owning task; an
* uncapped fan-out would turn one page load into hundreds of requests.
* - No model or provider identity is rendered. Those never leave the platform
* owner's console.
*/
import Link from "next/link";
import * as React from "react";
import type { AuditEvent, Run, RunDetail } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
EmptyState,
ErrorState,
LoadingState,
Stat,
Timeline,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { useSession } from "@/lib/session";
/** How many tasks to fan out into. Bounds the request count. */
const TASK_SCAN_LIMIT = 12;
const RUN_TONES: Record<string, "neutral" | "info" | "success" | "warning" | "danger"> = {
queued: "neutral",
planning: "info",
running: "info",
tool_calling: "info",
sandbox_running: "info",
waiting_approval: "warning",
completed: "success",
failed: "danger",
cancelled: "neutral",
timed_out: "danger",
};
const AUDIT_LABELS: Record<string, string> = {
"auth.login": "ورود کاربر",
"auth.signup": "ثبت‌نام کاربر",
"org.create": "ساخت سازمان",
"org.member_invite": "دعوت عضو",
"org.member_update": "تغییر نقش عضو",
"org.member_remove": "حذف عضو",
"project.create": "ساخت پروژه",
"project.repo_ensure": "ساخت مخزن",
"project.preview_domain_create": "ساخت دامنهٔ تست",
"project.preview_domain_delete": "حذف دامنهٔ تست",
"employee.create": "ساخت کارمند",
"employee.update": "ویرایش کارمند",
"employee.prompt_version": "نسخهٔ پرامپت جدید",
"employee.tool_permission": "تغییر دسترسی ابزار",
"task.create": "ساخت کار",
"run.start": "شروع اجرا",
"run.cancel": "لغو اجرا",
"approval.decide": "تصمیم تأیید",
"billing.add_credit": "افزایش اعتبار",
"billing.checkout": "شروع پرداخت",
"billing.settle": "تسویهٔ پرداخت",
"sandbox.workspace_create": "ساخت محیط ایزوله",
"sandbox.destroy": "حذف محیط ایزوله",
"sandbox.command": "اجرای دستور در sandbox",
};
function auditLabel(action: string): string {
return AUDIT_LABELS[action] ?? action;
}
function formatDuration(run: Run): string {
if (!run.started_at) return "—";
const end = run.finished_at ? new Date(run.finished_at).getTime() : Date.now();
const ms = end - new Date(run.started_at).getTime();
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.round(ms / 60_000)}m`;
}
interface RunRow {
run: Run;
taskId: string;
taskTitle: string;
}
export default function TracesPage() {
const { activeOrganization } = useSession();
const orgId = activeOrganization?.id ?? null;
const [rows, setRows] = React.useState<RunRow[]>([]);
const [audit, setAudit] = React.useState<AuditEvent[]>([]);
const [selected, setSelected] = React.useState<RunDetail | null>(null);
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [detailLoading, setDetailLoading] = React.useState(false);
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const [tasks, auditEvents] = await Promise.all([
api.listTasks(orgId),
api.listAudit(orgId),
]);
setAudit(auditEvents);
const recent = tasks.slice(0, TASK_SCAN_LIMIT);
const details = await Promise.all(
recent.map(async (task) => {
try {
return { task, detail: await api.getTask(orgId, task.id) };
} catch {
// One unreadable task must not blank the whole trace view.
return { task, detail: null };
}
}),
);
const collected: RunRow[] = [];
for (const { task, detail } of details) {
for (const run of detail?.runs ?? []) {
collected.push({ run, taskId: task.id, taskTitle: task.title });
}
}
collected.sort(
(a, b) => new Date(b.run.created_at).getTime() - new Date(a.run.created_at).getTime(),
);
setRows(collected);
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت رد‌یابی ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId]);
React.useEffect(() => {
void load();
}, [load]);
const onSelect = async (runId: string) => {
if (!orgId) return;
setSelectedId(runId);
setDetailLoading(true);
try {
setSelected(await api.getRun(orgId, runId));
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت جزئیات اجرا ناموفق بود.");
} finally {
setDetailLoading(false);
}
};
if (!activeOrganization) {
return (
<EmptyState
title="سازمانی انتخاب نشده است"
description="برای دیدن رد‌یابی ابتدا یک سازمان بسازید."
/>
);
}
if (loading) return <LoadingState label="در حال جمع‌آوری رد‌یابی…" />;
if (error) return <ErrorState message={error} onRetry={load} />;
const failed = rows.filter((r) => r.run.status === "failed" || r.run.status === "timed_out").length;
return (
<div className="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">
هر اجرا با خط زمانی گام‌ها، ابزارهای فراخوانی‌شده و رویدادهای audit. هویت مدل و
ارائه‌دهنده در این نما نمایش داده نمی‌شود.
</p>
</header>
<div className="grid gap-3 sm:grid-cols-3">
<Stat label="اجراها" value={rows.length} hint={`از ${TASK_SCAN_LIMIT} کار آخر`} />
<Stat label="اجراهای ناموفق" value={failed} tone={failed > 0 ? "danger" : "neutral"} />
<Stat label="رویدادهای audit" value={audit.length} />
</div>
<div className="grid gap-4 lg:grid-cols-[1fr_22rem]">
<div className="flex flex-col gap-4">
<Card>
<CardTitle>اجراها</CardTitle>
<CardHint>جدیدترین اجراها در همهٔ کارها.</CardHint>
{rows.length === 0 ? (
<p className="mt-3 text-xs text-slate-500">هنوز اجرایی ثبت نشده است.</p>
) : (
<ul className="mt-3 flex flex-col gap-2">
{rows.map(({ run, taskId, taskTitle }) => (
<li key={run.id}>
<button
type="button"
onClick={() => void onSelect(run.id)}
className={
"flex w-full flex-wrap items-center justify-between gap-2 rounded-lg border px-3 py-2 text-right transition-colors " +
(selectedId === run.id
? "border-accent/60 bg-surface-overlay"
: "border-surface-border hover:bg-surface-overlay")
}
>
<div className="flex min-w-0 flex-col gap-0.5">
<Link
href={`/tasks/${taskId}`}
onClick={(e) => e.stopPropagation()}
className="truncate text-xs text-slate-200 hover:text-accent"
>
{taskTitle}
</Link>
<span className="ltr-inline text-xs text-slate-500">
{run.id.slice(0, 8)} · {new Date(run.created_at).toLocaleString("fa-IR")}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="text-xs text-slate-500">{formatDuration(run)}</span>
<Badge tone={RUN_TONES[run.status] ?? "neutral"}>{run.status}</Badge>
</div>
</button>
</li>
))}
</ul>
)}
</Card>
{detailLoading && <LoadingState label="در حال دریافت جزئیات اجرا…" />}
{selected && !detailLoading && (
<Card>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>خط زمانی اجرا</CardTitle>
<CardHint>
<span className="ltr-inline">{selected.id}</span>
</CardHint>
</div>
<Badge tone={RUN_TONES[selected.status] ?? "neutral"}>{selected.status}</Badge>
</div>
<div className="mt-4">
{selected.steps.length === 0 ? (
<p className="text-xs text-slate-500">گامی ثبت نشده است.</p>
) : (
<Timeline
items={selected.steps.map((step) => ({
id: step.id,
title: step.title || step.kind,
meta: new Date(step.created_at).toLocaleString("fa-IR"),
tone:
step.kind === "error"
? ("danger" as const)
: step.kind === "artifact"
? ("success" as const)
: ("info" as const),
}))}
/>
)}
</div>
{selected.error && (
<div className="mt-3">
<ErrorState title="خطای اجرا" message={selected.error} />
</div>
)}
{selected.tool_calls.length > 0 && (
<div className="mt-4">
<p className="mb-2 text-xs font-medium text-slate-300">ابزارها</p>
<ul className="flex flex-col gap-2">
{selected.tool_calls.map((call) => (
<li
key={call.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2"
>
<span className="ltr-inline text-xs text-slate-300">{call.tool_key}</span>
<div className="flex gap-2">
{call.requires_approval && <Badge tone="warning">نیازمند تأیید</Badge>}
<Badge
tone={
call.status === "succeeded"
? "success"
: call.status === "failed" || call.status === "rejected"
? "danger"
: "neutral"
}
>
{call.status}
</Badge>
</div>
</li>
))}
</ul>
</div>
)}
{selected.artifacts.length > 0 && (
<div className="mt-4">
<p className="mb-2 text-xs font-medium text-slate-300">خروجی‌ها</p>
<ul className="flex flex-col gap-1.5">
{selected.artifacts.map((artifact) => (
<li key={artifact.id} className="text-xs text-slate-400">
<span className="text-slate-200">{artifact.name}</span> ·{" "}
<span className="ltr-inline">{artifact.kind}</span> ·{" "}
{artifact.size_bytes} بایت
</li>
))}
</ul>
</div>
)}
</Card>
)}
{!selected && !detailLoading && rows.length > 0 && (
<EmptyState
title="یک اجرا را انتخاب کنید"
description="برای دیدن گام‌ها، ابزارها و خروجی‌ها یکی از اجراهای بالا را بزنید."
/>
)}
</div>
<Card className="h-fit">
<CardTitle>رویدادهای audit</CardTitle>
<CardHint>هر اقدام حساس با کنشگر و هدف ثبت می‌شود.</CardHint>
<div className="mt-4 max-h-[32rem] overflow-auto">
{audit.length === 0 ? (
<p className="text-xs text-slate-500">رویدادی ثبت نشده است.</p>
) : (
<Timeline
items={audit.slice(0, 60).map((event) => ({
id: event.id,
title: auditLabel(event.action),
meta: `${new Date(event.created_at).toLocaleString("fa-IR")} · ${
event.target_type
}`,
tone: event.action.includes("remove") || event.action.includes("cancel")
? ("danger" as const)
: ("info" as const),
}))}
/>
)}
</div>
</Card>
</div>
<div className="flex justify-end">
<Button variant="secondary" size="sm" onClick={load}>
بازخوانی
</Button>
</div>
</div>
);
}
@@ -0,0 +1,386 @@
"use client";
import Link from "next/link";
import * as React from "react";
import type {
EmployeeDetail,
EmployeeStatus,
PromptVersion,
RoleKey,
RoutingChoice,
ToolSpec,
} from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
EmptyState,
ErrorState,
Field,
LoadingState,
Select,
Stat,
Timeline,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { CAN_MANAGE_EMPLOYEES, roleHasCapability } from "@/lib/permissions";
import { useSession } from "@/lib/session";
const ROLE_LABELS: Record<RoleKey, string> = {
manager: "مدیر",
planner: "تحلیل‌گر",
developer: "برنامه‌نویس",
reviewer: "بازبین کد",
tester: "تستر",
researcher: "محقق",
content_creator: "تولیدکننده محتوا",
support: "پشتیبان",
};
const STATUS_LABELS: Record<EmployeeStatus, string> = {
active: "فعال",
paused: "متوقف",
archived: "بایگانی",
};
export default function EmployeeDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id: employeeId } = React.use(params);
const { activeOrganization } = useSession();
const orgId = activeOrganization?.id ?? null;
const [employee, setEmployee] = React.useState<EmployeeDetail | null>(null);
const [choices, setChoices] = React.useState<RoutingChoice[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [name, setName] = React.useState("");
const [status, setStatus] = React.useState<EmployeeStatus>("active");
const [routingPolicyId, setRoutingPolicyId] = React.useState("");
const [saving, setSaving] = React.useState(false);
const [saveError, setSaveError] = React.useState<string | null>(null);
const [saved, setSaved] = React.useState(false);
const [promptDraft, setPromptDraft] = React.useState("");
const [addingPrompt, setAddingPrompt] = React.useState(false);
const [promptError, setPromptError] = React.useState<string | null>(null);
const [tools, setTools] = React.useState<ToolSpec[]>([]);
const [toolBusy, setToolBusy] = React.useState<string | null>(null);
const [toolError, setToolError] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const [nextEmployee, nextChoices, nextTools] = await Promise.all([
api.getEmployee(orgId, employeeId),
api.listRoutingChoices(orgId),
api.listTools(orgId),
]);
setEmployee(nextEmployee);
setChoices(nextChoices);
setTools(nextTools);
setName(nextEmployee.name);
setStatus(nextEmployee.status);
setRoutingPolicyId(nextEmployee.routing_policy_id ?? "");
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت کارمند ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId, employeeId]);
const onToggleTool = async (toolKey: string, allowed: boolean) => {
if (!orgId) return;
setToolError(null);
setToolBusy(toolKey);
try {
const updated = await api.setToolPermission(orgId, employeeId, toolKey, allowed);
setEmployee((current) => {
if (!current) return current;
const others = current.tool_permissions.filter((p) => p.tool_key !== toolKey);
return { ...current, tool_permissions: [...others, updated] };
});
} catch (err) {
setToolError(err instanceof ApiError ? err.message : "تغییر دسترسی ناموفق بود.");
} finally {
setToolBusy(null);
}
};
React.useEffect(() => {
void load();
}, [load]);
const onSave = async (event: React.FormEvent) => {
event.preventDefault();
if (!orgId) return;
setSaveError(null);
setSaved(false);
setSaving(true);
try {
await api.updateEmployee(orgId, employeeId, {
name: name.trim(),
status,
routing_policy_id: routingPolicyId || null,
});
setSaved(true);
window.setTimeout(() => setSaved(false), 1800);
await load();
} catch (err) {
setSaveError(err instanceof ApiError ? err.message : "ذخیرهٔ تغییرات ناموفق بود.");
} finally {
setSaving(false);
}
};
const onAddPrompt = async (event: React.FormEvent) => {
event.preventDefault();
if (!orgId) return;
setPromptError(null);
setAddingPrompt(true);
try {
const version: PromptVersion = await api.addPromptVersion(
orgId,
employeeId,
promptDraft.trim(),
);
setEmployee((current) =>
current
? { ...current, prompt_versions: [version, ...current.prompt_versions] }
: current,
);
setPromptDraft("");
} catch (err) {
setPromptError(err instanceof ApiError ? err.message : "ثبت نسخهٔ پرامپت ناموفق بود.");
} finally {
setAddingPrompt(false);
}
};
if (!activeOrganization) {
return (
<EmptyState
title="سازمانی انتخاب نشده است"
description="برای دیدن جزئیات کارمند ابتدا یک سازمان بسازید."
/>
);
}
if (loading) return <LoadingState label="در حال دریافت کارمند…" />;
if (error) return <ErrorState message={error} onRetry={load} />;
if (!employee) {
return (
<EmptyState
title="کارمند پیدا نشد"
description="این کارمند حذف شده است یا به سازمان دیگری تعلق دارد."
/>
);
}
const canManage = roleHasCapability(activeOrganization.role, CAN_MANAGE_EMPLOYEES);
const allowedTools = employee.tool_permissions.filter((p) => p.allowed);
return (
<div className="flex flex-col gap-5">
<nav className="text-xs text-slate-500">
<Link href="/workforce" className="hover:text-slate-300">
کارمندهای هوش مصنوعی
</Link>
<span className="mx-1.5">/</span>
<span className="text-slate-400">{employee.name}</span>
</nav>
<header className="flex flex-wrap items-center gap-2">
<h1 className="text-lg font-medium text-slate-100">{employee.name}</h1>
<Badge tone="info">{ROLE_LABELS[employee.role_key]}</Badge>
<Badge tone={employee.status === "active" ? "success" : "neutral"}>
{STATUS_LABELS[employee.status]}
</Badge>
</header>
<div className="grid gap-3 sm:grid-cols-3">
<Stat label="کارهای باز" value={employee.open_task_count} />
<Stat label="نسخه‌های پرامپت" value={employee.prompt_versions.length} />
<Stat label="ابزارهای مجاز" value={allowedTools.length} />
</div>
<Card>
<CardTitle>تنظیمات کارمند</CardTitle>
<CardHint>
«سیاست کاری» تعیین می‌کند کارمند با چه سبکی کار کند. انتخاب مدل و ارائه‌دهنده در اختیار
سامانه است و نمایش داده نمی‌شود.
</CardHint>
<form onSubmit={onSave} 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)}
disabled={!canManage}
/>
<Select
label="وضعیت"
value={status}
onChange={(e) => setStatus(e.target.value as EmployeeStatus)}
disabled={!canManage}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
<Select
label="سیاست کاری"
value={routingPolicyId}
onChange={(e) => setRoutingPolicyId(e.target.value)}
disabled={!canManage}
options={[
{ value: "", label: "پیش‌فرض سازمان" },
...choices.map((c) => ({ value: c.id, label: c.name })),
]}
/>
</div>
{canManage && (
<div className="flex items-center justify-end gap-3">
{saved && <span className="text-xs text-positive">ذخیره شد.</span>}
<Button type="submit" loading={saving}>
ذخیرهٔ تغییرات
</Button>
</div>
)}
</form>
{saveError && (
<div className="mt-3">
<ErrorState title="ذخیره ناموفق" message={saveError} />
</div>
)}
</Card>
<Card>
<CardTitle>تاریخچهٔ پرامپت</CardTitle>
<CardHint>
هر ویرایش یک نسخهٔ جدید می‌سازد. نسخه‌ها حذف نمی‌شوند تا بتوان رفتار کارمند را ردیابی کرد.
</CardHint>
<div className="mt-4 flex flex-col gap-3">
{employee.prompt_versions.length === 0 ? (
<p className="text-xs text-slate-500">هنوز پرامپتی ثبت نشده است.</p>
) : (
<Timeline
items={employee.prompt_versions.slice(0, 5).map((version) => ({
id: version.id,
title: `نسخهٔ ${version.version}`,
meta: version.content.slice(0, 140),
tone: "info" as const,
}))}
/>
)}
</div>
{canManage && (
<form onSubmit={onAddPrompt} className="mt-4 flex flex-col gap-3">
<Field
label="نسخهٔ جدید پرامپت"
required
value={promptDraft}
onChange={(e) => setPromptDraft(e.target.value)}
placeholder="دستورهای این کارمند را بنویسید…"
/>
<div className="flex justify-end">
<Button type="submit" loading={addingPrompt}>
ثبت نسخهٔ جدید
</Button>
</div>
</form>
)}
{promptError && (
<div className="mt-3">
<ErrorState title="ثبت نسخه ناموفق" message={promptError} />
</div>
)}
</Card>
<Card>
<CardTitle>دسترسی ابزارها</CardTitle>
<CardHint>
به‌صورت پیش‌فرض هیچ ابزاری مجاز نیست؛ هر دسترسی باید صریحاً داده شود. ابزارهای
بازگشت‌ناپذیر همیشه به تأیید انسانی نیاز دارند.
</CardHint>
<div className="mt-3">
{employee.tool_permissions.length === 0 ? (
<p className="text-xs text-slate-500">هیچ ابزاری برای این کارمند تعریف نشده است.</p>
) : (
<div className="flex flex-wrap gap-2">
{employee.tool_permissions.map((permission) => (
<Badge
key={permission.tool_key}
tone={permission.allowed ? "success" : "neutral"}
>
<span className="ltr-inline">{permission.tool_key}</span>
</Badge>
))}
</div>
)}
</div>
{canManage && (
<div className="mt-4 border-t border-surface-border pt-4">
<p className="text-xs font-medium text-slate-300">ویرایش دسترسی‌ها</p>
<div className="mt-3 flex flex-col gap-2">
{tools.length === 0 ? (
<LoadingState label="در حال دریافت فهرست ابزارها…" />
) : (
tools.map((tool) => {
const granted =
employee.tool_permissions.find((p) => p.tool_key === tool.key)?.allowed ??
false;
return (
<div
key={tool.key}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2"
>
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-200">{tool.label}</span>
<code className="ltr-inline text-[10px] text-slate-500">{tool.key}</code>
{tool.requires_approval && <Badge tone="warning">تأیید لازم</Badge>}
{tool.destructive && <Badge tone="danger">بازگشت‌ناپذیر</Badge>}
</div>
<span className="text-[11px] text-slate-500">{tool.description}</span>
</div>
<Button
size="sm"
variant={granted ? "danger" : "secondary"}
loading={toolBusy === tool.key}
onClick={() => void onToggleTool(tool.key, !granted)}
>
{granted ? "لغو دسترسی" : "اعطای دسترسی"}
</Button>
</div>
);
})
)}
</div>
{toolError && (
<div className="mt-3">
<ErrorState title="تغییر دسترسی ناموفق" message={toolError} />
</div>
)}
</div>
)}
</Card>
</div>
);
}
+269
View File
@@ -0,0 +1,269 @@
"use client";
import Link from "next/link";
import * as React from "react";
import type {
AgentTemplate,
Employee,
EmployeeDetail,
RoleKey,
} from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
EmptyState,
ErrorState,
Field,
LoadingState,
Select,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { CAN_MANAGE_EMPLOYEES, roleHasCapability } from "@/lib/permissions";
import { useSession } from "@/lib/session";
const ROLE_LABELS: Record<RoleKey, string> = {
manager: "مدیر",
planner: "تحلیل‌گر",
developer: "برنامه‌نویس",
reviewer: "بازبین کد",
tester: "تستر",
researcher: "محقق",
content_creator: "تولیدکننده محتوا",
support: "پشتیبان",
};
const STATUS_TONES = {
active: "success",
paused: "warning",
archived: "neutral",
} as const;
/**
* Above this size we stop enriching cards with per-employee detail, because the
* enrichment costs one request per employee. The page stays usable either way.
*/
const ENRICH_LIMIT = 50;
export default function WorkforcePage() {
const { activeOrganization } = useSession();
const [employees, setEmployees] = React.useState<Employee[]>([]);
const [details, setDetails] = React.useState<Record<string, EmployeeDetail>>({});
const [templates, setTemplates] = React.useState<AgentTemplate[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [showForm, setShowForm] = React.useState(false);
const [name, setName] = React.useState("");
const [templateId, setTemplateId] = React.useState("");
const [roleKey, setRoleKey] = React.useState<RoleKey>("developer");
const [creating, setCreating] = React.useState(false);
const [formError, setFormError] = React.useState<string | null>(null);
const orgId = activeOrganization?.id ?? null;
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const [nextEmployees, nextTemplates] = await Promise.all([
api.listEmployees(orgId),
api.listAgentTemplates(orgId),
]);
setEmployees(nextEmployees);
setTemplates(nextTemplates);
if (nextEmployees.length > 0 && nextEmployees.length <= ENRICH_LIMIT) {
const enriched = await Promise.all(
nextEmployees.map(async (employee) => {
try {
return [employee.id, await api.getEmployee(orgId, employee.id)] as const;
} catch {
// A single failed enrichment must not blank the whole page.
return null;
}
}),
);
setDetails(
Object.fromEntries(
enriched.filter((entry): entry is readonly [string, EmployeeDetail] => entry !== null),
),
);
} else {
setDetails({});
}
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت کارمندها ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId]);
React.useEffect(() => {
void load();
}, [load]);
const onTemplateChange = (value: string) => {
setTemplateId(value);
const template = templates.find((t) => t.id === value);
if (template) {
setRoleKey(template.role_key);
if (!name.trim()) setName(template.name);
}
};
const onCreate = async (event: React.FormEvent) => {
event.preventDefault();
if (!orgId) return;
setFormError(null);
setCreating(true);
try {
await api.createEmployee(orgId, {
name: name.trim(),
role_key: roleKey,
template_id: templateId || null,
});
setShowForm(false);
setName("");
setTemplateId("");
await load();
} catch (err) {
setFormError(err instanceof ApiError ? err.message : "ساخت کارمند ناموفق بود.");
} finally {
setCreating(false);
}
};
if (!activeOrganization) {
return (
<EmptyState
title="سازمانی انتخاب نشده است"
description="برای دیدن کارمندهای هوش مصنوعی ابتدا یک سازمان بسازید."
/>
);
}
const canManage = roleHasCapability(activeOrganization.role, CAN_MANAGE_EMPLOYEES);
return (
<div className="flex flex-col gap-5">
<header className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">کارمندهای هوش مصنوعی</h1>
<p className="text-sm text-slate-400">
هر کارمند یک نقش، یک پرامپت نسخه‌بندی‌شده و یک سیاست مسیریابی دارد. جزئیات مدل هرگز
نمایش داده نمی‌شود.
</p>
</div>
{canManage && (
<Button onClick={() => setShowForm((v) => !v)} variant={showForm ? "ghost" : "primary"}>
{showForm ? "بستن" : "کارمند جدید"}
</Button>
)}
</header>
{showForm && (
<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">
<Select
label="قالب"
value={templateId}
onChange={(e) => onTemplateChange(e.target.value)}
options={[
{ value: "", label: "بدون قالب (سفارشی)" },
...templates.map((t) => ({ value: t.id, label: t.name })),
]}
/>
<Field
label="نام کارمند"
required
minLength={2}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="مثلاً برنامه‌نویس ارشد"
/>
<Select
label="نقش"
value={roleKey}
onChange={(e) => setRoleKey(e.target.value as RoleKey)}
options={Object.entries(ROLE_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</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} />
) : employees.length === 0 ? (
<EmptyState
title="هنوز کارمندی ساخته نشده است"
description="یک قالب انتخاب کنید تا اولین کارمند هوش مصنوعی سازمان ساخته شود."
/>
) : (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{employees.map((employee) => {
const detail = details[employee.id];
return (
<Card key={employee.id} className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-slate-100">{employee.name}</p>
<p className="text-xs text-slate-500">{ROLE_LABELS[employee.role_key]}</p>
</div>
<Badge tone={STATUS_TONES[employee.status] ?? "neutral"}>
{employee.status}
</Badge>
</div>
<div className="flex flex-wrap gap-2 text-xs">
<Badge tone="neutral">
کارهای باز: {detail ? detail.open_task_count : "—"}
</Badge>
<Badge tone="neutral">
نسخهٔ پرامپت: {detail ? detail.prompt_versions.length : "—"}
</Badge>
<Badge tone="neutral">
ابزارها: {detail ? detail.tool_permissions.filter((p) => p.allowed).length : "—"}
</Badge>
</div>
<Link href={`/workforce/${employee.id}`} className="mt-auto">
<Button variant="secondary" size="sm" className="w-full">
تنظیمات و جزئیات
</Button>
</Link>
</Card>
);
})}
</div>
)}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/*
* Dark-first, RTL. The font stack prefers a Persian face when the user has one
* installed and falls back to the system UI font — no network fetch at build
* time, so `pnpm build` works offline.
*/
:root {
--font-sans: Vazirmatn, "IRANSans", "Segoe UI", Tahoma, system-ui, -apple-system,
"Noto Sans Arabic", sans-serif;
color-scheme: dark;
}
html,
body {
height: 100%;
}
body {
font-family: var(--font-sans);
background-color: #0b0d12;
color: #e2e8f0;
-webkit-font-smoothing: antialiased;
}
/* Numerals and latin fragments inside Persian text read better LTR. */
.ltr-inline {
direction: ltr;
unicode-bidi: embed;
}
::selection {
background-color: #2b3b63;
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import "./globals.css";
import { SessionProvider } from "@/lib/session";
export const metadata: Metadata = {
title: "AGY Web — پلتفرم نیروی کار هوش مصنوعی",
description: "سازمان بسازید، کارمند هوش مصنوعی استخدام کنید و کارها را بسپارید.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fa" dir="rtl" className="dark">
<body>
<SessionProvider>{children}</SessionProvider>
</body>
</html>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import * as React from "react";
import { Button, Card, CardHint, CardTitle, ErrorState, Field } from "@agy/ui";
import { ApiError } from "@/lib/api";
import { useSession } from "@/lib/session";
export default function LoginPage() {
const router = useRouter();
const { login, user, ready } = useSession();
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
const [error, setError] = React.useState<string | null>(null);
const [submitting, setSubmitting] = React.useState(false);
React.useEffect(() => {
if (ready && user) router.replace("/dashboard");
}, [ready, user, router]);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setSubmitting(true);
try {
await login(email, password);
router.replace("/dashboard");
} catch (err) {
setError(err instanceof ApiError ? err.message : "ورود ناموفق بود.");
} finally {
setSubmitting(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center p-5">
<Card className="w-full max-w-sm">
<CardTitle>ورود به AGY Web</CardTitle>
<CardHint>با ایمیل و رمز عبور خود وارد شوید.</CardHint>
<form onSubmit={onSubmit} className="mt-5 flex flex-col gap-4">
<Field
label="ایمیل"
type="email"
dir="ltr"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Field
label="رمز عبور"
type="password"
dir="ltr"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <ErrorState title="ورود ناموفق" message={error} />}
<Button type="submit" loading={submitting}>
ورود
</Button>
</form>
<p className="mt-4 text-xs text-slate-400">
حساب ندارید؟{" "}
<Link href="/signup" className="text-accent hover:underline">
ثبت‌نام کنید
</Link>
</p>
</Card>
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useRouter } from "next/navigation";
import * as React from "react";
import { Button, Card, CardHint, CardTitle, ErrorState, Field, LoadingState } from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { useSession } from "@/lib/session";
/** First-run flow: create the user's first organization. */
export default function OnboardingPage() {
const router = useRouter();
const { ready, user, organizations, refresh } = useSession();
const [name, setName] = React.useState("");
const [error, setError] = React.useState<string | null>(null);
const [submitting, setSubmitting] = React.useState(false);
React.useEffect(() => {
if (ready && !user) router.replace("/login");
}, [ready, user, router]);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setSubmitting(true);
try {
await api.createOrganization(name.trim());
await refresh();
router.replace("/dashboard");
} catch (err) {
setError(err instanceof ApiError ? err.message : "ساخت سازمان ناموفق بود.");
} finally {
setSubmitting(false);
}
};
if (!ready) {
return (
<div className="flex min-h-screen items-center justify-center p-8">
<LoadingState />
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center p-5">
<Card className="w-full max-w-md">
<CardTitle>ساخت اولین سازمان</CardTitle>
<CardHint>
سازمان ظرف اصلی محصول است: اعضا، پروژه‌ها، کارمندهای هوش مصنوعی و مصرف
همه به سازمان تعلق دارند.
</CardHint>
{organizations.length > 0 && (
<div className="mt-4 flex flex-col gap-2">
<p className="text-xs text-slate-400">سازمان‌های فعلی شما:</p>
{organizations.map((org) => (
<button
key={org.id}
type="button"
onClick={() => router.replace("/dashboard")}
className="rounded-lg border border-surface-border px-3 py-2 text-right text-xs text-slate-200 hover:bg-surface-overlay"
>
{org.name}
</button>
))}
<div className="my-2 border-t border-surface-border" />
</div>
)}
<form onSubmit={onSubmit} className="mt-4 flex flex-col gap-4">
<Field
label="نام سازمان"
required
minLength={2}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="مثلاً شرکت نمونه"
/>
{error && <ErrorState title="ساخت سازمان ناموفق" message={error} />}
<Button type="submit" loading={submitting}>
ساخت سازمان
</Button>
</form>
</Card>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
"use client";
import { useRouter } from "next/navigation";
import * as React from "react";
import { LoadingState } from "@agy/ui";
import { useSession } from "@/lib/session";
/** Entry point: route the visitor to the dashboard or the login screen. */
export default function RootPage() {
const { ready, user } = useSession();
const router = useRouter();
React.useEffect(() => {
if (!ready) return;
router.replace(user ? "/dashboard" : "/login");
}, [ready, user, router]);
return (
<div className="flex min-h-screen items-center justify-center p-8">
<LoadingState label="در حال بارگذاری…" />
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import * as React from "react";
import { Button, Card, CardHint, CardTitle, ErrorState, Field } from "@agy/ui";
import { ApiError } from "@/lib/api";
import { useSession } from "@/lib/session";
export default function SignupPage() {
const router = useRouter();
const { signup } = useSession();
const [name, setName] = React.useState("");
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
const [error, setError] = React.useState<string | null>(null);
const [submitting, setSubmitting] = React.useState(false);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setSubmitting(true);
try {
await signup(email, password, name);
router.replace("/onboarding");
} catch (err) {
setError(err instanceof ApiError ? err.message : "ثبت‌نام ناموفق بود.");
} finally {
setSubmitting(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center p-5">
<Card className="w-full max-w-sm">
<CardTitle>ساخت حساب</CardTitle>
<CardHint>پس از ثبت‌نام، اولین سازمان خود را می‌سازید.</CardHint>
<form onSubmit={onSubmit} className="mt-5 flex flex-col gap-4">
<Field
label="نام"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="نام شما"
/>
<Field
label="ایمیل"
type="email"
dir="ltr"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Field
label="رمز عبور"
type="password"
dir="ltr"
autoComplete="new-password"
required
minLength={8}
hint="حداقل ۸ کاراکتر."
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <ErrorState title="ثبت‌نام ناموفق" message={error} />}
<Button type="submit" loading={submitting}>
ثبت‌نام
</Button>
</form>
<p className="mt-4 text-xs text-slate-400">
قبلاً ثبت‌نام کرده‌اید؟{" "}
<Link href="/login" className="text-accent hover:underline">
ورود
</Link>
</p>
</Card>
</div>
);
}