diff --git a/frontend/src/api/ai-settings.schemas.ts b/frontend/src/api/ai-settings.schemas.ts index 9b182cce..09631203 100644 --- a/frontend/src/api/ai-settings.schemas.ts +++ b/frontend/src/api/ai-settings.schemas.ts @@ -19,6 +19,7 @@ export const tenantAiProfileSchema = z.object({ keepAlive: z.string(), allowExternalResearch: z.boolean(), webResearchMode: z.string(), + researchSourceKey: z.string(), includeCitations: z.boolean(), maxResearchSources: z.number(), allowedDomains: z.string(), @@ -48,6 +49,7 @@ export const saveTenantAiProfileSchema = z.object({ keepAlive: z.string(), allowExternalResearch: z.boolean(), webResearchMode: z.enum(['Disabled', 'ProviderNative', 'PatchHoundManaged', 'LocalVulnerabilityIntel']), + researchSourceKey: z.string(), includeCitations: z.boolean(), maxResearchSources: z.number().int().positive(), allowedDomains: z.string(), diff --git a/frontend/src/components/features/admin/GlobalEnrichmentSourceManagement.test.tsx b/frontend/src/components/features/admin/GlobalEnrichmentSourceManagement.test.tsx index 6c61baae..039c1d43 100644 --- a/frontend/src/components/features/admin/GlobalEnrichmentSourceManagement.test.tsx +++ b/frontend/src/components/features/admin/GlobalEnrichmentSourceManagement.test.tsx @@ -43,7 +43,9 @@ const nvdSource: EnrichmentSource = { displayName: 'NVD API', enabled: true, credentialMode: 'no-credential', + targets: ['Scheduled'], refreshTtlHours: null, + options: { jinaReader: null }, credentials: { storedCredentialId: null, acceptedCredentialTypes: ['api-key'], diff --git a/frontend/src/components/features/admin/GlobalEnrichmentSourceManagement.tsx b/frontend/src/components/features/admin/GlobalEnrichmentSourceManagement.tsx index 94c0ae25..32d3319f 100644 --- a/frontend/src/components/features/admin/GlobalEnrichmentSourceManagement.tsx +++ b/frontend/src/components/features/admin/GlobalEnrichmentSourceManagement.tsx @@ -125,7 +125,9 @@ export function GlobalEnrichmentSourceManagement({ key: source.key, displayName: source.displayName, enabled: source.enabled, + targets: source.targets, refreshTtlHours: source.refreshTtlHours, + options: source.options, credentials: { storedCredentialId: source.credentials.storedCredentialId ?? null, secret: source.credentials.secret, @@ -285,6 +287,18 @@ export function GlobalEnrichmentSourceManagement({ {source.key} + {source.targets.length > 0 ? ( +
+ {source.targets.map((target) => ( + + {formatTargetLabel(target)} + + ))} +
+ ) : null} {source.runtime.lastError ? (

Enable provider

- When enabled, the worker will invoke this enrichment source during vulnerability processing. + When enabled, selected PatchHound processes can invoke this enrichment source.

+ +
+

Process targets

+ + +
@@ -610,6 +654,131 @@ function EnrichmentSourceEditorSheetContent({ + {source.key === 'jina-reader' && source.options.jinaReader ? ( + +
+
+ Timeout Seconds + + onUpdateJinaReaderOption(source, onUpdateSource, 'timeoutSeconds', Number(event.target.value || 3)) + } + className="h-10" + /> +
+
+ Max Content Chars + + onUpdateJinaReaderOption(source, onUpdateSource, 'maxContentChars', Number(event.target.value || 1000)) + } + className="h-10" + /> +
+
+ Response Format + +
+
+ Reader Engine + +
+
+ Target Selector + + onUpdateJinaReaderOption(source, onUpdateSource, 'targetSelector', event.target.value) + } + className="h-10" + /> +
+
+ Exclude Selector + + onUpdateJinaReaderOption(source, onUpdateSource, 'excludeSelector', event.target.value) + } + className="h-10" + /> +
+
+ Wait For Selector + + onUpdateJinaReaderOption(source, onUpdateSource, 'waitForSelector', event.target.value) + } + className="h-10" + /> +
+
+ + + +
+
+
+ ) : null} + {source.credentials.acceptedCredentialTypes.length > 0 ? ( - ) : source.credentialMode === 'global-secret' || source.key === 'nvd' ? ( + ) : source.credentialMode === 'global-secret' || source.credentialMode === 'optional-global-secret' || source.key === 'nvd' ? (
API Key{source.key === 'nvd' ? ' (optional)' : ''} @@ -859,7 +1028,7 @@ function StoredCredentialSelector({ Enter a provider-specific secret below, or select a reusable global credential.

)} - {source.credentialMode === 'global-secret' || source.key === 'nvd' ? ( + {source.credentialMode === 'global-secret' || source.credentialMode === 'optional-global-secret' || source.key === 'nvd' ? ( item.toLowerCase() !== target.toLowerCase()) + return enabled ? [...normalized, target] : normalized +} + +function formatTargetLabel(target: string) { + switch (target.toLowerCase()) { + case 'airesearch': + return 'AI research' + case 'scheduled': + return 'Scheduled' + default: + return target + } +} + +function onUpdateJinaReaderOption>( + source: EnrichmentSourceDraft, + onUpdateSource: (key: string, mutate: (current: EnrichmentSourceDraft) => EnrichmentSourceDraft) => void, + key: TKey, + value: NonNullable[TKey], +) { + onUpdateSource(source.key, (current) => { + if (!current.options.jinaReader) return current + + return { + ...current, + options: { + ...current.options, + jinaReader: { + ...current.options.jinaReader, + [key]: value, + }, + }, + } + }) +} + function formatTimestamp(value: string | null) { if (!value) return 'Never' const date = new Date(value) @@ -936,6 +1143,11 @@ function getProviderStatusDescription(source: EnrichmentSource) { return 'Enriches software items with end-of-life date and support status from the endoflife.date database.' } + if (source.key === 'jina-reader') { + if (!source.enabled) return 'Jina Reader is configured but currently inactive.' + return 'Jina Reader can fetch public web pages as research context for AI assessments. API keys are optional.' + } + return source.enabled ? 'This shared provider is available to enrich tenant vulnerability data during worker processing.' : 'This shared provider is configured but not currently used by the worker.' diff --git a/frontend/src/components/features/settings/TenantAiSettingsPage.tsx b/frontend/src/components/features/settings/TenantAiSettingsPage.tsx index 3725f290..08914762 100644 --- a/frontend/src/components/features/settings/TenantAiSettingsPage.tsx +++ b/frontend/src/components/features/settings/TenantAiSettingsPage.tsx @@ -1,5 +1,5 @@ -import { useMemo, useState } from 'react' -import { useNavigate } from '@tanstack/react-router' +import { useEffect, useMemo, useState } from 'react' +import { Link, useNavigate } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { @@ -22,6 +22,7 @@ import { validateTenantAiProfile, } from '@/api/ai-settings.functions' import type { SaveTenantAiProfile, TenantAiProfile } from '@/api/ai-settings.schemas' +import { fetchEnrichmentSources, type EnrichmentSource } from '@/server/system.functions' import { useTenantScope } from '@/components/layout/tenant-scope' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -126,6 +127,7 @@ function createEmptyProfile(): SaveTenantAiProfile { keepAlive: '', allowExternalResearch: false, webResearchMode: 'Disabled', + researchSourceKey: '', includeCitations: true, maxResearchSources: 5, allowedDomains: '', @@ -154,6 +156,7 @@ function toDraft(profile: TenantAiProfile): SaveTenantAiProfile { keepAlive: profile.keepAlive, allowExternalResearch: profile.allowExternalResearch, webResearchMode: profile.webResearchMode as SaveTenantAiProfile['webResearchMode'], + researchSourceKey: profile.researchSourceKey, includeCitations: profile.includeCitations, maxResearchSources: profile.maxResearchSources, allowedDomains: profile.allowedDomains, @@ -235,6 +238,11 @@ export function TenantAiSettingsPage({ enabled: !!selectedTenantId, }) + const enrichmentSourcesQuery = useQuery({ + queryKey: ['enrichment-sources'], + queryFn: () => fetchEnrichmentSources(), + }) + const profiles = profilesQuery.data ?? EMPTY_PROFILES const editingProfileId = effectiveMode === 'edit' ? effectiveProfileId ?? null : null const isCreateMode = effectiveMode === 'new' @@ -348,6 +356,7 @@ export function TenantAiSettingsPage({ modelError={modelsMutation.isError ? getApiErrorMessage(modelsMutation.error, 'Failed to list available models.') : null} isListingModels={modelsMutation.isPending} onListModels={(id) => modelsMutation.mutate(id)} + enrichmentSources={enrichmentSourcesQuery.data ?? []} onDraftChange={setDraft} onSave={() => saveMutation.mutate(draft)} onBack={closeEditor} @@ -531,6 +540,7 @@ function AiProfileEditorPage({ modelError, isListingModels, onListModels, + enrichmentSources, onDraftChange, onSave, onBack, @@ -547,11 +557,47 @@ function AiProfileEditorPage({ modelError: string | null isListingModels: boolean onListModels: (id: string) => void + enrichmentSources: EnrichmentSource[] onDraftChange: React.Dispatch> onSave: () => void onBack: () => void }) { const saveLabel = profile ? 'Save changes' : 'Create profile' + const aiResearchSources = useMemo( + () => + enrichmentSources.filter( + (source) => + source.enabled && + source.targets.some((target) => target.toLowerCase() === 'airesearch'), + ), + [enrichmentSources], + ) + const selectedResearchSource = aiResearchSources.find((source) => source.key === draft.researchSourceKey) ?? null + const defaultResearchSourceKey = aiResearchSources[0]?.key ?? '' + const canUseProviderNativeResearch = draft.providerType === 'OpenAi' + const canUseManagedResearch = aiResearchSources.length > 0 + const noResearchToolsAvailable = !canUseManagedResearch && !canUseProviderNativeResearch + + useEffect(() => { + if ( + draft.allowExternalResearch && + draft.webResearchMode === 'PatchHoundManaged' && + !draft.researchSourceKey && + defaultResearchSourceKey + ) { + onDraftChange((current) => + current.researchSourceKey + ? current + : { ...current, researchSourceKey: defaultResearchSourceKey }, + ) + } + }, [ + defaultResearchSourceKey, + draft.allowExternalResearch, + draft.researchSourceKey, + draft.webResearchMode, + onDraftChange, + ]) return (
@@ -613,8 +659,16 @@ function AiProfileEditorPage({ ? current.webResearchMode === 'Disabled' ? 'ProviderNative' : current.webResearchMode - : 'PatchHoundManaged' + : canUseManagedResearch + ? 'PatchHoundManaged' + : 'Disabled' : 'Disabled', + allowExternalResearch: + current.allowExternalResearch && (provider.type === 'OpenAi' || canUseManagedResearch), + researchSourceKey: + provider.type === 'OpenAi' && current.webResearchMode === 'ProviderNative' + ? '' + : current.researchSourceKey || defaultResearchSourceKey, })) }} > @@ -872,31 +926,56 @@ function AiProfileEditorPage({
-