From ce1fa4705d9344d5d7fd6d5b2440c821134ac435 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Thu, 2 Jul 2026 17:06:09 +0300 Subject: [PATCH 1/2] feat: AI knowledge base (RAG grounding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grounds the AI assistant (0.5.0) in the account's own content so it can answer instead of handing off. - Hybrid retrieval: Postgres full-text search for every account; optional semantic search (pgvector + OpenAI text-embedding-3-small) when an embeddings key is set. Semantic-primary, topped up with FTS. - src/lib/ai: chunk, embeddings, knowledge (ingest + retrieve), query; buildSystemPrompt gains a knowledge block; config carries an (optional) embeddings key. - Wired into /api/ai/draft and the auto-reply bot (best-effort — never blocks a reply). - KB API (/api/ai/knowledge CRUD + /reindex) and a Settings → AI Assistant knowledge-base manager + embeddings-key field. - Migration 030: pgvector, ai_knowledge_documents, ai_knowledge_chunks (fts + embedding), match RPCs, ai_configs.embeddings_api_key. Migration required: supabase/migrations/030_ai_knowledge.sql. Stacked on feat/ai-reply-assistant (#329). Co-Authored-By: Claude Opus 4.8 --- .env.local.example | 6 + CHANGELOG.md | 24 ++ README.md | 3 + package.json | 2 +- src/app/api/ai/config/route.ts | 48 +++- src/app/api/ai/draft/route.ts | 12 + src/app/api/ai/knowledge/[id]/route.ts | 122 +++++++++ src/app/api/ai/knowledge/reindex/route.ts | 62 +++++ src/app/api/ai/knowledge/route.ts | 94 +++++++ src/app/api/ai/test/route.ts | 1 + src/components/settings/ai-config.tsx | 56 +++++ src/components/settings/ai-knowledge.tsx | 286 ++++++++++++++++++++++ src/lib/ai/auto-reply.test.ts | 12 + src/lib/ai/auto-reply.ts | 11 + src/lib/ai/chunk.test.ts | 39 +++ src/lib/ai/chunk.ts | 53 ++++ src/lib/ai/config.ts | 40 ++- src/lib/ai/defaults.ts | 18 +- src/lib/ai/embeddings.test.ts | 99 ++++++++ src/lib/ai/embeddings.ts | 93 +++++++ src/lib/ai/generate.test.ts | 1 + src/lib/ai/knowledge.test.ts | 134 ++++++++++ src/lib/ai/knowledge.ts | 122 +++++++++ src/lib/ai/query.ts | 14 ++ src/lib/ai/types.ts | 4 + supabase/migrations/030_ai_knowledge.sql | 174 +++++++++++++ 26 files changed, 1523 insertions(+), 7 deletions(-) create mode 100644 src/app/api/ai/knowledge/[id]/route.ts create mode 100644 src/app/api/ai/knowledge/reindex/route.ts create mode 100644 src/app/api/ai/knowledge/route.ts create mode 100644 src/components/settings/ai-knowledge.tsx create mode 100644 src/lib/ai/chunk.test.ts create mode 100644 src/lib/ai/chunk.ts create mode 100644 src/lib/ai/embeddings.test.ts create mode 100644 src/lib/ai/embeddings.ts create mode 100644 src/lib/ai/knowledge.test.ts create mode 100644 src/lib/ai/knowledge.ts create mode 100644 src/lib/ai/query.ts create mode 100644 supabase/migrations/030_ai_knowledge.sql diff --git a/.env.local.example b/.env.local.example index 9374919..0fdbcc3 100644 --- a/.env.local.example +++ b/.env.local.example @@ -100,6 +100,12 @@ NEXT_PUBLIC_SITE_URL=https://crm.example.com # stored AES-256-GCM-encrypted with ENCRYPTION_KEY (above) — there is # NO global provider key env var, and nothing here is required for the # feature to work. The two vars below only tune behaviour. +# +# The AI knowledge base (migration 030) uses Postgres full-text search +# out of the box. Optional semantic search needs the `pgvector` +# extension — migration 030 runs `CREATE EXTENSION IF NOT EXISTS vector` +# (Supabase has it available) — plus a per-account embeddings key set in +# Settings → AI Assistant. Still no env var required. # Per-call timeout for provider requests, in milliseconds. Default 30000. # AI_REQUEST_TIMEOUT_MS=30000 diff --git a/CHANGELOG.md b/CHANGELOG.md index 09c31bc..b46f4a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,30 @@ Versions follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Pre-1.0, `MINOR` bumps cover new modules; `PATCH` bumps cover bug fixes and polish. +## [0.6.0] — 2026-07-02 + +Adds an **AI knowledge base** so the assistant (0.5.0) can answer from +your own content instead of handing off. Paste FAQs, policies, or +product details under **Settings → AI Assistant → Knowledge base**; the +relevant excerpts are retrieved into every draft and auto-reply. + +### Added + +- **Knowledge base with hybrid retrieval.** Lexical Postgres full-text + search works for every account with no extra credentials. Optional + **semantic search** (pgvector, OpenAI `text-embedding-3-small`) turns + on when you add an **embeddings key** — semantic-primary, topped up + with lexical to fill the result set. Anthropic-only accounts (Anthropic + has no embeddings API) keep the lexical path with zero extra setup. +- **Knowledge base manager** in Settings — add/edit/delete documents and + a **Reindex** action to backfill embeddings after adding a key. Both + drafts and the auto-reply bot are grounded in the retrieved excerpts, + and the prompt still instructs the model to hand off (auto-reply) or + say it will follow up (draft) when the KB doesn't cover the question. + **Migration required:** apply `supabase/migrations/030_ai_knowledge.sql` + (enables `pgvector`; adds `ai_knowledge_documents` + `ai_knowledge_chunks` + and an `embeddings_api_key` column on `ai_configs`). + ## [0.5.0] — 2026-07-02 Adds the **AI reply assistant** — bring-your-own-key. Each account diff --git a/README.md b/README.md index d20376a..bb1b3f1 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,9 @@ clone or fork it to run your own CRM. (stored encrypted; no per-seat AI fee, your data stays yours). One-click AI-drafted replies in the inbox, plus an optional auto-reply bot with a per-conversation cap and clean human handoff. + Add a **knowledge base** (FAQs, policies, product docs) and it + answers from your own content — hybrid retrieval (Postgres full-text, + or semantic pgvector when an embeddings key is set). - **Real-time dashboard** — response times, daily volume, pipeline value, cross-module activity feed. - **Team accounts** — invite teammates by link, role-based access diff --git a/package.json b/package.json index a45cb23..f45bd67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wacrm", - "version": "0.5.0", + "version": "0.6.0", "private": true, "description": "Self-hostable CRM template for WhatsApp built on Next.js and Supabase — shared inbox, contacts, sales pipelines, broadcasts, and no-code automations.", "license": "MIT", diff --git a/src/app/api/ai/config/route.ts b/src/app/api/ai/config/route.ts index 9fa0716..113cb01 100644 --- a/src/app/api/ai/config/route.ts +++ b/src/app/api/ai/config/route.ts @@ -7,6 +7,7 @@ import { import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit' import { encrypt, decrypt } from '@/lib/whatsapp/encryption' import { validateAiCredentials } from '@/lib/ai/validate' +import { embedTexts } from '@/lib/ai/embeddings' import { AiError, type AiProvider } from '@/lib/ai/types' function bad(message: string) { @@ -29,7 +30,7 @@ export async function GET() { // `api_key` is selected only to derive `has_key` — it is stripped // out below and never returned to the client. .select( - 'provider, model, system_prompt, is_active, auto_reply_enabled, auto_reply_max_per_conversation, api_key', + 'provider, model, system_prompt, is_active, auto_reply_enabled, auto_reply_max_per_conversation, api_key, embeddings_api_key', ) .eq('account_id', accountId) .maybeSingle() @@ -43,8 +44,15 @@ export async function GET() { } if (!data) return NextResponse.json({ configured: false }) - const { api_key, ...safe } = data - return NextResponse.json({ configured: true, has_key: !!api_key, ...safe }) + // The keys are selected only to derive the has_* flags; neither is + // returned to the client. + const { api_key, embeddings_api_key, ...safe } = data + return NextResponse.json({ + configured: true, + has_key: !!api_key, + has_embeddings_key: !!embeddings_api_key, + ...safe, + }) } catch (err) { return toErrorResponse(err) } @@ -89,6 +97,15 @@ export async function POST(request: Request) { const rawKey = typeof body.api_key === 'string' ? body.api_key.trim() : '' + // Embeddings key (optional, for semantic KB search): a non-empty + // string sets/replaces it; an explicit null clears it; absent leaves + // it unchanged. The form only sends it when the admin edits it. + const rawEmbeddingsKey = + typeof body.embeddings_api_key === 'string' + ? body.embeddings_api_key.trim() + : '' + const clearEmbeddingsKey = body.embeddings_api_key === null + // Reuse the stored key when the form didn't send a fresh one. const { data: existing } = await supabase .from('ai_configs') @@ -129,6 +146,7 @@ export async function POST(request: Request) { isActive, autoReplyEnabled, autoReplyMaxPerConversation: maxPer, + embeddingsApiKey: null, }) } catch (err) { if (err instanceof AiError) { @@ -142,8 +160,25 @@ export async function POST(request: Request) { } } + // Validate a new embeddings key before storing (a cheap 1-input + // embed), same "verify before save" discipline as the chat key. + if (rawEmbeddingsKey) { + try { + await embedTexts(rawEmbeddingsKey, ['ping']) + } catch (err) { + if (err instanceof AiError) { + return NextResponse.json( + { error: `Embeddings key: ${err.message}`, code: err.code }, + { status: 400 }, + ) + } + console.error('[ai/config POST] embeddings validation error:', err) + return bad('Could not validate the embeddings key.') + } + } + const encryptedKey = rawKey ? encrypt(rawKey) : null - const shared = { + const shared: Record = { provider, model, system_prompt: systemPrompt, @@ -151,6 +186,11 @@ export async function POST(request: Request) { auto_reply_enabled: autoReplyEnabled, auto_reply_max_per_conversation: maxPer, } + if (rawEmbeddingsKey) { + shared.embeddings_api_key = encrypt(rawEmbeddingsKey) + } else if (clearEmbeddingsKey) { + shared.embeddings_api_key = null + } if (existing) { const { error: upErr } = await supabase diff --git a/src/app/api/ai/draft/route.ts b/src/app/api/ai/draft/route.ts index 23b303c..975af0f 100644 --- a/src/app/api/ai/draft/route.ts +++ b/src/app/api/ai/draft/route.ts @@ -3,8 +3,10 @@ import { requireRole, toErrorResponse } from '@/lib/auth/account' import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit' import { loadAiConfig } from '@/lib/ai/config' import { buildConversationContext } from '@/lib/ai/context' +import { retrieveKnowledge } from '@/lib/ai/knowledge' import { generateReply } from '@/lib/ai/generate' import { buildSystemPrompt } from '@/lib/ai/defaults' +import { latestUserMessage } from '@/lib/ai/query' import { AiError } from '@/lib/ai/types' /** @@ -85,9 +87,19 @@ export async function POST(request: Request) { ) } + // Ground the draft in the account's knowledge base (best-effort — + // returns [] when there's no KB or retrieval fails). + const knowledge = await retrieveKnowledge( + supabase, + accountId, + config, + latestUserMessage(messages), + ) + const systemPrompt = buildSystemPrompt({ userPrompt: config.systemPrompt, mode: 'draft', + knowledge, }) const { text } = await generateReply({ config, systemPrompt, messages }) diff --git a/src/app/api/ai/knowledge/[id]/route.ts b/src/app/api/ai/knowledge/[id]/route.ts new file mode 100644 index 0000000..5fc6a31 --- /dev/null +++ b/src/app/api/ai/knowledge/[id]/route.ts @@ -0,0 +1,122 @@ +import { NextResponse } from 'next/server' +import { + getCurrentAccount, + requireRole, + toErrorResponse, +} from '@/lib/auth/account' +import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit' +import { loadEmbeddingsKey } from '@/lib/ai/config' +import { ingestDocument } from '@/lib/ai/knowledge' +import { AiError } from '@/lib/ai/types' + +type Params = { params: Promise<{ id: string }> } + +/** + * GET /api/ai/knowledge/[id] — full document (any member). + */ +export async function GET(_request: Request, { params }: Params) { + try { + const { supabase, accountId } = await getCurrentAccount() + const { id } = await params + const { data, error } = await supabase + .from('ai_knowledge_documents') + .select('id, title, content, updated_at') + .eq('account_id', accountId) + .eq('id', id) + .maybeSingle() + if (error) { + console.error('[ai/knowledge/[id] GET] error:', error) + return NextResponse.json({ error: 'Failed to load document' }, { status: 500 }) + } + if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + return NextResponse.json(data) + } catch (err) { + return toErrorResponse(err) + } +} + +/** + * PATCH /api/ai/knowledge/[id] (admin+) — update title/content and + * re-index when the content changed. + */ +export async function PATCH(request: Request, { params }: Params) { + try { + const { supabase, accountId, userId } = await requireRole('admin') + const limit = checkRateLimit(`ai-kb:${userId}`, RATE_LIMITS.adminAction) + if (!limit.success) return rateLimitResponse(limit) + + const { id } = await params + const body = await request.json().catch(() => null) + const title = typeof body?.title === 'string' ? body.title.trim() : undefined + const content = typeof body?.content === 'string' ? body.content.trim() : undefined + if (title === undefined && content === undefined) { + return NextResponse.json({ error: 'Nothing to update' }, { status: 400 }) + } + if (title !== undefined && !title) { + return NextResponse.json({ error: 'title cannot be empty' }, { status: 400 }) + } + if (content !== undefined && !content) { + return NextResponse.json({ error: 'content cannot be empty' }, { status: 400 }) + } + + const update: Record = {} + if (title !== undefined) update.title = title + if (content !== undefined) update.content = content + + const { data: updated, error } = await supabase + .from('ai_knowledge_documents') + .update(update) + .eq('account_id', accountId) + .eq('id', id) + .select('id') + .maybeSingle() + if (error) { + console.error('[ai/knowledge/[id] PATCH] error:', error) + return NextResponse.json({ error: 'Failed to update document' }, { status: 500 }) + } + if (!updated) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + + if (content !== undefined) { + const embeddingsApiKey = await loadEmbeddingsKey(supabase, accountId) + try { + await ingestDocument(supabase, accountId, { embeddingsApiKey }, id, content) + } catch (err) { + const message = err instanceof AiError ? err.message : 'indexing failed' + console.error('[ai/knowledge/[id] PATCH] ingest error:', err) + return NextResponse.json( + { + success: true, + warning: `Updated, but semantic indexing failed (${message}). Lexical search still works; use Reindex to retry.`, + }, + { status: 200 }, + ) + } + } + + return NextResponse.json({ success: true }) + } catch (err) { + return toErrorResponse(err) + } +} + +/** + * DELETE /api/ai/knowledge/[id] (admin+) — chunks cascade. + */ +export async function DELETE(_request: Request, { params }: Params) { + try { + const { supabase, accountId } = await requireRole('admin') + const { id } = await params + const { error } = await supabase + .from('ai_knowledge_documents') + .delete() + .eq('account_id', accountId) + .eq('id', id) + if (error) { + console.error('[ai/knowledge/[id] DELETE] error:', error) + return NextResponse.json({ error: 'Failed to delete document' }, { status: 500 }) + } + return NextResponse.json({ success: true }) + } catch (err) { + return toErrorResponse(err) + } +} diff --git a/src/app/api/ai/knowledge/reindex/route.ts b/src/app/api/ai/knowledge/reindex/route.ts new file mode 100644 index 0000000..f36d824 --- /dev/null +++ b/src/app/api/ai/knowledge/reindex/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from 'next/server' +import { requireRole, toErrorResponse } from '@/lib/auth/account' +import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit' +import { loadEmbeddingsKey } from '@/lib/ai/config' +import { ingestDocument } from '@/lib/ai/knowledge' +import { AiError } from '@/lib/ai/types' + +/** + * POST /api/ai/knowledge/reindex (admin+) + * + * Re-chunk and re-embed every document in the account. The main use is + * after adding an embeddings key: existing documents were stored + * lexical-only, and this backfills their vectors so semantic search + * turns on. Also recovers documents whose indexing failed earlier. + */ +export async function POST() { + try { + const { supabase, accountId, userId } = await requireRole('admin') + const limit = checkRateLimit(`ai-kb-reindex:${userId}`, RATE_LIMITS.adminAction) + if (!limit.success) return rateLimitResponse(limit) + + const { data: docs, error } = await supabase + .from('ai_knowledge_documents') + .select('id, content') + .eq('account_id', accountId) + if (error) { + console.error('[ai/knowledge/reindex] fetch error:', error) + return NextResponse.json( + { error: 'Failed to load documents' }, + { status: 500 }, + ) + } + + const embeddingsApiKey = await loadEmbeddingsKey(supabase, accountId) + + let reindexed = 0 + for (const doc of docs ?? []) { + try { + await ingestDocument(supabase, accountId, { embeddingsApiKey }, doc.id, doc.content) + reindexed += 1 + } catch (err) { + // One bad document (e.g. a mid-run embeddings rate-limit) should + // not abort the whole batch. + const message = err instanceof AiError ? err.message : String(err) + console.error(`[ai/knowledge/reindex] doc ${doc.id} failed:`, message) + return NextResponse.json( + { + success: false, + reindexed, + total: (docs ?? []).length, + error: `Reindexed ${reindexed}, then hit an error: ${message}`, + }, + { status: 200 }, + ) + } + } + + return NextResponse.json({ success: true, reindexed }) + } catch (err) { + return toErrorResponse(err) + } +} diff --git a/src/app/api/ai/knowledge/route.ts b/src/app/api/ai/knowledge/route.ts new file mode 100644 index 0000000..affcaf2 --- /dev/null +++ b/src/app/api/ai/knowledge/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from 'next/server' +import { + getCurrentAccount, + requireRole, + toErrorResponse, +} from '@/lib/auth/account' +import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit' +import { loadEmbeddingsKey } from '@/lib/ai/config' +import { ingestDocument } from '@/lib/ai/knowledge' +import { AiError } from '@/lib/ai/types' + +/** + * GET /api/ai/knowledge + * + * List the account's knowledge-base documents (any member). + */ +export async function GET() { + try { + const { supabase, accountId } = await getCurrentAccount() + const { data, error } = await supabase + .from('ai_knowledge_documents') + .select('id, title, updated_at') + .eq('account_id', accountId) + .order('updated_at', { ascending: false }) + if (error) { + console.error('[ai/knowledge GET] error:', error) + return NextResponse.json( + { error: 'Failed to load knowledge base' }, + { status: 500 }, + ) + } + return NextResponse.json({ documents: data ?? [] }) + } catch (err) { + return toErrorResponse(err) + } +} + +/** + * POST /api/ai/knowledge (admin+) + * + * Create a document, then chunk + (optionally) embed it. If indexing + * fails the document is still saved so the admin can retry via reindex. + */ +export async function POST(request: Request) { + try { + const { supabase, accountId, userId } = await requireRole('admin') + const limit = checkRateLimit(`ai-kb:${userId}`, RATE_LIMITS.adminAction) + if (!limit.success) return rateLimitResponse(limit) + + const body = await request.json().catch(() => null) + const title = typeof body?.title === 'string' ? body.title.trim() : '' + const content = typeof body?.content === 'string' ? body.content.trim() : '' + if (!title || !content) { + return NextResponse.json( + { error: 'title and content are required' }, + { status: 400 }, + ) + } + + const { data: doc, error } = await supabase + .from('ai_knowledge_documents') + .insert({ account_id: accountId, created_by: userId, title, content }) + .select('id') + .single() + if (error || !doc) { + console.error('[ai/knowledge POST] insert error:', error) + return NextResponse.json( + { error: 'Failed to save document' }, + { status: 500 }, + ) + } + + const embeddingsApiKey = await loadEmbeddingsKey(supabase, accountId) + try { + await ingestDocument(supabase, accountId, { embeddingsApiKey }, doc.id, content) + } catch (err) { + const message = + err instanceof AiError ? err.message : 'indexing failed' + console.error('[ai/knowledge POST] ingest error:', err) + return NextResponse.json( + { + success: true, + id: doc.id, + warning: `Saved, but semantic indexing failed (${message}). Lexical search still works; use Reindex to retry.`, + }, + { status: 200 }, + ) + } + + return NextResponse.json({ success: true, id: doc.id }) + } catch (err) { + return toErrorResponse(err) + } +} diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index 2568dcd..400e3dc 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -71,6 +71,7 @@ export async function POST(request: Request) { isActive: true, autoReplyEnabled: false, autoReplyMaxPerConversation: 3, + embeddingsApiKey: null, }) } catch (err) { if (err instanceof AiError) { diff --git a/src/components/settings/ai-config.tsx b/src/components/settings/ai-config.tsx index 27511fd..d7d20ff 100644 --- a/src/components/settings/ai-config.tsx +++ b/src/components/settings/ai-config.tsx @@ -25,6 +25,7 @@ import { SelectValue, } from '@/components/ui/select'; import { SettingsPanelHead } from './settings-panel-head'; +import { AiKnowledgeCard } from './ai-knowledge'; import { AI_PROVIDER_DEFAULT_MODEL } from '@/lib/ai/defaults'; import type { AiProvider } from '@/lib/ai/types'; @@ -56,6 +57,9 @@ export function AiConfig() { const [keyEdited, setKeyEdited] = useState(false); const [showKey, setShowKey] = useState(false); const [hasStoredKey, setHasStoredKey] = useState(false); + const [embeddingsKey, setEmbeddingsKey] = useState(''); + const [embeddingsKeyEdited, setEmbeddingsKeyEdited] = useState(false); + const [hasStoredEmbeddingsKey, setHasStoredEmbeddingsKey] = useState(false); const [systemPrompt, setSystemPrompt] = useState(''); const [isActive, setIsActive] = useState(false); const [autoReplyEnabled, setAutoReplyEnabled] = useState(false); @@ -87,6 +91,9 @@ export function AiConfig() { setHasStoredKey(Boolean(data.has_key)); setApiKey(data.has_key ? MASKED_KEY : ''); setKeyEdited(false); + setHasStoredEmbeddingsKey(Boolean(data.has_embeddings_key)); + setEmbeddingsKey(data.has_embeddings_key ? MASKED_KEY : ''); + setEmbeddingsKeyEdited(false); } } catch { toast.error('Failed to load AI configuration'); @@ -114,10 +121,15 @@ export function AiConfig() { const keyPayload = () => (keyEdited ? apiKey.trim() : undefined); + // undefined = leave unchanged; '' typed = null (clear); text = set. + const embeddingsKeyPayload = () => + embeddingsKeyEdited ? embeddingsKey.trim() || null : undefined; + const buildBody = () => ({ provider, model: model.trim(), api_key: keyPayload(), + embeddings_api_key: embeddingsKeyPayload(), system_prompt: systemPrompt.trim() || null, is_active: isActive, auto_reply_enabled: autoReplyEnabled, @@ -316,6 +328,40 @@ export function AiConfig() { + +
+ + { + setEmbeddingsKey(e.target.value); + setEmbeddingsKeyEdited(true); + }} + onFocus={() => { + if (!embeddingsKeyEdited && hasStoredEmbeddingsKey) { + setEmbeddingsKey(''); + setEmbeddingsKeyEdited(true); + } + }} + placeholder="sk-... (OpenAI)" + disabled={disabled} + autoComplete="off" + /> +

+ An OpenAI key used only to embed your knowledge base + (text-embedding-3-small) + {provider === 'openai' ? ' — can be the same key as above' : ''}. + Leave blank to use keyword search instead. Clear it to turn + semantic search off. +

+
@@ -401,6 +447,16 @@ export function AiConfig() { + 0 + : hasStoredEmbeddingsKey + } + /> +
{configured ? ( + + + )} + + ))} + + )} + + {editing !== null ? ( +
+
+ + setTitle(e.target.value)} + placeholder="e.g. Returns & refunds policy" + disabled={saving} + /> +
+
+ +