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 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-20 19:54:36 +03:00
parent 46688074d8
commit 4fe42ead56
13 changed files with 265 additions and 29 deletions

View File

@@ -93,7 +93,10 @@ export default function ShareAiWidget({ shareId, pageId }: ShareAiWidgetProps) {
if (!open) {
return (
<Affix position={{ bottom: 20, right: 20 }}>
// Offset 80px from the bottom so the FAB stacks ABOVE the bottom-right
// "Powered by Gitmost" branding button (share-branding.tsx) without
// overlapping it.
<Affix position={{ bottom: 80, right: 20 }}>
<Tooltip label={t("Ask AI")} position="left">
<ActionIcon
size="xl"
@@ -110,7 +113,7 @@ export default function ShareAiWidget({ shareId, pageId }: ShareAiWidgetProps) {
}
return (
<Affix position={{ bottom: 20, right: 20 }}>
<Affix position={{ bottom: 80, right: 20 }}>
<Paper
shadow="md"
radius="md"
@@ -119,7 +122,7 @@ export default function ShareAiWidget({ shareId, pageId }: ShareAiWidgetProps) {
width: 360,
maxWidth: "calc(100vw - 40px)",
height: 480,
maxHeight: "calc(100vh - 40px)",
maxHeight: "calc(100vh - 100px)",
display: "flex",
flexDirection: "column",
}}

View File

@@ -2,14 +2,17 @@ import { Affix, Button } from "@mantine/core";
export default function ShareBranding() {
return (
// Pinned to the bottom-RIGHT corner. The AI assistant FAB
// (share-ai-widget.tsx) is stacked ABOVE this with a higher `bottom`
// offset, so the two Affix elements never overlap.
<Affix position={{ bottom: 20, right: 20 }}>
<Button
variant="default"
component="a"
target="_blank"
href="https://docmost.com?ref=public-share"
href="https://github.com/vvzvlad/gitmost?ref=public-share"
>
Powered by Docmost
Powered by Gitmost
</Button>
</Affix>
);

View File

@@ -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<boolean>(
@@ -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 (
<Stack mt="sm">
{/* Section header */}
@@ -590,6 +632,17 @@ export default function AiProviderSettings() {
"Optional cheaper model id for the public assistant. Empty uses the chat model above.",
)}
</Text>
<Select
mt="sm"
label={t("Assistant identity")}
description={t(
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.",
)}
data={roleOptions}
allowDeselect={false}
disabled={isLoading || !publicShareAssistantEnabled}
{...form.getInputProps("publicShareAssistantRoleId")}
/>
<Group mt="md" align="center">
<Button

View File

@@ -18,6 +18,9 @@ export interface IAiSettings {
chatModel?: string;
// Cheap model id for the anonymous public-share assistant; empty = chatModel.
publicShareChatModel?: string;
// Agent-role id whose persona the public-share assistant adopts; empty =
// built-in locked persona.
publicShareAssistantRoleId?: string;
embeddingModel?: string;
baseUrl?: string;
embeddingBaseUrl?: string;
@@ -45,6 +48,9 @@ export interface IAiSettingsUpdate {
driver?: AiDriver;
chatModel?: string;
publicShareChatModel?: string;
// Agent-role id whose persona the public-share assistant adopts; empty =
// built-in locked persona.
publicShareAssistantRoleId?: string;
embeddingModel?: string;
baseUrl?: string;
embeddingBaseUrl?: string;