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

@@ -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",

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;

View File

@@ -12,7 +12,7 @@ import {
} from '@nestjs/common';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
import { FastifyReply, FastifyRequest } from 'fastify';
import { Workspace } from '@docmost/db/types/entity.types';
import { Workspace, AiAgentRole } from '@docmost/db/types/entity.types';
import { Public } from '../../common/decorators/public.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
@@ -146,10 +146,14 @@ export class PublicShareChatController {
// yields a clean 503 (AiNotConfiguredException) BEFORE hijack. Only
// attempt this once the earlier gates passed, to avoid leaking timing.
let model: Awaited<ReturnType<PublicShareChatService['getShareChatModel']>> | undefined;
// Admin-selected identity (agent role) for the anonymous assistant, resolved
// server-authoritatively. null = built-in locked persona.
let role: AiAgentRole | null = null;
let providerConfigured = false;
if (assistantEnabled && shareUsable && pageInShare) {
try {
model = await this.publicShareChat.getShareChatModel(workspace.id);
role = await this.publicShareChat.resolveShareRole(workspace.id);
model = await this.publicShareChat.getShareChatModel(workspace.id, role);
providerConfigured = true;
} catch (err) {
if (err instanceof AiNotConfiguredException) {
@@ -235,6 +239,7 @@ export class PublicShareChatController {
res,
signal: controller.signal,
model: model!,
role,
});
} catch (err) {
// After hijack we can no longer send a clean JSON error.

View File

@@ -4,9 +4,10 @@
* This is a separate, locked-down persona from the authenticated agent
* (`ai-chat.prompt.ts`). The caller is an unauthenticated visitor of a public
* share, so the assistant is strictly read-only and scoped to the published
* share tree. There is no admin-configurable text here — the persona and the
* safety block are both immutable, because the security boundary is the tool
* scope (the share tree), not any per-request input.
* share tree. An admin MAY select an agent role whose `instructions` REPLACE the
* built-in PERSONA, but the SAFETY_FRAMEWORK is immutable and is ALWAYS still
* appended — the security boundary remains the tool scope (the share tree), not
* any persona text or other per-request input.
*/
/**
@@ -50,6 +51,12 @@ export interface BuildShareSystemPromptInput {
* reads via the share-scoped tools, which reject pages outside the share.
*/
openedPage?: { id?: string; title?: string } | null;
/**
* When an admin-selected agent role is active, its instructions REPLACE the
* built-in PERSONA; the SAFETY_FRAMEWORK is always still appended. Empty/null
* = keep the built-in locked persona.
*/
roleInstructions?: string | null;
}
const PERSONA = [
@@ -64,13 +71,17 @@ const PERSONA = [
].join(' ');
/**
* Compose the locked system prompt for the public-share assistant: an immutable
* persona, optional context (share title + opened page), then ALWAYS the
* non-removable safety framework. There is no admin override path.
* Compose the system prompt for the public-share assistant: a persona, optional
* context (share title + opened page), then ALWAYS the non-removable safety
* framework. The persona defaults to the built-in locked PERSONA, but an
* admin-selected agent role's `roleInstructions` may REPLACE it; either way the
* SAFETY_FRAMEWORK is immutable and always appended, and the tool scope (the
* share tree) remains the real security boundary.
*/
export function buildShareSystemPrompt({
share,
openedPage,
roleInstructions,
}: BuildShareSystemPromptInput): string {
let context = '';
@@ -91,5 +102,12 @@ export function buildShareSystemPrompt({
context += `\nThe reader is currently viewing the page "${title}" (pageId: ${pageId.trim()}). When they refer to "this page" or "the current page", use that pageId with the read tool.`;
}
return `${PERSONA}${context}\n${SAFETY_FRAMEWORK}`;
// An admin-selected role's instructions replace the built-in persona; the
// safety framework below is still always appended.
const persona =
typeof roleInstructions === 'string' && roleInstructions.trim().length > 0
? roleInstructions.trim()
: PERSONA;
return `${persona}${context}\n${SAFETY_FRAMEWORK}`;
}

View File

@@ -8,10 +8,13 @@ import {
type LanguageModel,
} from 'ai';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
import { AiAgentRole } from '@docmost/db/types/entity.types';
import { AiService } from '../../integrations/ai/ai.service';
import { AiSettingsService } from '../../integrations/ai/ai-settings.service';
import { PublicShareChatToolsService } from './tools/public-share-chat-tools.service';
import { buildShareSystemPrompt } from './public-share-chat.prompt';
import { roleModelOverride } from './roles/role-model-config';
import {
PublicShareWorkspaceLimiter,
createPublicShareWorkspaceLimiter,
@@ -47,6 +50,9 @@ export interface PublicShareChatStreamArgs {
// Resolved by the controller BEFORE res.hijack() so an unconfigured provider
// (AiNotConfiguredException -> 503) surfaces as clean JSON before streaming.
model: LanguageModel;
// Pre-resolved by the controller; its instructions replace the locked persona,
// while the safety framework is still always appended. null = built-in persona.
role: AiAgentRole | null;
}
/**
@@ -103,6 +109,7 @@ export class PublicShareChatService {
private readonly aiSettings: AiSettingsService,
private readonly tools: PublicShareChatToolsService,
redisService: RedisService,
private readonly aiAgentRoleRepo: AiAgentRoleRepo,
) {
this.workspaceLimiter = createPublicShareWorkspaceLimiter(redisService);
}
@@ -117,16 +124,41 @@ export class PublicShareChatService {
}
/**
* Resolve the public-share chat model BEFORE res.hijack() (clean 503 path).
* Uses the cheap `publicShareChatModel`, falling back to the workspace
* `chatModel` when unset.
*
* IMPORTANT: this override substitutes ONLY the model id. The driver, baseUrl
* and apiKey are reused from the workspace's main chat provider (see
* AiService.getChatModel) — the "cheap model" is NOT an isolated provider or
* key, just a different model on the SAME configured provider.
* Resolve the admin-selected agent role for the anonymous public-share
* assistant, scoped to the workspace and soft-delete aware. Returns null when
* no role is configured, or when the referenced role is missing or disabled —
* in which case the built-in locked persona applies. Mirrors the authenticated
* chat's server-authoritative role resolution.
*/
async getShareChatModel(workspaceId: string): Promise<LanguageModel> {
async resolveShareRole(workspaceId: string): Promise<AiAgentRole | null> {
const resolved = await this.aiSettings.resolve(workspaceId);
const roleId = resolved?.publicShareAssistantRoleId;
if (!roleId) return null;
const role = await this.aiAgentRoleRepo.findById(roleId, workspaceId);
if (!role || !role.enabled) return null;
return role;
}
/**
* Resolve the public-share chat model BEFORE res.hijack() (clean 503 path).
* An admin-selected role's model override takes precedence over the cheap
* `publicShareChatModel`; without a role override it uses the cheap
* `publicShareChatModel`, falling back to the workspace `chatModel` when unset.
*
* IMPORTANT: a model override substitutes ONLY the model id (unless the role
* also switches the driver). The baseUrl and apiKey are reused from the
* workspace's main chat provider (see AiService.getChatModel) — the "cheap
* model" is NOT an isolated provider or key, just a different model on the SAME
* configured provider.
*/
async getShareChatModel(
workspaceId: string,
role?: AiAgentRole | null,
): Promise<LanguageModel> {
const override = roleModelOverride(role);
if (override) {
return this.ai.getChatModel(workspaceId, override);
}
const resolved = await this.aiSettings.resolve(workspaceId);
return this.ai.getChatModel(workspaceId, {
chatModel: resolved?.publicShareChatModel,
@@ -142,6 +174,7 @@ export class PublicShareChatService {
res,
signal,
model,
role,
}: PublicShareChatStreamArgs): Promise<void> {
// Rebuild the conversation from the client payload. The client holds the
// transcript (ephemeral, never stored). Trusting it is safe: the share
@@ -153,6 +186,7 @@ export class PublicShareChatService {
const system = buildShareSystemPrompt({
share: { sharedPageTitle: share.sharedPage?.title ?? null },
openedPage,
roleInstructions: role?.instructions ?? null,
});
// Tiny, READ-only, in-process toolset hard-scoped to THIS share tree.

View File

@@ -182,25 +182,55 @@ describe('buildShareSystemPrompt locking', () => {
// Anti prompt-injection clause is present.
expect(prompt).toContain('anti prompt-injection');
});
it('a selected role REPLACES the persona but still appends the safety framework', () => {
const prompt = buildShareSystemPrompt({
share: null,
openedPage: null,
roleInstructions: 'You are Captain Docs.',
});
// The role's persona replaces the built-in one...
expect(prompt).toContain('Captain Docs');
// ...but the immutable safety clauses are still appended.
expect(prompt).toContain('read-only assistant');
expect(prompt).toContain('anti prompt-injection');
});
});
describe('PublicShareChatService model fallback', () => {
function makeService(resolvePublicModel: string | undefined) {
// `role` (optional) drives both the resolved settings (its id is returned as
// publicShareAssistantRoleId) and the role repo's findById mock, so the same
// helper exercises the no-role fallback AND the role-override paths.
function makeService(
resolvePublicModel: string | undefined,
role?: {
id: string;
name: string;
enabled: boolean;
instructions?: string;
modelConfig?: Record<string, unknown> | null;
},
) {
const aiSettings = {
resolve: jest
.fn()
.mockResolvedValue({ publicShareChatModel: resolvePublicModel }),
resolve: jest.fn().mockResolvedValue({
publicShareChatModel: resolvePublicModel,
publicShareAssistantRoleId: role ? role.id : undefined,
}),
};
const getChatModel = jest.fn().mockResolvedValue('MODEL');
const ai = { getChatModel };
const aiAgentRoleRepo = {
findById: jest.fn().mockResolvedValue(role ?? undefined),
};
const redisService = { getOrThrow: () => new FakeRedis() } as never;
const service = new PublicShareChatService(
ai as never,
aiSettings as never,
{} as never,
redisService,
aiAgentRoleRepo as never,
);
return { service, getChatModel };
return { service, getChatModel, aiAgentRoleRepo };
}
it('passes the cheap publicShareChatModel as the override', async () => {
@@ -216,6 +246,64 @@ describe('PublicShareChatService model fallback', () => {
await service.getShareChatModel('ws-1');
expect(getChatModel).toHaveBeenCalledWith('ws-1', { chatModel: undefined });
});
describe('resolveShareRole', () => {
it('returns null when no roleId is configured', async () => {
const { service } = makeService('cheap-model');
expect(await service.resolveShareRole('ws-1')).toBeNull();
});
it('returns null when the configured role is disabled', async () => {
const { service } = makeService('cheap-model', {
id: 'r-1',
name: 'R',
enabled: false,
});
expect(await service.resolveShareRole('ws-1')).toBeNull();
});
it('returns null when findById resolves undefined (missing/soft-deleted)', async () => {
const { service, aiAgentRoleRepo } = makeService('cheap-model', {
id: 'r-1',
name: 'R',
enabled: true,
});
// The settings point at r-1, but the repo can no longer find it.
aiAgentRoleRepo.findById.mockResolvedValue(undefined);
expect(await service.resolveShareRole('ws-1')).toBeNull();
});
it('returns the role when it exists and is enabled', async () => {
const role = { id: 'r-1', name: 'R', enabled: true };
const { service } = makeService('cheap-model', role);
expect(await service.resolveShareRole('ws-1')).toEqual(role);
});
});
describe('getShareChatModel with a role', () => {
it('applies the role model override (takes precedence over the cheap model)', async () => {
const role = {
id: 'r-1',
name: 'R',
enabled: true,
modelConfig: { chatModel: 'role-model' },
};
const { service, getChatModel } = makeService('cheap-model', role);
await service.getShareChatModel('ws-1', role as never);
expect(getChatModel).toHaveBeenCalledWith(
'ws-1',
expect.objectContaining({ chatModel: 'role-model', roleName: 'R' }),
);
});
it('falls back to the publicShareChatModel override when role is null', async () => {
const { service, getChatModel } = makeService('cheap-model');
await service.getShareChatModel('ws-1', null);
expect(getChatModel).toHaveBeenCalledWith('ws-1', {
chatModel: 'cheap-model',
});
});
});
});
describe('PublicShareWorkspaceLimiter (cluster-wide sliding-window per-workspace cap)', () => {
@@ -315,6 +403,7 @@ describe('PublicShareChatService.tryConsumeWorkspaceQuota', () => {
{} as never,
{} as never,
redisService,
{} as never,
);
// The default cap is high, so a couple of calls are allowed; this asserts
// the service exposes the async limiter contour the controller relies on.

View File

@@ -239,7 +239,7 @@ export class WorkspaceRepo {
// is a real jsonb object, never a double-encoded string. The CASE self-heals
// workspaces whose settings.ai.provider was previously corrupted into an
// array/string.
const ALLOWED = ['driver', 'chatModel', 'embeddingModel', 'baseUrl', 'embeddingBaseUrl', 'sttModel', 'sttBaseUrl', 'sttApiStyle', 'systemPrompt', 'publicShareChatModel'];
const ALLOWED = ['driver', 'chatModel', 'embeddingModel', 'baseUrl', 'embeddingBaseUrl', 'sttModel', 'sttBaseUrl', 'sttApiStyle', 'systemPrompt', 'publicShareChatModel', 'publicShareAssistantRoleId'];
const entries = Object.entries(provider).filter(
([k, v]) => v !== undefined && ALLOWED.includes(k),
);

View File

@@ -34,6 +34,7 @@ export interface UpdateAiSettingsInput {
sttApiStyle?: SttApiStyle;
sttApiKey?: string;
publicShareChatModel?: string;
publicShareAssistantRoleId?: string;
}
/**
@@ -135,6 +136,9 @@ export class AiSettingsService {
// Cheap model id for the anonymous public-share assistant; reuses the chat
// driver/baseUrl/apiKey. Empty/unset → callers fall back to chatModel.
publicShareChatModel: provider.publicShareChatModel,
// Agent-role id whose persona the public-share assistant adopts; empty/unset
// = built-in locked persona.
publicShareAssistantRoleId: provider.publicShareAssistantRoleId,
embeddingModel: provider.embeddingModel,
sttModel: provider.sttModel,
// Plain passthrough, no fallback; the transcribe path defaults unset to
@@ -216,6 +220,7 @@ export class AiSettingsService {
sttApiStyle: provider.sttApiStyle,
systemPrompt: provider.systemPrompt,
publicShareChatModel: provider.publicShareChatModel,
publicShareAssistantRoleId: provider.publicShareAssistantRoleId,
hasApiKey,
hasEmbeddingApiKey,
hasSttApiKey,
@@ -254,6 +259,7 @@ export class AiSettingsService {
'sttApiStyle',
'systemPrompt',
'publicShareChatModel',
'publicShareAssistantRoleId',
] as const) {
if (nonSecret[key] !== undefined) {
(providerPatch as Record<string, unknown>)[key] = nonSecret[key];

View File

@@ -38,6 +38,9 @@ export interface AiProviderSettings {
// `chatModel`. The workspace owner pays for anonymous tokens, so a cheaper
// model is preferred for read-only Q&A over published documentation.
publicShareChatModel?: string;
// Agent-role id whose persona the anonymous public-share assistant adopts;
// empty/unset = built-in locked persona.
publicShareAssistantRoleId?: string;
}
/**
@@ -55,6 +58,9 @@ export interface ResolvedAiConfig extends Partial<AiProviderSettings> {
chatModel?: string;
// Cheap model id for the public-share assistant; reuses the chat creds.
publicShareChatModel?: string;
// Agent-role id whose persona the public-share assistant adopts (empty/unset
// = built-in locked persona). Re-declared for parity with the explicit fields.
publicShareAssistantRoleId?: string;
apiKey?: string;
embeddingApiKey?: string;
sttApiKey?: string;
@@ -76,6 +82,9 @@ export interface MaskedAiSettings {
sttApiStyle?: SttApiStyle;
systemPrompt?: string;
publicShareChatModel?: string;
// Agent-role id whose persona the public-share assistant adopts; empty/unset
// = built-in locked persona.
publicShareAssistantRoleId?: string;
hasApiKey: boolean;
hasEmbeddingApiKey: boolean;
hasSttApiKey: boolean;

View File

@@ -63,4 +63,10 @@ export class UpdateAiSettingsDto {
@IsOptional()
@IsString()
publicShareChatModel?: string;
// Agent-role id whose persona the anonymous public-share assistant adopts;
// empty/unset = built-in locked persona.
@IsOptional()
@IsString()
publicShareAssistantRoleId?: string;
}