feat: simplify resource setup and detail guides

This commit is contained in:
fdc310
2026-09-19 02:39:35 +08:00
parent 960e322c79
commit cc8a0ec847
27 changed files with 1237 additions and 840 deletions
@@ -115,7 +115,6 @@ export default function AgentDetailContent({ id }: { id: string }) {
if (isCreateMode) {
return (
<AgentCreateContent
guideEnabled={canManage}
onCreated={(newAgentId) => {
refreshPipelines();
navigate(`/home/agents?id=${encodeURIComponent(newAgentId)}`);
@@ -27,16 +27,11 @@ import {
import { Input } from '@/components/ui/input';
import EmojiPicker from '@/components/ui/emoji-picker';
import ProcessorTypeDiagram from './ProcessorTypeDiagram';
import GuidedTour, {
GuidedTourStep,
} from '@/app/home/components/guided-tour/GuidedTour';
export default function AgentCreateContent({
onCreated,
guideEnabled = true,
}: {
onCreated: (agentId: string) => void;
guideEnabled?: boolean;
}) {
const { t } = useTranslation();
const [kind, setKind] = useState<AgentKind>('agent');
@@ -114,29 +109,6 @@ export default function AgentCreateContent({
description: t('agents.eventProcessor.description'),
},
];
const guideSteps: GuidedTourStep[] = [
{
id: 'type',
target: '[data-guide="processor-type"]',
title: t('guidedTour.processorCreate.type.title'),
description: t('guidedTour.processorCreate.type.description'),
},
{
id: 'basic',
target: '[data-guide="processor-basic"]',
title: t('guidedTour.processorCreate.basic.title'),
description: t('guidedTour.processorCreate.basic.description'),
complete: Boolean(form.watch('name')?.trim()),
requirement: t('guidedTour.processorCreate.basic.requirement'),
},
{
id: 'submit',
target: '[data-guide="processor-submit"]',
title: t('guidedTour.processorCreate.submit.title'),
description: t('guidedTour.processorCreate.submit.description'),
},
];
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between pb-4 shrink-0">
@@ -147,7 +119,6 @@ export default function AgentCreateContent({
type="submit"
form="agent-create-form"
disabled={form.formState.isSubmitting}
data-guide="processor-submit"
>
{t('common.submit')}
</Button>
@@ -160,7 +131,6 @@ export default function AgentCreateContent({
<section
aria-labelledby="processor-kind-heading"
className="space-y-3"
data-guide="processor-type"
>
<div>
<h2
@@ -211,7 +181,7 @@ export default function AgentCreateContent({
</ToggleGroup>
</section>
<Card data-guide="processor-basic">
<Card>
<CardHeader>
<CardTitle>{t('agents.basicInfo')}</CardTitle>
<CardDescription>
@@ -287,12 +257,6 @@ export default function AgentCreateContent({
</div>
</div>
</div>
<GuidedTour
enabled={guideEnabled}
storageKey="langbot_processor_create_guide_v1"
steps={guideSteps}
testId="processor-create-guide"
/>
</div>
);
}
@@ -48,7 +48,6 @@ import RunnerSelect from './RunnerSelect';
import GuidedTour, {
GuidedTourStep,
} from '@/app/home/components/guided-tour/GuidedTour';
import { areRequiredDynamicFieldsComplete } from '@/app/home/components/guided-tour/dynamic-form-progress';
import AgentApiToolPicker from './AgentApiToolPicker';
const OTHER_TOOL_SCOPES = [
@@ -482,8 +481,6 @@ function AgentFormComponent(
target: '[data-guide="runner-selector"]',
title: t('guidedTour.runner.select.title'),
description: t('guidedTour.runner.select.description'),
complete: Boolean(currentRunner && selectedRunnerOption),
requirement: t('guidedTour.runner.select.requirement'),
action: {
href: 'https://space.langbot.app/market?type=plugin&component=Runner&runner_usage=agent',
label: t('guidedTour.runner.select.action'),
@@ -497,11 +494,6 @@ function AgentFormComponent(
target: '[data-guide="runner-parameters"]',
title: t('guidedTour.runner.parameters.title'),
description: t('guidedTour.runner.parameters.description'),
complete: areRequiredDynamicFieldsComplete(
activeRunnerStage.config,
activeRunnerValues,
),
requirement: t('guidedTour.runner.parameters.requirement'),
});
}
@@ -510,18 +502,9 @@ function AgentFormComponent(
target: '[data-guide="agent-sections"]',
title: t('guidedTour.runner.events.title'),
description: t('guidedTour.runner.events.description'),
complete: activeSection === 'events_and_tools',
requirement: t('guidedTour.runner.events.requirement'),
});
return steps;
}, [
activeRunnerStage,
activeRunnerValues,
activeSection,
currentRunner,
selectedRunnerOption,
t,
]);
}, [activeRunnerStage, t]);
useEffect(() => {
onRunnerStatusChange?.(runnerStatus);
@@ -348,7 +348,7 @@ export default function RunnerSelect({
<SelectValue placeholder={t('common.select')} />
)}
</SelectTrigger>
<SelectContent className="max-h-72 w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
<SelectContent className="z-[70] max-h-72 w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
<SelectGroup>
<SelectLabel className="px-2 py-1 text-[11px] font-medium">
<span className="inline-flex items-center gap-1.5">
+5 -2
View File
@@ -162,7 +162,7 @@ export default function BotDetailContent({ id }: { id: string }) {
{/* Header */}
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('bots.createBot')}</h1>
{canManage && (
{canManage && adapterLabel && (
<Button type="submit" form="bot-form" data-guide="bot-submit">
{t('common.submit')}
</Button>
@@ -171,13 +171,14 @@ export default function BotDetailContent({ id }: { id: string }) {
{/* Content */}
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
<div className="mx-auto w-full min-w-0 max-w-3xl pb-8">
<div className="mx-auto w-full min-w-0 max-w-7xl pb-8">
<fieldset className="contents" disabled={!canManage}>
<BotForm
initBotId={undefined}
onFormSubmit={handleFormSubmit}
onNewBotCreated={handleNewBotCreated}
guideEnabled={canManage}
onAdapterLabelChange={setAdapterLabel}
/>
</fieldset>
</div>
@@ -236,6 +237,7 @@ export default function BotDetailContent({ id }: { id: string }) {
form="bot-form"
disabled={!formDirty}
className={activeTab !== 'config' ? 'invisible' : ''}
data-guide="bot-config-save"
>
{t('common.save')}
</Button>
@@ -318,6 +320,7 @@ export default function BotDetailContent({ id }: { id: string }) {
onNewBotCreated={handleNewBotCreated}
onDirtyChange={setFormDirty}
onAdapterLabelChange={setAdapterLabel}
guideEnabled={canManage}
/>
</fieldset>
</div>
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
import { createPortal } from 'react-dom';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronRight, ExternalLink, LockKeyhole } from 'lucide-react';
import { Check, ChevronRight, ExternalLink, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
@@ -24,9 +24,6 @@ export interface GuidedTourStep {
target: string;
title: string;
description: string;
complete?: boolean;
advanceOnComplete?: boolean;
requirement?: string;
action?: {
href: string;
label: string;
@@ -78,14 +75,9 @@ export default function GuidedTour({
useState<PopoverPosition | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const previousStorageKeyRef = useRef(storageKey);
const previousCompletionRef = useRef<{
stepId: string;
complete: boolean;
} | null>(null);
const activeIndex = steps.findIndex((step) => step.id === activeStepId);
const activeStep = activeIndex >= 0 ? steps[activeIndex] : undefined;
const isComplete = activeStep?.complete !== false;
const isPopoverPositioned = popoverPosition !== null;
useEffect(() => {
@@ -105,15 +97,7 @@ export default function GuidedTour({
}
const currentIndex = steps.findIndex((step) => step.id === activeStepId);
const firstIncompleteIndex = steps.findIndex(
(step) => step.complete === false,
);
const nextIndex =
currentIndex < 0
? 0
: firstIncompleteIndex >= 0 && firstIncompleteIndex < currentIndex
? firstIncompleteIndex
: currentIndex;
const nextIndex = currentIndex < 0 ? 0 : currentIndex;
if (nextIndex !== currentIndex) {
const nextStep = steps[nextIndex];
@@ -208,27 +192,33 @@ export default function GuidedTour({
if (isPopoverPositioned) measure();
}, [activeStep?.id, isPopoverPositioned, measure]);
const finishTour = useCallback(() => {
setTargetRect(null);
setPopoverPosition(null);
storeProgress(storageKey, 'completed');
setFinished(true);
setActiveStepId(null);
}, [storageKey]);
const handleNext = useCallback(() => {
if (!activeStep || !isComplete) return;
if (!activeStep) return;
const nextStep = steps[activeIndex + 1];
setTargetRect(null);
setPopoverPosition(null);
if (!nextStep) {
storeProgress(storageKey, 'completed');
setFinished(true);
setActiveStepId(null);
finishTour();
return;
}
storeProgress(storageKey, nextStep.id);
setActiveStepId(nextStep.id);
}, [activeIndex, activeStep, isComplete, steps, storageKey]);
}, [activeIndex, activeStep, finishTour, steps, storageKey]);
useEffect(() => {
const handleNativeClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
const button = target.closest<HTMLButtonElement>(
'[data-guided-tour-action="next"]',
'[data-guided-tour-action]',
);
if (
!button ||
@@ -237,33 +227,18 @@ export default function GuidedTour({
) {
return;
}
handleNext();
if (button.dataset.guidedTourAction === 'skip') {
finishTour();
} else if (button.dataset.guidedTourAction === 'next') {
handleNext();
}
};
// Translation extensions can rewrite nodes inside the popover and detach
// React's delegated handler. Capture the command by its stable data marker.
document.addEventListener('click', handleNativeClick, true);
return () => document.removeEventListener('click', handleNativeClick, true);
}, [handleNext, testId]);
useEffect(() => {
if (!activeStep) return;
const previous = previousCompletionRef.current;
previousCompletionRef.current = {
stepId: activeStep.id,
complete: isComplete,
};
if (
!activeStep.advanceOnComplete ||
previous?.stepId !== activeStep.id ||
previous.complete ||
!isComplete
) {
return;
}
window.setTimeout(handleNext, 0);
}, [activeStep, handleNext, isComplete]);
}, [finishTour, handleNext, testId]);
if (
!enabled ||
@@ -282,7 +257,6 @@ export default function GuidedTour({
<div
data-testid={testId}
data-active-step={activeStep.id}
data-step-complete={String(isComplete)}
translate="no"
className="notranslate pointer-events-none"
>
@@ -301,19 +275,30 @@ export default function GuidedTour({
ref={popoverRef}
role="dialog"
aria-labelledby={titleId}
className="pointer-events-auto fixed z-[62] rounded-lg border bg-popover p-4 text-popover-foreground shadow-xl"
className="pointer-events-auto fixed z-[80] rounded-lg border bg-popover p-4 text-popover-foreground shadow-xl"
style={popoverPosition}
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="mb-3 flex items-center gap-2">
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">
{t('guidedTour.label')}
</span>
<span className="text-xs tabular-nums text-muted-foreground">
<span className="ml-auto text-xs tabular-nums text-muted-foreground">
{t('guidedTour.progress', {
current: activeIndex + 1,
total: steps.length,
})}
</span>
<Button
type="button"
variant="ghost"
size="sm"
data-guided-tour-action="skip"
data-guided-tour-id={testId}
className="-my-2 -mr-2 h-7 gap-1 px-2 text-xs text-muted-foreground"
>
<X className="size-3.5" />
{t('guidedTour.skip')}
</Button>
</div>
<h2 id={titleId} className="text-base font-semibold">
{activeStep.title}
@@ -332,18 +317,11 @@ export default function GuidedTour({
<ExternalLink className="size-3.5" />
</a>
)}
{!isComplete && activeStep.requirement && (
<div className="mt-3 flex items-start gap-2 rounded-md bg-muted px-3 py-2 text-xs leading-5 text-muted-foreground">
<LockKeyhole className="mt-0.5 size-3.5 shrink-0" />
<span>{activeStep.requirement}</span>
</div>
)}
<Button
type="button"
data-guided-tour-action="next"
data-guided-tour-id={testId}
className="mt-4 w-full"
disabled={!isComplete}
>
{isLastStep ? (
<Check className="size-4" />
@@ -1,46 +0,0 @@
import {
IDynamicFormItemSchema,
SYSTEM_FIELD_PREFIX,
} from '@/app/infra/entities/form/dynamic';
function isVisible(
item: IDynamicFormItemSchema,
values: Record<string, unknown>,
externalValues: Record<string, unknown> = {},
) {
if (!item.show_if || item.show_if.field.startsWith(SYSTEM_FIELD_PREFIX)) {
return true;
}
const actual =
values[item.show_if.field] ?? externalValues[item.show_if.field];
if (item.show_if.operator === 'eq') return actual === item.show_if.value;
if (item.show_if.operator === 'neq') return actual !== item.show_if.value;
return (
Array.isArray(item.show_if.value) && item.show_if.value.includes(actual)
);
}
function hasValue(value: unknown) {
if (value === null || value === undefined) return false;
if (typeof value === 'string') return value.trim().length > 0;
if (Array.isArray(value)) return value.length > 0;
if (typeof value === 'object' && 'primary' in value) {
return String((value as { primary?: unknown }).primary ?? '').trim() !== '';
}
return true;
}
export function areRequiredDynamicFieldsComplete(
items: IDynamicFormItemSchema[],
values: Record<string, unknown>,
externalValues?: Record<string, unknown>,
) {
return items
.filter(
(item) =>
item.required &&
!item.name.startsWith(SYSTEM_FIELD_PREFIX) &&
isVisible(item, values, externalValues),
)
.every((item) => hasValue(values[item.name]));
}
+22 -13
View File
@@ -164,7 +164,7 @@ export default function KBDetailContent({ id }: { id: string }) {
</h1>
{canManage && (
<Button type="submit" form="kb-form" data-guide="knowledge-submit">
{t('common.submit')}
{t('common.save')}
</Button>
)}
</div>
@@ -215,6 +215,7 @@ export default function KBDetailContent({ id }: { id: string }) {
form="kb-form"
disabled={!formDirty}
className={activeTab !== 'metadata' ? 'invisible' : ''}
data-guide="knowledge-config-save"
>
{t('common.save')}
</Button>
@@ -233,16 +234,18 @@ export default function KBDetailContent({ id }: { id: string }) {
<FileText className="size-3.5" />
{t('knowledge.metadata')}
</TabsTrigger>
{hasDocumentCapability() && (
{kbInfo.initialized !== false && hasDocumentCapability() && (
<TabsTrigger value="documents" className="gap-1.5">
<FolderOpen className="size-3.5" />
{t('knowledge.documents')}
</TabsTrigger>
)}
<TabsTrigger value="retrieve" className="gap-1.5">
<Search className="size-3.5" />
{t('knowledge.retrieve')}
</TabsTrigger>
{kbInfo.initialized !== false && (
<TabsTrigger value="retrieve" className="gap-1.5">
<Search className="size-3.5" />
{t('knowledge.retrieve')}
</TabsTrigger>
)}
</TabsList>
{/* Tab: Metadata */}
@@ -258,6 +261,7 @@ export default function KBDetailContent({ id }: { id: string }) {
onNewKbCreated={handleNewKbCreated}
onKbUpdated={handleKbUpdated}
onDirtyChange={setFormDirty}
guideEnabled={canManage}
/>
</fieldset>
@@ -299,7 +303,7 @@ export default function KBDetailContent({ id }: { id: string }) {
</TabsContent>
{/* Tab: Documents */}
{hasDocumentCapability() && (
{kbInfo.initialized !== false && hasDocumentCapability() && (
<TabsContent
value="documents"
className="flex-1 min-h-0 overflow-y-auto mt-4"
@@ -315,12 +319,17 @@ export default function KBDetailContent({ id }: { id: string }) {
)}
{/* Tab: Retrieve */}
<TabsContent
value="retrieve"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<KBRetrieveGeneric kbId={id} retrieveFunction={retrieveFunction} />
</TabsContent>
{kbInfo.initialized !== false && (
<TabsContent
value="retrieve"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<KBRetrieveGeneric
kbId={id}
retrieveFunction={retrieveFunction}
/>
</TabsContent>
)}
</Tabs>
</div>
@@ -40,7 +40,6 @@ import KnowledgeEngineSelect from './KnowledgeEngineSelect';
import GuidedTour, {
GuidedTourStep,
} from '@/app/home/components/guided-tour/GuidedTour';
import { areRequiredDynamicFieldsComplete } from '@/app/home/components/guided-tour/dynamic-form-progress';
const KNOWLEDGE_ENGINE_MARKETPLACE_URL =
'https://space.langbot.app/market?type=plugin&component=KnowledgeEngine';
@@ -104,6 +103,7 @@ export default function KBForm({
Record<string, unknown>
>({});
const [isEditing, setIsEditing] = useState(Boolean(initKbId));
const [engineInitialized, setEngineInitialized] = useState(true);
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [initialDataLoaded, setInitialDataLoaded] = useState(false);
@@ -220,11 +220,13 @@ export default function KBForm({
setConfigSettings(kb.creation_settings || {});
setRetrievalSettings(kb.retrieval_settings || {});
setEngineInitialized(kb.initialized !== false);
// Capture snapshot after a tick so dynamic forms have emitted initial values
setTimeout(() => {
captureSnapshot();
isInitializing.current = false;
onDirtyChange?.(kb.initialized === false);
}, 500);
} catch (err) {
isInitializing.current = false;
@@ -269,8 +271,8 @@ export default function KBForm({
}, []);
const onSubmit = async (data: z.infer<typeof formSchema>) => {
// Validate dynamic forms before submission
if (configValidateRef.current) {
// Engine parameters are configured only after the draft has been created.
if (initKbId && configValidateRef.current) {
const configValid = await configValidateRef.current();
if (!configValid) {
toast.error(t('knowledge.engineSettingsInvalid'));
@@ -278,7 +280,7 @@ export default function KBForm({
}
}
if (retrievalValidateRef.current) {
if (initKbId && retrievalValidateRef.current) {
const retrievalValid = await retrievalValidateRef.current();
if (!retrievalValid) {
toast.error(t('knowledge.retrievalSettingsInvalid'));
@@ -293,12 +295,16 @@ export default function KBForm({
knowledge_engine_plugin_id: selectedEngineId,
creation_settings: configSettings,
retrieval_settings: retrievalSettings,
...(initKbId
? { initialize_engine: !engineInitialized }
: { defer_initialization: true }),
};
if (initKbId) {
httpClient
.updateKnowledgeBase(initKbId, kbData)
.then((res) => {
setEngineInitialized(true);
captureSnapshot();
onDirtyChange?.(false);
onKbUpdated(res.uuid);
@@ -339,21 +345,11 @@ export default function KBForm({
const guideSteps = useMemo<GuidedTourStep[]>(() => {
const steps: GuidedTourStep[] = [
{
id: 'basic',
target: '[data-guide="knowledge-basic"]',
title: t('guidedTour.knowledge.basic.title'),
description: t('guidedTour.knowledge.basic.description'),
complete: Boolean(watchedFormValues.name?.trim()),
requirement: t('guidedTour.knowledge.basic.requirement'),
},
{
id: 'engine',
target: '[data-guide="knowledge-engine"]',
title: t('guidedTour.knowledge.engine.title'),
description: t('guidedTour.knowledge.engine.description'),
complete: Boolean(selectedEngineId),
requirement: t('guidedTour.knowledge.engine.requirement'),
action: {
href: KNOWLEDGE_ENGINE_MARKETPLACE_URL,
label: t('guidedTour.knowledge.engine.action'),
@@ -367,12 +363,6 @@ export default function KBForm({
target: '[data-guide="knowledge-engine-parameters"]',
title: t('guidedTour.knowledge.parameters.title'),
description: t('guidedTour.knowledge.parameters.description'),
complete: areRequiredDynamicFieldsComplete(
configFormItems,
configSettings,
retrievalSettings,
),
requirement: t('guidedTour.knowledge.parameters.requirement'),
});
}
@@ -382,31 +372,17 @@ export default function KBForm({
target: '[data-guide="knowledge-retrieval"]',
title: t('guidedTour.knowledge.retrieval.title'),
description: t('guidedTour.knowledge.retrieval.description'),
complete: areRequiredDynamicFieldsComplete(
retrievalFormItems,
retrievalSettings,
configSettings,
),
requirement: t('guidedTour.knowledge.retrieval.requirement'),
});
}
steps.push({
id: 'submit',
target: '[data-guide="knowledge-submit"]',
title: t('guidedTour.knowledge.submit.title'),
description: t('guidedTour.knowledge.submit.description'),
id: 'save',
target: '[data-guide="knowledge-config-save"]',
title: t('guidedTour.knowledge.save.title'),
description: t('guidedTour.knowledge.save.description'),
});
return steps;
}, [
configFormItems,
configSettings,
retrievalFormItems,
retrievalSettings,
selectedEngineId,
t,
watchedFormValues.name,
]);
}, [configFormItems, retrievalFormItems, t]);
if (loadFailed)
return (
@@ -529,7 +505,7 @@ export default function KBForm({
)}
/>
{configFormItems.length > 0 && (
{isEditing && configFormItems.length > 0 && (
<div
data-guide="knowledge-engine-parameters"
className="space-y-6"
@@ -541,7 +517,7 @@ export default function KBForm({
onSubmit={(val) =>
setConfigSettings(val as Record<string, unknown>)
}
isEditing={isEditing}
isEditing={engineInitialized}
externalDependentValues={retrievalSettings}
onValidate={(validateFn) =>
(configValidateRef.current = validateFn)
@@ -553,7 +529,7 @@ export default function KBForm({
</Card>
{/* Retrieval Settings (dynamic form from retrieval_schema) */}
{retrievalFormItems.length > 0 && (
{isEditing && retrievalFormItems.length > 0 && (
<Card data-guide="knowledge-retrieval">
<CardHeader>
<CardTitle>{t('knowledge.retrievalSettings')}</CardTitle>
@@ -578,10 +554,10 @@ export default function KBForm({
)}
</form>
<GuidedTour
enabled={!isEditing && guideEnabled}
storageKey="langbot_knowledge_create_guide_v1"
enabled={isEditing && guideEnabled}
storageKey="langbot_knowledge_detail_guide_v1"
steps={guideSteps}
testId="knowledge-create-guide"
testId="knowledge-detail-guide"
/>
</Form>
);
+3
View File
@@ -431,6 +431,9 @@ export interface KnowledgeBase {
knowledge_engine_plugin_id?: string;
creation_settings?: Record<string, unknown>;
retrieval_settings?: Record<string, unknown>;
initialized?: boolean;
defer_initialization?: boolean;
initialize_engine?: boolean;
knowledge_engine?: KnowledgeEngineInfo;
}