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

@@ -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'];
const ALLOWED = ['driver', 'chatModel', 'embeddingModel', 'baseUrl', 'embeddingBaseUrl', 'sttModel', 'sttBaseUrl', 'sttApiStyle', 'systemPrompt', 'publicShareChatModel'];
const entries = Object.entries(provider).filter(
([k, v]) => v !== undefined && ALLOWED.includes(k),
);