feat(KB): first-chat JIT prompt for ingest policy (RFC #883 Phase 3 task 12)

When a user opens AI Chat with content available but no global ingest
policy yet recorded, surface a one-time banner above the chat header
asking how they want new content handled:

  - 'Index existing content' -> sets rag.defaultIngestPolicy=Always and
    triggers a sync so pending_decision files queue immediately
  - 'Maybe later' -> sets policy=Manual; existing and future content
    waits in pending_decision until the user opts in from the KB modal

After either button is clicked the banner never reappears, because both
write the policy KV (the same one #894 manages via the KB modal toggle).
There is intentionally no 'dismiss without deciding' X — that would just
re-show the banner forever.

Backend
- New GET /api/rag/policy-prompt-state returns
  {shouldPrompt, hasContent, totalFiles}
- RagService.getPolicyPromptState() reads KVStore('rag.defaultIngestPolicy')
  and counts kb_ingest_state rows; shouldPrompt is true only when policy
  is null AND scanner has seen >=1 file (avoids prompting on empty NOMADs)

Frontend
- New KbPolicyPromptBanner component (~120 LOC) handles the two-button
  decision flow with optimistic loading state, success/error toasts, and
  invalidates kbPolicyPromptState + ingestPolicy + embed-jobs + storedFiles
  on success
- Mounted in components/chat/index.tsx as the first child of the main
  content column so it sits above the chat title bar without taking space
  when shouldPrompt is false (renders nothing)
- Reads aiAssistantName from Inertia page props so banner copy matches
  the user's chosen assistant name

Stacks on feat/kb-policy-toggle (#894) because the policy KV mechanism
it writes through is introduced there. Both can land in rc.5; this PR
auto-rebases to rc once #894 merges.

Existing users on first upgrade to v1.32.0 will see this banner on first
chat visit post-upgrade — an explicit opt-in moment for content that was
already on disk. New users see it the first time they have curated
content downloaded.
This commit is contained in:
Chris Sherwood 2026-05-14 16:16:28 -07:00 committed by Jake Turner
parent 8ed0bdfd8f
commit fd153b46b8
6 changed files with 164 additions and 0 deletions

View File

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

View File

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

View File

@ -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 (
<div className="px-6 py-3 bg-blue-50 dark:bg-blue-950/30 border-b border-blue-200 dark:border-blue-800 flex-shrink-0">
<div className="flex items-center gap-3">
<IconBrain className="h-6 w-6 text-blue-600 dark:text-blue-300 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm text-text-primary">
<strong>
{fileCount === 1
? `Index your existing file for ${aiAssistantName}?`
: `Index your ${fileCount.toLocaleString()} existing files for ${aiAssistantName}?`}
</strong>
{' '}When indexed, {aiAssistantName} can reference them while answering your questions.
</p>
</div>
<div className="flex gap-2 flex-shrink-0">
<StyledButton
onClick={() => indexNowMutation.mutate()}
variant="primary"
size="sm"
disabled={isBusy}
loading={indexNowMutation.isPending}
>
Index existing content
</StyledButton>
<StyledButton
onClick={() => maybeLaterMutation.mutate()}
variant="ghost"
size="sm"
disabled={isBusy}
loading={maybeLaterMutation.isPending}
>
Maybe later
</StyledButton>
</div>
</div>
</div>
)
}

View File

@ -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}
/>
<div className="flex-1 flex flex-col min-h-0">
<KbPolicyPromptBanner />
<div className="px-6 py-3 border-b border-border-subtle bg-surface-secondary flex items-center justify-between h-[75px] flex-shrink-0">
<h2 className="text-lg font-semibold text-text-primary">
{activeSession?.title || 'New Chat'}

View File

@ -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<WikipediaState | undefined> {

View File

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