From 4fe42ead5627a4c98b028e365c2c6e11262cf5ff Mon Sep 17 00:00:00 2001 From: claude_code Date: Sat, 20 Jun 2026 19:54:36 +0300 Subject: [PATCH] feat(public-share): selectable agent-role identity + fix floating-icon overlap Anonymous public-share AI assistant: - Add a workspace setting `publicShareAssistantRoleId` so an admin can pick which agent role (identity/persona) the anonymous assistant adopts. The role's instructions REPLACE the built-in persona while the immutable safety framework is still always appended; the role's optional model override takes precedence over the cheap publicShareChatModel. Resolved server-authoritatively (workspace-scoped, soft-delete aware; disabled/missing roles fall back to the built-in persona, so the tool scope remains the real security boundary). - Plumb the field through the update DTO, ai-settings service, the workspace.repo ALLOWED whitelist, resolve()/getMasked(), stream-time role resolution and the prompt/model, plus the settings UI: a new "Assistant identity" Select listing enabled roles (and surfacing a saved-but-disabled role explicitly). Public-share branding / floating icon: - Fix the AI assistant FAB overlapping the "Powered by ..." button (both were Affixed bottom-right): stack the FAB above the bottom-right branding. - Rename "Powered by Docmost" -> "Powered by Gitmost" and point the link at the gitmost repo. Tests: extend public-share-chat.spec (role persona replacement still appends the safety framework, resolveShareRole edge cases, model-override precedence). Co-Authored-By: Claude Opus 4.8 --- .../public/locales/en-US/translation.json | 4 + .../share/components/share-ai-widget.tsx | 9 +- .../share/components/share-branding.tsx | 7 +- .../components/ai-provider-settings.tsx | 53 ++++++++++ .../workspace/services/ai-settings-service.ts | 6 ++ .../ai-chat/public-share-chat.controller.ts | 9 +- .../core/ai-chat/public-share-chat.prompt.ts | 32 ++++-- .../core/ai-chat/public-share-chat.service.ts | 52 ++++++++-- .../core/ai-chat/public-share-chat.spec.ts | 99 ++++++++++++++++++- .../repos/workspace/workspace.repo.ts | 2 +- .../integrations/ai/ai-settings.service.ts | 6 ++ apps/server/src/integrations/ai/ai.types.ts | 9 ++ .../ai/dto/update-ai-settings.dto.ts | 6 ++ 13 files changed, 265 insertions(+), 29 deletions(-) diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 314f400b..c04fc72d 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -529,6 +529,7 @@ "Add 2FA method": "Add 2FA method", "Backup codes": "Backup codes", "Disable": "Disable", + "disabled": "disabled", "Invalid verification code": "Invalid verification code", "New backup codes have been generated": "New backup codes have been generated", "Failed to regenerate backup codes": "Failed to regenerate backup codes", @@ -1135,6 +1136,9 @@ "Public assistant model": "Public assistant model", "Defaults to the chat model": "Defaults to the chat model", "Optional cheaper model id for the public assistant. Empty uses the chat model above.": "Optional cheaper model id for the public assistant. Empty uses the chat model above.", + "Assistant identity": "Assistant identity", + "Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.", + "Built-in assistant persona": "Built-in assistant persona", "Minimize": "Minimize", "Current context size": "Current context size", "AI agent": "AI agent", diff --git a/apps/client/src/features/share/components/share-ai-widget.tsx b/apps/client/src/features/share/components/share-ai-widget.tsx index 455d3cea..90d0b9af 100644 --- a/apps/client/src/features/share/components/share-ai-widget.tsx +++ b/apps/client/src/features/share/components/share-ai-widget.tsx @@ -93,7 +93,10 @@ export default function ShareAiWidget({ shareId, pageId }: ShareAiWidgetProps) { if (!open) { return ( - + // Offset 80px from the bottom so the FAB stacks ABOVE the bottom-right + // "Powered by Gitmost" branding button (share-branding.tsx) without + // overlapping it. + + ); diff --git a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx index 82a19b22..e4ff8724 100644 --- a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx +++ b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx @@ -38,6 +38,8 @@ import { IAiSettingsUpdate, SttApiStyle, } from "@/features/workspace/services/ai-settings-service.ts"; +import { useAiRolesQuery } from "@/features/ai-chat/queries/ai-chat-query.ts"; +import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts"; import AiMcpServers from "./ai-mcp-servers.tsx"; // No driver field: every endpoint is OpenAI-compatible, so the form carries only @@ -47,6 +49,9 @@ const formSchema = z.object({ chatModel: z.string(), // Cheap model id for the anonymous public-share assistant; empty = use chatModel. publicShareChatModel: z.string(), + // Agent-role id whose persona the public-share assistant adopts; empty = + // built-in locked persona. + publicShareAssistantRoleId: z.string(), embeddingModel: z.string(), baseUrl: z.string(), // Embedding-specific base URL. Empty means "use the chat base URL". @@ -145,6 +150,10 @@ export default function AiProviderSettings() { const embedTest = useTestAiConnectionMutation(); const sttTest = useTestAiConnectionMutation(); + // Agent roles drive the public-share assistant identity picker. Admin-gated + // (the component returns early for non-admins), same as the AI settings query. + const { data: roles } = useAiRolesQuery(isAdmin); + // Workspace-level feature toggles live in the card headers. const [workspace, setWorkspace] = useAtom(workspaceAtom); const [chatEnabled, setChatEnabled] = useState( @@ -187,6 +196,7 @@ export default function AiProviderSettings() { initialValues: { chatModel: "", publicShareChatModel: "", + publicShareAssistantRoleId: "", embeddingModel: "", baseUrl: "", embeddingBaseUrl: "", @@ -207,6 +217,7 @@ export default function AiProviderSettings() { form.setValues({ chatModel: settings.chatModel ?? "", publicShareChatModel: settings.publicShareChatModel ?? "", + publicShareAssistantRoleId: settings.publicShareAssistantRoleId ?? "", embeddingModel: settings.embeddingModel ?? "", baseUrl: settings.baseUrl ?? "", embeddingBaseUrl: settings.embeddingBaseUrl ?? "", @@ -236,6 +247,9 @@ export default function AiProviderSettings() { // Cheap model id for the anonymous public-share assistant; empty falls // back to chatModel server-side. publicShareChatModel: values.publicShareChatModel, + // Agent-role id whose persona the public-share assistant adopts; empty = + // built-in locked persona server-side. + publicShareAssistantRoleId: values.publicShareAssistantRoleId, embeddingModel: values.embeddingModel, // The embedding base URL is optional; empty falls back to the chat base // URL server-side. @@ -471,6 +485,34 @@ export default function AiProviderSettings() { const monoFont = "ui-monospace, Menlo, monospace"; + // Public-share assistant identity options: a leading "built-in persona" entry + // (empty value, the server default) plus every enabled agent role. If the saved + // role was since disabled it is filtered out of the enabled list, so surface it + // explicitly (labeled "disabled") instead of letting the Select render a blank + // field for a still-stored id. + const selectedRoleId = form.values.publicShareAssistantRoleId; + const enabledRoles = (roles ?? []).filter((r: IAiRole) => r.enabled); + const selectedDisabledRole = + selectedRoleId.length > 0 && + !enabledRoles.some((r: IAiRole) => r.id === selectedRoleId) + ? (roles ?? []).find((r: IAiRole) => r.id === selectedRoleId) + : undefined; + const roleOptions = [ + { value: "", label: t("Built-in assistant persona") }, + ...enabledRoles.map((r: IAiRole) => ({ + value: r.id, + label: r.emoji ? `${r.emoji} ${r.name}` : r.name, + })), + ...(selectedDisabledRole + ? [ + { + value: selectedDisabledRole.id, + label: `${selectedDisabledRole.emoji ? `${selectedDisabledRole.emoji} ` : ""}${selectedDisabledRole.name} (${t("disabled")})`, + }, + ] + : []), + ]; + return ( {/* Section header */} @@ -590,6 +632,17 @@ export default function AiProviderSettings() { "Optional cheaper model id for the public assistant. Empty uses the chat model above.", )} +