Feat: Rewrite wiki template with reui (#16797)

This commit is contained in:
balibabu
2026-07-10 15:44:04 +08:00
committed by GitHub
parent 7db39822db
commit d317742975
8 changed files with 648 additions and 39 deletions

View File

@@ -11,7 +11,7 @@
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
"lint": "eslint src --ext .ts,.tsx --report-unused-disable-directives",
"prepare": "cd .. && git rev-parse --git-dir >/dev/null 2>&1 && lefthook install || true",
"prepare": "node scripts/prepare.js",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"test": "jest --no-cache --coverage",

33
web/scripts/prepare.js Normal file
View File

@@ -0,0 +1,33 @@
import { execSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const repoRoot = dirname(__dirname);
const isWindows = process.platform === 'win32';
// Use the locally installed lefthook binary to avoid relying on a global install or PATH.
const lefthookBin = join(
repoRoot,
'node_modules',
'.bin',
`lefthook${isWindows ? '.cmd' : ''}`,
);
try {
// Verify we are inside a Git repository first.
execSync('git rev-parse --git-dir', {
cwd: repoRoot,
stdio: 'ignore',
});
// Install lefthook hooks from the repository root.
execSync(`"${lefthookBin}" install`, {
cwd: repoRoot,
stdio: 'inherit',
});
} catch {
// Silently ignore failures (not a Git repo or lefthook install failed) so npm install is not interrupted.
}

View File

@@ -0,0 +1,488 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import {
Children,
createContext,
HTMLAttributes,
isValidElement,
ReactElement,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { cn } from '@/lib/utils';
// Types
type StepperOrientation = 'horizontal' | 'vertical';
type StepState = 'active' | 'completed' | 'inactive' | 'loading';
type StepIndicators = {
active?: React.ReactNode;
completed?: React.ReactNode;
inactive?: React.ReactNode;
loading?: React.ReactNode;
};
interface StepperContextValue {
activeStep: number;
setActiveStep: (step: number) => void;
stepsCount: number;
orientation: StepperOrientation;
registerTrigger: (node: HTMLButtonElement | null) => void;
triggerNodes: HTMLButtonElement[];
focusNext: (currentIdx: number) => void;
focusPrev: (currentIdx: number) => void;
focusFirst: () => void;
focusLast: () => void;
indicators: StepIndicators;
}
interface StepItemContextValue {
step: number;
state: StepState;
isDisabled: boolean;
isLoading: boolean;
}
const StepperContext = createContext<StepperContextValue | undefined>(
undefined,
);
const StepItemContext = createContext<StepItemContextValue | undefined>(
undefined,
);
function useStepper() {
const ctx = useContext(StepperContext);
if (!ctx) throw new Error('useStepper must be used within a Stepper');
return ctx;
}
function useStepItem() {
const ctx = useContext(StepItemContext);
if (!ctx) throw new Error('useStepItem must be used within a StepperItem');
return ctx;
}
interface StepperProps extends HTMLAttributes<HTMLDivElement> {
defaultValue?: number;
value?: number;
onValueChange?: (value: number) => void;
orientation?: StepperOrientation;
indicators?: StepIndicators;
}
function Stepper({
defaultValue = 1,
value,
onValueChange,
orientation = 'horizontal',
className,
children,
indicators = {},
...props
}: StepperProps) {
const [activeStep, setActiveStep] = useState(defaultValue);
const [triggerNodes, setTriggerNodes] = useState<HTMLButtonElement[]>([]);
// Register/unregister triggers
const registerTrigger = useCallback((node: HTMLButtonElement | null) => {
setTriggerNodes((prev) => {
if (node && !prev.includes(node)) {
return [...prev, node];
} else if (!node && prev.includes(node!)) {
return prev.filter((n) => n !== node);
} else {
return prev;
}
});
}, []);
const handleSetActiveStep = useCallback(
(step: number) => {
if (value === undefined) {
setActiveStep(step);
}
onValueChange?.(step);
},
[value, onValueChange],
);
const currentStep = value ?? activeStep;
// Keyboard navigation logic
const focusTrigger = (idx: number) => {
if (triggerNodes[idx]) triggerNodes[idx].focus();
};
const focusNext = (currentIdx: number) =>
focusTrigger((currentIdx + 1) % triggerNodes.length);
const focusPrev = (currentIdx: number) =>
focusTrigger((currentIdx - 1 + triggerNodes.length) % triggerNodes.length);
const focusFirst = () => focusTrigger(0);
const focusLast = () => focusTrigger(triggerNodes.length - 1);
// Context value
const contextValue = useMemo<StepperContextValue>(
() => ({
activeStep: currentStep,
setActiveStep: handleSetActiveStep,
stepsCount: Children.toArray(children).filter(
(child): child is ReactElement =>
isValidElement(child) &&
(child.type as { displayName?: string }).displayName ===
'StepperItem',
).length,
orientation,
registerTrigger,
focusNext,
focusPrev,
focusFirst,
focusLast,
triggerNodes,
indicators,
}),
[
currentStep,
handleSetActiveStep,
children,
orientation,
registerTrigger,
triggerNodes,
],
);
return (
<StepperContext.Provider value={contextValue}>
<div
role="tablist"
aria-orientation={orientation}
data-slot="stepper"
className={cn('w-full', className)}
data-orientation={orientation}
{...props}
>
{children}
</div>
</StepperContext.Provider>
);
}
interface StepperItemProps extends React.HTMLAttributes<HTMLDivElement> {
step: number;
completed?: boolean;
disabled?: boolean;
loading?: boolean;
}
function StepperItem({
step,
completed = false,
disabled = false,
loading = false,
className,
children,
...props
}: StepperItemProps) {
const { activeStep } = useStepper();
const state: StepState =
completed || step < activeStep
? 'completed'
: activeStep === step
? 'active'
: 'inactive';
const isLoading = loading && step === activeStep;
return (
<StepItemContext.Provider
value={{ step, state, isDisabled: disabled, isLoading }}
>
<div
data-slot="stepper-item"
className={cn(
'group/step flex items-center justify-center not-last:flex-1 group-data-[orientation=horizontal]/stepper-nav:flex-row group-data-[orientation=vertical]/stepper-nav:flex-col',
className,
)}
data-state={state}
{...(isLoading ? { 'data-loading': true } : {})}
{...props}
>
{children}
</div>
</StepItemContext.Provider>
);
}
interface StepperTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
}
function StepperTrigger({
asChild = false,
className,
children,
tabIndex,
...props
}: StepperTriggerProps) {
const { state, isLoading } = useStepItem();
const stepperCtx = useStepper();
const {
setActiveStep,
activeStep,
registerTrigger,
triggerNodes,
focusNext,
focusPrev,
focusFirst,
focusLast,
} = stepperCtx;
const { step, isDisabled } = useStepItem();
const isSelected = activeStep === step;
const id = `stepper-tab-${step}`;
const panelId = `stepper-panel-${step}`;
// Register this trigger for keyboard navigation
const btnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (btnRef.current) {
registerTrigger(btnRef.current);
}
}, [btnRef.current]);
// Find our index among triggers for navigation
const myIdx = useMemo(
() =>
triggerNodes.findIndex((n: HTMLButtonElement) => n === btnRef.current),
[triggerNodes, btnRef.current],
);
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
switch (e.key) {
case 'ArrowRight':
case 'ArrowDown':
e.preventDefault();
if (myIdx !== -1 && focusNext) focusNext(myIdx);
break;
case 'ArrowLeft':
case 'ArrowUp':
e.preventDefault();
if (myIdx !== -1 && focusPrev) focusPrev(myIdx);
break;
case 'Home':
e.preventDefault();
if (focusFirst) focusFirst();
break;
case 'End':
e.preventDefault();
if (focusLast) focusLast();
break;
case 'Enter':
case ' ':
e.preventDefault();
setActiveStep(step);
break;
}
};
if (asChild) {
return (
<span
data-slot="stepper-trigger"
data-state={state}
className={className}
>
{children}
</span>
);
}
return (
<button
ref={btnRef}
role="tab"
id={id}
aria-selected={isSelected}
aria-controls={panelId}
tabIndex={typeof tabIndex === 'number' ? tabIndex : isSelected ? 0 : -1}
data-slot="stepper-trigger"
data-state={state}
data-loading={isLoading}
className={cn(
'focus-visible:border-ring focus-visible:ring-ring/50 inline-flex cursor-pointer items-center outline-none focus-visible:z-10 focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-60',
'gap-2.5 rounded-full',
className,
)}
onClick={() => setActiveStep(step)}
onKeyDown={handleKeyDown}
disabled={isDisabled}
{...props}
>
{children}
</button>
);
}
function StepperIndicator({
children,
className,
}: React.ComponentProps<'div'>) {
const { state, isLoading } = useStepItem();
const { indicators } = useStepper();
return (
<div
data-slot="stepper-indicator"
data-state={state}
className={cn(
'border-background bg-accent text-accent-foreground data-[state=completed]:bg-primary data-[state=completed]:text-primary-foreground data-[state=active]:bg-primary data-[state=active]:text-primary-foreground relative flex size-6 shrink-0 items-center justify-center overflow-hidden',
'rounded-full text-xs',
className,
)}
>
<div className="absolute">
{indicators &&
((isLoading && indicators.loading) ||
(state === 'completed' && indicators.completed) ||
(state === 'active' && indicators.active) ||
(state === 'inactive' && indicators.inactive))
? (isLoading && indicators.loading) ||
(state === 'completed' && indicators.completed) ||
(state === 'active' && indicators.active) ||
(state === 'inactive' && indicators.inactive)
: children}
</div>
</div>
);
}
function StepperSeparator({ className }: React.ComponentProps<'div'>) {
const { state } = useStepItem();
return (
<div
data-slot="stepper-separator"
data-state={state}
className={cn(
'bg-muted rounded-sm group-data-[orientation=horizontal]/stepper-nav:h-0.5 group-data-[orientation=vertical]/stepper-nav:h-12 group-data-[orientation=vertical]/stepper-nav:w-0.5 m-0.5 group-data-[orientation=horizontal]/stepper-nav:flex-1',
className,
)}
/>
);
}
function StepperTitle({ children, className }: React.ComponentProps<'h3'>) {
const { state } = useStepItem();
return (
<h3
data-slot="stepper-title"
data-state={state}
className={cn('text-sm leading-none font-medium', className)}
>
{children}
</h3>
);
}
function StepperDescription({
children,
className,
}: React.ComponentProps<'div'>) {
const { state } = useStepItem();
return (
<div
data-slot="stepper-description"
data-state={state}
className={cn('text-muted-foreground text-sm', className)}
>
{children}
</div>
);
}
function StepperNav({ children, className }: React.ComponentProps<'nav'>) {
const { activeStep, orientation } = useStepper();
return (
<nav
data-slot="stepper-nav"
data-state={activeStep}
data-orientation={orientation}
className={cn(
'group/stepper-nav inline-flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col',
className,
)}
>
{children}
</nav>
);
}
function StepperPanel({ children, className }: React.ComponentProps<'div'>) {
const { activeStep } = useStepper();
return (
<div
data-slot="stepper-panel"
data-state={activeStep}
className={cn('w-full', className)}
>
{children}
</div>
);
}
interface StepperContentProps extends React.ComponentProps<'div'> {
value: number;
forceMount?: boolean;
}
function StepperContent({
value,
forceMount,
children,
className,
}: StepperContentProps) {
const { activeStep } = useStepper();
const isActive = value === activeStep;
if (!forceMount && !isActive) {
return null;
}
return (
<div
data-slot="stepper-content"
data-state={activeStep}
className={cn('w-full', className, !isActive && forceMount && 'hidden')}
hidden={!isActive && forceMount}
>
{children}
</div>
);
}
export {
Stepper,
StepperContent,
StepperDescription,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
useStepItem,
useStepper,
type StepperContentProps,
type StepperItemProps,
type StepperProps,
type StepperTriggerProps,
};

View File

@@ -526,7 +526,6 @@ Example: A 1 KB message with 1024-dim embedding uses ~9 KB. The 5 MB default lim
directoryNamePlaceholder: 'Instance name',
directoryRule: 'Rule',
directoryRulePlaceholder: 'Input',
entity: 'Entity',
selectArtifact: 'Select an item from the contents to view details',
sourceDocuments: 'Source documents',
compilationTitleSuffix: "' dataset",

View File

@@ -0,0 +1,109 @@
import {
Stepper,
StepperDescription,
StepperIndicator,
StepperItem,
StepperNav,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from '@/components/reui/stepper';
import { cn } from '@/lib/utils';
import { Cog, FileText, Palette } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
interface TemplateStepperProps {
activeStep: number;
isArtifacts: boolean;
onStepChange: (step: number) => void;
}
export function TemplateStepper({
activeStep,
isArtifacts,
onStepChange,
}: TemplateStepperProps) {
const { t } = useTranslation();
const steps = useMemo(
() => [
{
id: 'basic-info',
title: t('setting.basicInfo'),
description: t('setting.basicInfoDescription'),
icon: FileText,
},
{
id: 'configuration',
title: t('setting.templateWizardConfiguration'),
description: t('setting.templateWizardConfigurationDescription'),
icon: Cog,
},
...(isArtifacts
? [
{
id: 'blueprints',
title: t('setting.blueprints'),
description: t('setting.blueprintsDescription'),
icon: Palette,
},
]
: []),
],
[isArtifacts, t],
);
return (
<Stepper
value={activeStep}
onValueChange={onStepChange}
className="flex justify-center"
>
<StepperNav className="justify-center max-w-4xl">
{steps.map((step, index) => {
const stepNumber = index + 1;
const isActive = stepNumber === activeStep;
const Icon = step.icon;
const isLast = index === steps.length - 1;
return (
<StepperItem
key={step.id}
step={stepNumber}
className="items-start flex-1 relative"
disabled={stepNumber > activeStep}
>
<StepperTrigger type="button" className="flex flex-col gap-1">
<StepperIndicator
className={cn(
'size-8 border !bg-bg-card !text-text-primary !border-border-button',
isActive && '!border-accent-primary !text-accent-primary',
)}
>
<Icon className="size-4" />
</StepperIndicator>
<StepperTitle
className={cn(
'mt-2 text-center text-sm font-medium leading-none',
isActive && 'text-accent-primary',
)}
>
{step.title}
</StepperTitle>
<StepperDescription
className={'text-center text-text-secondary'}
>
{step.description}
</StepperDescription>
</StepperTrigger>
{!isLast && (
<StepperSeparator className="group-data-[state=completed]/step:bg-primary absolute inset-x-0 top-3.5 left-[calc(50%+1.4rem)] m-0 group-data-[orientation=horizontal]/stepper-nav:w-[calc(100%-3rem+0.225rem)] group-data-[orientation=horizontal]/stepper-nav:flex-none" />
)}
</StepperItem>
);
})}
</StepperNav>
</Stepper>
);
}

View File

@@ -1,5 +1,4 @@
import BackButton from '@/components/back-button';
import { CustomTimeline } from '@/components/originui/timeline';
import { Button } from '@/components/ui/button';
import { Form } from '@/components/ui/form';
import {
@@ -9,7 +8,7 @@ import {
} from '@/components/ui/resizable';
import { CompilationTemplateKind } from '@/constants/compilation';
import { Routes } from '@/routes';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useState } from 'react';
import { useFieldArray, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@@ -17,6 +16,7 @@ import { BasicInfoStep } from './components/basic-info-step';
import { BlueprintsStep } from './components/blueprints-step';
import { TemplateConfiguration } from './components/template-configuration';
import { TemplateSidebar } from './components/template-sidebar';
import { TemplateStepper } from './components/template-stepper';
import { useCreateNextCompilationTemplateGroup } from './hooks/use-create-next-compilation-template-group';
export default function CreateNextCompilationTemplate() {
@@ -39,29 +39,14 @@ export default function CreateNextCompilationTemplate() {
const isArtifacts = selectedKind === CompilationTemplateKind.Artifacts;
const timelineNodes = useMemo(
() => [
{
id: 'basic-info',
title: t('setting.basicInfo'),
content: t('setting.basicInfoDescription'),
},
{
id: 'configuration',
title: t('setting.templateWizardConfiguration'),
content: t('setting.templateWizardConfigurationDescription'),
},
...(isArtifacts
? [
{
id: 'blueprints',
title: t('setting.blueprints'),
content: t('setting.blueprintsDescription'),
},
]
: []),
],
[isArtifacts, t],
const handleStepChange = useCallback(
(step: number) => {
// Allow clicking back to completed steps only.
if (step < activeStep) {
setActiveStep(step);
}
},
[activeStep],
);
const handleNext = useCallback(async () => {
@@ -85,7 +70,7 @@ export default function CreateNextCompilationTemplate() {
return (
<section className="h-full flex flex-col bg-bg-base">
<header className="shrink-0 px-5 py-4 border-b border-border-button flex gap-3">
<header className="shrink-0 px-5 py-4 border-b border-border-button flex gap-3 items-center">
<BackButton
to={`${Routes.UserSetting}${Routes.CompilationTemplates}`}
/>
@@ -97,16 +82,10 @@ export default function CreateNextCompilationTemplate() {
</header>
<div className="shrink-0 px-5 py-4 border-b border-border-button">
<CustomTimeline
nodes={timelineNodes}
<TemplateStepper
activeStep={activeStep}
onStepChange={(step) => {
// Allow clicking back to completed steps only.
if (step < activeStep) {
setActiveStep(step);
}
}}
orientation="horizontal"
isArtifacts={isArtifacts}
onStepChange={handleStepChange}
/>
</div>

View File

@@ -37,6 +37,7 @@ export const useCompilationTemplateGroupForm = ({
const form = useForm<FormSchemaType>({
resolver: zodResolver(buildFormSchema(t)),
defaultValues,
mode: 'onChange',
});
useEffect(() => {

View File

@@ -64,12 +64,12 @@ export default defineConfig(({ mode }) => {
ws: true,
},
'/api': {
target: 'http://127.0.0.1:9386/',
target: 'http://127.0.0.1:9380/',
changeOrigin: true,
ws: true,
},
'/v1': {
target: 'http://127.0.0.1:9386/',
target: 'http://127.0.0.1:9380/',
changeOrigin: true,
ws: true,
},