Files
ragflow/web/src/components/ui/radio.tsx
CaptainTimon 2717ee283f feat(raptor): add Psi tree builder with original-space ranking and safe migration (#14679)
### What problem does this PR solve?

Closes #14674.

This PR improves RAPTOR configuration and tree construction while
preserving the existing RAPTOR behavior as the default.

RAPTOR currently builds summary layers with the original UMAP + GMM
clustering path. This PR keeps that default path, and adds:

- A hidden backend tree-builder option:
  - `tree_builder="raptor"`: default, existing RAPTOR behavior.
- `tree_builder="psi"`: rank-aware Psi-style tree builder using original
embedding-space cosine ranking.
- A user-facing clustering method option for the default RAPTOR builder:
  - `clustering_method="gmm"`: existing default.
- `clustering_method="ahc"`: agglomerative hierarchical clustering path.
- A RAPTOR UI setting for `Clustering method` and `Max cluster`.

### What changed

#### Backend

- Added `tree_builder` support for RAPTOR/Psi.
- Added `clustering_method` support for GMM/AHC.
- Kept existing RAPTOR + GMM as the default.
- Added Psi tree building from original-space cosine similarity.
- Added bucketed Psi building controls for large inputs:
  - `raptor.ext.psi_exact_max_leaves`
  - `raptor.ext.psi_bucket_size`
- Added method-aware RAPTOR summary metadata using existing
`extra.raptor_method`.
- Avoided adding a dedicated DB schema field for experimental method
tracking.
- Added cleanup/migration logic to avoid mixing stale RAPTOR summary
trees.
- Added defensive checks for Psi tree construction and summary failures.

#### Frontend/UI

- Added `Clustering method` in RAPTOR settings with `GMM` and `AHC`.
- Added/kept `Max cluster` in RAPTOR settings.
- Enlarged max cluster UI limit to `1024`, matching backend validation.
- Kept AHC editable even when a RAPTOR task has already finished.
- Fixed the UI save payload so `clustering_method` and `tree_builder`
are serialized through `parser_config.raptor.ext`, avoiding backend
validation errors for extra top-level RAPTOR fields.

Example saved RAPTOR config:

```json
{
  "raptor": {
    "max_cluster": 317,
    "ext": {
      "clustering_method": "ahc",
      "tree_builder": "raptor"
    }
  }
}

Co-authored-by: CaptainTimon <CaptainTimon@users.noreply.github.com>
2026-05-12 09:42:31 +08:00

176 lines
4.2 KiB
TypeScript

import { cn } from '@/lib/utils';
import React, { useContext, useState } from 'react';
const RadioGroupContext = React.createContext<{
name?: string;
value: string | number;
onChange: (value: string | number) => void;
disabled?: boolean;
} | null>(null);
type RadioProps = {
value: string | number;
checked?: boolean;
disabled?: boolean;
onChange?: (checked: boolean) => void;
testId?: string;
children?: React.ReactNode;
} & Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'value' | 'checked' | 'onChange'
>;
function Radio({
className,
value,
checked,
disabled,
onChange,
testId,
children,
...props
}: RadioProps) {
const groupContext = useContext(RadioGroupContext);
const isControlled = checked !== undefined;
// const [internalChecked, setInternalChecked] = useState(false);
const isChecked = isControlled ? checked : groupContext?.value === value;
const mergedDisabled = disabled || groupContext?.disabled;
const handleChange = () => {
if (mergedDisabled) return;
// if (!isControlled) {
// setInternalChecked(!isChecked);
// }
if (onChange) {
onChange(!isChecked);
}
if (groupContext && !groupContext.disabled) {
groupContext.onChange(value);
}
};
return (
<label
className={cn(
'group/radio relative flex items-center cursor-pointer gap-2 text-sm',
mergedDisabled && 'cursor-not-allowed opacity-50',
)}
>
<input
type="radio"
value={value}
checked={isChecked}
onChange={handleChange}
disabled={mergedDisabled}
className={cn('peer absolute size-[1px] opacity-0', className)}
data-testid={testId}
{...props}
name={groupContext?.name}
/>
<div
className={cn(
'flex h-4 w-4 items-center justify-center rounded-full text-border-button border border-current transition-colors',
'group-hover/radio:text-border-default hover:text-border-default',
'peer-focus:text-text-primary',
isChecked && 'border-primary bg-primary/10',
mergedDisabled && 'border-muted',
)}
>
<div
className={cn(
'h-2 w-2 fill-primary text-primary bg-text-primary rounded-full opacity-0 scale-0 transition-all',
isChecked && 'opacity-100 scale-100',
)}
/>
</div>
{children && <span className="text-foreground">{children}</span>}
</label>
);
}
type RadioGroupProps = {
name?: string;
value?: string | number;
defaultValue?: string | number;
onChange?: (value: string | number) => void;
disabled?: boolean;
children: React.ReactNode;
className?: string;
direction?: 'horizontal' | 'vertical';
};
const Group = React.forwardRef<HTMLDivElement, RadioGroupProps>(
(
{
name,
value,
defaultValue,
onChange,
disabled,
children,
className,
direction = 'horizontal',
},
ref,
) => {
const [internalValue, setInternalValue] = useState(defaultValue || '');
const isControlled = value !== undefined;
const mergedValue = isControlled ? value : internalValue;
const handleChange = (val: string | number) => {
if (disabled) return;
if (!isControlled) {
setInternalValue(val);
}
if (onChange) {
onChange(val);
}
};
return (
<RadioGroupContext.Provider
value={{
name,
value: mergedValue,
onChange: handleChange,
disabled,
}}
>
<div
ref={ref}
className={cn(
'flex gap-4',
direction === 'vertical' ? 'flex-col' : 'flex-row',
className,
)}
>
{React.Children.map(children, (child) => {
if (!React.isValidElement<RadioProps>(child)) {
return child;
}
return React.cloneElement(child, {
disabled: disabled || child.props.disabled,
});
})}
</div>
</RadioGroupContext.Provider>
);
},
);
const RadioComponent = Object.assign(Radio, {
Group,
});
Group.displayName = 'RadioGroup';
export { RadioComponent as Radio };