Files
gitmost/apps/client/src/features/workspace/services/ai-settings-service.ts
claude code agent 227 acf3df9e9d 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>
2026-06-20 07:59:56 +03:00

96 lines
3.1 KiB
TypeScript

import api from "@/lib/api-client";
// Supported LLM providers/drivers.
export type AiDriver = "openai" | "gemini" | "ollama";
// How STT (speech-to-text) requests are encoded for the transcription endpoint.
// - 'multipart' -> OpenAI-compatible multipart/form-data (OpenAI, speaches,
// faster-whisper-server)
// - 'json' -> JSON body with base64-encoded audio (OpenRouter)
export type SttApiStyle = "multipart" | "json";
// Masked AI provider settings returned by the server.
// No API key is ever returned; only `hasApiKey` / `hasEmbeddingApiKey` indicate
// whether one is stored. `embeddingBaseUrl` is the RAW stored value (empty means
// "uses the chat base URL").
export interface IAiSettings {
driver?: AiDriver;
chatModel?: string;
// Cheap model id for the anonymous public-share assistant; empty = chatModel.
publicShareChatModel?: string;
embeddingModel?: string;
baseUrl?: string;
embeddingBaseUrl?: string;
systemPrompt?: string;
hasApiKey: boolean;
hasEmbeddingApiKey: boolean;
// STT-specific settings. `sttBaseUrl` is the RAW stored value (empty means
// "uses the chat base URL"). `hasSttApiKey` indicates whether an STT-specific
// key is stored (empty means "uses the chat API key").
sttModel?: string;
sttBaseUrl?: string;
sttApiStyle?: SttApiStyle;
hasSttApiKey: boolean;
// RAG indexing coverage (pages indexed for semantic search).
indexedPages: number;
totalPages: number;
}
// Update payload. Key semantics (same for `apiKey` and `embeddingApiKey`):
// - omit the key -> key unchanged
// - `key: ''` -> clear the stored key
// - `key: 'non-empty'` -> set the key
// Non-secret fields are saved as given.
export interface IAiSettingsUpdate {
driver?: AiDriver;
chatModel?: string;
publicShareChatModel?: string;
embeddingModel?: string;
baseUrl?: string;
embeddingBaseUrl?: string;
systemPrompt?: string;
apiKey?: string;
embeddingApiKey?: string;
sttModel?: string;
sttBaseUrl?: string;
sttApiStyle?: SttApiStyle;
// Write-only STT key (same semantics as `apiKey` / `embeddingApiKey`).
sttApiKey?: string;
}
// Result of a connection test against the configured provider.
// The error string is already sanitized server-side.
export interface IAiTestResult {
ok: boolean;
error?: string;
}
// Which endpoint a connection test probes.
export type AiTestCapability = "chat" | "embeddings" | "stt";
export async function getAiSettings(): Promise<IAiSettings> {
const req = await api.post<IAiSettings>("/workspace/ai-settings");
return req.data;
}
export async function updateAiSettings(
data: IAiSettingsUpdate,
): Promise<IAiSettings> {
const req = await api.post<IAiSettings>("/workspace/ai-settings/update", data);
return req.data;
}
export async function testAiConnection(
capability: AiTestCapability,
): Promise<IAiTestResult> {
const req = await api.post<IAiTestResult>("/workspace/ai-settings/test", {
capability,
});
return req.data;
}
export async function reindexAiEmbeddings(): Promise<IAiSettings> {
const req = await api.post<IAiSettings>("/workspace/ai-settings/reindex");
return req.data;
}