feat(ai): anonymous AI assistant on public shares
Lets an unauthenticated viewer of a published share ask an AI scoped strictly to that share's page tree. The authenticated agent is untouched; the security boundary is the tool scope (no identity), and nothing is persisted. Server: - workspace toggle settings.ai.publicShareAssistant (default off) + optional settings.ai.provider.publicShareChatModel (cheap model id; reuses the chat driver/baseUrl/key). getChatModel(workspaceId, override) substitutes only the model id, falling back to chatModel. - POST /api/shares/ai/stream (@Public, SSE). Guardrail funnel, each failing before streaming: toggle off -> 404; share missing/wrong-workspace/sharing off -> 404; pageId not in share tree -> 404; provider unconfigured -> 503; per-IP (5/min) and per-workspace (300/h, IP-independent) rate limits -> 429. Uniform 404s never confirm a private page's existence. - forShare read-only in-process toolset: searchSharePages (existing shareId FTS branch, no spaceId/userId), getSharePage (getShareForPage gate + share.id check, content via the public sanitizer), listSharePages. No write/ comment/history/cross-space/external-MCP tools. - Locked share system prompt + immutable safety block; stepCountIs(5). - /shares/page-info exposes an aiAssistant flag (gated behind isSharingAllowed). Client: an ephemeral, text-only Ask-AI widget on the public shared page, shown only when the flag is set; useChat -> /api/shares/ai/stream, credentials omit. Admin toggle + model field in Settings -> AI. Also adds a jest moduleNameMapper for src/-rooted imports (fixes pre-existing unresolvable specs; additive). Implements docs/public-share-assistant-plan.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ export interface UpdateAiSettingsInput {
|
||||
sttBaseUrl?: string;
|
||||
sttApiStyle?: SttApiStyle;
|
||||
sttApiKey?: string;
|
||||
publicShareChatModel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,6 +95,20 @@ export class AiSettingsService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the anonymous public-share AI assistant is enabled for a workspace
|
||||
* (single master toggle `settings.ai.publicShareAssistant`, default false).
|
||||
* Used by the public `/api/shares/ai/stream` guardrail funnel: when off, the
|
||||
* route 404s so the feature's existence is not revealed.
|
||||
*/
|
||||
async isPublicShareAssistantEnabled(workspaceId: string): Promise<boolean> {
|
||||
const workspace = await this.workspaceRepo.findById(workspaceId);
|
||||
const settings = (workspace?.settings ?? {}) as {
|
||||
ai?: { publicShareAssistant?: boolean };
|
||||
};
|
||||
return settings?.ai?.publicShareAssistant === true;
|
||||
}
|
||||
|
||||
/** Read the stored non-secret provider settings for a workspace. */
|
||||
private async readProvider(
|
||||
workspaceId: string,
|
||||
@@ -117,6 +132,9 @@ export class AiSettingsService {
|
||||
const config: ResolvedAiConfig = {
|
||||
driver: provider.driver,
|
||||
chatModel: provider.chatModel,
|
||||
// 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,
|
||||
embeddingModel: provider.embeddingModel,
|
||||
sttModel: provider.sttModel,
|
||||
// Plain passthrough, no fallback; the transcribe path defaults unset to
|
||||
@@ -197,6 +215,7 @@ export class AiSettingsService {
|
||||
sttBaseUrl: provider.sttBaseUrl,
|
||||
sttApiStyle: provider.sttApiStyle,
|
||||
systemPrompt: provider.systemPrompt,
|
||||
publicShareChatModel: provider.publicShareChatModel,
|
||||
hasApiKey,
|
||||
hasEmbeddingApiKey,
|
||||
hasSttApiKey,
|
||||
@@ -234,6 +253,7 @@ export class AiSettingsService {
|
||||
'sttBaseUrl',
|
||||
'sttApiStyle',
|
||||
'systemPrompt',
|
||||
'publicShareChatModel',
|
||||
] as const) {
|
||||
if (nonSecret[key] !== undefined) {
|
||||
(providerPatch as Record<string, unknown>)[key] = nonSecret[key];
|
||||
|
||||
@@ -32,8 +32,17 @@ export class AiService {
|
||||
/**
|
||||
* Resolve the workspace config and build the chat language model.
|
||||
* Throws AiNotConfiguredException (→ 503) when the config is incomplete.
|
||||
*
|
||||
* `override.chatModel` substitutes ONLY the model id; the driver, baseUrl and
|
||||
* apiKey are ALWAYS reused from the workspace's configured chat provider (the
|
||||
* override is not an isolated provider/key). The public-share assistant uses
|
||||
* this to run the cheap `publicShareChatModel` on the SAME provider. An
|
||||
* empty/blank override falls back to the workspace `chatModel`.
|
||||
*/
|
||||
async getChatModel(workspaceId: string): Promise<LanguageModel> {
|
||||
async getChatModel(
|
||||
workspaceId: string,
|
||||
override?: { chatModel?: string },
|
||||
): Promise<LanguageModel> {
|
||||
const cfg = await this.aiSettings.resolve(workspaceId);
|
||||
if (
|
||||
!cfg?.driver ||
|
||||
@@ -43,6 +52,13 @@ export class AiService {
|
||||
throw new AiNotConfiguredException();
|
||||
}
|
||||
|
||||
// Effective model id: a non-blank override, else the workspace chatModel.
|
||||
const overrideModel =
|
||||
typeof override?.chatModel === 'string' && override.chatModel.trim()
|
||||
? override.chatModel.trim()
|
||||
: undefined;
|
||||
const modelId = overrideModel ?? cfg.chatModel;
|
||||
|
||||
switch (cfg.driver) {
|
||||
case 'openai':
|
||||
// baseURL (when set) covers openai-compatible endpoints. Use Chat
|
||||
@@ -52,13 +68,13 @@ export class AiService {
|
||||
// (OpenRouter, etc.) reject on multi-turn requests (history with
|
||||
// assistant messages) → 400.
|
||||
return createOpenAI({ apiKey: cfg.apiKey, baseURL: cfg.baseUrl }).chat(
|
||||
cfg.chatModel,
|
||||
modelId,
|
||||
);
|
||||
case 'gemini':
|
||||
return createGoogleGenerativeAI({ apiKey: cfg.apiKey })(cfg.chatModel);
|
||||
return createGoogleGenerativeAI({ apiKey: cfg.apiKey })(modelId);
|
||||
case 'ollama':
|
||||
// Ollama needs no API key.
|
||||
return createOllama({ baseURL: cfg.baseUrl })(cfg.chatModel);
|
||||
return createOllama({ baseURL: cfg.baseUrl })(modelId);
|
||||
default:
|
||||
throw new AiNotConfiguredException();
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@ export interface AiProviderSettings {
|
||||
sttBaseUrl?: string;
|
||||
sttApiStyle?: SttApiStyle;
|
||||
systemPrompt?: string;
|
||||
// Cheap chat model id used ONLY by the anonymous public-share assistant. The
|
||||
// driver / baseUrl / apiKey of the main chat provider are reused; this is the
|
||||
// model id only. Empty/unset → the public-share assistant falls back to
|
||||
// `chatModel`. The workspace owner pays for anonymous tokens, so a cheaper
|
||||
// model is preferred for read-only Q&A over published documentation.
|
||||
publicShareChatModel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,6 +53,8 @@ export interface AiProviderSettings {
|
||||
export interface ResolvedAiConfig extends Partial<AiProviderSettings> {
|
||||
driver?: AiDriver;
|
||||
chatModel?: string;
|
||||
// Cheap model id for the public-share assistant; reuses the chat creds.
|
||||
publicShareChatModel?: string;
|
||||
apiKey?: string;
|
||||
embeddingApiKey?: string;
|
||||
sttApiKey?: string;
|
||||
@@ -67,6 +75,7 @@ export interface MaskedAiSettings {
|
||||
sttBaseUrl?: string;
|
||||
sttApiStyle?: SttApiStyle;
|
||||
systemPrompt?: string;
|
||||
publicShareChatModel?: string;
|
||||
hasApiKey: boolean;
|
||||
hasEmbeddingApiKey: boolean;
|
||||
hasSttApiKey: boolean;
|
||||
|
||||
@@ -57,4 +57,10 @@ export class UpdateAiSettingsDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sttApiKey?: string;
|
||||
|
||||
// Cheap model id for the anonymous public-share assistant; reuses the chat
|
||||
// driver/baseUrl/apiKey. Empty → the assistant falls back to chatModel.
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
publicShareChatModel?: string;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis'
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
import { EnvironmentModule } from '../environment/environment.module';
|
||||
import { parseRedisUrl } from '../../common/helpers';
|
||||
import { AUTH_THROTTLER, AI_CHAT_THROTTLER } from './throttler-names';
|
||||
import {
|
||||
AUTH_THROTTLER,
|
||||
AI_CHAT_THROTTLER,
|
||||
PUBLIC_SHARE_AI_THROTTLER,
|
||||
} from './throttler-names';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
@Module({
|
||||
@@ -18,6 +22,8 @@ import Redis from 'ioredis';
|
||||
throttlers: [
|
||||
{ name: AUTH_THROTTLER, ttl: 60_000, limit: 10 },
|
||||
{ name: AI_CHAT_THROTTLER, ttl: 60_000, limit: 25 },
|
||||
// Anonymous public-share assistant: ~5 req/min per IP.
|
||||
{ name: PUBLIC_SHARE_AI_THROTTLER, ttl: 60_000, limit: 5 },
|
||||
],
|
||||
errorMessage: 'Too many requests',
|
||||
storage: new ThrottlerStorageRedisService(
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
export const AUTH_THROTTLER = 'auth';
|
||||
export const AI_CHAT_THROTTLER = 'ai-chat';
|
||||
// IP-keyed throttler for the anonymous public-share AI assistant. There is no
|
||||
// authenticated user on that route, so it is keyed by client IP (the default
|
||||
// ThrottlerGuard tracker) to bound anonymous abuse — the workspace owner pays
|
||||
// for the tokens.
|
||||
export const PUBLIC_SHARE_AI_THROTTLER = 'public-share-ai';
|
||||
|
||||
Reference in New Issue
Block a user