mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-11 03:12:55 +00:00
Exposes the three per-source graph options in the source's retrieval settings, shown only when the retriever is graphrag: where the walk starts (entities or relationships), whether passages join the walk, and whether vector hits are blended in. Defaults match the backend's measured-best configuration and are filled in for sources saved before the options existed. A note points at the "search tool" exposure, which is what offers the graph to an agent.
921 lines
29 KiB
TypeScript
921 lines
29 KiB
TypeScript
import { type ReactNode, useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
import { Button } from '../../components/ui/button';
|
|
import { Input } from '../../components/ui/input';
|
|
import { Label } from '../../components/ui/label';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '../../components/ui/select';
|
|
import { Switch } from '../../components/ui/switch';
|
|
import type {
|
|
ChunkingStrategy,
|
|
GraphSeedStrategy,
|
|
RetrievalExposure,
|
|
SourceConfig,
|
|
} from '../../models/misc';
|
|
import type { Model } from '../../models/types';
|
|
|
|
import ChevronRight from '../../assets/chevron-right.svg';
|
|
|
|
// Defaults mirror the backend SourceConfig
|
|
// (application/storage/db/source_config.py). A form seeded with these and sent
|
|
// verbatim reproduces today's behavior, so the backend's "absent == default"
|
|
// contract is preserved either way.
|
|
export const DEFAULT_PRESCREEN = {
|
|
candidate_k: 40,
|
|
model: null as string | null,
|
|
batch_size: 10,
|
|
max_keep: 8,
|
|
};
|
|
|
|
// Fully-populated form shape so every control is controlled. Prescreen is
|
|
// flattened into the form (with an `enabled` flag) and re-nested on serialize.
|
|
// `kind` is carried so serialization preserves a source's behavior selector
|
|
// (classic/wiki/graphrag) instead of silently downgrading it.
|
|
export type RetrievalOptionsValue = {
|
|
kind: string;
|
|
chunking: {
|
|
strategy: ChunkingStrategy;
|
|
max_tokens: number;
|
|
min_tokens: number;
|
|
duplicate_headers: boolean;
|
|
};
|
|
retrieval: {
|
|
retriever: string;
|
|
exposure: RetrievalExposure;
|
|
chunks: number;
|
|
score_threshold: number | null;
|
|
rephrase_query: boolean;
|
|
prescreen: {
|
|
enabled: boolean;
|
|
candidate_k: number;
|
|
model: string | null;
|
|
batch_size: number;
|
|
max_keep: number;
|
|
};
|
|
graph: {
|
|
seed_strategy: GraphSeedStrategy;
|
|
passage_nodes: boolean;
|
|
blend_vector: boolean;
|
|
};
|
|
};
|
|
graph: {
|
|
extraction_model: string | null;
|
|
max_chunks: number | null;
|
|
gleanings: number;
|
|
};
|
|
};
|
|
|
|
export const DEFAULT_RETRIEVAL_OPTIONS: RetrievalOptionsValue = {
|
|
kind: 'classic',
|
|
chunking: {
|
|
strategy: 'classic_chunk',
|
|
max_tokens: 1250,
|
|
min_tokens: 150,
|
|
duplicate_headers: false,
|
|
},
|
|
retrieval: {
|
|
retriever: 'classic',
|
|
exposure: 'prefetch',
|
|
chunks: 2,
|
|
score_threshold: null,
|
|
rephrase_query: true,
|
|
prescreen: {
|
|
enabled: false,
|
|
...DEFAULT_PRESCREEN,
|
|
},
|
|
// The configuration that measured best across the corpora tested.
|
|
graph: {
|
|
seed_strategy: 'entities',
|
|
passage_nodes: true,
|
|
blend_vector: true,
|
|
},
|
|
},
|
|
graph: {
|
|
extraction_model: null,
|
|
max_chunks: null,
|
|
gleanings: 0,
|
|
},
|
|
};
|
|
|
|
const CHUNKING_STRATEGIES: ChunkingStrategy[] = [
|
|
'classic_chunk',
|
|
'recursive',
|
|
'markdown',
|
|
'parent_child',
|
|
'semantic',
|
|
];
|
|
|
|
const CLASSIC_RETRIEVER = 'classic';
|
|
const HYBRID_RETRIEVER = 'hybrid';
|
|
const GRAPHRAG_RETRIEVER = 'graphrag';
|
|
// Sentinel select value for "use the instance default model" (Select can't take
|
|
// an empty/null item value); maps to extraction_model = null on change.
|
|
const GRAPH_DEFAULT_MODEL = '__default__';
|
|
|
|
/**
|
|
* Score threshold is a cosine-similarity cutoff; the hybrid (RRF) and graphrag
|
|
* (PPR) retrievers don't produce comparable scores, so the control is hidden
|
|
* for them.
|
|
*/
|
|
export function scoreThresholdHidden(retriever: string): boolean {
|
|
return retriever === HYBRID_RETRIEVER || retriever === GRAPHRAG_RETRIEVER;
|
|
}
|
|
|
|
/**
|
|
* Retriever options to offer. Classic is always available; hybrid and graphrag
|
|
* are gated on instance support, but the currently-selected retriever is always
|
|
* included so an existing source never renders an out-of-range select.
|
|
*/
|
|
export function availableRetrievers(
|
|
current: string,
|
|
hybridAvailable: boolean,
|
|
graphRAGAvailable: boolean,
|
|
): string[] {
|
|
const options = [CLASSIC_RETRIEVER];
|
|
if (hybridAvailable || current === HYBRID_RETRIEVER) {
|
|
options.push(HYBRID_RETRIEVER);
|
|
}
|
|
if (graphRAGAvailable || current === GRAPHRAG_RETRIEVER) {
|
|
options.push(GRAPHRAG_RETRIEVER);
|
|
}
|
|
return options;
|
|
}
|
|
|
|
/**
|
|
* Group eyebrow: an uppercase, tracked title with a normal-weight muted ` · tag`
|
|
* suffix. Reads as more prominent than the individual field labels below it.
|
|
*/
|
|
function GroupHeader({ title, tag }: { title: string; tag: string }) {
|
|
return (
|
|
<h4 className="text-foreground text-xs font-semibold tracking-wider uppercase">
|
|
{title}
|
|
<span className="text-muted-foreground font-normal normal-case">
|
|
{' · '}
|
|
{tag}
|
|
</span>
|
|
</h4>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* One settings row: a left block (medium-weight label plus optional muted
|
|
* description) and a right block holding the control. `alignStart` top-aligns
|
|
* the row for controls paired with a multi-line description; otherwise both
|
|
* sides are vertically centered.
|
|
*/
|
|
function SettingRow({
|
|
label,
|
|
htmlFor,
|
|
description,
|
|
alignStart = false,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
htmlFor?: string;
|
|
description?: ReactNode;
|
|
alignStart?: boolean;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex flex-row justify-between gap-4 py-3 first:pt-0 last:pb-0',
|
|
alignStart ? 'items-start' : 'items-center',
|
|
)}
|
|
>
|
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
<Label
|
|
htmlFor={htmlFor}
|
|
className="text-foreground pointer-events-none w-fit text-sm font-medium"
|
|
>
|
|
{label}
|
|
</Label>
|
|
{description ? (
|
|
<p className="text-muted-foreground text-xs">{description}</p>
|
|
) : null}
|
|
</div>
|
|
<div className="shrink-0">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Hydrate the form value from a stored (possibly partial/absent) SourceConfig,
|
|
* filling every missing field with the documented default. Lenient on read so a
|
|
* legacy `{}` row produces an all-defaults form.
|
|
*/
|
|
export function configToOptions(config?: SourceConfig): RetrievalOptionsValue {
|
|
const chunking = config?.chunking ?? {};
|
|
const retrieval = config?.retrieval ?? {};
|
|
const prescreen = retrieval.prescreen ?? null;
|
|
const retrievalGraph = retrieval.graph ?? {};
|
|
const graph = config?.graph ?? {};
|
|
const d = DEFAULT_RETRIEVAL_OPTIONS;
|
|
return {
|
|
kind: config?.kind ?? d.kind,
|
|
chunking: {
|
|
strategy: chunking.strategy ?? d.chunking.strategy,
|
|
max_tokens: chunking.max_tokens ?? d.chunking.max_tokens,
|
|
min_tokens: chunking.min_tokens ?? d.chunking.min_tokens,
|
|
duplicate_headers:
|
|
chunking.duplicate_headers ?? d.chunking.duplicate_headers,
|
|
},
|
|
retrieval: {
|
|
retriever: retrieval.retriever ?? d.retrieval.retriever,
|
|
exposure: retrieval.exposure ?? d.retrieval.exposure,
|
|
chunks: retrieval.chunks ?? d.retrieval.chunks,
|
|
score_threshold: retrieval.score_threshold ?? d.retrieval.score_threshold,
|
|
rephrase_query: retrieval.rephrase_query ?? d.retrieval.rephrase_query,
|
|
prescreen: {
|
|
enabled: prescreen != null,
|
|
candidate_k: prescreen?.candidate_k ?? DEFAULT_PRESCREEN.candidate_k,
|
|
model: prescreen?.model ?? DEFAULT_PRESCREEN.model,
|
|
batch_size: prescreen?.batch_size ?? DEFAULT_PRESCREEN.batch_size,
|
|
max_keep: prescreen?.max_keep ?? DEFAULT_PRESCREEN.max_keep,
|
|
},
|
|
graph: {
|
|
seed_strategy:
|
|
retrievalGraph.seed_strategy ?? d.retrieval.graph.seed_strategy,
|
|
passage_nodes:
|
|
retrievalGraph.passage_nodes ?? d.retrieval.graph.passage_nodes,
|
|
blend_vector:
|
|
retrievalGraph.blend_vector ?? d.retrieval.graph.blend_vector,
|
|
},
|
|
},
|
|
graph: {
|
|
extraction_model: graph.extraction_model ?? d.graph.extraction_model,
|
|
max_chunks: graph.max_chunks ?? d.graph.max_chunks,
|
|
gleanings: graph.gleanings ?? d.graph.gleanings,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Serialize the form value into a full SourceConfig object the backend accepts.
|
|
* The backend uses `extra="forbid"` and re-validates the whole object, so we
|
|
* always send the complete (kind + chunking + retrieval + graph) shape.
|
|
* Prescreen is re-nested to `null` when disabled. `kind` is preserved from the
|
|
* value and forced to `graphrag` when the graphrag retriever is chosen (so the
|
|
* create-flow ingest auto-extracts), never silently downgrading wiki/graphrag.
|
|
*/
|
|
export function optionsToConfig(value: RetrievalOptionsValue): SourceConfig {
|
|
const ps = value.retrieval.prescreen;
|
|
const kind =
|
|
value.retrieval.retriever === GRAPHRAG_RETRIEVER ? 'graphrag' : value.kind;
|
|
return {
|
|
kind,
|
|
chunking: {
|
|
strategy: value.chunking.strategy,
|
|
max_tokens: value.chunking.max_tokens,
|
|
min_tokens: value.chunking.min_tokens,
|
|
duplicate_headers: value.chunking.duplicate_headers,
|
|
},
|
|
retrieval: {
|
|
retriever: value.retrieval.retriever,
|
|
exposure: value.retrieval.exposure,
|
|
chunks: value.retrieval.chunks,
|
|
score_threshold: value.retrieval.score_threshold,
|
|
rephrase_query: value.retrieval.rephrase_query,
|
|
prescreen: ps.enabled
|
|
? {
|
|
candidate_k: ps.candidate_k,
|
|
model: ps.model?.trim() ? ps.model.trim() : null,
|
|
batch_size: ps.batch_size,
|
|
max_keep: ps.max_keep,
|
|
}
|
|
: null,
|
|
graph: {
|
|
seed_strategy: value.retrieval.graph.seed_strategy,
|
|
passage_nodes: value.retrieval.graph.passage_nodes,
|
|
blend_vector: value.retrieval.graph.blend_vector,
|
|
},
|
|
},
|
|
graph: {
|
|
extraction_model: value.graph.extraction_model?.trim()
|
|
? value.graph.extraction_model.trim()
|
|
: null,
|
|
max_chunks: value.graph.max_chunks,
|
|
gleanings: value.graph.gleanings,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* True when the prescreen config is consistent with the backend's rules.
|
|
* Disabled prescreen is always valid; enabled requires `candidate_k >= chunks`
|
|
* and `max_keep <= candidate_k` (mirrors SourceConfig/PreScreenConfig).
|
|
*/
|
|
export function isPrescreenConfigValid(value: RetrievalOptionsValue): boolean {
|
|
const ps = value.retrieval.prescreen;
|
|
if (!ps.enabled) return true;
|
|
return (
|
|
ps.candidate_k >= value.retrieval.chunks && ps.max_keep <= ps.candidate_k
|
|
);
|
|
}
|
|
|
|
/** True when the form differs from the stored config's chunking group only. */
|
|
export function chunkingChanged(
|
|
before: RetrievalOptionsValue,
|
|
after: RetrievalOptionsValue,
|
|
): boolean {
|
|
return (
|
|
before.chunking.strategy !== after.chunking.strategy ||
|
|
before.chunking.max_tokens !== after.chunking.max_tokens ||
|
|
before.chunking.min_tokens !== after.chunking.min_tokens ||
|
|
before.chunking.duplicate_headers !== after.chunking.duplicate_headers
|
|
);
|
|
}
|
|
|
|
type RetrievalOptionsProps = {
|
|
value: RetrievalOptionsValue;
|
|
onChange: (value: RetrievalOptionsValue) => void;
|
|
// When true the section is always expanded (modal use); when false it renders
|
|
// its own collapsible toggle (create-flow use). Defaults to collapsible.
|
|
alwaysOpen?: boolean;
|
|
disabled?: boolean;
|
|
// Adds the hybrid retriever option when the instance supports it (pgvector,
|
|
// from /api/config).
|
|
hybridAvailable?: boolean;
|
|
// Adds the graphrag retriever option + extraction config when the instance
|
|
// supports it (pgvector + GRAPHRAG_ENABLED, from /api/config).
|
|
graphRAGAvailable?: boolean;
|
|
// Models for the graph extraction-model picker, same shape as the agent form.
|
|
availableModels?: Model[];
|
|
// Shows only the knobs that change what a query retrieves, hiding the
|
|
// ingest-time groups (chunking, graph extraction) and `exposure` (which picks
|
|
// *when* a source is searched at answer time, not what comes back). Used by
|
|
// the retrieval tester, where those knobs cannot affect the result and
|
|
// showing them would imply they do.
|
|
queryOnly?: boolean;
|
|
};
|
|
|
|
/**
|
|
* Shared "Retrieval options" panel reused by the create flow (Upload) and the
|
|
* edit modal (SourceConfigModal). Groups live "Retrieval" knobs from
|
|
* re-ingest-gated "Chunking" knobs, with a visible note on the chunking group.
|
|
*/
|
|
export default function RetrievalOptions({
|
|
value,
|
|
onChange,
|
|
alwaysOpen = false,
|
|
disabled = false,
|
|
hybridAvailable = false,
|
|
graphRAGAvailable = false,
|
|
availableModels = [],
|
|
queryOnly = false,
|
|
}: RetrievalOptionsProps) {
|
|
const { t } = useTranslation();
|
|
const [open, setOpen] = useState(false);
|
|
const expanded = alwaysOpen || open;
|
|
|
|
const strategyOptions = useMemo(
|
|
() =>
|
|
CHUNKING_STRATEGIES.map((s) => ({
|
|
value: s,
|
|
label: t(`settings.sources.retrievalOptions.chunking.strategies.${s}`),
|
|
})),
|
|
[t],
|
|
);
|
|
|
|
const isGraphRAG = value.retrieval.retriever === GRAPHRAG_RETRIEVER;
|
|
const retrievers = useMemo(
|
|
() =>
|
|
availableRetrievers(
|
|
value.retrieval.retriever,
|
|
hybridAvailable,
|
|
graphRAGAvailable,
|
|
),
|
|
[value.retrieval.retriever, hybridAvailable, graphRAGAvailable],
|
|
);
|
|
|
|
const setRetrieval = (patch: Partial<RetrievalOptionsValue['retrieval']>) => {
|
|
onChange({
|
|
...value,
|
|
retrieval: { ...value.retrieval, ...patch },
|
|
});
|
|
};
|
|
|
|
const setChunking = (patch: Partial<RetrievalOptionsValue['chunking']>) => {
|
|
onChange({
|
|
...value,
|
|
chunking: { ...value.chunking, ...patch },
|
|
});
|
|
};
|
|
|
|
const setPrescreen = (
|
|
patch: Partial<RetrievalOptionsValue['retrieval']['prescreen']>,
|
|
) => {
|
|
setRetrieval({
|
|
prescreen: { ...value.retrieval.prescreen, ...patch },
|
|
});
|
|
};
|
|
|
|
const setGraph = (patch: Partial<RetrievalOptionsValue['graph']>) => {
|
|
onChange({
|
|
...value,
|
|
graph: { ...value.graph, ...patch },
|
|
});
|
|
};
|
|
|
|
const setGraphRetrieval = (
|
|
patch: Partial<RetrievalOptionsValue['retrieval']['graph']>,
|
|
) => {
|
|
setRetrieval({ graph: { ...value.retrieval.graph, ...patch } });
|
|
};
|
|
|
|
const modelOptions = useMemo(() => {
|
|
const builtin: Model[] = [];
|
|
const user: Model[] = [];
|
|
availableModels.forEach((m) =>
|
|
(m.source === 'user' ? user : builtin).push(m),
|
|
);
|
|
return { builtin, user };
|
|
}, [availableModels]);
|
|
|
|
const tr = (key: string) => t(`settings.sources.retrievalOptions.${key}`);
|
|
|
|
const body = (
|
|
<div className="flex flex-col gap-6">
|
|
{/* Retrieval group (live) */}
|
|
<div className="flex flex-col gap-3">
|
|
<GroupHeader title={tr('retrieval.title')} tag={tr('retrieval.tag')} />
|
|
|
|
<div className="divide-border/50 divide-y">
|
|
<SettingRow
|
|
label={tr('retrieval.retriever')}
|
|
htmlFor="retrieval-retriever"
|
|
description={tr('retrieval.retrieverHint')}
|
|
alignStart
|
|
>
|
|
<Select
|
|
value={value.retrieval.retriever}
|
|
disabled={disabled}
|
|
onValueChange={(v) => setRetrieval({ retriever: v })}
|
|
>
|
|
<SelectTrigger
|
|
id="retrieval-retriever"
|
|
className="w-52 rounded-md"
|
|
size="lg"
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{retrievers.map((r) => (
|
|
<SelectItem key={r} value={r}>
|
|
{tr(`retrieval.retrievers.${r}`)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</SettingRow>
|
|
|
|
<SettingRow label={tr('retrieval.chunks')} htmlFor="retrieval-chunks">
|
|
<Input
|
|
id="retrieval-chunks"
|
|
type="number"
|
|
min={1}
|
|
className="w-24 text-right"
|
|
value={String(value.retrieval.chunks)}
|
|
disabled={disabled}
|
|
onChange={(e) =>
|
|
setRetrieval({
|
|
chunks: Math.max(1, Number(e.target.value) || 1),
|
|
})
|
|
}
|
|
/>
|
|
</SettingRow>
|
|
|
|
{!scoreThresholdHidden(value.retrieval.retriever) && (
|
|
<SettingRow
|
|
label={tr('retrieval.scoreThreshold')}
|
|
htmlFor="retrieval-score-threshold"
|
|
>
|
|
<Input
|
|
id="retrieval-score-threshold"
|
|
type="number"
|
|
min={0}
|
|
max={1}
|
|
step="0.01"
|
|
className="w-24 text-right"
|
|
value={
|
|
value.retrieval.score_threshold === null
|
|
? ''
|
|
: String(value.retrieval.score_threshold)
|
|
}
|
|
disabled={disabled}
|
|
placeholder={tr('retrieval.scoreThresholdPlaceholder')}
|
|
onChange={(e) => {
|
|
const raw = e.target.value;
|
|
setRetrieval({
|
|
score_threshold:
|
|
raw === ''
|
|
? null
|
|
: Math.min(1, Math.max(0, Number(raw) || 0)),
|
|
});
|
|
}}
|
|
/>
|
|
</SettingRow>
|
|
)}
|
|
|
|
{/* Rephrasing only fires when there is chat history to rephrase
|
|
against, and a test has none — so the switch would do nothing. */}
|
|
{!queryOnly && (
|
|
<SettingRow
|
|
label={tr('retrieval.rephraseQuery')}
|
|
htmlFor="retrieval-rephrase"
|
|
>
|
|
<Switch
|
|
id="retrieval-rephrase"
|
|
checked={value.retrieval.rephrase_query}
|
|
disabled={disabled}
|
|
onCheckedChange={(checked) =>
|
|
setRetrieval({ rephrase_query: checked })
|
|
}
|
|
/>
|
|
</SettingRow>
|
|
)}
|
|
|
|
{!queryOnly && (
|
|
<SettingRow
|
|
label={tr('retrieval.exposure')}
|
|
htmlFor="retrieval-exposure"
|
|
description={tr('retrieval.exposureHint')}
|
|
alignStart
|
|
>
|
|
<Select
|
|
value={value.retrieval.exposure}
|
|
disabled={disabled}
|
|
onValueChange={(v) =>
|
|
setRetrieval({ exposure: v as RetrievalExposure })
|
|
}
|
|
>
|
|
<SelectTrigger
|
|
id="retrieval-exposure"
|
|
className="w-52 rounded-md"
|
|
size="lg"
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="prefetch">
|
|
{tr('retrieval.exposures.prefetch')}
|
|
</SelectItem>
|
|
<SelectItem value="agentic_tool">
|
|
{tr('retrieval.exposures.agentic_tool')}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</SettingRow>
|
|
)}
|
|
|
|
<SettingRow
|
|
label={tr('prescreen.enable')}
|
|
htmlFor="retrieval-prescreen"
|
|
description={tr('prescreen.warning')}
|
|
alignStart
|
|
>
|
|
<Switch
|
|
id="retrieval-prescreen"
|
|
checked={value.retrieval.prescreen.enabled}
|
|
disabled={disabled}
|
|
onCheckedChange={(checked) => setPrescreen({ enabled: checked })}
|
|
/>
|
|
</SettingRow>
|
|
</div>
|
|
|
|
{/* Prescreen expanded inputs (kept as floating-label cards) */}
|
|
{value.retrieval.prescreen.enabled && (
|
|
<div className="border-border ml-4 flex flex-col gap-4 rounded-lg border p-4">
|
|
<Input
|
|
type="number"
|
|
min={value.retrieval.chunks}
|
|
label={tr('prescreen.candidateK')}
|
|
value={String(value.retrieval.prescreen.candidate_k)}
|
|
disabled={disabled}
|
|
labelBgClassName="bg-card"
|
|
onChange={(e) =>
|
|
setPrescreen({
|
|
candidate_k: Math.max(
|
|
value.retrieval.chunks,
|
|
Number(e.target.value) || value.retrieval.chunks,
|
|
),
|
|
})
|
|
}
|
|
/>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
label={tr('prescreen.maxKeep')}
|
|
value={String(value.retrieval.prescreen.max_keep)}
|
|
disabled={disabled}
|
|
labelBgClassName="bg-card"
|
|
onChange={(e) =>
|
|
setPrescreen({
|
|
max_keep: Math.min(
|
|
value.retrieval.prescreen.candidate_k,
|
|
Math.max(1, Number(e.target.value) || 1),
|
|
),
|
|
})
|
|
}
|
|
/>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
label={tr('prescreen.batchSize')}
|
|
value={String(value.retrieval.prescreen.batch_size)}
|
|
disabled={disabled}
|
|
labelBgClassName="bg-card"
|
|
onChange={(e) =>
|
|
setPrescreen({
|
|
batch_size: Math.max(1, Number(e.target.value) || 1),
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Graph retrieval group (graphrag only; live, so shown when testing too) */}
|
|
{isGraphRAG && (
|
|
<div className="flex flex-col gap-3">
|
|
<GroupHeader
|
|
title={tr('graphRetrieval.title')}
|
|
tag={tr('graphRetrieval.tag')}
|
|
/>
|
|
<p className="text-muted-foreground text-xs">
|
|
{tr('graphRetrieval.agentToolHint')}
|
|
</p>
|
|
|
|
<div className="divide-border/50 divide-y">
|
|
<SettingRow
|
|
label={tr('graphRetrieval.seedStrategy')}
|
|
htmlFor="graph-seed-strategy"
|
|
description={tr('graphRetrieval.seedStrategyHint')}
|
|
alignStart
|
|
>
|
|
<Select
|
|
value={value.retrieval.graph.seed_strategy}
|
|
disabled={disabled}
|
|
onValueChange={(v) =>
|
|
setGraphRetrieval({ seed_strategy: v as GraphSeedStrategy })
|
|
}
|
|
>
|
|
<SelectTrigger
|
|
id="graph-seed-strategy"
|
|
className="w-52 rounded-md"
|
|
size="lg"
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="entities">
|
|
{tr('graphRetrieval.seedEntities')}
|
|
</SelectItem>
|
|
<SelectItem value="relationships">
|
|
{tr('graphRetrieval.seedRelationships')}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</SettingRow>
|
|
|
|
<SettingRow
|
|
label={tr('graphRetrieval.passageNodes')}
|
|
htmlFor="graph-passage-nodes"
|
|
description={tr('graphRetrieval.passageNodesHint')}
|
|
alignStart
|
|
>
|
|
<Switch
|
|
id="graph-passage-nodes"
|
|
checked={value.retrieval.graph.passage_nodes}
|
|
disabled={disabled}
|
|
onCheckedChange={(checked) =>
|
|
setGraphRetrieval({ passage_nodes: checked })
|
|
}
|
|
/>
|
|
</SettingRow>
|
|
|
|
<SettingRow
|
|
label={tr('graphRetrieval.blendVector')}
|
|
htmlFor="graph-blend-vector"
|
|
description={tr('graphRetrieval.blendVectorHint')}
|
|
alignStart
|
|
>
|
|
<Switch
|
|
id="graph-blend-vector"
|
|
checked={value.retrieval.graph.blend_vector}
|
|
disabled={disabled}
|
|
onCheckedChange={(checked) =>
|
|
setGraphRetrieval({ blend_vector: checked })
|
|
}
|
|
/>
|
|
</SettingRow>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Graph extraction group (graphrag only; re-ingest required to apply) */}
|
|
{isGraphRAG && !queryOnly && (
|
|
<div className="flex flex-col gap-3">
|
|
<GroupHeader title={tr('graph.title')} tag={tr('graph.tag')} />
|
|
|
|
<div className="divide-border/50 divide-y">
|
|
<SettingRow
|
|
label={tr('graph.extractionModel')}
|
|
htmlFor="graph-extraction-model"
|
|
description={tr('graph.extractionModelHint')}
|
|
alignStart
|
|
>
|
|
<Select
|
|
value={value.graph.extraction_model ?? GRAPH_DEFAULT_MODEL}
|
|
disabled={disabled}
|
|
onValueChange={(v) =>
|
|
setGraph({
|
|
extraction_model: v === GRAPH_DEFAULT_MODEL ? null : v,
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger
|
|
id="graph-extraction-model"
|
|
className="w-52 rounded-md"
|
|
size="lg"
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={GRAPH_DEFAULT_MODEL}>
|
|
{tr('graph.defaultModel')}
|
|
</SelectItem>
|
|
{modelOptions.builtin.map((m) => (
|
|
<SelectItem key={`builtin-${m.id}`} value={m.id}>
|
|
{m.display_name}
|
|
</SelectItem>
|
|
))}
|
|
{modelOptions.user.map((m) => (
|
|
<SelectItem key={`user-${m.id}`} value={m.id}>
|
|
{m.display_name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</SettingRow>
|
|
|
|
<SettingRow
|
|
label={tr('graph.maxChunks')}
|
|
htmlFor="graph-max-chunks"
|
|
description={tr('graph.maxChunksHint')}
|
|
alignStart
|
|
>
|
|
<Input
|
|
id="graph-max-chunks"
|
|
type="number"
|
|
min={1}
|
|
className="w-24 text-right"
|
|
value={
|
|
value.graph.max_chunks === null
|
|
? ''
|
|
: String(value.graph.max_chunks)
|
|
}
|
|
disabled={disabled}
|
|
placeholder={tr('graph.maxChunksPlaceholder')}
|
|
onChange={(e) => {
|
|
const raw = e.target.value;
|
|
setGraph({
|
|
max_chunks:
|
|
raw === '' ? null : Math.max(1, Number(raw) || 1),
|
|
});
|
|
}}
|
|
/>
|
|
</SettingRow>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Chunking group (re-ingest required) */}
|
|
<div className={cn('flex flex-col gap-3', queryOnly && 'hidden')}>
|
|
<GroupHeader title={tr('chunking.title')} tag={tr('chunking.tag')} />
|
|
|
|
<div className="divide-border/50 divide-y">
|
|
<SettingRow
|
|
label={tr('chunking.strategy')}
|
|
htmlFor="chunking-strategy"
|
|
>
|
|
<Select
|
|
value={value.chunking.strategy}
|
|
disabled={disabled}
|
|
onValueChange={(v) =>
|
|
setChunking({ strategy: v as ChunkingStrategy })
|
|
}
|
|
>
|
|
<SelectTrigger
|
|
id="chunking-strategy"
|
|
className="w-52 rounded-md"
|
|
size="lg"
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{strategyOptions.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</SettingRow>
|
|
|
|
<SettingRow
|
|
label={tr('chunking.maxTokens')}
|
|
htmlFor="chunking-max-tokens"
|
|
>
|
|
<Input
|
|
id="chunking-max-tokens"
|
|
type="number"
|
|
min={1}
|
|
className="w-24 text-right"
|
|
value={String(value.chunking.max_tokens)}
|
|
disabled={disabled}
|
|
onChange={(e) =>
|
|
setChunking({
|
|
max_tokens: Math.max(1, Number(e.target.value) || 1),
|
|
})
|
|
}
|
|
/>
|
|
</SettingRow>
|
|
|
|
<SettingRow
|
|
label={tr('chunking.minTokens')}
|
|
htmlFor="chunking-min-tokens"
|
|
>
|
|
<Input
|
|
id="chunking-min-tokens"
|
|
type="number"
|
|
min={0}
|
|
className="w-24 text-right"
|
|
value={String(value.chunking.min_tokens)}
|
|
disabled={disabled}
|
|
onChange={(e) =>
|
|
setChunking({
|
|
min_tokens: Math.max(0, Number(e.target.value) || 0),
|
|
})
|
|
}
|
|
/>
|
|
</SettingRow>
|
|
|
|
<SettingRow
|
|
label={tr('chunking.duplicateHeaders')}
|
|
htmlFor="chunking-dup-headers"
|
|
>
|
|
<Switch
|
|
id="chunking-dup-headers"
|
|
checked={value.chunking.duplicate_headers}
|
|
disabled={disabled}
|
|
onCheckedChange={(checked) =>
|
|
setChunking({ duplicate_headers: checked })
|
|
}
|
|
/>
|
|
</SettingRow>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
if (alwaysOpen) {
|
|
return body;
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<Button
|
|
type="button"
|
|
variant="link"
|
|
onClick={() => setOpen((o) => !o)}
|
|
className="text-foreground hover:text-foreground h-auto w-fit justify-start px-0 py-2 text-sm font-normal hover:no-underline"
|
|
>
|
|
<img
|
|
src={ChevronRight}
|
|
alt=""
|
|
className={`h-3 w-3 transform transition-transform dark:invert ${
|
|
expanded ? 'rotate-90' : ''
|
|
}`}
|
|
/>
|
|
<span>{tr('title')}</span>
|
|
</Button>
|
|
<div
|
|
className={`grid transition-all duration-300 ease-in-out ${
|
|
expanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
|
|
}`}
|
|
>
|
|
<div className="overflow-hidden">{body}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|