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:
parent
4479919632
commit
60e7d45908
|
|
@ -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) {
|
public async scanAndSync({ response }: HttpContext) {
|
||||||
try {
|
try {
|
||||||
const syncResult = await this.ragService.scanAndSyncStorage()
|
const syncResult = await this.ragService.scanAndSyncStorage()
|
||||||
|
|
|
||||||
|
|
@ -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
|
* Compute conditional warnings (RFC #883 §6) for every source the scanner
|
||||||
* sees on disk. Returns `{ ok, warnings }` — `ok: false` distinguishes a
|
* sees on disk. Returns `{ ok, warnings }` — `ok: false` distinguishes a
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import ChatSidebar from './ChatSidebar'
|
import ChatSidebar from './ChatSidebar'
|
||||||
import ChatInterface from './ChatInterface'
|
import ChatInterface from './ChatInterface'
|
||||||
|
import KbPolicyPromptBanner from './KbPolicyPromptBanner'
|
||||||
import StyledModal from '../StyledModal'
|
import StyledModal from '../StyledModal'
|
||||||
import api from '~/lib/api'
|
import api from '~/lib/api'
|
||||||
import { formatBytes } from '~/lib/util'
|
import { formatBytes } from '~/lib/util'
|
||||||
|
|
@ -366,6 +367,7 @@ export default function Chat({
|
||||||
isInModal={isInModal}
|
isInModal={isInModal}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<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">
|
<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">
|
<h2 className="text-lg font-semibold text-text-primary">
|
||||||
{activeSession?.title || 'New Chat'}
|
{activeSession?.title || 'New Chat'}
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Wikipedia selector methods
|
||||||
|
|
||||||
async getWikipediaState(): Promise<WikipediaState | undefined> {
|
async getWikipediaState(): Promise<WikipediaState | undefined> {
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,7 @@ router
|
||||||
router.post('/re-embed-all', [RagController, 'reembedAll'])
|
router.post('/re-embed-all', [RagController, 'reembedAll'])
|
||||||
router.post('/reset-and-rebuild', [RagController, 'resetAndRebuild'])
|
router.post('/reset-and-rebuild', [RagController, 'resetAndRebuild'])
|
||||||
router.post('/estimate-batch', [RagController, 'estimateBatch'])
|
router.post('/estimate-batch', [RagController, 'estimateBatch'])
|
||||||
|
router.get('/policy-prompt-state', [RagController, 'policyPromptState'])
|
||||||
router.get('/health', [RagController, 'health'])
|
router.get('/health', [RagController, 'health'])
|
||||||
})
|
})
|
||||||
.prefix('/api/rag')
|
.prefix('/api/rag')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue