* Base ace step 1.5 xl added. Generating, still wip on training and ui * Base training code done * Fix some issues with caching text embeddings. Update sample cards to show audio * Fix issue with quantizing ace step * Add album artwork to samples with waveform. * Cleanup logs * Add album art endpoint to speed up album art loading * Made an make video with artwork script * Make ui handle basic audio models. Make multi line adjustments to the editor and better syntax hilighting. * Add prompt tagging system for special tagged models. * prompt tagging processing for ui working. * Moved default samples to a special file so we can add more when needed and they can be adjusted for a specific model * Add a captioner job with music captioner that is prepped for use with the ui * Add basit ui setup for captioning modal and handeling captioning jobs * Starting captioning job from ui working. Still better management for it. * Better filtering of job options in the job view for captioning jobs * Added qwen3 vl as a captioner for images * Have an indicator when a dataset is being captioned. * Adjust the way caption jobs look in the queue * Fix a few issues. Adjust defaults. * Version bump * Added ace step to the readme.
595 lines
18 KiB
TypeScript
595 lines
18 KiB
TypeScript
'use client';
|
|
|
|
import React, { forwardRef } from 'react';
|
|
import classNames from 'classnames';
|
|
import dynamic from 'next/dynamic';
|
|
import { CircleHelp } from 'lucide-react';
|
|
import { getDoc } from '@/docs';
|
|
import { openDoc } from '@/components/DocModal';
|
|
import { ConfigDoc, GroupedSelectOption, SelectOption } from '@/types';
|
|
|
|
const Select = dynamic(() => import('react-select'), { ssr: false });
|
|
|
|
const labelClasses = 'block text-xs mb-1 mt-2 text-gray-300';
|
|
const inputClasses =
|
|
'w-full text-sm px-3 py-1 bg-gray-800 border border-gray-700 rounded-sm focus:ring-2 focus:ring-gray-600 focus:border-transparent';
|
|
|
|
export interface InputProps {
|
|
label?: string;
|
|
docKey?: string | null;
|
|
doc?: ConfigDoc | null;
|
|
className?: string;
|
|
placeholder?: string;
|
|
required?: boolean;
|
|
}
|
|
|
|
export interface TextInputProps extends InputProps {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
type?: 'text' | 'password';
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>((props: TextInputProps, ref) => {
|
|
const { label, value, onChange, placeholder, required, disabled, type = 'text', className, docKey = null } = props;
|
|
let { doc } = props;
|
|
if (!doc && docKey) {
|
|
doc = getDoc(docKey);
|
|
}
|
|
return (
|
|
<div className={classNames(className)}>
|
|
{label && (
|
|
<label className={labelClasses}>
|
|
{label}{' '}
|
|
{doc && (
|
|
<div className="inline-block ml-1 text-xs text-gray-500 cursor-pointer" onClick={() => openDoc(doc)}>
|
|
<CircleHelp className="inline-block w-4 h-4 cursor-pointer" />
|
|
</div>
|
|
)}
|
|
</label>
|
|
)}
|
|
<input
|
|
ref={ref}
|
|
type={type}
|
|
value={value}
|
|
onChange={e => {
|
|
if (!disabled) onChange(e.target.value);
|
|
}}
|
|
className={`${inputClasses} ${disabled ? 'opacity-30 cursor-not-allowed' : ''}`}
|
|
placeholder={placeholder}
|
|
required={required}
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
// 👇 Helpful for debugging
|
|
TextInput.displayName = 'TextInput';
|
|
|
|
export interface TextAreaInputProps extends InputProps {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
disabled?: boolean;
|
|
rows?: number;
|
|
}
|
|
|
|
export const TextAreaInput = forwardRef<HTMLTextAreaElement, TextAreaInputProps>((props: TextAreaInputProps, ref) => {
|
|
const { label, value, onChange, placeholder, required, disabled, rows = 4, className, docKey = null } = props;
|
|
let { doc } = props;
|
|
if (!doc && docKey) {
|
|
doc = getDoc(docKey);
|
|
}
|
|
return (
|
|
<div className={classNames(className)}>
|
|
{label && (
|
|
<label className={labelClasses}>
|
|
{label}{' '}
|
|
{doc && (
|
|
<div className="inline-block ml-1 text-xs text-gray-500 cursor-pointer" onClick={() => openDoc(doc)}>
|
|
<CircleHelp className="inline-block w-4 h-4 cursor-pointer" />
|
|
</div>
|
|
)}
|
|
</label>
|
|
)}
|
|
<textarea
|
|
ref={ref}
|
|
value={value}
|
|
onChange={e => {
|
|
if (!disabled) onChange(e.target.value);
|
|
}}
|
|
className={`${inputClasses} ${disabled ? 'opacity-30 cursor-not-allowed' : ''}`}
|
|
placeholder={placeholder}
|
|
required={required}
|
|
disabled={disabled}
|
|
rows={rows}
|
|
/>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
TextAreaInput.displayName = 'TextAreaInput';
|
|
|
|
export interface NumberInputProps extends InputProps {
|
|
value: number | null;
|
|
onChange: (value: number | null) => void;
|
|
min?: number;
|
|
max?: number;
|
|
}
|
|
|
|
export const NumberInput = (props: NumberInputProps) => {
|
|
const { label, value, onChange, placeholder, required, min, max, docKey = null } = props;
|
|
let { doc } = props;
|
|
if (!doc && docKey) {
|
|
doc = getDoc(docKey);
|
|
}
|
|
|
|
// Add controlled internal state to properly handle partial inputs
|
|
const [inputValue, setInputValue] = React.useState<string | number>(value ?? '');
|
|
|
|
// Sync internal state with prop value
|
|
React.useEffect(() => {
|
|
setInputValue(value ?? '');
|
|
}, [value]);
|
|
|
|
return (
|
|
<div className={classNames(props.className)}>
|
|
{label && (
|
|
<label className={labelClasses}>
|
|
{label}{' '}
|
|
{doc && (
|
|
<div className="inline-block ml-1 text-xs text-gray-500 cursor-pointer" onClick={() => openDoc(doc)}>
|
|
<CircleHelp className="inline-block w-4 h-4 cursor-pointer" />
|
|
</div>
|
|
)}
|
|
</label>
|
|
)}
|
|
<input
|
|
type="number"
|
|
value={inputValue}
|
|
onChange={e => {
|
|
const rawValue = e.target.value;
|
|
|
|
// Update the input display with the raw value
|
|
setInputValue(rawValue);
|
|
|
|
// Handle empty or partial inputs
|
|
if (rawValue === '' || rawValue === '-') {
|
|
// For empty or partial negative input, don't call onChange yet
|
|
return;
|
|
}
|
|
|
|
const numValue = Number(rawValue);
|
|
|
|
// Only apply constraints and call onChange when we have a valid number
|
|
if (!isNaN(numValue)) {
|
|
let constrainedValue = numValue;
|
|
|
|
// Apply min/max constraints if they exist
|
|
if (min !== undefined && constrainedValue < min) {
|
|
constrainedValue = min;
|
|
}
|
|
if (max !== undefined && constrainedValue > max) {
|
|
constrainedValue = max;
|
|
}
|
|
|
|
onChange(constrainedValue);
|
|
}
|
|
}}
|
|
className={inputClasses}
|
|
placeholder={placeholder}
|
|
required={required}
|
|
min={min}
|
|
max={max}
|
|
step="any"
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export interface SelectInputProps extends InputProps {
|
|
value: string;
|
|
disabled?: boolean;
|
|
onChange: (value: string) => void;
|
|
options: GroupedSelectOption[] | SelectOption[];
|
|
}
|
|
|
|
export const SelectInput = (props: SelectInputProps) => {
|
|
const { label, value, onChange, options, docKey = null } = props;
|
|
let { doc } = props;
|
|
if (!doc && docKey) {
|
|
doc = getDoc(docKey);
|
|
}
|
|
let selectedOption: SelectOption | undefined;
|
|
if (options && options.length > 0) {
|
|
// see if grouped options
|
|
if ('options' in options[0]) {
|
|
selectedOption = (options as GroupedSelectOption[])
|
|
.flatMap(group => group.options)
|
|
.find(opt => opt.value === value);
|
|
} else {
|
|
selectedOption = (options as SelectOption[]).find(opt => opt.value === value);
|
|
}
|
|
}
|
|
return (
|
|
<div
|
|
className={classNames(props.className, {
|
|
'opacity-30 cursor-not-allowed': props.disabled,
|
|
})}
|
|
>
|
|
{label && (
|
|
<label className={labelClasses}>
|
|
{label}{' '}
|
|
{doc && (
|
|
<div className="inline-block ml-1 text-xs text-gray-500 cursor-pointer" onClick={() => openDoc(doc)}>
|
|
<CircleHelp className="inline-block w-4 h-4 cursor-pointer" />
|
|
</div>
|
|
)}
|
|
</label>
|
|
)}
|
|
<Select
|
|
value={selectedOption}
|
|
options={options}
|
|
isDisabled={props.disabled}
|
|
className="aitk-react-select-container"
|
|
classNamePrefix="aitk-react-select"
|
|
onChange={selected => {
|
|
if (selected) {
|
|
onChange((selected as { value: string }).value);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export interface CreatableSelectInputProps extends InputProps {
|
|
value: string;
|
|
disabled?: boolean;
|
|
onChange: (value: string) => void;
|
|
options: GroupedSelectOption[] | SelectOption[];
|
|
}
|
|
|
|
const CUSTOM_SELECT_VALUE = '__custom__';
|
|
|
|
export const CreatableSelectInput = (props: CreatableSelectInputProps) => {
|
|
const { label, value, onChange, options, docKey = null } = props;
|
|
let { doc } = props;
|
|
if (!doc && docKey) {
|
|
doc = getDoc(docKey);
|
|
}
|
|
|
|
// Check if current value matches any predefined option
|
|
let isInOptions = false;
|
|
if (options && options.length > 0) {
|
|
if ('options' in options[0]) {
|
|
isInOptions = (options as GroupedSelectOption[]).flatMap(g => g.options).some(opt => opt.value === value);
|
|
} else {
|
|
isInOptions = (options as SelectOption[]).some(opt => opt.value === value);
|
|
}
|
|
}
|
|
|
|
const [isCustom, setIsCustom] = React.useState(!isInOptions && !!value);
|
|
|
|
// Build select options with "Custom" at the top
|
|
const customOption: SelectOption = { value: CUSTOM_SELECT_VALUE, label: 'Custom' };
|
|
const selectOptions = React.useMemo(() => {
|
|
if (options && options.length > 0 && 'options' in options[0]) {
|
|
return [{ label: '', options: [customOption] }, ...(options as GroupedSelectOption[])];
|
|
}
|
|
return [customOption, ...(options as SelectOption[])];
|
|
}, [options]);
|
|
|
|
const selectedOption = isCustom
|
|
? customOption
|
|
: (() => {
|
|
if (!options || options.length === 0) return undefined;
|
|
if ('options' in options[0]) {
|
|
return (options as GroupedSelectOption[]).flatMap(g => g.options).find(opt => opt.value === value);
|
|
}
|
|
return (options as SelectOption[]).find(opt => opt.value === value);
|
|
})();
|
|
|
|
return (
|
|
<div
|
|
className={classNames(props.className, {
|
|
'opacity-30 cursor-not-allowed': props.disabled,
|
|
})}
|
|
>
|
|
{label && (
|
|
<label className={labelClasses}>
|
|
{label}{' '}
|
|
{doc && (
|
|
<div className="inline-block ml-1 text-xs text-gray-500 cursor-pointer" onClick={() => openDoc(doc)}>
|
|
<CircleHelp className="inline-block w-4 h-4 cursor-pointer" />
|
|
</div>
|
|
)}
|
|
</label>
|
|
)}
|
|
<div className="flex gap-2">
|
|
<div className={isCustom ? 'w-1/3' : 'w-full'}>
|
|
<Select
|
|
value={selectedOption}
|
|
options={selectOptions}
|
|
isDisabled={props.disabled}
|
|
className="aitk-react-select-container"
|
|
classNamePrefix="aitk-react-select"
|
|
formatOptionLabel={(option: unknown) => {
|
|
const opt = option as SelectOption;
|
|
return opt.value === CUSTOM_SELECT_VALUE ? (
|
|
<span className="opacity-50 italic">~ Custom ~</span>
|
|
) : (
|
|
opt.label
|
|
);
|
|
}}
|
|
onChange={selected => {
|
|
if (selected) {
|
|
const val = (selected as { value: string }).value;
|
|
if (val === CUSTOM_SELECT_VALUE) {
|
|
setIsCustom(true);
|
|
onChange('');
|
|
} else {
|
|
setIsCustom(false);
|
|
onChange(val);
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
{isCustom && (
|
|
<input
|
|
type="text"
|
|
value={value}
|
|
onChange={e => onChange(e.target.value)}
|
|
className={`${inputClasses} w-2/3`}
|
|
placeholder={props.placeholder ?? 'Enter custom value'}
|
|
disabled={props.disabled}
|
|
autoFocus
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export interface CheckboxProps {
|
|
label?: string | React.ReactNode;
|
|
checked: boolean;
|
|
onChange: (checked: boolean) => void;
|
|
className?: string;
|
|
required?: boolean;
|
|
disabled?: boolean;
|
|
docKey?: string | null;
|
|
doc?: ConfigDoc | null;
|
|
}
|
|
|
|
export const Checkbox = (props: CheckboxProps) => {
|
|
const { label, checked, onChange, required, disabled } = props;
|
|
let { doc } = props;
|
|
if (!doc && props.docKey) {
|
|
doc = getDoc(props.docKey);
|
|
}
|
|
|
|
const id = React.useId();
|
|
|
|
return (
|
|
<div className={classNames('flex items-center gap-3', props.className)}>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
id={id}
|
|
aria-checked={checked}
|
|
aria-required={required}
|
|
disabled={disabled}
|
|
onClick={() => !disabled && onChange(!checked)}
|
|
className={classNames(
|
|
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2',
|
|
checked ? 'bg-blue-500' : 'bg-gray-600',
|
|
disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-opacity-80',
|
|
)}
|
|
>
|
|
<span className="sr-only">Toggle {label}</span>
|
|
<span
|
|
className={classNames(
|
|
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
|
checked ? 'translate-x-5' : 'translate-x-0',
|
|
)}
|
|
/>
|
|
</button>
|
|
{label && (
|
|
<>
|
|
<label
|
|
htmlFor={id}
|
|
className={classNames(
|
|
'text-sm font-medium cursor-pointer select-none',
|
|
disabled ? 'text-gray-500' : 'text-gray-300',
|
|
)}
|
|
>
|
|
{label}
|
|
</label>
|
|
{doc && (
|
|
<div className="inline-block ml-1 text-xs text-gray-500 cursor-pointer" onClick={() => openDoc(doc)}>
|
|
<CircleHelp className="inline-block w-4 h-4 cursor-pointer" />
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface FormGroupProps {
|
|
label?: string;
|
|
className?: string;
|
|
docKey?: string | null;
|
|
doc?: ConfigDoc | null;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export const FormGroup: React.FC<FormGroupProps> = props => {
|
|
const { label, className, children, docKey = null } = props;
|
|
let { doc } = props;
|
|
if (!doc && docKey) {
|
|
doc = getDoc(docKey);
|
|
}
|
|
return (
|
|
<div className={classNames(className)}>
|
|
{label && (
|
|
<label className={classNames(labelClasses, 'mb-2')}>
|
|
{label}{' '}
|
|
{doc && (
|
|
<div className="inline-block ml-1 text-xs text-gray-500 cursor-pointer" onClick={() => openDoc(doc)}>
|
|
<CircleHelp className="inline-block w-4 h-4 cursor-pointer" />
|
|
</div>
|
|
)}
|
|
</label>
|
|
)}
|
|
<div className="space-y-2">{children}</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export interface SliderInputProps extends InputProps {
|
|
value: number;
|
|
onChange: (value: number) => void;
|
|
min: number;
|
|
max: number;
|
|
step?: number;
|
|
disabled?: boolean;
|
|
showValue?: boolean;
|
|
}
|
|
|
|
export const SliderInput: React.FC<SliderInputProps> = props => {
|
|
const { label, value, onChange, min, max, step = 1, disabled, className, docKey = null, showValue = true } = props;
|
|
let { doc } = props;
|
|
if (!doc && docKey) {
|
|
doc = getDoc(docKey);
|
|
}
|
|
|
|
const trackRef = React.useRef<HTMLDivElement | null>(null);
|
|
const [dragging, setDragging] = React.useState(false);
|
|
|
|
const clamp = (v: number) => (v < min ? min : v > max ? max : v);
|
|
const snapToStep = (v: number) => {
|
|
if (!Number.isFinite(v)) return min;
|
|
const steps = Math.round((v - min) / step);
|
|
const snapped = min + steps * step;
|
|
return clamp(Number(snapped.toFixed(6)));
|
|
};
|
|
|
|
const percent = React.useMemo(() => {
|
|
if (max === min) return 0;
|
|
const p = ((value - min) / (max - min)) * 100;
|
|
return p < 0 ? 0 : p > 100 ? 100 : p;
|
|
}, [value, min, max]);
|
|
|
|
const calcFromClientX = React.useCallback(
|
|
(clientX: number) => {
|
|
const el = trackRef.current;
|
|
if (!el || !Number.isFinite(clientX)) return;
|
|
const rect = el.getBoundingClientRect();
|
|
const width = rect.right - rect.left;
|
|
if (!(width > 0)) return;
|
|
|
|
// Clamp ratio to [0, 1] so it can never flip ends.
|
|
const ratioRaw = (clientX - rect.left) / width;
|
|
const ratio = ratioRaw <= 0 ? 0 : ratioRaw >= 1 ? 1 : ratioRaw;
|
|
|
|
const raw = min + ratio * (max - min);
|
|
onChange(snapToStep(raw));
|
|
},
|
|
[min, max, step, onChange],
|
|
);
|
|
|
|
// Mouse/touch pointer drag
|
|
const onPointerDown = (e: React.PointerEvent) => {
|
|
if (disabled) return;
|
|
e.preventDefault();
|
|
|
|
// Capture the pointer so moves outside the element are still tracked correctly
|
|
try {
|
|
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
|
|
} catch {}
|
|
|
|
setDragging(true);
|
|
calcFromClientX(e.clientX);
|
|
|
|
const handleMove = (ev: PointerEvent) => {
|
|
ev.preventDefault();
|
|
calcFromClientX(ev.clientX);
|
|
};
|
|
const handleUp = (ev: PointerEvent) => {
|
|
setDragging(false);
|
|
// release capture if we got it
|
|
try {
|
|
(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
|
|
} catch {}
|
|
window.removeEventListener('pointermove', handleMove);
|
|
window.removeEventListener('pointerup', handleUp);
|
|
};
|
|
|
|
window.addEventListener('pointermove', handleMove);
|
|
window.addEventListener('pointerup', handleUp);
|
|
};
|
|
|
|
return (
|
|
<div className={classNames(className, disabled ? 'opacity-30 cursor-not-allowed' : '')}>
|
|
{label && (
|
|
<label className={labelClasses}>
|
|
{label}{' '}
|
|
{doc && (
|
|
<div className="inline-block ml-1 text-xs text-gray-500 cursor-pointer" onClick={() => openDoc(doc)}>
|
|
<CircleHelp className="inline-block w-4 h-4 cursor-pointer" />
|
|
</div>
|
|
)}
|
|
</label>
|
|
)}
|
|
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-1">
|
|
<div
|
|
ref={trackRef}
|
|
onPointerDown={onPointerDown}
|
|
className={classNames(
|
|
'relative w-full h-6 select-none outline-none',
|
|
disabled ? 'pointer-events-none' : 'cursor-pointer',
|
|
)}
|
|
>
|
|
{/* Thicker track */}
|
|
<div className="pointer-events-none absolute left-0 right-0 top-1/2 -translate-y-1/2 h-3 rounded-sm bg-gray-800 border border-gray-700" />
|
|
|
|
{/* Fill */}
|
|
<div
|
|
className="pointer-events-none absolute left-0 top-1/2 -translate-y-1/2 h-3 rounded-sm bg-blue-500"
|
|
style={{ width: `${percent}%` }}
|
|
/>
|
|
|
|
{/* Thumb */}
|
|
<div
|
|
onPointerDown={onPointerDown}
|
|
className={classNames(
|
|
'absolute top-1/2 -translate-y-1/2 -ml-2',
|
|
'h-4 w-4 rounded-full bg-white shadow border border-gray-300 cursor-pointer',
|
|
'after:content-[""] after:absolute after:inset-[-6px] after:rounded-full after:bg-transparent', // expands hit area
|
|
dragging ? 'ring-2 ring-blue-600' : '',
|
|
)}
|
|
style={{ left: `calc(${percent}% )` }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex justify-between text-xs text-gray-500 mt-0.5 select-none">
|
|
<span>{min}</span>
|
|
<span>{max}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{showValue && (
|
|
<div className="min-w-[3.5rem] text-right text-sm px-3 py-1 bg-gray-800 border border-gray-700 rounded-sm">
|
|
{Number.isFinite(value) ? value : ''}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|