feat(public-share): selectable agent-role identity + fix floating-icon overlap #25
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user