feat(admin/models): delete an added model with inline confirm

Backend: AiModelRepository.delete() + DELETE /api/admin/models/{id} (owner-only,
audited) in the RPC-served admin router; session commits per request.
Frontend: api.adminDeleteModel() (WebSocket RPC DELETE) and a per-row حذف
action with a two-step inline confirm (تأیید حذف / انصراف) that removes the
row from state on success. tsc clean, py_compile clean.
This commit is contained in:
mjalmoudy94
2026-10-13 17:40:16 +03:30
parent f2983afaec
commit d5c9917371
4 changed files with 85 additions and 0 deletions
@@ -281,6 +281,14 @@ class AiModelRepository:
await self.session.flush() await self.session.flush()
return _model_to_domain(row) return _model_to_domain(row)
async def delete(self, model_id: str) -> bool:
row = await self.session.get(AiModelRow, model_id)
if row is None:
return False
await self.session.delete(row)
await self.session.flush()
return True
async def pick_for_mode(self, mode: RoutingMode) -> AiModel | None: async def pick_for_mode(self, mode: RoutingMode) -> AiModel | None:
"""Choose a model for a routing mode. """Choose a model for a routing mode.
@@ -215,6 +215,28 @@ async def create_model(
return _model_out(model) return _model_out(model)
@router.delete("/models/{model_id}", status_code=status.HTTP_200_OK)
async def delete_model(
model_id: str,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> dict:
repo = AiModelRepository(session)
existing = await repo.get(model_id)
if existing is None:
raise NotFoundError("مدلی با این شناسه یافت نشد.")
await repo.delete(model_id)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.MODEL_CHANGE, actor_user_id=owner.id,
target_type="model", target_id=model_id,
metadata={"internal_name": existing.internal_name, "deleted": True},
ip=ip, user_agent=ua,
)
return {"ok": True}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routing policies # Routing policies
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -120,6 +120,9 @@ export default function AdminModelsPage() {
* list updated without hunting for it (the list sits below the form). */ * list updated without hunting for it (the list sits below the form). */
const [justAddedId, setJustAddedId] = React.useState<string | null>(null); const [justAddedId, setJustAddedId] = React.useState<string | null>(null);
const listRef = React.useRef<HTMLDivElement | null>(null); const listRef = React.useRef<HTMLDivElement | null>(null);
/** Inline delete-confirmation: which row is armed, and which is in flight. */
const [confirmId, setConfirmId] = React.useState<string | null>(null);
const [deletingId, setDeletingId] = React.useState<string | null>(null);
const load = React.useCallback(async () => { const load = React.useCallback(async () => {
setLoading(true); setLoading(true);
@@ -272,6 +275,19 @@ export default function AdminModelsPage() {
} }
}; };
const onDelete = async (model: AdminModel) => {
setDeletingId(model.id);
try {
await api.adminDeleteModel(model.id);
setModels((current) => current.filter((m) => m.id !== model.id));
setConfirmId(null);
} catch (err) {
setFormError(err instanceof ApiError ? err.message : "حذف مدل ناموفق بود.");
} finally {
setDeletingId(null);
}
};
const selectedFamily = groupedModels.find((g) => g.base === modelId); const selectedFamily = groupedModels.find((g) => g.base === modelId);
const efforts: ReasoningEffort[] = selectedFamily const efforts: ReasoningEffort[] = selectedFamily
? (selectedFamily.efforts as ReasoningEffort[]) ? (selectedFamily.efforts as ReasoningEffort[])
@@ -443,6 +459,7 @@ export default function AdminModelsPage() {
<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>
<th className="px-2 py-2 text-right font-medium">عملیات</th>
</tr> </tr>
</thead> </thead>
<tbody className="anim-stagger"> <tbody className="anim-stagger">
@@ -487,6 +504,40 @@ export default function AdminModelsPage() {
{model.is_active ? "فعال" : "غیرفعال"} {model.is_active ? "فعال" : "غیرفعال"}
</Badge> </Badge>
</td> </td>
<td className="px-2 py-2">
{confirmId === model.id ? (
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="danger"
loading={deletingId === model.id}
onClick={() => void onDelete(model)}
>
تأیید حذف
</Button>
<Button
type="button"
size="sm"
variant="ghost"
disabled={deletingId === model.id}
onClick={() => setConfirmId(null)}
>
انصراف
</Button>
</div>
) : (
<Button
type="button"
size="sm"
variant="ghost"
className="text-danger hover:bg-danger/10"
onClick={() => setConfirmId(model.id)}
>
حذف
</Button>
)}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
+4
View File
@@ -518,6 +518,10 @@ export const api = {
method: "POST", method: "POST",
body, body,
}), }),
adminDeleteModel: (modelId: string) =>
apiRequest<void>(`/api/admin/models/${encodeURIComponent(modelId)}`, {
method: "DELETE",
}),
adminRoutingPolicies: () => adminRoutingPolicies: () =>
apiRequest<import("@agy/contracts").AdminRoutingPolicy[]>("/api/admin/routing-policies"), apiRequest<import("@agy/contracts").AdminRoutingPolicy[]>("/api/admin/routing-policies"),
adminCreateRoutingPolicy: (body: { adminCreateRoutingPolicy: (body: {