From 24bf0ab18fd5390dde2df003147b9271f8df7993 Mon Sep 17 00:00:00 2001 From: "glm5.2 agent 180" Date: Sat, 20 Jun 2026 15:54:23 +0300 Subject: [PATCH] feat(ai-chat): add reusable agent roles (persona + optional model) Roles are workspace-admin presets that customize the AI agent's system- prompt persona and, optionally, the model, attached to a chat at creation time. Examples: a 'Proofreader' that only touches grammar, a 'Fact- checker' that cites web sources. A role changes ONLY instructions and ( optional ) the model; the toolset stays full, so the security boundary (CASL via the per-user loopback token) is unchanged. Backend: - Migration 20260620T150000-ai-agent-roles: ai_agent_roles table (workspace-scoped, soft-delete, model_config jsonb) + ai_chats.role_id (ON DELETE SET NULL). - AiAgentRoleRepo / AiAgentRolesService / AiAgentRolesController at /workspace/ai-agent-roles. LIST (picker view) is open to all workspace members; create/update/delete are admin-only. The picker view omits instructions and model_config so they never leak to non-admins. - buildSystemPrompt: optional roleInstructions REPLACES the admin persona (priority order: role > admin > default). The non-removable SAFETY_FRAMEWORK is always appended - a role cannot strip it. - AiChatService.stream: persists roleId on first turn; subsequent turns read role_id from the chat row, never from the request body. The role's instructions are applied even if it was later disabled or soft-deleted (existing chats keep their persona). - AiService.getChatModel accepts an optional override. Same-driver overrides reuse the workspace key; cross-driver (openai/gemini) loads alternate creds from ai_provider_credentials and throws a clean 503 if they are missing (no silent fallback). Cross-driver ollama is rejected with a clear message (no per-driver ollama base URL exists yet). - Controller resolves the role model BEFORE res.hijack so misconfigured overrides return JSON 503, not a broken stream. Client: - New chat picker (Mantine Select) lists enabled roles, default 'Universal assistant' (roleId null). The roleId is sent only when starting a new chat; existing chats show the role as a fixed badge. - Role badge in the chat window header and conversation list. - Settings -> AI: new 'Agent roles' management section mirrors the external MCP servers UI (add/edit/delete + enable toggle + optional model override). Form fields: name, emoji, description, instructions, model override (driver + chatModel), with a reminder that the safety framework is always appended. Hardening after review: - Empty-string roleId coerced to null on both client and server (picker 'Universal assistant' option used to crash the uuid INSERT). - New-chat insert validates picker-eligibility (enabled + not soft-deleted + workspace-scoped); ineligible ids silently fall back to null. - findByCreator's role JOIN is workspace-scoped and every column ref is table-qualified (avoids Postgres ambiguous-column errors). - getChatModelForRole applies the same picker-eligibility gate as stream on the new-chat path, so model and persona resolve from one source. --- .../public/locales/en-US/translation.json | 25 ++ .../features/ai-chat/atoms/ai-chat-atom.ts | 9 + .../ai-chat/components/ai-chat-window.tsx | 57 ++++- .../ai-chat/components/chat-thread.tsx | 21 +- .../ai-chat/components/conversation-list.tsx | 9 +- .../features/ai-chat/types/ai-chat.types.ts | 37 +++ .../components/ai-agent-role-form.tsx | 236 ++++++++++++++++++ .../settings/components/ai-agent-roles.tsx | 184 ++++++++++++++ .../components/ai-provider-settings.tsx | 4 + .../workspace/queries/ai-agent-roles-query.ts | 107 ++++++++ .../services/ai-agent-roles-service.ts | 75 ++++++ .../src/core/ai-chat/ai-chat.controller.ts | 10 +- .../server/src/core/ai-chat/ai-chat.module.ts | 11 +- .../server/src/core/ai-chat/ai-chat.prompt.ts | 31 ++- .../src/core/ai-chat/ai-chat.service.ts | 114 +++++++++ .../roles/ai-agent-roles.controller.ts | 109 ++++++++ .../ai-chat/roles/ai-agent-roles.module.ts | 19 ++ .../ai-chat/roles/ai-agent-roles.service.ts | 196 +++++++++++++++ .../ai-chat/roles/dto/agent-role-id.dto.ts | 7 + .../roles/dto/create-agent-role.dto.ts | 68 +++++ .../roles/dto/update-agent-role.dto.ts | 63 +++++ apps/server/src/database/database.module.ts | 3 + .../20260620T150000-ai-agent-roles.ts | 78 ++++++ .../repos/ai-chat/ai-agent-role.repo.ts | 205 +++++++++++++++ .../database/repos/ai-chat/ai-chat.repo.ts | 34 ++- .../database/types/ai-agent-roles.types.ts | 45 ++++ apps/server/src/database/types/db.d.ts | 3 + .../server/src/database/types/db.interface.ts | 2 + .../server/src/database/types/entity.types.ts | 10 + .../integrations/ai/ai-settings.service.ts | 32 +++ apps/server/src/integrations/ai/ai.service.ts | 78 +++++- 31 files changed, 1845 insertions(+), 37 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/client/src/features/workspace/queries/ai-agent-roles-query.ts create mode 100644 apps/client/src/features/workspace/services/ai-agent-roles-service.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-id.dto.ts create mode 100644 apps/server/src/core/ai-chat/roles/dto/create-agent-role.dto.ts create mode 100644 apps/server/src/core/ai-chat/roles/dto/update-agent-role.dto.ts create mode 100644 apps/server/src/database/migrations/20260620T150000-ai-agent-roles.ts create mode 100644 apps/server/src/database/repos/ai-chat/ai-agent-role.repo.ts create mode 100644 apps/server/src/database/types/ai-agent-roles.types.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 21f7c5f7..7a92a236 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -709,6 +709,31 @@ "No tools available": "No tools available", "Created successfully": "Created successfully", "Deleted successfully": "Deleted successfully", + "Agent roles": "Agent roles", + "Personas": "Personas", + "Add role": "Add role", + "Edit role": "Edit role", + "Delete role": "Delete role", + "Role name": "Role name", + "Emoji": "Emoji", + "Optional. Shown next to the role name in the picker.": "Optional. Shown next to the role name in the picker.", + "Description": "Description", + "Optional. Shown in the role picker to help users choose.": "Optional. Shown in the role picker to help users choose.", + "Instructions": "Instructions", + "The agent persona. Replaces the workspace system message for chats bound to this role. A built-in safety framework is always appended.": "The agent persona. Replaces the workspace system message for chats bound to this role. A built-in safety framework is always appended.", + "Model override": "Model override", + "Optional. Use a different model for chats bound to this role.": "Optional. Use a different model for chats bound to this role.", + "Use workspace default": "Use workspace default", + "Custom model": "Custom model", + "Driver": "Driver", + "Defaults to the workspace driver. Credentials for an alternate driver must be set in the provider settings.": "Defaults to the workspace driver. Credentials for an alternate driver must be set in the provider settings.", + "No agent roles configured": "No agent roles configured", + "Reusable agent personas for specialized chats.": "Reusable agent personas for specialized chats.", + "Are you sure you want to delete this role? Existing chats keep their persona.": "Are you sure you want to delete this role? Existing chats keep their persona.", + "Disabled roles are hidden from the picker but existing chats keep using them.": "Disabled roles are hidden from the picker but existing chats keep using them.", + "Universal assistant": "Universal assistant", + "Agent role": "Agent role", + "Default workspace persona": "Default workspace persona", "Clear": "Clear", "Provider": "Provider", "•••• set": "•••• set", 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..9b5c0728 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 @@ -19,3 +19,12 @@ export const aiChatWindowOpenAtom = atom(false); // in ChatInput's local state, that remount would wipe text the user typed while // the agent was still streaming. Reset on deliberate chat switches. export const aiChatDraftAtom = atom(""); + +/** + * The role selected for the NEXT new chat (picker). Bound to the chat only on + * the first turn (sent as `roleId` in the `/ai-chat/stream` body when chatId is + * null); after that the role is fixed on the chat row and this atom is not read + * again for that chat. Reset to null (universal assistant) on "New chat". null + * means the universal assistant (no bound role). + */ +export const selectedRoleForNewChatAtom = atom(null as string | null); 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..f686d5b7 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,12 @@ 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 +30,7 @@ import { activeAiChatIdAtom, aiChatWindowOpenAtom, aiChatDraftAtom, + selectedRoleForNewChatAtom, } from "@/features/ai-chat/atoms/ai-chat-atom.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { extractPageSlugId } from "@/lib"; @@ -33,6 +39,7 @@ import { useAiChatMessagesQuery, useAiChatsQuery, } from "@/features/ai-chat/queries/ai-chat-query.ts"; +import { useAiRolesQuery } from "@/features/workspace/queries/ai-agent-roles-query.ts"; import ConversationList from "@/features/ai-chat/components/conversation-list.tsx"; import ChatThread from "@/features/ai-chat/components/chat-thread.tsx"; import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts"; @@ -102,6 +109,13 @@ export default function AiChatWindow() { const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom); const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom); const setDraft = useSetAtom(aiChatDraftAtom); + const [selectedRoleId, setSelectedRoleId] = useAtom( + selectedRoleForNewChatAtom, + ); + + // Roles for the chat-start picker (any workspace member can list). Loaded + // unconditionally so the picker is ready when the user starts a new chat. + const { data: roles } = useAiRolesQuery(); // History section starts collapsed (matches the former panel's behavior). const [historyOpen, setHistoryOpen] = useState(false); @@ -144,7 +158,8 @@ export default function AiChatWindow() { setActiveChatId(null); setHistoryOpen(false); setDraft(""); - }, [setActiveChatId, setDraft]); + setSelectedRoleId(null); + }, [setActiveChatId, setDraft, setSelectedRoleId]); const selectChat = useCallback( (chatId: string): void => { @@ -342,6 +357,17 @@ export default function AiChatWindow() { style={{ flex: "none" }} /> {t("AI chat")} + {activeChat?.roleName && ( + + + {activeChat.roleEmoji ? `${activeChat.roleEmoji} ` : ""} + {activeChat.roleName} + + + )}
{contextTokens > 0 && ( @@ -432,6 +458,33 @@ export default function AiChatWindow() { )}
+ {/* Role picker for a NEW chat only. The role is bound once at chat + creation; for an existing chat the role is fixed on the chat row and + shown as a badge in the drag bar above. Hidden when no roles exist. */} + {activeChatId === null && roles && roles.length > 0 && ( +
+