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:
claude code agent 227
2026-06-20 07:59:56 +03:00
parent c8af637654
commit acf3df9e9d
27 changed files with 1533 additions and 11 deletions

View File

@@ -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];

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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(

View File

@@ -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';