mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-05-25 13:55:05 +02:00
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
8ed0bdfd8f
commit
fd153b46b8
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
119
admin/inertia/components/chat/KbPolicyPromptBanner.tsx
Normal file
119
admin/inertia/components/chat/KbPolicyPromptBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user