diff --git a/admin/app/controllers/rag_controller.ts b/admin/app/controllers/rag_controller.ts index de84e11..7ca7b6d 100644 --- a/admin/app/controllers/rag_controller.ts +++ b/admin/app/controllers/rag_controller.ts @@ -95,6 +95,11 @@ export default class RagController { }) } + public async policyPromptState({ response }: HttpContext) { + const result = await this.ragService.getPolicyPromptState() + return response.status(200).json(result) + } + public async scanAndSync({ response }: HttpContext) { try { const syncResult = await this.ragService.scanAndSyncStorage() diff --git a/admin/app/services/rag_service.ts b/admin/app/services/rag_service.ts index a0eb5be..21e302f 100644 --- a/admin/app/services/rag_service.ts +++ b/admin/app/services/rag_service.ts @@ -1111,6 +1111,32 @@ export class RagService { } } + /** + * Compute whether the first-chat JIT prompt should fire and surface the file + * count the banner uses in its copy ("Index your N existing files?"). The + * banner appears when the user hasn't yet picked a global ingest policy + * (`rag.defaultIngestPolicy` unset) and the scanner has actually seen at + * least one embeddable file — i.e., the prompt is actionable, not theoretical + * on a freshly-installed empty NOMAD. + * + * Once the user picks a policy (Always or Manual) via the banner buttons or + * the KB modal toggle, `shouldPrompt` flips to false for good. + */ + public async getPolicyPromptState(): Promise<{ + shouldPrompt: boolean + hasContent: boolean + totalFiles: number + }> { + const policy = await KVStore.getValue('rag.defaultIngestPolicy') + const countRow = await KbIngestState.query().count('* as total').first() + const totalFiles = Number((countRow as any)?.$extras?.total ?? 0) + return { + shouldPrompt: policy === null && totalFiles > 0, + hasContent: totalFiles > 0, + totalFiles, + } + } + /** * Compute conditional warnings (RFC #883 §6) for every source the scanner * sees on disk. Returns `{ ok, warnings }` — `ok: false` distinguishes a diff --git a/admin/inertia/components/chat/KbPolicyPromptBanner.tsx b/admin/inertia/components/chat/KbPolicyPromptBanner.tsx new file mode 100644 index 0000000..3daa654 --- /dev/null +++ b/admin/inertia/components/chat/KbPolicyPromptBanner.tsx @@ -0,0 +1,119 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { usePage } from '@inertiajs/react' +import { IconBrain } from '@tabler/icons-react' +import api from '~/lib/api' +import StyledButton from '~/components/StyledButton' +import { useNotifications } from '~/context/NotificationContext' + +/** + * First-chat onboarding banner (RFC #883 Phase 3 task 12). + * + * Renders above the chat header when the scanner has seen at least one + * embeddable file AND the user has not yet picked a global ingest policy + * (`rag.defaultIngestPolicy` unset). Two buttons let the user decide once, + * after which the prompt never returns: + * + * - "Index existing content" → sets policy=Always and dispatches a sync so + * anything already on disk + in `pending_decision` gets queued for embed. + * - "Maybe later" → sets policy=Manual. New content waits in + * `pending_decision` until the user opts in from the KB modal. + * + * The "dismiss without deciding" X is intentionally NOT here. Dismissing + * without setting policy would make the banner reappear on every visit until + * a choice is recorded — annoying. The two action buttons each set policy, + * and the user can change their mind any time via the Always/Manual radio in + * the KB modal. + */ +export default function KbPolicyPromptBanner() { + const queryClient = useQueryClient() + const { addNotification } = useNotifications() + // Inertia injects `aiAssistantName` as a shared page prop on chat-mounted + // pages so the banner pulls the user-set name when surfaced. Default to + // "AI Assistant" when accessed outside that context (no-op for chat pages, + // but keeps the component safe for future reuse elsewhere). + const aiAssistantName = + usePage<{ aiAssistantName?: string }>().props?.aiAssistantName || 'AI Assistant' + + const { data: promptState } = useQuery({ + queryKey: ['kbPolicyPromptState'], + queryFn: () => api.getKbPolicyPromptState(), + }) + + const indexNowMutation = useMutation({ + mutationFn: async () => { + await api.updateSetting('rag.defaultIngestPolicy', 'Always') + await api.syncRAGStorage() + }, + onSuccess: () => { + addNotification({ + type: 'success', + message: `${aiAssistantName} will index your existing content. You can track progress in the Knowledge Base panel.`, + }) + queryClient.invalidateQueries({ queryKey: ['kbPolicyPromptState'] }) + queryClient.invalidateQueries({ queryKey: ['ingestPolicy'] }) + queryClient.invalidateQueries({ queryKey: ['embed-jobs'] }) + queryClient.invalidateQueries({ queryKey: ['storedFiles'] }) + }, + onError: (error: any) => { + addNotification({ + type: 'error', + message: error?.message || 'Could not start indexing. Try again from the Knowledge Base panel.', + }) + }, + }) + + const maybeLaterMutation = useMutation({ + mutationFn: () => api.updateSetting('rag.defaultIngestPolicy', 'Manual'), + onSuccess: () => { + addNotification({ + type: 'success', + message: 'Your content stays unindexed for now. You can opt in any time from the Knowledge Base panel.', + }) + queryClient.invalidateQueries({ queryKey: ['kbPolicyPromptState'] }) + queryClient.invalidateQueries({ queryKey: ['ingestPolicy'] }) + }, + }) + + if (!promptState?.shouldPrompt) return null + + const fileCount = promptState.totalFiles + const isBusy = indexNowMutation.isPending || maybeLaterMutation.isPending + + return ( +
+
+ +
+

+ + {fileCount === 1 + ? `Index your existing file for ${aiAssistantName}?` + : `Index your ${fileCount.toLocaleString()} existing files for ${aiAssistantName}?`} + + {' '}When indexed, {aiAssistantName} can reference them while answering your questions. +

+
+
+ indexNowMutation.mutate()} + variant="primary" + size="sm" + disabled={isBusy} + loading={indexNowMutation.isPending} + > + Index existing content + + maybeLaterMutation.mutate()} + variant="ghost" + size="sm" + disabled={isBusy} + loading={maybeLaterMutation.isPending} + > + Maybe later + +
+
+
+ ) +} diff --git a/admin/inertia/components/chat/index.tsx b/admin/inertia/components/chat/index.tsx index 577579a..04f7c15 100644 --- a/admin/inertia/components/chat/index.tsx +++ b/admin/inertia/components/chat/index.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useRef, useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import ChatSidebar from './ChatSidebar' import ChatInterface from './ChatInterface' +import KbPolicyPromptBanner from './KbPolicyPromptBanner' import StyledModal from '../StyledModal' import api from '~/lib/api' import { formatBytes } from '~/lib/util' @@ -366,6 +367,7 @@ export default function Chat({ isInModal={isInModal} />
+

{activeSession?.title || 'New Chat'} diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index f16f730..3d92995 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -853,6 +853,17 @@ class API { })() } + async getKbPolicyPromptState() { + return catchInternal(async () => { + const response = await this.client.get<{ + shouldPrompt: boolean + hasContent: boolean + totalFiles: number + }>('/rag/policy-prompt-state') + return response.data + })() + } + // Wikipedia selector methods async getWikipediaState(): Promise { diff --git a/admin/start/routes.ts b/admin/start/routes.ts index d30dc61..d117463 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -151,6 +151,7 @@ router router.post('/re-embed-all', [RagController, 'reembedAll']) router.post('/reset-and-rebuild', [RagController, 'resetAndRebuild']) router.post('/estimate-batch', [RagController, 'estimateBatch']) + router.get('/policy-prompt-state', [RagController, 'policyPromptState']) router.get('/health', [RagController, 'health']) }) .prefix('/api/rag')