Merge pull request 'feat(ai-roles): импортируемый мультиязычный каталог ролей агента' (#222) from feature/agent-roles-catalog into develop
Reviewed-on: #222
This commit was merged in pull request #222.
This commit is contained in:
@@ -1346,5 +1346,22 @@
|
||||
"Could not generate a title": "Could not generate a title",
|
||||
"AI title generation is disabled": "AI title generation is disabled",
|
||||
"AI is not configured": "AI is not configured",
|
||||
"Too many requests, please try again later": "Too many requests, please try again later"
|
||||
"Too many requests, please try again later": "Too many requests, please try again later",
|
||||
"Import from catalog": "Import from catalog",
|
||||
"Browse the catalog": "Browse the catalog",
|
||||
"Role catalog": "Role catalog",
|
||||
"On name conflict": "On name conflict",
|
||||
"Skip": "Skip",
|
||||
"Import": "Import",
|
||||
"Installed": "Installed",
|
||||
"v{{from}} → v{{to}}": "v{{from}} → v{{to}}",
|
||||
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}": "Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}",
|
||||
"Failed to import {{count}} role(s)": "Failed to import {{count}} role(s)",
|
||||
"The role catalog is unavailable": "The role catalog is unavailable",
|
||||
"Please try again later.": "Please try again later.",
|
||||
"No bundles available": "No bundles available",
|
||||
"Already up to date": "Already up to date",
|
||||
"Updated to the latest version": "Updated to the latest version",
|
||||
"This role is no longer in the catalog": "This role is no longer in the catalog",
|
||||
"This language is no longer available in the catalog": "This language is no longer available in the catalog"
|
||||
}
|
||||
|
||||
@@ -1203,5 +1203,23 @@
|
||||
"Could not generate a title": "Не удалось придумать название",
|
||||
"AI title generation is disabled": "Генерация названий через AI отключена",
|
||||
"AI is not configured": "AI не настроен",
|
||||
"Too many requests, please try again later": "Слишком много запросов, попробуйте позже"
|
||||
"Too many requests, please try again later": "Слишком много запросов, попробуйте позже",
|
||||
"Import from catalog": "Импорт из каталога",
|
||||
"Browse the catalog": "Открыть каталог",
|
||||
"Role catalog": "Каталог ролей",
|
||||
"On name conflict": "При конфликте имён",
|
||||
"Skip": "Пропустить",
|
||||
"Import": "Импортировать",
|
||||
"Installed": "Установлено",
|
||||
"v{{from}} → v{{to}}": "v{{from}} → v{{to}}",
|
||||
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}": "Импортировано: {{created}}, переименовано: {{renamed}}, пропущено: {{skipped}}",
|
||||
"Failed to import {{count}} role(s)": "Не удалось импортировать ролей: {{count}}",
|
||||
"The role catalog is unavailable": "Каталог ролей недоступен",
|
||||
"Please try again later.": "Попробуйте позже.",
|
||||
"No bundles available": "Наборы недоступны",
|
||||
"No roles configured": "Роли не настроены",
|
||||
"Already up to date": "Уже актуальна",
|
||||
"Updated to the latest version": "Обновлено до последней версии",
|
||||
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
|
||||
"This language is no longer available in the catalog": "Этот язык больше не доступен в каталоге"
|
||||
}
|
||||
|
||||
@@ -13,21 +13,40 @@ import {
|
||||
deleteAiRole,
|
||||
getAiChatMessages,
|
||||
getAiChats,
|
||||
getAiRoleCatalog,
|
||||
getAiRoleCatalogBundle,
|
||||
getAiRoles,
|
||||
importAiRolesFromCatalog,
|
||||
renameAiChat,
|
||||
updateAiRole,
|
||||
updateAiRoleFromCatalog,
|
||||
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import {
|
||||
IAiChat,
|
||||
IAiChatMessageRow,
|
||||
IAiRole,
|
||||
IAiRoleCatalog,
|
||||
IAiRoleCatalogBundle,
|
||||
IAiRoleCreate,
|
||||
IAiRoleImportPayload,
|
||||
IAiRoleImportResult,
|
||||
IAiRoleUpdate,
|
||||
IAiRoleUpdateFromCatalogResult,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
export const AI_CHATS_RQ_KEY = ["ai-chats"];
|
||||
export const AI_ROLES_RQ_KEY = ["ai-roles"];
|
||||
// Catalog reads resolve bundle names per language, so the language is part of
|
||||
// the cache key (a language switch refetches rather than reusing stale names).
|
||||
export const AI_ROLE_CATALOG_RQ_KEY = (language: string) => [
|
||||
"ai-role-catalog",
|
||||
language,
|
||||
];
|
||||
export const AI_ROLE_CATALOG_BUNDLE_RQ_KEY = (
|
||||
bundleId: string,
|
||||
language: string,
|
||||
) => ["ai-role-catalog-bundle", bundleId, language];
|
||||
export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
||||
"ai-chat-messages",
|
||||
chatId,
|
||||
@@ -223,3 +242,109 @@ export function useDeleteAiRoleMutation() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse the role catalog for a language. Gated by `enabled` so the (admin-only)
|
||||
* fetch runs only when the catalog modal is open. The catalog can 502 when the
|
||||
* curated source is unreachable; callers handle the error state in the UI.
|
||||
*/
|
||||
export function useAiRoleCatalogQuery(language: string, enabled: boolean) {
|
||||
return useQuery<IAiRoleCatalog, Error>({
|
||||
queryKey: AI_ROLE_CATALOG_RQ_KEY(language),
|
||||
queryFn: () => getAiRoleCatalog(language),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one catalog bundle (role content + versions). Gated by `enabled` so the
|
||||
* fetch only runs when a bundle is actually expanded.
|
||||
*/
|
||||
export function useAiRoleCatalogBundleQuery(
|
||||
bundleId: string,
|
||||
language: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useQuery<IAiRoleCatalogBundle, Error>({
|
||||
queryKey: AI_ROLE_CATALOG_BUNDLE_RQ_KEY(bundleId, language),
|
||||
queryFn: () => getAiRoleCatalogBundle(bundleId, language),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useImportAiRolesFromCatalogMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IAiRoleImportResult, Error, IAiRoleImportPayload>({
|
||||
mutationFn: (payload) => importAiRolesFromCatalog(payload),
|
||||
onSuccess: (result) => {
|
||||
notifications.show({
|
||||
message: t("Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}", {
|
||||
created: result.created,
|
||||
renamed: result.renamed,
|
||||
skipped: result.skipped,
|
||||
}),
|
||||
});
|
||||
// Surface partial failures (e.g. unique-name races) as a red warning.
|
||||
if (result.errors.length > 0) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: t("Failed to import {{count}} role(s)", {
|
||||
count: result.errors.length,
|
||||
}),
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||
// Imported roles can appear in the chat picker / badges.
|
||||
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAiRoleFromCatalogMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IAiRoleUpdateFromCatalogResult, Error, string>({
|
||||
mutationFn: (id) => updateAiRoleFromCatalog(id),
|
||||
onSuccess: (result) => {
|
||||
// The server returns updated:false with a reason for a no-op (already
|
||||
// up to date / removed from catalog / language no longer offered). Map
|
||||
// each reason to a specific message instead of a generic "up to date".
|
||||
// Narrow the discriminated union via `"reason" in result` (the `updated`
|
||||
// boolean discriminant does not narrow under this project's
|
||||
// strictNullChecks:false). Inside the branch, `reason` is the typed literal
|
||||
// union, so the comparisons below are compiler-checked.
|
||||
let message: string;
|
||||
if (!("reason" in result)) {
|
||||
message = t("Updated to the latest version");
|
||||
} else if (result.reason === "not-in-catalog") {
|
||||
message = t("This role is no longer in the catalog");
|
||||
} else if (result.reason === "language-unavailable") {
|
||||
message = t("This language is no longer available in the catalog");
|
||||
} else {
|
||||
// "up-to-date" (the only remaining reason).
|
||||
message = t("Already up to date");
|
||||
}
|
||||
notifications.show({ message });
|
||||
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||
// The role badge denormalized onto the chat list may have changed.
|
||||
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { IAiRoleImportResult } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// `useImportAiRolesFromCatalogMutation` always shows an Imported/renamed/skipped
|
||||
// summary, and ADDITIONALLY a red "Failed to import N role(s)" notification when
|
||||
// the result carries partial errors. These tests pin both branches via
|
||||
// renderHook with a mocked service (twin precedent:
|
||||
// update-from-catalog-message.test.tsx).
|
||||
|
||||
const notificationsShowMock = vi.fn();
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
|
||||
}));
|
||||
|
||||
// `t` echoes the key with interpolated values so we assert against the exact
|
||||
// English message strings (mirrors react-i18next's default interpolation).
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars
|
||||
? key.replace(/\{\{(\w+)\}\}/g, (_m, name) => String(vars[name]))
|
||||
: key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||
importAiRolesFromCatalog: vi.fn(),
|
||||
// Other named exports referenced by ai-chat-query.ts must exist on the mock so
|
||||
// the module import resolves; they are unused by these tests.
|
||||
createAiRole: vi.fn(),
|
||||
deleteAiChat: vi.fn(),
|
||||
deleteAiRole: vi.fn(),
|
||||
getAiChatMessages: vi.fn(),
|
||||
getAiChats: vi.fn(),
|
||||
getAiRoleCatalog: vi.fn(),
|
||||
getAiRoleCatalogBundle: vi.fn(),
|
||||
getAiRoles: vi.fn(),
|
||||
renameAiChat: vi.fn(),
|
||||
updateAiRole: vi.fn(),
|
||||
updateAiRoleFromCatalog: vi.fn(),
|
||||
}));
|
||||
|
||||
import { importAiRolesFromCatalog } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useImportAiRolesFromCatalogMutation } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
async function runMutation(result: IAiRoleImportResult) {
|
||||
vi.mocked(importAiRolesFromCatalog).mockResolvedValue(result);
|
||||
const { result: hook } = renderHook(
|
||||
() => useImportAiRolesFromCatalogMutation(),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
hook.current.mutate({
|
||||
bundleId: "general",
|
||||
language: "en",
|
||||
conflict: "rename",
|
||||
});
|
||||
await waitFor(() => expect(hook.current.isSuccess).toBe(true));
|
||||
}
|
||||
|
||||
describe("useImportAiRolesFromCatalogMutation — success notifications", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
|
||||
await runMutation({ created: 3, renamed: 1, skipped: 2, errors: [] });
|
||||
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||
message: "Imported 3, renamed 1, skipped 2",
|
||||
});
|
||||
});
|
||||
|
||||
it("errors.length > 0 -> summary PLUS the red failure notification", async () => {
|
||||
await runMutation({
|
||||
created: 1,
|
||||
renamed: 0,
|
||||
skipped: 0,
|
||||
errors: [
|
||||
{ slug: "a", message: "name taken" },
|
||||
{ slug: "b", message: "name taken" },
|
||||
],
|
||||
});
|
||||
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
|
||||
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
|
||||
message: "Imported 1, renamed 0, skipped 0",
|
||||
});
|
||||
expect(notificationsShowMock).toHaveBeenNthCalledWith(2, {
|
||||
color: "red",
|
||||
message: "Failed to import 2 role(s)",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { IAiRoleUpdateFromCatalogResult } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// `useUpdateAiRoleFromCatalogMutation` maps the server's discriminated result to
|
||||
// a user-facing notification message. These tests pin each of the four branches
|
||||
// (updated / not-in-catalog / language-unavailable / up-to-date) via renderHook
|
||||
// with a mocked service (precedent: share-query.null-normalization.test.tsx).
|
||||
|
||||
const notificationsShowMock = vi.fn();
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
|
||||
}));
|
||||
|
||||
// `t` echoes the key so we assert against the exact English message strings.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||
updateAiRoleFromCatalog: vi.fn(),
|
||||
// Other named exports referenced by ai-chat-query.ts must exist on the mock so
|
||||
// the module import resolves; they are unused by these tests.
|
||||
createAiRole: vi.fn(),
|
||||
deleteAiChat: vi.fn(),
|
||||
deleteAiRole: vi.fn(),
|
||||
getAiChatMessages: vi.fn(),
|
||||
getAiChats: vi.fn(),
|
||||
getAiRoleCatalog: vi.fn(),
|
||||
getAiRoleCatalogBundle: vi.fn(),
|
||||
getAiRoles: vi.fn(),
|
||||
importAiRolesFromCatalog: vi.fn(),
|
||||
renameAiChat: vi.fn(),
|
||||
updateAiRole: vi.fn(),
|
||||
}));
|
||||
|
||||
import { updateAiRoleFromCatalog } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useUpdateAiRoleFromCatalogMutation } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
async function runMutation(result: IAiRoleUpdateFromCatalogResult) {
|
||||
vi.mocked(updateAiRoleFromCatalog).mockResolvedValue(result);
|
||||
const { result: hook } = renderHook(
|
||||
() => useUpdateAiRoleFromCatalogMutation(),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
hook.current.mutate("role-1");
|
||||
await waitFor(() => expect(hook.current.isSuccess).toBe(true));
|
||||
}
|
||||
|
||||
describe("useUpdateAiRoleFromCatalogMutation — reason → message", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("updated:true -> 'Updated to the latest version'", async () => {
|
||||
await runMutation({
|
||||
updated: true,
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
role: { id: "role-1" } as never,
|
||||
});
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||
message: "Updated to the latest version",
|
||||
});
|
||||
});
|
||||
|
||||
it("not-in-catalog -> 'This role is no longer in the catalog'", async () => {
|
||||
await runMutation({ updated: false, reason: "not-in-catalog" });
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||
message: "This role is no longer in the catalog",
|
||||
});
|
||||
});
|
||||
|
||||
it("language-unavailable -> 'This language is no longer available in the catalog'", async () => {
|
||||
await runMutation({ updated: false, reason: "language-unavailable" });
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||
message: "This language is no longer available in the catalog",
|
||||
});
|
||||
});
|
||||
|
||||
it("up-to-date -> 'Already up to date'", async () => {
|
||||
await runMutation({ updated: false, reason: "up-to-date" });
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||
message: "Already up to date",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,13 @@ import {
|
||||
IAiChatMessageRow,
|
||||
IAiChatMessagesParams,
|
||||
IAiRole,
|
||||
IAiRoleCatalog,
|
||||
IAiRoleCatalogBundle,
|
||||
IAiRoleCreate,
|
||||
IAiRoleImportPayload,
|
||||
IAiRoleImportResult,
|
||||
IAiRoleUpdate,
|
||||
IAiRoleUpdateFromCatalogResult,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
@@ -112,3 +117,54 @@ export async function deleteAiRole(id: string): Promise<{ success: true }> {
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Role catalog API (`/ai-chat/roles/*`, admin-only — the server enforces this).
|
||||
* Browse a curated catalog, import roles/bundles into the workspace, and update
|
||||
* an imported role when the catalog ships a newer version. Same `{ data }`
|
||||
* unwrap convention as above.
|
||||
*/
|
||||
|
||||
/** Browse the catalog, optionally localized to `language`. */
|
||||
export async function getAiRoleCatalog(
|
||||
language?: string,
|
||||
): Promise<IAiRoleCatalog> {
|
||||
const req = await api.post<IAiRoleCatalog>("/ai-chat/roles/catalog", {
|
||||
language,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Open one catalog bundle in a language (role content + versions). */
|
||||
export async function getAiRoleCatalogBundle(
|
||||
bundleId: string,
|
||||
language: string,
|
||||
): Promise<IAiRoleCatalogBundle> {
|
||||
const req = await api.post<IAiRoleCatalogBundle>(
|
||||
"/ai-chat/roles/catalog/bundle",
|
||||
{ bundleId, language },
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Import roles from a catalog bundle into the workspace (admin). */
|
||||
export async function importAiRolesFromCatalog(
|
||||
payload: IAiRoleImportPayload,
|
||||
): Promise<IAiRoleImportResult> {
|
||||
const req = await api.post<IAiRoleImportResult>(
|
||||
"/ai-chat/roles/import",
|
||||
payload,
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Update an already-imported role from its catalog source (admin). */
|
||||
export async function updateAiRoleFromCatalog(
|
||||
id: string,
|
||||
): Promise<IAiRoleUpdateFromCatalogResult> {
|
||||
const req = await api.post<IAiRoleUpdateFromCatalogResult>(
|
||||
"/ai-chat/roles/update-from-catalog",
|
||||
{ id },
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@@ -57,10 +57,79 @@ export interface IAiRole {
|
||||
autoStart: boolean;
|
||||
// Custom auto-start text; null/empty => the default launch message is sent.
|
||||
launchMessage: string | null;
|
||||
// Catalog origin of an imported role, or null for a manually-created one.
|
||||
// Admin-only (present only in the admin list view); the picker view omits it.
|
||||
// The admin UI compares `version` against the catalog to offer an update.
|
||||
source?: { slug: string; language: string; version: number } | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/** One bundle's summary in the catalog index (mirrors `getCatalog().bundles[]`). */
|
||||
export interface IAiRoleCatalogBundleSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
languages: string[];
|
||||
roles: { slug: string; version: number }[];
|
||||
}
|
||||
|
||||
/** The browsable catalog index (mirrors `getCatalog()`). */
|
||||
export interface IAiRoleCatalog {
|
||||
languages: string[];
|
||||
bundles: IAiRoleCatalogBundleSummary[];
|
||||
}
|
||||
|
||||
/** A single role inside an opened catalog bundle (localized content + version). */
|
||||
export interface IAiRoleCatalogRole {
|
||||
slug: string;
|
||||
emoji: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string;
|
||||
autoStart: boolean;
|
||||
launchMessage: string | null;
|
||||
version: number;
|
||||
}
|
||||
|
||||
/** An opened catalog bundle (mirrors `getCatalogBundle()`). */
|
||||
export interface IAiRoleCatalogBundle {
|
||||
bundleId: string;
|
||||
language: string;
|
||||
roles: IAiRoleCatalogRole[];
|
||||
}
|
||||
|
||||
/** Import payload (mirrors the server `ImportFromCatalogDto`). */
|
||||
export interface IAiRoleImportPayload {
|
||||
bundleId: string;
|
||||
language: string;
|
||||
// Omitted => import the whole bundle; otherwise only these slugs.
|
||||
slugs?: string[];
|
||||
conflict: "skip" | "rename";
|
||||
}
|
||||
|
||||
/** Import result counts (mirrors `importFromCatalog()`). */
|
||||
export interface IAiRoleImportResult {
|
||||
created: number;
|
||||
skipped: number;
|
||||
renamed: number;
|
||||
errors: { slug: string; message: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update-from-catalog result (mirrors the server `updateFromCatalog()`). A
|
||||
* discriminated union on `updated`: a no-op carries a typed `reason` the UI maps
|
||||
* to a specific message; a successful update carries the version bump + new role.
|
||||
* Keeping the union (not a widened `reason?: string`) lets the consumer's literal
|
||||
* comparisons be compiler-checked.
|
||||
*/
|
||||
export type IAiRoleUpdateFromCatalogResult =
|
||||
| {
|
||||
updated: false;
|
||||
reason: "not-in-catalog" | "up-to-date" | "language-unavailable";
|
||||
}
|
||||
| { updated: true; fromVersion: number; toVersion: number; role: IAiRole };
|
||||
|
||||
/** Admin create payload for a role. */
|
||||
export interface IAiRoleCreate {
|
||||
name: string;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { catalogRoleInstallState } from "./catalog-role-install-state.ts";
|
||||
import type { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// Build a workspace role with a catalog source. Fields irrelevant to the
|
||||
// install-state decision are filled with harmless defaults.
|
||||
function installedRole(
|
||||
source: { slug: string; language: string; version: number },
|
||||
overrides: Partial<IAiRole> = {},
|
||||
): IAiRole {
|
||||
return {
|
||||
id: `role-${source.slug}-${source.language}`,
|
||||
name: source.slug,
|
||||
emoji: null,
|
||||
description: null,
|
||||
enabled: true,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
source,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const catalogRole = { slug: "writer", version: 3 };
|
||||
|
||||
// Mirrors the role-launch.ts precedent: the modal's role-state computation is a
|
||||
// pure function so the import/installed/update decision is testable directly.
|
||||
describe("catalogRoleInstallState", () => {
|
||||
it("no matching installed role -> import", () => {
|
||||
const result = catalogRoleInstallState(catalogRole, [], "en");
|
||||
expect(result).toEqual({ state: "import" });
|
||||
});
|
||||
|
||||
it("same slug + language, installed version > catalog -> installed", () => {
|
||||
const installed = installedRole({
|
||||
slug: "writer",
|
||||
language: "en",
|
||||
version: 5,
|
||||
});
|
||||
const result = catalogRoleInstallState(catalogRole, [installed], "en");
|
||||
expect(result).toEqual({ state: "installed", installed });
|
||||
});
|
||||
|
||||
it("same slug + language, installed version == catalog -> installed", () => {
|
||||
const installed = installedRole({
|
||||
slug: "writer",
|
||||
language: "en",
|
||||
version: 3,
|
||||
});
|
||||
const result = catalogRoleInstallState(catalogRole, [installed], "en");
|
||||
expect(result).toEqual({ state: "installed", installed });
|
||||
});
|
||||
|
||||
it("same slug + language, installed version < catalog -> update (from/to)", () => {
|
||||
const installed = installedRole({
|
||||
slug: "writer",
|
||||
language: "en",
|
||||
version: 1,
|
||||
});
|
||||
const result = catalogRoleInstallState(catalogRole, [installed], "en");
|
||||
expect(result).toEqual({
|
||||
state: "update",
|
||||
installed,
|
||||
fromVersion: 1,
|
||||
toVersion: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("same slug but DIFFERENT language -> import (a separate install)", () => {
|
||||
// 'writer' is installed in 'ru'; browsing the 'en' catalog must offer it as a
|
||||
// fresh import, not treat the ru copy as already installed.
|
||||
const installed = installedRole({
|
||||
slug: "writer",
|
||||
language: "ru",
|
||||
version: 5,
|
||||
});
|
||||
const result = catalogRoleInstallState(catalogRole, [installed], "en");
|
||||
expect(result).toEqual({ state: "import" });
|
||||
});
|
||||
|
||||
it("matches the right language when the same slug is installed in several", () => {
|
||||
const ru = installedRole(
|
||||
{ slug: "writer", language: "ru", version: 5 },
|
||||
{ id: "ru-role" },
|
||||
);
|
||||
const en = installedRole(
|
||||
{ slug: "writer", language: "en", version: 1 },
|
||||
{ id: "en-role" },
|
||||
);
|
||||
const result = catalogRoleInstallState(catalogRole, [ru, en], "en");
|
||||
expect(result).toEqual({
|
||||
state: "update",
|
||||
installed: en,
|
||||
fromVersion: 1,
|
||||
toVersion: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores manually-created roles (no source) sharing the name", () => {
|
||||
const manual = installedRole(
|
||||
{ slug: "writer", language: "en", version: 9 },
|
||||
{ source: null },
|
||||
);
|
||||
const result = catalogRoleInstallState(catalogRole, [manual], "en");
|
||||
expect(result).toEqual({ state: "import" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type {
|
||||
IAiRole,
|
||||
IAiRoleCatalogRole,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
* The install state of a single catalog role relative to the workspace's
|
||||
* existing roles. Extracted as a pure function so the catalog modal's role-state
|
||||
* computation is unit-testable without mounting the component (mirrors the
|
||||
* `roleLaunchMessage` precedent in role-launch.ts).
|
||||
*
|
||||
* A catalog role is matched to an installed role by BOTH `source.slug` and
|
||||
* `source.language`: the same slug in a different language is a separate install
|
||||
* (so it shows as "import", not "installed"). When matched, the installed source
|
||||
* version decides the state:
|
||||
* - no match -> "import"
|
||||
* - matched & installed version >= catalog version -> "installed"
|
||||
* - matched & installed version < catalog version -> "update" (from -> to)
|
||||
*/
|
||||
export type CatalogRoleInstallState =
|
||||
| { state: "import" }
|
||||
| { state: "installed"; installed: IAiRole }
|
||||
| {
|
||||
state: "update";
|
||||
installed: IAiRole;
|
||||
fromVersion: number;
|
||||
toVersion: number;
|
||||
};
|
||||
|
||||
export function catalogRoleInstallState(
|
||||
role: Pick<IAiRoleCatalogRole, "slug" | "version">,
|
||||
workspaceRoles: IAiRole[],
|
||||
language: string,
|
||||
): CatalogRoleInstallState {
|
||||
const installed = workspaceRoles.find(
|
||||
(r) => r.source?.slug === role.slug && r.source?.language === language,
|
||||
);
|
||||
if (!installed) return { state: "import" };
|
||||
const fromVersion = installed.source?.version ?? 0;
|
||||
if (fromVersion >= role.version) {
|
||||
return { state: "installed", installed };
|
||||
}
|
||||
return {
|
||||
state: "update",
|
||||
installed,
|
||||
fromVersion,
|
||||
toVersion: role.version,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Checkbox,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Radio,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useAiRoleCatalogBundleQuery,
|
||||
useAiRoleCatalogQuery,
|
||||
useImportAiRolesFromCatalogMutation,
|
||||
useUpdateAiRoleFromCatalogMutation,
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import {
|
||||
IAiRole,
|
||||
IAiRoleCatalogBundleSummary,
|
||||
IAiRoleCatalogRole,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { catalogRoleInstallState } from "@/features/ai-chat/utils/catalog-role-install-state.ts";
|
||||
|
||||
interface AiAgentRolesCatalogModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
// The current admin role list (full view, including `source`). Used to compute
|
||||
// each catalog role's install state (import / installed / update available).
|
||||
roles: IAiRole[];
|
||||
}
|
||||
|
||||
/** How a name collision with an existing role is handled on import. */
|
||||
type Conflict = "skip" | "rename";
|
||||
|
||||
/**
|
||||
* Admin modal: browse the curated role catalog, import roles, and update an
|
||||
* imported role when the catalog ships a newer version.
|
||||
*
|
||||
* Import is per-bundle (the endpoint takes a single bundleId). Each bundle's
|
||||
* Accordion panel has its own "Import" button that imports only that bundle's
|
||||
* checked roles — the simplest mapping to the one-bundle-per-call API and the
|
||||
* clearest UX. Selection state is tracked per bundle.
|
||||
*/
|
||||
export default function AiAgentRolesCatalogModal({
|
||||
opened,
|
||||
onClose,
|
||||
roles,
|
||||
}: AiAgentRolesCatalogModalProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
// The user's i18n base subtag (e.g. "ru-RU" => "ru"); the preferred catalog
|
||||
// language both when seeding and when reconciling against offered languages.
|
||||
const baseLang = (i18n.language || "en").split("-")[0].toLowerCase();
|
||||
|
||||
// Fetch the catalog only while the modal is open. `language` drives both the
|
||||
// catalog query (bundle names) and bundle reads (role content). Seed it
|
||||
// synchronously from the base subtag so the first fetch already uses the
|
||||
// user's language; the effect below still reconciles against the catalog's
|
||||
// offered languages once they load.
|
||||
const [language, setLanguage] = useState<string>(() => baseLang);
|
||||
const catalogQuery = useAiRoleCatalogQuery(language || "en", opened);
|
||||
|
||||
// On name conflict: Skip (default) or Rename to a free " (N)" name.
|
||||
const [conflict, setConflict] = useState<Conflict>("skip");
|
||||
|
||||
// The currently expanded bundle id (Accordion is single-open: one bundle's
|
||||
// roles are fetched at a time).
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
// Per-bundle selected slugs (import-state roles checked for import).
|
||||
const [selected, setSelected] = useState<Record<string, Set<string>>>({});
|
||||
|
||||
const languages = catalogQuery.data?.languages;
|
||||
|
||||
// Pick a sensible default language from the catalog once it loads: the i18n
|
||||
// base subtag (e.g. "ru-RU" => "ru") if offered, else "en", else the first.
|
||||
useEffect(() => {
|
||||
if (!languages || languages.length === 0) return;
|
||||
if (language && languages.includes(language)) return;
|
||||
const preferred = languages.includes(baseLang)
|
||||
? baseLang
|
||||
: languages.includes("en")
|
||||
? "en"
|
||||
: languages[0];
|
||||
setLanguage(preferred);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [languages]);
|
||||
|
||||
// Reset per-language UI state when the language changes (the bundle content,
|
||||
// hence the install computations, are language-specific).
|
||||
useEffect(() => {
|
||||
setExpanded(null);
|
||||
setSelected({});
|
||||
}, [language]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("Role catalog")}
|
||||
size="lg"
|
||||
>
|
||||
<Stack>
|
||||
<Select
|
||||
label={t("Language")}
|
||||
data={languages ?? []}
|
||||
value={language || null}
|
||||
onChange={(value) => value && setLanguage(value)}
|
||||
allowDeselect={false}
|
||||
disabled={!languages || languages.length === 0}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
|
||||
<Radio.Group
|
||||
label={t("On name conflict")}
|
||||
value={conflict}
|
||||
onChange={(value) => setConflict(value as Conflict)}
|
||||
>
|
||||
<Group mt="xs">
|
||||
<Radio value="skip" label={t("Skip")} />
|
||||
<Radio value="rename" label={t("Rename")} />
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
|
||||
{catalogQuery.isLoading && (
|
||||
<Center py="lg">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{catalogQuery.isError && (
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
title={t("The role catalog is unavailable")}
|
||||
>
|
||||
{t("Please try again later.")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{catalogQuery.data && catalogQuery.data.bundles.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("No bundles available")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{catalogQuery.data && catalogQuery.data.bundles.length > 0 && (
|
||||
<Accordion
|
||||
variant="separated"
|
||||
value={expanded}
|
||||
onChange={setExpanded}
|
||||
>
|
||||
{catalogQuery.data.bundles.map((bundle) => (
|
||||
<BundlePanel
|
||||
key={bundle.id}
|
||||
bundle={bundle}
|
||||
language={language}
|
||||
expanded={expanded === bundle.id}
|
||||
roles={roles}
|
||||
conflict={conflict}
|
||||
selected={selected[bundle.id]}
|
||||
onToggleSlug={(slug, checked) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev[bundle.id] ?? []);
|
||||
if (checked) next.add(slug);
|
||||
else next.delete(slug);
|
||||
return { ...prev, [bundle.id]: next };
|
||||
})
|
||||
}
|
||||
onSetSelected={(slugs) =>
|
||||
setSelected((prev) => ({
|
||||
...prev,
|
||||
[bundle.id]: new Set(slugs),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t("Close")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface BundlePanelProps {
|
||||
bundle: IAiRoleCatalogBundleSummary;
|
||||
language: string;
|
||||
expanded: boolean;
|
||||
roles: IAiRole[];
|
||||
conflict: Conflict;
|
||||
selected: Set<string> | undefined;
|
||||
onToggleSlug: (slug: string, checked: boolean) => void;
|
||||
onSetSelected: (slugs: string[]) => void;
|
||||
}
|
||||
|
||||
/** One catalog bundle: its roles (fetched when expanded) + a per-bundle import. */
|
||||
function BundlePanel({
|
||||
bundle,
|
||||
language,
|
||||
expanded,
|
||||
roles,
|
||||
conflict,
|
||||
selected,
|
||||
onToggleSlug,
|
||||
onSetSelected,
|
||||
}: BundlePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Only fetch this bundle's roles once it is actually expanded.
|
||||
const bundleQuery = useAiRoleCatalogBundleQuery(
|
||||
bundle.id,
|
||||
language,
|
||||
expanded && !!language,
|
||||
);
|
||||
|
||||
const importMutation = useImportAiRolesFromCatalogMutation();
|
||||
const updateMutation = useUpdateAiRoleFromCatalogMutation();
|
||||
|
||||
// Compute each catalog role's install state against the current workspace
|
||||
// roles (matched by source.slug + source.language). The decision lives in the
|
||||
// pure `catalogRoleInstallState` helper so it is unit-tested directly.
|
||||
const computed = useMemo(() => {
|
||||
const list = bundleQuery.data?.roles ?? [];
|
||||
return list.map((role) => ({
|
||||
role,
|
||||
...catalogRoleInstallState(role, roles, language),
|
||||
}));
|
||||
}, [bundleQuery.data, roles, language]);
|
||||
|
||||
// Default-check every importable role once the bundle content arrives (unless
|
||||
// the user already touched the selection for this bundle).
|
||||
useEffect(() => {
|
||||
if (!bundleQuery.data || selected !== undefined) return;
|
||||
onSetSelected(
|
||||
computed.filter((c) => c.state === "import").map((c) => c.role.slug),
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bundleQuery.data]);
|
||||
|
||||
const importableSlugs = computed
|
||||
.filter((c) => c.state === "import")
|
||||
.map((c) => c.role.slug);
|
||||
const checkedSlugs = importableSlugs.filter((slug) => selected?.has(slug));
|
||||
|
||||
function handleImport() {
|
||||
importMutation.mutate({
|
||||
bundleId: bundle.id,
|
||||
language,
|
||||
slugs: checkedSlugs,
|
||||
conflict,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion.Item value={bundle.id}>
|
||||
<Accordion.Control>
|
||||
<Stack gap={2}>
|
||||
<Text fw={500}>{bundle.name}</Text>
|
||||
{bundle.description && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{bundle.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{bundleQuery.isLoading && (
|
||||
<Center py="md">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{bundleQuery.isError && (
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
title={t("The role catalog is unavailable")}
|
||||
>
|
||||
{t("Please try again later.")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{bundleQuery.data && (
|
||||
<Stack gap="xs">
|
||||
{computed.map((entry) => (
|
||||
<CatalogRoleRow
|
||||
key={entry.role.slug}
|
||||
role={entry.role}
|
||||
state={entry.state}
|
||||
checked={
|
||||
entry.state === "import"
|
||||
? !!selected?.has(entry.role.slug)
|
||||
: false
|
||||
}
|
||||
onToggle={(checked) => onToggleSlug(entry.role.slug, checked)}
|
||||
fromVersion={
|
||||
entry.state === "update" ? entry.fromVersion : undefined
|
||||
}
|
||||
onUpdate={
|
||||
entry.state === "update"
|
||||
? () => updateMutation.mutate(entry.installed.id)
|
||||
: undefined
|
||||
}
|
||||
updating={updateMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={handleImport}
|
||||
loading={importMutation.isPending}
|
||||
disabled={checkedSlugs.length === 0}
|
||||
>
|
||||
{t("Import")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
}
|
||||
|
||||
interface CatalogRoleRowProps {
|
||||
role: IAiRoleCatalogRole;
|
||||
state: "import" | "installed" | "update";
|
||||
checked: boolean;
|
||||
onToggle: (checked: boolean) => void;
|
||||
// The installed role's current source version (only set in the "update" state).
|
||||
fromVersion?: number;
|
||||
onUpdate?: () => void;
|
||||
updating: boolean;
|
||||
}
|
||||
|
||||
/** A single catalog role row with its install-state affordance. */
|
||||
function CatalogRoleRow({
|
||||
role,
|
||||
state,
|
||||
checked,
|
||||
onToggle,
|
||||
fromVersion,
|
||||
onUpdate,
|
||||
updating,
|
||||
}: CatalogRoleRowProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Group gap="xs" wrap="nowrap" align="flex-start" style={{ minWidth: 0 }}>
|
||||
{state === "import" && (
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={(event) => onToggle(event.currentTarget.checked)}
|
||||
aria-label={role.name}
|
||||
/>
|
||||
)}
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Text fw={500} truncate>
|
||||
{role.emoji ? `${role.emoji} ` : ""}
|
||||
{role.name}
|
||||
</Text>
|
||||
{role.description && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{role.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap" style={{ flex: "none" }}>
|
||||
{state === "installed" && (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{t("Installed")}
|
||||
</Badge>
|
||||
)}
|
||||
{state === "update" && (
|
||||
<>
|
||||
<Badge size="sm" variant="light" color="blue">
|
||||
{t("v{{from}} → v{{to}}", {
|
||||
from: fromVersion ?? 0,
|
||||
to: role.version,
|
||||
})}
|
||||
</Badge>
|
||||
<Button size="xs" variant="light" onClick={onUpdate} loading={updating}>
|
||||
{t("Update")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import {
|
||||
IconPackageImport,
|
||||
IconPencil,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import {
|
||||
@@ -23,6 +28,7 @@ import {
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import AiAgentRoleForm from "./ai-agent-role-form.tsx";
|
||||
import AiAgentRolesCatalogModal from "./ai-agent-roles-catalog-modal.tsx";
|
||||
|
||||
/**
|
||||
* Admin section: list / add / edit / delete reusable agent roles. A role
|
||||
@@ -39,6 +45,9 @@ export default function AiAgentRoles() {
|
||||
const deleteMutation = useDeleteAiRoleMutation();
|
||||
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
// Separate disclosure for the catalog (import/update) modal.
|
||||
const [catalogOpened, { open: openCatalog, close: closeCatalog }] =
|
||||
useDisclosure(false);
|
||||
// The role being edited; undefined => the modal is in "create" mode.
|
||||
const [editing, setEditing] = useState<IAiRole | undefined>(undefined);
|
||||
|
||||
@@ -86,14 +95,24 @@ export default function AiAgentRoles() {
|
||||
/>
|
||||
<Text fw={600}>{t("Agent roles")}</Text>
|
||||
</Group>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={openCreate}
|
||||
>
|
||||
{t("Add role")}
|
||||
</Button>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
leftSection={<IconPackageImport size={16} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={openCatalog}
|
||||
>
|
||||
{t("Import from catalog")}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={openCreate}
|
||||
>
|
||||
{t("Add role")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
@@ -102,9 +121,19 @@ export default function AiAgentRoles() {
|
||||
</Text>
|
||||
|
||||
{!isLoading && (!roles || roles.length === 0) && (
|
||||
<Text size="sm" c="dimmed" mt="sm">
|
||||
{t("No roles configured")}
|
||||
</Text>
|
||||
<Group gap="sm" mt="sm" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("No roles configured")}
|
||||
</Text>
|
||||
<Button
|
||||
leftSection={<IconPackageImport size={16} />}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={openCatalog}
|
||||
>
|
||||
{t("Browse the catalog")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Stack gap="xs" mt="sm">
|
||||
@@ -170,6 +199,12 @@ export default function AiAgentRoles() {
|
||||
{/* Remount the form per target so its internal state re-hydrates. */}
|
||||
<AiAgentRoleForm key={editing?.id ?? "new"} role={editing} onClose={close} />
|
||||
</Modal>
|
||||
|
||||
<AiAgentRolesCatalogModal
|
||||
opened={catalogOpened}
|
||||
onClose={closeCatalog}
|
||||
roles={roles ?? []}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user