feat: add plugin framework with UI contributions and suite selection

- Implemented PluginBlocks component to render various plugin UI elements.
- Created SuitePicker for selecting test suites and questions.
- Introduced usePluginUI hook for managing plugin UI state and manifest.
- Developed DocsPage for comprehensive plugin framework documentation.
- Added PluginPage to render pages declared by plugins based on the current path.
This commit is contained in:
Netherwarlord
2026-07-28 03:00:55 -04:00
parent adf61ae1a0
commit 8f246fabbc
73 changed files with 4972 additions and 640 deletions
+129 -15
View File
@@ -46,11 +46,27 @@ from .schemas import (
SaveSuiteRequest,
SettingsResponse,
SettingsUpdate,
SuiteCreateRequest,
SuiteDetail,
SuiteDuplicateRequest,
SuiteSummary,
SuiteUpdateRequest,
SystemInfo,
TestPrompt,
TestSuiteResponse,
)
from .suite import SuiteError, list_tests, find_tests, save_suite
from .suite import (
ReadOnlySuiteError,
SuiteError,
create_suite,
delete_suite,
duplicate_suite,
get_suite,
list_suites,
load_suite_prompts,
resolve_selections,
save_suite_tests,
update_suite,
)
router = APIRouter(prefix="/api")
@@ -93,6 +109,16 @@ async def get_plugins() -> List[PluginSummary]:
return [PluginSummary(**vars(info)) for info in get_plugin_manager().info()]
@router.get("/plugins/ui")
async def get_plugin_ui() -> dict:
"""The interface plugins declare: nav entries, pages and slot panels.
The frontend renders straight from this, which is why installing a plugin
never requires rebuilding the UI.
"""
return get_plugin_manager().ui_manifest()
@router.post("/plugins/reload", response_model=List[PluginSummary])
async def reload_plugins_endpoint() -> List[PluginSummary]:
"""Re-scan plugins/. Newly added HTTP routes still need a server restart."""
@@ -144,27 +170,115 @@ async def get_models(provider_name: str) -> ModelListResponse:
# ---------------------------------------------------------------- test suite
@router.get("/tests", response_model=TestSuiteResponse)
async def get_tests() -> TestSuiteResponse:
tests = [TestPrompt(**prompt) for prompt in list_tests()]
return TestSuiteResponse(tests=tests, directory=str(TESTS_DIR))
def _as_prompt(entry: Dict[str, str]) -> TestPrompt:
return TestPrompt(
filename=entry["filename"],
title=entry["title"],
prompt=entry["prompt"],
suite=entry.get("suite", ""),
id=entry.get("id", ""),
)
@router.put("/tests", response_model=TestSuiteResponse)
async def put_tests(payload: SaveSuiteRequest) -> TestSuiteResponse:
def _summary(suite) -> SuiteSummary:
return SuiteSummary(
slug=suite.slug,
name=suite.name,
description=suite.description,
order=suite.order,
builtin=suite.builtin,
count=suite.count,
)
def _guard_active_run(action: str) -> None:
if run_manager.active() is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail="A run is in progress. Wait for it to finish before editing the suite.",
detail=f"A run is in progress. Wait for it to finish before {action}.",
)
@router.get("/suites", response_model=List[SuiteSummary])
async def get_suites() -> List[SuiteSummary]:
"""Every suite, built-ins first."""
return [_summary(suite) for suite in list_suites()]
@router.get("/suites/{slug}", response_model=SuiteDetail)
async def get_suite_detail(slug: str) -> SuiteDetail:
try:
tests = save_suite([draft.prompt for draft in payload.tests])
suite = get_suite(slug)
tests = load_suite_prompts(slug)
except SuiteError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
return SuiteDetail(**_summary(suite).model_dump(), tests=[_as_prompt(t) for t in tests])
@router.post("/suites", response_model=SuiteDetail, status_code=status.HTTP_201_CREATED)
async def post_suite(payload: SuiteCreateRequest) -> SuiteDetail:
try:
suite = create_suite(payload.name, payload.description, payload.slug)
except SuiteError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return SuiteDetail(**_summary(suite).model_dump(), tests=[])
return TestSuiteResponse(
tests=[TestPrompt(**prompt) for prompt in tests],
directory=str(TESTS_DIR),
@router.put("/suites/{slug}", response_model=SuiteDetail)
async def put_suite(slug: str, payload: SuiteUpdateRequest) -> SuiteDetail:
try:
suite = update_suite(slug, name=payload.name, description=payload.description)
except ReadOnlySuiteError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except SuiteError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
return SuiteDetail(
**_summary(suite).model_dump(),
tests=[_as_prompt(t) for t in load_suite_prompts(slug)],
)
@router.delete("/suites/{slug}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_suite(slug: str) -> None:
_guard_active_run("deleting a suite")
try:
delete_suite(slug)
except ReadOnlySuiteError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except SuiteError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.put("/suites/{slug}/tests", response_model=SuiteDetail)
async def put_suite_tests(slug: str, payload: SaveSuiteRequest) -> SuiteDetail:
_guard_active_run("editing questions")
try:
tests = save_suite_tests(slug, [draft.prompt for draft in payload.tests])
suite = get_suite(slug)
except ReadOnlySuiteError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except SuiteError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return SuiteDetail(**_summary(suite).model_dump(), tests=[_as_prompt(t) for t in tests])
@router.post(
"/suites/{slug}/duplicate",
response_model=SuiteDetail,
status_code=status.HTTP_201_CREATED,
)
async def duplicate(slug: str, payload: SuiteDuplicateRequest) -> SuiteDetail:
"""Clone any suite, built-in included, into an editable custom one.
This is what stops read-only from being a dead end.
"""
try:
suite = duplicate_suite(slug, payload.name)
except SuiteError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return SuiteDetail(
**_summary(suite).model_dump(),
tests=[_as_prompt(t) for t in load_suite_prompts(suite.slug)],
)
@@ -180,14 +294,14 @@ async def create_run(payload: RunRequest) -> RunResponse:
)
try:
prompts = find_tests(payload.filenames) if payload.filenames else list_tests()
prompts = resolve_selections(payload.selections, payload.filenames)
except SuiteError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if not prompts:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="No questions in the suite. Add at least one in the Suite Builder.",
detail="No questions selected. Pick at least one suite in Testing Suites.",
)
def _resolve_label() -> str: