mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
fix(web): unify processor creation and entity detail loading states
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import EntityLoadState from '@/components/EntityLoadState';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -43,6 +44,8 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
|||||||
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
|
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
|
||||||
const [agent, setAgent] = useState<Agent | null>(null);
|
const [agent, setAgent] = useState<Agent | null>(null);
|
||||||
const [platformTools, setPlatformTools] = useState<AgentPlatformTool[]>([]);
|
const [platformTools, setPlatformTools] = useState<AgentPlatformTool[]>([]);
|
||||||
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
|
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||||
const [loading, setLoading] = useState(!isCreateMode);
|
const [loading, setLoading] = useState(!isCreateMode);
|
||||||
const [formDirty, setFormDirty] = useState(false);
|
const [formDirty, setFormDirty] = useState(false);
|
||||||
const [formSaving, setFormSaving] = useState(false);
|
const [formSaving, setFormSaving] = useState(false);
|
||||||
@@ -77,6 +80,7 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
|||||||
if (isCreateMode) return;
|
if (isCreateMode) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setLoadFailed(false);
|
||||||
Promise.all([
|
Promise.all([
|
||||||
httpClient.getAgent(id),
|
httpClient.getAgent(id),
|
||||||
httpClient.getAdapters().catch(() => ({ adapters: [] })),
|
httpClient.getAdapters().catch(() => ({ adapters: [] })),
|
||||||
@@ -97,13 +101,16 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
|||||||
);
|
);
|
||||||
setAgent(resp.agent);
|
setAgent(resp.agent);
|
||||||
})
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setLoadFailed(true);
|
||||||
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [id, isCreateMode]);
|
}, [id, isCreateMode, loadAttempt]);
|
||||||
|
|
||||||
if (isCreateMode) {
|
if (isCreateMode) {
|
||||||
return (
|
return (
|
||||||
@@ -116,13 +123,11 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading || !agent) {
|
if (loadFailed)
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
|
||||||
{t('common.loading')}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
if (loading || !agent) return <EntityLoadState />;
|
||||||
|
|
||||||
if (agent.kind === 'pipeline') {
|
if (agent.kind === 'pipeline') {
|
||||||
return <PipelineDetailContent id={id} routeBase="/home/agents" />;
|
return <PipelineDetailContent id={id} routeBase="/home/agents" />;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import EntityLoadState from '@/components/EntityLoadState';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
@@ -79,6 +80,7 @@ export default function PluginProcessorDetailContent({
|
|||||||
const [events, setEvents] = useState<ProcessorRunEvent[]>([]);
|
const [events, setEvents] = useState<ProcessorRunEvent[]>([]);
|
||||||
const [eventCursor, setEventCursor] = useState<number | null>(null);
|
const [eventCursor, setEventCursor] = useState<number | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [pagingRuns, setPagingRuns] = useState(false);
|
const [pagingRuns, setPagingRuns] = useState(false);
|
||||||
const [pagingEvents, setPagingEvents] = useState(false);
|
const [pagingEvents, setPagingEvents] = useState(false);
|
||||||
@@ -89,6 +91,7 @@ export default function PluginProcessorDetailContent({
|
|||||||
const available = Boolean(component);
|
const available = Boolean(component);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
try {
|
try {
|
||||||
const [metadata, page] = await Promise.all([
|
const [metadata, page] = await Promise.all([
|
||||||
@@ -99,6 +102,7 @@ export default function PluginProcessorDetailContent({
|
|||||||
setPlatformTools(metadata.platform_tools ?? []);
|
setPlatformTools(metadata.platform_tools ?? []);
|
||||||
setRuns(page.items);
|
setRuns(page.items);
|
||||||
setCursor(page.has_more ? page.next_cursor : null);
|
setCursor(page.has_more ? page.next_cursor : null);
|
||||||
|
setInitialLoadComplete(true);
|
||||||
} catch {
|
} catch {
|
||||||
setFailed(true);
|
setFailed(true);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -385,6 +389,9 @@ export default function PluginProcessorDetailContent({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!initialLoadComplete)
|
||||||
|
return <EntityLoadState error={failed} onRetry={() => void load()} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProcessorDetailWorkbench
|
<ProcessorDetailWorkbench
|
||||||
title={`${agent.emoji || '🧩'} ${agent.name}`}
|
title={`${agent.emoji || '🧩'} ${agent.name}`}
|
||||||
|
|||||||
@@ -121,11 +121,7 @@ export default function AgentCreateContent({
|
|||||||
form="agent-create-form"
|
form="agent-create-form"
|
||||||
disabled={form.formState.isSubmitting}
|
disabled={form.formState.isSubmitting}
|
||||||
>
|
>
|
||||||
{t(
|
{t('common.submit')}
|
||||||
kind === 'event_processor'
|
|
||||||
? 'agents.eventProcessor.create'
|
|
||||||
: 'common.submit',
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import EntityLoadState from '@/components/EntityLoadState';
|
||||||
import {
|
import {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
type ForwardedRef,
|
type ForwardedRef,
|
||||||
@@ -145,6 +146,8 @@ function AgentFormComponent(
|
|||||||
useState<ApiRespPluginSystemStatus | null>(null);
|
useState<ApiRespPluginSystemStatus | null>(null);
|
||||||
const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
|
const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
|
||||||
const [pluginStatusError, setPluginStatusError] = useState(false);
|
const [pluginStatusError, setPluginStatusError] = useState(false);
|
||||||
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
|
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||||
const [initialDataLoaded, setInitialDataLoaded] = useState(false);
|
const [initialDataLoaded, setInitialDataLoaded] = useState(false);
|
||||||
const [runnerInstallRecovering, setRunnerInstallRecovering] = useState(false);
|
const [runnerInstallRecovering, setRunnerInstallRecovering] = useState(false);
|
||||||
const [activeSection, setActiveSection] =
|
const [activeSection, setActiveSection] =
|
||||||
@@ -208,6 +211,8 @@ function AgentFormComponent(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
setInitialDataLoaded(false);
|
||||||
|
setLoadFailed(false);
|
||||||
Promise.all([httpClient.getAgentMetadata(), httpClient.getAgent(agentId)])
|
Promise.all([httpClient.getAgentMetadata(), httpClient.getAgent(agentId)])
|
||||||
.then(([metadata, resp]) => {
|
.then(([metadata, resp]) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -274,12 +279,14 @@ function AgentFormComponent(
|
|||||||
setInitialDataLoaded(true);
|
setInitialDataLoaded(true);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoadFailed(true);
|
||||||
toast.error(t('agents.loadError') + err.msg);
|
toast.error(t('agents.loadError') + err.msg);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [agentId, form, t]);
|
}, [agentId, form, t, loadAttempt]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialDataLoaded || !readPendingRunnerInstall(runnerInstallScope)) {
|
if (!initialDataLoaded || !readPendingRunnerInstall(runnerInstallScope)) {
|
||||||
@@ -390,7 +397,8 @@ function AgentFormComponent(
|
|||||||
];
|
];
|
||||||
|
|
||||||
const runnerStatus = useMemo<RunnerStatus>(() => {
|
const runnerStatus = useMemo<RunnerStatus>(() => {
|
||||||
if (pluginStatusLoading) {
|
if (loadFailed) return { label: t('common.loadFailed'), tone: 'error' };
|
||||||
|
if (!initialDataLoaded || pluginStatusLoading) {
|
||||||
return {
|
return {
|
||||||
label: t('agents.runnerStatusLoading'),
|
label: t('agents.runnerStatusLoading'),
|
||||||
tone: 'neutral',
|
tone: 'neutral',
|
||||||
@@ -459,6 +467,8 @@ function AgentFormComponent(
|
|||||||
tone: 'success',
|
tone: 'success',
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
|
initialDataLoaded,
|
||||||
|
loadFailed,
|
||||||
currentRunner,
|
currentRunner,
|
||||||
pluginStatusError,
|
pluginStatusError,
|
||||||
pluginStatusLoading,
|
pluginStatusLoading,
|
||||||
@@ -635,6 +645,7 @@ function AgentFormComponent(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async save() {
|
async save() {
|
||||||
|
if (!initialDataLoaded || loadFailed) return false;
|
||||||
if (!hasUnsavedChangesRef.current) return true;
|
if (!hasUnsavedChangesRef.current) return true;
|
||||||
if (isSavingRef.current) return false;
|
if (isSavingRef.current) return false;
|
||||||
const valid = await form.trigger();
|
const valid = await form.trigger();
|
||||||
@@ -642,9 +653,15 @@ function AgentFormComponent(
|
|||||||
return (await saveValues(form.getValues())) ?? false;
|
return (await saveValues(form.getValues())) ?? false;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[form, saveValues],
|
[form, initialDataLoaded, loadFailed, saveValues],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (loadFailed)
|
||||||
|
return (
|
||||||
|
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
|
||||||
|
);
|
||||||
|
if (!initialDataLoaded) return <EntityLoadState />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full p-0 flex flex-col">
|
<div className="h-full p-0 flex flex-col">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default function AgentsPage() {
|
|||||||
const detailId = searchParams.get('id');
|
const detailId = searchParams.get('id');
|
||||||
|
|
||||||
if (detailId) {
|
if (detailId) {
|
||||||
return <AgentDetailContent id={detailId} />;
|
return <AgentDetailContent key={detailId} id={detailId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import EntityLoadState from '@/components/EntityLoadState';
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
@@ -59,6 +60,8 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
const [adapterLabel, setAdapterLabel] = useState('');
|
const [adapterLabel, setAdapterLabel] = useState('');
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
|
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
|
||||||
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
|
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||||
const [bot, setBot] = useState<Bot | null>(null);
|
const [bot, setBot] = useState<Bot | null>(null);
|
||||||
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false);
|
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false);
|
||||||
const sessionMonitorRef = useRef<BotSessionMonitorHandle>(null);
|
const sessionMonitorRef = useRef<BotSessionMonitorHandle>(null);
|
||||||
@@ -74,13 +77,17 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
// Fetch bot enable state
|
// Fetch bot enable state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCreateMode) {
|
if (!isCreateMode) {
|
||||||
httpClient.getBot(id).then((res) => {
|
setLoadFailed(false);
|
||||||
setBot(res.bot);
|
httpClient
|
||||||
setBotEnabled(res.bot.enable ?? true);
|
.getBot(id)
|
||||||
setEnableLoaded(true);
|
.then((res) => {
|
||||||
});
|
setBot(res.bot);
|
||||||
|
setBotEnabled(res.bot.enable ?? true);
|
||||||
|
setEnableLoaded(true);
|
||||||
|
})
|
||||||
|
.catch(() => setLoadFailed(true));
|
||||||
}
|
}
|
||||||
}, [id, isCreateMode]);
|
}, [id, isCreateMode, loadAttempt]);
|
||||||
|
|
||||||
const handleEnableToggle = useCallback(
|
const handleEnableToggle = useCallback(
|
||||||
async (checked: boolean) => {
|
async (checked: boolean) => {
|
||||||
@@ -178,6 +185,12 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loadFailed)
|
||||||
|
return (
|
||||||
|
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
|
||||||
|
);
|
||||||
|
if (!enableLoaded) return <EntityLoadState />;
|
||||||
|
|
||||||
// ==================== Edit Mode ====================
|
// ==================== Edit Mode ====================
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import EntityLoadState from '@/components/EntityLoadState';
|
||||||
import { showBotError } from '../../bot-error';
|
import { showBotError } from '../../bot-error';
|
||||||
import React, {
|
import React, {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
@@ -137,6 +138,9 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
|||||||
|
|
||||||
// Track whether initial data loading is complete.
|
// Track whether initial data loading is complete.
|
||||||
// setValue calls during init should NOT mark the form as dirty.
|
// setValue calls during init should NOT mark the form as dirty.
|
||||||
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
|
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||||
|
const [initialDataLoaded, setInitialDataLoaded] = useState(false);
|
||||||
const isInitializing = useRef(true);
|
const isInitializing = useRef(true);
|
||||||
|
|
||||||
const [adapterNameToDynamicConfigMap, setAdapterNameToDynamicConfigMap] =
|
const [adapterNameToDynamicConfigMap, setAdapterNameToDynamicConfigMap] =
|
||||||
@@ -225,49 +229,55 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBotFormValues();
|
setBotFormValues();
|
||||||
}, []);
|
}, [initBotId, loadAttempt]);
|
||||||
|
|
||||||
function setBotFormValues() {
|
function setBotFormValues() {
|
||||||
|
setInitialDataLoaded(false);
|
||||||
|
setLoadFailed(false);
|
||||||
isInitializing.current = true;
|
isInitializing.current = true;
|
||||||
initBotFormComponent().then(() => {
|
initBotFormComponent()
|
||||||
if (initBotId) {
|
.then(() => {
|
||||||
getBotConfig(initBotId)
|
if (initBotId) {
|
||||||
.then((val) => {
|
return getBotConfig(initBotId)
|
||||||
// Use form.reset() to set values AND update the dirty baseline,
|
.then((val) => {
|
||||||
// so isDirty stays false after initial load.
|
// Use form.reset() to set values AND update the dirty baseline,
|
||||||
form.reset({
|
// so isDirty stays false after initial load.
|
||||||
name: val.name,
|
form.reset({
|
||||||
description: val.description,
|
name: val.name,
|
||||||
adapter: val.adapter,
|
description: val.description,
|
||||||
adapter_config: val.adapter_config,
|
adapter: val.adapter,
|
||||||
enable: val.enable,
|
adapter_config: val.adapter_config,
|
||||||
event_bindings: val.event_bindings || [],
|
enable: val.enable,
|
||||||
plugin_processors: val.plugin_processors || [],
|
event_bindings: val.event_bindings || [],
|
||||||
});
|
plugin_processors: val.plugin_processors || [],
|
||||||
handleAdapterSelect(val.adapter);
|
});
|
||||||
|
handleAdapterSelect(val.adapter);
|
||||||
|
|
||||||
if (val.webhook_full_url) {
|
if (val.webhook_full_url) {
|
||||||
setWebhookUrl(val.webhook_full_url);
|
setWebhookUrl(val.webhook_full_url);
|
||||||
} else {
|
} else {
|
||||||
setWebhookUrl('');
|
setWebhookUrl('');
|
||||||
}
|
}
|
||||||
setExtraWebhookUrl(val.extra_webhook_full_url || '');
|
setExtraWebhookUrl(val.extra_webhook_full_url || '');
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
toast.error(
|
setLoadFailed(true);
|
||||||
t('bots.getBotConfigError') + (err as CustomApiError).msg,
|
toast.error(
|
||||||
);
|
t('bots.getBotConfigError') + (err as CustomApiError).msg,
|
||||||
})
|
);
|
||||||
.finally(() => {
|
})
|
||||||
isInitializing.current = false;
|
.finally(() => {
|
||||||
});
|
isInitializing.current = false;
|
||||||
} else {
|
});
|
||||||
form.reset();
|
} else {
|
||||||
setWebhookUrl('');
|
form.reset();
|
||||||
setExtraWebhookUrl('');
|
setWebhookUrl('');
|
||||||
isInitializing.current = false;
|
setExtraWebhookUrl('');
|
||||||
}
|
isInitializing.current = false;
|
||||||
});
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setLoadFailed(true))
|
||||||
|
.finally(() => setInitialDataLoaded(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initBotFormComponent() {
|
async function initBotFormComponent() {
|
||||||
@@ -460,6 +470,12 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loadFailed)
|
||||||
|
return (
|
||||||
|
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
|
||||||
|
);
|
||||||
|
if (!initialDataLoaded) return <EntityLoadState />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default function BotConfigPage() {
|
|||||||
const detailId = searchParams.get('id');
|
const detailId = searchParams.get('id');
|
||||||
|
|
||||||
if (detailId) {
|
if (detailId) {
|
||||||
return <BotDetailContent id={detailId} />;
|
return <BotDetailContent key={detailId} id={detailId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import EntityLoadState from '@/components/EntityLoadState';
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
@@ -57,16 +58,20 @@ export default function KBDetailContent({ id }: { id: string }) {
|
|||||||
const [activeTab, setActiveTab] = useState('metadata');
|
const [activeTab, setActiveTab] = useState('metadata');
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [showBasicInfoDialog, setShowBasicInfoDialog] = useState(false);
|
const [showBasicInfoDialog, setShowBasicInfoDialog] = useState(false);
|
||||||
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
|
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||||
const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null);
|
const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null);
|
||||||
const [formDirty, setFormDirty] = useState(false);
|
const [formDirty, setFormDirty] = useState(false);
|
||||||
const [formVersion, setFormVersion] = useState(0);
|
const [formVersion, setFormVersion] = useState(0);
|
||||||
|
|
||||||
const loadKbInfo = useCallback(
|
const loadKbInfo = useCallback(
|
||||||
async (kbId: string) => {
|
async (kbId: string) => {
|
||||||
|
setLoadFailed(false);
|
||||||
try {
|
try {
|
||||||
const resp = await httpClient.getKnowledgeBase(kbId);
|
const resp = await httpClient.getKnowledgeBase(kbId);
|
||||||
setKbInfo(resp.base);
|
setKbInfo(resp.base);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setLoadFailed(true);
|
||||||
console.error('Failed to load KB info:', e);
|
console.error('Failed to load KB info:', e);
|
||||||
toast.error(
|
toast.error(
|
||||||
t('knowledge.loadKnowledgeBaseFailed') + (e as CustomApiError).msg,
|
t('knowledge.loadKnowledgeBaseFailed') + (e as CustomApiError).msg,
|
||||||
@@ -81,7 +86,7 @@ export default function KBDetailContent({ id }: { id: string }) {
|
|||||||
if (!isCreateMode) {
|
if (!isCreateMode) {
|
||||||
loadKbInfo(id);
|
loadKbInfo(id);
|
||||||
}
|
}
|
||||||
}, [id, isCreateMode, loadKbInfo]);
|
}, [id, isCreateMode, loadKbInfo, loadAttempt]);
|
||||||
|
|
||||||
const hasDocumentCapability = (): boolean => {
|
const hasDocumentCapability = (): boolean => {
|
||||||
if (!kbInfo || !kbInfo.knowledge_engine) return false;
|
if (!kbInfo || !kbInfo.knowledge_engine) return false;
|
||||||
@@ -179,6 +184,12 @@ export default function KBDetailContent({ id }: { id: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loadFailed)
|
||||||
|
return (
|
||||||
|
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
|
||||||
|
);
|
||||||
|
if (!kbInfo) return <EntityLoadState />;
|
||||||
|
|
||||||
// ==================== Edit Mode ====================
|
// ==================== Edit Mode ====================
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import EntityLoadState from '@/components/EntityLoadState';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@@ -94,6 +95,9 @@ export default function KBForm({
|
|||||||
Record<string, unknown>
|
Record<string, unknown>
|
||||||
>({});
|
>({});
|
||||||
const [isEditing, setIsEditing] = useState(Boolean(initKbId));
|
const [isEditing, setIsEditing] = useState(Boolean(initKbId));
|
||||||
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
|
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||||
|
const [initialDataLoaded, setInitialDataLoaded] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Dirty tracking: snapshot of saved state for comparison
|
// Dirty tracking: snapshot of saved state for comparison
|
||||||
@@ -144,12 +148,15 @@ export default function KBForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadRagEngines().then(() => {
|
setInitialDataLoaded(false);
|
||||||
if (initKbId) {
|
setLoadFailed(false);
|
||||||
loadKbConfig(initKbId);
|
loadRagEngines()
|
||||||
}
|
.then(() => {
|
||||||
});
|
if (initKbId) return loadKbConfig(initKbId);
|
||||||
}, []);
|
})
|
||||||
|
.catch(() => setLoadFailed(true))
|
||||||
|
.finally(() => setInitialDataLoaded(true));
|
||||||
|
}, [initKbId, loadAttempt]);
|
||||||
|
|
||||||
// Auto-select first engine when engines are loaded and no selection
|
// Auto-select first engine when engines are loaded and no selection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -178,7 +185,7 @@ export default function KBForm({
|
|||||||
const resp = await httpClient.getKnowledgeEngines();
|
const resp = await httpClient.getKnowledgeEngines();
|
||||||
setRagEngines(resp.engines);
|
setRagEngines(resp.engines);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load Knowledge Engines:', err);
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -211,8 +218,8 @@ export default function KBForm({
|
|||||||
isInitializing.current = false;
|
isInitializing.current = false;
|
||||||
}, 500);
|
}, 500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load KB config:', err);
|
|
||||||
isInitializing.current = false;
|
isInitializing.current = false;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -321,6 +328,12 @@ export default function KBForm({
|
|||||||
[selectedEngine?.retrieval_schema],
|
[selectedEngine?.retrieval_schema],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (loadFailed)
|
||||||
|
return (
|
||||||
|
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
|
||||||
|
);
|
||||||
|
if (!initialDataLoaded) return <EntityLoadState />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function KnowledgePage() {
|
|||||||
externalKbCount={migrationExternalCount}
|
externalKbCount={migrationExternalCount}
|
||||||
onMigrationComplete={handleMigrationComplete}
|
onMigrationComplete={handleMigrationComplete}
|
||||||
/>
|
/>
|
||||||
<KBDetailContent id={detailId} />
|
<KBDetailContent key={detailId} id={detailId} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import EntityLoadState from '@/components/EntityLoadState';
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -74,6 +75,8 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
|||||||
|
|
||||||
// Enable state managed here so the header switch works
|
// Enable state managed here so the header switch works
|
||||||
const [serverEnabled, setServerEnabled] = useState(true);
|
const [serverEnabled, setServerEnabled] = useState(true);
|
||||||
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
|
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||||
const [enableLoaded, setEnableLoaded] = useState(false);
|
const [enableLoaded, setEnableLoaded] = useState(false);
|
||||||
const [detailRuntimeStatus, setDetailRuntimeStatus] =
|
const [detailRuntimeStatus, setDetailRuntimeStatus] =
|
||||||
useState<MCPRuntimeState | null>(null);
|
useState<MCPRuntimeState | null>(null);
|
||||||
@@ -120,14 +123,18 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCreateMode) {
|
if (!isCreateMode) {
|
||||||
setDetailRuntimeStatus(null);
|
setDetailRuntimeStatus(null);
|
||||||
httpClient.getMCPServer(id).then((res) => {
|
setLoadFailed(false);
|
||||||
const server = res.server ?? res;
|
httpClient
|
||||||
setServerEnabled(server.enable ?? true);
|
.getMCPServer(id)
|
||||||
setDetailRuntimeStatus(server.runtime_info?.status ?? null);
|
.then((res) => {
|
||||||
setEnableLoaded(true);
|
const server = res.server ?? res;
|
||||||
});
|
setServerEnabled(server.enable ?? true);
|
||||||
|
setDetailRuntimeStatus(server.runtime_info?.status ?? null);
|
||||||
|
setEnableLoaded(true);
|
||||||
|
})
|
||||||
|
.catch(() => setLoadFailed(true));
|
||||||
}
|
}
|
||||||
}, [id, isCreateMode]);
|
}, [id, isCreateMode, loadAttempt]);
|
||||||
|
|
||||||
const handleEnableToggle = useCallback(
|
const handleEnableToggle = useCallback(
|
||||||
async (checked: boolean) => {
|
async (checked: boolean) => {
|
||||||
@@ -325,6 +332,12 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ==================== Edit Mode ====================
|
// ==================== Edit Mode ====================
|
||||||
|
if (loadFailed)
|
||||||
|
return (
|
||||||
|
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
|
||||||
|
);
|
||||||
|
if (!enableLoaded) return <EntityLoadState />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import EntityLoadState from '@/components/EntityLoadState';
|
||||||
import React, {
|
import React, {
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useState,
|
useState,
|
||||||
@@ -563,6 +564,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const watchMode = form.watch('mode');
|
const watchMode = form.watch('mode');
|
||||||
const {
|
const {
|
||||||
|
loading: boxLoading,
|
||||||
available: boxAvailable,
|
available: boxAvailable,
|
||||||
hint: boxHint,
|
hint: boxHint,
|
||||||
reason: boxReason,
|
reason: boxReason,
|
||||||
@@ -577,6 +579,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
watchMode === 'stdio' && mcpStdioEnabled && !boxAvailable;
|
watchMode === 'stdio' && mcpStdioEnabled && !boxAvailable;
|
||||||
const stdioBlocked = stdioBlockedByPolicy || stdioBlockedByBox;
|
const stdioBlocked = stdioBlockedByPolicy || stdioBlockedByBox;
|
||||||
|
|
||||||
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
|
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||||
|
const [initialDataLoaded, setInitialDataLoaded] = useState(!isEditMode);
|
||||||
const { isDirty } = form.formState;
|
const { isDirty } = form.formState;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onDirtyChange?.(isDirty);
|
onDirtyChange?.(isDirty);
|
||||||
@@ -606,10 +611,13 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setLoadFailed(false);
|
||||||
|
setInitialDataLoaded(!isEditMode);
|
||||||
isInitializing.current = true;
|
isInitializing.current = true;
|
||||||
if (isEditMode && initServerName) {
|
if (isEditMode && initServerName) {
|
||||||
loadServerForEdit(initServerName).finally(() => {
|
loadServerForEdit(initServerName).finally(() => {
|
||||||
isInitializing.current = false;
|
isInitializing.current = false;
|
||||||
|
setInitialDataLoaded(true);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
form.reset({
|
form.reset({
|
||||||
@@ -636,7 +644,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
pollingIntervalRef.current = null;
|
pollingIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [initServerName]);
|
}, [initServerName, loadAttempt]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onDraftChange || isEditMode) return;
|
if (!onDraftChange || isEditMode) return;
|
||||||
@@ -756,6 +764,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
setRuntimeInfo(server.runtime_info ?? null);
|
setRuntimeInfo(server.runtime_info ?? null);
|
||||||
setReadme(server.readme ?? '');
|
setReadme(server.readme ?? '');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setLoadFailed(true);
|
||||||
console.error('Failed to load server:', error);
|
console.error('Failed to load server:', error);
|
||||||
toast.error(t('mcp.loadFailed'));
|
toast.error(t('mcp.loadFailed'));
|
||||||
}
|
}
|
||||||
@@ -1337,6 +1346,12 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
runtimePanel
|
runtimePanel
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (loadFailed)
|
||||||
|
return (
|
||||||
|
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
|
||||||
|
);
|
||||||
|
if (!initialDataLoaded || boxLoading) return <EntityLoadState />;
|
||||||
|
|
||||||
if (layout === 'split') {
|
if (layout === 'split') {
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default function MCPPage() {
|
|||||||
const detailId = searchParams.get('id');
|
const detailId = searchParams.get('id');
|
||||||
|
|
||||||
if (detailId) {
|
if (detailId) {
|
||||||
return <MCPDetailContent id={detailId} />;
|
return <MCPDetailContent key={detailId} id={detailId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import EntityLoadState from '@/components/EntityLoadState';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -52,6 +53,8 @@ export default function PipelineDetailContent({
|
|||||||
const [formDirty, setFormDirty] = useState(false);
|
const [formDirty, setFormDirty] = useState(false);
|
||||||
const [formSaving, setFormSaving] = useState(false);
|
const [formSaving, setFormSaving] = useState(false);
|
||||||
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
|
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
|
||||||
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
|
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||||
const [pipelineDetails, setPipelineDetails] = useState<Pipeline | null>(null);
|
const [pipelineDetails, setPipelineDetails] = useState<Pipeline | null>(null);
|
||||||
const pipelineFormRef = useRef<PipelineFormHandle>(null);
|
const pipelineFormRef = useRef<PipelineFormHandle>(null);
|
||||||
const sidebarPipeline = pipelines.find((item) => item.id === id);
|
const sidebarPipeline = pipelines.find((item) => item.id === id);
|
||||||
@@ -59,13 +62,17 @@ export default function PipelineDetailContent({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreateMode) return;
|
if (isCreateMode) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
httpClient.getPipeline(id).then((response) => {
|
setLoadFailed(false);
|
||||||
if (!cancelled) setPipelineDetails(response.pipeline);
|
httpClient
|
||||||
});
|
.getPipeline(id)
|
||||||
|
.then((response) => {
|
||||||
|
if (!cancelled) setPipelineDetails(response.pipeline);
|
||||||
|
})
|
||||||
|
.catch(() => setLoadFailed(true));
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [id, isCreateMode]);
|
}, [id, isCreateMode, loadAttempt]);
|
||||||
|
|
||||||
function handleFinish() {
|
function handleFinish() {
|
||||||
refreshPipelines();
|
refreshPipelines();
|
||||||
@@ -137,6 +144,12 @@ export default function PipelineDetailContent({
|
|||||||
navigate(routeBase);
|
navigate(routeBase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loadFailed)
|
||||||
|
return (
|
||||||
|
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
|
||||||
|
);
|
||||||
|
if (!pipelineDetails) return <EntityLoadState />;
|
||||||
|
|
||||||
// ==================== Edit Mode ====================
|
// ==================== Edit Mode ====================
|
||||||
const pipelineName =
|
const pipelineName =
|
||||||
pipelineDetails?.name ||
|
pipelineDetails?.name ||
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import EntityLoadState from '@/components/EntityLoadState';
|
||||||
import {
|
import {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -211,6 +212,8 @@ const PipelineFormComponent = forwardRef<
|
|||||||
useState<PipelineConfigTab>();
|
useState<PipelineConfigTab>();
|
||||||
const [outputConfigTabSchema, setOutputConfigTabSchema] =
|
const [outputConfigTabSchema, setOutputConfigTabSchema] =
|
||||||
useState<PipelineConfigTab>();
|
useState<PipelineConfigTab>();
|
||||||
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
|
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||||
const [metadataLoaded, setMetadataLoaded] = useState(false);
|
const [metadataLoaded, setMetadataLoaded] = useState(false);
|
||||||
const [pipelineLoaded, setPipelineLoaded] = useState(!isEditMode);
|
const [pipelineLoaded, setPipelineLoaded] = useState(!isEditMode);
|
||||||
|
|
||||||
@@ -257,28 +260,37 @@ const PipelineFormComponent = forwardRef<
|
|||||||
}, [hasUnsavedChanges, onDirtyChange]);
|
}, [hasUnsavedChanges, onDirtyChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadFailed(false);
|
||||||
setMetadataLoaded(false);
|
setMetadataLoaded(false);
|
||||||
setPipelineLoaded(!isEditMode);
|
setPipelineLoaded(!isEditMode);
|
||||||
// get config schema from metadata
|
// get config schema from metadata
|
||||||
httpClient.getGeneralPipelineMetadata().then((resp) => {
|
httpClient
|
||||||
for (const config of resp.configs) {
|
.getGeneralPipelineMetadata()
|
||||||
if (config.name === 'ai') {
|
.then((resp) => {
|
||||||
setAIConfigTabSchema(config);
|
if (cancelled) return;
|
||||||
} else if (config.name === 'trigger') {
|
for (const config of resp.configs) {
|
||||||
setTriggerConfigTabSchema(config);
|
if (config.name === 'ai') {
|
||||||
} else if (config.name === 'safety') {
|
setAIConfigTabSchema(config);
|
||||||
setSafetyConfigTabSchema(config);
|
} else if (config.name === 'trigger') {
|
||||||
} else if (config.name === 'output') {
|
setTriggerConfigTabSchema(config);
|
||||||
setOutputConfigTabSchema(config);
|
} else if (config.name === 'safety') {
|
||||||
|
setSafetyConfigTabSchema(config);
|
||||||
|
} else if (config.name === 'output') {
|
||||||
|
setOutputConfigTabSchema(config);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
setMetadataLoaded(true);
|
||||||
setMetadataLoaded(true);
|
})
|
||||||
});
|
.catch(() => {
|
||||||
|
if (!cancelled) setLoadFailed(true);
|
||||||
|
});
|
||||||
|
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
httpClient
|
httpClient
|
||||||
.getPipeline(pipelineId || '')
|
.getPipeline(pipelineId || '')
|
||||||
.then((resp: GetPipelineResponseData) => {
|
.then((resp: GetPipelineResponseData) => {
|
||||||
|
if (cancelled) return;
|
||||||
setIsDefaultPipeline(resp.pipeline.is_default ?? false);
|
setIsDefaultPipeline(resp.pipeline.is_default ?? false);
|
||||||
|
|
||||||
const loadedValues = {
|
const loadedValues = {
|
||||||
@@ -296,9 +308,15 @@ const PipelineFormComponent = forwardRef<
|
|||||||
savedSnapshotRef.current = JSON.stringify(loadedValues);
|
savedSnapshotRef.current = JSON.stringify(loadedValues);
|
||||||
initializedStagesRef.current.clear();
|
initializedStagesRef.current.clear();
|
||||||
setPipelineLoaded(true);
|
setPipelineLoaded(true);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setLoadFailed(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, isEditMode, pipelineId]);
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [form, isEditMode, pipelineId, loadAttempt]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -693,6 +711,12 @@ const PipelineFormComponent = forwardRef<
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (loadFailed)
|
||||||
|
return (
|
||||||
|
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
|
||||||
|
);
|
||||||
|
if (!metadataLoaded || !pipelineLoaded) return <EntityLoadState />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="h-full p-0 flex flex-col">
|
<div className="h-full p-0 flex flex-col">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default function PipelineConfigPage() {
|
|||||||
const detailId = searchParams.get('id');
|
const detailId = searchParams.get('id');
|
||||||
|
|
||||||
if (detailId) {
|
if (detailId) {
|
||||||
return <PipelineDetailContent id={detailId} />;
|
return <PipelineDetailContent key={detailId} id={detailId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import EntityLoadState from '@/components/EntityLoadState';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
@@ -78,11 +79,7 @@ export default function PluginPagesPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return <EntityLoadState />;
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetPath = page.path;
|
const assetPath = page.path;
|
||||||
@@ -209,9 +206,7 @@ function PluginPageIframe({
|
|||||||
{t('plugins.loadFailed')}
|
{t('plugins.loadFailed')}
|
||||||
</div>
|
</div>
|
||||||
) : loading || !assetUrl ? (
|
) : loading || !assetUrl ? (
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
<EntityLoadState />
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
{!assetError && assetUrl && (
|
{!assetError && assetUrl && (
|
||||||
<iframe
|
<iframe
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import EntityLoadState from '@/components/EntityLoadState';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
|
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
|
||||||
@@ -40,6 +41,8 @@ export default function PluginDetailContent({ id }: { id: string }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { plugins, setDetailEntityName, refreshPlugins } = useSidebarData();
|
const { plugins, setDetailEntityName, refreshPlugins } = useSidebarData();
|
||||||
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
|
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||||
const [pluginInfo, setPluginInfo] = useState<Plugin | null>(null);
|
const [pluginInfo, setPluginInfo] = useState<Plugin | null>(null);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [deleteData, setDeleteData] = useState(false);
|
const [deleteData, setDeleteData] = useState(false);
|
||||||
@@ -76,15 +79,19 @@ export default function PluginDetailContent({ id }: { id: string }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
httpClient.getPlugin(pluginAuthor, pluginName).then((res) => {
|
setLoadFailed(false);
|
||||||
if (!cancelled) {
|
httpClient
|
||||||
setPluginInfo(res.plugin);
|
.getPlugin(pluginAuthor, pluginName)
|
||||||
}
|
.then((res) => {
|
||||||
});
|
if (!cancelled) {
|
||||||
|
setPluginInfo(res.plugin);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setLoadFailed(true));
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [pluginAuthor, pluginName]);
|
}, [pluginAuthor, pluginName, loadAttempt]);
|
||||||
|
|
||||||
function handleFormSubmit(timeout?: number) {
|
function handleFormSubmit(timeout?: number) {
|
||||||
if (timeout) {
|
if (timeout) {
|
||||||
@@ -189,6 +196,12 @@ export default function PluginDetailContent({ id }: { id: string }) {
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (loadFailed)
|
||||||
|
return (
|
||||||
|
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
|
||||||
|
);
|
||||||
|
if (!pluginInfo) return <EntityLoadState />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import EntityLoadState from '@/components/EntityLoadState';
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { ApiRespPluginConfig } from '@/app/infra/entities/api';
|
import { ApiRespPluginConfig } from '@/app/infra/entities/api';
|
||||||
import { Plugin } from '@/app/infra/entities/plugin';
|
import { Plugin } from '@/app/infra/entities/plugin';
|
||||||
@@ -25,6 +26,8 @@ export default function PluginForm({
|
|||||||
onFormSubmit: (timeout?: number) => void;
|
onFormSubmit: (timeout?: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
|
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||||
const [pluginInfo, setPluginInfo] = useState<Plugin>();
|
const [pluginInfo, setPluginInfo] = useState<Plugin>();
|
||||||
const [pluginConfig, setPluginConfig] = useState<ApiRespPluginConfig>();
|
const [pluginConfig, setPluginConfig] = useState<ApiRespPluginConfig>();
|
||||||
const [isSaving, setIsLoading] = useState(false);
|
const [isSaving, setIsLoading] = useState(false);
|
||||||
@@ -33,36 +36,55 @@ export default function PluginForm({
|
|||||||
const initialFileKeys = useRef<Set<string>>(new Set());
|
const initialFileKeys = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadFailed(false);
|
||||||
|
setPluginInfo(undefined);
|
||||||
|
setPluginConfig(undefined);
|
||||||
// 获取插件信息
|
// 获取插件信息
|
||||||
httpClient.getPlugin(pluginAuthor, pluginName).then((res) => {
|
httpClient
|
||||||
setPluginInfo(res.plugin);
|
.getPlugin(pluginAuthor, pluginName)
|
||||||
});
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setPluginInfo(res.plugin);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setLoadFailed(true);
|
||||||
|
});
|
||||||
// 获取插件配置
|
// 获取插件配置
|
||||||
httpClient.getPluginConfig(pluginAuthor, pluginName).then((res) => {
|
httpClient
|
||||||
setPluginConfig(res);
|
.getPluginConfig(pluginAuthor, pluginName)
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setPluginConfig(res);
|
||||||
|
|
||||||
// 提取初始配置中的所有文件 key
|
// 提取初始配置中的所有文件 key
|
||||||
const extractFileKeys = (obj: any): string[] => {
|
const extractFileKeys = (obj: any): string[] => {
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
if (obj && typeof obj === 'object') {
|
if (obj && typeof obj === 'object') {
|
||||||
if ('file_key' in obj && typeof obj.file_key === 'string') {
|
if ('file_key' in obj && typeof obj.file_key === 'string') {
|
||||||
keys.push(obj.file_key);
|
keys.push(obj.file_key);
|
||||||
}
|
}
|
||||||
for (const value of Object.values(obj)) {
|
for (const value of Object.values(obj)) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
value.forEach((item) => keys.push(...extractFileKeys(item)));
|
value.forEach((item) => keys.push(...extractFileKeys(item)));
|
||||||
} else if (typeof value === 'object' && value !== null) {
|
} else if (typeof value === 'object' && value !== null) {
|
||||||
keys.push(...extractFileKeys(value));
|
keys.push(...extractFileKeys(value));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return keys;
|
||||||
return keys;
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const fileKeys = extractFileKeys(res.config);
|
const fileKeys = extractFileKeys(res.config);
|
||||||
initialFileKeys.current = new Set(fileKeys);
|
initialFileKeys.current = new Set(fileKeys);
|
||||||
});
|
})
|
||||||
}, [pluginAuthor, pluginName]);
|
.catch(() => {
|
||||||
|
if (!cancelled) setLoadFailed(true);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [pluginAuthor, pluginName, loadAttempt]);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -132,13 +154,11 @@ export default function PluginForm({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!pluginInfo || !pluginConfig) {
|
if (loadFailed)
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full mb-[2rem]">
|
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
|
||||||
{t('plugins.loading')}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
if (!pluginInfo || !pluginConfig) return <EntityLoadState />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 max-w-full space-y-4">
|
<div className="min-w-0 max-w-full space-y-4">
|
||||||
|
|||||||
+12
-5
@@ -1,3 +1,4 @@
|
|||||||
|
import EntityLoadState from '@/components/EntityLoadState';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -88,12 +89,15 @@ export default function PluginReadme({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [readme, setReadme] = useState<string>('');
|
const [readme, setReadme] = useState<string>('');
|
||||||
const [isLoadingReadme, setIsLoadingReadme] = useState(false);
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
|
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||||
|
const [isLoadingReadme, setIsLoadingReadme] = useState(true);
|
||||||
|
|
||||||
const language = getAPILanguageCode();
|
const language = getAPILanguageCode();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetch plugin README
|
// Fetch plugin README
|
||||||
|
setLoadFailed(false);
|
||||||
setIsLoadingReadme(true);
|
setIsLoadingReadme(true);
|
||||||
httpClient
|
httpClient
|
||||||
.getPluginReadme(pluginAuthor, pluginName, language)
|
.getPluginReadme(pluginAuthor, pluginName, language)
|
||||||
@@ -101,19 +105,22 @@ export default function PluginReadme({
|
|||||||
setReadme(res.readme);
|
setReadme(res.readme);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
setLoadFailed(true);
|
||||||
setReadme('');
|
setReadme('');
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoadingReadme(false);
|
setIsLoadingReadme(false);
|
||||||
});
|
});
|
||||||
}, [pluginAuthor, pluginName]);
|
}, [pluginAuthor, pluginName, language, loadAttempt]);
|
||||||
|
|
||||||
|
if (loadFailed)
|
||||||
|
return (
|
||||||
|
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full overflow-auto overscroll-none">
|
<div className="w-full h-full overflow-auto overscroll-none">
|
||||||
{isLoadingReadme ? (
|
{isLoadingReadme ? (
|
||||||
<div className="p-6 text-sm text-gray-500 dark:text-gray-400">
|
<EntityLoadState />
|
||||||
{t('plugins.loadingReadme')}
|
|
||||||
</div>
|
|
||||||
) : readme ? (
|
) : readme ? (
|
||||||
<div className="markdown-body p-6 max-w-none pt-0">
|
<div className="markdown-body p-6 max-w-none pt-0">
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function PluginConfigPage() {
|
|||||||
const detailId = searchParams.get('id');
|
const detailId = searchParams.get('id');
|
||||||
|
|
||||||
if (detailId) {
|
if (detailId) {
|
||||||
return <PluginDetailContent id={detailId} />;
|
return <PluginDetailContent key={detailId} id={detailId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <PluginListView />;
|
return <PluginListView />;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import EntityLoadState from '@/components/EntityLoadState';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -37,6 +38,7 @@ export default function SkillDetailContent({ id }: { id: string }) {
|
|||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const skill = skills.find((item) => item.id === id);
|
const skill = skills.find((item) => item.id === id);
|
||||||
const {
|
const {
|
||||||
|
loading: boxLoading,
|
||||||
available: boxAvailable,
|
available: boxAvailable,
|
||||||
hint: boxHint,
|
hint: boxHint,
|
||||||
reason: boxReason,
|
reason: boxReason,
|
||||||
@@ -77,6 +79,8 @@ export default function SkillDetailContent({ id }: { id: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (boxLoading) return <EntityLoadState />;
|
||||||
|
|
||||||
if (isCreateMode) {
|
if (isCreateMode) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import EntityLoadState from '@/components/EntityLoadState';
|
||||||
import {
|
import {
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -280,7 +281,8 @@ const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(function FileTree(
|
|||||||
const [dirContents, setDirContents] = useState<Map<string, FileEntry[]>>(
|
const [dirContents, setDirContents] = useState<Map<string, FileEntry[]>>(
|
||||||
new Map(),
|
new Map(),
|
||||||
);
|
);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [filesFailed, setFilesFailed] = useState(false);
|
||||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -288,12 +290,14 @@ const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(function FileTree(
|
|||||||
}, [selectedFile]);
|
}, [selectedFile]);
|
||||||
|
|
||||||
const loadRootFiles = useCallback(async () => {
|
const loadRootFiles = useCallback(async () => {
|
||||||
|
setFilesFailed(false);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
onLoadingChange?.(true);
|
onLoadingChange?.(true);
|
||||||
try {
|
try {
|
||||||
const result = await httpClient.listSkillFiles(skillName, '.');
|
const result = await httpClient.listSkillFiles(skillName, '.');
|
||||||
setRootEntries(result.entries);
|
setRootEntries(result.entries);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setFilesFailed(true);
|
||||||
console.error('Failed to load skill files:', error);
|
console.error('Failed to load skill files:', error);
|
||||||
toast.error(t('skills.loadFilesError') + String(error));
|
toast.error(t('skills.loadFilesError') + String(error));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -416,6 +420,14 @@ const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(function FileTree(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (loading || filesFailed)
|
||||||
|
return (
|
||||||
|
<EntityLoadState
|
||||||
|
error={filesFailed}
|
||||||
|
onRetry={() => void loadRootFiles()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="max-h-[min(46vh,32rem)] space-y-1 overflow-y-auto overscroll-contain pr-1">
|
<div className="max-h-[min(46vh,32rem)] space-y-1 overflow-y-auto overscroll-contain pr-1">
|
||||||
@@ -592,18 +604,26 @@ export default function SkillForm({
|
|||||||
const [fileContent, setFileContent] = useState<string>('');
|
const [fileContent, setFileContent] = useState<string>('');
|
||||||
const fileTreeRef = useRef<FileTreeHandle>(null);
|
const fileTreeRef = useRef<FileTreeHandle>(null);
|
||||||
const directoryInputRef = useRef<HTMLInputElement>(null);
|
const directoryInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
|
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||||
|
const [initialDataLoaded, setInitialDataLoaded] = useState(!initSkillName);
|
||||||
const [fileTreeLoading, setFileTreeLoading] = useState(false);
|
const [fileTreeLoading, setFileTreeLoading] = useState(false);
|
||||||
|
|
||||||
const loadSkill = useCallback(
|
const loadSkill = useCallback(
|
||||||
async (skillName: string) => {
|
async (skillName: string) => {
|
||||||
|
setInitialDataLoaded(false);
|
||||||
|
setLoadFailed(false);
|
||||||
try {
|
try {
|
||||||
const resp = await httpClient.getSkill(skillName);
|
const resp = await httpClient.getSkill(skillName);
|
||||||
setSkill(resp.skill);
|
setSkill(resp.skill);
|
||||||
setSelectedFile('SKILL.md');
|
setSelectedFile('SKILL.md');
|
||||||
setFileContent(resp.skill.instructions || '');
|
setFileContent(resp.skill.instructions || '');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setLoadFailed(true);
|
||||||
console.error('Failed to load skill:', error);
|
console.error('Failed to load skill:', error);
|
||||||
toast.error(t('skills.getSkillListError') + String(error));
|
toast.error(t('skills.getSkillListError') + String(error));
|
||||||
|
} finally {
|
||||||
|
setInitialDataLoaded(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[t],
|
[t],
|
||||||
@@ -627,7 +647,7 @@ export default function SkillForm({
|
|||||||
setDirectorySourceName('');
|
setDirectorySourceName('');
|
||||||
setDirectoryTree([]);
|
setDirectoryTree([]);
|
||||||
setDirectoryFileMap(new Map());
|
setDirectoryFileMap(new Map());
|
||||||
}, [initSkillName, loadSkill]);
|
}, [initSkillName, loadSkill, loadAttempt]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initSkillName) return;
|
if (initSkillName) return;
|
||||||
@@ -959,6 +979,12 @@ export default function SkillForm({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (loadFailed)
|
||||||
|
return (
|
||||||
|
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
|
||||||
|
);
|
||||||
|
if (!initialDataLoaded) return <EntityLoadState />;
|
||||||
|
|
||||||
if (layout === 'split') {
|
if (layout === 'split') {
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function SkillsPage() {
|
|||||||
}, [detailId, isCreateView, navigate]);
|
}, [detailId, isCreateView, navigate]);
|
||||||
|
|
||||||
if (detailId) {
|
if (detailId) {
|
||||||
return <SkillDetailContent id={detailId} />;
|
return <SkillDetailContent key={detailId} id={detailId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCreatedSkill(skillName: string) {
|
function handleCreatedSkill(skillName: string) {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export default function EntityLoadState({
|
||||||
|
error = false,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
error?: boolean;
|
||||||
|
onRetry?: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role={error ? 'alert' : 'status'}
|
||||||
|
aria-busy={!error}
|
||||||
|
className="flex min-h-40 flex-1 flex-col items-center justify-center gap-3 p-6 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{error ? (
|
||||||
|
<>
|
||||||
|
<p>{t('common.loadFailed')}</p>
|
||||||
|
{onRetry && (
|
||||||
|
<Button type="button" variant="outline" onClick={onRetry}>
|
||||||
|
{t('common.retry')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Loader2 aria-hidden="true" className="size-5 animate-spin" />
|
||||||
|
<p>{t('common.loading')}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ const enUS = {
|
|||||||
editionCloud: 'Cloud',
|
editionCloud: 'Cloud',
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
|
loadFailed: 'Failed to load. Please try again.',
|
||||||
login: 'Login',
|
login: 'Login',
|
||||||
logout: 'Logout',
|
logout: 'Logout',
|
||||||
accountOptions: 'Settings',
|
accountOptions: 'Settings',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const jaJP = {
|
|||||||
editionCloud: 'Cloud',
|
editionCloud: 'Cloud',
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
|
loadFailed: '読み込みに失敗しました。再試行してください。',
|
||||||
login: 'ログイン',
|
login: 'ログイン',
|
||||||
logout: 'ログアウト',
|
logout: 'ログアウト',
|
||||||
accountOptions: 'システム設定',
|
accountOptions: 'システム設定',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const zhHans = {
|
|||||||
editionCloud: 'Cloud',
|
editionCloud: 'Cloud',
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
|
loadFailed: '加载失败,请重试。',
|
||||||
login: '登录',
|
login: '登录',
|
||||||
logout: '退出登录',
|
logout: '退出登录',
|
||||||
accountOptions: '系统设置',
|
accountOptions: '系统设置',
|
||||||
|
|||||||
@@ -1012,6 +1012,7 @@ test.describe('agent runner resource selectors', () => {
|
|||||||
install_count: 12,
|
install_count: 12,
|
||||||
latest_version: '1.0.0',
|
latest_version: '1.0.0',
|
||||||
components: { Runner: 1 },
|
components: { Runner: 1 },
|
||||||
|
runner_usages: ['agent'],
|
||||||
status: 'live',
|
status: 'live',
|
||||||
type: 'plugin',
|
type: 'plugin',
|
||||||
created_at: '2026-01-01T00:00:00Z',
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
@@ -1128,6 +1129,7 @@ test.describe('agent runner resource selectors', () => {
|
|||||||
install_count: 9,
|
install_count: 9,
|
||||||
latest_version: '1.0.0',
|
latest_version: '1.0.0',
|
||||||
components: { Runner: 1 },
|
components: { Runner: 1 },
|
||||||
|
runner_usages: ['agent'],
|
||||||
status: 'live',
|
status: 'live',
|
||||||
type: 'plugin',
|
type: 'plugin',
|
||||||
created_at: '2026-01-01T00:00:00Z',
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||||
|
|
||||||
|
async function setup(page: Page) {
|
||||||
|
await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => {
|
||||||
|
ws.onMessage((raw) => {
|
||||||
|
if (JSON.parse(String(raw)).type === 'authenticate')
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'connected',
|
||||||
|
connection_id: 'loading-test',
|
||||||
|
session_type: 'person',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await installLangBotApiMocks(page, {
|
||||||
|
authenticated: true,
|
||||||
|
withAdapterEvents: true,
|
||||||
|
withRunnerToolSelector: true,
|
||||||
|
});
|
||||||
|
await page.route('**/api/v1/plugins/qa/loading**', async (route) => {
|
||||||
|
const path = new URL(route.request().url()).pathname;
|
||||||
|
const data = path.endsWith('/config')
|
||||||
|
? { config: {} }
|
||||||
|
: path.endsWith('/readme')
|
||||||
|
? { readme: '# Loaded documentation' }
|
||||||
|
: {
|
||||||
|
plugin: {
|
||||||
|
manifest: {
|
||||||
|
manifest: {
|
||||||
|
metadata: {
|
||||||
|
author: 'qa',
|
||||||
|
name: 'loading',
|
||||||
|
label: { en_US: 'Loading test plugin' },
|
||||||
|
description: { en_US: 'Test' },
|
||||||
|
},
|
||||||
|
spec: { config: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await route.fulfill({ json: { code: 0, data } });
|
||||||
|
});
|
||||||
|
await page.route('**/api/v1/agents/processor-loading', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
agent: {
|
||||||
|
uuid: 'processor-loading',
|
||||||
|
kind: 'event_processor',
|
||||||
|
name: 'Loading test processor',
|
||||||
|
config: {},
|
||||||
|
supported_event_patterns: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/agents/processor-loading/runs**', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
code: 0,
|
||||||
|
data: { items: [], has_more: false, next_cursor: null },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cases = [
|
||||||
|
{
|
||||||
|
name: 'plugin documentation',
|
||||||
|
url: '/home/extensions?id=qa/loading',
|
||||||
|
endpoint: '/plugins/qa/loading/readme?**',
|
||||||
|
form: '[data-slot="card-title"]',
|
||||||
|
readyText: 'Loaded documentation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'bot adapters',
|
||||||
|
url: '/home/bots?id=bot-loading',
|
||||||
|
endpoint: '/platform/adapters',
|
||||||
|
form: '#bot-form',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'processor run history',
|
||||||
|
url: '/home/agents?id=processor-loading',
|
||||||
|
endpoint: '/agents/processor-loading/runs',
|
||||||
|
form: '#event-processor-form',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'skill file list',
|
||||||
|
url: '/home/skills?id=skill-loading',
|
||||||
|
endpoint: '/skills/skill-loading/files?**',
|
||||||
|
form: '#skill-form',
|
||||||
|
readyText: 'SKILL.md',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'bot details',
|
||||||
|
url: '/home/bots?id=bot-loading',
|
||||||
|
endpoint: '/platform/bots/bot-loading',
|
||||||
|
form: '#bot-form',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'agent details',
|
||||||
|
url: '/home/agents?id=agent-loading',
|
||||||
|
endpoint: '/agents/agent-loading',
|
||||||
|
form: '#agent-form',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pipeline details',
|
||||||
|
url: '/home/agents?id=pipeline-loading',
|
||||||
|
endpoint: '/pipelines/pipeline-loading',
|
||||||
|
form: '#pipeline-form',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'legacy pipeline route',
|
||||||
|
url: '/home/pipelines?id=pipeline-loading',
|
||||||
|
endpoint: '/pipelines/pipeline-loading',
|
||||||
|
form: '#pipeline-form',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'plugin processor details',
|
||||||
|
url: '/home/agents?id=processor-loading',
|
||||||
|
endpoint: '/agents/processor-loading',
|
||||||
|
form: '#event-processor-form',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'knowledge base details',
|
||||||
|
url: '/home/knowledge?id=kb-loading',
|
||||||
|
endpoint: '/knowledge/bases/kb-loading',
|
||||||
|
form: '#kb-form',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'MCP details',
|
||||||
|
url: '/home/mcp?id=mcp-loading',
|
||||||
|
endpoint: '/mcp/servers/mcp-loading',
|
||||||
|
form: '#mcp-form',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'skill details',
|
||||||
|
url: '/home/skills?id=skill-loading',
|
||||||
|
endpoint: '/skills/skill-loading',
|
||||||
|
form: '#skill-form',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'plugin details',
|
||||||
|
url: '/home/extensions?id=qa/loading',
|
||||||
|
endpoint: '/plugins/qa/loading',
|
||||||
|
form: '[data-slot="card-title"]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'agent metadata after runtime health',
|
||||||
|
url: '/home/agents?id=agent-loading',
|
||||||
|
endpoint: '/agents/_/metadata',
|
||||||
|
form: '#agent-form',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pipeline metadata',
|
||||||
|
url: '/home/agents?id=pipeline-loading',
|
||||||
|
endpoint: '/pipelines/_/metadata',
|
||||||
|
form: '#pipeline-form',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'processor metadata',
|
||||||
|
url: '/home/agents?id=processor-loading',
|
||||||
|
endpoint: '/agents/_/metadata',
|
||||||
|
form: '#event-processor-form',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'knowledge engines',
|
||||||
|
url: '/home/knowledge?id=kb-loading',
|
||||||
|
endpoint: '/knowledge/engines',
|
||||||
|
form: '#kb-form',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'plugin configuration',
|
||||||
|
url: '/home/extensions?id=qa/loading',
|
||||||
|
endpoint: '/plugins/qa/loading/config',
|
||||||
|
form: '[data-slot="card-title"]',
|
||||||
|
readyText: 'Plugin Configuration',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const scenario of cases) {
|
||||||
|
test(`${scenario.name}: loading until response arrives`, async ({ page }) => {
|
||||||
|
await setup(page);
|
||||||
|
let release!: () => void;
|
||||||
|
const pending = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
let requested = false;
|
||||||
|
await page.route(`**/api/v1${scenario.endpoint}`, async (route) => {
|
||||||
|
requested = true;
|
||||||
|
await pending;
|
||||||
|
await route.fallback();
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await page.goto(scenario.url);
|
||||||
|
await expect.poll(() => requested).toBe(true);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('status').filter({ hasText: 'Loading...' }).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText('No runners are available', { exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
if (scenario.name === 'agent metadata after runtime health') {
|
||||||
|
await page.screenshot({
|
||||||
|
path: '../../.codex-run/entity-loading-agent.png',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!scenario.readyText)
|
||||||
|
await expect(page.locator(scenario.form)).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
await expect(page.locator(scenario.form).first()).toBeAttached();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('status').filter({ hasText: 'Loading...' }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${scenario.name}: failed request can be retried`, async ({ page }) => {
|
||||||
|
await setup(page);
|
||||||
|
let fail = true;
|
||||||
|
await page.route(`**/api/v1${scenario.endpoint}`, async (route) => {
|
||||||
|
if (fail)
|
||||||
|
await route.fulfill({
|
||||||
|
status: 503,
|
||||||
|
json: {
|
||||||
|
code: -1,
|
||||||
|
msg: 'Temporarily unavailable',
|
||||||
|
message: 'Temporarily unavailable',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
else await route.fallback();
|
||||||
|
});
|
||||||
|
await page.goto(scenario.url);
|
||||||
|
const error = page
|
||||||
|
.getByRole('alert')
|
||||||
|
.filter({ hasText: 'Failed to load. Please try again.' });
|
||||||
|
await expect(error).toBeVisible();
|
||||||
|
fail = false;
|
||||||
|
await error.getByRole('button', { name: 'Retry', exact: true }).click();
|
||||||
|
await expect(error).toHaveCount(0);
|
||||||
|
await expect(page.locator(scenario.form).first()).toBeAttached();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('status').filter({ hasText: 'Loading...' }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('completed empty metadata displays the genuine empty state', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await setup(page);
|
||||||
|
await page.route('**/api/v1/agents/_/metadata', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
code: 0,
|
||||||
|
data: { runner_config: null, platform_tools: [], host_tools: [] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.goto('/home/agents?id=agent-empty');
|
||||||
|
await expect(
|
||||||
|
page.getByText('No runners are available', { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('status').filter({ hasText: 'Loading...' }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switching bots resets the form and ignores an old response', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await setup(page);
|
||||||
|
await page.route('**/api/v1/platform/bots', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
bots: ['bot-first', 'bot-second'].map((uuid) => ({
|
||||||
|
uuid,
|
||||||
|
name: uuid,
|
||||||
|
adapter: 'aiocqhttp',
|
||||||
|
enable: true,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let release!: () => void;
|
||||||
|
const pending = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
let requested = false;
|
||||||
|
let responded = false;
|
||||||
|
await page.route('**/api/v1/platform/bots/bot-second', async (route) => {
|
||||||
|
requested = true;
|
||||||
|
await pending;
|
||||||
|
await route.fallback();
|
||||||
|
responded = true;
|
||||||
|
});
|
||||||
|
await page.goto('/home/bots?id=bot-first');
|
||||||
|
await expect(page.locator('#bot-form')).toBeVisible();
|
||||||
|
try {
|
||||||
|
await page.locator('a[href="/home/bots?id=bot-second"]').click();
|
||||||
|
await expect.poll(() => requested).toBe(true);
|
||||||
|
await expect(page.locator('#bot-form')).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('status').filter({ hasText: 'Loading...' }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.locator('a[href="/home/bots?id=bot-first"]').click();
|
||||||
|
await expect(page.locator('#bot-form')).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
await expect.poll(() => responded).toBe(true);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'bot-first', exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'bot-second', exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -222,9 +222,7 @@ test('create first, select a plugin in the header, debug beside scrollable logs'
|
|||||||
await page
|
await page
|
||||||
.getByRole('textbox', { name: 'Name', exact: false })
|
.getByRole('textbox', { name: 'Name', exact: false })
|
||||||
.fill('Welcome processor');
|
.fill('Welcome processor');
|
||||||
await page
|
await page.getByRole('button', { name: 'Submit', exact: true }).click();
|
||||||
.getByRole('button', { name: 'Create plugin processor', exact: true })
|
|
||||||
.click();
|
|
||||||
await expect(page).toHaveURL(/id=processor-qa/);
|
await expect(page).toHaveURL(/id=processor-qa/);
|
||||||
expect(creations).toHaveLength(1);
|
expect(creations).toHaveLength(1);
|
||||||
expect(creations[0]).toMatchObject({
|
expect(creations[0]).toMatchObject({
|
||||||
|
|||||||
Reference in New Issue
Block a user