Compare commits

...

3 Commits

37 changed files with 967 additions and 182 deletions
+35 -4
View File
@@ -2,6 +2,11 @@ name: Build and Publish to PyPI
on:
workflow_dispatch:
inputs:
source_ref:
description: 'Existing release tag to publish (for example v4.10.11)'
required: true
type: string
release:
types: [published]
@@ -11,13 +16,39 @@ jobs:
permissions:
contents: read
id-token: write # Required for trusted publishing to PyPI
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ inputs.source_ref || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Validate release source and version
env:
RELEASE_TAG: ${{ inputs.source_ref || github.event.release.tag_name }}
run: |
python3 - <<'PY'
import os
import re
import subprocess
import tomllib
from pathlib import Path
tag = os.environ['RELEASE_TAG']
if not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+(?:-(?:alpha|beta|rc)\.[0-9]+)?', tag):
raise SystemExit('source_ref must be an existing release tag: vX.Y.Z or vX.Y.Z-beta.N')
def revision(ref):
return subprocess.check_output(['git', 'rev-parse', '--verify', ref], text=True).strip()
if revision('HEAD') != revision(f'refs/tags/{tag}^{{}}'):
raise SystemExit('Checked-out commit does not match the release tag')
version = tomllib.loads(Path('pyproject.toml').read_text())['project']['version']
if version != tag[1:]:
raise SystemExit(f'Package version {version} does not match tag {tag}')
print(f'Validated {tag} at {revision("HEAD")} (package {version})')
PY
- name: Set up Node.js
uses: actions/setup-node@v4
with:
@@ -26,9 +57,9 @@ jobs:
- name: Build frontend
run: |
cd web
npm install -g pnpm
pnpm install
pnpm build
# Match the archive/Docker npm path; npm ci rejects older tags' stale npm lockfiles.
npm install --include=optional
npm run build
mkdir -p ../src/langbot/web/dist
cp -r dist ../src/langbot/web/
+10 -7
View File
@@ -48,12 +48,15 @@ class RunnerInvoker:
context=context,
)
while True:
try:
result_dict = await self._next_with_deadline(gen, descriptor, context)
except StopAsyncIteration:
break
yield result_dict
try:
while True:
try:
result_dict = await self._next_with_deadline(gen, descriptor, context)
except StopAsyncIteration:
break
yield result_dict
finally:
await self._close_generator(gen, descriptor)
except asyncio.TimeoutError as e:
raise RunnerExecutionError(
@@ -128,4 +131,4 @@ class RunnerInvoker:
try:
await gen.aclose()
except Exception as e:
self.ap.logger.warning(f'Failed to close timed-out runner {descriptor.id}: {e}')
self.ap.logger.warning(f'Failed to close runner {descriptor.id}: {e}')
+29 -13
View File
@@ -2237,12 +2237,12 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
include_plugins=bound_plugins,
)
runtime_handler = self._runtime_handler()
with runtime_handler.installation_scope(binding):
gen = runtime_handler.execute_command(
command_ctx.model_dump(serialize_as_any=True),
include_plugins=bound_plugins,
)
async for ret in gen:
gen = runtime_handler.execute_command(
command_ctx.model_dump(serialize_as_any=True),
include_plugins=bound_plugins,
)
async with contextlib.aclosing(self._installation_scoped_stream(runtime_handler, binding, gen)) as scoped:
async for ret in scoped:
yield command_context.CommandReturn.model_validate(ret)
# Runner methods
@@ -2308,15 +2308,31 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
require_enabled=True,
)
runtime_handler = self._runtime_handler()
with runtime_handler.installation_scope(binding):
async for ret in runtime_handler.run_runner(
plugin_author,
plugin_name,
runner_name,
context,
):
gen = runtime_handler.run_runner(plugin_author, plugin_name, runner_name, context)
async with contextlib.aclosing(self._installation_scoped_stream(runtime_handler, binding, gen)) as scoped:
async for ret in scoped:
yield ret
@staticmethod
async def _installation_scoped_stream(runtime_handler, binding, gen):
"""Keep ContextVar tokens inside a single resume, never across yields.
Consumers may use a different Task for each anext (e.g. wait_for).
Reset the installation before exposing a result to the consumer, and
re-enter the same immutable binding for transport cleanup.
"""
try:
while True:
with runtime_handler.installation_scope(binding):
try:
result = await anext(gen)
except StopAsyncIteration:
return
yield result
finally:
with runtime_handler.installation_scope(binding):
await gen.aclose()
async def retrieve_knowledge(
self,
plugin_author: str,
+6 -4
View File
@@ -2933,8 +2933,9 @@ class RuntimeConnectionHandler(handler.Handler):
timeout=timeout,
)
async for ret in gen:
yield ret
async with contextlib.aclosing(gen):
async for ret in gen:
yield ret
def _get_runner_action_timeout(self, context: dict[str, Any]) -> float:
"""Use the run deadline as the transport idle timeout when available."""
@@ -3143,8 +3144,9 @@ class RuntimeConnectionHandler(handler.Handler):
timeout=180,
)
async for ret in gen:
yield ret
async with contextlib.aclosing(gen):
async for ret in gen:
yield ret
async def retrieve_knowledge(
self,
@@ -0,0 +1,158 @@
"""Regression coverage for installation scopes across async-generator resumes."""
import asyncio
import contextvars
import time
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from langbot.pkg.agent.runner.errors import RunnerExecutionError
from langbot.pkg.agent.runner.invoker import RunnerInvoker
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
from langbot.pkg.plugin.connector import PluginRuntimeConnector
from tests.unit_tests.plugin.test_connector_methods import (
TEST_EXECUTION_CONTEXT,
TEST_INSTALLATION_BINDING,
create_mock_connector,
)
def make_stream(*, deadline=True, failure=None, blocked=False):
handler = object.__new__(RuntimeConnectionHandler)
handler._outbound_installation_context = contextvars.ContextVar('test_installation', default=None)
entered = asyncio.Event()
closed = []
observed = []
async def wire_stream(*args, **kwargs):
try:
for index in range(3):
observed.append(handler._outbound_installation_context.get())
if index == 1:
entered.set()
if blocked:
await asyncio.Event().wait()
if failure:
raise failure
yield {'type': 'message.delta', 'sequence': index}
finally:
closed.append(handler._outbound_installation_context.get())
handler.call_action_generator = wire_stream
connector = create_mock_connector()
connector.handler = handler
invoker = RunnerInvoker(SimpleNamespace(plugin_connector=connector, logger=Mock()))
descriptor = SimpleNamespace(
id='plugin:qa/runner/default', plugin_author='qa', plugin_name='runner', runner_name='default'
)
context = {
'conversation': {'workspace_id': TEST_EXECUTION_CONTEXT.workspace_uuid},
'runtime': {'deadline_at': time.time() + 10 if deadline else None},
}
return invoker.invoke(descriptor, context), handler, context, entered, closed, observed
@pytest.mark.asyncio
@pytest.mark.parametrize('deadline', [True, False])
async def test_scope_is_reset_before_yield_and_stream_finishes(deadline):
stream, handler, _, _, closed, observed = make_stream(deadline=deadline)
frames = []
async for frame in stream:
frames.append(frame)
assert handler._outbound_installation_context.get() is None
await asyncio.sleep(0)
assert [frame['sequence'] for frame in frames] == [0, 1, 2]
assert observed == [TEST_INSTALLATION_BINDING] * 3
assert closed == [TEST_INSTALLATION_BINDING]
@pytest.mark.asyncio
@pytest.mark.parametrize('deadline', [True, False])
async def test_early_close_releases_wire_stream_in_scope(deadline):
stream, handler, _, _, closed, _ = make_stream(deadline=deadline)
await anext(stream)
await stream.aclose()
assert closed == [TEST_INSTALLATION_BINDING]
assert handler._outbound_installation_context.get() is None
@pytest.mark.asyncio
async def test_deadline_expired_between_frames_closes_in_scope():
stream, handler, context, _, closed, _ = make_stream()
await anext(stream)
context['runtime']['deadline_at'] = time.time() - 1
with pytest.raises(RunnerExecutionError) as exc:
await anext(stream)
assert exc.value.error_code == 'runner.timeout'
assert closed == [TEST_INSTALLATION_BINDING]
assert handler._outbound_installation_context.get() is None
@pytest.mark.asyncio
async def test_timeout_during_next_frame_keeps_timeout_error():
stream, handler, context, _, closed, _ = make_stream(blocked=True)
await anext(stream)
context['runtime']['deadline_at'] = time.time() + 0.05
with pytest.raises(RunnerExecutionError) as exc:
await anext(stream)
assert exc.value.error_code == 'runner.timeout'
assert closed == [TEST_INSTALLATION_BINDING]
assert handler._outbound_installation_context.get() is None
@pytest.mark.asyncio
async def test_cancellation_preserves_cancelled_error_and_cleans_up():
stream, handler, _, entered, closed, _ = make_stream(blocked=True)
await anext(stream)
task = asyncio.create_task(anext(stream))
await asyncio.wait_for(entered.wait(), 1)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert closed == [TEST_INSTALLATION_BINDING]
assert handler._outbound_installation_context.get() is None
@pytest.mark.asyncio
async def test_transport_error_is_not_masked_by_context_reset():
stream, handler, _, _, closed, _ = make_stream(failure=RuntimeError('wire failed'))
await anext(stream)
with pytest.raises(RunnerExecutionError, match='wire failed'):
await anext(stream)
assert closed == [TEST_INSTALLATION_BINDING]
assert handler._outbound_installation_context.get() is None
@pytest.mark.asyncio
async def test_interleaved_installations_share_handler_without_scope_leakage():
handler = object.__new__(RuntimeConnectionHandler)
handler._outbound_installation_context = contextvars.ContextVar('shared_installation', default=None)
other_binding = TEST_INSTALLATION_BINDING.model_copy(
update={'installation_uuid': '00000000-0000-4000-8000-000000000002'}
)
closed = []
async def wire(binding):
try:
for index in range(3):
assert handler._outbound_installation_context.get() == binding
await asyncio.sleep(0)
assert handler._outbound_installation_context.get() == binding
yield index
finally:
assert handler._outbound_installation_context.get() == binding
closed.append(binding)
async def consume(binding):
stream = PluginRuntimeConnector._installation_scoped_stream(handler, binding, wire(binding))
for index in range(3):
assert await asyncio.wait_for(anext(stream), 1) == index
assert handler._outbound_installation_context.get() is None
# Closing in yet another task must also restore that task's context.
await asyncio.wait_for(stream.aclose(), 1)
await asyncio.gather(consume(TEST_INSTALLATION_BINDING), consume(other_binding))
assert set(closed) == {TEST_INSTALLATION_BINDING, other_binding}
assert handler._outbound_installation_context.get() is None
+11 -6
View File
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@@ -43,6 +44,8 @@ export default function AgentDetailContent({ id }: { id: string }) {
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
const [agent, setAgent] = useState<Agent | null>(null);
const [platformTools, setPlatformTools] = useState<AgentPlatformTool[]>([]);
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [loading, setLoading] = useState(!isCreateMode);
const [formDirty, setFormDirty] = useState(false);
const [formSaving, setFormSaving] = useState(false);
@@ -77,6 +80,7 @@ export default function AgentDetailContent({ id }: { id: string }) {
if (isCreateMode) return;
let cancelled = false;
setLoading(true);
setLoadFailed(false);
Promise.all([
httpClient.getAgent(id),
httpClient.getAdapters().catch(() => ({ adapters: [] })),
@@ -97,13 +101,16 @@ export default function AgentDetailContent({ id }: { id: string }) {
);
setAgent(resp.agent);
})
.catch(() => {
if (!cancelled) setLoadFailed(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [id, isCreateMode]);
}, [id, isCreateMode, loadAttempt]);
if (isCreateMode) {
return (
@@ -116,13 +123,11 @@ export default function AgentDetailContent({ id }: { id: string }) {
);
}
if (loading || !agent) {
if (loadFailed)
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
{t('common.loading')}
</div>
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
}
if (loading || !agent) return <EntityLoadState />;
if (agent.kind === 'pipeline') {
return <PipelineDetailContent id={id} routeBase="/home/agents" />;
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useSearchParams } from 'react-router-dom';
@@ -79,6 +80,7 @@ export default function PluginProcessorDetailContent({
const [events, setEvents] = useState<ProcessorRunEvent[]>([]);
const [eventCursor, setEventCursor] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const [saving, setSaving] = useState(false);
const [pagingRuns, setPagingRuns] = useState(false);
const [pagingEvents, setPagingEvents] = useState(false);
@@ -89,6 +91,7 @@ export default function PluginProcessorDetailContent({
const available = Boolean(component);
const load = useCallback(async () => {
setLoading(true);
setFailed(false);
try {
const [metadata, page] = await Promise.all([
@@ -99,6 +102,7 @@ export default function PluginProcessorDetailContent({
setPlatformTools(metadata.platform_tools ?? []);
setRuns(page.items);
setCursor(page.has_more ? page.next_cursor : null);
setInitialLoadComplete(true);
} catch {
setFailed(true);
} finally {
@@ -385,6 +389,9 @@ export default function PluginProcessorDetailContent({
</div>
);
if (!initialLoadComplete)
return <EntityLoadState error={failed} onRetry={() => void load()} />;
return (
<ProcessorDetailWorkbench
title={`${agent.emoji || '🧩'} ${agent.name}`}
@@ -121,11 +121,7 @@ export default function AgentCreateContent({
form="agent-create-form"
disabled={form.formState.isSubmitting}
>
{t(
kind === 'event_processor'
? 'agents.eventProcessor.create'
: 'common.submit',
)}
{t('common.submit')}
</Button>
</div>
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import {
forwardRef,
type ForwardedRef,
@@ -145,6 +146,8 @@ function AgentFormComponent(
useState<ApiRespPluginSystemStatus | null>(null);
const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
const [pluginStatusError, setPluginStatusError] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [initialDataLoaded, setInitialDataLoaded] = useState(false);
const [runnerInstallRecovering, setRunnerInstallRecovering] = useState(false);
const [activeSection, setActiveSection] =
@@ -208,6 +211,8 @@ function AgentFormComponent(
useEffect(() => {
let cancelled = false;
setInitialDataLoaded(false);
setLoadFailed(false);
Promise.all([httpClient.getAgentMetadata(), httpClient.getAgent(agentId)])
.then(([metadata, resp]) => {
if (cancelled) return;
@@ -274,12 +279,14 @@ function AgentFormComponent(
setInitialDataLoaded(true);
})
.catch((err) => {
if (cancelled) return;
setLoadFailed(true);
toast.error(t('agents.loadError') + err.msg);
});
return () => {
cancelled = true;
};
}, [agentId, form, t]);
}, [agentId, form, t, loadAttempt]);
useEffect(() => {
if (!initialDataLoaded || !readPendingRunnerInstall(runnerInstallScope)) {
@@ -390,7 +397,8 @@ function AgentFormComponent(
];
const runnerStatus = useMemo<RunnerStatus>(() => {
if (pluginStatusLoading) {
if (loadFailed) return { label: t('common.loadFailed'), tone: 'error' };
if (!initialDataLoaded || pluginStatusLoading) {
return {
label: t('agents.runnerStatusLoading'),
tone: 'neutral',
@@ -459,6 +467,8 @@ function AgentFormComponent(
tone: 'success',
};
}, [
initialDataLoaded,
loadFailed,
currentRunner,
pluginStatusError,
pluginStatusLoading,
@@ -635,6 +645,7 @@ function AgentFormComponent(
}
},
async save() {
if (!initialDataLoaded || loadFailed) return false;
if (!hasUnsavedChangesRef.current) return true;
if (isSavingRef.current) return false;
const valid = await form.trigger();
@@ -642,9 +653,15 @@ function AgentFormComponent(
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 (
<div className="h-full p-0 flex flex-col">
<Form {...form}>
+1 -1
View File
@@ -8,7 +8,7 @@ export default function AgentsPage() {
const detailId = searchParams.get('id');
if (detailId) {
return <AgentDetailContent id={detailId} />;
return <AgentDetailContent key={detailId} id={detailId} />;
}
return (
+19 -6
View File
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
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 [showDeleteConfirm, setShowDeleteConfirm] = 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 [isRefreshingSessions, setIsRefreshingSessions] = useState(false);
const sessionMonitorRef = useRef<BotSessionMonitorHandle>(null);
@@ -74,13 +77,17 @@ export default function BotDetailContent({ id }: { id: string }) {
// Fetch bot enable state
useEffect(() => {
if (!isCreateMode) {
httpClient.getBot(id).then((res) => {
setBot(res.bot);
setBotEnabled(res.bot.enable ?? true);
setEnableLoaded(true);
});
setLoadFailed(false);
httpClient
.getBot(id)
.then((res) => {
setBot(res.bot);
setBotEnabled(res.bot.enable ?? true);
setEnableLoaded(true);
})
.catch(() => setLoadFailed(true));
}
}, [id, isCreateMode]);
}, [id, isCreateMode, loadAttempt]);
const handleEnableToggle = useCallback(
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 ====================
return (
<>
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { showBotError } from '../../bot-error';
import React, {
forwardRef,
@@ -137,6 +138,9 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
// Track whether initial data loading is complete.
// 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 [adapterNameToDynamicConfigMap, setAdapterNameToDynamicConfigMap] =
@@ -225,49 +229,55 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
useEffect(() => {
setBotFormValues();
}, []);
}, [initBotId, loadAttempt]);
function setBotFormValues() {
setInitialDataLoaded(false);
setLoadFailed(false);
isInitializing.current = true;
initBotFormComponent().then(() => {
if (initBotId) {
getBotConfig(initBotId)
.then((val) => {
// Use form.reset() to set values AND update the dirty baseline,
// so isDirty stays false after initial load.
form.reset({
name: val.name,
description: val.description,
adapter: val.adapter,
adapter_config: val.adapter_config,
enable: val.enable,
event_bindings: val.event_bindings || [],
plugin_processors: val.plugin_processors || [],
});
handleAdapterSelect(val.adapter);
initBotFormComponent()
.then(() => {
if (initBotId) {
return getBotConfig(initBotId)
.then((val) => {
// Use form.reset() to set values AND update the dirty baseline,
// so isDirty stays false after initial load.
form.reset({
name: val.name,
description: val.description,
adapter: val.adapter,
adapter_config: val.adapter_config,
enable: val.enable,
event_bindings: val.event_bindings || [],
plugin_processors: val.plugin_processors || [],
});
handleAdapterSelect(val.adapter);
if (val.webhook_full_url) {
setWebhookUrl(val.webhook_full_url);
} else {
setWebhookUrl('');
}
setExtraWebhookUrl(val.extra_webhook_full_url || '');
})
.catch((err) => {
toast.error(
t('bots.getBotConfigError') + (err as CustomApiError).msg,
);
})
.finally(() => {
isInitializing.current = false;
});
} else {
form.reset();
setWebhookUrl('');
setExtraWebhookUrl('');
isInitializing.current = false;
}
});
if (val.webhook_full_url) {
setWebhookUrl(val.webhook_full_url);
} else {
setWebhookUrl('');
}
setExtraWebhookUrl(val.extra_webhook_full_url || '');
})
.catch((err) => {
setLoadFailed(true);
toast.error(
t('bots.getBotConfigError') + (err as CustomApiError).msg,
);
})
.finally(() => {
isInitializing.current = false;
});
} else {
form.reset();
setWebhookUrl('');
setExtraWebhookUrl('');
isInitializing.current = false;
}
})
.catch(() => setLoadFailed(true))
.finally(() => setInitialDataLoaded(true));
}
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 (
<Form {...form}>
<form
+1 -1
View File
@@ -8,7 +8,7 @@ export default function BotConfigPage() {
const detailId = searchParams.get('id');
if (detailId) {
return <BotDetailContent id={detailId} />;
return <BotDetailContent key={detailId} id={detailId} />;
}
return (
+12 -1
View File
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
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 [showDeleteConfirm, setShowDeleteConfirm] = 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 [formDirty, setFormDirty] = useState(false);
const [formVersion, setFormVersion] = useState(0);
const loadKbInfo = useCallback(
async (kbId: string) => {
setLoadFailed(false);
try {
const resp = await httpClient.getKnowledgeBase(kbId);
setKbInfo(resp.base);
} catch (e) {
setLoadFailed(true);
console.error('Failed to load KB info:', e);
toast.error(
t('knowledge.loadKnowledgeBaseFailed') + (e as CustomApiError).msg,
@@ -81,7 +86,7 @@ export default function KBDetailContent({ id }: { id: string }) {
if (!isCreateMode) {
loadKbInfo(id);
}
}, [id, isCreateMode, loadKbInfo]);
}, [id, isCreateMode, loadKbInfo, loadAttempt]);
const hasDocumentCapability = (): boolean => {
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 ====================
return (
<>
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -94,6 +95,9 @@ export default function KBForm({
Record<string, unknown>
>({});
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);
// Dirty tracking: snapshot of saved state for comparison
@@ -144,12 +148,15 @@ export default function KBForm({
};
useEffect(() => {
loadRagEngines().then(() => {
if (initKbId) {
loadKbConfig(initKbId);
}
});
}, []);
setInitialDataLoaded(false);
setLoadFailed(false);
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
useEffect(() => {
@@ -178,7 +185,7 @@ export default function KBForm({
const resp = await httpClient.getKnowledgeEngines();
setRagEngines(resp.engines);
} catch (err) {
console.error('Failed to load Knowledge Engines:', err);
throw err;
} finally {
setLoading(false);
}
@@ -211,8 +218,8 @@ export default function KBForm({
isInitializing.current = false;
}, 500);
} catch (err) {
console.error('Failed to load KB config:', err);
isInitializing.current = false;
throw err;
}
};
@@ -321,6 +328,12 @@ export default function KBForm({
[selectedEngine?.retrieval_schema],
);
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!initialDataLoaded) return <EntityLoadState />;
return (
<Form {...form}>
<form
+1 -1
View File
@@ -48,7 +48,7 @@ export default function KnowledgePage() {
externalKbCount={migrationExternalCount}
onMigrationComplete={handleMigrationComplete}
/>
<KBDetailContent id={detailId} />
<KBDetailContent key={detailId} id={detailId} />
</>
);
}
+20 -7
View File
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
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
const [serverEnabled, setServerEnabled] = useState(true);
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [enableLoaded, setEnableLoaded] = useState(false);
const [detailRuntimeStatus, setDetailRuntimeStatus] =
useState<MCPRuntimeState | null>(null);
@@ -120,14 +123,18 @@ export default function MCPDetailContent({ id }: { id: string }) {
useEffect(() => {
if (!isCreateMode) {
setDetailRuntimeStatus(null);
httpClient.getMCPServer(id).then((res) => {
const server = res.server ?? res;
setServerEnabled(server.enable ?? true);
setDetailRuntimeStatus(server.runtime_info?.status ?? null);
setEnableLoaded(true);
});
setLoadFailed(false);
httpClient
.getMCPServer(id)
.then((res) => {
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(
async (checked: boolean) => {
@@ -325,6 +332,12 @@ export default function MCPDetailContent({ id }: { id: string }) {
);
// ==================== Edit Mode ====================
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!enableLoaded) return <EntityLoadState />;
return (
<>
<div className="flex h-full flex-col">
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import React, {
type ReactNode,
useState,
@@ -563,6 +564,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const watchMode = form.watch('mode');
const {
loading: boxLoading,
available: boxAvailable,
hint: boxHint,
reason: boxReason,
@@ -577,6 +579,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
watchMode === 'stdio' && mcpStdioEnabled && !boxAvailable;
const stdioBlocked = stdioBlockedByPolicy || stdioBlockedByBox;
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [initialDataLoaded, setInitialDataLoaded] = useState(!isEditMode);
const { isDirty } = form.formState;
useEffect(() => {
onDirtyChange?.(isDirty);
@@ -606,10 +611,13 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
);
useEffect(() => {
setLoadFailed(false);
setInitialDataLoaded(!isEditMode);
isInitializing.current = true;
if (isEditMode && initServerName) {
loadServerForEdit(initServerName).finally(() => {
isInitializing.current = false;
setInitialDataLoaded(true);
});
} else {
form.reset({
@@ -636,7 +644,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
pollingIntervalRef.current = null;
}
};
}, [initServerName]);
}, [initServerName, loadAttempt]);
useEffect(() => {
if (!onDraftChange || isEditMode) return;
@@ -756,6 +764,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
setRuntimeInfo(server.runtime_info ?? null);
setReadme(server.readme ?? '');
} catch (error) {
setLoadFailed(true);
console.error('Failed to load server:', error);
toast.error(t('mcp.loadFailed'));
}
@@ -1337,6 +1346,12 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
runtimePanel
);
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!initialDataLoaded || boxLoading) return <EntityLoadState />;
if (layout === 'split') {
return (
<Form {...form}>
+1 -1
View File
@@ -8,7 +8,7 @@ export default function MCPPage() {
const detailId = searchParams.get('id');
if (detailId) {
return <MCPDetailContent id={detailId} />;
return <MCPDetailContent key={detailId} id={detailId} />;
}
return (
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
@@ -52,6 +53,8 @@ export default function PipelineDetailContent({
const [formDirty, setFormDirty] = useState(false);
const [formSaving, setFormSaving] = 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 pipelineFormRef = useRef<PipelineFormHandle>(null);
const sidebarPipeline = pipelines.find((item) => item.id === id);
@@ -59,13 +62,17 @@ export default function PipelineDetailContent({
useEffect(() => {
if (isCreateMode) return;
let cancelled = false;
httpClient.getPipeline(id).then((response) => {
if (!cancelled) setPipelineDetails(response.pipeline);
});
setLoadFailed(false);
httpClient
.getPipeline(id)
.then((response) => {
if (!cancelled) setPipelineDetails(response.pipeline);
})
.catch(() => setLoadFailed(true));
return () => {
cancelled = true;
};
}, [id, isCreateMode]);
}, [id, isCreateMode, loadAttempt]);
function handleFinish() {
refreshPipelines();
@@ -137,6 +144,12 @@ export default function PipelineDetailContent({
navigate(routeBase);
}
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!pipelineDetails) return <EntityLoadState />;
// ==================== Edit Mode ====================
const pipelineName =
pipelineDetails?.name ||
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import {
forwardRef,
useCallback,
@@ -211,6 +212,8 @@ const PipelineFormComponent = forwardRef<
useState<PipelineConfigTab>();
const [outputConfigTabSchema, setOutputConfigTabSchema] =
useState<PipelineConfigTab>();
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [metadataLoaded, setMetadataLoaded] = useState(false);
const [pipelineLoaded, setPipelineLoaded] = useState(!isEditMode);
@@ -257,28 +260,37 @@ const PipelineFormComponent = forwardRef<
}, [hasUnsavedChanges, onDirtyChange]);
useEffect(() => {
let cancelled = false;
setLoadFailed(false);
setMetadataLoaded(false);
setPipelineLoaded(!isEditMode);
// get config schema from metadata
httpClient.getGeneralPipelineMetadata().then((resp) => {
for (const config of resp.configs) {
if (config.name === 'ai') {
setAIConfigTabSchema(config);
} else if (config.name === 'trigger') {
setTriggerConfigTabSchema(config);
} else if (config.name === 'safety') {
setSafetyConfigTabSchema(config);
} else if (config.name === 'output') {
setOutputConfigTabSchema(config);
httpClient
.getGeneralPipelineMetadata()
.then((resp) => {
if (cancelled) return;
for (const config of resp.configs) {
if (config.name === 'ai') {
setAIConfigTabSchema(config);
} else if (config.name === 'trigger') {
setTriggerConfigTabSchema(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) {
httpClient
.getPipeline(pipelineId || '')
.then((resp: GetPipelineResponseData) => {
if (cancelled) return;
setIsDefaultPipeline(resp.pipeline.is_default ?? false);
const loadedValues = {
@@ -296,9 +308,15 @@ const PipelineFormComponent = forwardRef<
savedSnapshotRef.current = JSON.stringify(loadedValues);
initializedStagesRef.current.clear();
setPipelineLoaded(true);
})
.catch(() => {
if (!cancelled) setLoadFailed(true);
});
}
}, [form, isEditMode, pipelineId]);
return () => {
cancelled = true;
};
}, [form, isEditMode, pipelineId, loadAttempt]);
useEffect(() => {
if (
@@ -693,6 +711,12 @@ const PipelineFormComponent = forwardRef<
}
};
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!metadataLoaded || !pipelineLoaded) return <EntityLoadState />;
return (
<>
<div className="h-full p-0 flex flex-col">
+1 -1
View File
@@ -8,7 +8,7 @@ export default function PipelineConfigPage() {
const detailId = searchParams.get('id');
if (detailId) {
return <PipelineDetailContent id={detailId} />;
return <PipelineDetailContent key={detailId} id={detailId} />;
}
return (
+3 -8
View File
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useSearchParams } from 'react-router-dom';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useEffect, useRef, useState, useCallback } from 'react';
@@ -78,11 +79,7 @@ export default function PluginPagesPage() {
</div>
);
}
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading...
</div>
);
return <EntityLoadState />;
}
const assetPath = page.path;
@@ -209,9 +206,7 @@ function PluginPageIframe({
{t('plugins.loadFailed')}
</div>
) : loading || !assetUrl ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading...
</div>
<EntityLoadState />
) : null}
{!assetError && assetUrl && (
<iframe
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
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 navigate = useNavigate();
const { plugins, setDetailEntityName, refreshPlugins } = useSidebarData();
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [pluginInfo, setPluginInfo] = useState<Plugin | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteData, setDeleteData] = useState(false);
@@ -76,15 +79,19 @@ export default function PluginDetailContent({ id }: { id: string }) {
useEffect(() => {
let cancelled = false;
httpClient.getPlugin(pluginAuthor, pluginName).then((res) => {
if (!cancelled) {
setPluginInfo(res.plugin);
}
});
setLoadFailed(false);
httpClient
.getPlugin(pluginAuthor, pluginName)
.then((res) => {
if (!cancelled) {
setPluginInfo(res.plugin);
}
})
.catch(() => setLoadFailed(true));
return () => {
cancelled = true;
};
}, [pluginAuthor, pluginName]);
}, [pluginAuthor, pluginName, loadAttempt]);
function handleFormSubmit(timeout?: number) {
if (timeout) {
@@ -189,6 +196,12 @@ export default function PluginDetailContent({ id }: { id: string }) {
</Card>
);
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!pluginInfo) return <EntityLoadState />;
return (
<>
<div className="flex h-full flex-col">
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useState, useEffect, useRef } from 'react';
import { ApiRespPluginConfig } from '@/app/infra/entities/api';
import { Plugin } from '@/app/infra/entities/plugin';
@@ -25,6 +26,8 @@ export default function PluginForm({
onFormSubmit: (timeout?: number) => void;
}) {
const { t } = useTranslation();
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [pluginInfo, setPluginInfo] = useState<Plugin>();
const [pluginConfig, setPluginConfig] = useState<ApiRespPluginConfig>();
const [isSaving, setIsLoading] = useState(false);
@@ -33,36 +36,55 @@ export default function PluginForm({
const initialFileKeys = useRef<Set<string>>(new Set());
useEffect(() => {
let cancelled = false;
setLoadFailed(false);
setPluginInfo(undefined);
setPluginConfig(undefined);
// 获取插件信息
httpClient.getPlugin(pluginAuthor, pluginName).then((res) => {
setPluginInfo(res.plugin);
});
httpClient
.getPlugin(pluginAuthor, pluginName)
.then((res) => {
if (cancelled) return;
setPluginInfo(res.plugin);
})
.catch(() => {
if (!cancelled) setLoadFailed(true);
});
// 获取插件配置
httpClient.getPluginConfig(pluginAuthor, pluginName).then((res) => {
setPluginConfig(res);
httpClient
.getPluginConfig(pluginAuthor, pluginName)
.then((res) => {
if (cancelled) return;
setPluginConfig(res);
// 提取初始配置中的所有文件 key
const extractFileKeys = (obj: any): string[] => {
const keys: string[] = [];
if (obj && typeof obj === 'object') {
if ('file_key' in obj && typeof obj.file_key === 'string') {
keys.push(obj.file_key);
}
for (const value of Object.values(obj)) {
if (Array.isArray(value)) {
value.forEach((item) => keys.push(...extractFileKeys(item)));
} else if (typeof value === 'object' && value !== null) {
keys.push(...extractFileKeys(value));
// 提取初始配置中的所有文件 key
const extractFileKeys = (obj: any): string[] => {
const keys: string[] = [];
if (obj && typeof obj === 'object') {
if ('file_key' in obj && typeof obj.file_key === 'string') {
keys.push(obj.file_key);
}
for (const value of Object.values(obj)) {
if (Array.isArray(value)) {
value.forEach((item) => keys.push(...extractFileKeys(item)));
} else if (typeof value === 'object' && value !== null) {
keys.push(...extractFileKeys(value));
}
}
}
}
return keys;
};
return keys;
};
const fileKeys = extractFileKeys(res.config);
initialFileKeys.current = new Set(fileKeys);
});
}, [pluginAuthor, pluginName]);
const fileKeys = extractFileKeys(res.config);
initialFileKeys.current = new Set(fileKeys);
})
.catch(() => {
if (!cancelled) setLoadFailed(true);
});
return () => {
cancelled = true;
};
}, [pluginAuthor, pluginName, loadAttempt]);
const handleSubmit = async () => {
setIsLoading(true);
@@ -132,13 +154,11 @@ export default function PluginForm({
}
};
if (!pluginInfo || !pluginConfig) {
if (loadFailed)
return (
<div className="flex items-center justify-center h-full mb-[2rem]">
{t('plugins.loading')}
</div>
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
}
if (!pluginInfo || !pluginConfig) return <EntityLoadState />;
return (
<div className="min-w-0 max-w-full space-y-4">
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useState, useEffect } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useTranslation } from 'react-i18next';
@@ -88,12 +89,15 @@ export default function PluginReadme({
}) {
const { t } = useTranslation();
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();
useEffect(() => {
// Fetch plugin README
setLoadFailed(false);
setIsLoadingReadme(true);
httpClient
.getPluginReadme(pluginAuthor, pluginName, language)
@@ -101,19 +105,22 @@ export default function PluginReadme({
setReadme(res.readme);
})
.catch(() => {
setLoadFailed(true);
setReadme('');
})
.finally(() => {
setIsLoadingReadme(false);
});
}, [pluginAuthor, pluginName]);
}, [pluginAuthor, pluginName, language, loadAttempt]);
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
return (
<div className="w-full h-full overflow-auto overscroll-none">
{isLoadingReadme ? (
<div className="p-6 text-sm text-gray-500 dark:text-gray-400">
{t('plugins.loadingReadme')}
</div>
<EntityLoadState />
) : readme ? (
<div className="markdown-body p-6 max-w-none pt-0">
<ReactMarkdown
+1 -1
View File
@@ -36,7 +36,7 @@ export default function PluginConfigPage() {
const detailId = searchParams.get('id');
if (detailId) {
return <PluginDetailContent id={detailId} />;
return <PluginDetailContent key={detailId} id={detailId} />;
}
return <PluginListView />;
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@@ -37,6 +38,7 @@ export default function SkillDetailContent({ id }: { id: string }) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const skill = skills.find((item) => item.id === id);
const {
loading: boxLoading,
available: boxAvailable,
hint: boxHint,
reason: boxReason,
@@ -77,6 +79,8 @@ export default function SkillDetailContent({ id }: { id: string }) {
}
}
if (boxLoading) return <EntityLoadState />;
if (isCreateMode) {
return (
<div className="flex h-full flex-col">
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import {
type FormEvent,
type ReactNode,
@@ -280,7 +281,8 @@ const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(function FileTree(
const [dirContents, setDirContents] = useState<Map<string, FileEntry[]>>(
new Map(),
);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [filesFailed, setFilesFailed] = useState(false);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
useEffect(() => {
@@ -288,12 +290,14 @@ const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(function FileTree(
}, [selectedFile]);
const loadRootFiles = useCallback(async () => {
setFilesFailed(false);
setLoading(true);
onLoadingChange?.(true);
try {
const result = await httpClient.listSkillFiles(skillName, '.');
setRootEntries(result.entries);
} catch (error) {
setFilesFailed(true);
console.error('Failed to load skill files:', error);
toast.error(t('skills.loadFilesError') + String(error));
} finally {
@@ -416,6 +420,14 @@ const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(function FileTree(
);
};
if (loading || filesFailed)
return (
<EntityLoadState
error={filesFailed}
onRetry={() => void loadRootFiles()}
/>
);
return (
<div className="space-y-2">
<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 fileTreeRef = useRef<FileTreeHandle>(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 loadSkill = useCallback(
async (skillName: string) => {
setInitialDataLoaded(false);
setLoadFailed(false);
try {
const resp = await httpClient.getSkill(skillName);
setSkill(resp.skill);
setSelectedFile('SKILL.md');
setFileContent(resp.skill.instructions || '');
} catch (error) {
setLoadFailed(true);
console.error('Failed to load skill:', error);
toast.error(t('skills.getSkillListError') + String(error));
} finally {
setInitialDataLoaded(true);
}
},
[t],
@@ -627,7 +647,7 @@ export default function SkillForm({
setDirectorySourceName('');
setDirectoryTree([]);
setDirectoryFileMap(new Map());
}, [initSkillName, loadSkill]);
}, [initSkillName, loadSkill, loadAttempt]);
useEffect(() => {
if (initSkillName) return;
@@ -959,6 +979,12 @@ export default function SkillForm({
</div>
);
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!initialDataLoaded) return <EntityLoadState />;
if (layout === 'split') {
return (
<form
+1 -1
View File
@@ -34,7 +34,7 @@ export default function SkillsPage() {
}, [detailId, isCreateView, navigate]);
if (detailId) {
return <SkillDetailContent id={detailId} />;
return <SkillDetailContent key={detailId} id={detailId} />;
}
function handleCreatedSkill(skillName: string) {
+36
View File
@@ -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>
);
}
+1
View File
@@ -14,6 +14,7 @@ const enUS = {
editionCloud: 'Cloud',
},
common: {
loadFailed: 'Failed to load. Please try again.',
login: 'Login',
logout: 'Logout',
accountOptions: 'Settings',
+1
View File
@@ -14,6 +14,7 @@ const jaJP = {
editionCloud: 'Cloud',
},
common: {
loadFailed: '読み込みに失敗しました。再試行してください。',
login: 'ログイン',
logout: 'ログアウト',
accountOptions: 'システム設定',
+1
View File
@@ -14,6 +14,7 @@ const zhHans = {
editionCloud: 'Cloud',
},
common: {
loadFailed: '加载失败,请重试。',
login: '登录',
logout: '退出登录',
accountOptions: '系统设置',
+2
View File
@@ -1012,6 +1012,7 @@ test.describe('agent runner resource selectors', () => {
install_count: 12,
latest_version: '1.0.0',
components: { Runner: 1 },
runner_usages: ['agent'],
status: 'live',
type: 'plugin',
created_at: '2026-01-01T00:00:00Z',
@@ -1128,6 +1129,7 @@ test.describe('agent runner resource selectors', () => {
install_count: 9,
latest_version: '1.0.0',
components: { Runner: 1 },
runner_usages: ['agent'],
status: 'live',
type: 'plugin',
created_at: '2026-01-01T00:00:00Z',
+328
View File
@@ -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);
});
+1 -3
View File
@@ -222,9 +222,7 @@ test('create first, select a plugin in the header, debug beside scrollable logs'
await page
.getByRole('textbox', { name: 'Name', exact: false })
.fill('Welcome processor');
await page
.getByRole('button', { name: 'Create plugin processor', exact: true })
.click();
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await expect(page).toHaveURL(/id=processor-qa/);
expect(creations).toHaveLength(1);
expect(creations[0]).toMatchObject({