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:
claude code agent 227
2026-06-20 06:30:06 +03:00
parent c8af637654
commit 30c3189220
30 changed files with 1674 additions and 40 deletions

View File

@@ -142,10 +142,16 @@ export class AiChatController {
const body = (req.body ?? {}) as AiChatStreamBody;
// Resolve the model BEFORE hijack so an unconfigured provider returns a
// clean JSON 503 (AiNotConfiguredException is a 503 HttpException; letting
// it propagate here yields a normal response, not a broken stream).
const model = await this.aiChatService.getChatModel(workspace.id);
// Resolve the agent role for this turn BEFORE hijack: existing chats read it
// from ai_chats.role_id (authoritative), a new chat from body.roleId. The
// role drives both the persona and the optional model override below.
const role = await this.aiChatService.resolveRoleForRequest(workspace, body);
// Resolve the model (applying the role's optional override) BEFORE hijack so
// an unconfigured provider — including a role pointing at an unconfigured
// driver — returns a clean JSON 503 (AiNotConfiguredException is a 503
// HttpException) instead of breaking mid-stream.
const model = await this.aiChatService.getChatModel(workspace.id, role);
// Abort the agent loop when the client disconnects. `close` also fires on
// normal completion, so only abort when the response has not finished
@@ -173,6 +179,7 @@ export class AiChatController {
res,
signal: controller.signal,
model,
role,
});
} catch (err) {
// Any failure AFTER hijack can no longer send a clean JSON error, so emit