feat(org): model grants, org memory, MCP gateway (v2 phases A1-3..5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
c93b25741e
commit
dee2f72c46
@@ -1,16 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { PlaceholderPage } from "@/components/PlaceholderPage";
|
||||
/**
|
||||
* Platform owner console: the MCP gateway's two switches.
|
||||
*
|
||||
* An external agent can only act inside the platform through a scoped token, and
|
||||
* this page is where the owner closes the door when something is wrong: the
|
||||
* gateway as a whole, and each organization independently. A switch takes effect
|
||||
* on the next call, because the gateway checks it per request rather than
|
||||
* trusting a cached state.
|
||||
*
|
||||
* A revoked token keeps its place in the list — after an incident, "who could
|
||||
* have done this" is the question that matters.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
Stat,
|
||||
ToastStack,
|
||||
type ToastMessage,
|
||||
} from "@agy/ui";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { mcpApi, type McpOrgState } from "@/lib/api-org";
|
||||
|
||||
function errorMessage(err: unknown, fallback: string): string {
|
||||
return err instanceof ApiError ? err.message : fallback;
|
||||
}
|
||||
|
||||
export default function AdminMcpPage() {
|
||||
const [state, setState] = React.useState<{
|
||||
gateway_enabled: boolean;
|
||||
organizations: McpOrgState[];
|
||||
total_tokens: number;
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [busy, setBusy] = React.useState<string | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [pendingSwitch, setPendingSwitch] = React.useState<McpOrgState | null>(null);
|
||||
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
|
||||
|
||||
const toast = React.useCallback(
|
||||
(title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
|
||||
setToasts((current) => [
|
||||
...current,
|
||||
{ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description },
|
||||
]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setState(await mcpApi.adminState());
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "دریافت وضعیت MCP ناموفق بود."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const toggleGateway = async () => {
|
||||
if (!state) return;
|
||||
setBusy("gateway");
|
||||
try {
|
||||
const next = !state.gateway_enabled;
|
||||
await mcpApi.setGateway(next);
|
||||
setState((current) => (current ? { ...current, gateway_enabled: next } : current));
|
||||
toast(next ? "دسترسی MCP سامانه روشن شد" : "دسترسی MCP سامانه خاموش شد");
|
||||
} catch (err) {
|
||||
toast("تغییر وضعیت ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmOrgSwitch = async (org: McpOrgState) => {
|
||||
setBusy(`org-${org.organization_id}`);
|
||||
try {
|
||||
await mcpApi.setOrgEnabled(org.organization_id, !org.mcp_enabled);
|
||||
setState((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
organizations: current.organizations.map((row) =>
|
||||
row.organization_id === org.organization_id
|
||||
? { ...row, mcp_enabled: !row.mcp_enabled }
|
||||
: row,
|
||||
),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
toast(org.mcp_enabled ? "دسترسی این سازمان خاموش شد" : "دسترسی این سازمان روشن شد");
|
||||
setPendingSwitch(null);
|
||||
} catch (err) {
|
||||
toast("تغییر وضعیت ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingState label="در حال دریافت وضعیت MCP…" />;
|
||||
if (error || !state)
|
||||
return <ErrorState message={error ?? "—"} onRetry={load} />;
|
||||
|
||||
const enabledOrgs = state.organizations.filter((org) => org.mcp_enabled).length;
|
||||
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="MCP"
|
||||
description="کنترل دسترسی عاملهای بیرونی به سازمانها."
|
||||
planned={[
|
||||
"کلید سراسری",
|
||||
"توکنهای هر سازمان",
|
||||
]}
|
||||
/>
|
||||
<div className="anim-stagger flex flex-col gap-5">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-medium text-slate-100">دسترسی MCP</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
عاملهای بیرونی با توکنهای محدود به یک سازمان کار میکنند. اینجا کل درگاه و
|
||||
دسترسی هر سازمان را میتوان خاموش کرد؛ توکنها را مدیران خودشان در تنظیمات
|
||||
سازمان میسازند.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Stat
|
||||
label="وضعیت درگاه"
|
||||
value={state.gateway_enabled ? "روشن" : "خاموش"}
|
||||
tone={state.gateway_enabled ? "success" : "danger"}
|
||||
/>
|
||||
<Stat label="سازمانهای دارای دسترسی" value={enabledOrgs} />
|
||||
<Stat label="توکنهای فعال" value={state.total_tokens} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardTitle>کلید اصلی</CardTitle>
|
||||
<CardHint>
|
||||
خاموشکردن درگاه، همهٔ توکنها را بیاثر میکند بدون آنکه حذف شوند: وقتی دوباره
|
||||
روشن شود، همان توکنها از جایشان کار میکنند.
|
||||
</CardHint>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant={state.gateway_enabled ? "danger" : "secondary"}
|
||||
loading={busy === "gateway"}
|
||||
onClick={() => void toggleGateway()}
|
||||
>
|
||||
{state.gateway_enabled ? "خاموشکردن دسترسی MCP سامانه" : "روشنکردن دسترسی MCP"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle>سازمانها</CardTitle>
|
||||
<CardHint>هر سازمان جداگانه قابل خاموشکردن است.</CardHint>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
{state.organizations.length === 0 ? (
|
||||
<EmptyState title="سازمانی وجود ندارد" />
|
||||
) : (
|
||||
<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" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="anim-stagger">
|
||||
{state.organizations.map((org) => (
|
||||
<tr key={org.organization_id} className="border-b border-surface-border/60">
|
||||
<td className="px-2 py-2 text-slate-200">{org.name}</td>
|
||||
<td className="px-2 py-2 text-slate-400" dir="ltr">
|
||||
{org.slug}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-slate-400">{org.token_count}</td>
|
||||
<td className="px-2 py-2">
|
||||
<Badge tone={org.mcp_enabled ? "success" : "neutral"}>
|
||||
{org.mcp_enabled ? "فعال" : "خاموش"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
loading={busy === `org-${org.organization_id}`}
|
||||
onClick={() =>
|
||||
org.mcp_enabled
|
||||
? setPendingSwitch(org)
|
||||
: void confirmOrgSwitch(org)
|
||||
}
|
||||
>
|
||||
{org.mcp_enabled ? "خاموش کن" : "روشن کن"}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingSwitch !== null}
|
||||
title="خاموشکردن دسترسی یک سازمان"
|
||||
description={
|
||||
pendingSwitch
|
||||
? `توکنهای «${pendingSwitch.name}» از این لحظه کار نمیکنند. مدیران سازمان میتوانند توکن تازه نسازند.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="خاموش کن"
|
||||
onConfirm={() => pendingSwitch && void confirmOrgSwitch(pendingSwitch)}
|
||||
onCancel={() => setPendingSwitch(null)}
|
||||
/>
|
||||
|
||||
<ToastStack
|
||||
toasts={toasts}
|
||||
onDismiss={(id) => setToasts((current) => current.filter((t) => t.id !== id))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,514 @@
|
||||
"use client";
|
||||
|
||||
import { PlaceholderPage } from "@/components/PlaceholderPage";
|
||||
/**
|
||||
* Platform owner console: which models each organization may use.
|
||||
*
|
||||
* Two independent switches decide whether a model is usable, and this page is
|
||||
* the only place both can be set: the model is active in the catalogue, and the
|
||||
* owner granted it to *this* organization. Revoking here takes effect on the
|
||||
* engine's next query — there is no cached grant set to expire.
|
||||
*
|
||||
* The tabs mirror the three decisions: the per-organization matrix, the default
|
||||
* set copied into every newly created organization, and the organization's
|
||||
* policy (employee cap, monthly budget, automation and MCP switches).
|
||||
*
|
||||
* Model names are visible here and nowhere else: an organization's own users
|
||||
* only ever see a neutral tier.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import type { AdminOrganization } from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Select,
|
||||
Stat,
|
||||
Tabs,
|
||||
ToastStack,
|
||||
type ToastMessage,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { orgApi, type ModelGrantRow, type OrgPolicyView } from "@/lib/api-org";
|
||||
|
||||
const TIER_TONES: Record<string, "success" | "info" | "warning"> = {
|
||||
fast: "success",
|
||||
balanced: "info",
|
||||
strong: "warning",
|
||||
};
|
||||
|
||||
function errorMessage(err: unknown, fallback: string): string {
|
||||
return err instanceof ApiError ? err.message : fallback;
|
||||
}
|
||||
|
||||
export default function AdminModelGrantsPage() {
|
||||
const [organizations, setOrganizations] = React.useState<AdminOrganization[]>([]);
|
||||
const [orgId, setOrgId] = React.useState("");
|
||||
const [models, setModels] = React.useState<ModelGrantRow[]>([]);
|
||||
const [defaults, setDefaults] = React.useState<ModelGrantRow[]>([]);
|
||||
const [policy, setPolicy] = React.useState<OrgPolicyView | null>(null);
|
||||
//: Two independent editors, so they need independent inputs: the org matrix
|
||||
//: and the default set can hold different caps for the same model.
|
||||
const [caps, setCaps] = React.useState<Record<string, string>>({});
|
||||
const [defaultCaps, setDefaultCaps] = React.useState<Record<string, string>>({});
|
||||
const [tab, setTab] = React.useState("org");
|
||||
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [busy, setBusy] = React.useState<string | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [orgError, setOrgError] = React.useState<string | null>(null);
|
||||
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
|
||||
|
||||
const [budget, setBudget] = React.useState("");
|
||||
const [maxEmployees, setMaxEmployees] = React.useState("5");
|
||||
|
||||
const toast = React.useCallback(
|
||||
(title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
|
||||
setToasts((current) => [
|
||||
...current,
|
||||
{ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description },
|
||||
]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadOrgs = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nextOrganizations, nextDefaults] = await Promise.all([
|
||||
api.adminOrganizations(),
|
||||
orgApi.listDefaultGrants(),
|
||||
]);
|
||||
setOrganizations(nextOrganizations);
|
||||
setDefaults(nextDefaults);
|
||||
setDefaultCaps(
|
||||
Object.fromEntries(
|
||||
nextDefaults.map((m) => [
|
||||
m.ai_model_id,
|
||||
m.daily_credit_cap === null ? "" : String(m.daily_credit_cap),
|
||||
]),
|
||||
),
|
||||
);
|
||||
setOrgId((current) => current || (nextOrganizations[0]?.id ?? ""));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "دریافت دادههای تخصیص مدل ناموفق بود."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadOrgDetail = React.useCallback(async (id: string) => {
|
||||
if (!id) {
|
||||
setModels([]);
|
||||
setPolicy(null);
|
||||
return;
|
||||
}
|
||||
setOrgError(null);
|
||||
try {
|
||||
const [grants, nextPolicy] = await Promise.all([
|
||||
orgApi.listOrgGrants(id),
|
||||
orgApi.getOrgPolicy(id),
|
||||
]);
|
||||
setModels(grants.models);
|
||||
setCaps(
|
||||
Object.fromEntries(
|
||||
grants.models.map((m) => [
|
||||
m.ai_model_id,
|
||||
m.daily_credit_cap === null ? "" : String(m.daily_credit_cap),
|
||||
]),
|
||||
),
|
||||
);
|
||||
setPolicy(nextPolicy);
|
||||
setMaxEmployees(String(nextPolicy.max_employees));
|
||||
setBudget(
|
||||
nextPolicy.monthly_credit_budget === null
|
||||
? ""
|
||||
: String(nextPolicy.monthly_credit_budget),
|
||||
);
|
||||
} catch (err) {
|
||||
setOrgError(errorMessage(err, "دریافت تخصیصهای این سازمان ناموفق بود."));
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadOrgs();
|
||||
}, [loadOrgs]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadOrgDetail(orgId);
|
||||
}, [loadOrgDetail, orgId]);
|
||||
|
||||
const parseCap = (raw: string): number | null => {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "") return null;
|
||||
const value = Number(trimmed);
|
||||
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||
};
|
||||
|
||||
const toggleGrant = async (model: ModelGrantRow, next: boolean) => {
|
||||
setBusy(model.ai_model_id);
|
||||
try {
|
||||
if (next) {
|
||||
await orgApi.setOrgGrant(orgId, model.ai_model_id, parseCap(caps[model.ai_model_id] ?? ""));
|
||||
} else {
|
||||
await orgApi.revokeOrgGrant(orgId, model.ai_model_id);
|
||||
}
|
||||
await loadOrgDetail(orgId);
|
||||
toast(next ? `مدل «${model.label}» تخصیص یافت` : `دسترسی مدل «${model.label}» لغو شد`);
|
||||
} catch (err) {
|
||||
toast("تغییر تخصیص ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const saveCap = async (model: ModelGrantRow) => {
|
||||
setBusy(model.ai_model_id);
|
||||
try {
|
||||
await orgApi.setOrgGrant(orgId, model.ai_model_id, parseCap(caps[model.ai_model_id] ?? ""));
|
||||
await loadOrgDetail(orgId);
|
||||
toast("سقف اعتبار روزانه ذخیره شد");
|
||||
} catch (err) {
|
||||
toast("ذخیرهٔ سقف ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDefault = async (model: ModelGrantRow, next: boolean) => {
|
||||
setBusy(model.ai_model_id);
|
||||
try {
|
||||
await orgApi.setDefaultGrant(model.ai_model_id, {
|
||||
is_active: next,
|
||||
daily_credit_cap: parseCap(defaultCaps[model.ai_model_id] ?? ""),
|
||||
});
|
||||
const nextDefaults = await orgApi.listDefaultGrants();
|
||||
setDefaults(nextDefaults);
|
||||
setDefaultCaps(
|
||||
Object.fromEntries(
|
||||
nextDefaults.map((m) => [
|
||||
m.ai_model_id,
|
||||
m.daily_credit_cap === null ? "" : String(m.daily_credit_cap),
|
||||
]),
|
||||
),
|
||||
);
|
||||
toast(next ? "به مجموعهٔ پیشفرض افزوده شد" : "از مجموعهٔ پیشفرض حذف شد");
|
||||
} catch (err) {
|
||||
toast("تغییر مجموعهٔ پیشفرض ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const savePolicy = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!policy) return;
|
||||
setBusy("policy");
|
||||
try {
|
||||
const updated = await orgApi.updateOrgPolicy(orgId, {
|
||||
max_employees: Number(maxEmployees),
|
||||
monthly_credit_budget: budget.trim() === "" ? null : Number(budget),
|
||||
automation_enabled: policy.automation_enabled,
|
||||
mcp_enabled: policy.mcp_enabled,
|
||||
});
|
||||
setPolicy(updated);
|
||||
toast("سیاست سازمان ذخیره شد");
|
||||
} catch (err) {
|
||||
toast("ذخیرهٔ سیاست ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const setPolicyFlag = (key: "automation_enabled" | "mcp_enabled", value: boolean) => {
|
||||
setPolicy((current) => (current ? { ...current, [key]: value } : current));
|
||||
};
|
||||
|
||||
if (loading) return <LoadingState label="در حال دریافت تخصیص مدلها…" />;
|
||||
if (error) return <ErrorState message={error} onRetry={loadOrgs} />;
|
||||
|
||||
const grantedCount = models.filter((m) => m.granted).length;
|
||||
const defaultCount = defaults.filter((m) => m.granted).length;
|
||||
const activeDefaults = defaults.filter((m) => m.granted);
|
||||
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="مدلهای سازمانها"
|
||||
description="تخصیص مدلها به هر سازمان و سیاستهای سازمان."
|
||||
planned={[
|
||||
"ماتریس تخصیص مدل",
|
||||
"سقف اعتبار روزانه",
|
||||
"مجموعهٔ پیشفرض سازمانهای جدید",
|
||||
]}
|
||||
/>
|
||||
<div className="anim-stagger flex flex-col gap-5">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-medium text-slate-100">مدلهای سازمانها</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
هر سازمان فقط میتواند از مدلهایی استفاده کند که مالک سامانه تخصیص داده است. کاربران
|
||||
سازمان نام مدل را نمیبینند؛ فقط سطح آن («سریع»، «متعادل»، «قوی») را میبینند.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Stat label="سازمانها" value={organizations.length} />
|
||||
<Stat label="مدلهای تخصیصیافته (سازمان جاری)" value={grantedCount} />
|
||||
<Stat label="مدلهای پیشفرض" value={defaultCount} />
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{ key: "org", label: "تخصیص سازمانها" },
|
||||
{ key: "defaults", label: "مجموعهٔ پیشفرض" },
|
||||
{ key: "policy", label: "سیاست سازمان" },
|
||||
]}
|
||||
active={tab}
|
||||
onChange={setTab}
|
||||
/>
|
||||
|
||||
{tab !== "defaults" && (
|
||||
<Card>
|
||||
<CardTitle>انتخاب سازمان</CardTitle>
|
||||
<div className="mt-3 max-w-sm">
|
||||
<Select
|
||||
label="سازمان"
|
||||
value={orgId}
|
||||
onChange={(e) => setOrgId(e.target.value)}
|
||||
options={
|
||||
organizations.length === 0
|
||||
? [{ value: "", label: "سازمانی وجود ندارد" }]
|
||||
: organizations.map((org) => ({
|
||||
value: org.id,
|
||||
label: `${org.name} (${org.slug})`,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{tab === "org" && (
|
||||
<>
|
||||
{orgError && <ErrorState message={orgError} onRetry={() => void loadOrgDetail(orgId)} />}
|
||||
{!orgError && !orgId && (
|
||||
<EmptyState
|
||||
title="سازمانی وجود ندارد"
|
||||
description="پس از ساخت نخستین سازمان توسط یک مدیر، تخصیص مدلها از همینجا انجام میشود."
|
||||
/>
|
||||
)}
|
||||
{!orgError && orgId && models.length === 0 && (
|
||||
<EmptyState
|
||||
title="مدلی در کاتالوگ نیست"
|
||||
description="ابتدا از بخش «مدلها» مدلهای فعال را تعریف کنید."
|
||||
/>
|
||||
)}
|
||||
{!orgError && orgId && models.length > 0 && (
|
||||
<Card>
|
||||
<CardTitle>ماتریس تخصیص</CardTitle>
|
||||
<div className="mt-1">
|
||||
<CardHint>
|
||||
تیک هر مدل یعنی این سازمان میتواند از آن استفاده کند؛ لغو تیک بلافاصله اعمال
|
||||
میشود. «سقف روزانه» اختیاری است و بر حسب اعتبار محاسبه میشود.
|
||||
</CardHint>
|
||||
</div>
|
||||
<div className="mt-4 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 className="anim-stagger">
|
||||
{models.map((model) => (
|
||||
<tr key={model.ai_model_id} className="border-b border-surface-border/60">
|
||||
<td className="px-2 py-2 text-slate-200">{model.label}</td>
|
||||
<td className="px-2 py-2">
|
||||
<Badge tone={TIER_TONES[model.tier] ?? "neutral"}>
|
||||
{model.tier_label}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-xs text-slate-400">
|
||||
{model.capabilities.join("، ") || "—"}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<Checkbox
|
||||
label="فعال"
|
||||
checked={model.granted}
|
||||
disabled={busy === model.ai_model_id}
|
||||
onChange={(e) => void toggleGrant(model, e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.5"
|
||||
aria-label={`سقف روزانهٔ ${model.label}`}
|
||||
value={caps[model.ai_model_id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setCaps((current) => ({
|
||||
...current,
|
||||
[model.ai_model_id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-24 rounded-lg border border-surface-border bg-surface px-2 py-1 text-xs text-slate-100"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={!model.granted || busy === model.ai_model_id}
|
||||
onClick={() => void saveCap(model)}
|
||||
>
|
||||
ذخیره
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "defaults" && (
|
||||
<Card>
|
||||
<CardTitle>مجموعهٔ پیشفرض سازمانهای جدید</CardTitle>
|
||||
<div className="mt-1">
|
||||
<CardHint>
|
||||
این مجموعه هنگام ساخت هر سازمان جدید بهصورت خودکار کپی میشود. خاموشکردن یک مدل
|
||||
آن را در فهرست نگه میدارد اما به سازمانهای بعدی کپی نمیشود؛ سازمانهای قبلی
|
||||
دسترسی خود را از دست نمیدهند.
|
||||
</CardHint>
|
||||
</div>
|
||||
<div className="mt-4 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 className="anim-stagger">
|
||||
{defaults.map((model) => (
|
||||
<tr key={model.ai_model_id} className="border-b border-surface-border/60">
|
||||
<td className="px-2 py-2 text-slate-200">{model.label}</td>
|
||||
<td className="px-2 py-2">
|
||||
<Badge tone={TIER_TONES[model.tier] ?? "neutral"}>{model.tier_label}</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<Checkbox
|
||||
label="پیشفرض"
|
||||
checked={model.granted}
|
||||
disabled={busy === model.ai_model_id}
|
||||
onChange={(e) => void toggleDefault(model, e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.5"
|
||||
aria-label={`سقف روزانهٔ پیشفرض ${model.label}`}
|
||||
value={defaultCaps[model.ai_model_id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setDefaultCaps((current) => ({
|
||||
...current,
|
||||
[model.ai_model_id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-24 rounded-lg border border-surface-border bg-surface px-2 py-1 text-xs text-slate-100"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={!model.granted || busy === model.ai_model_id}
|
||||
onClick={() => void toggleDefault(model, true)}
|
||||
>
|
||||
ذخیره
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{activeDefaults.length === 0 && (
|
||||
<div className="mt-3">
|
||||
<CardHint>
|
||||
مجموعهٔ پیشفرض خالی است: سازمانهای جدید تا زمانی که تخصیص دستی نگیرند هیچ
|
||||
مدلی ندارند.
|
||||
</CardHint>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{tab === "policy" && (
|
||||
<>
|
||||
{orgError && <ErrorState message={orgError} onRetry={() => void loadOrgDetail(orgId)} />}
|
||||
{!orgError && policy && (
|
||||
<Card>
|
||||
<CardTitle>سیاست سازمان</CardTitle>
|
||||
<div className="mt-1">
|
||||
<CardHint>
|
||||
سقف پرسنل نمیتواند از سقف سامانه ({policy.max_employees_limit}) بیشتر باشد.
|
||||
بودجهٔ ماهانه خالی یعنی بدون سقف صریح.
|
||||
</CardHint>
|
||||
</div>
|
||||
<form onSubmit={savePolicy} className="mt-4 flex max-w-lg flex-col gap-4">
|
||||
<Field
|
||||
label="حداکثر پرسنل"
|
||||
type="number"
|
||||
min={1}
|
||||
max={policy.max_employees_limit}
|
||||
value={maxEmployees}
|
||||
onChange={(e) => setMaxEmployees(e.target.value)}
|
||||
/>
|
||||
<Field
|
||||
label="بودجهٔ ماهانه (اختیاری)"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.5"
|
||||
value={budget}
|
||||
onChange={(e) => setBudget(e.target.value)}
|
||||
hint="خالی بگذارید تا سقف صریحی اعمال نشود."
|
||||
/>
|
||||
<Checkbox
|
||||
label="اتوماسیون فعال باشد"
|
||||
checked={policy.automation_enabled}
|
||||
onChange={(e) => setPolicyFlag("automation_enabled", e.target.checked)}
|
||||
/>
|
||||
<Checkbox
|
||||
label="دسترسی MCP برای این سازمان فعال باشد"
|
||||
checked={policy.mcp_enabled}
|
||||
onChange={(e) => setPolicyFlag("mcp_enabled", e.target.checked)}
|
||||
/>
|
||||
<div>
|
||||
<Button type="submit" loading={busy === "policy"}>
|
||||
ذخیرهٔ سیاست
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ToastStack
|
||||
toasts={toasts}
|
||||
onDismiss={(id) => setToasts((current) => current.filter((t) => t.id !== id))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { PlaceholderPage } from "@/components/PlaceholderPage";
|
||||
/**
|
||||
* Knowledge is now a tab of ``/memory``: a document and a one-line rule are
|
||||
* different things, and they belong on one page so a reader never has to guess
|
||||
* which half of the organization's knowledge lives where.
|
||||
*
|
||||
* This route stays alive as a permanent redirect so an old bookmark (or a link
|
||||
* in a chat message) lands on the page that replaced it.
|
||||
*/
|
||||
|
||||
export default function KnowledgePage() {
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="دانش سازمانی"
|
||||
description="منابع دانش سازمان که کارمندهای هوش مصنوعی از آنها استفاده میکنند."
|
||||
planned={[
|
||||
"افزودن فایل، متن، URL و مخزن بهعنوان منبع دانش",
|
||||
"استخراج متن، تکهبندی (chunk) و ایندکس برداری",
|
||||
"جستوجوی سادهٔ کلیدواژهای در MVP",
|
||||
"اتصال هر منبع دانش به کارمندهای مشخص",
|
||||
"جداسازی کامل دانش بین سازمانها",
|
||||
]}
|
||||
/>
|
||||
);
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function KnowledgeRedirectPage() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace("/memory");
|
||||
}, [router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,538 @@
|
||||
"use client";
|
||||
|
||||
import { PlaceholderPage } from "@/components/PlaceholderPage";
|
||||
/**
|
||||
* Org memory: what the organization has learned about itself.
|
||||
*
|
||||
* Four kinds of memory, four tabs, one page — the plan's "one place" rule. The
|
||||
* Knowledge Base keeps its own endpoints and appears here as a second family of
|
||||
* tabs, because a document and a one-line rule are different things and
|
||||
* pretending otherwise is how knowledge bases become unreadable.
|
||||
*
|
||||
* Live by design: the API publishes ``memory.changed`` on ``org:<id>`` after
|
||||
* every write, so a page left open on one tab sees a lesson that another member
|
||||
* (or the engine's own reflection) just recorded — including the realtime
|
||||
* refresh, not a stale list.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Modal,
|
||||
Select,
|
||||
ToastStack,
|
||||
type ToastMessage,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import {
|
||||
MEMORY_CHANGED_EVENT,
|
||||
memoryApi,
|
||||
memoryKinds,
|
||||
type KnowledgeSourceRow,
|
||||
type MemoryRow,
|
||||
} from "@/lib/api-org";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
type Tab = "rules" | "lessons" | "code" | "knowledge";
|
||||
|
||||
const TABS: { key: Tab; label: string; hint: string }[] = [
|
||||
{ key: "rules", label: "قوانین و ترجیحات", hint: "تصمیمهای همیشگی سازمان" },
|
||||
{ key: "lessons", label: "درسهای آموخته", hint: "آنچه کارهای تمامشده یاد دادند" },
|
||||
{ key: "code", label: "نقشهٔ کد", hint: "واقعیتها و نقشهٔ اپلیکیشن" },
|
||||
{ key: "knowledge", label: "منابع دانش", hint: "اسناد و متنهای بلند" },
|
||||
];
|
||||
|
||||
/** Which kinds each tab shows. */
|
||||
const TAB_KINDS: Record<Tab, string[]> = {
|
||||
rules: ["rule", "preference"],
|
||||
lessons: ["lesson"],
|
||||
code: ["code_map", "fact"],
|
||||
knowledge: [],
|
||||
};
|
||||
|
||||
const KIND_TONES: Record<string, "success" | "info" | "warning" | "neutral"> = {
|
||||
rule: "success",
|
||||
preference: "info",
|
||||
lesson: "warning",
|
||||
fact: "neutral",
|
||||
code_map: "info",
|
||||
};
|
||||
|
||||
function errorMessage(err: unknown, fallback: string): string {
|
||||
return err instanceof ApiError ? err.message : fallback;
|
||||
}
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "—" : date.toLocaleDateString("fa-IR");
|
||||
}
|
||||
|
||||
export default function MemoryPage() {
|
||||
const { activeOrganization } = useSession();
|
||||
const orgId = activeOrganization?.id ?? null;
|
||||
|
||||
const [tab, setTab] = React.useState<Tab>("rules");
|
||||
const [items, setItems] = React.useState<MemoryRow[]>([]);
|
||||
const [stats, setStats] = React.useState({ total: 0, pinned: 0, chars: 0 });
|
||||
const [sources, setSources] = React.useState<KnowledgeSourceRow[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [busy, setBusy] = React.useState<string | null>(null);
|
||||
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
|
||||
|
||||
const [teachOpen, setTeachOpen] = React.useState(false);
|
||||
const [teachKind, setTeachKind] = React.useState<"rule" | "preference">("rule");
|
||||
const [teachContent, setTeachContent] = React.useState("");
|
||||
const [editing, setEditing] = React.useState<MemoryRow | null>(null);
|
||||
const [editContent, setEditContent] = React.useState("");
|
||||
const [pendingDelete, setPendingDelete] = React.useState<MemoryRow | null>(null);
|
||||
|
||||
const [sourceName, setSourceName] = React.useState("");
|
||||
const [sourceText, setSourceText] = React.useState("");
|
||||
const [pendingSource, setPendingSource] = React.useState<KnowledgeSourceRow | null>(null);
|
||||
|
||||
const toast = React.useCallback(
|
||||
(title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
|
||||
setToasts((current) => [
|
||||
...current,
|
||||
{ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description },
|
||||
]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!orgId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [memory, knowledge] = await Promise.all([
|
||||
memoryApi.list(orgId),
|
||||
memoryApi.listKnowledge(orgId),
|
||||
]);
|
||||
setItems(memory.items);
|
||||
setStats({ total: memory.total, pinned: memory.pinned, chars: memory.chars });
|
||||
setSources(knowledge);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "دریافت حافظهٔ سازمان ناموفق بود."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// Live: another member (or a reflection) changed memory while this page was open.
|
||||
useLiveChannel(orgId ? [`org:${orgId}`] : [], (event) => {
|
||||
if ((event.type as string) === MEMORY_CHANGED_EVENT) void load();
|
||||
});
|
||||
|
||||
const visible = React.useMemo(
|
||||
() => items.filter((item) => TAB_KINDS[tab].includes(item.kind)),
|
||||
[items, tab],
|
||||
);
|
||||
|
||||
const run = async (key: string, action: () => Promise<void>) => {
|
||||
setBusy(key);
|
||||
try {
|
||||
await action();
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast("عملیات ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const teach = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!orgId || !teachContent.trim()) return;
|
||||
await run("teach", async () => {
|
||||
const created = await memoryApi.teach(orgId, {
|
||||
kind: teachKind,
|
||||
content: teachContent.trim(),
|
||||
});
|
||||
setTeachOpen(false);
|
||||
setTeachContent("");
|
||||
toast(created.kind_label === "قاعده" ? "قاعده یادآوری شد" : "ترجیح ذخیره شد");
|
||||
});
|
||||
};
|
||||
|
||||
const saveEdit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!orgId || !editing) return;
|
||||
const content = editContent.trim();
|
||||
if (!content) return;
|
||||
await run(`edit-${editing.id}`, async () => {
|
||||
await memoryApi.edit(orgId, editing.id, { content });
|
||||
setEditing(null);
|
||||
toast("یادداشت ویرایش شد");
|
||||
});
|
||||
};
|
||||
|
||||
const togglePin = (item: MemoryRow) =>
|
||||
void run(`pin-${item.id}`, async () => {
|
||||
await memoryApi.setPinned(orgId!, item.id, !item.pinned);
|
||||
toast(item.pinned ? "سنجاق برداشته شد" : "سنجاق شد: همیشه در متن کارها");
|
||||
});
|
||||
|
||||
const confirmLesson = (item: MemoryRow) =>
|
||||
void run(`confirm-${item.id}`, async () => {
|
||||
await memoryApi.confirm(orgId!, item.id);
|
||||
toast("درس تأیید شد: دیگر منقضی نمیشود");
|
||||
});
|
||||
|
||||
const remove = (item: MemoryRow) =>
|
||||
void run(`delete-${item.id}`, async () => {
|
||||
await memoryApi.remove(orgId!, item.id);
|
||||
setPendingDelete(null);
|
||||
toast("یادداشت حذف شد");
|
||||
});
|
||||
|
||||
const addSource = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!orgId || !sourceName.trim() || !sourceText.trim()) return;
|
||||
await run("source", async () => {
|
||||
const created = await memoryApi.createKnowledge(orgId, {
|
||||
name: sourceName.trim(),
|
||||
content: sourceText,
|
||||
});
|
||||
setSources((current) => [created, ...current]);
|
||||
setSourceName("");
|
||||
setSourceText("");
|
||||
toast(`منبع «${created.name}» افزوده شد`);
|
||||
});
|
||||
};
|
||||
|
||||
const removeSource = (source: KnowledgeSourceRow) =>
|
||||
void run(`source-${source.id}`, async () => {
|
||||
await memoryApi.removeKnowledge(orgId!, source.id);
|
||||
setSources((current) => current.filter((row) => row.id !== source.id));
|
||||
setPendingSource(null);
|
||||
toast("منبع دانش حذف شد");
|
||||
});
|
||||
|
||||
if (!orgId) return <LoadingState label="در حال بارگذاری سازمان…" />;
|
||||
if (loading && items.length === 0) return <LoadingState label="در حال دریافت حافظه…" />;
|
||||
if (error && items.length === 0) return <ErrorState message={error} onRetry={load} />;
|
||||
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="حافظهٔ سازمان"
|
||||
description="آنچه سازمان دربارهٔ شیوهٔ مدیریت و کد اپلیکیشن یاد گرفته است."
|
||||
planned={[
|
||||
"قوانین و ترجیحات",
|
||||
"درسهای آموخته",
|
||||
"نقشهٔ کد",
|
||||
"منابع دانش",
|
||||
]}
|
||||
/>
|
||||
<div className="anim-stagger flex flex-col gap-5">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-medium text-slate-100">حافظهٔ سازمان</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
آنچه سازمان دربارهٔ شیوهٔ کار و اپلیکیشنش یاد گرفته است. کارمندهای هوش مصنوعی
|
||||
پیش از هر کار، یادداشتهای مرتبط را میخوانند؛ هر چه سازمان بیشتر یاد بدهد،
|
||||
کارها دقیقتر انجام میشود.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-surface-border bg-surface p-3">
|
||||
<p className="text-xs text-slate-400">یادداشتها</p>
|
||||
<p className="mt-1 text-lg text-slate-100">{stats.total}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-surface-border bg-surface p-3">
|
||||
<p className="text-xs text-slate-400">سنجاقشده</p>
|
||||
<p className="mt-1 text-lg text-slate-100">{stats.pinned}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-surface-border bg-surface p-3">
|
||||
<p className="text-xs text-slate-400">حجم متن</p>
|
||||
<p className="mt-1 text-lg text-slate-100">{stats.chars.toLocaleString("fa-IR")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{TABS.map((entry) => (
|
||||
<button
|
||||
key={entry.key}
|
||||
type="button"
|
||||
onClick={() => setTab(entry.key)}
|
||||
title={entry.hint}
|
||||
className={
|
||||
"rounded-lg border px-3 py-1.5 text-sm transition-colors " +
|
||||
(tab === entry.key
|
||||
? "border-accent bg-accent/10 text-slate-100"
|
||||
: "border-surface-border text-slate-400 hover:text-slate-200")
|
||||
}
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="ms-auto">
|
||||
{tab !== "knowledge" && (
|
||||
<Button size="sm" onClick={() => setTeachOpen(true)}>
|
||||
به سازمان یاد بده
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <ErrorState message={error} onRetry={load} />}
|
||||
|
||||
{tab === "knowledge" ? (
|
||||
<>
|
||||
<Card>
|
||||
<CardTitle>افزودن منبع متنی</CardTitle>
|
||||
<CardHint>
|
||||
منابع دانش اسناد بلند هستند: متن را یکجا میدهیم، سامانه آن را تکهتکه و
|
||||
قابل جستوجو میکند. یادداشتهای کوتاهتر را در تبهای دیگر بیاموزید.
|
||||
</CardHint>
|
||||
<form onSubmit={addSource} className="mt-4 flex flex-col gap-3">
|
||||
<Field
|
||||
label="نام منبع"
|
||||
value={sourceName}
|
||||
onChange={(e) => setSourceName(e.target.value)}
|
||||
placeholder="مثلاً قواعد کدنویسی"
|
||||
required
|
||||
/>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-1 block text-xs text-slate-400">متن</span>
|
||||
<textarea
|
||||
value={sourceText}
|
||||
onChange={(e) => setSourceText(e.target.value)}
|
||||
rows={5}
|
||||
className="w-full rounded-lg border border-surface-border bg-surface px-3 py-2 text-sm text-slate-100"
|
||||
placeholder="متن منبع دانش…"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<Button type="submit" loading={busy === "source"}>
|
||||
افزودن منبع
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{sources.length === 0 ? (
|
||||
<EmptyState
|
||||
title="منبعی ثبت نشده است"
|
||||
description="متنهای بلند سازمان را اینجا بگذارید تا کارمندها در کارهایشان از آنها استفاده کنند."
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardTitle>منابع دانش</CardTitle>
|
||||
<div className="mt-4 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" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sources.map((source) => (
|
||||
<tr key={source.id} className="border-b border-surface-border/60">
|
||||
<td className="px-2 py-2 text-slate-200">{source.name}</td>
|
||||
<td className="px-2 py-2 text-slate-400">{source.kind}</td>
|
||||
<td className="px-2 py-2 text-slate-400">
|
||||
{source.chunk_count.toLocaleString("fa-IR")}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-slate-400">{formatDate(source.created_at)}</td>
|
||||
<td className="px-2 py-2 text-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPendingSource(source)}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : visible.length === 0 ? (
|
||||
<EmptyState
|
||||
title={tab === "lessons" ? "هنوز درسی ثبت نشده" : "هنوز چیزی یادشده نیست"}
|
||||
description="با دکمهٔ «به سازمان یاد بده» یک قاعده یا ترجیح ثبت کنید؛ درسها خودبهخود از کارهای تمامشده استخراج میشوند."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{visible.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone={KIND_TONES[item.kind] ?? "neutral"}>{item.kind_label}</Badge>
|
||||
{item.pinned && <Badge tone="info">سنجاقشده</Badge>}
|
||||
<span className="text-xs text-slate-500">{item.source_label}</span>
|
||||
{item.key && (
|
||||
<code className="rounded bg-surface-border/40 px-1.5 py-0.5 text-[11px] text-slate-400">
|
||||
{item.key}
|
||||
</code>
|
||||
)}
|
||||
{item.kind === "lesson" && (
|
||||
<span className="text-[11px] text-slate-500">
|
||||
اعتماد {Math.round(item.confidence * 100).toLocaleString("fa-IR")}٪
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm text-slate-200">{item.content}</p>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{item.hits > 0
|
||||
? `${item.hits.toLocaleString("fa-IR")} بار در متن کارها آمده`
|
||||
: "هنوز در کاری استفاده نشده"}
|
||||
{" · "}
|
||||
{formatDate(item.updated_at)}
|
||||
{item.expires_at ? ` · انقضا ${formatDate(item.expires_at)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
loading={busy === `pin-${item.id}`}
|
||||
onClick={() => togglePin(item)}
|
||||
>
|
||||
{item.pinned ? "برداشتن سنجاق" : "سنجاق کردن"}
|
||||
</Button>
|
||||
{item.kind === "lesson" && item.confidence < 1 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
loading={busy === `confirm-${item.id}`}
|
||||
onClick={() => confirmLesson(item)}
|
||||
>
|
||||
تأیید درس
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setEditing(item);
|
||||
setEditContent(item.content);
|
||||
}}
|
||||
>
|
||||
ویرایش
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setPendingDelete(item)}>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={teachOpen}
|
||||
title="به سازمان یاد بده"
|
||||
description="یک قاعده یا یک ترجیح: یک جملهٔ کافی است."
|
||||
onClose={() => setTeachOpen(false)}
|
||||
>
|
||||
|
||||
<form onSubmit={teach} className="flex flex-col gap-4">
|
||||
<Select
|
||||
label="نوع"
|
||||
value={teachKind}
|
||||
onChange={(e) => setTeachKind(e.target.value as "rule" | "preference")}
|
||||
options={memoryKinds
|
||||
.filter((kind) => kind.key === "rule" || kind.key === "preference")
|
||||
.map((kind) => ({ value: kind.key, label: kind.label }))}
|
||||
/>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-1 block text-xs text-slate-400">متن یادداشت</span>
|
||||
<textarea
|
||||
value={teachContent}
|
||||
onChange={(e) => setTeachContent(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={1500}
|
||||
className="w-full rounded-lg border border-surface-border bg-surface px-3 py-2 text-sm text-slate-100"
|
||||
placeholder="مثلاً: پیش از هر تغییر، تست مربوطه را اجرا کن."
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-slate-500">
|
||||
یک جملهٔ کافی است. هیچ گذرواژه یا کلیدی در یادداشتها ذخیره نمیشود.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" type="button" onClick={() => setTeachOpen(false)}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button type="submit" loading={busy === "teach"}>
|
||||
یادآوری کن
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{editing && (
|
||||
<Modal open title="ویرایش یادداشت" onClose={() => setEditing(null)}>
|
||||
<form onSubmit={saveEdit} className="flex flex-col gap-4">
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-1 block text-xs text-slate-400">متن</span>
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={1500}
|
||||
className="w-full rounded-lg border border-surface-border bg-surface px-3 py-2 text-sm text-slate-100"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" type="button" onClick={() => setEditing(null)}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button type="submit" loading={busy === `edit-${editing.id}`}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingDelete !== null}
|
||||
title="حذف یادداشت"
|
||||
description={
|
||||
pendingDelete
|
||||
? `«${pendingDelete.content.slice(0, 60)}…» از حافظهٔ سازمان برداشته شود؟`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="حذف"
|
||||
onConfirm={() => pendingDelete && remove(pendingDelete)}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingSource !== null}
|
||||
title="حذف منبع دانش"
|
||||
description={
|
||||
pendingSource ? `منبع «${pendingSource.name}» و تکههای متن آن حذف شود؟` : ""
|
||||
}
|
||||
confirmLabel="حذف"
|
||||
onConfirm={() => pendingSource && removeSource(pendingSource)}
|
||||
onCancel={() => setPendingSource(null)}
|
||||
/>
|
||||
|
||||
<ToastStack
|
||||
toasts={toasts}
|
||||
onDismiss={(id) => setToasts((current) => current.filter((t) => t.id !== id))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,13 +8,17 @@ import {
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
Checkbox,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Modal,
|
||||
Select,
|
||||
Timeline,
|
||||
ToastStack,
|
||||
type ToastMessage,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import {
|
||||
@@ -23,7 +27,14 @@ import {
|
||||
CAN_VIEW_AUDIT,
|
||||
roleHasCapability,
|
||||
} from "@/lib/permissions";
|
||||
import { appProfileApi, setupApi } from "@/lib/api-org";
|
||||
import {
|
||||
mcpApi,
|
||||
appProfileApi,
|
||||
setupApi,
|
||||
type McpSettings,
|
||||
type McpTokenCreated,
|
||||
type McpTokenRow,
|
||||
} from "@/lib/api-org";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
const ROLE_LABELS: Record<Role, string> = {
|
||||
@@ -64,7 +75,11 @@ const AUDIT_LABELS: Record<string, string> = {
|
||||
"run.cancel": "لغو اجرا",
|
||||
};
|
||||
|
||||
type Tab = "members" | "audit" | "app";
|
||||
type Tab = "members" | "app" | "mcp" | "audit";
|
||||
|
||||
function errorText(err: unknown, fallback: string): string {
|
||||
return err instanceof ApiError ? err.message : fallback;
|
||||
}
|
||||
|
||||
/** Matches ``INVITE_TTL_DAYS`` in the API so the hint cannot drift. */
|
||||
const INVITE_TTL_DAYS = 14;
|
||||
@@ -101,6 +116,27 @@ export default function SettingsPage() {
|
||||
const [busyMemberId, setBusyMemberId] = React.useState<string | null>(null);
|
||||
const [pendingRemove, setPendingRemove] = React.useState<Member | null>(null);
|
||||
|
||||
// --- MCP integration ----------------------------------------------------
|
||||
const [mcpTokens, setMcpTokens] = React.useState<McpTokenRow[]>([]);
|
||||
const [mcpSettings, setMcpSettings] = React.useState<McpSettings | null>(null);
|
||||
const [mcpLoading, setMcpLoading] = React.useState(false);
|
||||
const [mcpName, setMcpName] = React.useState("");
|
||||
const [mcpScopes, setMcpScopes] = React.useState<string[]>(["tasks:read"]);
|
||||
const [mcpBusy, setMcpBusy] = React.useState(false);
|
||||
const [mcpCreated, setMcpCreated] = React.useState<McpTokenCreated | null>(null);
|
||||
const [pendingRevoke, setPendingRevoke] = React.useState<McpTokenRow | null>(null);
|
||||
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
|
||||
|
||||
const toast = React.useCallback(
|
||||
(title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
|
||||
setToasts((current) => [
|
||||
...current,
|
||||
{ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description },
|
||||
]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!orgId) {
|
||||
setLoading(false);
|
||||
@@ -129,13 +165,36 @@ export default function SettingsPage() {
|
||||
}
|
||||
}, [orgId]);
|
||||
|
||||
const loadMcp = React.useCallback(async () => {
|
||||
if (!orgId) return;
|
||||
setMcpLoading(true);
|
||||
try {
|
||||
const [tokens, settings] = await Promise.all([
|
||||
mcpApi.list(orgId),
|
||||
mcpApi.settings(orgId),
|
||||
]);
|
||||
setMcpTokens(tokens);
|
||||
setMcpSettings(settings);
|
||||
setMcpScopes((current) =>
|
||||
current.filter((scope) => (settings.scopes as string[]).includes(scope)).length > 0
|
||||
? current
|
||||
: (settings.scopes.slice(0, 1) as string[]),
|
||||
);
|
||||
} catch {
|
||||
setMcpSettings(null);
|
||||
} finally {
|
||||
setMcpLoading(false);
|
||||
}
|
||||
}, [orgId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tab === "audit") void loadAudit();
|
||||
}, [tab, loadAudit]);
|
||||
if (tab === "mcp") void loadMcp();
|
||||
}, [tab, loadAudit, loadMcp]);
|
||||
|
||||
const onInvite = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -197,6 +256,45 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// --- MCP ----------------------------------------------------------------
|
||||
|
||||
const onCreateToken = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!orgId || !mcpName.trim() || mcpScopes.length === 0) return;
|
||||
setMcpBusy(true);
|
||||
try {
|
||||
const created = await mcpApi.create(orgId, {
|
||||
name: mcpName.trim(),
|
||||
scopes: mcpScopes,
|
||||
});
|
||||
setMcpCreated(created);
|
||||
setMcpTokens((current) => [created, ...current]);
|
||||
setMcpName("");
|
||||
toast("توکن ساخته شد", "success", "توکن فقط یک بار نمایش داده میشود؛ آن را ذخیره کنید.");
|
||||
} catch (err) {
|
||||
toast("ساخت توکن ناموفق بود", "danger", errorText(err, "دوباره تلاش کنید."));
|
||||
} finally {
|
||||
setMcpBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onRevokeToken = async (token: McpTokenRow) => {
|
||||
if (!orgId) return;
|
||||
setMcpBusy(true);
|
||||
try {
|
||||
await mcpApi.revoke(orgId, token.id);
|
||||
setMcpTokens((current) =>
|
||||
current.map((row) => (row.id === token.id ? { ...row, status: "revoked" } : row)),
|
||||
);
|
||||
setPendingRevoke(null);
|
||||
toast("دسترسی توکن لغو شد");
|
||||
} catch (err) {
|
||||
toast("لغو توکن ناموفق بود", "danger", errorText(err, "دوباره تلاش کنید."));
|
||||
} finally {
|
||||
setMcpBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeOrganization) {
|
||||
return (
|
||||
<EmptyState
|
||||
@@ -209,6 +307,8 @@ export default function SettingsPage() {
|
||||
const canManageMembers = roleHasCapability(activeOrganization.role, CAN_MANAGE_MEMBERS);
|
||||
const canViewAudit = roleHasCapability(activeOrganization.role, CAN_VIEW_AUDIT);
|
||||
const canManageProjects = roleHasCapability(activeOrganization.role, CAN_MANAGE_PROJECTS);
|
||||
const canManageMcp =
|
||||
activeOrganization.role === "owner" || activeOrganization.role === "admin";
|
||||
|
||||
return (
|
||||
<div className="anim-stagger flex flex-col gap-5">
|
||||
@@ -222,6 +322,7 @@ export default function SettingsPage() {
|
||||
[
|
||||
["members", "اعضا و دعوت"],
|
||||
["app", "اپلیکیشن"],
|
||||
["mcp", "یکپارچهسازی (MCP)"],
|
||||
["audit", "رویدادهای حسابرسی"],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
@@ -378,6 +479,134 @@ export default function SettingsPage() {
|
||||
</>
|
||||
) : tab === "app" ? (
|
||||
<AppTab orgId={activeOrganization.id} canEdit={canManageProjects} />
|
||||
) : tab === "mcp" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardTitle>یکپارچهسازی (MCP)</CardTitle>
|
||||
<CardHint>
|
||||
با یک توکن، عاملهای هوش مصنوعی بیرونی میتوانند در همین سازمان کار کنند:
|
||||
وظایف بسازند، حافظه را جستوجو کنند و اپلیکیشن را بخوانند. توکن نشان میدهد
|
||||
که عامل از طرف کدام سازمان حرف میزند — هرگز برعکس.
|
||||
</CardHint>
|
||||
{mcpSettings && (!mcpSettings.gateway_enabled || !mcpSettings.mcp_enabled) && (
|
||||
<p className="mt-3 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
{!mcpSettings.gateway_enabled
|
||||
? "دسترسی MCP سامانه با دستور مالک سامانه خاموش است؛ توکنها تا روشنشدن آن کار نمیکنند."
|
||||
: "دسترسی MCP این سازمان خاموش است؛ برای روشنکردن آن با مالک سامانه تماس بگیرید."}
|
||||
</p>
|
||||
)}
|
||||
{mcpLoading ? (
|
||||
<LoadingState label="در حال دریافت توکنها…" />
|
||||
) : mcpTokens.length === 0 ? (
|
||||
<EmptyState
|
||||
title="توکنی ساخته نشده است"
|
||||
description="توکنها دسترسی یک عامل بیرونی را به همین سازمان محدود میکنند."
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-4 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" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="anim-stagger">
|
||||
{mcpTokens.map((token) => (
|
||||
<tr key={token.id} className="border-b border-surface-border/60">
|
||||
<td className="px-2 py-2 text-slate-200">{token.name}</td>
|
||||
<td className="px-2 py-2 text-slate-400" dir="ltr">
|
||||
{token.prefix}…
|
||||
</td>
|
||||
<td className="px-2 py-2 text-xs text-slate-400" dir="ltr">
|
||||
{token.scopes.join(", ")}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<Badge
|
||||
tone={
|
||||
token.status === "active"
|
||||
? "success"
|
||||
: token.status === "expired"
|
||||
? "warning"
|
||||
: "neutral"
|
||||
}
|
||||
>
|
||||
{token.status === "active"
|
||||
? "فعال"
|
||||
: token.status === "expired"
|
||||
? "منقضی"
|
||||
: "لغو شده"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-end">
|
||||
{canManageMcp && token.status === "active" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPendingRevoke(token)}
|
||||
>
|
||||
لغو
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{canManageMcp && (
|
||||
<Card>
|
||||
<CardTitle>توکن تازه</CardTitle>
|
||||
<CardHint>
|
||||
توکن فقط یک بار، همینجا، نمایش داده میشود. محدودهها را کمترین چیزی
|
||||
بگیرید که عامل برای کارش لازم دارد.
|
||||
</CardHint>
|
||||
<form onSubmit={onCreateToken} className="mt-4 flex flex-col gap-4">
|
||||
<Field
|
||||
label="نام توکن"
|
||||
value={mcpName}
|
||||
onChange={(e) => setMcpName(e.target.value)}
|
||||
placeholder="مثلاً عامل CI"
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<p className="mb-2 text-xs text-slate-400">محدودههای دسترسی</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{(mcpSettings?.scopes ?? []).map((scope) => (
|
||||
<Checkbox
|
||||
key={scope}
|
||||
label={scope}
|
||||
checked={mcpScopes.includes(scope)}
|
||||
onChange={(e) =>
|
||||
setMcpScopes((current) =>
|
||||
e.target.checked
|
||||
? [...current, scope]
|
||||
: current.filter((item) => item !== scope),
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={mcpBusy}
|
||||
disabled={mcpScopes.length === 0 || !mcpName.trim()}
|
||||
>
|
||||
ساخت توکن
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardTitle>رویدادهای حسابرسی</CardTitle>
|
||||
@@ -413,6 +642,56 @@ export default function SettingsPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={mcpCreated !== null}
|
||||
title="توکن ساخته شد"
|
||||
description="این توکن فقط حالا نمایش داده میشود؛ بعداً هیچ راهی برای دیدن آن نیست."
|
||||
onClose={() => setMcpCreated(null)}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<code
|
||||
dir="ltr"
|
||||
className="block break-all rounded-lg border border-surface-border bg-surface px-3 py-2 text-xs text-slate-100"
|
||||
>
|
||||
{mcpCreated?.token}
|
||||
</code>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (mcpCreated) void navigator.clipboard.writeText(mcpCreated.token);
|
||||
toast("توکن کپی شد");
|
||||
}}
|
||||
>
|
||||
کپی توکن
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
این توکن را در تنظیمات عامل خود بگذارید (هدر Authorization با مقدار Bearer).
|
||||
اگر گم شد، توکن را لغو و یکی دیگر بسازید.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingRevoke !== null}
|
||||
title="لغو توکن"
|
||||
description={
|
||||
pendingRevoke
|
||||
? `دسترسی «${pendingRevoke.name}» به این سازمان فوراً قطع میشود.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="لغو توکن"
|
||||
onConfirm={() => pendingRevoke && void onRevokeToken(pendingRevoke)}
|
||||
onCancel={() => setPendingRevoke(null)}
|
||||
/>
|
||||
|
||||
<ToastStack
|
||||
toasts={toasts}
|
||||
onDismiss={(id) => setToasts((current) => current.filter((t) => t.id !== id))}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingRemove !== null}
|
||||
title="حذف عضو"
|
||||
|
||||
@@ -22,6 +22,57 @@ import type {
|
||||
import { apiRequest } from "./api";
|
||||
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Owner model grants + org policy */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* One catalogue model as the owner sees it, plus whether an organization has it.
|
||||
*
|
||||
* ``label`` is the owner-facing model name; organization users never see this
|
||||
* list — for them a model is only ever a tier («سریع» / «متعادل» / «قوی»).
|
||||
*/
|
||||
export interface ModelGrantRow {
|
||||
ai_model_id: string;
|
||||
label: string;
|
||||
provider_id: string;
|
||||
capabilities: string[];
|
||||
tier: string;
|
||||
tier_label: string;
|
||||
output_price_per_1m: number;
|
||||
model_active: boolean;
|
||||
granted: boolean;
|
||||
daily_credit_cap: number | null;
|
||||
}
|
||||
|
||||
/** An active grant, as the engine would see it (no model name). */
|
||||
export interface ActiveGrantRow {
|
||||
ai_model_id: string;
|
||||
label: string;
|
||||
tier: string;
|
||||
tier_label: string;
|
||||
capabilities: string[];
|
||||
daily_credit_cap: number | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
/** The limits the owner sets for one organization. */
|
||||
export interface OrgPolicyView {
|
||||
organization_id: string;
|
||||
max_employees: number;
|
||||
monthly_credit_budget: number | null;
|
||||
automation_enabled: boolean;
|
||||
mcp_enabled: boolean;
|
||||
/** The platform ceiling for ``max_employees`` (``ENGINE_MAX_EMPLOYEES``). */
|
||||
max_employees_limit: number;
|
||||
}
|
||||
|
||||
export interface OrgGrantsView {
|
||||
organization_id: string;
|
||||
models: ModelGrantRow[];
|
||||
}
|
||||
|
||||
|
||||
/** The platform owner's account invitations (split into organizations of the future). */
|
||||
export const orgApi = {
|
||||
/** Create an invitation link for a new manager account. */
|
||||
@@ -76,6 +127,280 @@ export const orgApi = {
|
||||
`/api/organizations/${orgId}/invites/${encodeURIComponent(inviteId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
|
||||
// --- owner model grants --------------------------------------------------
|
||||
listOrgGrants: (orgId: string) =>
|
||||
apiRequest<OrgGrantsView>(`/api/admin/model-grants/${encodeURIComponent(orgId)}`),
|
||||
listActiveGrants: (orgId: string) =>
|
||||
apiRequest<ActiveGrantRow[]>(
|
||||
`/api/admin/model-grants/${encodeURIComponent(orgId)}/active`,
|
||||
),
|
||||
setOrgGrant: (orgId: string, modelId: string, dailyCreditCap: number | null) =>
|
||||
apiRequest<ActiveGrantRow>(
|
||||
`/api/admin/model-grants/${encodeURIComponent(orgId)}/models/${encodeURIComponent(modelId)}`,
|
||||
{ method: "PUT", body: { daily_credit_cap: dailyCreditCap } },
|
||||
),
|
||||
revokeOrgGrant: (orgId: string, modelId: string) =>
|
||||
apiRequest<{ organization_id: string; ai_model_id: string; granted: boolean }>(
|
||||
`/api/admin/model-grants/${encodeURIComponent(orgId)}/models/${encodeURIComponent(modelId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
|
||||
// --- the default set copied into every new organization ------------------
|
||||
listDefaultGrants: () => apiRequest<ModelGrantRow[]>("/api/admin/default-model-grants"),
|
||||
setDefaultGrant: (
|
||||
modelId: string,
|
||||
body: { is_active: boolean; daily_credit_cap: number | null },
|
||||
) =>
|
||||
apiRequest<ModelGrantRow>(
|
||||
`/api/admin/default-model-grants/${encodeURIComponent(modelId)}`,
|
||||
{ method: "PUT", body },
|
||||
),
|
||||
removeDefaultGrant: (modelId: string) =>
|
||||
apiRequest<{ ai_model_id: string; in_default_set: boolean }>(
|
||||
`/api/admin/default-model-grants/${encodeURIComponent(modelId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
|
||||
// --- org policy ----------------------------------------------------------
|
||||
getOrgPolicy: (orgId: string) =>
|
||||
apiRequest<OrgPolicyView>(`/api/admin/org-policies/${encodeURIComponent(orgId)}`),
|
||||
updateOrgPolicy: (
|
||||
orgId: string,
|
||||
body: Partial<
|
||||
Pick<
|
||||
OrgPolicyView,
|
||||
"max_employees" | "monthly_credit_budget" | "automation_enabled" | "mcp_enabled"
|
||||
>
|
||||
>,
|
||||
) =>
|
||||
apiRequest<OrgPolicyView>(`/api/admin/org-policies/${encodeURIComponent(orgId)}`, {
|
||||
method: "PUT",
|
||||
body,
|
||||
}),
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Org memory + knowledge sources */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* One row of the organization's memory: a rule, a preference, a lesson, a fact
|
||||
* or a code-map note.
|
||||
*
|
||||
* ``key`` is the stable handle a rule can be addressed by (the same key always
|
||||
* corrects the same entry), ``confidence`` is a lesson's 0..1 score, and
|
||||
* ``hits`` counts how many tasks have been built with this entry in context.
|
||||
*/
|
||||
export interface MemoryRow {
|
||||
id: string;
|
||||
kind: string;
|
||||
kind_label: string;
|
||||
key: string | null;
|
||||
content: string;
|
||||
source: string;
|
||||
source_label: string;
|
||||
evidence_task_id: string | null;
|
||||
pinned: boolean;
|
||||
confidence: number;
|
||||
expires_at: string | null;
|
||||
hits: number;
|
||||
last_used_at: string | null;
|
||||
created_by_user_id: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface MemoryList {
|
||||
organization_id: string;
|
||||
items: MemoryRow[];
|
||||
total: number;
|
||||
pinned: number;
|
||||
chars: number;
|
||||
}
|
||||
|
||||
/** A knowledge-base source, shown as the memory page's second family of tabs. */
|
||||
export interface KnowledgeSourceRow {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
project_id: string | null;
|
||||
created_at: string;
|
||||
chunk_count: number;
|
||||
}
|
||||
|
||||
export const memoryKinds = [
|
||||
{ key: "rule", label: "قاعده" },
|
||||
{ key: "preference", label: "ترجیح" },
|
||||
{ key: "lesson", label: "درس" },
|
||||
{ key: "fact", label: "واقعیت" },
|
||||
{ key: "code_map", label: "نقشهٔ کد" },
|
||||
] as const;
|
||||
|
||||
export const memoryApi = {
|
||||
// --- memory -------------------------------------------------------------
|
||||
list: (orgId: string, params: { kind?: string; q?: string } = {}) =>
|
||||
apiRequest<MemoryList>(
|
||||
`/api/organizations/${encodeURIComponent(orgId)}/memory?${new URLSearchParams(
|
||||
Object.fromEntries(
|
||||
Object.entries(params).filter(([, value]) => Boolean(value)),
|
||||
) as Record<string, string>,
|
||||
).toString()}`,
|
||||
),
|
||||
teach: (
|
||||
orgId: string,
|
||||
body: { kind: string; content: string; key?: string | null },
|
||||
) =>
|
||||
apiRequest<MemoryRow>(`/api/organizations/${encodeURIComponent(orgId)}/memory`, {
|
||||
method: "POST",
|
||||
body,
|
||||
}),
|
||||
edit: (orgId: string, memoryId: string, body: { content: string; key?: string | null }) =>
|
||||
apiRequest<MemoryRow>(
|
||||
`/api/organizations/${encodeURIComponent(orgId)}/memory/${encodeURIComponent(memoryId)}`,
|
||||
{ method: "PATCH", body },
|
||||
),
|
||||
setPinned: (orgId: string, memoryId: string, pinned: boolean) =>
|
||||
apiRequest<MemoryRow>(
|
||||
`/api/organizations/${encodeURIComponent(orgId)}/memory/${encodeURIComponent(memoryId)}/pin`,
|
||||
{ method: "POST", body: { pinned } },
|
||||
),
|
||||
confirm: (orgId: string, memoryId: string) =>
|
||||
apiRequest<MemoryRow>(
|
||||
`/api/organizations/${encodeURIComponent(orgId)}/memory/${encodeURIComponent(memoryId)}/confirm`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
remove: (orgId: string, memoryId: string) =>
|
||||
apiRequest<void>(
|
||||
`/api/organizations/${encodeURIComponent(orgId)}/memory/${encodeURIComponent(memoryId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
|
||||
// --- knowledge sources (unchanged endpoints, shown as a second tab) ------
|
||||
listKnowledge: (orgId: string) =>
|
||||
apiRequest<KnowledgeSourceRow[]>(
|
||||
`/api/organizations/${encodeURIComponent(orgId)}/knowledge/sources`,
|
||||
),
|
||||
createKnowledge: (
|
||||
orgId: string,
|
||||
body: { name: string; content: string; title?: string },
|
||||
) =>
|
||||
apiRequest<KnowledgeSourceRow>(
|
||||
`/api/organizations/${encodeURIComponent(orgId)}/knowledge/sources`,
|
||||
{ method: "POST", body },
|
||||
),
|
||||
removeKnowledge: (orgId: string, sourceId: string) =>
|
||||
apiRequest<void>(
|
||||
`/api/organizations/${encodeURIComponent(orgId)}/knowledge/sources/${encodeURIComponent(sourceId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Org memory realtime */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The event the API publishes on ``org:<id>`` whenever memory changes.
|
||||
*
|
||||
* Declared here rather than in ``@agy/contracts``: the event vocabulary is a
|
||||
* frozen, foundation-owned list, so a page compares the raw string instead of
|
||||
* widening the union for one new type.
|
||||
*/
|
||||
export const MEMORY_CHANGED_EVENT = "memory.changed";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* MCP tokens (org admin) + the owner's switches */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Every scope the gateway understands, as the mint dialog offers it. */
|
||||
export const MCP_SCOPES = [
|
||||
"app:read",
|
||||
"tasks:read",
|
||||
"tasks:write",
|
||||
"memory:read",
|
||||
"memory:write",
|
||||
"delivery:read",
|
||||
] as const;
|
||||
|
||||
export type McpScope = (typeof MCP_SCOPES)[number];
|
||||
|
||||
/**
|
||||
* A token as the UI sees it: the prefix, the scopes, the status.
|
||||
*
|
||||
* The raw token exists only on the create response (``McpTokenCreated``), which
|
||||
* is why that type is separate: nothing else in the app can produce one.
|
||||
*/
|
||||
export interface McpTokenRow {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
scopes: McpScope[];
|
||||
expires_at: string | null;
|
||||
last_used_at: string | null;
|
||||
revoked_at: string | null;
|
||||
created_at: string | null;
|
||||
status: "active" | "expired" | "revoked";
|
||||
}
|
||||
|
||||
export interface McpTokenCreated extends McpTokenRow {
|
||||
/** Shown once, in the modal, with a copy button. Never stored by the client. */
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface McpSettings {
|
||||
organization_id: string;
|
||||
mcp_enabled: boolean;
|
||||
gateway_enabled: boolean;
|
||||
scopes: McpScope[];
|
||||
token_count: number;
|
||||
}
|
||||
|
||||
export interface McpOrgState {
|
||||
organization_id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
mcp_enabled: boolean;
|
||||
token_count: number;
|
||||
}
|
||||
|
||||
export interface McpAdminState {
|
||||
gateway_enabled: boolean;
|
||||
organizations: McpOrgState[];
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
export const mcpApi = {
|
||||
list: (orgId: string) =>
|
||||
apiRequest<McpTokenRow[]>(`/api/organizations/${encodeURIComponent(orgId)}/mcp-tokens`),
|
||||
create: (orgId: string, body: { name: string; scopes: string[]; ttl_days?: number }) =>
|
||||
apiRequest<McpTokenCreated>(
|
||||
`/api/organizations/${encodeURIComponent(orgId)}/mcp-tokens`,
|
||||
{ method: "POST", body },
|
||||
),
|
||||
revoke: (orgId: string, tokenId: string) =>
|
||||
apiRequest<{ revoked: boolean }>(
|
||||
`/api/organizations/${encodeURIComponent(orgId)}/mcp-tokens/${encodeURIComponent(tokenId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
settings: (orgId: string) =>
|
||||
apiRequest<McpSettings>(
|
||||
`/api/organizations/${encodeURIComponent(orgId)}/mcp-tokens/settings`,
|
||||
),
|
||||
|
||||
// --- owner ---------------------------------------------------------------
|
||||
adminState: () => apiRequest<McpAdminState>("/api/admin/mcp"),
|
||||
setGateway: (enabled: boolean) =>
|
||||
apiRequest<{ gateway_enabled: boolean }>("/api/admin/mcp", {
|
||||
method: "PUT",
|
||||
body: { enabled },
|
||||
}),
|
||||
setOrgEnabled: (orgId: string, enabled: boolean) =>
|
||||
apiRequest<{ mcp_enabled: boolean }>(
|
||||
`/api/admin/mcp/organizations/${encodeURIComponent(orgId)}`,
|
||||
{ method: "PUT", body: { enabled } },
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* MCP integration: the org settings tab and the owner's gateway switches.
|
||||
*
|
||||
* The security-relevant assertion is the show-once token: the raw value comes
|
||||
* back from the create call and nowhere else, so the list table must never carry
|
||||
* it, and the modal that shows it is the only place a user can copy it.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
class FakeApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
return {
|
||||
FakeApiError,
|
||||
api: {
|
||||
listMembers: vi.fn(),
|
||||
listAudit: vi.fn(),
|
||||
updateMember: vi.fn(),
|
||||
removeMember: vi.fn(),
|
||||
},
|
||||
orgApi: {
|
||||
listOrgInvites: vi.fn(),
|
||||
getAppProfile: vi.fn(),
|
||||
patchAppProfile: vi.fn(),
|
||||
transferOwnership: vi.fn(),
|
||||
leaveOrganization: vi.fn(),
|
||||
},
|
||||
mcpApi: {
|
||||
list: vi.fn(),
|
||||
settings: vi.fn(),
|
||||
create: vi.fn(),
|
||||
revoke: vi.fn(),
|
||||
adminState: vi.fn(),
|
||||
setGateway: vi.fn(),
|
||||
setOrgEnabled: vi.fn(),
|
||||
},
|
||||
session: {
|
||||
activeOrganization: { id: "org-1", name: "Acme", slug: "acme", role: "admin" as const },
|
||||
refresh: vi.fn(async () => {}),
|
||||
},
|
||||
router: { replace: vi.fn() },
|
||||
clipboard: { writeText: vi.fn(async () => {}) },
|
||||
channels: [] as string[][],
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => mocks.router }));
|
||||
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
|
||||
vi.mock("@/lib/api", () => ({ ApiError: mocks.FakeApiError, api: mocks.api }));
|
||||
vi.mock("@/lib/api-org", () => ({
|
||||
orgApi: mocks.orgApi,
|
||||
mcpApi: mocks.mcpApi,
|
||||
MCP_SCOPES: [
|
||||
"app:read",
|
||||
"tasks:read",
|
||||
"tasks:write",
|
||||
"memory:read",
|
||||
"memory:write",
|
||||
"delivery:read",
|
||||
],
|
||||
memoryApi: { list: vi.fn(), listKnowledge: vi.fn() },
|
||||
MEMORY_CHANGED_EVENT: "memory.changed",
|
||||
memoryKinds: [],
|
||||
useNeedsOnboarding: () => ({ needsOnboarding: false, ready: true }),
|
||||
}));
|
||||
vi.mock("@/lib/realtime", () => ({
|
||||
useLiveChannel: (channels: string[], onEvent: (event: unknown) => void) => {
|
||||
mocks.channels.push(channels);
|
||||
},
|
||||
}));
|
||||
|
||||
import AdminMcpPage from "@/app/(app)/admin/mcp/page";
|
||||
import SettingsPage from "@/app/(app)/settings/page";
|
||||
|
||||
const TOKEN_ROW = {
|
||||
id: "tok-1",
|
||||
organization_id: "org-1",
|
||||
name: "agent CI",
|
||||
prefix: "smo_abc123",
|
||||
scopes: ["tasks:read"],
|
||||
expires_at: "2026-06-01T00:00:00Z",
|
||||
last_used_at: null,
|
||||
revoked_at: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
status: "active" as const,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.channels = [];
|
||||
Object.defineProperty(window.navigator, "clipboard", {
|
||||
value: mocks.clipboard,
|
||||
configurable: true,
|
||||
});
|
||||
mocks.session.activeOrganization = {
|
||||
id: "org-1",
|
||||
name: "Acme",
|
||||
slug: "acme",
|
||||
role: "admin" as const,
|
||||
};
|
||||
mocks.api.listMembers.mockResolvedValue([]);
|
||||
mocks.api.listAudit.mockResolvedValue([]);
|
||||
mocks.orgApi.listOrgInvites.mockResolvedValue([]);
|
||||
mocks.orgApi.getAppProfile.mockRejectedValue(new mocks.FakeApiError(404, "nf", "no app"));
|
||||
mocks.mcpApi.list.mockResolvedValue([TOKEN_ROW]);
|
||||
mocks.mcpApi.settings.mockResolvedValue({
|
||||
organization_id: "org-1",
|
||||
mcp_enabled: true,
|
||||
gateway_enabled: true,
|
||||
scopes: [
|
||||
"app:read",
|
||||
"tasks:read",
|
||||
"tasks:write",
|
||||
"memory:read",
|
||||
"memory:write",
|
||||
"delivery:read",
|
||||
],
|
||||
token_count: 1,
|
||||
});
|
||||
mocks.mcpApi.adminState.mockResolvedValue({
|
||||
gateway_enabled: true,
|
||||
organizations: [
|
||||
{
|
||||
organization_id: "org-1",
|
||||
name: "Acme",
|
||||
slug: "acme",
|
||||
mcp_enabled: true,
|
||||
token_count: 1,
|
||||
},
|
||||
],
|
||||
total_tokens: 1,
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings — MCP integration", () => {
|
||||
it("lists tokens by prefix and never shows the raw token", async () => {
|
||||
render(<SettingsPage />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "یکپارچهسازی (MCP)" }));
|
||||
|
||||
expect(await screen.findByText("agent CI")).toBeInTheDocument();
|
||||
// Only the prefix is rendered — a nine-character handle, never a secret.
|
||||
expect(screen.getByText("smo_abc123…")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/smo_[A-Za-z0-9_\-]{20,}/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a minted token exactly once, with a copy button", async () => {
|
||||
mocks.mcpApi.create.mockResolvedValue({
|
||||
...TOKEN_ROW,
|
||||
id: "tok-2",
|
||||
token: "smo_SEKRET-VALUE-1234567890abcdef",
|
||||
});
|
||||
render(<SettingsPage />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "یکپارچهسازی (MCP)" }));
|
||||
|
||||
fireEvent.change(await screen.findByLabelText("نام توکن"), {
|
||||
target: { value: "agent جدید" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "ساخت توکن" }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "توکن ساخته شد" });
|
||||
expect(within(dialog).getByText("smo_SEKRET-VALUE-1234567890abcdef")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "کپی توکن" }));
|
||||
await waitFor(() =>
|
||||
expect(mocks.clipboard.writeText).toHaveBeenCalledWith("smo_SEKRET-VALUE-1234567890abcdef"),
|
||||
);
|
||||
|
||||
// After closing, the raw value is gone from the page for good.
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "بستن" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("smo_SEKRET-VALUE-1234567890abcdef")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a scope before minting, and confirms before revoking", async () => {
|
||||
render(<SettingsPage />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "یکپارچهسازی (MCP)" }));
|
||||
|
||||
// Deselect the pre-checked scope: mint is disabled without one.
|
||||
fireEvent.change(await screen.findByLabelText("نام توکن"), {
|
||||
target: { value: "بدون محدوده" },
|
||||
});
|
||||
const checkbox = document.getElementById("check-tasks:read") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox.checked).toBe(false);
|
||||
expect(screen.getByRole("button", { name: "ساخت توکن" })).toBeDisabled();
|
||||
|
||||
// …and selectable again, which is what re-enables it.
|
||||
fireEvent.click(checkbox);
|
||||
expect(screen.getByRole("button", { name: "ساخت توکن" })).toBeEnabled();
|
||||
|
||||
mocks.mcpApi.revoke.mockResolvedValue({ revoked: true });
|
||||
fireEvent.click(screen.getByRole("button", { name: "لغو" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "لغو توکن" });
|
||||
fireEvent.click(dialog.querySelectorAll("button")[1]);
|
||||
await waitFor(() => expect(mocks.mcpApi.revoke).toHaveBeenCalledWith("org-1", "tok-1"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin — MCP switches", () => {
|
||||
it("shows the gateway state and switches off one organization", async () => {
|
||||
mocks.mcpApi.setOrgEnabled.mockResolvedValue({ mcp_enabled: false });
|
||||
render(<AdminMcpPage />);
|
||||
|
||||
expect(await screen.findByText("Acme")).toBeInTheDocument();
|
||||
expect(screen.getByText("روشن")).toBeInTheDocument();
|
||||
|
||||
// Turning one organization off asks first — it is a destructive switch.
|
||||
fireEvent.click(screen.getByRole("button", { name: "خاموش کن" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "خاموشکردن دسترسی یک سازمان" });
|
||||
fireEvent.click(dialog.querySelectorAll("button")[1]);
|
||||
await waitFor(() =>
|
||||
expect(mocks.mcpApi.setOrgEnabled).toHaveBeenCalledWith("org-1", false),
|
||||
);
|
||||
});
|
||||
|
||||
it("turns the whole gateway off with a confirmation", async () => {
|
||||
mocks.mcpApi.setGateway.mockResolvedValue({ gateway_enabled: false });
|
||||
render(<AdminMcpPage />);
|
||||
|
||||
const button = await screen.findByRole("button", {
|
||||
name: "خاموشکردن دسترسی MCP سامانه",
|
||||
});
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => expect(mocks.mcpApi.setGateway).toHaveBeenCalledWith(false));
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "روشنکردن دسترسی MCP" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Org memory page.
|
||||
*
|
||||
* Four tabs over one organization's memory, a "teach the organization" dialog
|
||||
* that is a single sentence, and a live refresh on ``memory.changed``. The
|
||||
* security-relevant assertion here is the payload one: a credential typed into
|
||||
* the teach box must never reach the API — the server redacts, and the client
|
||||
* must not smuggle it into a key field either.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
class FakeApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
return {
|
||||
FakeApiError,
|
||||
api: {},
|
||||
memoryApi: {
|
||||
list: vi.fn(),
|
||||
teach: vi.fn(),
|
||||
edit: vi.fn(),
|
||||
setPinned: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
listKnowledge: vi.fn(),
|
||||
createKnowledge: vi.fn(),
|
||||
removeKnowledge: vi.fn(),
|
||||
},
|
||||
session: {
|
||||
activeOrganization: { id: "org-1", name: "Acme", slug: "acme", role: "admin" as const },
|
||||
refresh: vi.fn(async () => {}),
|
||||
},
|
||||
router: { replace: vi.fn() },
|
||||
subscriptions: [] as string[][],
|
||||
handlers: [] as ((event: unknown) => void)[],
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => mocks.router }));
|
||||
|
||||
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
|
||||
|
||||
vi.mock("@/lib/api", () => ({ ApiError: mocks.FakeApiError, api: mocks.api }));
|
||||
|
||||
vi.mock("@/lib/api-org", () => ({
|
||||
MEMORY_CHANGED_EVENT: "memory.changed",
|
||||
memoryApi: mocks.memoryApi,
|
||||
memoryKinds: [
|
||||
{ key: "rule", label: "قاعده" },
|
||||
{ key: "preference", label: "ترجیح" },
|
||||
{ key: "lesson", label: "درس" },
|
||||
{ key: "fact", label: "واقعیت" },
|
||||
{ key: "code_map", label: "نقشهٔ کد" },
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/realtime", () => ({
|
||||
useLiveChannel: (channels: string[], onEvent: (event: unknown) => void) => {
|
||||
mocks.subscriptions.push(channels);
|
||||
mocks.handlers.push(onEvent);
|
||||
},
|
||||
}));
|
||||
|
||||
import MemoryPage from "@/app/(app)/memory/page";
|
||||
|
||||
const RULE = {
|
||||
id: "m-rule",
|
||||
kind: "rule",
|
||||
kind_label: "قاعده",
|
||||
key: "tests-first",
|
||||
content: "پیش از هر تغییر، تست مربوطه را اجرا کن.",
|
||||
source: "user",
|
||||
source_label: "کاربر",
|
||||
evidence_task_id: null,
|
||||
pinned: true,
|
||||
confidence: 1,
|
||||
expires_at: null,
|
||||
hits: 3,
|
||||
last_used_at: "2026-02-01T00:00:00Z",
|
||||
created_by_user_id: "u-1",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T00:00:00Z",
|
||||
};
|
||||
|
||||
const LESSON = {
|
||||
...RULE,
|
||||
id: "m-lesson",
|
||||
kind: "lesson",
|
||||
kind_label: "درس",
|
||||
key: null,
|
||||
content: "دروازهٔ lint دو بار شکست؛ پیش از ارسال، lint محلی را اجرا کن.",
|
||||
source: "reflection",
|
||||
source_label: "بازتاب",
|
||||
confidence: 0.5,
|
||||
pinned: false,
|
||||
expires_at: "2026-02-15T00:00:00Z",
|
||||
evidence_task_id: "task-9",
|
||||
};
|
||||
|
||||
const KNOWLEDGE = {
|
||||
id: "src-1",
|
||||
name: "قواعد کدنویسی",
|
||||
kind: "text",
|
||||
project_id: null,
|
||||
created_at: "2026-01-05T00:00:00Z",
|
||||
chunk_count: 4,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.subscriptions = [];
|
||||
mocks.handlers = [];
|
||||
mocks.memoryApi.list.mockResolvedValue({
|
||||
organization_id: "org-1",
|
||||
items: [RULE, LESSON],
|
||||
total: 2,
|
||||
pinned: 1,
|
||||
chars: 180,
|
||||
});
|
||||
mocks.memoryApi.listKnowledge.mockResolvedValue([KNOWLEDGE]);
|
||||
});
|
||||
|
||||
describe("memory page", () => {
|
||||
it("shows rules and lessons on their own tabs", async () => {
|
||||
render(<MemoryPage />);
|
||||
expect(await screen.findByText(RULE.content)).toBeInTheDocument();
|
||||
// The lesson lives on its own tab, not in the rules list.
|
||||
expect(screen.queryByText(LESSON.content)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "درسهای آموخته" }));
|
||||
expect(await screen.findByText(LESSON.content)).toBeInTheDocument();
|
||||
// A reflection-sourced lesson carries its half-confidence in the UI.
|
||||
expect(screen.getByText(/اعتماد ۵۰/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(RULE.content)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("teaches a rule with one sentence and never puts a secret in the payload", async () => {
|
||||
mocks.memoryApi.teach.mockResolvedValue({ ...RULE, id: "m-new", content: "یادداشت تازه" });
|
||||
render(<MemoryPage />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "به سازمان یاد بده" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
|
||||
// A credential pasted into the teach box is a user error the server must
|
||||
// catch; the client's job is to send exactly what was typed and nothing else.
|
||||
fireEvent.change(dialog.querySelector("textarea")!, {
|
||||
target: { value: "توکن CI من ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123 است" },
|
||||
});
|
||||
fireEvent.click(dialog.querySelector('button[type="submit"]')!);
|
||||
|
||||
await waitFor(() => expect(mocks.memoryApi.teach).toHaveBeenCalledTimes(1));
|
||||
const [orgId, payload] = mocks.memoryApi.teach.mock.calls[0];
|
||||
expect(orgId).toBe("org-1");
|
||||
expect(payload.kind).toBe("rule");
|
||||
// Only kind/content are ever sent (no key is invented), and there is no
|
||||
// field a secret could hide in.
|
||||
expect(Object.keys(payload).sort()).toEqual(["content", "kind"]);
|
||||
});
|
||||
|
||||
it("pins a rule and asks for confirmation before deleting one", async () => {
|
||||
mocks.memoryApi.setPinned.mockResolvedValue({ ...RULE, pinned: false });
|
||||
mocks.memoryApi.remove.mockResolvedValue(undefined);
|
||||
render(<MemoryPage />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "برداشتن سنجاق" }));
|
||||
await waitFor(() =>
|
||||
expect(mocks.memoryApi.setPinned).toHaveBeenCalledWith("org-1", "m-rule", false),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "حذف" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "حذف یادداشت" });
|
||||
const confirm = dialog.querySelector('button') as HTMLButtonElement;
|
||||
// The confirm button is the dangerous one; the dialog explains the damage.
|
||||
expect(confirm).toBeDefined();
|
||||
expect(screen.getByText(/از حافظهٔ سازمان برداشته شود/)).toBeInTheDocument();
|
||||
fireEvent.click(dialog.querySelectorAll("button")[1]);
|
||||
await waitFor(() => expect(mocks.memoryApi.remove).toHaveBeenCalledWith("org-1", "m-rule"));
|
||||
});
|
||||
|
||||
it("refreshes when the org channel says memory changed", async () => {
|
||||
render(<MemoryPage />);
|
||||
await screen.findByText(RULE.content);
|
||||
|
||||
// The page subscribes to its own tenant's channel only.
|
||||
expect(mocks.subscriptions.at(-1)).toEqual(["org:org-1"]);
|
||||
const callsBefore = mocks.memoryApi.list.mock.calls.length;
|
||||
|
||||
mocks.handlers.at(-1)!({ type: "memory.changed", data: { kind: "lesson" } });
|
||||
await waitFor(() =>
|
||||
expect(mocks.memoryApi.list.mock.calls.length).toBeGreaterThan(callsBefore),
|
||||
);
|
||||
|
||||
// An unrelated event does not cause a reload.
|
||||
const callsAfter = mocks.memoryApi.list.mock.calls.length;
|
||||
mocks.handlers.at(-1)!({ type: "run.step", data: {} });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(mocks.memoryApi.list.mock.calls.length).toBe(callsAfter);
|
||||
});
|
||||
|
||||
it("keeps the knowledge sources as a second tab", async () => {
|
||||
render(<MemoryPage />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "منابع دانش" }));
|
||||
expect(await screen.findByText(KNOWLEDGE.name)).toBeInTheDocument();
|
||||
// Chunk counts use Persian digits like every other number in the product.
|
||||
expect(screen.getByText("۴")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user