feat(ai-chat): agent roles (admin-defined persona + optional model)
Reusable, workspace-shared agent roles for the built-in AI chat. A role is a named persona (system-prompt instructions) + optional model override; a chat is bound to a role at creation and applies it every turn. Backend: - migration 20260620T120000: ai_agent_roles table + ai_chats.role_id (FK ON DELETE SET NULL); hand-merged types into db.d.ts/entity.types.ts (db.d.ts is hand-curated here, full codegen would clobber it). - core/ai-chat/roles: CRUD module. list = any workspace member; create/ update/delete = admin (Manage Settings ability, like ai-settings/mcp). All repo queries scoped by workspace_id; soft-delete (deleted_at). - buildSystemPrompt gains roleInstructions: role REPLACES the persona base (admin prompt / DEFAULT_PROMPT) but SAFETY_FRAMEWORK + context are always still appended. - stream(): role resolved from ai_chats.role_id for existing chats (never the request body -> no per-turn role swap); body.roleId only on creation. Disabled (enabled=false) and soft-deleted roles fall back to universal. - getChatModel(workspaceId, override): role model_config can swap model id / driver; a driver without configured creds throws 503 with a clear message naming the driver+role, resolved BEFORE response hijack. Client: - new-chat role picker (enabled roles only, default Universal assistant), roleId sent only on the first message; role badge (emoji+name) in the chat header and conversation list; admin Agent-roles management section in Settings -> AI (add/edit/delete, MCP-form pattern). Tests: ai-chat.prompt.spec (role layering + safety always present, incl. jailbreak); ai.service.spec (override on unconfigured driver -> 503). Implements docs/ai-agent-roles-plan.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,15 @@ export const activeAiChatIdAtom = atom(null as string | null);
|
||||
// Whether the floating AI chat window is open. Non-persistent (resets per session).
|
||||
export const aiChatWindowOpenAtom = atom<boolean>(false);
|
||||
|
||||
/**
|
||||
* The agent role selected for the NEXT new chat. `null` = "Universal assistant"
|
||||
* (no role). Consulted ONLY when creating a chat (its first message): the server
|
||||
* persists it to ai_chats.role_id and the role is immutable afterwards. Reset to
|
||||
* null when starting a new chat. It does NOT affect already-created chats.
|
||||
*/
|
||||
// Cast default for the same jotai overload reason as activeAiChatIdAtom above.
|
||||
export const selectedAiRoleIdAtom = atom(null as string | null);
|
||||
|
||||
// The AI chat composer draft (text typed but not yet sent). Held here — OUTSIDE
|
||||
// ChatThread — so it survives the thread remount that happens when a brand-new
|
||||
// chat adopts its freshly created id after the first turn finishes. If it lived
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Group, Loader, Tooltip } from "@mantine/core";
|
||||
import { Group, Loader, Select, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconArrowsDiagonal,
|
||||
IconCheck,
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
selectedAiRoleIdAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
AI_CHATS_RQ_KEY,
|
||||
useAiChatMessagesQuery,
|
||||
useAiChatsQuery,
|
||||
useAiRolesQuery,
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||
@@ -102,6 +104,8 @@ export default function AiChatWindow() {
|
||||
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
|
||||
const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom);
|
||||
const setDraft = useSetAtom(aiChatDraftAtom);
|
||||
// The role chosen for the next new chat (null = universal assistant).
|
||||
const [selectedRoleId, setSelectedRoleId] = useAtom(selectedAiRoleIdAtom);
|
||||
|
||||
// History section starts collapsed (matches the former panel's behavior).
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
@@ -123,6 +127,16 @@ export default function AiChatWindow() {
|
||||
const adoptNewChat = useRef(false);
|
||||
|
||||
const { data: chats } = useAiChatsQuery();
|
||||
// Roles for the new-chat picker (any member may list them). Only fetched while
|
||||
// the window is open.
|
||||
const { data: roles } = useAiRolesQuery(windowOpen);
|
||||
// The new-chat picker only offers ENABLED roles. The list endpoint returns
|
||||
// all live roles (so the admin settings section can manage disabled ones), so
|
||||
// we filter to `enabled` here, client-side, for the composer picker only.
|
||||
const enabledRoles = useMemo(
|
||||
() => (roles ?? []).filter((r) => r.enabled === true),
|
||||
[roles],
|
||||
);
|
||||
const { data: messageRows, isLoading: messagesLoading } =
|
||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||
|
||||
@@ -144,7 +158,9 @@ export default function AiChatWindow() {
|
||||
setActiveChatId(null);
|
||||
setHistoryOpen(false);
|
||||
setDraft("");
|
||||
}, [setActiveChatId, setDraft]);
|
||||
// Default the picker back to "Universal assistant" for the fresh chat.
|
||||
setSelectedRoleId(null);
|
||||
}, [setActiveChatId, setDraft, setSelectedRoleId]);
|
||||
|
||||
const selectChat = useCallback(
|
||||
(chatId: string): void => {
|
||||
@@ -343,6 +359,15 @@ export default function AiChatWindow() {
|
||||
/>
|
||||
<span className={classes.title}>{t("AI chat")}</span>
|
||||
|
||||
{/* Role badge for the active chat (emoji + name). Shown only when the
|
||||
chat is bound to a role that still exists. */}
|
||||
{activeChat?.roleName && (
|
||||
<span className={classes.badge} title={t("Agent role")}>
|
||||
{activeChat.roleEmoji ? `${activeChat.roleEmoji} ` : ""}
|
||||
{activeChat.roleName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
||||
{contextTokens > 0 && (
|
||||
<Tooltip label={t("Current context size")} withArrow>
|
||||
@@ -432,6 +457,29 @@ export default function AiChatWindow() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role picker — only for a NEW chat (before it is created). Once the
|
||||
chat exists, its role is fixed and shown as a header badge instead.
|
||||
Defaults to "Universal assistant" (no role). */}
|
||||
{activeChatId === null && (enabledRoles?.length ?? 0) > 0 && (
|
||||
<div style={{ padding: "4px 8px 0" }}>
|
||||
<Select
|
||||
size="xs"
|
||||
label={t("Agent role")}
|
||||
value={selectedRoleId ?? ""}
|
||||
onChange={(value) => setSelectedRoleId(value || null)}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
data={[
|
||||
{ value: "", label: t("Universal assistant") },
|
||||
...enabledRoles.map((r) => ({
|
||||
value: r.id,
|
||||
label: `${r.emoji ? `${r.emoji} ` : ""}${r.name}`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* body: active chat thread */}
|
||||
<div className={classes.body}>
|
||||
{waitingForHistory ? (
|
||||
@@ -444,6 +492,8 @@ export default function AiChatWindow() {
|
||||
chatId={activeChatId}
|
||||
initialRows={activeChatId ? messageRows : []}
|
||||
openPage={openPage}
|
||||
// Honoured only for a new chat; null = universal assistant.
|
||||
roleId={activeChatId === null ? selectedRoleId : null}
|
||||
onTurnFinished={onTurnFinished}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -25,6 +25,10 @@ interface ChatThreadProps {
|
||||
/** The page currently open in the workspace, or null on a non-page route.
|
||||
* Sent with each turn so the agent knows what "this page" refers to. */
|
||||
openPage?: OpenPageContext | null;
|
||||
/** The agent role selected for a NEW chat (null = universal assistant). Sent
|
||||
* in the request body so the server persists it on chat creation; ignored by
|
||||
* the server for existing chats (the role is read from the chat row). */
|
||||
roleId?: string | null;
|
||||
/** Called when a turn finishes; the parent refreshes the chat list and, for
|
||||
* a new chat, adopts the freshly created chat id. */
|
||||
onTurnFinished: () => void;
|
||||
@@ -61,6 +65,7 @@ export default function ChatThread({
|
||||
chatId,
|
||||
initialRows,
|
||||
openPage,
|
||||
roleId,
|
||||
onTurnFinished,
|
||||
}: ChatThreadProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -84,6 +89,12 @@ export default function ChatThread({
|
||||
const openPageRef = useRef<OpenPageContext | null>(openPage ?? null);
|
||||
openPageRef.current = openPage ?? null;
|
||||
|
||||
// Keep the selected role id in a ref, same rationale as openPageRef. Only the
|
||||
// FIRST request of a brand-new chat uses it (the server persists it then and
|
||||
// ignores it for existing chats), but sending it on every send is harmless.
|
||||
const roleIdRef = useRef<string | null>(roleId ?? null);
|
||||
roleIdRef.current = roleId ?? null;
|
||||
|
||||
// Stable `useChat` store key for the lifetime of THIS mount.
|
||||
//
|
||||
// CRITICAL: `useChat` (@ai-sdk/react) re-creates its internal `Chat` store
|
||||
@@ -119,6 +130,9 @@ export default function ChatThread({
|
||||
...body,
|
||||
chatId: chatIdRef.current,
|
||||
openPage: openPageRef.current,
|
||||
// Honoured by the server only when creating a new chat; null =>
|
||||
// universal assistant.
|
||||
roleId: roleIdRef.current,
|
||||
messages,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -117,9 +117,16 @@ export default function ConversationList({
|
||||
)}
|
||||
onClick={() => onSelect(chat.id)}
|
||||
>
|
||||
<Text size="sm" lineClamp={1} style={{ flex: 1 }}>
|
||||
{chat.title || t("Untitled chat")}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
{chat.roleName && (
|
||||
<Text size="sm" span title={chat.roleName} style={{ flex: "none" }}>
|
||||
{chat.roleEmoji || "🤖"}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm" lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
{chat.title || t("Untitled chat")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
|
||||
@@ -8,18 +8,26 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
createAiRole,
|
||||
deleteAiChat,
|
||||
deleteAiRole,
|
||||
getAiChatMessages,
|
||||
getAiChats,
|
||||
getAiRoles,
|
||||
renameAiChat,
|
||||
updateAiRole,
|
||||
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import {
|
||||
IAiChat,
|
||||
IAiChatMessageRow,
|
||||
IAiRole,
|
||||
IAiRoleCreate,
|
||||
IAiRoleUpdate,
|
||||
} 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"];
|
||||
export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
||||
"ai-chat-messages",
|
||||
chatId,
|
||||
@@ -114,3 +122,79 @@ export function useDeleteAiChatMutation() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List the workspace's agent roles. Available to any workspace member (used by
|
||||
* the chat-creation role picker and the admin management section). `enabled`
|
||||
* lets callers gate the fetch (e.g. only fetch in the settings section).
|
||||
*/
|
||||
export function useAiRolesQuery(enabled: boolean = true) {
|
||||
return useQuery<IAiRole[], Error>({
|
||||
queryKey: AI_ROLES_RQ_KEY,
|
||||
queryFn: () => getAiRoles(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAiRoleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IAiRole, Error, IAiRoleCreate>({
|
||||
mutationFn: (data) => createAiRole(data),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Created successfully") });
|
||||
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAiRoleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IAiRole, Error, IAiRoleUpdate>({
|
||||
mutationFn: (data) => updateAiRole(data),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Updated successfully") });
|
||||
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",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAiRoleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<{ success: true }, Error, string>({
|
||||
mutationFn: (id) => deleteAiRole(id),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Deleted successfully") });
|
||||
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||
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",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
IAiChatListParams,
|
||||
IAiChatMessageRow,
|
||||
IAiChatMessagesParams,
|
||||
IAiRole,
|
||||
IAiRoleCreate,
|
||||
IAiRoleUpdate,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
@@ -46,3 +49,33 @@ export async function renameAiChat(data: {
|
||||
export async function deleteAiChat(chatId: string): Promise<void> {
|
||||
await api.post("/ai-chat/delete", { chatId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent roles API (`/ai-chat/roles`). `list` is available to any workspace
|
||||
* member (for the chat-creation picker); create/update/delete are admin-only
|
||||
* (the server enforces this). Same `{ data }` unwrap convention as above.
|
||||
*/
|
||||
|
||||
/** List the workspace's agent roles. */
|
||||
export async function getAiRoles(): Promise<IAiRole[]> {
|
||||
const req = await api.post<IAiRole[]>("/ai-chat/roles");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Create a role (admin). */
|
||||
export async function createAiRole(data: IAiRoleCreate): Promise<IAiRole> {
|
||||
const req = await api.post<IAiRole>("/ai-chat/roles/create", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Update a role (admin). */
|
||||
export async function updateAiRole(data: IAiRoleUpdate): Promise<IAiRole> {
|
||||
const req = await api.post<IAiRole>("/ai-chat/roles/update", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Soft-delete a role (admin). */
|
||||
export async function deleteAiRole(id: string): Promise<{ success: true }> {
|
||||
const req = await api.post<{ success: true }>("/ai-chat/roles/delete", { id });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,59 @@ export interface IAiChat {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string | null;
|
||||
// The agent role bound to this chat, if any (immutable after creation).
|
||||
roleId?: string | null;
|
||||
// Denormalized via a JOIN in the chat list response (the bound role's badge).
|
||||
// Null when the chat has no role or the role was soft-deleted.
|
||||
roleName?: string | null;
|
||||
roleEmoji?: string | null;
|
||||
}
|
||||
|
||||
/** Supported model drivers (mirrors the server `AI_DRIVERS`). */
|
||||
export type AiRoleDriver = "openai" | "gemini" | "ollama";
|
||||
|
||||
/** Optional per-role model override (mirrors `model_config`). */
|
||||
export interface IAiRoleModelConfig {
|
||||
driver?: AiRoleDriver;
|
||||
chatModel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent role (mirrors the server `AgentRoleView`). A role replaces the
|
||||
* agent's persona (instructions) and may optionally override the model. The
|
||||
* safety framework is always still applied server-side.
|
||||
*/
|
||||
export interface IAiRole {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string | null;
|
||||
description: string | null;
|
||||
instructions: string;
|
||||
modelConfig: IAiRoleModelConfig | null;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Admin create payload for a role. */
|
||||
export interface IAiRoleCreate {
|
||||
name: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
instructions: string;
|
||||
modelConfig?: IAiRoleModelConfig | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/** Admin update payload for a role (partial). */
|
||||
export interface IAiRoleUpdate {
|
||||
id: string;
|
||||
name?: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
instructions?: string;
|
||||
modelConfig?: IAiRoleModelConfig | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useEffect } from "react";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useCreateAiRoleMutation,
|
||||
useUpdateAiRoleMutation,
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import {
|
||||
IAiRole,
|
||||
IAiRoleCreate,
|
||||
IAiRoleUpdate,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// Supported drivers for the optional model override (mirrors server AI_DRIVERS).
|
||||
// "" => use the workspace default driver/model.
|
||||
const DRIVER_OPTIONS = [
|
||||
{ value: "", label: "Workspace default" },
|
||||
{ value: "openai", label: "OpenAI" },
|
||||
{ value: "gemini", label: "Gemini" },
|
||||
{ value: "ollama", label: "Ollama" },
|
||||
];
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
emoji: z.string(),
|
||||
description: z.string(),
|
||||
instructions: z.string().min(1),
|
||||
// "" => no driver override (use the workspace driver).
|
||||
driver: z.enum(["", "openai", "gemini", "ollama"]),
|
||||
chatModel: z.string(),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
interface AiAgentRoleFormProps {
|
||||
// When provided, edits an existing role; otherwise creates one.
|
||||
role?: IAiRole;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AiAgentRoleForm({
|
||||
role,
|
||||
onClose,
|
||||
}: AiAgentRoleFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = Boolean(role);
|
||||
|
||||
const createMutation = useCreateAiRoleMutation();
|
||||
const updateMutation = useUpdateAiRoleMutation();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
name: role?.name ?? "",
|
||||
emoji: role?.emoji ?? "",
|
||||
description: role?.description ?? "",
|
||||
instructions: role?.instructions ?? "",
|
||||
driver: (role?.modelConfig?.driver ?? "") as FormValues["driver"],
|
||||
chatModel: role?.modelConfig?.chatModel ?? "",
|
||||
enabled: role?.enabled ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
// Re-hydrate when the target role changes (reusing the modal).
|
||||
useEffect(() => {
|
||||
form.setValues({
|
||||
name: role?.name ?? "",
|
||||
emoji: role?.emoji ?? "",
|
||||
description: role?.description ?? "",
|
||||
instructions: role?.instructions ?? "",
|
||||
driver: (role?.modelConfig?.driver ?? "") as FormValues["driver"],
|
||||
chatModel: role?.modelConfig?.chatModel ?? "",
|
||||
enabled: role?.enabled ?? true,
|
||||
});
|
||||
form.resetDirty();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [role?.id]);
|
||||
|
||||
// Build the model override payload: null when neither a driver nor a model id
|
||||
// is set (use the workspace default).
|
||||
function resolveModelConfig(values: FormValues) {
|
||||
const driver = values.driver || undefined;
|
||||
const chatModel = values.chatModel.trim() || undefined;
|
||||
if (!driver && !chatModel) return null;
|
||||
return { driver, chatModel };
|
||||
}
|
||||
|
||||
async function handleSubmit(values: FormValues) {
|
||||
const modelConfig = resolveModelConfig(values);
|
||||
|
||||
if (isEdit && role) {
|
||||
const payload: IAiRoleUpdate = {
|
||||
id: role.id,
|
||||
name: values.name,
|
||||
emoji: values.emoji,
|
||||
description: values.description,
|
||||
instructions: values.instructions,
|
||||
modelConfig,
|
||||
enabled: values.enabled,
|
||||
};
|
||||
await updateMutation.mutateAsync(payload);
|
||||
} else {
|
||||
const payload: IAiRoleCreate = {
|
||||
name: values.name,
|
||||
emoji: values.emoji || undefined,
|
||||
description: values.description || undefined,
|
||||
instructions: values.instructions,
|
||||
modelConfig,
|
||||
enabled: values.enabled,
|
||||
};
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}
|
||||
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t("Role name")}
|
||||
placeholder={t("e.g. Proofreader")}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t("Emoji")}
|
||||
description={t("Optional. Shown as the chat badge.")}
|
||||
maxLength={8}
|
||||
{...form.getInputProps("emoji")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t("Description")}
|
||||
description={t("Optional. A short note about what this role does.")}
|
||||
{...form.getInputProps("description")}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={t("Instructions")}
|
||||
description={t(
|
||||
"The built-in safety framework is always added automatically.",
|
||||
)}
|
||||
autosize
|
||||
minRows={4}
|
||||
maxRows={14}
|
||||
{...form.getInputProps("instructions")}
|
||||
/>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label={t("Model provider override")}
|
||||
description={t("Optional. Defaults to the workspace provider.")}
|
||||
data={DRIVER_OPTIONS}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
{...form.getInputProps("driver")}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("Model override")}
|
||||
description={t("Optional. Defaults to the workspace model.")}
|
||||
placeholder={t("e.g. gpt-4o-mini")}
|
||||
{...form.getInputProps("chatModel")}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={-8}>
|
||||
{t(
|
||||
"If you choose a different provider, it must already be configured in AI settings.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Switch
|
||||
label={t("Enabled")}
|
||||
checked={form.values.enabled}
|
||||
onChange={(event) =>
|
||||
form.setFieldValue("enabled", event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button type="button" variant="default" onClick={onClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleSubmit(form.values)}
|
||||
disabled={isSaving || !form.isValid()}
|
||||
loading={isSaving}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import {
|
||||
useAiRolesQuery,
|
||||
useDeleteAiRoleMutation,
|
||||
useUpdateAiRoleMutation,
|
||||
} 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";
|
||||
|
||||
/**
|
||||
* Admin section: list / add / edit / delete reusable agent roles. A role
|
||||
* replaces the agent's persona (instructions) and may optionally override the
|
||||
* model; the safety framework is always still applied. The add/edit form lives
|
||||
* in `AiAgentRoleForm`, opened in a modal.
|
||||
*/
|
||||
export default function AiAgentRoles() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = useUserRole();
|
||||
|
||||
const { data: roles, isLoading } = useAiRolesQuery(isAdmin);
|
||||
const updateMutation = useUpdateAiRoleMutation();
|
||||
const deleteMutation = useDeleteAiRoleMutation();
|
||||
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
// The role being edited; undefined => the modal is in "create" mode.
|
||||
const [editing, setEditing] = useState<IAiRole | undefined>(undefined);
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Only workspace admins can manage AI provider settings.")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setEditing(undefined);
|
||||
open();
|
||||
}
|
||||
|
||||
function openEdit(role: IAiRole) {
|
||||
setEditing(role);
|
||||
open();
|
||||
}
|
||||
|
||||
function confirmDelete(role: IAiRole) {
|
||||
modals.openConfirmModal({
|
||||
title: t("Delete role"),
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t("Are you sure you want to delete this role?")}
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => deleteMutation.mutate(role.id),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<Box
|
||||
w={9}
|
||||
h={9}
|
||||
bg="green.6"
|
||||
style={{ borderRadius: "50%", flex: "none" }}
|
||||
/>
|
||||
<Text fw={600}>{t("Agent roles")}</Text>
|
||||
</Group>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={openCreate}
|
||||
>
|
||||
{t("Add role")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{!isLoading && (!roles || roles.length === 0) && (
|
||||
<Text size="sm" c="dimmed" mt="sm">
|
||||
{t("No roles configured")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Stack gap="xs" mt="sm">
|
||||
{roles?.map((role) => (
|
||||
<Group key={role.id} justify="space-between" wrap="nowrap">
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} truncate>
|
||||
{role.emoji ? `${role.emoji} ` : ""}
|
||||
{role.name}
|
||||
</Text>
|
||||
{role.modelConfig?.chatModel && (
|
||||
<Badge size="xs" variant="light">
|
||||
{role.modelConfig.chatModel}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{role.description && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{role.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={role.enabled}
|
||||
aria-label={t("Enabled")}
|
||||
onChange={(event) =>
|
||||
updateMutation.mutate({
|
||||
id: role.id,
|
||||
enabled: event.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
aria-label={t("Edit")}
|
||||
onClick={() => openEdit(role)}
|
||||
>
|
||||
<IconPencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t("Delete")}
|
||||
onClick={() => confirmDelete(role)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={editing ? t("Edit role") : t("Add role")}
|
||||
size="lg"
|
||||
>
|
||||
{/* Remount the form per target so its internal state re-hydrates. */}
|
||||
<AiAgentRoleForm key={editing?.id ?? "new"} role={editing} onClose={close} />
|
||||
</Modal>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||
import McpSettings from "@/features/workspace/components/settings/components/mcp-settings.tsx";
|
||||
import AiProviderSettings from "@/features/workspace/components/settings/components/ai-provider-settings.tsx";
|
||||
import AiAgentRoles from "@/features/workspace/components/settings/components/ai-agent-roles.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getAppName } from "@/lib/config.ts";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
@@ -20,6 +21,13 @@ export default function AiSettings() {
|
||||
<SettingsTitle title={t("AI")} />
|
||||
{isAdmin && <AiProviderSettings />}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Divider my="lg" />
|
||||
<AiAgentRoles />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<McpSettings />
|
||||
|
||||
Reference in New Issue
Block a user