mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-15 06:17:14 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c98329170 | |||
| d497defbf7 | |||
| 9db097399c |
@@ -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/
|
||||
|
||||
|
||||
@@ -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}')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function KnowledgePage() {
|
||||
externalKbCount={migrationExternalCount}
|
||||
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 { 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}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
+12
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
common: {
|
||||
loadFailed: 'Failed to load. Please try again.',
|
||||
login: 'Login',
|
||||
logout: 'Logout',
|
||||
accountOptions: 'Settings',
|
||||
|
||||
@@ -14,6 +14,7 @@ const jaJP = {
|
||||
editionCloud: 'Cloud',
|
||||
},
|
||||
common: {
|
||||
loadFailed: '読み込みに失敗しました。再試行してください。',
|
||||
login: 'ログイン',
|
||||
logout: 'ログアウト',
|
||||
accountOptions: 'システム設定',
|
||||
|
||||
@@ -14,6 +14,7 @@ const zhHans = {
|
||||
editionCloud: 'Cloud',
|
||||
},
|
||||
common: {
|
||||
loadFailed: '加载失败,请重试。',
|
||||
login: '登录',
|
||||
logout: '退出登录',
|
||||
accountOptions: '系统设置',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
.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({
|
||||
|
||||
Reference in New Issue
Block a user