From 30c31892202d03719ce6cdc7c4e4e97d1cc8e077 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sat, 20 Jun 2026 06:30:06 +0300 Subject: [PATCH 1/5] 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 --- .../public/locales/en-US/translation.json | 23 +- .../features/ai-chat/atoms/ai-chat-atom.ts | 9 + .../ai-chat/components/ai-chat-window.tsx | 54 ++++- .../ai-chat/components/chat-thread.tsx | 14 ++ .../ai-chat/components/conversation-list.tsx | 13 +- .../features/ai-chat/queries/ai-chat-query.ts | 84 +++++++ .../ai-chat/services/ai-chat-service.ts | 33 +++ .../features/ai-chat/types/ai-chat.types.ts | 53 +++++ .../components/ai-agent-role-form.tsx | 209 ++++++++++++++++++ .../settings/components/ai-agent-roles.tsx | 175 +++++++++++++++ .../pages/settings/workspace/ai-settings.tsx | 8 + .../src/core/ai-chat/ai-chat.controller.ts | 15 +- .../server/src/core/ai-chat/ai-chat.module.ts | 9 +- .../src/core/ai-chat/ai-chat.prompt.spec.ts | 59 +++++ .../server/src/core/ai-chat/ai-chat.prompt.ts | 20 +- .../src/core/ai-chat/ai-chat.service.ts | 75 ++++++- .../roles/ai-agent-roles.controller.ts | 101 +++++++++ .../ai-chat/roles/ai-agent-roles.module.ts | 16 ++ .../ai-chat/roles/ai-agent-roles.service.ts | 151 +++++++++++++ .../core/ai-chat/roles/dto/agent-role.dto.ts | 92 ++++++++ .../core/ai-chat/roles/role-model-config.ts | 39 ++++ apps/server/src/database/database.module.ts | 3 + .../20260620T120000-ai-agent-roles.ts | 70 ++++++ .../ai-agent-roles/ai-agent-roles.repo.ts | 141 ++++++++++++ .../database/repos/ai-chat/ai-chat.repo.ts | 24 +- apps/server/src/database/types/db.d.ts | 28 +++ .../server/src/database/types/entity.types.ts | 8 + .../ai/ai-not-configured.exception.ts | 4 +- .../src/integrations/ai/ai.service.spec.ts | 87 ++++++++ apps/server/src/integrations/ai/ai.service.ts | 97 ++++++-- 30 files changed, 1674 insertions(+), 40 deletions(-) create mode 100644 apps/client/src/features/workspace/components/settings/components/ai-agent-role-form.tsx create mode 100644 apps/client/src/features/workspace/components/settings/components/ai-agent-roles.tsx create mode 100644 apps/server/src/core/ai-chat/ai-chat.prompt.spec.ts create mode 100644 apps/server/src/core/ai-chat/roles/ai-agent-roles.controller.ts create mode 100644 apps/server/src/core/ai-chat/roles/ai-agent-roles.module.ts create mode 100644 apps/server/src/core/ai-chat/roles/ai-agent-roles.service.ts create mode 100644 apps/server/src/core/ai-chat/roles/dto/agent-role.dto.ts create mode 100644 apps/server/src/core/ai-chat/roles/role-model-config.ts create mode 100644 apps/server/src/database/migrations/20260620T120000-ai-agent-roles.ts create mode 100644 apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.ts create mode 100644 apps/server/src/integrations/ai/ai.service.spec.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 21f7c5f7..3ebed63d 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1195,5 +1195,26 @@ "Request format": "Request format", "How transcription requests are sent to the endpoint": "How transcription requests are sent to the endpoint", "OpenAI-compatible (multipart/form-data)": "OpenAI-compatible (multipart/form-data)", - "OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, base64 audio)" + "OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, base64 audio)", + "Agent role": "Agent role", + "Universal assistant": "Universal assistant", + "Add role": "Add role", + "Edit role": "Edit role", + "Role name": "Role name", + "e.g. Proofreader": "e.g. Proofreader", + "Optional. Shown as the chat badge.": "Optional. Shown as the chat badge.", + "Optional. A short note about what this role does.": "Optional. A short note about what this role does.", + "Instructions": "Instructions", + "The built-in safety framework is always added automatically.": "The built-in safety framework is always added automatically.", + "Model provider override": "Model provider override", + "Optional. Defaults to the workspace provider.": "Optional. Defaults to the workspace provider.", + "Model override": "Model override", + "Optional. Defaults to the workspace model.": "Optional. Defaults to the workspace model.", + "e.g. gpt-4o-mini": "e.g. gpt-4o-mini", + "If you choose a different provider, it must already be configured in AI settings.": "If you choose a different provider, it must already be configured in AI settings.", + "Agent roles": "Agent roles", + "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.", + "No roles configured": "No roles configured", + "Delete role": "Delete role", + "Are you sure you want to delete this role?": "Are you sure you want to delete this role?" } diff --git a/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts b/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts index b3707cb9..027a8c50 100644 --- a/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts +++ b/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts @@ -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(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 diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx index 122f80ff..854e5021 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx @@ -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() { /> {t("AI chat")} + {/* Role badge for the active chat (emoji + name). Shown only when the + chat is bound to a role that still exists. */} + {activeChat?.roleName && ( + + {activeChat.roleEmoji ? `${activeChat.roleEmoji} ` : ""} + {activeChat.roleName} + + )} +
{contextTokens > 0 && ( @@ -432,6 +457,29 @@ export default function AiChatWindow() { )}
+ {/* 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 && ( +
+